forked from wangziqi/gongxue-base
fix: add taro h5 release guardrails
This commit is contained in:
13
README.md
13
README.md
@@ -52,7 +52,7 @@
|
||||
| 公共题库商业化 | √ 可联调 | 平台题库授权、单地区/全国 SaaS 范围、租户采纳、手动/自动同步、冲突处理和通知已完成基础闭环 |
|
||||
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 |
|
||||
| PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 |
|
||||
| Taro H5 三端前端 | √ 第一版可构建 | 学生端、租户后台、平台后台均有真实 API 页面;后续继续补小程序兼容、视觉精修、状态管理和端到端测试 |
|
||||
| Taro H5 三端前端 | √ 第一版可构建 | 学生端、租户后台、平台后台均有真实 API 页面;已补 H5 `index.html` 模板和发布产物守卫;后续继续补小程序兼容、视觉精修、状态管理、包体优化和端到端测试 |
|
||||
| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发混合读写压测;已补 PostgreSQL 调参证据脚本和上线前角色旅程烟测;上云后仍需执行生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优和上线证据门禁。`@codex-security` 只有在 Codex 环境暴露扫描工具时才能标记完成 |
|
||||
|
||||
更完整的进度看这些文档:
|
||||
@@ -557,6 +557,17 @@ npm run smoke:launch-persona
|
||||
|
||||
`smoke:launch-persona` 会从普通学生、租户管理员、平台管理员三个视角调用真实 API,覆盖 SVIP 后刷题、收藏、错题复习入口、租户数据看板/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试记录,生产只建议在灰度或演练租户运行。
|
||||
|
||||
Taro H5 发布产物守卫:
|
||||
|
||||
```powershell
|
||||
npm run build:taro:h5:student
|
||||
npm run build:taro:h5:tenant
|
||||
npm run build:taro:h5:platform
|
||||
node scripts\taro-h5-release-guardrails-test.js --require-dist
|
||||
```
|
||||
|
||||
该守卫会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
||||
|
||||
最近一次本地真实迁移库数据规模约为 74,117 题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、3,690 个用户、113,810 条答题记录、38,207 条错题和 458 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。
|
||||
|
||||
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 |
|
||||
|
||||
@@ -21,6 +21,14 @@ apps/taro/dist/h5-platform-admin
|
||||
|
||||
可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`。
|
||||
|
||||
H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前运行:
|
||||
|
||||
```bash
|
||||
node ../../scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||
```
|
||||
|
||||
该守卫会扫描三套 H5 产物和运行配置边界,阻断旧 PocketBase 引用、`x-user-id`、平台本地 key、数据库连接串和服务端密钥形态进入前端产物。正式部署时仍需要在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
||||
|
||||
## 当前学生端页面
|
||||
|
||||
已接入第一批真实后端 API:
|
||||
@@ -115,3 +123,5 @@ platform-admin.example.com -> apps/taro/dist/h5-platform-admin
|
||||
```
|
||||
|
||||
API 使用 `TARO_APP_API_BASE_URL` 指向统一后端。学生端通过域名解析租户;小程序或预览环境可用 `TARO_APP_TENANT_CODE` 指定租户。
|
||||
|
||||
更完整的部署、安全头、CORS、CSP 和运行时配置说明见 `docs/refactor/taro-h5-deployment.md`。
|
||||
|
||||
19
apps/taro/src/index.html
Normal file
19
apps/taro/src/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no,viewport-fit=cover">
|
||||
<meta name="format-detection" content="telephone=no,address=no,email=no">
|
||||
<meta name="renderer" content="webkit">
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<title>工学题库</title>
|
||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -209,7 +209,7 @@
|
||||
- 旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro 小程序/H5 交互补齐时只作为交互、页面状态和微信平台能力参考,不继承旧直连 PocketBase 或旧鉴权逻辑。
|
||||
- 租户通过域名、小程序配置或启动参数解析。
|
||||
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细第一版、男女预设头像选择 UI、题干/选项/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台员工列表/创建/编辑/禁用恢复、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、学习督导自动化/每日规则保存、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、状态管理、更细数据范围 UI、督导触达联动和小程序兼容验证。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;已补 `apps/taro/src/index.html` 模板,三套 H5 构建都会生成 `index.html`;已补 `scripts/taro-h5-release-guardrails-test.js` 扫描源码、发布产物和 runtime-config 边界,阻断旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串和服务端密钥形态进入前端产物。学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细第一版、男女预设头像选择 UI、题干/选项/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台员工列表/创建/编辑/禁用恢复、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、学习督导自动化/每日规则保存、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、状态管理、首屏包体优化、更细数据范围 UI、督导触达联动和小程序兼容验证。
|
||||
|
||||
### 第一批页面
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、男女预设头像选择、收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、小程序支付容器、分享场景和状态管理。前端不得实现头像上传、头像裁剪或第三方头像同步。
|
||||
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 继续做人工复核和抽样验收;当前 dry-run/导入/校验链路已跑通,下一步重点是 7 个已支付缺用户订单、22 个待复核手册章节、2533 道旧分类缺失题目和 5298 条引用已删除题目的答题记录的运营处理结论。导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。
|
||||
6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`,执行 `node scripts/taro-h5-release-guardrails-test.js --require-dist`、人工打开学生端/租户后台/平台后台域名,确认入口、租户解析、登录态和 `Authorization + x-tenant-id` 请求正常。当前构建仍有 webpack 体积 warning,后续做首屏拆包、按入口拆页面和 Supabase client 引入优化。
|
||||
6. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 继续做人工复核和抽样验收;当前 dry-run/导入/校验链路已跑通,下一步重点是 7 个已支付缺用户订单、22 个待复核手册章节、2533 道旧分类缺失题目和 5298 条引用已删除题目的答题记录的运营处理结论。导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。
|
||||
7. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
8. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Taro H5 三入口部署说明
|
||||
|
||||
更新时间:2026-06-30
|
||||
更新时间:2026-07-01
|
||||
|
||||
当前 `apps/taro` 采用一个 Taro 4 React 工程、三套 H5 产物的方式交付:
|
||||
|
||||
@@ -26,6 +26,8 @@ apps/taro/dist/h5-tenant-admin
|
||||
apps/taro/dist/h5-platform-admin
|
||||
```
|
||||
|
||||
注意:Taro H5 入口依赖 `apps/taro/src/index.html` 模板生成 `index.html`。如果构建产物目录里只有 `js/css/assets` 而没有 `index.html`,不要发布;重新构建并运行发布守卫脚本。
|
||||
|
||||
推荐部署:
|
||||
|
||||
| 域名 | 静态目录 | 说明 |
|
||||
@@ -186,6 +188,14 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务
|
||||
npm run check:taro
|
||||
```
|
||||
|
||||
构建后再运行 H5 发布守卫脚本:
|
||||
|
||||
```bash
|
||||
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||
```
|
||||
|
||||
该脚本会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning;正式发布前必须在每个 H5 目录根部补齐该文件。
|
||||
|
||||
7. 收集生产上线证据并运行 launch gate:
|
||||
|
||||
```bash
|
||||
@@ -195,7 +205,7 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务
|
||||
|
||||
证据文件只保存命令摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。真实 artifact 建议放在 `docs/refactor/launch-artifacts/`,该目录不入 Git。
|
||||
|
||||
8. 打开三个域名,确认启动页能解析租户,登录后接口请求使用 `Authorization` 和正确的 `x-tenant-id`。
|
||||
8. 打开三个域名,确认 `index.html` 正常加载,启动页能解析租户,登录后接口请求使用 `Authorization` 和正确的 `x-tenant-id`。
|
||||
|
||||
## 安全审计边界
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Web 版上线前验收清单
|
||||
|
||||
更新时间:2026-06-30
|
||||
更新时间:2026-07-01
|
||||
|
||||
这份清单用于先上线 H5 Web 题库。Taro 仍然是前端工程,后端以 Supabase Auth/JWT、PostgreSQL/RLS、`apps/api`、worker 为主。前端视觉和交互可以参考 `F:\project\参考\旧题库小程序前端文件` 和旧 Web 版,但不能继承旧 PocketBase 直连、旧鉴权或旧字段模型。
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- 私有图片、PDF、视频只消费后端短签名和 `content_assets` 权限结果,不能拼对象存储 URL。
|
||||
- 学生头像只做男女预设,不做上传、裁剪或第三方头像同步。
|
||||
- 排行榜默认不请求、不展示;只有租户购买/开启活动且完成专项压测后再接独立页面。
|
||||
- H5 构建目录必须包含 `index.html`、`js/`、`css/`,并且每个上线目录根部必须由部署方放置对应 `runtime-config.json`。
|
||||
|
||||
## 学生端验收旅程
|
||||
|
||||
@@ -80,9 +81,12 @@ npm run audit:runtime
|
||||
npm run check:api
|
||||
npm run check:worker
|
||||
npm run check:taro
|
||||
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||
npm run smoke:launch-persona
|
||||
```
|
||||
|
||||
`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。
|
||||
|
||||
如 `@codex-security` 插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。
|
||||
|
||||
生产 API 推荐:
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
||||
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
|
||||
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
||||
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
|
||||
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
|
||||
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
|
||||
"test:launch-gate": "node scripts/production-launch-gate-test.js",
|
||||
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
|
||||
|
||||
295
scripts/taro-h5-release-guardrails-test.js
Normal file
295
scripts/taro-h5-release-guardrails-test.js
Normal file
@@ -0,0 +1,295 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const taroRoot = path.join(repoRoot, 'apps', 'taro');
|
||||
const sourceRoot = path.join(taroRoot, 'src');
|
||||
const deployRoot = path.join(taroRoot, 'deploy');
|
||||
const distRoot = path.join(taroRoot, 'dist');
|
||||
|
||||
const portals = [
|
||||
{ portal: 'student', dist: 'h5-student', runtimeExample: 'h5-student.runtime-config.example.json' },
|
||||
{ portal: 'tenant-admin', dist: 'h5-tenant-admin', runtimeExample: 'h5-tenant-admin.runtime-config.example.json' },
|
||||
{ portal: 'platform-admin', dist: 'h5-platform-admin', runtimeExample: 'h5-platform-admin.runtime-config.example.json' },
|
||||
];
|
||||
|
||||
const allowedRuntimeConfigKeys = new Set([
|
||||
'portal',
|
||||
'apiBaseUrl',
|
||||
'supabaseUrl',
|
||||
'supabasePublishableKey',
|
||||
'tenantCode',
|
||||
'TARO_APP_PORTAL',
|
||||
'TARO_APP_API_BASE_URL',
|
||||
'TARO_APP_SUPABASE_URL',
|
||||
'TARO_APP_SUPABASE_PUBLISHABLE_KEY',
|
||||
'TARO_APP_TENANT_CODE',
|
||||
]);
|
||||
|
||||
const forbiddenRuntimeConfigKeys = [
|
||||
'SUPABASE_SERVICE_ROLE_KEY',
|
||||
'SUPABASE_SECRET_KEY',
|
||||
'DATABASE_URL',
|
||||
'ALIYUN_OSS_ACCESS_KEY_SECRET',
|
||||
'TENCENT_COS_SECRET_KEY',
|
||||
'WECHAT_PAY_PRIVATE_KEY',
|
||||
'ALIPAY_APP_PRIVATE_KEY',
|
||||
'AUTH_SESSION_SECRET',
|
||||
'PLATFORM_ADMIN_API_KEY',
|
||||
];
|
||||
|
||||
const sourceForbiddenPatterns = [
|
||||
{ id: 'legacy-user-header', pattern: /\bx-user-id\b/i, message: 'Taro source must not use x-user-id. Identity comes from Supabase JWT/session.' },
|
||||
{ id: 'platform-admin-key', pattern: /\bx-platform-admin-key\b/i, message: 'Taro source must not use platform admin key headers.' },
|
||||
{ id: 'legacy-pocketbase', pattern: /\bpocketbase\b/i, message: 'Taro source must not depend on PocketBase.' },
|
||||
{ id: 'postgres-url', pattern: /postgres(?:ql)?:\/\//i, message: 'Taro source must not contain database connection strings.' },
|
||||
{ id: 'private-key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, message: 'Taro source must not contain private keys.' },
|
||||
{ id: 'cloud-access-key', pattern: /\bAKIA[0-9A-Z]{16}\b/, message: 'Taro source must not contain cloud access keys.' },
|
||||
{ id: 'provider-secret', pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/, message: 'Taro source must not contain provider secret keys.' },
|
||||
];
|
||||
|
||||
const artifactForbiddenPatterns = [
|
||||
{ id: 'legacy-user-header', pattern: /\bx-user-id\b/i, message: 'H5 artifact must not contain x-user-id.' },
|
||||
{ id: 'platform-admin-key', pattern: /\bx-platform-admin-key\b/i, message: 'H5 artifact must not contain platform admin key headers.' },
|
||||
{ id: 'local-platform-admin-key', pattern: /\blocal-platform-admin-key\b/i, message: 'H5 artifact must not contain local platform admin key fallback.' },
|
||||
{ id: 'legacy-pocketbase', pattern: /\bpocketbase\b/i, message: 'H5 artifact must not contain PocketBase references.' },
|
||||
{ id: 'postgres-url', pattern: /postgres(?:ql)?:\/\//i, message: 'H5 artifact must not contain database connection strings.' },
|
||||
{ id: 'private-key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i, message: 'H5 artifact must not contain private keys.' },
|
||||
{ id: 'cloud-access-key', pattern: /\bAKIA[0-9A-Z]{16}\b/, message: 'H5 artifact must not contain cloud access keys.' },
|
||||
{ id: 'provider-secret', pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/, message: 'H5 artifact must not contain provider secret keys.' },
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
return {
|
||||
checkDist: argv.includes('--check-dist') || argv.includes('--require-dist'),
|
||||
requireDist: argv.includes('--require-dist'),
|
||||
requireRuntimeConfig: argv.includes('--require-runtime-config'),
|
||||
json: argv.includes('--json'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSlashes(value) {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function relative(filePath) {
|
||||
return normalizeSlashes(path.relative(repoRoot, filePath));
|
||||
}
|
||||
|
||||
function readText(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(readText(filePath));
|
||||
}
|
||||
|
||||
function walkFiles(dir, extensions = null) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const result = [];
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...walkFiles(entryPath, extensions));
|
||||
continue;
|
||||
}
|
||||
if (!extensions || extensions.some(extension => entry.name.endsWith(extension))) result.push(entryPath);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createCollector() {
|
||||
const checks = [];
|
||||
function push(status, id, message, details = {}) {
|
||||
checks.push({ status, id, message, details });
|
||||
}
|
||||
return {
|
||||
checks,
|
||||
pass: (id, message, details) => push('pass', id, message, details),
|
||||
warn: (id, message, details) => push('warn', id, message, details),
|
||||
fail: (id, message, details) => push('fail', id, message, details),
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(checks) {
|
||||
return checks.reduce(
|
||||
(summary, item) => {
|
||||
summary[item.status] += 1;
|
||||
return summary;
|
||||
},
|
||||
{ fail: 0, warn: 0, pass: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function looksSecretish(value) {
|
||||
const text = String(value || '');
|
||||
return /postgres(?:ql)?:\/\//i.test(text)
|
||||
|| /-----BEGIN [A-Z ]*PRIVATE KEY-----/i.test(text)
|
||||
|| /\bAKIA[0-9A-Z]{16}\b/.test(text)
|
||||
|| /\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/.test(text);
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(filePath, expectedPortal, options, collector) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
if (options.required) collector.fail(`runtime.${expectedPortal}.exists`, 'runtime-config.json is required for this check', { file: relative(filePath) });
|
||||
else collector.warn(`runtime.${expectedPortal}.exists`, 'runtime-config.json is not present; deploy must provide it beside index.html', { file: relative(filePath) });
|
||||
return;
|
||||
}
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = readJson(filePath);
|
||||
} catch (error) {
|
||||
collector.fail(`runtime.${expectedPortal}.json`, 'runtime config must be valid JSON', { file: relative(filePath), error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Object.keys(config);
|
||||
const unknownKeys = keys.filter(key => !allowedRuntimeConfigKeys.has(key));
|
||||
const forbiddenKeys = keys.filter(key => forbiddenRuntimeConfigKeys.includes(key));
|
||||
if (unknownKeys.length) collector.fail(`runtime.${expectedPortal}.keys`, 'runtime config has unknown keys', { file: relative(filePath), unknownKeys });
|
||||
else collector.pass(`runtime.${expectedPortal}.keys`, 'runtime config uses only approved public keys', { file: relative(filePath) });
|
||||
if (forbiddenKeys.length) collector.fail(`runtime.${expectedPortal}.secrets`, 'runtime config includes forbidden server secret keys', { file: relative(filePath), forbiddenKeys });
|
||||
else collector.pass(`runtime.${expectedPortal}.secrets`, 'runtime config does not include forbidden server secret keys', { file: relative(filePath) });
|
||||
|
||||
const portal = config.portal || config.TARO_APP_PORTAL;
|
||||
if (portal !== expectedPortal) collector.fail(`runtime.${expectedPortal}.portal`, 'runtime config portal does not match deployment target', { file: relative(filePath), portal, expectedPortal });
|
||||
else collector.pass(`runtime.${expectedPortal}.portal`, 'runtime config portal matches deployment target');
|
||||
|
||||
const apiBaseUrl = String(config.apiBaseUrl || config.TARO_APP_API_BASE_URL || '');
|
||||
const supabaseUrl = String(config.supabaseUrl || config.TARO_APP_SUPABASE_URL || '');
|
||||
const publishableKey = String(config.supabasePublishableKey || config.TARO_APP_SUPABASE_PUBLISHABLE_KEY || '');
|
||||
if (!apiBaseUrl.startsWith('https://')) collector.fail(`runtime.${expectedPortal}.api_https`, 'apiBaseUrl must be HTTPS in release config', { file: relative(filePath), apiBaseUrl });
|
||||
else collector.pass(`runtime.${expectedPortal}.api_https`, 'apiBaseUrl is HTTPS');
|
||||
if (!supabaseUrl.startsWith('https://')) collector.fail(`runtime.${expectedPortal}.supabase_https`, 'supabaseUrl must be HTTPS in release config', { file: relative(filePath), supabaseUrl });
|
||||
else collector.pass(`runtime.${expectedPortal}.supabase_https`, 'supabaseUrl is HTTPS');
|
||||
if (!publishableKey) collector.fail(`runtime.${expectedPortal}.publishable_key`, 'supabasePublishableKey is required', { file: relative(filePath) });
|
||||
else if (options.allowPlaceholderKey && publishableKey === 'replace-with-supabase-publishable-key') collector.pass(`runtime.${expectedPortal}.publishable_key`, 'runtime example uses explicit publishable-key placeholder');
|
||||
else if (publishableKey === 'replace-with-supabase-publishable-key') collector.fail(`runtime.${expectedPortal}.publishable_key`, 'production runtime config still contains placeholder publishable key', { file: relative(filePath) });
|
||||
else collector.pass(`runtime.${expectedPortal}.publishable_key`, 'supabase publishable key is present');
|
||||
|
||||
const secretValues = Object.entries(config).filter(([, value]) => looksSecretish(value));
|
||||
if (secretValues.length) {
|
||||
collector.fail(`runtime.${expectedPortal}.secret_values`, 'runtime config contains secret-looking values', {
|
||||
file: relative(filePath),
|
||||
keys: secretValues.map(([key]) => key),
|
||||
});
|
||||
} else {
|
||||
collector.pass(`runtime.${expectedPortal}.secret_values`, 'runtime config values do not look like server secrets');
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeExamples(collector) {
|
||||
for (const item of portals) {
|
||||
validateRuntimeConfig(path.join(deployRoot, item.runtimeExample), item.portal, { required: true, allowPlaceholderKey: true }, collector);
|
||||
}
|
||||
}
|
||||
|
||||
function validateSourceRequests(collector) {
|
||||
const files = walkFiles(sourceRoot, ['.ts', '.tsx']);
|
||||
const directRequestViolations = [];
|
||||
const directFetchViolations = [];
|
||||
const headerOverrideViolations = [];
|
||||
const forbiddenViolations = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const rel = relative(filePath);
|
||||
const text = readText(filePath);
|
||||
if (/Taro\.request\s*\(/.test(text) && rel !== 'apps/taro/src/services/api.ts') directRequestViolations.push(rel);
|
||||
if (/window\.fetch\s*\(/.test(text) && rel !== 'apps/taro/src/env.ts') directFetchViolations.push(rel);
|
||||
if (/['"`]authorization['"`]\s*:|['"`]Authorization['"`]\s*:|['"`]x-tenant-id['"`]\s*:|['"`]X-Tenant-Id['"`]\s*:/i.test(text) && rel !== 'apps/taro/src/services/api-auth.ts') {
|
||||
headerOverrideViolations.push(rel);
|
||||
}
|
||||
for (const rule of sourceForbiddenPatterns) {
|
||||
if (rule.pattern.test(text)) forbiddenViolations.push({ file: rel, rule: rule.id, message: rule.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (directRequestViolations.length) collector.fail('source.direct_taro_request', 'Only apps/taro/src/services/api.ts may call Taro.request directly', { files: directRequestViolations });
|
||||
else collector.pass('source.direct_taro_request', 'Taro.request is centralized in the API client');
|
||||
if (directFetchViolations.length) collector.fail('source.direct_fetch', 'Only env.ts may use window.fetch for runtime-config.json', { files: directFetchViolations });
|
||||
else collector.pass('source.direct_fetch', 'window.fetch usage is limited to runtime config loading');
|
||||
if (headerOverrideViolations.length) collector.fail('source.reserved_headers', 'Page/service code must not hand-build Authorization or x-tenant-id headers outside api-auth.ts', { files: headerOverrideViolations });
|
||||
else collector.pass('source.reserved_headers', 'Reserved API headers are owned by api-auth.ts');
|
||||
if (forbiddenViolations.length) collector.fail('source.forbidden_patterns', 'Taro source contains forbidden release patterns', { violations: forbiddenViolations });
|
||||
else collector.pass('source.forbidden_patterns', 'Taro source has no legacy auth, PocketBase or secret-looking patterns');
|
||||
}
|
||||
|
||||
function validateDistArtifact(portal, distName, options, collector) {
|
||||
const dir = path.join(distRoot, distName);
|
||||
if (!fs.existsSync(dir)) {
|
||||
if (options.requireDist) collector.fail(`dist.${portal}.exists`, 'H5 dist directory is required for release guardrails', { dir: relative(dir) });
|
||||
else collector.warn(`dist.${portal}.exists`, 'H5 dist directory not found; build before final deployment validation', { dir: relative(dir) });
|
||||
return;
|
||||
}
|
||||
|
||||
collector.pass(`dist.${portal}.exists`, 'H5 dist directory exists', { dir: relative(dir) });
|
||||
const indexPath = path.join(dir, 'index.html');
|
||||
if (!fs.existsSync(indexPath)) collector.fail(`dist.${portal}.index`, 'H5 dist index.html is missing', { file: relative(indexPath) });
|
||||
else {
|
||||
const indexHtml = readText(indexPath);
|
||||
if (!/<script\b/i.test(indexHtml)) collector.fail(`dist.${portal}.index`, 'H5 index.html has no script tags', { file: relative(indexPath) });
|
||||
else collector.pass(`dist.${portal}.index`, 'H5 index.html is present and loads scripts', { file: relative(indexPath) });
|
||||
}
|
||||
|
||||
const textFiles = walkFiles(dir, ['.html', '.js', '.css', '.json', '.txt']).filter(filePath => !filePath.endsWith('.LICENSE.txt'));
|
||||
const artifactViolations = [];
|
||||
for (const filePath of textFiles) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > 5 * 1024 * 1024) continue;
|
||||
const text = readText(filePath);
|
||||
for (const rule of artifactForbiddenPatterns) {
|
||||
if (rule.pattern.test(text)) artifactViolations.push({ file: relative(filePath), rule: rule.id, message: rule.message });
|
||||
}
|
||||
}
|
||||
if (artifactViolations.length) collector.fail(`dist.${portal}.forbidden_patterns`, 'H5 artifact contains forbidden release patterns', { violations: artifactViolations.slice(0, 20) });
|
||||
else collector.pass(`dist.${portal}.forbidden_patterns`, 'H5 artifact has no legacy auth, PocketBase or secret-looking patterns');
|
||||
|
||||
validateRuntimeConfig(path.join(dir, 'runtime-config.json'), portal, {
|
||||
required: options.requireRuntimeConfig,
|
||||
allowPlaceholderKey: false,
|
||||
}, collector);
|
||||
}
|
||||
|
||||
function validateDistArtifacts(options, collector) {
|
||||
if (!options.checkDist) {
|
||||
collector.pass('dist.check_skipped', 'H5 dist artifact scan is skipped; pass --check-dist or --require-dist for release artifact validation');
|
||||
return;
|
||||
}
|
||||
for (const item of portals) validateDistArtifact(item.portal, item.dist, options, collector);
|
||||
}
|
||||
|
||||
function validateBuildScripts(collector) {
|
||||
const packageJson = readJson(path.join(taroRoot, 'package.json'));
|
||||
const scripts = packageJson.scripts || {};
|
||||
const requiredScripts = ['build:h5:student', 'build:h5:tenant', 'build:h5:platform'];
|
||||
const missing = requiredScripts.filter(name => !scripts[name]);
|
||||
if (missing.length) collector.fail('package.h5_build_scripts', 'Taro package is missing portal-specific H5 build scripts', { missing });
|
||||
else collector.pass('package.h5_build_scripts', 'Taro has portal-specific H5 build scripts');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const collector = createCollector();
|
||||
validateBuildScripts(collector);
|
||||
validateRuntimeExamples(collector);
|
||||
validateSourceRequests(collector);
|
||||
validateDistArtifacts(options, collector);
|
||||
|
||||
const summary = summarize(collector.checks);
|
||||
const payload = { summary, checks: collector.checks };
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
} else {
|
||||
console.log(`Taro H5 release guardrails: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
|
||||
for (const item of collector.checks) {
|
||||
const marker = item.status === 'fail' ? 'FAIL' : item.status === 'warn' ? 'WARN' : 'PASS';
|
||||
console.log(`[${marker}] ${item.id}: ${item.message}`);
|
||||
}
|
||||
}
|
||||
if (summary.fail > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user