forked from wangziqi/gongxue-base
feat: add production readiness checks
This commit is contained in:
50
.env.example
50
.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 暴露非敏感配置。
|
||||
|
||||
18
README.md
18
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
|
||||
|
||||
@@ -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` 管线。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 推送、资源签名都有幂等和审计。
|
||||
- 有生产环境变量模板、部署脚本、备份恢复方案、日志告警、错误追踪和基础压测报告。
|
||||
- 有生产环境变量模板、生产就绪检查脚本、部署脚本、备份恢复方案、日志告警、错误追踪和基础压测报告。
|
||||
|
||||
@@ -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 生效。
|
||||
- 跨租户学生读取题目/订单/资料返回拒绝。
|
||||
- 销售只能查看自己权限范围内客资。
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
- 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。
|
||||
|
||||
4. 部署配置
|
||||
- 整理生产 `.env` 模板。
|
||||
- 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。
|
||||
- 确认数据库迁移流程、备份恢复、日志、告警。
|
||||
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
90
scripts/production-readiness-check-test.js
Normal file
90
scripts/production-readiness-check-test.js
Normal 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');
|
||||
419
scripts/production-readiness-check.js
Normal file
419
scripts/production-readiness-check.js
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user