d3ro-voice/scripts/ci/sync-core-contract.mjs
Yun Chan eedd127ea7
Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 1m13s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / 모바일 린트·타입·Jest (push) Failing after 1m4s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 37s
ci / .NET API 서버 테스트 (push) Successful in 27s
deploy-site / deploy (push) Failing after 20s
refactor(billing): remove Stripe; payments are Payple (web) and Google Play (mobile)
Stripe is not used. Keeping its checkout, portal and webhook paths meant a
second payment provider, a second return-URL format and dead UI.

- Delete the stripe-checkout, stripe-portal and stripe-webhook functions and
  their config; billing-catalog serves Payple prices only, and the web parser
  rejects a catalog that still mixes in Stripe prices.
- Web: drop the Stripe checkout/portal buttons, provider toggle and return
  notices; billing shows Payple only. Past rows with provider='stripe' are
  still displayed ("Stripe (종료)") with a support contact instead of a portal.
- Desktop: delete the Stripe checkout modal, payment IPC channels, preload
  namespace and their types; "Remove ads with Pro" opens the web billing page
  via license.openBilling. Support/refund copy names Payple.
- billingUrl() loses the Stripe-only success/canceled result option; the
  Deno contract is regenerated.
- Migrations and the DB's accepted provider values are untouched (history).
- Docs and the backlog record the removal (MON-04, EXT-STRIPE-01, GAP-BILL-03).

Verified: typecheck (desktop/web/admin/api-client/mobile), contract:check,
deno check all functions, deno test 80/80, desktop 1478/1480 on the Electron
runtime (2 known environment failures), web and admin builds, release
metadata and mobile boundary self-tests, eslint on changed files.
2026-09-26 20:56:18 +09:00

170 lines
6.8 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' })
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)
}