refactor(core): keep plan prices, cloud quotas and public URLs in one contract (WS-A)

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.
This commit is contained in:
Yun Chan 2026-09-26 15:48:18 +09:00
parent e689683b72
commit 88f24d84a1
21 changed files with 568 additions and 152 deletions

View file

@ -0,0 +1,170 @@
// 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)
}