feat: add production readiness checks

This commit is contained in:
Codex
2026-06-29 10:34:12 +08:00
parent d89f77e9af
commit e587a0c955
10 changed files with 564 additions and 32 deletions

View File

@@ -1,41 +1,31 @@
# 阿里云短信服务配置(脚本/服务端使用,前端不读取此文件)
# 复制此文件为 .env 并填写实际值
#
# 生产环境说明:
# - 学生端 https://tiku.tjszsb.com
# - 超管后台 https://tikuguanli.tjszsb.com
# - 服务器 39.107.64.207PocketBase 运行于 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 暴露非敏感配置。

View File

@@ -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

View File

@@ -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` 管线。

View File

@@ -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

View File

@@ -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 推送、资源签名都有幂等和审计。
- 有生产环境变量模板、部署脚本、备份恢复方案、日志告警、错误追踪和基础压测报告。
- 有生产环境变量模板、生产就绪检查脚本、部署脚本、备份恢复方案、日志告警、错误追踪和基础压测报告。

View File

@@ -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 生效。
- 跨租户学生读取题目/订单/资料返回拒绝。
- 销售只能查看自己权限范围内客资。

View File

@@ -56,7 +56,7 @@
- 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。
4. 部署配置
- 整理生产 `.env` 模板。
- 生产 `.env` 模板`npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate
- 确认数据库迁移流程、备份恢复、日志、告警。
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。

View File

@@ -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",

View File

@@ -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');

View File

@@ -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;
});