From e587a0c955295716e5e4cf56caea3d20f5ed1172 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 10:34:12 +0800 Subject: [PATCH] feat: add production readiness checks --- .env.example | 50 +-- README.md | 18 + docs/refactor/README.md | 2 +- docs/refactor/backend-capability-status.md | 3 +- docs/refactor/backend-handoff-roadmap.md | 4 +- .../multitenant-auth-security-contract.md | 3 + docs/refactor/next-development-todo.md | 2 +- package.json | 5 +- scripts/production-readiness-check-test.js | 90 ++++ scripts/production-readiness-check.js | 419 ++++++++++++++++++ 10 files changed, 564 insertions(+), 32 deletions(-) create mode 100644 scripts/production-readiness-check-test.js create mode 100644 scripts/production-readiness-check.js diff --git a/.env.example b/.env.example index a94e9f41..ce10296c 100644 --- a/.env.example +++ b/.env.example @@ -1,41 +1,31 @@ -# 阿里云短信服务配置(脚本/服务端使用,前端不读取此文件) -# 复制此文件为 .env 并填写实际值 -# -# 生产环境说明: -# - 学生端 https://tiku.tjszsb.com -# - 超管后台 https://tikuguanli.tjszsb.com -# - 服务器 39.107.64.207(PocketBase 运行于 127.0.0.1:8090,由 Nginx 反代) -# -# 前端(Vite)运行时 PocketBase 地址会根据 window.location 自动判断: -# - 本地开发 → http://127.0.0.1:8090 -# - 线上部署 → 与页面同源(Nginx 代理到后端) -# 因此前端代码无需配置服务器 IP;本文件仅供 Node 脚本使用。 +# Supabase/PostgreSQL SaaS 题库后端配置(服务端/worker 使用,前端不读取此文件) +# 复制此文件为 .env 并填写实际值。 +# 生产上线前运行: +# npm run readiness:production +# npm run readiness:production:db -# PocketBase 地址(本地脚本连接用) -POCKETBASE_URL=http://127.0.0.1:8090 +# 运行环境。生产必须是 production;本地开发可用 development。 +NODE_ENV=development -# 阿里云 AccessKey -ALIYUN_ACCESS_KEY_ID=your_access_key_id_here -ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret_here +# API 服务端口 +PORT=8787 -# 阿里云短信配置 -ALIYUN_SMS_SIGN=天津专升本 -ALIYUN_SMS_TEMPLATE=SMS_123456789 - -# 服务端口 -PORT=3000 - -# 新 Supabase/PostgreSQL 重构 API +# Supabase/PostgreSQL 重构 API DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres DEFAULT_TENANT_SLUG=master +# 生产必须只写真实 HTTPS 域名,不能包含 * CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173 MAX_JSON_BODY_BYTES=1048576 MAX_IMPORT_JSON_BODY_BYTES=10485760 -# 认证迁移期配置:生产环境必须替换为高强度随机值 +# 认证迁移期配置:生产环境必须替换为高强度随机值,并配置 Supabase Auth JWT AUTH_SMS_PROVIDER=mock AUTH_CODE_PEPPER=replace_with_a_long_random_secret AUTH_SESSION_SECRET=replace_with_another_long_random_secret +AUTH_JWT_ISSUER= +AUTH_JWT_AUDIENCE=authenticated +AUTH_JWT_SECRET=development-jwt-secret-change-me +AUTH_JWT_JWKS_URL= AUTH_CODE_TTL_SECONDS=300 AUTH_SMS_COOLDOWN_SECONDS=60 AUTH_SESSION_TTL_SECONDS=604800 @@ -67,6 +57,11 @@ WORKER_ASSET_MIN_AGE_SECONDS=300 WORKER_ASSET_RECHECK_INTERVAL_SECONDS=86400 WORKER_ASSET_REQUEST_TIMEOUT_MS=10000 +# Worker 配置:大批量内容导入 +WORKER_IMPORT_BATCH_SIZE=5 +WORKER_IMPORT_ID=imports-1 +WORKER_IMPORT_BACKOFF_SECONDS=30,120,600,1800 + # Worker 配置:公共题库自动同步。冲突会保留租户自改题目并等待后台处理。 WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE=5 WORKER_PUBLIC_BANK_SYNC_COPY_LIMIT=1000 @@ -97,3 +92,6 @@ TENCENT_COS_SECURITY_TOKEN= SUPABASE_STORAGE_URL= SUPABASE_STORAGE_SERVICE_KEY= + +# 国内短信/OAuth/支付的租户级密钥不建议放在 .env。 +# 正式使用时请通过租户后台写入 app_private.tenant_secrets,并只在 config_public 暴露非敏感配置。 diff --git a/README.md b/README.md index 3905bfb8..18af68ed 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ npm run check:api npm run check:worker npm run check:importer npm run pb:import:validate +npm run test:readiness npm run test:api npm run test:worker:crm npm run test:worker:commerce @@ -166,6 +167,22 @@ npm run test:worker:imports npm run test:worker:public-banks ``` +## 生产就绪检查 + +填好生产 `.env` 后,先跑环境变量级检查: + +```bash +npm run readiness:production +``` + +确认 `DATABASE_URL` 指向生产 Supabase/PostgreSQL 后,再跑数据库配置检查: + +```bash +npm run readiness:production:db +``` + +这个检查会阻断默认弱密钥、`CORS=*`、mock 短信、legacy 身份头、local_dev 存储、对象存储未配置、CRM insecure localhost 等生产风险;带 `:db` 的版本还会检查租户 provider 公开配置是否混入密钥、活跃短信/OAuth/支付 provider 是否缺少 `app_private.tenant_secrets`、域名是否未验证。 + ## API 模块 当前 API 目录: @@ -214,6 +231,7 @@ API 身份上下文: ```text npx supabase db reset npm run check:refactor +npm run test:readiness npm run test:worker:imports npm run test:worker:crm npm run test:worker:commerce diff --git a/docs/refactor/README.md b/docs/refactor/README.md index 317dca41..0d0e2b94 100644 --- a/docs/refactor/README.md +++ b/docs/refactor/README.md @@ -31,7 +31,7 @@ 下一步优先级: -1. 先按 `docs/refactor/multitenant-auth-security-contract.md` 的 P0 清单补齐鉴权、生产配置和租户隔离测试。 +1. 先按 `docs/refactor/multitenant-auth-security-contract.md` 的 P0 清单补齐鉴权、生产配置和租户隔离测试,并在上云前运行 `npm run readiness:production` / `npm run readiness:production:db`。 2. 新建 `apps/taro`,按 `docs/refactor/taro-frontend-integration.md` 优先接租户解析、首页、题库练习、背单词、知识手册、个人中心。 3. 导出 PocketBase 真实数据到 `pb_export/*.json`,执行 `npm run pb:import:json` 和 `npm run pb:import:validate`。 4. 为对象存储、分数线、视频、Excel/CSV 补齐 provider/导入能力,并复用 `content_import_jobs` 管线。 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 0c220e2b..fec8591f 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -18,7 +18,7 @@ | RLS/租户隔离 | 可联调 | 表层普遍有 `tenant_id` 和 RLS 策略;API 已支持 `tk_` 迁移 session 与 Supabase Auth JWT 双入口,并覆盖跨租户/伪造身份集成测试;生产前继续补真实云端 JWT/RLS 回归 | | API 分层 | 可联调 | `apps/api/src/core` + `apps/api/src/features/*` | | Docker API | 可联调 | `docker-compose.api.yml` 和 `apps/api/Dockerfile` 可用 | -| 测试 | 可联调 | `npm run check:refactor` 覆盖 TS 检查、导入校验、seed、API 集成测试;排行榜已覆盖四类指标、班级范围和跨租户拒绝 | +| 测试 | 可联调 | `npm run check:refactor` 覆盖 TS 检查、导入校验、生产 readiness 脚本测试、seed、API 集成测试;排行榜已覆盖四类指标、班级范围和跨租户拒绝 | | 根 workspace | 可联调 | 根目录已清理为新技术栈 monorepo 编排层 | ## 租户与品牌 @@ -178,6 +178,7 @@ npx supabase db reset npm audit --audit-level=high npm run check:refactor +npm run test:readiness npm run test:worker:imports npm run test:worker:crm npm run test:worker:commerce diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 908dc863..2fe385d4 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -78,7 +78,7 @@ - 生产鉴权:API 已支持 Supabase Auth JWT;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。 - 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。 - 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,跑迁移和校验报告。 -- 生产环境配置:补 `.env` 模板、数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 +- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 - Taro scaffold:建立 `apps/taro`,先完成租户解析、首页、题库、背单词、知识手册、个人中心主链路。 ### P1:商用收费和运营能力 @@ -113,4 +113,4 @@ - JWT/RLS、短信、登录、支付、对象存储、CRM webhook 都使用真实 provider 或生产可用 adapter。 - 支付 webhook、导入任务、CRM 推送、资源签名都有幂等和审计。 -- 有生产环境变量模板、部署脚本、备份恢复方案、日志告警、错误追踪和基础压测报告。 +- 有生产环境变量模板、生产就绪检查脚本、部署脚本、备份恢复方案、日志告警、错误追踪和基础压测报告。 diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index d6b44a49..b08254f8 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -65,6 +65,7 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 - 禁止 `AUTH_SMS_PROVIDER=mock`。 - 禁止 `ALLOW_LEGACY_AUTH_HEADERS=true`。 - 禁止 `ALLOW_PLATFORM_ADMIN_KEY=true`。 + - 上云前必须运行 `npm run readiness:production`;连接生产数据库后再运行 `npm run readiness:production:db`。 5. 请求体大小限制 - 普通 JSON API 必须有默认上限。 @@ -172,6 +173,8 @@ provider event id 幂等 - `npm audit` 为 0 高危/严重漏洞。 - `npm run check:refactor` 通过。 +- `npm run readiness:production` 没有 blocker。 +- `npm run readiness:production:db` 没有 blocker。 - 生产环境启动时默认密钥 fail-fast 生效。 - 跨租户学生读取题目/订单/资料返回拒绝。 - 销售只能查看自己权限范围内客资。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index c8a81a3c..6ee867b6 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -56,7 +56,7 @@ - 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。 4. 部署配置 - - 整理生产 `.env` 模板。 + - 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。 - 确认数据库迁移流程、备份恢复、日志、告警。 - 准备 API 容器部署和 Supabase 云端/自托管连接方案。 diff --git a/package.json b/package.json index bd564baa..187f2294 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "check:api": "npm --workspace @tiku-saas/api run check", "check:importer": "npm --workspace @tiku-saas/import-pocketbase run check", "check:worker": "npm --workspace @tiku-saas/worker run check", - "check:refactor": "npm run check:api && npm run check:worker && npm run check:importer && npm run pb:import:validate && npm run test:api", + "check:refactor": "npm run check:api && npm run check:worker && npm run check:importer && npm run pb:import:validate && npm run test:readiness && npm run test:api", "docker:api:build": "docker compose -f docker-compose.api.yml build", "docker:api:up": "docker compose -f docker-compose.api.yml up api", "docker:api:down": "docker compose -f docker-compose.api.yml down", @@ -38,6 +38,9 @@ "test:worker:assets": "npm run db:smoke-seed && npm run build:worker && node scripts/asset-worker-integration-test.js", "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:readiness": "node scripts/production-readiness-check-test.js", + "readiness:production": "node scripts/production-readiness-check.js --skip-db", + "readiness:production:db": "node scripts/production-readiness-check.js --check-db", "test:api:remote": "node scripts/api-integration-test.js", "pb:schema:summary": "npm --workspace @tiku-saas/import-pocketbase run schema:summary", "pb:schema:risk": "npm --workspace @tiku-saas/import-pocketbase run schema:risk", diff --git a/scripts/production-readiness-check-test.js b/scripts/production-readiness-check-test.js new file mode 100644 index 00000000..471df084 --- /dev/null +++ b/scripts/production-readiness-check-test.js @@ -0,0 +1,90 @@ +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'; + +const repoRoot = process.cwd(); +const scriptPath = path.join(repoRoot, 'scripts', 'production-readiness-check.js'); + +function runReadiness(envContent) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-readiness-')); + const envFile = path.join(tempDir, '.env'); + fs.writeFileSync(envFile, envContent, 'utf8'); + + const result = spawnSync(process.execPath, [scriptPath, '--env-file', envFile, '--skip-db', '--json'], { + 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 unsafe = runReadiness(` +NODE_ENV=development +DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres +AUTH_SMS_PROVIDER=mock +`); + +assert.notEqual(unsafe.status, 0, 'unsafe production readiness should fail'); +assert.ok(unsafe.payload.summary?.blocker > 0, 'unsafe readiness should report blockers'); +assert.ok( + unsafe.payload.checks?.some(item => item.id === 'env.node_env' && item.status === 'blocker'), + 'unsafe readiness should block non-production NODE_ENV', +); +assert.ok( + unsafe.payload.checks?.some(item => item.id === 'env.auth_sms_provider' && item.status === 'blocker'), + 'unsafe readiness should block mock SMS provider', +); + +const strongSecretA = 's3cure-prod-code-pepper-2026-06-29-abcdef'; +const strongSecretB = 's3cure-prod-session-secret-2026-06-29-ghijkl'; +const strongSecretC = 's3cure-platform-admin-key-2026-06-29-mnopqr'; + +const safe = runReadiness(` +NODE_ENV=production +DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku +CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com +AUTH_SMS_PROVIDER=aliyun +AUTH_CODE_PEPPER=${strongSecretA} +AUTH_SESSION_SECRET=${strongSecretB} +AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +PLATFORM_ADMIN_API_KEY=${strongSecretC} +MAX_JSON_BODY_BYTES=1048576 +MAX_IMPORT_JSON_BODY_BYTES=10485760 +STORAGE_DEFAULT_PROVIDER=aliyun_oss +STORAGE_DEFAULT_BUCKET=tiku-assets +STORAGE_REQUIRE_TENANT_PREFIX=true +STORAGE_ALLOWED_MIME_TYPES=application/pdf,image/png,image/jpeg,video/mp4,text/plain,text/csv +ALIYUN_OSS_REGION=cn-hangzhou +ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY +ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder +WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false +WORKER_CRM_BATCH_SIZE=20 +WORKER_COMMERCE_BATCH_SIZE=20 +WORKER_ASSET_BATCH_SIZE=50 +WORKER_IMPORT_BATCH_SIZE=5 +WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE=5 +`); + +assert.equal(safe.status, 0, `safe readiness should pass without blockers: ${safe.stdout} ${safe.stderr}`); +assert.equal(safe.payload.summary?.blocker, 0, 'safe readiness should have no blockers'); +assert.ok( + safe.payload.checks?.some(item => item.id === 'db.skipped' && item.status === 'warn'), + 'env-only readiness should explicitly warn that DB checks are skipped', +); + +console.log('[PASS] production readiness check script'); diff --git a/scripts/production-readiness-check.js b/scripts/production-readiness-check.js new file mode 100644 index 00000000..f7c6db73 --- /dev/null +++ b/scripts/production-readiness-check.js @@ -0,0 +1,419 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import pg from 'pg'; + +const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me'; +const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me'; +const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me'; +const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key'; +const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024; + +const args = new Set(process.argv.slice(2)); +const argValues = new Map(); +for (let index = 2; index < process.argv.length; index += 1) { + const current = process.argv[index]; + if (current.startsWith('--') && process.argv[index + 1] && !process.argv[index + 1].startsWith('--')) { + argValues.set(current, process.argv[index + 1]); + index += 1; + } +} + +const jsonOutput = args.has('--json'); +const checkDb = args.has('--check-db'); +const skipDb = args.has('--skip-db') || !checkDb; +const envFile = argValues.get('--env-file') || path.resolve(process.cwd(), '.env'); +const checks = []; + +function loadEnvFile(filePath) { + if (!filePath || !fs.existsSync(filePath)) return; + const content = fs.readFileSync(filePath, 'utf8'); + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const index = trimmed.indexOf('='); + if (index === -1) continue; + const key = trimmed.slice(0, index).trim(); + const value = trimmed.slice(index + 1).trim().replace(/^"|"$/g, ''); + if (key && process.env[key] === undefined) process.env[key] = value; + } +} + +function env(key, fallback = '') { + return process.env[key] ?? fallback; +} + +function envBool(key, fallback = false) { + const value = process.env[key]; + if (value === undefined) return fallback; + return ['true', '1', 'yes', 'y', 'on'].includes(value.toLowerCase()); +} + +function envNumber(key, fallback) { + const parsed = Number(process.env[key]); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function envList(key, fallback = '') { + return env(key, fallback) + .split(',') + .map(item => item.trim()) + .filter(Boolean); +} + +function add(status, id, message, details = {}) { + checks.push({ status, id, message, details }); +} + +function pass(id, message, details = {}) { + add('pass', id, message, details); +} + +function warn(id, message, details = {}) { + add('warn', id, message, details); +} + +function block(id, message, details = {}) { + add('blocker', id, message, details); +} + +function isUnsafeSecret(value, defaultValue) { + const normalized = String(value || '').trim().toLowerCase(); + return ( + !normalized || + value === defaultValue || + normalized.length < 32 || + normalized.includes('replace_with') || + normalized.includes('change-me') || + normalized.includes('changeme') || + normalized.includes('your_') || + normalized.includes('example') + ); +} + +function hostFromUrl(value) { + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return ''; + } +} + +function isLocalHost(hostname) { + return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname); +} + +function isSecretLikeKey(key) { + const normalized = key.toLowerCase().replace(/[-_\s]/g, ''); + const allowedSecretRef = normalized === 'secretref' || normalized.endsWith('secretref'); + return ( + !allowedSecretRef && + (normalized.includes('secret') || + normalized.includes('password') || + normalized.includes('token') || + normalized.includes('privatekey') || + normalized.includes('apikey') || + normalized.includes('apiv3key') || + normalized.includes('mchkey') || + normalized.includes('signkey') || + normalized.includes('aeskey') || + normalized.includes('partnerkey')) + ); +} + +function findSecretLikePaths(value, prefix = 'configPublic') { + if (!value || typeof value !== 'object') return []; + if (Array.isArray(value)) { + return value.flatMap((item, index) => findSecretLikePaths(item, `${prefix}[${index}]`)); + } + const paths = []; + for (const [key, child] of Object.entries(value)) { + const nextPath = `${prefix}.${key}`; + if (isSecretLikeKey(key)) paths.push(nextPath); + paths.push(...findSecretLikePaths(child, nextPath)); + } + return paths; +} + +function safeProviderName(provider) { + return String(provider || '').replace(/[^a-zA-Z0-9_.:-]/g, '_'); +} + +function validateEnv() { + const nodeEnv = env('NODE_ENV', 'development'); + if (nodeEnv !== 'production') block('env.node_env', 'NODE_ENV must be production for production readiness checks'); + else pass('env.node_env', 'NODE_ENV is production'); + + const databaseUrl = env('DATABASE_URL', DEFAULT_DATABASE_URL); + const databaseHost = hostFromUrl(databaseUrl); + if (!databaseUrl || databaseUrl === DEFAULT_DATABASE_URL) { + block('env.database_url', 'DATABASE_URL must point to the production Supabase/PostgreSQL database'); + } else if (isLocalHost(databaseHost)) { + warn('env.database_url.localhost', 'DATABASE_URL points to a local host; confirm this is intentional for self-hosted deployment'); + } else { + pass('env.database_url', 'DATABASE_URL is not the local development default'); + } + + const corsOrigins = envList('CORS_ORIGIN', '*'); + if (corsOrigins.includes('*')) { + block('env.cors_origin', 'CORS_ORIGIN must not include * in production'); + } else if (corsOrigins.length === 0) { + block('env.cors_origin.empty', 'CORS_ORIGIN must include the deployed H5/admin domains'); + } else { + const unsafeOrigins = corsOrigins.filter(origin => { + const host = hostFromUrl(origin); + return !origin.startsWith('https://') || isLocalHost(host); + }); + if (unsafeOrigins.length > 0) { + block('env.cors_origin.unsafe', 'CORS_ORIGIN must use production HTTPS origins only', { count: unsafeOrigins.length }); + } else { + pass('env.cors_origin', 'CORS_ORIGIN is restricted to HTTPS origins', { count: corsOrigins.length }); + } + } + + const authSmsProvider = env('AUTH_SMS_PROVIDER', 'mock'); + if (authSmsProvider === 'mock') block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER=mock is not allowed in production'); + else pass('env.auth_sms_provider', 'AUTH_SMS_PROVIDER is not mock'); + + if (isUnsafeSecret(env('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER), DEFAULT_AUTH_CODE_PEPPER)) { + block('env.auth_code_pepper', 'AUTH_CODE_PEPPER must be a strong production secret'); + } else { + pass('env.auth_code_pepper', 'AUTH_CODE_PEPPER looks production-grade'); + } + + if (isUnsafeSecret(env('AUTH_SESSION_SECRET', DEFAULT_AUTH_SESSION_SECRET), DEFAULT_AUTH_SESSION_SECRET)) { + block('env.auth_session_secret', 'AUTH_SESSION_SECRET must be a strong production secret'); + } else { + pass('env.auth_session_secret', 'AUTH_SESSION_SECRET looks production-grade'); + } + + const jwksUrl = env('AUTH_JWT_JWKS_URL', ''); + const jwtSecret = env('AUTH_JWT_SECRET', DEFAULT_AUTH_JWT_SECRET); + if (jwksUrl) { + const host = hostFromUrl(jwksUrl); + if (!jwksUrl.startsWith('https://') || isLocalHost(host)) { + block('env.auth_jwt_jwks_url', 'AUTH_JWT_JWKS_URL must be an HTTPS production URL'); + } else { + pass('env.auth_jwt_jwks_url', 'AUTH_JWT_JWKS_URL is configured'); + } + } else if (isUnsafeSecret(jwtSecret, DEFAULT_AUTH_JWT_SECRET)) { + block('env.auth_jwt_secret', 'AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification'); + } else { + pass('env.auth_jwt_secret', 'AUTH_JWT_SECRET looks production-grade'); + } + + if (envBool('ALLOW_LEGACY_AUTH_HEADERS', false)) { + block('env.allow_legacy_auth_headers', 'ALLOW_LEGACY_AUTH_HEADERS must be false in production'); + } else { + pass('env.allow_legacy_auth_headers', 'legacy x-user-id auth headers are disabled'); + } + + if (envBool('ALLOW_PLATFORM_ADMIN_KEY', false)) { + block('env.allow_platform_admin_key', 'ALLOW_PLATFORM_ADMIN_KEY must be false in production'); + } else { + pass('env.allow_platform_admin_key', 'platform admin API key compatibility is disabled'); + } + + if (isUnsafeSecret(env('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY), DEFAULT_PLATFORM_ADMIN_API_KEY)) { + warn('env.platform_admin_api_key', 'PLATFORM_ADMIN_API_KEY is weak/default; keep ALLOW_PLATFORM_ADMIN_KEY=false and rotate before any temporary use'); + } else { + pass('env.platform_admin_api_key', 'PLATFORM_ADMIN_API_KEY is not default'); + } + + const maxJson = envNumber('MAX_JSON_BODY_BYTES', 1024 * 1024); + const maxImportJson = envNumber('MAX_IMPORT_JSON_BODY_BYTES', 10 * 1024 * 1024); + if (maxJson > HARD_MAX_JSON_BODY_BYTES || maxImportJson > HARD_MAX_JSON_BODY_BYTES) { + block('env.body_size', 'JSON body limits must stay at or below 50MB'); + } else { + pass('env.body_size', 'JSON body limits are bounded'); + } + + const storageProvider = env('STORAGE_DEFAULT_PROVIDER', 'local_dev'); + if (storageProvider === 'local_dev') { + block('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER=local_dev is not allowed for production assets'); + } else { + pass('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER is production-capable', { provider: storageProvider }); + } + if (!env('STORAGE_DEFAULT_BUCKET', '')) { + block('env.storage_bucket', 'STORAGE_DEFAULT_BUCKET is required'); + } else { + pass('env.storage_bucket', 'STORAGE_DEFAULT_BUCKET is configured'); + } + if (!envBool('STORAGE_REQUIRE_TENANT_PREFIX', true)) { + block('env.storage_tenant_prefix', 'STORAGE_REQUIRE_TENANT_PREFIX must remain true to protect tenant assets'); + } else { + pass('env.storage_tenant_prefix', 'tenant-prefixed object keys are required'); + } + + if (storageProvider === 'aliyun_oss') { + for (const key of ['ALIYUN_OSS_REGION', 'ALIYUN_OSS_ENDPOINT', 'ALIYUN_OSS_ACCESS_KEY_ID', 'ALIYUN_OSS_ACCESS_KEY_SECRET']) { + if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for aliyun_oss`); + } + } + if (storageProvider === 'tencent_cos') { + for (const key of ['TENCENT_COS_REGION', 'TENCENT_COS_APP_ID', 'TENCENT_COS_SECRET_ID', 'TENCENT_COS_SECRET_KEY']) { + if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for tencent_cos`); + } + } + if (storageProvider === 'supabase_storage') { + for (const key of ['SUPABASE_STORAGE_URL', 'SUPABASE_STORAGE_SERVICE_KEY']) { + if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for supabase_storage`); + } + } + + if (envList('STORAGE_ALLOWED_MIME_TYPES').includes('application/octet-stream')) { + warn('env.storage_octet_stream', 'application/octet-stream is allowed; consider removing it after import migration is stable'); + } + + if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) { + block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production'); + } else { + pass('env.worker_crm_insecure_localhost', 'CRM worker insecure localhost webhook mode is disabled'); + } + + const requiredPositiveNumbers = [ + 'WORKER_CRM_BATCH_SIZE', + 'WORKER_COMMERCE_BATCH_SIZE', + 'WORKER_ASSET_BATCH_SIZE', + 'WORKER_IMPORT_BATCH_SIZE', + 'WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE', + ]; + for (const key of requiredPositiveNumbers) { + if (envNumber(key, 1) <= 0) block(`env.${key.toLowerCase()}`, `${key} must be greater than 0`); + } +} + +async function validateDatabase() { + if (skipDb) { + warn('db.skipped', 'Database readiness checks skipped; run with --check-db after production DATABASE_URL is configured'); + return; + } + + const pool = new pg.Pool({ connectionString: env('DATABASE_URL', DEFAULT_DATABASE_URL), max: 2 }); + try { + const tenantRows = await pool.query(` + select id, name, status + from public.tenants + where status = 'active' + order by created_at asc + `); + if (tenantRows.rowCount === 0) block('db.tenants', 'No active tenant exists in the production database'); + else pass('db.tenants', 'Active tenants found', { count: tenantRows.rowCount }); + + const publicSecretRows = await pool.query(` + select source, tenant_id, provider, config_public + from ( + select 'auth' as source, tenant_id, provider, config_public + from public.tenant_auth_providers + where status in ('active', 'testing') + union all + select 'payment' as source, tenant_id, provider, config_public + from public.tenant_payment_accounts + where status = 'active' + ) providers + `); + for (const row of publicSecretRows.rows) { + const secretPaths = findSecretLikePaths(row.config_public || {}); + if (secretPaths.length > 0) { + block(`db.${row.source}.${safeProviderName(row.provider)}.public_secret`, 'Provider public config contains secret-like keys', { + tenantId: row.tenant_id, + provider: row.provider, + secretPathCount: secretPaths.length, + }); + } + } + if (publicSecretRows.rows.every(row => findSecretLikePaths(row.config_public || {}).length === 0)) { + pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys'); + } + + const missingAuthSecretRows = await pool.query(` + select p.tenant_id, p.provider + from public.tenant_auth_providers p + left join app_private.tenant_secrets s + on s.tenant_id = p.tenant_id + and s.secret_scope = case + when lower(replace(p.provider, '_', '-')) in ('aliyun', 'aliyun-sms', 'tencent', 'tencent-sms') then 'sms' + else 'oauth' + end + and s.secret_key = coalesce(nullif(split_part(p.config_public->>'secretRef', ':', 3), ''), p.provider) + where p.status in ('active', 'testing') + and p.provider not in ('mock') + and s.id is null + `); + if (missingAuthSecretRows.rowCount > 0) { + block('db.auth_provider_secrets', 'Some active auth providers are missing tenant secret rows', { count: missingAuthSecretRows.rowCount }); + } else { + pass('db.auth_provider_secrets', 'Active auth providers have private secret rows'); + } + + const missingPaymentSecretRows = await pool.query(` + select p.tenant_id, p.provider + from public.tenant_payment_accounts p + left join app_private.tenant_secrets s + on s.tenant_id = p.tenant_id + and s.secret_scope = 'payment' + and s.secret_key = coalesce(nullif(split_part(p.config_public->>'secretRef', ':', 3), ''), p.provider) + where p.status = 'active' + and p.provider not in ('manual') + and s.id is null + `); + if (missingPaymentSecretRows.rowCount > 0) { + block('db.payment_provider_secrets', 'Some active payment accounts are missing tenant secret rows', { count: missingPaymentSecretRows.rowCount }); + } else { + pass('db.payment_provider_secrets', 'Active payment accounts have private secret rows'); + } + + const unverifiedDomainRows = await pool.query(` + select count(*)::int as count + from public.tenant_domains + where status not in ('active', 'verified') + `); + const unverifiedDomains = Number(unverifiedDomainRows.rows[0]?.count || 0); + if (unverifiedDomains > 0) warn('db.tenant_domains', 'Some tenant domains are not active/verified', { count: unverifiedDomains }); + else pass('db.tenant_domains', 'Tenant domains are active/verified or not configured'); + } finally { + await pool.end(); + } +} + +function printSummary() { + const summary = checks.reduce( + (acc, item) => { + acc[item.status] += 1; + return acc; + }, + { blocker: 0, warn: 0, pass: 0 }, + ); + + if (jsonOutput) { + console.log(JSON.stringify({ summary, checks }, null, 2)); + return; + } + + console.log('Production readiness check'); + console.log(`Env file: ${fs.existsSync(envFile) ? envFile : '(not found, using process env)'}`); + console.log(`Host: ${os.hostname()}`); + 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}`); + } +} + +async function main() { + loadEnvFile(envFile); + validateEnv(); + await validateDatabase(); + printSummary(); + + if (checks.some(item => item.status === 'blocker')) { + process.exitCode = 1; + } +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +});