// 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' }) if (billing !== `${contract.WEB_APP_URL}/billing?tier=pro`) { 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) }