diff --git a/README.md b/README.md index 55965fd9..ecb5fd7b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ - `docs/refactor/ai-development-guardrails.md` - `docs/refactor/content-import-contract.md` - `docs/refactor/object-storage.md` +- `docs/refactor/object-storage-production-runbook.md` - `docs/refactor/project-structure.md` - `docs/refactor/frontend-handoff-index.md` - `docs/refactor/backend-capability-status.md` @@ -335,6 +336,7 @@ API 身份上下文: - 租户公开配置和主题配置不能存放密钥;主题 token 只能是后端允许的颜色、半径、安全 CSS 变量、图标 token 和公开素材引用。 - 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。 - 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;学生端预览、锁定资料和视频会使用短 TTL,并返回带 `traceId` 的 `watermark` 上下文供前端渲染可见水印。`members/svip/private` 外部 CDN URL 默认拒绝,除非显式登记 provider-managed 访问;所有上传签名、上传确认、下载/预览 granted/denied 都写入 `content_asset_access_events`。托管对象必须 `uploadStatus=verified` 且 `securityScanStatus=passed` 后才能发布、下载、预览或播放;生产环境应定时运行 assets worker 复检对象元数据,执行 `metadata_rules` 和外部 HTTP scanner,异常资源会被标记 failed/skipped 并退回 draft。 +- `NODE_ENV=production` 下 API 和 worker 都会拒绝 `STORAGE_DEFAULT_PROVIDER=local_dev`、空 bucket 或关闭 `STORAGE_REQUIRE_TENANT_PREFIX`;worker 还会拒绝未接入外部 HTTP 安全扫描或开启 fail-open 的生产配置。 - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。学生端题干/解析/手册内容统一走 `apps/taro/src/components/RichContent.tsx` 做受控渲染,不执行导入内容中的任意 HTML/JS;公式只渲染解析出的 LaTeX token,私有题图只接受资源 ID 引用并走后端短签名。 - 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。 diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts index fa578b79..57f1b6d7 100644 --- a/apps/api/src/core/config.ts +++ b/apps/api/src/core/config.ts @@ -95,6 +95,33 @@ function validateProductionConfig(nextConfig: ApiConfig) { if (nextConfig.allowPlatformAdminKey) { failures.push('ALLOW_PLATFORM_ADMIN_KEY=true is not allowed in production'); } + if (nextConfig.storageDefaultProvider === 'local_dev') { + failures.push('STORAGE_DEFAULT_PROVIDER=local_dev is not allowed in production'); + } + if (!nextConfig.storageDefaultBucket.trim()) { + failures.push('STORAGE_DEFAULT_BUCKET is required in production'); + } + if (!nextConfig.storageRequireTenantPrefix) { + failures.push('STORAGE_REQUIRE_TENANT_PREFIX=false is not allowed in production'); + } + if (nextConfig.storageDefaultProvider === 'aliyun_oss') { + if (!nextConfig.aliyunOssAccessKeyId || !nextConfig.aliyunOssAccessKeySecret) { + failures.push('ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET are required for aliyun_oss'); + } + if (!nextConfig.aliyunOssRegion && !nextConfig.aliyunOssEndpoint) { + failures.push('ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT is required for aliyun_oss'); + } + } + if (nextConfig.storageDefaultProvider === 'tencent_cos') { + if (!nextConfig.tencentCosRegion || !nextConfig.tencentCosAppId || !nextConfig.tencentCosSecretId || !nextConfig.tencentCosSecretKey) { + failures.push('TENCENT_COS_REGION, TENCENT_COS_APP_ID, TENCENT_COS_SECRET_ID and TENCENT_COS_SECRET_KEY are required for tencent_cos'); + } + } + if (nextConfig.storageDefaultProvider === 'supabase_storage') { + if (!nextConfig.supabaseStorageUrl || !nextConfig.supabaseStorageServiceKey) { + failures.push('SUPABASE_STORAGE_URL and SUPABASE_STORAGE_SERVICE_KEY are required for supabase_storage'); + } + } if (failures.length > 0) { throw new Error(`Invalid production API configuration: ${failures.join('; ')}`); diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts index 5e66d177..2c2970d4 100644 --- a/apps/worker/src/config.ts +++ b/apps/worker/src/config.ts @@ -3,6 +3,8 @@ import { DEFAULT_DATABASE_URL, envBoolean, envList, envNumber, envString, loadDo loadDotenv(); export interface WorkerConfig { + nodeEnv: string; + isProduction: boolean; databaseUrl: string; crmBatchSize: number; crmPollIntervalMs: number; @@ -59,7 +61,96 @@ export interface WorkerConfig { supabaseStorageServiceKey: string; } -export const config: WorkerConfig = { +function hostFromUrl(value: string) { + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return ''; + } +} + +function isLocalHost(hostname: string) { + return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname); +} + +function isUnsafeSecret(value: string) { + const normalized = value.trim().toLowerCase(); + return ( + !normalized || + normalized.length < 32 || + normalized.includes('replace_with') || + normalized.includes('change-me') || + normalized.includes('changeme') || + normalized.includes('your_') || + normalized.includes('example') + ); +} + +function validateProductionConfig(nextConfig: WorkerConfig) { + if (!nextConfig.isProduction) return; + + const failures: string[] = []; + if (nextConfig.storageDefaultProvider === 'local_dev') { + failures.push('STORAGE_DEFAULT_PROVIDER=local_dev is not allowed in production workers'); + } + if (!nextConfig.storageDefaultBucket.trim()) { + failures.push('STORAGE_DEFAULT_BUCKET is required in production workers'); + } + if (!nextConfig.storageRequireTenantPrefix) { + failures.push('STORAGE_REQUIRE_TENANT_PREFIX=false is not allowed in production workers'); + } + if (nextConfig.assetSecurityScanFailOpen) { + failures.push('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=true is not allowed in production workers'); + } + const scannerModes = nextConfig.assetSecurityScanner + .split(',') + .map(item => item.trim().toLowerCase()) + .filter(Boolean); + const unsupportedScannerModes = scannerModes.filter(mode => mode !== 'metadata_rules' && mode !== 'http'); + if (scannerModes.length === 0 || unsupportedScannerModes.length > 0) { + failures.push('WORKER_ASSET_SECURITY_SCANNER must include supported modes: metadata_rules,http'); + } + if (!scannerModes.includes('http')) { + failures.push('WORKER_ASSET_SECURITY_SCANNER must include http in production workers'); + } + if (scannerModes.includes('http')) { + const scannerHost = hostFromUrl(nextConfig.assetSecurityScanHttpEndpoint); + if (!nextConfig.assetSecurityScanHttpEndpoint.startsWith('https://') || isLocalHost(scannerHost)) { + failures.push('WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT must be a production HTTPS URL'); + } + if (isUnsafeSecret(nextConfig.assetSecurityScanHttpToken)) { + failures.push('WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN must be a strong production secret'); + } + } + if (nextConfig.storageDefaultProvider === 'aliyun_oss') { + if (!nextConfig.aliyunOssAccessKeyId || !nextConfig.aliyunOssAccessKeySecret) { + failures.push('ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET are required for aliyun_oss'); + } + if (!nextConfig.aliyunOssRegion && !nextConfig.aliyunOssEndpoint) { + failures.push('ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT is required for aliyun_oss'); + } + } + if (nextConfig.storageDefaultProvider === 'tencent_cos') { + if (!nextConfig.tencentCosRegion || !nextConfig.tencentCosAppId || !nextConfig.tencentCosSecretId || !nextConfig.tencentCosSecretKey) { + failures.push('TENCENT_COS_REGION, TENCENT_COS_APP_ID, TENCENT_COS_SECRET_ID and TENCENT_COS_SECRET_KEY are required for tencent_cos'); + } + } + if (nextConfig.storageDefaultProvider === 'supabase_storage') { + if (!nextConfig.supabaseStorageUrl || !nextConfig.supabaseStorageServiceKey) { + failures.push('SUPABASE_STORAGE_URL and SUPABASE_STORAGE_SERVICE_KEY are required for supabase_storage'); + } + } + + if (failures.length > 0) { + throw new Error(`Invalid production worker configuration: ${failures.join('; ')}`); + } +} + +const nodeEnv = envString('NODE_ENV', 'development'); + +const loadedConfig: WorkerConfig = { + nodeEnv, + isProduction: nodeEnv === 'production', databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL), crmBatchSize: envNumber('WORKER_CRM_BATCH_SIZE', 20), crmPollIntervalMs: envNumber('WORKER_CRM_POLL_INTERVAL_MS', 10_000), @@ -139,3 +230,7 @@ export const config: WorkerConfig = { supabaseStorageUrl: envString('SUPABASE_STORAGE_URL', ''), supabaseStorageServiceKey: envString('SUPABASE_STORAGE_SERVICE_KEY', ''), }; + +validateProductionConfig(loadedConfig); + +export const config = loadedConfig; diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index be7baacc..e6ea5a17 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -61,6 +61,8 @@ - 已补租户后台媒体运营报表,支持资料/视频访问汇总、Top 资源/视频和 traceId 回查;大租户后续再把实时查询替换为日/周预聚合。 - 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。 - 已补外部 HTTP 杀毒/内容安全 scanner 接入层、失败关闭、生产 readiness 阻断和动态水印 traceId 审计;继续联调真实扫描服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略。 + - 已补 API/worker 生产启动 fail-fast:生产环境会拒绝 `local_dev` 存储、空 bucket、关闭租户前缀;worker 会拒绝未接入外部 HTTP scanner 或 fail-open。 + - 已补 `docs/refactor/object-storage-production-runbook.md`,真实云厂商联调必须按 runbook 抽样验收。 - `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。 3. 真实导入 dry-run diff --git a/docs/refactor/object-storage-production-runbook.md b/docs/refactor/object-storage-production-runbook.md new file mode 100644 index 00000000..46706b41 --- /dev/null +++ b/docs/refactor/object-storage-production-runbook.md @@ -0,0 +1,216 @@ +# 对象存储生产验收 Runbook + +更新时间:2026-06-30 + +本系统的题图、PDF、视频、音频、资料包、题库导出文件都必须进入 `content_assets` 台账,并由 `apps/api` 做权限判断、短期签名、水印和审计。前端不得直接持有云厂商密钥、service role key、私有 bucket 路径或长期私有资源 URL。 + +## 官方能力依据 + +- 阿里云 OSS 官方文档说明可由服务端生成预签名 URL,客户端用该 URL 上传或下载对象,并通过有效期限制访问时间。阿里云也建议在更复杂的大文件场景中评估 STS 授权直传。参考:[OSS 预签名 URL 上传](https://help.aliyun.com/zh/oss/user-guide/upload-files-using-presigned-urls)、[OSS 预签名 URL 下载/预览](https://www.alibabacloud.com/help/zh/oss/user-guide/how-to-obtain-the-url-of-a-single-object-or-the-urls-of-multiple-objects)。 +- 腾讯云 COS 官方文档说明预签名 URL 可用于临时上传/下载私有对象,URL 中携带签名和有效期;官方也建议签名有效期设置为完成本次操作所需的最短期限。参考:[COS 预签名 URL 访问](https://cloud.tencent.com/document/product/436/68284)、[COS 预签名授权上传](https://cloud.tencent.com/document/product/436/14114)。 +- Supabase Storage 官方文档说明 `createSignedUrl` 可以按秒设置下载 URL 有效期,访问私有对象需要相应 `select` 权限;本项目由后端 service key 集中签名,前端只拿短期 URL。参考:[Supabase Storage signed URL](https://supabase.com/docs/reference/javascript/storage-from-createsignedurl)、[Supabase Storage downloads](https://supabase.com/docs/guides/storage/serving/downloads)。 + +## 生产阻断项 + +上线前必须满足: + +- `NODE_ENV=production` 下 API 和 worker 都不能使用 `STORAGE_DEFAULT_PROVIDER=local_dev`。 +- `STORAGE_DEFAULT_BUCKET` 必须配置。 +- `STORAGE_REQUIRE_TENANT_PREFIX=true` 必须保持开启。 +- 阿里云 OSS 必须配置 `ALIYUN_OSS_REGION` 或 `ALIYUN_OSS_ENDPOINT`,以及 AccessKey。 +- 腾讯云 COS 必须配置 `TENCENT_COS_REGION`、`TENCENT_COS_APP_ID`、`TENCENT_COS_SECRET_ID`、`TENCENT_COS_SECRET_KEY`。 +- Supabase Storage 必须配置 `SUPABASE_STORAGE_URL` 和 `SUPABASE_STORAGE_SERVICE_KEY`。 +- `WORKER_ASSET_SECURITY_SCANNER` 生产必须包含 `http`,例如 `metadata_rules,http`。 +- `WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT` 必须是 HTTPS 生产地址。 +- `WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN` 必须是强随机密钥。 +- `WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false`,外部扫描服务不可用时必须 fail-closed。 + +对应自动化命令: + +```bash +npm run test:readiness +npm run readiness:production +npm run readiness:production:db +``` + +其中 `test:readiness` 会同时验证 production readiness 脚本和 API/worker 的生产配置 fail-fast。 + +## 推荐环境变量模板 + +阿里云 OSS: + +```text +NODE_ENV=production +STORAGE_DEFAULT_PROVIDER=aliyun_oss +STORAGE_DEFAULT_BUCKET=tiku-assets-prod +STORAGE_REQUIRE_TENANT_PREFIX=true +ALIYUN_OSS_REGION=cn-hangzhou +ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +ALIYUN_OSS_ACCESS_KEY_ID=... +ALIYUN_OSS_ACCESS_KEY_SECRET=... +WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http +WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.example.com/api/scan +WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=... +WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false +``` + +腾讯云 COS: + +```text +NODE_ENV=production +STORAGE_DEFAULT_PROVIDER=tencent_cos +STORAGE_DEFAULT_BUCKET=tiku-assets-prod +STORAGE_REQUIRE_TENANT_PREFIX=true +TENCENT_COS_REGION=ap-shanghai +TENCENT_COS_APP_ID=... +TENCENT_COS_SECRET_ID=... +TENCENT_COS_SECRET_KEY=... +WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http +WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.example.com/api/scan +WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=... +WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false +``` + +Supabase Storage: + +```text +NODE_ENV=production +STORAGE_DEFAULT_PROVIDER=supabase_storage +STORAGE_DEFAULT_BUCKET=tiku-assets-prod +STORAGE_REQUIRE_TENANT_PREFIX=true +SUPABASE_STORAGE_URL=https://.supabase.co/storage/v1 +SUPABASE_STORAGE_SERVICE_KEY=... +WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http +WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.example.com/api/scan +WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=... +WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false +``` + +## 云侧配置验收 + +以下配置无法仅靠仓库代码证明,必须在云控制台或云 API 上确认: + +| 项目 | 验收标准 | +| --- | --- | +| Bucket 权限 | 默认私有;公开 bucket 只允许存放确认为公开的品牌素材 | +| CORS | 只允许学生端 H5、租户后台 H5、平台后台 H5 域名和微信小程序合法域名;允许必要方法 `GET/HEAD/PUT` 和必要 headers | +| RAM/CAM 权限 | 服务端密钥最小权限,仅允许目标 bucket 的指定前缀读写和 HEAD;不要使用主账号密钥 | +| 租户前缀 | 对象 key 必须以 `{tenantId}/` 开头,bucket policy 不应允许跨前缀写入 | +| CDN/防盗链 | 私有资料、SVIP 资料、视频、资料包禁止普通长效 CDN URL;如使用 CDN,必须由 provider 侧签名或回源鉴权 | +| 生命周期 | 临时上传、导出中间文件、失败扫描资源设置清理规则;正式资料按业务保留周期设置归档或低频 | +| 版本控制/备份 | 生产 bucket 开启版本控制、跨区域复制或定时备份,满足误删和容灾要求 | +| 日志审计 | 开启对象访问日志或云审计,至少保留一个完整售后周期 | +| 内容安全 | 外部 scanner 真实接入,并覆盖 PDF、图片、压缩包、视频封面或转码前文件 | + +## 联调步骤 + +1. 启动 API 和 worker,确保不是 `local_dev` provider。 + +```bash +npm run build:api +npm run build:worker +npm run readiness:production +npm run readiness:production:db +``` + +2. 租户后台申请上传签名: + +```text +POST /api/tenant-content/assets/sign-upload +``` + +验收点: + +- 返回 URL 为目标云厂商域名或 Supabase Storage 域名。 +- `headers` 包含上传所需 `content-type`。 +- `objectKey` 带当前 `tenantId/` 前缀。 +- `content_asset_access_events` 写入 `upload_sign`。 + +3. 前端直传云存储后调用确认上传: + +```text +POST /api/tenant-content/assets/confirm-upload +``` + +验收点: + +- 后端读取云对象元数据。 +- 大小、MIME、checksum 能对上。 +- 状态变为 `uploadStatus=verified`、`securityScanStatus=pending`。 +- 即使传 `publish=true`,也不能绕过扫描直接发布。 + +4. 运行 assets worker: + +```bash +npm --workspace @tiku-saas/worker run assets:once +``` + +验收点: + +- 外部 scanner 收到请求。 +- scanner token 未进入前端响应。 +- 扫描通过后 `securityScanStatus=passed`。 +- 扫描失败、超时或服务不可用时资源退回 `draft`,并记录 `content_asset_security_scan_events`。 + +5. 后台发布资源: + +```text +PUT /api/tenant-content/assets +``` + +验收点: + +- 只有 `verified + passed` 的托管对象能发布为 `active`。 +- `failed/skipped/pending` 资源发布被拒绝。 + +6. 学生端预览/下载: + +```text +GET /api/catalog/assets/preview?assetId=... +GET /api/catalog/assets/download?assetId=... +``` + +验收点: + +- 未登录、无权益、跨租户、扫描未通过都被拒绝。 +- 私有/SVIP/视频/资料包 TTL 不超过 300 秒。 +- 响应包含 `watermark.mode=visible_overlay` 和 `traceId`。 +- `content_asset_access_events` 记录 granted/denied、TTL、signatureMode、watermark traceId。 + +7. 题库导出 worker: + +```bash +npm --workspace @tiku-saas/worker run exports:once +``` + +验收点: + +- PDF/Word/每日一练 ZIP 写入对象存储。 +- 自动创建 `content_assets`。 +- `securityScanProvider=trusted_export_worker`。 +- 后台通过 `sign-download` 或 `sign-preview` 取短签名 URL。 + +## 抽样清单 + +每次生产联调至少抽样: + +- PDF 10 个,含大文件、中文文件名、水印预览。 +- 图片 20 张,含题图、手册图、品牌图。 +- 视频 10 个,含有会员限制和播放次数限制的题目视频。 +- ZIP/资料包 5 个,含每日一练导出包。 +- 扫描失败样本 3 个,确认不能发布、不能下载。 +- 跨租户访问 5 组,确认无法签名下载或预览。 +- 私有 CDN URL 样本 3 个,未标记 provider-managed 时必须拒绝。 + +## 准出标准 + +满足以下条件后,才允许把资料、题图、视频迁到生产对象存储: + +- `npm run test:readiness` 通过。 +- `npm run readiness:production` 无 blocker。 +- `npm run readiness:production:db` 无 blocker。 +- `npm run test:worker:assets` 通过。 +- API/worker 在 `NODE_ENV=production` 下无法用 `local_dev` 启动。 +- 真实云存储上传、确认、扫描、发布、预览、下载、导出全链路通过。 +- 云侧 CORS、防盗链、生命周期、备份/版本控制、访问日志有截图或变更记录。 + diff --git a/docs/refactor/object-storage.md b/docs/refactor/object-storage.md index 63c8b2a8..c1cf1bec 100644 --- a/docs/refactor/object-storage.md +++ b/docs/refactor/object-storage.md @@ -6,6 +6,12 @@ 题库里的图片、PDF、视频、音频、资料包等媒体资源统一走 `content_assets` 台账和后端签名接口。前端不直接保存或读取云厂商密钥,也不直接拼接私有资源 URL。题目视频播放还需要经过 `POST /api/videos/play` 校验 SVIP 或视频次数权益后下发短期签名 URL。 +生产上线验收请按完整 runbook 执行,包含 API/worker production fail-fast、readiness、云控制台配置、上传确认、安全扫描、短签名、水印、跨租户和导出资源抽样: + +```text +docs/refactor/object-storage-production-runbook.md +``` + 已接入的 provider: - `local_dev`:本地开发占位签名,便于前后端联调。 diff --git a/package.json b/package.json index d7aed3e4..943501f6 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "test:worker:exports": "npm run db:smoke-seed && npm run build:worker && node scripts/export-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", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-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", diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 480c6b3e..50f12c62 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -722,6 +722,7 @@ async function testProductionConfigFailFast() { AUTH_CODE_PEPPER: 'development-code-pepper-change-me', AUTH_SESSION_SECRET: 'development-session-secret-change-me', PLATFORM_ADMIN_API_KEY: 'local-platform-admin-key', + STORAGE_DEFAULT_PROVIDER: 'local_dev', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, @@ -738,6 +739,7 @@ async function testProductionConfigFailFast() { const result = await waitForProcessExit(child); assert.notEqual(result.code, 0, 'production server with unsafe defaults should fail to start'); assert.match(logs, /Invalid production API configuration/, 'production fail-fast should explain unsafe config'); + assert.match(logs, /STORAGE_DEFAULT_PROVIDER=local_dev/, 'production fail-fast should reject local_dev storage'); } async function loginBySms(phone = '13800000000') { diff --git a/scripts/production-config-failfast-test.js b/scripts/production-config-failfast-test.js new file mode 100644 index 00000000..715b3d92 --- /dev/null +++ b/scripts/production-config-failfast-test.js @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const repoRoot = process.cwd(); +const apiConfigUrl = pathToFileURL(`${repoRoot}/apps/api/src/core/config.ts`).href; +const workerConfigUrl = pathToFileURL(`${repoRoot}/apps/worker/src/config.ts`).href; + +const safeBaseEnv = { + NODE_ENV: 'production', + DATABASE_URL: 'postgresql://prod_user:prod_password@db.prod.internal:5432/tiku', + STORAGE_DEFAULT_PROVIDER: 'aliyun_oss', + STORAGE_DEFAULT_BUCKET: 'tiku-assets', + STORAGE_REQUIRE_TENANT_PREFIX: 'true', + ALIYUN_OSS_REGION: 'cn-hangzhou', + ALIYUN_OSS_ENDPOINT: 'https://oss-cn-hangzhou.aliyuncs.com', + ALIYUN_OSS_ACCESS_KEY_ID: 'LTAI_PRODUCTION_CONFIG_TEST_ONLY', + ALIYUN_OSS_ACCESS_KEY_SECRET: 'aliyun-production-config-secret-placeholder', + WORKER_ASSET_SECURITY_SCANNER: 'metadata_rules,http', + WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: 'https://scanner.gongxue100.com/api/scan', + WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 's3cure-asset-scanner-token-2026-06-30-abcdef', + WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false', +}; + +const safeApiEnv = { + ...safeBaseEnv, + CORS_ORIGIN: 'https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com', + AUTH_SMS_PROVIDER: 'aliyun', + AUTH_CODE_PEPPER: 's3cure-prod-code-pepper-2026-06-30-abcdef', + AUTH_SESSION_SECRET: 's3cure-prod-session-secret-2026-06-30-ghijkl', + 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: 's3cure-platform-admin-key-2026-06-30-mnopqr', +}; + +function runImport(moduleUrl, env) { + const result = spawnSync(process.execPath, ['--import', 'tsx', '-e', `await import(${JSON.stringify(moduleUrl)})`], { + 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 || '', + TMP: process.env.TMP || '', + ...env, + }, + }); + return { ...result, output: `${result.stdout || ''}${result.stderr || ''}` }; +} + +const unsafeApi = runImport(apiConfigUrl, { + ...safeApiEnv, + STORAGE_DEFAULT_PROVIDER: 'local_dev', +}); +assert.notEqual(unsafeApi.status, 0, 'production API config should reject local_dev storage'); +assert.match(unsafeApi.output, /Invalid production API configuration/, 'API config should explain production config failure'); +assert.match(unsafeApi.output, /STORAGE_DEFAULT_PROVIDER=local_dev/, 'API config should name unsafe storage provider'); + +const safeApi = runImport(apiConfigUrl, safeApiEnv); +assert.equal(safeApi.status, 0, `safe production API config should load: ${safeApi.output}`); + +const unsafeWorker = runImport(workerConfigUrl, { + ...safeBaseEnv, + STORAGE_DEFAULT_PROVIDER: 'local_dev', +}); +assert.notEqual(unsafeWorker.status, 0, 'production worker config should reject local_dev storage'); +assert.match(unsafeWorker.output, /Invalid production worker configuration/, 'worker config should explain production config failure'); +assert.match(unsafeWorker.output, /STORAGE_DEFAULT_PROVIDER=local_dev/, 'worker config should name unsafe storage provider'); + +const unsafeWorkerScanner = runImport(workerConfigUrl, { + ...safeBaseEnv, + WORKER_ASSET_SECURITY_SCANNER: 'metadata_rules', +}); +assert.notEqual(unsafeWorkerScanner.status, 0, 'production worker config should require external scanner'); +assert.match(unsafeWorkerScanner.output, /WORKER_ASSET_SECURITY_SCANNER must include http/, 'worker config should require http scanner'); + +const safeWorker = runImport(workerConfigUrl, safeBaseEnv); +assert.equal(safeWorker.status, 0, `safe production worker config should load: ${safeWorker.output}`); + +console.log('[PASS] production config fail-fast');