Prices, quotas and site URLs were copied by hand into the edge functions, admin, desktop and the landing site, and the copies disagreed (Payple billed 9,900/29,900 KRW, admin labels said 12,900/24,900 KRW and $9.9/$19.9, the site said 2,900/8,900 KRW). - packages/core/src/plan-catalog.ts is the single source for PLAN_PRICE_KRW (Free 0 / Pro 2,900 / Pro+ 8,900 a month) and PLAN_QUOTA. - packages/core/src/web-urls.ts is the single source for the public origin, the /app web-app base path, SITE_URLS and billingUrl(). - Deno cannot bundle packages/core, so scripts/ci/sync-core-contract.mjs generates _shared/core-contract.generated.ts; `npm run contract:check` fails on drift (same pattern as version:sync). - Payple checkout, renewal and webhook amount checks now bill the catalog price, so existing subscribers move to the new price at their next renewal. quota.ts, team-contract.ts and the tests read the generated values. - Admin MRR/ARR is computed in KRW from the catalog; license labels, the release link and desktop PREMIUM_LLM limits derive from core; the site imports prices and quotas directly. Policy: docs/REFACTOR_POLICY.md Wave 3, W3-1 and W3-2.
170 lines
6.9 KiB
JavaScript
170 lines
6.9 KiB
JavaScript
// Core contract (plans + public URLs) generator for runtimes that cannot import packages/core.
|
|
//
|
|
// Supabase Edge Functions run on Deno and are deployed per function directory, so they
|
|
// cannot bundle `packages/core`. Instead of hand-copied constants, this script copies the
|
|
// canonical sources verbatim into one generated file and CI verifies it never drifts.
|
|
//
|
|
// Usage:
|
|
// node scripts/ci/sync-core-contract.mjs --check (default; exit 1 when out of sync)
|
|
// node scripts/ci/sync-core-contract.mjs --write
|
|
|
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
const args = process.argv.slice(2)
|
|
const write = args.includes('--write')
|
|
const check = args.includes('--check') || !write
|
|
|
|
if (write && args.includes('--check')) fail('Choose exactly one mode: --check or --write.')
|
|
const unknown = args.filter((arg) => arg !== '--check' && arg !== '--write')
|
|
if (unknown.length > 0) fail(`Unknown arguments: ${unknown.join(' ')}`)
|
|
|
|
const TARGET = 'server/supabase/functions/_shared/core-contract.generated.ts'
|
|
|
|
/**
|
|
* Canonical sources, concatenated in this order. `allowedImports` lists the only
|
|
* import statements a source may contain; each must point at an earlier source so the
|
|
* concatenated file stays self-contained.
|
|
*/
|
|
const SOURCES = [
|
|
{ path: 'packages/core/src/plan-catalog.ts', allowedImports: [] },
|
|
{
|
|
path: 'packages/core/src/web-urls.ts',
|
|
allowedImports: ["import type { PaidPlanTier } from './plan-catalog'"],
|
|
},
|
|
]
|
|
|
|
const sections = SOURCES.map(({ path, allowedImports }) => {
|
|
const absolutePath = join(root, path)
|
|
if (!existsSync(absolutePath)) fail(`Canonical source is missing: ${path}`)
|
|
const text = readFileSync(absolutePath, 'utf8').replace(/\r\n/g, '\n')
|
|
|
|
const importLines = text.split('\n').filter((line) => /^\s*(import|export\s+\*|export\s+\{[^}]*\}\s+from)\b/.test(line))
|
|
for (const line of importLines) {
|
|
if (!allowedImports.includes(line.trim())) {
|
|
fail(
|
|
`${path} must stay import-free (pure values). Unexpected: "${line.trim()}".\n` +
|
|
' Deno cannot resolve it from the generated copy. Move the value into the canonical file instead.',
|
|
)
|
|
}
|
|
}
|
|
if (/\bDeno\.|\bprocess\.env\b|\bimport\.meta\b|\brequire\(/.test(text)) {
|
|
fail(`${path} must not read the runtime environment; it is copied into Deno verbatim.`)
|
|
}
|
|
|
|
const body = text
|
|
.split('\n')
|
|
.filter((line) => !allowedImports.includes(line.trim()))
|
|
.join('\n')
|
|
.trim()
|
|
return { path, body }
|
|
})
|
|
|
|
const generated = [
|
|
'// 생성 파일 — 직접 수정 금지.',
|
|
`// 정본: ${SOURCES.map(({ path }) => path).join(', ')}`,
|
|
'// 갱신: npm run contract:sync / 검사: npm run contract:check',
|
|
'// Deno Edge Function은 packages/core를 번들할 수 없어서 정본 소스를 그대로 복사한다.',
|
|
'',
|
|
...sections.flatMap(({ path, body }) => [`// ── 원본: ${path} ${'─'.repeat(Math.max(4, 60 - path.length))}`, '', body, '']),
|
|
].join('\n')
|
|
|
|
await validateContract(generated)
|
|
|
|
const targetPath = join(root, TARGET)
|
|
const current = existsSync(targetPath) ? readFileSync(targetPath, 'utf8').replace(/\r\n/g, '\n') : null
|
|
|
|
if (current === generated) {
|
|
process.stdout.write(`[contract] GREEN ${TARGET}\n`)
|
|
process.exit(0)
|
|
}
|
|
|
|
if (check) {
|
|
process.stderr.write(`[contract] out of sync: ${TARGET}${current === null ? ' (missing)' : ''}\n`)
|
|
fail('Run `npm run contract:sync` and commit the generated file.')
|
|
}
|
|
|
|
writeFileSync(targetPath, generated, 'utf8')
|
|
process.stdout.write(`[contract] synchronized ${TARGET}\n`)
|
|
|
|
/**
|
|
* Transpile the generated TypeScript with the repository's TypeScript compiler and
|
|
* evaluate it, so a syntax error or a malformed value fails here instead of at deploy.
|
|
*/
|
|
async function validateContract(source) {
|
|
let ts
|
|
try {
|
|
ts = createRequire(import.meta.url)('typescript')
|
|
} catch {
|
|
fail('The `typescript` package is required. Run `npm ci` at the repository root first.')
|
|
}
|
|
const output = ts.transpileModule(source, {
|
|
reportDiagnostics: true,
|
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
})
|
|
const diagnostics = output.diagnostics ?? []
|
|
if (diagnostics.length > 0) {
|
|
const messages = diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'))
|
|
fail(`Generated contract does not parse:\n ${messages.join('\n ')}`)
|
|
}
|
|
|
|
let contract
|
|
try {
|
|
contract = await import(`data:text/javascript;base64,${Buffer.from(output.outputText).toString('base64')}`)
|
|
} catch (error) {
|
|
fail(`Generated contract cannot be evaluated: ${error instanceof Error ? error.message : String(error)}`)
|
|
}
|
|
|
|
const prices = contract.PLAN_PRICE_KRW
|
|
expectKeys('PLAN_PRICE_KRW', prices, ['free', 'pro', 'pro_plus'])
|
|
if (prices.free !== 0) fail('PLAN_PRICE_KRW.free must be 0.')
|
|
for (const tier of ['pro', 'pro_plus']) {
|
|
if (!Number.isSafeInteger(prices[tier]) || prices[tier] < 100) {
|
|
fail(`PLAN_PRICE_KRW.${tier} must be a whole KRW amount >= 100; got ${prices[tier]}.`)
|
|
}
|
|
}
|
|
|
|
const quota = contract.PLAN_QUOTA
|
|
const features = ['stt_transcribe', 'llm_haiku', 'llm_sonnet', 'llm_opus', 'realtime_session']
|
|
expectKeys('PLAN_QUOTA', quota, ['free', 'pro', 'pro_plus', 'team', 'enterprise'])
|
|
for (const [tier, perFeature] of Object.entries(quota)) {
|
|
expectKeys(`PLAN_QUOTA.${tier}`, perFeature, features)
|
|
for (const [feature, value] of Object.entries(perFeature)) {
|
|
if (!Number.isSafeInteger(value?.limit) || value.limit < -1 || !['daily', 'weekly'].includes(value?.period)) {
|
|
fail(`PLAN_QUOTA.${tier}.${feature} must be { limit: integer >= -1, period: daily|weekly }.`)
|
|
}
|
|
}
|
|
}
|
|
|
|
let origin
|
|
try {
|
|
origin = new URL(contract.PUBLIC_SITE_ORIGIN)
|
|
} catch {
|
|
fail(`PUBLIC_SITE_ORIGIN is not a URL: ${contract.PUBLIC_SITE_ORIGIN}`)
|
|
}
|
|
if (origin.protocol !== 'https:' || origin.origin !== contract.PUBLIC_SITE_ORIGIN) {
|
|
fail(`PUBLIC_SITE_ORIGIN must be a bare https origin; got ${contract.PUBLIC_SITE_ORIGIN}`)
|
|
}
|
|
for (const [key, url] of Object.entries(contract.SITE_URLS ?? {})) {
|
|
if (!String(url).startsWith(`${contract.PUBLIC_SITE_ORIGIN}/`)) fail(`SITE_URLS.${key} must live under PUBLIC_SITE_ORIGIN.`)
|
|
}
|
|
const billing = contract.billingUrl({ tier: 'pro', result: 'success' })
|
|
if (billing !== `${contract.WEB_APP_URL}/billing?tier=pro&success=1`) {
|
|
fail(`billingUrl() contract changed unexpectedly: ${billing}`)
|
|
}
|
|
}
|
|
|
|
function expectKeys(label, value, expected) {
|
|
const actual = value && typeof value === 'object' ? Object.keys(value).sort() : []
|
|
if (JSON.stringify(actual) !== JSON.stringify([...expected].sort())) {
|
|
fail(`${label} keys must be exactly: ${expected.join(', ')} (got ${actual.join(', ') || 'none'})`)
|
|
}
|
|
}
|
|
|
|
function fail(message) {
|
|
process.stderr.write(`[contract] ${message}\n`)
|
|
process.exit(1)
|
|
}
|