From 9a8a85a8c04368f4c3927b731662cd11314e152e Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 02:31:25 +0800 Subject: [PATCH] test: add production launch evidence gate --- .gitignore | 4 + README.md | 18 + docs/refactor/README.md | 1 + docs/refactor/frontend-handoff-index.md | 3 + docs/refactor/next-development-todo.md | 1 + .../production-launch-evidence.template.json | 241 ++++++++++ docs/refactor/taro-h5-deployment.md | 11 +- package.json | 4 +- scripts/production-launch-gate-test.js | 119 +++++ scripts/production-launch-gate.js | 422 ++++++++++++++++++ 10 files changed, 822 insertions(+), 2 deletions(-) create mode 100644 docs/refactor/production-launch-evidence.template.json create mode 100644 scripts/production-launch-gate-test.js create mode 100644 scripts/production-launch-gate.js diff --git a/.gitignore b/.gitignore index cd418f08..d3b16c71 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,10 @@ stash_comparison.txt stash_diff.txt .local-storage/ +# ✅ 生产上线验收证据(可能包含内部域名、抽样说明或敏感运维信息) +docs/refactor/production-launch-evidence.json +docs/refactor/launch-artifacts/ + # ✅ 备份文件 *.backup diff --git a/README.md b/README.md index 0fc8d81e..a66182c6 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ npm run check:importer npm run check:taro npm run test:readiness npm run test:auth:remote-smoke +npm run test:launch-gate npm run smoke:auth:remote npm run audit:runtime npm run pb:import:dry-run @@ -304,6 +305,23 @@ npm run test:rls 这个命令会先执行本地 smoke seed,再在事务内模拟 Supabase `authenticated/anon/platform_admin` JWT claims,验证主租户和合作商租户的品牌、设置、域名、成员、题库、订单、资源、SaaS 账单等代表性表不会跨租户泄露;同时验证无 `tenant_id` claim 不能读取租户数据,普通租户上下文不能跨租户写入。脚本里的临时 grant 会随事务回滚,不会改变实际 schema 权限。 +## 生产上线证据门禁 + +正式切换前不要只看“口头跑过测试”。把真实生产/预生产验收结果整理成证据文件,再运行上线门禁: + +```bash +cp docs/refactor/production-launch-evidence.template.json docs/refactor/production-launch-evidence.json +npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json +``` + +`production-launch-evidence.json` 不入 Git,里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过:`readiness:production`、`readiness:production:db`、真实 `smoke:auth:remote`、`test:rls`、PocketBase production dry-run、`pb:import:validate`、API/worker/Taro 构建、`audit:runtime`、`@codex-security` 扫描,以及备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。 + +模板文件: + +```text +docs/refactor/production-launch-evidence.template.json +``` + ## PocketBase 迁移 Dry-Run 把旧 PocketBase 导出的集合 JSON 放到仓库根目录 `pb_export/` 后,先执行不写数据库的静态 dry-run: diff --git a/docs/refactor/README.md b/docs/refactor/README.md index 4a7700ce..de2b235b 100644 --- a/docs/refactor/README.md +++ b/docs/refactor/README.md @@ -29,6 +29,7 @@ - `docs/refactor/taro-frontend-integration.md`:Taro/H5/小程序启动、请求封装、页面/API 映射。 - `docs/refactor/taro-h5-deployment.md`:Taro H5 三域名部署、运行时配置、Nginx、CSP、缓存和 CORS 边界。 - `docs/refactor/multitenant-auth-security-contract.md`:多租户隔离、鉴权、权限和资源安全红线。 +- `docs/refactor/production-launch-evidence.template.json`:生产上线证据模板;真实证据填入本地 `production-launch-evidence.json` 后运行 `npm run launch:gate`。 下一步优先级: diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 2931e343..69ccb538 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -24,6 +24,8 @@ - 多租户、鉴权、权限、资源签名和生产安全红线。 9. `docs/refactor/content-import-contract.md` - 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。 +10. `docs/refactor/production-launch-evidence.template.json` + - 上线前证据文件模板;真实生产验收结果填入 `production-launch-evidence.json` 后运行 `npm run launch:gate`,该真实证据文件不入 Git。 ## 当前可进入的前端工作 @@ -41,6 +43,7 @@ - `apps/taro/src/services/api.ts` 现在默认 Supabase JWT 优先、迁移期 `tk_` 兜底;公共接口必须显式 `authMode='none'`。页面不要手写 `Authorization`、`x-tenant-id` 或 `x-user-id`。 - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。 - H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。 +- 上线前需要把三套 H5 构建、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。 - 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。 - 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。 - 租户后台可以接入勋章管理和手动发放:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`;学生端用 `GET /api/profile/badges` 展示成就。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 1e779930..b15d995f 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -74,6 +74,7 @@ - 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。 - Auth/JWKS 上云后必须临时设置 `AUTH_SMOKE_*` 环境变量并运行 `npm run smoke:auth:remote`,真实 access token 不得写入仓库、前端配置或日志。 - 本地/预生产必须同时跑 `npm run test:rls`,它验证运行时 JWT claim 下的租户隔离,和 `readiness:production:db` 的静态 policy 检查互补。 + - 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。 - 确认数据库迁移流程、备份恢复、日志、告警。 - 准备 API 容器部署和 Supabase 云端/自托管连接方案。 diff --git a/docs/refactor/production-launch-evidence.template.json b/docs/refactor/production-launch-evidence.template.json new file mode 100644 index 00000000..6f780902 --- /dev/null +++ b/docs/refactor/production-launch-evidence.template.json @@ -0,0 +1,241 @@ +{ + "schemaVersion": 1, + "environment": "production", + "commit": "replace-with-deployed-git-sha", + "target": { + "apiBaseUrl": "https://api.example.com", + "studentH5Url": "https://www.example.com", + "tenantAdminH5Url": "https://admin.example.com", + "platformAdminH5Url": "https://console.example.com" + }, + "checks": [ + { + "id": "readiness.production.env", + "status": "pass", + "command": "npm run readiness:production -- --json > docs/refactor/launch-artifacts/readiness-production.json", + "completedAt": "2026-06-30T10:00:00+08:00", + "artifact": "launch-artifacts/readiness-production.json", + "summary": { + "blocker": 0 + } + }, + { + "id": "readiness.production.db", + "status": "pass", + "command": "npm run readiness:production:db -- --json > docs/refactor/launch-artifacts/readiness-production-db.json", + "completedAt": "2026-06-30T10:05:00+08:00", + "artifact": "launch-artifacts/readiness-production-db.json", + "summary": { + "blocker": 0 + } + }, + { + "id": "auth.remote-smoke", + "status": "pass", + "command": "AUTH_SMOKE_REQUIRE_ADMIN_TOKENS=true npm run smoke:auth:remote > docs/refactor/launch-artifacts/auth-remote-smoke.log", + "completedAt": "2026-06-30T10:10:00+08:00", + "artifact": "launch-artifacts/auth-remote-smoke.log", + "summary": { + "failed": 0, + "requireAdminTokens": true + } + }, + { + "id": "rls.tenant-isolation", + "status": "pass", + "command": "npm run test:rls > docs/refactor/launch-artifacts/rls-tenant-isolation.log", + "completedAt": "2026-06-30T10:20:00+08:00", + "artifact": "launch-artifacts/rls-tenant-isolation.log", + "summary": { + "failed": 0 + } + }, + { + "id": "migration.pb-production-dry-run", + "status": "pass", + "command": "npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings > docs/refactor/launch-artifacts/pb-production-dry-run.json", + "completedAt": "2026-06-30T10:30:00+08:00", + "artifact": "launch-artifacts/pb-production-dry-run.json", + "summary": { + "blocker": 0, + "warning": 0, + "migrationProfile": "production", + "requiredCollectionsMissing": 0, + "criticalFieldCoverageWarnings": 0 + } + }, + { + "id": "migration.pb-import-validate", + "status": "pass", + "command": "FAIL_ON_WARNINGS=true npm run pb:import:validate > docs/refactor/launch-artifacts/pb-import-validate.log", + "completedAt": "2026-06-30T10:40:00+08:00", + "artifact": "launch-artifacts/pb-import-validate.log", + "summary": { + "fail": 0 + } + }, + { + "id": "api.integration", + "status": "pass", + "command": "npm run test:api > docs/refactor/launch-artifacts/api-integration.log", + "completedAt": "2026-06-30T10:50:00+08:00", + "artifact": "launch-artifacts/api-integration.log", + "summary": { + "failed": 0 + } + }, + { + "id": "worker.assets", + "status": "pass", + "command": "npm run test:worker:assets > docs/refactor/launch-artifacts/worker-assets.log", + "completedAt": "2026-06-30T11:00:00+08:00", + "artifact": "launch-artifacts/worker-assets.log", + "summary": { + "failed": 0 + } + }, + { + "id": "worker.commerce", + "status": "pass", + "command": "npm run test:worker:commerce > docs/refactor/launch-artifacts/worker-commerce.log", + "completedAt": "2026-06-30T11:10:00+08:00", + "artifact": "launch-artifacts/worker-commerce.log", + "summary": { + "failed": 0 + } + }, + { + "id": "worker.imports", + "status": "pass", + "command": "npm run test:worker:imports > docs/refactor/launch-artifacts/worker-imports.log", + "completedAt": "2026-06-30T11:20:00+08:00", + "artifact": "launch-artifacts/worker-imports.log", + "summary": { + "failed": 0 + } + }, + { + "id": "worker.public-banks", + "status": "pass", + "command": "npm run test:worker:public-banks > docs/refactor/launch-artifacts/worker-public-banks.log", + "completedAt": "2026-06-30T11:30:00+08:00", + "artifact": "launch-artifacts/worker-public-banks.log", + "summary": { + "failed": 0 + } + }, + { + "id": "taro.check", + "status": "pass", + "command": "npm run check:taro > docs/refactor/launch-artifacts/taro-check.log", + "completedAt": "2026-06-30T11:40:00+08:00", + "artifact": "launch-artifacts/taro-check.log", + "summary": { + "failed": 0 + } + }, + { + "id": "taro.build.student", + "status": "pass", + "command": "npm run build:taro:h5:student > docs/refactor/launch-artifacts/taro-build-student.log", + "completedAt": "2026-06-30T11:50:00+08:00", + "artifact": "launch-artifacts/taro-build-student.log", + "summary": { + "failed": 0 + } + }, + { + "id": "taro.build.tenant", + "status": "pass", + "command": "npm run build:taro:h5:tenant > docs/refactor/launch-artifacts/taro-build-tenant.log", + "completedAt": "2026-06-30T12:00:00+08:00", + "artifact": "launch-artifacts/taro-build-tenant.log", + "summary": { + "failed": 0 + } + }, + { + "id": "taro.build.platform", + "status": "pass", + "command": "npm run build:taro:h5:platform > docs/refactor/launch-artifacts/taro-build-platform.log", + "completedAt": "2026-06-30T12:10:00+08:00", + "artifact": "launch-artifacts/taro-build-platform.log", + "summary": { + "failed": 0 + } + }, + { + "id": "audit.runtime", + "status": "pass", + "command": "npm run audit:runtime > docs/refactor/launch-artifacts/audit-runtime.log", + "completedAt": "2026-06-30T12:20:00+08:00", + "artifact": "launch-artifacts/audit-runtime.log", + "summary": { + "critical": 0, + "high": 0 + } + }, + { + "id": "security.codex-scan", + "status": "pass", + "command": "@codex-security scan; save findings to docs/refactor/launch-artifacts/codex-security.md", + "completedAt": "2026-06-30T12:30:00+08:00", + "artifact": "launch-artifacts/codex-security.md", + "summary": { + "critical": 0, + "high": 0 + } + } + ], + "attestations": [ + { + "id": "backup.snapshot", + "status": "approved", + "approver": "replace-with-owner", + "approvedAt": "2026-06-30T13:00:00+08:00", + "notes": "Production database backup/snapshot ID and restore test notes." + }, + { + "id": "rollback.plan", + "status": "approved", + "approver": "replace-with-owner", + "approvedAt": "2026-06-30T13:05:00+08:00", + "notes": "Old PocketBase read-only snapshot, DNS rollback path and responsible people are confirmed." + }, + { + "id": "migration.sampling", + "status": "approved", + "approver": "replace-with-owner", + "approvedAt": "2026-06-30T13:10:00+08:00", + "notes": "Real users, questions, orders, entitlements, assets and learning records sampled." + }, + { + "id": "provider.production-accounts", + "status": "approved", + "approver": "replace-with-owner", + "approvedAt": "2026-06-30T13:15:00+08:00", + "notes": "Aliyun/Tencent SMS, WeChat/QQ OAuth, WeChat Pay and Alipay callbacks verified." + }, + { + "id": "object-storage.production-controls", + "status": "approved", + "approver": "replace-with-owner", + "approvedAt": "2026-06-30T13:20:00+08:00", + "notes": "AV/content scanner, CDN anti-leeching, watermark, lifecycle and private asset access sampled." + }, + { + "id": "payment.reconciliation-sampling", + "status": "approved", + "approver": "replace-with-owner", + "approvedAt": "2026-06-30T13:25:00+08:00", + "notes": "Real payment/refund bills sampled and reconciliation differences handled." + }, + { + "id": "frontend.runtime-config-review", + "status": "approved", + "approver": "replace-with-owner", + "approvedAt": "2026-06-30T13:30:00+08:00", + "notes": "Student, tenant-admin and platform-admin runtime-config.json contain public values only." + } + ] +} diff --git a/docs/refactor/taro-h5-deployment.md b/docs/refactor/taro-h5-deployment.md index fa87e672..4ee8ba49 100644 --- a/docs/refactor/taro-h5-deployment.md +++ b/docs/refactor/taro-h5-deployment.md @@ -186,7 +186,16 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务 npm run check:taro ``` -7. 打开三个域名,确认启动页能解析租户,登录后接口请求使用 `Authorization` 和正确的 `x-tenant-id`。 +7. 收集生产上线证据并运行 launch gate: + + ```bash + cp docs/refactor/production-launch-evidence.template.json docs/refactor/production-launch-evidence.json + npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json + ``` + + 证据文件只保存命令摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。真实 artifact 建议放在 `docs/refactor/launch-artifacts/`,该目录不入 Git。 + +8. 打开三个域名,确认启动页能解析租户,登录后接口请求使用 `Authorization` 和正确的 `x-tenant-id`。 ## 安全审计边界 diff --git a/package.json b/package.json index 77ecb36a..2a83771f 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,13 @@ "test:worker:imports": "npm run db:smoke-seed && npm run build:worker && node scripts/import-worker-integration-test.js", "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/remote-auth-jwt-smoke-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", + "test:launch-gate": "node scripts/production-launch-gate-test.js", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js", "readiness:production": "node scripts/production-readiness-check.js --skip-db", "readiness:production:db": "node scripts/production-readiness-check.js --check-db", + "launch:gate": "node scripts/production-launch-gate.js", "test:api:remote": "node scripts/api-integration-test.js", "dev:taro:h5": "npm --workspace @tiku-saas/taro run dev:h5", "build:taro:h5": "npm --workspace @tiku-saas/taro run build:h5", diff --git a/scripts/production-launch-gate-test.js b/scripts/production-launch-gate-test.js new file mode 100644 index 00000000..2a86e7c0 --- /dev/null +++ b/scripts/production-launch-gate-test.js @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { gateChecks, requiredAttestations } from './production-launch-gate.js'; + +const repoRoot = process.cwd(); +const scriptPath = path.join(repoRoot, 'scripts', 'production-launch-gate.js'); + +function isoNow() { + return new Date().toISOString(); +} + +function createEvidence(tempDir, overrides = {}) { + const artifactDir = path.join(tempDir, 'launch-artifacts'); + fs.mkdirSync(artifactDir, { recursive: true }); + + const checks = gateChecks.map(spec => { + const artifact = `launch-artifacts/${spec.id}.log`; + fs.writeFileSync(path.join(tempDir, artifact), `[PASS] ${spec.id}\n`, 'utf8'); + return { + id: spec.id, + status: 'pass', + command: `npm run ${spec.commandIncludes} -- recorded-for-launch-gate`, + completedAt: isoNow(), + artifact, + summary: { ...spec.summary }, + }; + }); + + const attestations = requiredAttestations.map(spec => ({ + id: spec.id, + status: 'approved', + approver: 'test-owner', + approvedAt: isoNow(), + notes: spec.label, + })); + + return { + schemaVersion: 1, + environment: 'production', + commit: '52cef9fabcd1234567890abcdef1234567890abc', + target: { + apiBaseUrl: 'https://api.gongxue100.com', + studentH5Url: 'https://www.gongxue100.com', + tenantAdminH5Url: 'https://admin.gongxue100.com', + platformAdminH5Url: 'https://console.gongxue100.com', + }, + checks, + attestations, + ...overrides, + }; +} + +function runGate(evidence, options = {}) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-')); + const evidencePath = path.join(tempDir, 'evidence.json'); + const finalEvidence = typeof evidence === 'function' ? evidence(tempDir) : evidence; + fs.writeFileSync(evidencePath, JSON.stringify(finalEvidence, null, 2), 'utf8'); + + const result = spawnSync(process.execPath, [scriptPath, '--evidence', evidencePath, '--json', ...(options.args || [])], { + cwd: repoRoot, + encoding: 'utf8', + env: { + PATH: process.env.PATH || '', + Path: process.env.Path || '', + SystemRoot: process.env.SystemRoot || '', + ComSpec: process.env.ComSpec || '', + TEMP: process.env.TEMP || os.tmpdir(), + TMP: process.env.TMP || os.tmpdir(), + }, + }); + + const payload = JSON.parse(result.stdout || '{}'); + fs.rmSync(tempDir, { recursive: true, force: true }); + return { ...result, payload }; +} + +const safe = runGate(tempDir => createEvidence(tempDir)); +assert.equal(safe.status, 0, `complete launch evidence should pass: ${safe.stdout} ${safe.stderr}`); +assert.equal(safe.payload.summary?.blocker, 0, 'complete launch evidence should have no blockers'); + +const missingArtifact = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'auth.remote-smoke'); + fs.rmSync(path.join(tempDir, item.artifact), { force: true }); + return evidence; +}); +assert.notEqual(missingArtifact.status, 0, 'missing artifact should fail launch gate'); +assert.ok( + missingArtifact.payload.checks?.some(item => item.id === 'check.auth.remote-smoke.artifact' && item.status === 'blocker'), + 'missing artifact should be reported as a blocker', +); + +const wrongMigrationProfile = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'migration.pb-production-dry-run'); + item.summary.migrationProfile = 'development'; + return evidence; +}); +assert.notEqual(wrongMigrationProfile.status, 0, 'development dry-run evidence should fail launch gate'); +assert.ok( + wrongMigrationProfile.payload.checks?.some(item => item.id === 'check.migration.pb-production-dry-run.summary' && item.status === 'blocker'), + 'migration profile mismatch should be reported as a blocker', +); + +const missingAttestation = runGate(tempDir => { + const evidence = createEvidence(tempDir); + evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot'); + return evidence; +}); +assert.notEqual(missingAttestation.status, 0, 'missing manual attestation should fail launch gate'); +assert.ok( + missingAttestation.payload.checks?.some(item => item.id === 'attestation.backup.snapshot' && item.status === 'blocker'), + 'missing attestation should be reported as a blocker', +); + +console.log('[PASS] production launch gate'); diff --git a/scripts/production-launch-gate.js b/scripts/production-launch-gate.js new file mode 100644 index 00000000..9c397d57 --- /dev/null +++ b/scripts/production-launch-gate.js @@ -0,0 +1,422 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const defaultEvidencePath = path.resolve(process.cwd(), 'docs/refactor/production-launch-evidence.json'); +const defaultMaxAgeDays = 14; + +const gateChecks = [ + { + id: 'readiness.production.env', + label: 'Production environment readiness', + commandIncludes: 'readiness:production', + summary: { blocker: 0 }, + }, + { + id: 'readiness.production.db', + label: 'Production database readiness', + commandIncludes: 'readiness:production:db', + summary: { blocker: 0 }, + }, + { + id: 'auth.remote-smoke', + label: 'Remote Supabase Auth/JWKS smoke', + commandIncludes: 'smoke:auth:remote', + summary: { failed: 0, requireAdminTokens: true }, + }, + { + id: 'rls.tenant-isolation', + label: 'Runtime tenant RLS isolation', + commandIncludes: 'test:rls', + summary: { failed: 0 }, + }, + { + id: 'migration.pb-production-dry-run', + label: 'PocketBase production dry-run', + commandIncludes: 'pb:import:dry-run', + summary: { + blocker: 0, + warning: 0, + migrationProfile: 'production', + requiredCollectionsMissing: 0, + criticalFieldCoverageWarnings: 0, + }, + }, + { + id: 'migration.pb-import-validate', + label: 'PocketBase import validation', + commandIncludes: 'pb:import:validate', + summary: { fail: 0 }, + }, + { + id: 'api.integration', + label: 'API integration regression', + commandIncludes: 'test:api', + summary: { failed: 0 }, + }, + { + id: 'worker.assets', + label: 'Asset worker security regression', + commandIncludes: 'test:worker:assets', + summary: { failed: 0 }, + }, + { + id: 'worker.commerce', + label: 'Commerce/payment worker regression', + commandIncludes: 'test:worker:commerce', + summary: { failed: 0 }, + }, + { + id: 'worker.imports', + label: 'Async import worker regression', + commandIncludes: 'test:worker:imports', + summary: { failed: 0 }, + }, + { + id: 'worker.public-banks', + label: 'Public question bank worker regression', + commandIncludes: 'test:worker:public-banks', + summary: { failed: 0 }, + }, + { + id: 'taro.check', + label: 'Taro type/security guardrails', + commandIncludes: 'check:taro', + summary: { failed: 0 }, + }, + { + id: 'taro.build.student', + label: 'Student H5 build', + commandIncludes: 'build:taro:h5:student', + summary: { failed: 0 }, + }, + { + id: 'taro.build.tenant', + label: 'Tenant admin H5 build', + commandIncludes: 'build:taro:h5:tenant', + summary: { failed: 0 }, + }, + { + id: 'taro.build.platform', + label: 'Platform admin H5 build', + commandIncludes: 'build:taro:h5:platform', + summary: { failed: 0 }, + }, + { + id: 'audit.runtime', + label: 'Runtime dependency audit', + commandIncludes: 'audit:runtime', + summary: { critical: 0, high: 0 }, + }, + { + id: 'security.codex-scan', + label: 'Codex Security scan', + commandIncludes: 'codex-security', + summary: { critical: 0, high: 0 }, + }, +]; + +const requiredAttestations = [ + { + id: 'backup.snapshot', + label: 'Production database backup/snapshot is prepared', + }, + { + id: 'rollback.plan', + label: 'Rollback plan and old PocketBase read-only snapshot are prepared', + }, + { + id: 'migration.sampling', + label: 'Real migrated users/questions/orders/assets were sampled', + }, + { + id: 'provider.production-accounts', + label: 'SMS/OAuth/payment production accounts and callback domains were verified', + }, + { + id: 'object-storage.production-controls', + label: 'Object storage AV/content scan, CDN boundary, watermark and lifecycle controls were verified', + }, + { + id: 'payment.reconciliation-sampling', + label: 'Real payment/refund bill samples were reconciled', + }, + { + id: 'frontend.runtime-config-review', + label: 'Three H5 runtime-config.json files were reviewed for public-only values', + }, +]; + +function parseArgs(argv) { + const options = { + evidencePath: defaultEvidencePath, + json: false, + maxAgeDays: defaultMaxAgeDays, + allowStale: false, + }; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--json') options.json = true; + else if (arg === '--allow-stale') options.allowStale = true; + else if (arg === '--evidence') { + options.evidencePath = path.resolve(process.cwd(), argv[index + 1] || ''); + index += 1; + } else if (arg.startsWith('--evidence=')) { + options.evidencePath = path.resolve(process.cwd(), arg.slice('--evidence='.length)); + } else if (arg === '--max-age-days') { + options.maxAgeDays = Number(argv[index + 1]); + index += 1; + } else if (arg.startsWith('--max-age-days=')) { + options.maxAgeDays = Number(arg.slice('--max-age-days='.length)); + } + } + if (!Number.isFinite(options.maxAgeDays) || options.maxAgeDays <= 0) options.maxAgeDays = defaultMaxAgeDays; + return options; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function normalizeStatus(value) { + return String(value || '').trim().toLowerCase(); +} + +function isApproved(value) { + return ['approved', 'pass', 'passed', 'ok', 'complete', 'completed'].includes(normalizeStatus(value)); +} + +function findById(items, id) { + return (Array.isArray(items) ? items : []).find(item => item && item.id === id); +} + +function resolveArtifact(evidencePath, artifact) { + if (!artifact) return ''; + if (path.isAbsolute(artifact)) return artifact; + return path.resolve(path.dirname(evidencePath), artifact); +} + +function daysSince(value, now = Date.now()) { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return Number.POSITIVE_INFINITY; + return (now - timestamp) / (24 * 60 * 60 * 1000); +} + +function hasPath(object, keyPath) { + const parts = keyPath.split('.'); + let cursor = object; + for (const part of parts) { + if (!cursor || typeof cursor !== 'object' || !(part in cursor)) return false; + cursor = cursor[part]; + } + return true; +} + +function valueAt(object, keyPath) { + const parts = keyPath.split('.'); + let cursor = object; + for (const part of parts) cursor = cursor?.[part]; + return cursor; +} + +function compareSummary(actualSummary, expectedSummary) { + const failures = []; + for (const [key, expectedValue] of Object.entries(expectedSummary || {})) { + if (!hasPath(actualSummary || {}, key)) { + failures.push(`${key} is missing`); + continue; + } + const actualValue = valueAt(actualSummary, key); + if (actualValue !== expectedValue) { + failures.push(`${key} expected ${JSON.stringify(expectedValue)} but got ${JSON.stringify(actualValue)}`); + } + } + return failures; +} + +function resultCollector() { + const checks = []; + function pass(id, message, details = {}) { + checks.push({ status: 'pass', id, message, details }); + } + function warn(id, message, details = {}) { + checks.push({ status: 'warn', id, message, details }); + } + function block(id, message, details = {}) { + checks.push({ status: 'blocker', id, message, details }); + } + return { checks, pass, warn, block }; +} + +function validateTopLevel(evidence, collector) { + if (evidence.schemaVersion !== 1) { + collector.block('evidence.schema_version', 'Evidence schemaVersion must be 1', { actual: evidence.schemaVersion }); + } else { + collector.pass('evidence.schema_version', 'Evidence schema version is supported'); + } + + if (evidence.environment !== 'production') { + collector.block('evidence.environment', 'Evidence environment must be production', { actual: evidence.environment }); + } else { + collector.pass('evidence.environment', 'Evidence environment is production'); + } + + if (!evidence.commit || !/^[0-9a-f]{7,40}$/i.test(String(evidence.commit))) { + collector.block('evidence.commit', 'Evidence must include the reviewed deployment commit hash'); + } else { + collector.pass('evidence.commit', 'Deployment commit is recorded', { commit: evidence.commit }); + } + + const target = evidence.target || {}; + const requiredTargets = ['apiBaseUrl', 'studentH5Url', 'tenantAdminH5Url', 'platformAdminH5Url']; + for (const key of requiredTargets) { + const value = String(target[key] || ''); + if (!value.startsWith('https://')) { + collector.block(`target.${key}`, `${key} must be an HTTPS production URL`, { value }); + } else { + collector.pass(`target.${key}`, `${key} is HTTPS`); + } + } +} + +function validateGateCheck(spec, evidence, options, collector) { + const item = findById(evidence.checks, spec.id); + if (!item) { + collector.block(`check.${spec.id}`, `${spec.label} evidence is missing`); + return; + } + + if (normalizeStatus(item.status) !== 'pass') { + collector.block(`check.${spec.id}.status`, `${spec.label} must have status=pass`, { status: item.status }); + } else { + collector.pass(`check.${spec.id}.status`, `${spec.label} passed`); + } + + if (spec.commandIncludes && !String(item.command || '').includes(spec.commandIncludes)) { + collector.block(`check.${spec.id}.command`, `${spec.label} command must include ${spec.commandIncludes}`, { + command: item.command || '', + }); + } else { + collector.pass(`check.${spec.id}.command`, `${spec.label} command is recorded`); + } + + const age = daysSince(item.completedAt); + if (!options.allowStale && age > options.maxAgeDays) { + collector.block(`check.${spec.id}.freshness`, `${spec.label} evidence is stale or missing completedAt`, { + completedAt: item.completedAt || '', + maxAgeDays: options.maxAgeDays, + }); + } else { + collector.pass(`check.${spec.id}.freshness`, `${spec.label} evidence is fresh enough`, { + completedAt: item.completedAt || '', + }); + } + + if (!item.artifact) { + collector.block(`check.${spec.id}.artifact`, `${spec.label} must include a saved artifact/log path`); + } else { + const artifactPath = resolveArtifact(options.evidencePath, item.artifact); + if (!fs.existsSync(artifactPath)) { + collector.block(`check.${spec.id}.artifact`, `${spec.label} artifact file is missing`, { artifact: item.artifact }); + } else { + collector.pass(`check.${spec.id}.artifact`, `${spec.label} artifact exists`, { artifact: item.artifact }); + } + } + + const summaryFailures = compareSummary(item.summary || {}, spec.summary); + if (summaryFailures.length > 0) { + collector.block(`check.${spec.id}.summary`, `${spec.label} summary does not satisfy gate`, { failures: summaryFailures }); + } else { + collector.pass(`check.${spec.id}.summary`, `${spec.label} summary satisfies gate`); + } +} + +function validateAttestation(spec, evidence, options, collector) { + const item = findById(evidence.attestations, spec.id); + if (!item) { + collector.block(`attestation.${spec.id}`, `${spec.label} attestation is missing`); + return; + } + + if (!isApproved(item.status)) { + collector.block(`attestation.${spec.id}.status`, `${spec.label} must be approved`, { status: item.status }); + } else { + collector.pass(`attestation.${spec.id}.status`, `${spec.label} is approved`); + } + + if (!String(item.approver || '').trim()) { + collector.block(`attestation.${spec.id}.approver`, `${spec.label} must include an approver`); + } else { + collector.pass(`attestation.${spec.id}.approver`, `${spec.label} has an approver`); + } + + const age = daysSince(item.approvedAt); + if (!options.allowStale && age > options.maxAgeDays) { + collector.block(`attestation.${spec.id}.freshness`, `${spec.label} approval is stale or missing approvedAt`, { + approvedAt: item.approvedAt || '', + maxAgeDays: options.maxAgeDays, + }); + } else { + collector.pass(`attestation.${spec.id}.freshness`, `${spec.label} approval is fresh enough`, { + approvedAt: item.approvedAt || '', + }); + } +} + +function validateEvidence(evidence, options) { + const collector = resultCollector(); + validateTopLevel(evidence, collector); + for (const spec of gateChecks) validateGateCheck(spec, evidence, options, collector); + for (const spec of requiredAttestations) validateAttestation(spec, evidence, options, collector); + return collector.checks; +} + +function summarize(checks) { + return checks.reduce( + (summary, item) => { + summary[item.status] += 1; + return summary; + }, + { blocker: 0, warn: 0, pass: 0 }, + ); +} + +function printHuman(options, evidence, checks) { + const summary = summarize(checks); + console.log('Production launch gate'); + console.log(`Evidence: ${options.evidencePath}`); + console.log(`Host: ${os.hostname()}`); + console.log(`Target API: ${evidence.target?.apiBaseUrl || '(missing)'}`); + console.log(`Summary: ${summary.blocker} blocker(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`); + for (const item of checks) { + const marker = item.status === 'blocker' ? 'BLOCK' : item.status === 'warn' ? 'WARN ' : 'PASS '; + console.log(`[${marker}] ${item.id}: ${item.message}`); + } +} + +function main() { + const options = parseArgs(process.argv); + if (!fs.existsSync(options.evidencePath)) { + const message = `Evidence file not found: ${options.evidencePath}`; + if (options.json) console.log(JSON.stringify({ summary: { blocker: 1, warn: 0, pass: 0 }, checks: [{ status: 'blocker', id: 'evidence.file', message }] }, null, 2)); + else console.error(`${message}\nCreate it from docs/refactor/production-launch-evidence.template.json after running real production checks.`); + process.exitCode = 1; + return; + } + + const evidence = readJson(options.evidencePath); + const checks = validateEvidence(evidence, options); + const summary = summarize(checks); + if (options.json) console.log(JSON.stringify({ summary, checks }, null, 2)); + else printHuman(options, evidence, checks); + if (summary.blocker > 0) process.exitCode = 1; +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) { + main(); +} + +export { gateChecks, requiredAttestations, validateEvidence };