diff --git a/.env.example b/.env.example index a788169f..055d8a35 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,36 @@ NODE_ENV=development # API 服务端口 PORT=8787 +API_HEADERS_TIMEOUT_MS=15000 +API_REQUEST_TIMEOUT_MS=120000 +API_KEEP_ALIVE_TIMEOUT_MS=5000 +API_SHUTDOWN_GRACE_PERIOD_MS=30000 +API_MAX_REQUESTS_PER_SOCKET=1000 # Supabase/PostgreSQL 重构 API DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres +# 本地 postgres 可留空;生产 API 必须为 tiku_api,worker 必须为 tiku_worker。 +DB_EXPECTED_RUNTIME_ROLE= +# 不要在 .env 中持久化 SMOKE_SEED_CONFIRM;破坏性测试只通过受控 npm script/CLI 临时确认。 +# API/worker 各自进程使用。生产建议 API 10-20、worker 4-8,并结合 PgBouncer/连接池总量核算。 +DB_POOL_MAX=10 +DB_CONNECTION_TIMEOUT_MS=5000 +DB_QUERY_TIMEOUT_MS=35000 +DB_STATEMENT_TIMEOUT_MS=30000 +DB_LOCK_TIMEOUT_MS=5000 +DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=30000 +DB_IDLE_TIMEOUT_MS=30000 +DB_POOL_MAX_USES=7500 +DB_POOL_MAX_LIFETIME_SECONDS=1800 +DB_APPLICATION_NAME=tiku-local DEFAULT_TENANT_SLUG=master -# 生产必须只写真实 HTTPS 域名,不能包含 * +# 生产只列中央平台/运维的固定 HTTPS Origin,不能包含 *。 +# 各租户 H5 Origin 从 active tenant_domains + active tenants 动态准入,不要把数百个租户域名展开到此列表。 CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173 +CORS_TENANT_DOMAINS_ENABLED=false +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 MAX_JSON_BODY_BYTES=1048576 MAX_IMPORT_JSON_BODY_BYTES=10485760 @@ -32,6 +56,11 @@ AUTH_JWT_SECRET=development-jwt-secret-change-me AUTH_JWT_JWKS_URL= AUTH_CODE_TTL_SECONDS=300 AUTH_SMS_COOLDOWN_SECONDS=60 +AUTH_SMS_TENANT_DAILY_LIMIT=20000 +AUTH_SMS_PHONE_DAILY_LIMIT=10 +# 校园/运营商 NAT 会共享 IP,IP 配额应宽于手机号/设备配额。 +AUTH_SMS_IP_HOURLY_LIMIT=120 +AUTH_SMS_DEVICE_HOURLY_LIMIT=10 AUTH_SESSION_TTL_SECONDS=604800 # 远程 Auth/JWKS 验收脚本配置。只在预生产/生产验收命令行临时设置真实 access token, @@ -113,6 +142,8 @@ WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false # Worker 配置:大批量内容导入 WORKER_IMPORT_BATCH_SIZE=5 WORKER_IMPORT_ID=imports-1 +WORKER_IMPORT_LEASE_SECONDS=120 +WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000 WORKER_IMPORT_BACKOFF_SECONDS=30,120,600,1800 # Worker 配置:公共题库自动同步。冲突会保留租户自改题目并等待后台处理。 diff --git a/.gitignore b/.gitignore index acd974fc..70d8effb 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,9 @@ whisper_models/ # ✅ 部署脚本本地缓存(记录 package-lock 哈希) .deploy-cache/ +/deploy.env +/.deploy.env +/shared/ # ✅ PocketBase 运行期产出(不入 git) pb_data/ diff --git a/README.md b/README.md index fad184c2..58c07181 100644 --- a/README.md +++ b/README.md @@ -1,438 +1,109 @@ # tiku-supabase -这是题库项目从 PocketBase/SQLite 重构到 Supabase/PostgreSQL 的新后端仓库。 +面向数百租户、单租户十万级学生的 SaaS 题库工程。仓库采用 Supabase/PostgreSQL + Node.js API/Worker + Taro 4 React,学生端复用 H5、微信小程序和后续 App 的业务层;租户后台与平台后台首发为响应式 H5。 -当前仓库重点承载“商用 SaaS 版本”的新架构代码,包括多租户数据库、业务 API、PocketBase 数据导入器、本地验证脚本和重构进度文档。旧 PocketBase/React 项目仍保留在原工作区作为功能参照和迁移来源,但这个 Git 仓库不打算作为旧项目全量镜像。 +当前仓库是旧 PocketBase/SQLite 题库的重构目标,不是旧项目的镜像。旧代码和旧数据只作为迁移与功能对照来源。 ## 当前状态 -更新时间:2026-07-02 +生产地基候选已在本地冻结验证,前端可以基于 `apps/taro` 开始正式设计与实现。它还不是“可直接全量放量”的生产版本:目标服务器上的真实配置、数据库迁移、Provider、首个平台超管、备份恢复、真实数据抽样、容量证据和生产上线证据仍必须完成。 -## 接管快照 +2026-07-12 基线结果: -截至 2026-07-02,当前 `main` 已同步到 Gitea,最新前端交付提交为 `f54421f test: align Taro visual guardrails with legacy UI`,其前一个核心实现提交为 `aa71251 fix: align Taro H5 with legacy question bank UI`。另一台工作机接管时,优先从 Gitea 拉取 `main`,不要从服务器的构建产物反向覆盖源码。 +| 范围 | 结果 | +| --- | --- | +| clean-room PostgreSQL | 78/78 migrations;public RLS 140/140;tenant RLS 134/134 | +| 租户完整性 | 189/189 租户外键;单租户 100,000 学生基准通过 | +| 后端质量 | API、Worker、Importer、Taro TypeScript 通过 | +| 安全 | production runtime audit 0;仓库扫描 0 findings | +| API 容器 | Node 20.20.2/Alpine 3.23 固定摘要;非 root;约 53 MB | +| 三端 H5 | production 构建通过;静态烟测 25/25;Chrome 交互烟测 33/33 | +| 响应式 | 桌面与 `390x844` 移动探针通过;无运行时异常和页面级横向溢出 | +| 上线门禁 | 缺少真实生产证据时 fail closed,当前不会误放行 | -本轮已完成的关键变化: +权威基线: -- 学生 H5 已按旧题库主视觉重做:浅灰网格背景、旧蓝色主色、PC 侧边栏、移动端底部导航、蓝色学习 banner、白色数据卡、彩色功能模块和紧凑列表。 -- 租户后台和平台后台已按旧后台工作台风格收敛:白/灰高密度界面、侧边栏/移动导航、深色激活态、紧凑表格/表单/行布局。 -- 受保护页面已补路由守卫:Auth/角色校验期间不渲染业务内容;静态 H5 可以加载入口壳,但不应在未鉴权时暴露可用业务数据。 -- 三端 H5 首屏入口按构建目标区分:`student`、`tenant-admin`、`platform-admin`。 -- 视觉守卫已从通用圆角/渐变规则调整为旧题库 UI 规则,防止后续改回营销页式大卡片风格。 +- [生产地基基线](docs/refactor/production-foundation-baseline-20260712.md) +- [clean-room 迁移审计](docs/refactor/clean-room-migration-audit-20260712.md) +- [Taro H5 浏览器 QA](docs/refactor/taro-h5-browser-qa-20260712.md) +- [Taro 供应链基线](docs/refactor/taro-supply-chain-baseline-20260712.md) +- [服务器部署手册](scripts/deploy/README.md) -本地已通过的验证命令: +## 系统边界 -```bash -npm --workspace @tiku-saas/taro run check -node scripts/taro-route-contract-test.js -npm run guard:taro:visual -node scripts/taro-visual-guardrails-test.js -npm run build:taro:h5:student -npm run build:taro:h5:tenant -npm run build:taro:h5:platform -npm run smoke:taro:h5 -npm run smoke:taro:h5:interaction -node scripts/taro-h5-release-guardrails-test.js +```text +学生 H5 / 微信小程序 / 后续 App +租户管理 H5 +平台管理 H5 + | + v +apps/api 统一鉴权、租户隔离和业务命令 + | + +---- PostgreSQL / Supabase Auth / Storage + | + +---- apps/worker 显式队列任务和定时任务 ``` -云服务器当前接管点: +- 前端业务数据默认只访问 `apps/api`,不得在页面中直写 Supabase 业务表。 +- `x-tenant-id` 只提供租户上下文,不是身份凭证。 +- API 与 Worker 使用独立 PostgreSQL 角色 `tiku_api`、`tiku_worker`。 +- 两个运行角色的 `BYPASSRLS` 是受控后端边界,API 路由必须显式执行身份、租户和权限过滤;浏览器、Taro 和 Data API 不得获得这两个凭据。 +- 微信小程序只发布学生端。批量导入、复杂财务和平台运营不强行迁入小程序。 -- 服务器仓库:`/opt/tiku-saas/repo` -- H5 发布目录:`/srv/tiku-saas/www/student`、`/srv/tiku-saas/www/tenant-admin`、`/srv/tiku-saas/www/platform-admin` -- 发布脚本:`/opt/tiku-saas/bin/deploy.sh` -- 线上 Supabase、自研 API、Nginx、runtime-config 已能基本联通;`https://api.tjszsb.com/api/tenant/resolve?host=app.tjszsb.com` 已返回 `master` 租户。 -- Gitea SSH 端口是 `2222`,服务器 `deploy` 用户拉取应使用 `ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git` 或在 SSH config 中显式配置 `Port 2222`。 -- 2026-07-02 服务器手动执行 `sudo -u deploy npm run build:taro:h5:student` 时出现 Taro 构建长时间无输出、CPU 持续接近满载、`dist/h5-student` 未更新的情况。接管后建议先中止残留构建进程,清理目标目录,再用 `CI=1 NODE_OPTIONS="--max-old-space-size=4096"` 重新构建三端,详见 `scripts/deploy/README.md` 的“服务器接管和故障恢复”。 - -## 最新交付快照 - -截至 2026-07-02,当前 `main` 已推送到 Gitea,可进入云服务器部署和生产测试阶段。这里的“生产测试”指在真实云服务器、真实域名、真实对象存储和真实 provider 配置下做灰度/演练验证;正式对 C 端或合作商放量前,仍必须通过本文的生产上线证据门禁。 - -- 后端核心业务已经覆盖旧题库主链路:刷题、背单词、知识手册、分数线、错题本、收藏夹、资料下载、题目视频、会员/订单/优惠券/激活码、个人中心、站内通知、勋章、租户后台、平台 SaaS 管理、公共题库授权/采纳/同步、CRM/销售/代理/分佣、财务对账和 worker 任务。 -- 多租户安全底座已经具备商用联调条件:平台/租户/学生三类身份边界、Supabase Auth JWT、迁移期 `tk_` session、RLS 测试、租户角色模板、成员权限、班级/教师/学生范围、字段脱敏、审计日志、私有资源短签名、导入/导出 job 审计均已落地。 -- H5/Taro 第一版已经具备前后端联调入口:学生端、租户后台、平台后台三套 H5 构建入口、统一 API client、Supabase client 初始化、运行时公开配置、H5 静态烟测和 32 项真实浏览器交互 smoke 已接入。 -- `smoke:launch-persona` 已升级为默认 `LAUNCH_SMOKE_AUTH_MODE=app_session`,可用 `--write` / `--write-md` 生成稳定上线证据文件,覆盖普通学生、租户管理员、平台管理员三类真实 API 角色旅程和越权拒绝。 -- 本地真实迁移库最新规模约为 19 个租户、74,131 道题、3,722 个平台用户、3,712 个学生资料、81,160 个练习 session、327,925 条答题记录、38,207 条错题、1,501 个内容资源和 474 条权益。 -- 2026-07-01 08:41 本地真实数据压测通过:30 并发只读 37,520 请求、0 错误、310.34 req/s、P95 251.08ms;50 并发混合读写 25,047 请求、0 错误、410.49 req/s、P95 237.41ms;100 并发混合读写 24,116 请求、0 错误、393.56 req/s、P95 437.37ms;150 并发混合读写 22,864 请求、0 错误、371.29 req/s、P95 633.46ms。该结果只能作为本机真实数据观察,正式容量必须在目标 4 核 16G 云服务器复跑。 - -目前已经完成并在本地验证通过的内容: - -- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 -- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、跟进效果统计、学习督导自动化预览/生成、督导规则模板、学生批量 CRM 推送、审计日志。 -- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 -- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口(租户默认关闭)、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。 -- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、平台用量自动采集 worker、用量记录、SaaS 套餐额度判定、用量超额账单候选预览/dry-run/生成、自动开票 worker 和失败审计告警、公共题库授权。 -- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细;worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status` 按 `platform:question_bank:ops` 查看跨租户同步运营摘要。 -- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 -- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送。 -- `apps/worker` 后台任务进程:CRM webhook 队列消费、`lead.created` 客资事件、`student.crm_push` 学生跟进事件、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;student-supervision worker 可按租户督导规则模板定时生成学习跟进任务;commerce worker 可补偿查询微信/支付宝支付和退款状态;provider-bills worker 可下载微信/支付宝官方账单并导入资金对账;platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计;platform-usage worker 可按月从权威业务表自动采集学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益,并写入用量台账和审计;platform-usage-overage worker 可按上月账期自动生成 `usage_overage` 账单,执行失败会写入脱敏平台审计日志并由审计告警 worker 转为开放告警;platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计;platform-dunning-notifications worker 可把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;platform-audit-alerts worker 可把高风险平台审计动作和自动化失败转换为内部告警并递归脱敏告警 details;platform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 -- 销售/代理分佣和转化看板基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核、销售/代理转化报表、近期未成交客资、CRM 失败/跟进积压和本人/全局权限隔离。 -- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 -- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。 -- PocketBase SQLite 只读导出、JSON dry-run、标准化导入和导入后校验脚本;真实旧库 248555 条业务记录已能在干净本地 Supabase 中完成全量导入。 -- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。 - -还没有达到生产交付的部分: - -- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 -- 阿里云 PNVS 短信认证已接入并作为生产短信登录/换绑验证码推荐路径,传统阿里云/腾讯云短信 adapter 保留兼容;微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;本地阶段使用 mock/fake provider 和回调后业务链路验收,不要求真实平台密钥。资金对账已支持手工/API 账单导入、微信/支付宝官方账单下载任务、provider-bills worker 自动导入比对、差错工单处理、异常订单运营台和人工调整凭证复核报表;真实生产账号、真实回调域名、PNVS 真实手机号 smoke 和真实生产账单抽样验收等上线后密钥联调还没接完。 -- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成;Taro 学生资料页已按短期签名和 `watermark.traceId` 渲染可见水印确认/预览第一版。生产还要配置真实扫描服务 endpoint/token,并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。 -- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载、字段映射 API 和 Taro 租户内容页第一版导入操作台已完成。 -- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 -- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 -- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩系统触发勋章已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。学生激励默认以后台配置勋章自动发放为主,排行榜默认不开启也不在学生端默认请求。CRM 死信运营第一版已完成失败池、日志脱敏、重试/忽略和审计闭环,销售/代理转化看板第一版已完成。后续还要补更细活动效果看板、外部微信订阅消息/短信推送、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、外部失败告警升级、公共题库版本通知和冲突处理操作台。 -- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料、独立消息中心和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、学习概览、掌握率、收藏数、计划拆分、卡片翻转、发音、美/英音切换和本地位置恢复第一版,知识手册已接章节内搜索、安全文本摘要高亮和目录定位第一版,分数线已接目标地区默认筛选、院校/专业/年份 chip、租户动态字段筛选、结果字段 chip 和趋势摘要第一版,AI 择校已接报告生成、历史报告和 Markdown/HTML 导出第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细和消息中心摘要第一版,独立消息中心已接状态/类型筛选、批量已读、归档/忽略和站内安全跳转第一版;学生端不默认请求排行榜,仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,学生运营页已接跟进看板、学习督导自动化、督导规则保存和批量 CRM 推送第一版,营销中心已接 CRM 配置、队列筛选、死信失败池、日志查看、重试/忽略、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。 -- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 - -## 商用功能完成度总览 - -| 模块 | 当前状态 | 说明 | -| --- | --- | --- | -| 多租户 SaaS 底座 | √ 可联调 | PostgreSQL schema、RLS、租户、品牌、域名、主题、成员权限、平台/租户/学生三类身份边界已建立 | -| 学生刷题主链路 | √ 可联调 | 入口、分类、集合、顺序/随机/模考、答题、错题、收藏、报告、视频、资料、个人中心、勋章、站内通知已接 API | -| 背单词/知识手册/分数线 | √ 可联调 | 列表、学习/阅读、动态筛选、JSON/CSV/Excel 导入和 Taro 第一版页面已具备 | -| 会员/订单/优惠券/激活码 | √ 可联调 | 下单、订单详情/状态轮询、优惠券规则/核销、激活码、权益、退款状态机和对账地基已完成 | -| 国内登录/支付 provider | √ 本地可跑,待真实密钥 | 阿里云 PNVS 短信认证已接入并作为短信登录/换绑验证码推荐路径,传统阿里云/腾讯云短信保留兼容;微信小程序/网页、QQ、微信支付、支付宝均有 adapter/fake 测试;生产 readiness 已阻断不支持 provider、非 HTTPS/localhost 回调、缺少支付回调和公开配置混入密钥;生产账号、PNVS 真实手机号 smoke 和回调域名上云后联调 | -| 租户后台运营 | √ 可联调 | 学生/班级、内容导入导出、营销、优惠券、积分、勋章、CRM、分佣、销售/代理转化、财务运营、主题和角色模板已具备第一版 | -| 平台 SaaS 账务 | √ 可联调 | 套餐、订阅、订阅账单、自动计费、用量采集、超额账单 API/worker、收款、逾期催缴、外部通知和审计已具备 | -| 公共题库商业化 | √ 可联调 | 平台题库授权、单地区/全国 SaaS 范围、租户采纳、手动/自动同步、冲突处理和通知已完成基础闭环 | -| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成;生产配置会拒绝 local_dev、非 HTTPS 公开 URL、非官方阿里云 OSS endpoint、阿里云 OSS 内网直签和未接外部扫描服务 | -| PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 | -| Taro H5 三端前端 | √ 第一版可构建 | 学生端、租户后台、平台后台均有真实 API 页面;已补 H5 `index.html` 模板和发布产物守卫;后续继续补小程序兼容、视觉精修、状态管理、包体优化和端到端测试 | -| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 2026-07-01 08:41 本地真实迁移库 API 进程压测:30 并发只读 0 错误、310.34 req/s、P95 251.08ms;50 并发混合读写 0 错误、410.49 req/s、P95 237.41ms;100 并发混合读写 0 错误、393.56 req/s、P95 437.37ms;150 并发混合读写 0 错误、371.29 req/s、P95 633.46ms。Docker API 2C/4G 受限复核和 DB 2C/8G 未调参悲观复核已留档。上云后必须执行生产 readiness、PostgreSQL shared-host 调优、远程 Auth/RLS、4C16G 复测、`security:repo`、真实 `@codex-security` 扫描和上线证据门禁。 | - -更完整的进度看这些文档: - -- `docs/refactor/implementation-status.md` -- `docs/refactor/backend-progress.md` -- `docs/refactor/backend-handoff-roadmap.md` -- `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` -- `docs/refactor/legacy-feature-gap-matrix.md` -- `docs/refactor/supabase-frontend-access-strategy.md` -- `docs/refactor/taro-frontend-integration.md` -- `docs/refactor/taro-visual-language.md` -- `docs/refactor/taro-production-integration-checklist.md` -- `docs/refactor/multitenant-auth-security-contract.md` -- `docs/refactor/next-development-todo.md` -- `docs/refactor/blueprint-coverage.md` -- `docs/refactor/api-structure.md` -- `docs/refactor/web-launch-acceptance-checklist.md` -- `docs/refactor/backend-open-items-and-capacity-20260701.md` - -## 目录结构 +## 目录 ```text apps/api/ Node.js 业务 API -apps/taro/ Taro 4 React 跨端前端,H5 三入口,后续扩展小程序 -apps/worker/ 后台异步任务:CRM webhook、支付/退款补偿、官方账单下载、平台计费/用量/催缴、平台审计告警/外部通知、资源复检、导入执行、公共题库同步、题库导出渲染等 -packages/config/ 共享配置 -packages/db/ PostgreSQL 连接池和查询封装 -packages/domain/ 领域常量和共享类型 -supabase/migrations/ 数据库迁移:schema、RLS、索引、触发器 -supabase/seed.sql 最小租户 seed -scripts/import-pocketbase/ PocketBase schema/数据导入器和校验器 -scripts/smoke-seed.js 本地集成测试 seed 数据 -scripts/api-integration-test.js -scripts/deploy/ 云服务器部署模板:Nginx、systemd、环境变量示例、发布脚本 -docs/refactor/ 重构架构、进度、治理文档 -docker-compose.api.yml API 容器化运行配置 +apps/taro/ Taro 4 React,学生/租户/平台三端 +apps/worker/ 队列消费者、平台任务、导入导出和资源复检 +packages/db/ PostgreSQL 连接池与共享数据库配置 +packages/domain/ 共享领域常量和类型 +supabase/migrations/ schema、RLS、索引、约束和安全边界 +scripts/import-pocketbase/ PocketBase 导出、dry-run、导入和校验 +scripts/deploy/ 服务器脚本、systemd、Nginx 和配置模板 +docs/refactor/ 架构、迁移、容量、前端交接和生产证据文档 +deploy.sh 新服务器推荐的 release/symlink 发布入口 ``` -旧项目参考文件在本机 `F:\project\参考\旧题库项目`,旧前端构建产物在 `F:\project\参考\旧构建产物`。这两个目录都只用于对照和迁移,不作为当前新项目源码。 - ## 本地开发 -前置要求: +要求: - Node.js 20+ -- Docker Desktop +- Docker Desktop 或兼容 Docker 环境 - Supabase CLI +- Chrome/Chromium,用于 H5 交互烟测 -启动本地 Supabase 和 API: +安装和启动: ```bash -npm install +npm ci --workspaces --include-workspace-root --include=dev npm run supabase:start npm run supabase:reset -npm run db:smoke-seed npm run dev:api ``` -Taro H5 本地开发: +`supabase:reset` 会清空本地数据库,只能用于本地或隔离测试库。需要集成测试数据时使用受保护命令: ```bash +npm run db:smoke-seed:test +``` + +脚本会同时检查数据库中的 `app_private.environment_safety` 标记和精确确认短语。不要对 staging/production 运行 `supabase:reset`、`db:smoke-seed:test`、`test:api`、`test:rls` 或会重建测试数据的 Worker 集成测试。 + +常用开发命令: + +```bash +npm run dev:api npm run dev:taro:h5 +npm run dev:taro:weapp:student ``` -三套 H5 构建: +Taro 具体页面、跨端能力和公开环境变量见 [apps/taro/README.md](apps/taro/README.md)。 -```bash -npm run build:taro:h5:student -npm run build:taro:h5:tenant -npm run build:taro:h5:platform -``` +## 验证 -对应产物: - -```text -apps/taro/dist/h5-student -apps/taro/dist/h5-tenant-admin -apps/taro/dist/h5-platform-admin -``` - -推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `apps/taro/src/services/api.ts` 请求层,业务数据默认调用 `apps/api`,不要在页面里直写 Supabase 表。 - -构建后可以先跑静态启动烟测,确认三套 H5 产物能被普通静态服务器托管、`runtime-config.json` 只含公开字段、JS/CSS 资源不 404,并用 mock 后端验证 `/api/tenant/resolve` 契约: - -```bash -node scripts/taro-route-contract-test.js -node scripts/taro-api-contract-test.js -node scripts/taro-persona-contract-test.js -node scripts/product-scope-guardrails-test.js -npm run smoke:taro:h5 -npm run smoke:taro:h5:interaction -``` - -`taro-route-contract-test` 会校验 `apps/taro/src/app.config.ts`、真实 `pages/**/index.tsx`、启动页三端跳转、H5 静态烟测入口和前端交接文档中的页面引用保持一致。新增或删除页面时必须同步路由和文档,避免 H5/小程序构建后才发现入口漂移。 -`taro-api-contract-test` 会比对 `apps/taro/src` 中所有 `apiRequest('/api/...')` 调用与 `apps/api/src/features/*/index.ts` 注册路由,阻断前端调用不存在 API、method 写错或绕过统一 `/api` 命名空间的漂移;动态导入和少量 server alias 需要在脚本 allowlist 中显式声明。 -`taro-persona-contract-test` 会从学生、租户管理员、平台管理员三类前端视角检查关键页面、路由和服务调用,阻断刷题、会员订单、错题收藏、学生运营、内容导入、营销财务、租户设置、平台租户账务和公共题库授权入口被误删或漂移。 -`smoke:taro:h5:interaction` 会启动三套 H5 发布产物、本地 mock API 和本机 Chrome/Edge,在真实浏览器里点击学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台内容导入、公共题库采纳/同步/冲突处理、学生运营、营销/CRM/分佣、主题/角色/成员写操作,以及平台后台租户、账务、公共题库授权和员工写操作,用来补足静态烟测无法发现的 H5 运行时空白页、history 路由和点击事件问题。 - -H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包: - -```text -apps/taro/deploy/h5-student.runtime-config.example.json -apps/taro/deploy/h5-tenant-admin.runtime-config.example.json -apps/taro/deploy/h5-platform-admin.runtime-config.example.json -``` - -部署时把示例复制为对应 Web 根目录的 `runtime-config.json`,只填写 `portal`、`apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode` 这类公开字段。完整 Nginx、CSP、缓存、CORS 和三域名部署说明见: - -```text -docs/refactor/taro-h5-deployment.md -scripts/deploy/README.md -``` - -学生端当前页面: - -```text -apps/taro/src/pages/student/login -apps/taro/src/pages/student/home -apps/taro/src/pages/student/catalog -apps/taro/src/pages/student/practice -apps/taro/src/pages/student/vocabulary -apps/taro/src/pages/student/handbook -apps/taro/src/pages/student/scoreline -apps/taro/src/pages/student/ai-school -apps/taro/src/pages/student/assets -apps/taro/src/pages/student/profile -``` - -租户后台当前页面: - -```text -apps/taro/src/pages/tenant-admin/workbench -apps/taro/src/pages/tenant-admin/dashboard -apps/taro/src/pages/tenant-admin/students -apps/taro/src/pages/tenant-admin/content -apps/taro/src/pages/tenant-admin/marketing -apps/taro/src/pages/tenant-admin/finance -apps/taro/src/pages/tenant-admin/settings -``` - -平台后台当前页面: - -```text -apps/taro/src/pages/platform-admin/workbench -apps/taro/src/pages/platform-admin/tenants -apps/taro/src/pages/platform-admin/billing -apps/taro/src/pages/platform-admin/question-banks -apps/taro/src/pages/platform-admin/staff -``` - -单次运行 CRM worker: - -```bash -npm --workspace @tiku-saas/worker run crm:once -``` - -单次运行支付/退款补偿 worker: - -```bash -npm --workspace @tiku-saas/worker run commerce:once -``` - -单次运行微信/支付宝官方账单下载 worker: - -```bash -npm --workspace @tiku-saas/worker run provider-bills:once -``` - -单次运行平台 SaaS 订阅自动计费 worker: - -```bash -npm --workspace @tiku-saas/worker run platform-billing:once -``` - -生产定时任务建议每天低峰运行一次 `node dist/apps/worker/src/index.js --once --job platform-billing`。可用环境变量控制批量大小和提前开票窗口: - -```text -WORKER_PLATFORM_BILLING_BATCH_SIZE=50 -WORKER_PLATFORM_BILLING_DAYS_AHEAD=45 -WORKER_PLATFORM_BILLING_DUE_DAYS=15 -WORKER_PLATFORM_BILLING_ID=platform-billing-prod-1 -``` - -单次运行平台 SaaS 用量超额自动开票 worker: - -```bash -npm --workspace @tiku-saas/worker run platform-usage-overage:once -``` - -生产定时任务建议每月 1 日低峰先用 `WORKER_PLATFORM_USAGE_MONTH=上月 YYYY-MM` 运行 `platform-usage` 采集完整用量快照,再运行 `node dist/apps/worker/src/index.js --once --job platform-usage-overage` 自动生成上一个自然月的 `usage_overage` 账单。该 worker 复用后端统一超额计算服务,只读取 `tenant_usage_records`、SaaS 套餐 `included_quotas/overage_prices` 和订阅 metadata 覆盖,使用唯一索引和账单查重防重复开票;账期月份必须是 `YYYY-MM` 且月份在 `01..12`。worker 执行失败会写入 `platform.invoice.usage_overage_worker_failed` 脱敏平台审计日志,后续由 `platform-audit-alerts` 转为高优先级开放告警,并可继续通过 `platform-audit-notifications` 推送到钉钉/飞书/企微。 - -```text -WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE=100 -WORKER_PLATFORM_USAGE_OVERAGE_MONTH= -WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS=15 -WORKER_PLATFORM_USAGE_OVERAGE_ID=platform-usage-overage-prod-1 -``` - -单次运行平台 SaaS 逾期催缴 worker: - -```bash -npm --workspace @tiku-saas/worker run platform-dunning:once -``` - -生产定时任务建议每天在自动计费之后运行一次 `node dist/apps/worker/src/index.js --once --job platform-dunning`。它只处理已过 `due_date` 且未结清的服务费账单:把账单标记为 `overdue`、将租户 `billing_status` 推为 `past_due`、写入 `tenant_invoice_reminders` 内部催缴台账和审计,不会自动停用租户。 - -```text -WORKER_PLATFORM_DUNNING_BATCH_SIZE=100 -WORKER_PLATFORM_DUNNING_ID=platform-dunning-prod-1 -``` - -单次运行平台 SaaS 逾期催缴外部通知 worker: - -```bash -npm --workspace @tiku-saas/worker run platform-dunning-notifications:once -``` - -生产定时任务建议在 `platform-dunning` 之后每 5 到 15 分钟运行一次 `node dist/apps/worker/src/index.js --once --job platform-dunning-notifications`。它会把 `tenant_invoice_reminders` 中待发送或失败的内部催缴记录按 `platform_dunning_notification_channels` 配置入队到 `platform_dunning_notification_events`,支持 generic、钉钉、飞书和企业微信 webhook;发送成功后会把对应催缴记录标记为 `sent`,发送失败会按退避策略重试并在终止失败时标记 `failed`。渠道密钥必须写入 `app_private.platform_secrets`,API 只返回 `secretRef` 和 webhook host/path,事件查询会递归脱敏 request payload。 - -```text -WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE=50 -WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS=5 -WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800 -WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS=10000 -WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false -``` - -单次运行平台审计告警 worker: - -```bash -npm --workspace @tiku-saas/worker run platform-audit-alerts:once -``` - -生产定时任务建议每 5 到 15 分钟运行一次 `node dist/apps/worker/src/index.js --once --job platform-audit-alerts`。它只扫描 `platform.%` 审计日志,把命中启用规则的高风险动作写入 `platform_audit_alerts`;告警 details 会递归脱敏 token、secret、password、key、authorization、cookie、session、cert、signature 等敏感字段。 - -```text -WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE=200 -WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS=14 -WORKER_PLATFORM_AUDIT_ALERT_ID=platform-audit-alerts-prod-1 -``` - -单次运行平台审计告警外部通知 worker: - -```bash -npm --workspace @tiku-saas/worker run platform-audit-notifications:once -``` - -生产定时任务建议在 `platform-audit-alerts` 后每 5 到 15 分钟运行一次 `node dist/apps/worker/src/index.js --once --job platform-audit-notifications`。它会把开放告警按 `platform_audit_notification_channels` 配置入队到 `platform_audit_notification_events`,支持 generic、钉钉、飞书和企业微信 webhook,发送请求和事件台账都会递归脱敏敏感字段。钉钉/飞书签名密钥必须存入 `app_private.platform_secrets`,API 只返回 `secretRef` 和 webhook host/path。 - -```text -WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE=50 -WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS=5 -WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800 -WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS=10000 -WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false -``` - -单次运行内容资源复检 worker: - -```bash -npm --workspace @tiku-saas/worker run assets:once -``` - -单次运行内容导入 worker: - -```bash -npm --workspace @tiku-saas/worker run imports:once -``` - -单次运行公共题库自动同步 worker: - -```bash -npm --workspace @tiku-saas/worker run public-banks:once -``` - -单次运行学习督导规则 worker: - -```bash -npm run build:worker -node apps/worker/dist/apps/worker/src/index.js --once --job student-supervision -``` - -生产定时任务建议每 15 到 60 分钟运行一次 `node dist/apps/worker/src/index.js --once --job student-supervision`。它只处理启用状态的租户学习督导规则,按规则阈值扫描未学习、错题积压、低正确率、单词待复习和超期未完成练习的学生,并幂等生成 `learning` 跟进任务;教师/班主任范围规则必须绑定班级,生成结果、失败原因和 worker 信息会写入规则 `last_result/metadata` 与审计。 - -```text -WORKER_STUDENT_SUPERVISION_BATCH_SIZE=20 -WORKER_STUDENT_SUPERVISION_ID=student-supervision-prod-1 -WORKER_STUDENT_SUPERVISION_CLAIM_STALE_SECONDS=900 -``` - -单次运行题库 PDF/Word/每日一练 ZIP 导出 worker: - -```bash -npm --workspace @tiku-saas/worker run exports:once -``` - -默认本地数据库: - -```text -postgresql://postgres:postgres@127.0.0.1:54322/postgres -``` - -默认 API 地址: - -```text -http://127.0.0.1:8787 -``` - -## 验证命令 - -完整后端重构检查: - -```bash -npm run check:refactor -``` - -这个命令会依次执行: - -- API TypeScript 检查 -- PocketBase importer TypeScript 检查 -- PocketBase 导入后校验 -- 本地 smoke seed -- API 构建 -- 本地 API 集成测试 - -常用单项命令: +日常改动至少运行与改动范围对应的 TypeScript 和 contract 测试: ```bash npm run check:api @@ -440,420 +111,169 @@ npm run check:worker npm run check:importer npm run check:taro npm run test:readiness -npm run test:auth:remote-smoke -npm run test:launch-gate -npm run smoke:auth:remote -npm run audit:runtime -npm run security:repo -npm run pb:import:dry-run -npm run pb:import:validate -npm run test:pb:dry-run -npm run test:api -npm run test:worker:crm -npm run test:worker:commerce -npm run test:worker:platform-billing -npm run test:worker:platform-usage -npm run test:worker:platform-dunning -npm run test:worker:platform-dunning-notifications -npm run test:worker:platform-audit-alerts -npm run test:worker:platform-audit-notifications -npm run test:worker:assets -npm run test:worker:exports -npm run test:worker:imports -npm run test:worker:public-banks -npm run test:worker:student-supervision -npm run test:rls ``` -## 云服务器部署与生产测试顺序 - -接下来在新云服务器上建议按下面顺序推进。不要跳过证据门禁;它是后续给合作商交付 SaaS 时的安全底线。 - -当前 `tjszsb.com` 已建议按 6 个生产入口使用:`api.tjszsb.com` 反代业务 API,`app.tjszsb.com` 承载学生 H5,`admin.tjszsb.com` 承载租户后台,`console.tjszsb.com` 承载平台后台,`supabase.tjszsb.com` 反代 Supabase gateway/Auth/Storage/PostgREST,`studio.tjszsb.com` 仅限固定 IP/VPN 访问 Supabase Studio。可提交到仓库的部署模板在 `scripts/deploy/`;真实服务器文件建议放在 `/opt/tiku-saas/repo`、`/srv/tiku-saas/www/*` 和 `/etc/tiku-saas/*.env`。Gitea token、数据库密码、支付私钥、短信密钥、对象存储密钥都只允许放服务器本地,不允许写入 Git、前端运行时配置或部署脚本。 - -当前云服务器是 Alibaba Cloud Linux 3 + 宝塔面板环境,宝塔 Nginx 配置在 `/www/server/nginx` 和 `/www/server/panel/vhost/nginx`,不是 `/etc/nginx`。H5 静态产物仍发布到 `/srv/tiku-saas/www/*`,宝塔站点根目录通过 `/www/wwwroot/tiku-saas/*` 软链接接入。完整服务器落地记录见 `scripts/deploy/README.md`。 - -1. 准备服务器基础环境:安装 Docker、Node.js 20+、Supabase CLI、Nginx/Caddy、进程管理或容器编排工具;拉取本仓库 `main`,以 Gitea 最新提交为准。 -2. 启动 Supabase/PostgreSQL,执行全部 migrations 和最小 seed;确认 `DATABASE_URL` 指向云端数据库。 -3. 按 `docs/refactor/postgresql-4c16g-tuning.md` 应用 4 核 16G `shared-host` 起步参数,启用 `pg_stat_statements`,重启 PostgreSQL 后跑 `PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json`。 -4. 配置生产 `.env`:关闭 legacy 身份头和平台本地 key,配置 HTTPS CORS 白名单、强随机 session/JWT secret、对象存储 provider、短信/OAuth/支付 provider、worker provider 和 webhook 域名。生产环境禁止 `CORS=*`、mock/未知短信 provider、local_dev 存储、localhost webhook、非 HTTPS 回调。 -5. 导入旧 PocketBase 数据:先跑 production dry-run 和 sample,确认 7 个已支付缺用户订单、22 个缺所属手册章节等旧数据人工复核项有明确处理结论,再执行正式导入演练。 -6. 构建并部署 API/worker:API 对外只开放 HTTPS;worker 按本文的计费、用量、催缴、审计、资源复检、导入、导出、公共题库同步等任务设置 cron 或队列。 -7. 构建三套 H5:`build:taro:h5:student`、`build:taro:h5:tenant`、`build:taro:h5:platform`,分别部署到学生端、租户后台、平台后台域名。每个发布目录根部放置真实公开 `runtime-config.json`,只允许公开字段。 -8. 执行生产验收:`readiness:production`、`readiness:production:db`、`smoke:auth:remote`、`test:rls`、`smoke:launch-persona -- --write ...`、Taro H5 静态/交互 smoke、API/worker 构建检查、`security:repo`、真实 `@codex-security` 扫描。 -9. 执行云端真实数据压测:按 runbook 跑 6/30/50/100 阶梯,只读和混合读写各一组;把摘要写入 `production-launch-evidence.json`,不要把原始压测报告提交 Git。 -10. 通过 `npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json` 后,再进入灰度生产测试。灰度期间继续观察 CPU、内存、慢 SQL、连接数、错误率、P95/P99、对象存储签名、支付回调、短信/OAuth 登录、订单权益开通和 worker 失败告警。 - -当前可上线生产测试的边界:后端主链路和 H5 第一版已具备联调条件;真实生产账号、真实支付/短信/OAuth 回调、对象存储 AV/CDN、水印策略、4C16G 云端容量和真实迁移数据人工复核仍是上线前必须验收项。学生头像只支持男女预设,不做上传;排行榜默认不开启,只有租户明确购买/开启活动并完成专项压测后再进入独立功能。 - -## 生产就绪检查 - -填好生产 `.env` 后,先跑环境变量级检查: +三端 H5 正式产物必须分别构建;根脚本 `build:taro:h5` 只等价于学生端构建: ```bash -npm run readiness:production -``` - -确认 `DATABASE_URL` 指向生产 Supabase/PostgreSQL 后,再跑数据库配置检查: - -```bash -npm run readiness:production:db -``` - -这个检查会阻断默认弱密钥、`CORS=*`、不支持或 mock 短信 provider、legacy 身份头、local_dev 存储、非 HTTPS 对象存储公开 URL、非官方阿里云 OSS endpoint、阿里云 OSS 内网直签、对象存储未配置、CRM insecure localhost、平台审计/催缴通知 localhost 等生产风险;带 `:db` 的版本还会检查租户 provider 公开配置是否混入密钥、活跃短信/OAuth/支付 provider 是否缺少 `app_private.tenant_secrets`、短信/OAuth/支付公开配置是否缺少生产必填字段、OAuth redirectUri 和支付 notifyUrl 是否为生产 HTTPS、平台通知 webhook 是否为生产 HTTPS、钉钉/飞书平台通知是否缺少 `app_private.platform_secrets`、域名是否未验证。 - -Supabase Auth/JWKS 上云后需要用真实 access token 跑远程验收: - -```bash -AUTH_SMOKE_API_BASE_URL=https://api.example.com \ -AUTH_SMOKE_TENANT_ID= \ -AUTH_SMOKE_STUDENT_ACCESS_TOKEN= \ -AUTH_SMOKE_TENANT_ADMIN_ACCESS_TOKEN= \ -AUTH_SMOKE_PLATFORM_ADMIN_ACCESS_TOKEN= \ -AUTH_SMOKE_WRONG_TENANT_ID= \ -AUTH_SMOKE_REQUIRE_ADMIN_TOKENS=true \ -npm run smoke:auth:remote -``` - -这个命令会验证真实 Supabase JWT 能访问 `/api/auth/me`、`/api/profile/me`,学生不能访问租户后台/平台后台,租户管理员不能访问平台后台,平台管理员能访问平台后台,坏 token 和错租户上下文会被拒绝。真实 access token 只允许在验收命令行临时提供,不要写入仓库、前端 `runtime-config.json` 或长期 `.env`。 - -RLS 需要同时跑动态隔离验收: - -```bash -npm run test:rls -``` - -这个命令会先执行本地 smoke seed,再在事务内模拟 Supabase `authenticated/anon/platform_admin` JWT claims,验证主租户和合作商租户的品牌、设置、域名、成员、题库、订单、资源、SaaS 账单等代表性表不会跨租户泄露;同时验证无 `tenant_id` claim 不能读取租户数据,普通租户上下文不能跨租户写入。脚本里的临时 grant 会随事务回滚,不会改变实际 schema 权限。 - -## 生产上线证据门禁 - -正式切换前不要只看“口头跑过测试”。把真实生产/预生产验收结果整理成证据文件,再运行上线门禁: - -```bash -cp docs/refactor/production-launch-evidence.template.json docs/refactor/production-launch-evidence.json -npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json -``` - -`production-launch-evidence.json` 不入 Git,里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过: - -- `readiness:production`、`readiness:production:db`、严格 `perf:postgres:evidence -- --strict`。 -- 真实 `smoke:auth:remote`、`test:rls`。 -- PocketBase production dry-run、`pb:import:validate`、`pb:import:sample`。 -- 真实数据 API 读路径压测、API/worker/Taro 构建。 -- `smoke:launch-persona` 真实 API 角色旅程:普通学生 SVIP 后刷题、收藏、错题/收藏复习入口,租户管理员看板/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及越权拒绝。 -- `smoke:taro:h5`、`smoke:taro:h5:interaction`、严格 `taro-h5-release-guardrails-test --require-runtime-config`。 -- `audit:runtime`、`security:repo`、真实 `@codex-security` 扫描。 -- 备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。 - -补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime`、`npm run security:repo`、`npm run test:readiness`、`npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。 - -模板文件: - -```text -docs/refactor/production-launch-evidence.template.json -``` - -## PocketBase 迁移 Dry-Run - -如果旧数据源是 SQLite,先从旧 PocketBase 数据目录只读导出业务 collection。默认读取 `F:\project\参考\旧题库数据库文件`,输出到已被 `.gitignore` 覆盖的 `pb_export/`: - -```powershell -$env:PB_SQLITE_DIR="F:\project\参考\旧题库数据库文件" -$env:PB_EXPORT_DIR="F:\project\pb_export" -npm run pb:export:sqlite -``` - -导出会生成: - -```text -pb_export/*.json 各 PocketBase collection 的普通业务 JSON -pb_export/sqlite-export-manifest.json -pb_export/storage-manifest.json -pb_export/pb_schema.sqlite.json -``` - -默认导出会移除 `password/token/secret/sessionKey/openid/unionid` 等敏感身份或密钥字段,只在 manifest 中记录脱敏字段数量;手机号等迁移必需字段会保留。不要把 `pb_export/`、manifest 或真实迁移报告提交到 Git。 - -把旧 PocketBase 导出的集合 JSON 放到仓库根目录 `pb_export/` 后,执行不写数据库的静态 dry-run: - -```bash -npm run pb:import:dry-run -``` - -需要给 CI 或脚本读取时: - -```bash -npm run pb:import:dry-run -- --json -``` - -dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失旧 ID、敏感字段、旧 schema 关系断裂和未映射集合。存在 blocker 时命令返回非 0;所有 blocker 处理完后,再执行 `npm run pb:import:json` 和 `npm run pb:import:validate`。 - -默认 dry-run 使用 `development` profile;正式迁移、预生产验收和 CI 应使用 `production` profile。生产 profile 会额外输出 `migrationReadiness`,检查用户、题目、科目、分类、订单、套餐、激活码、单词和知识手册等必需集合,以及用户手机号、题目归属、订单套餐、激活码、单词和手册归属等关键字段覆盖率。`--profile` 只接受 `development` 或 `production`,拼写错误会按 blocker 失败。 - -当前真实 SQLite 基线已经跑通只读导出和干净库全量导入:58 个业务 collection、248555 条记录、9 个 storage 原始资源文件;本地 `npx supabase db reset` 后执行 `npm run pb:import:json` 最近用时约 10 分 11 秒,`npm run pb:import:validate` 结果为 0 failures、3 warnings。`user_answer_records`、`mock_exam_configs`、`referral_qrcodes`、`commission_settings` 已进入标准化导入,导入后核心计数包括 3670 用户、74102 题目、85199 条旧答题记录、38205 条错题、636 订单、447 权益、79 个推广码和 1 条租户分佣设置。导入器现在还会从旧 `region_modules/module_nodes/subjects/categories/questions.nodeId` 生成新架构 `content_entries/content_nodes/question_collections/practice_blueprints`,最新导入计数为 11 个题库入口、2830 个内容节点、1597 个题目合集、82106 条合集题目关系、3102 个顺序/随机练习蓝图;所有已发布旧题都会写入 `entry_id/content_node_id/primary_collection_id`,供 Taro 前端按新模型直接消费。 - -导入结构校验后,还要运行真实业务抽样: - -```powershell -$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" -npm run pb:import:sample -``` - -`pb:import:sample` 是只读脚本,用来证明迁移数据能被新 SaaS 业务模型消费:题库入口、分类节点、题目合集、顺序/随机/全真模拟蓝图、题目当前版本、答题记录、错题、收藏、单词、知识手册、分数线、订单、支付、权益、激活码、资源台账和敏感字段泄露都会被抽样检查。当前真实迁移库最近结果为 `0 failures, 6 warnings, 1 skipped, 39 passed`;warning 均为旧数据人工复核项或上线前留档项,详见 runbook。 - -如需生成本地报告: - -```powershell -$env:PB_SAMPLE_WRITE_REPORT="true" -npm run pb:import:sample -Remove-Item Env:\PB_SAMPLE_WRITE_REPORT -``` - -报告输出到已忽略的 `docs/refactor/migration-reports/`,不要提交真实用户、订单或学习数据样本。 - -production dry-run 目前仍有 2 个真实数据 blocker:30 个订单缺用户、22 个知识手册章节缺所属手册;导入器会把它们隔离到财务复核/迁移待复核手册并写入 `pb_import_issues`,其中最新导入 run 的 critical issue 剩余 29 个:7 个已支付订单缺用户、22 个手册章节缺所属手册。正式切换前仍必须人工确认,详见: - -```text -docs/refactor/pocketbase-real-data-migration-runbook.md -docs/refactor/next-development-todo.md -``` - -真实生产数据迁移不要只看命令是否能跑完,需要按迁移验收 runbook 执行 dry-run、正式导入演练、导入后校验、业务抽样、Taro 联调、冻结切换和回滚准备: - -```text -docs/refactor/pocketbase-real-data-migration-runbook.md -``` - -### API 压测和 4 核 16G 评估 - -默认本地烟测会构建 API 并自动启动临时端口,使用当前 `DATABASE_URL` 的真实数据做只读混合请求: - -```powershell -$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" -npm run perf:api:local -``` - -报告输出到已忽略的 `docs/refactor/performance-reports/`。可以用 `npm run perf:summary -- --input --json` 自动提取 `launch:gate` 需要的错误率、P95/P99、并发和时长摘要。4 核 16G 云服务器应按压测 runbook 跑 6/30/50/100 阶梯并发,并结合 PostgreSQL 调参文档观察慢 SQL、连接数、锁等待和 P95/P99: - -```text -docs/refactor/postgresql-4c16g-tuning.md -docs/refactor/performance-benchmark-runbook.md -``` - -本地 Docker Desktop 可用时,可以先用受限 API 容器做 4 核 16G shared-host 风格的预演。该入口默认把 API 容器限制为 2 CPU/4G、关闭 legacy `x-user-id`,并用 `Authorization: Bearer ` 跑 30 只读、50/100 混合读写矩阵: - -```powershell -$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" -npm run perf:api:docker-4c16g -``` - -注意:这个入口默认只限制 API 容器资源,并会生成 `docker-4c16g-resource-evidence-*.json/md` 记录 Docker Desktop、API 容器和 Supabase DB 容器的资源上下文;本地 Supabase/PostgreSQL 仍受 Docker Desktop 全局资源影响。需要更贴近本地 4C16G 模拟时,可以显式设置 `BENCHMARK_LIMIT_DB_RESOURCES=true`、`BENCHMARK_DB_CPUS` 和 `BENCHMARK_DB_MEMORY`,脚本默认会在结束后恢复 DB 容器限制。正式容量承诺仍要在目标 4 核 16G 云服务器复跑。 - -PostgreSQL 调参与运行证据采集: - -```powershell -$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" -npm run perf:postgres:evidence -``` - -生产上线前必须用严格模式生成门禁摘要: - -```powershell -$env:PG_TUNING_PROFILE="shared-host" -npm run perf:postgres:evidence -- --strict --json -Remove-Item Env:\PG_TUNING_PROFILE -``` - -严格模式会检查 4 核 16G profile、`pending_restart=0`、`pg_stat_statements` 可用、`jit=off`,以及 API 请求相关超时不为 0。需要生成可人工复核的 `ALTER SYSTEM` SQL 时运行: - -```powershell -npm run perf:postgres:sql -- --profile=shared-host -``` - -上线前角色旅程烟测: - -```powershell -$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" -$env:LAUNCH_SMOKE_AUTH_MODE="app_session" -npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md -Remove-Item Env:\LAUNCH_SMOKE_AUTH_MODE -``` - -`smoke:launch-persona` 会从普通学生、租户管理员、平台管理员三个视角调用真实 API,默认使用 `LAUNCH_SMOKE_AUTH_MODE=app_session` 的 Bearer `tk_` session,不依赖旧 `x-user-id` 或平台本地 key;覆盖 SVIP 后刷题、收藏、错题复习入口、租户数据看板/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试记录,生产只建议在灰度或演练租户运行。脚本始终保留带时间戳的 JSON/Markdown 报告;写生产证据时用 `--write` 和 `--write-md` 同步生成 `production-launch-evidence.json` 里引用的稳定 artifact。 - -Taro H5 发布产物守卫: - -```powershell npm run build:taro:h5:student npm run build:taro:h5:tenant npm run build:taro:h5:platform npm run smoke:taro:h5 -npm run smoke:taro:h5:interaction -npm run manifest:taro:h5 -node scripts\taro-h5-release-guardrails-test.js --require-dist +TARO_H5_INTERACTION_OUTPUT_DIR=/tmp/tiku-h5-smoke npm run smoke:taro:h5:interaction +npm run audit:taro:supply-chain +npm run guard:taro:visual +node scripts/taro-h5-release-guardrails-test.js --require-dist +npm run manifest:taro:h5 -- --require-dist ``` -`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`smoke:taro:h5:interaction` 会再拉起真实 Chrome/Edge,打开三套 H5 产物并点击 32 项关键入口,覆盖学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台内容导入、公共题库采纳/同步/冲突处理、学生运营、营销/CRM/分佣、主题/角色/成员写操作,以及平台后台租户、账务、公共题库授权和员工写操作,确认页面 JS 执行、路由跳转、关键 API 和后台入口点击没有空白页或运行时异常。`manifest:taro:h5` 会生成三套 H5 的部署清单,记录构建命令、发布目录、入口路由、`index.html` hash、资源数量、runtime-config 状态和租户解析模式,方便前端/运维核对实际上传目录。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。 - -写入生产上线证据时使用严格模式,确保三套正式发布目录已经放好真实公开 `runtime-config.json`,且 warning 为 0: - -```powershell -npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json -npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json -node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json -npm --silent run manifest:taro:h5 -- --require-dist --require-runtime-config --json --write docs/refactor/launch-artifacts/taro-h5-release-manifest.json > docs/refactor/launch-artifacts/taro-h5-release-manifest.stdout.json -``` - -最近一次本地真实迁移库已包含前期压测写入记录,当前规模约为 19 个租户、74,131 道题、1,631 个题目合集、3,120 个练习蓝图、3,519 个单词、2,692 条知识手册、3,722 个平台用户、3,712 个学生资料、81,160 个练习 session、327,925 条答题记录、38,207 条错题、1,501 个内容资源和 474 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。 - -README 只保留最新本地真实数据压测摘要。更早的 Docker API 受限资源复核、高资源 Docker Desktop 历史结果和每轮说明见 `docs/refactor/performance-benchmark-summary-20260630.md` 与 `docs/refactor/backend-open-items-and-capacity-20260701.md`。 - -| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | -| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| API 进程 / 30 | 120s | 0% | 37,520 | 0.00% | 310.34 req/s | 251.08 ms | 388.49 ms | -| API 进程 / 50 | 60s | 10% | 25,047 | 0.00% | 410.49 req/s | 237.41 ms | 344.51 ms | -| API 进程 / 100 | 60s | 8% | 24,116 | 0.00% | 393.56 req/s | 437.37 ms | 533.63 ms | -| API 进程 / 150 | 60s | 6% | 22,864 | 0.00% | 371.29 req/s | 633.46 ms | 759.62 ms | -| Docker API 2c4g / 30 | 120s | 0% | 36,800 | 0.00% | 305.59 req/s | 241.46 ms | 351.17 ms | -| Docker API 2c4g / 50 | 60s | 10% | 24,120 | 0.00% | 398.79 req/s | 231.28 ms | 325.94 ms | -| Docker API 2c4g / 100 | 60s | 8% | 22,462 | 0.00% | 370.86 req/s | 445.26 ms | 532.32 ms | -| Docker API 2c4g / 150 | 60s | 6% | 20,708 | 0.00% | 340.74 req/s | 689.67 ms | 833.08 ms | -| Docker API 2c4g + DB 2c8g / 30 | 120s | 0% | 8,397 | 0.00% | 69.48 req/s | 1183.29 ms | 1697.59 ms | -| Docker API 2c4g + DB 2c8g / 50 | 60s | 10% | 6,640 | 0.00% | 109.61 req/s | 996.01 ms | 1303.45 ms | -| Docker API 2c4g + DB 2c8g / 100 | 60s | 8% | 6,381 | 0.00% | 104.13 req/s | 1705.51 ms | 2098.41 ms | -| Docker API 2c4g + DB 2c8g / 150 | 60s | 6% | 5,211 | 0.00% | 83.23 req/s | 3006.71 ms | 3699.64 ms | - -只读上线门禁继续要求 `includeWrites=false`;混合读写报告需要显式使用 `--allow-writes` 做人工容量观察,例如: - -```powershell -npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-20260701-075709.json --json --allow-writes --min-duration-seconds=60 --min-concurrency=50 --max-p95-ms=500 --max-p99-ms=1200 -``` - -使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。 - -本地结论:当前 Docker Desktop 分配 20 CPU、约 63GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 08:41 本地 API 进程压测中,50 worker 混合读写为 25,047 请求、0 错误、410.49 req/s、P95 237.41ms,这是当前真实迁移库的舒适观察;按 0.05 到 0.2 req/s/人的页面节奏粗略折算,约对应 2,052 到 8,210 名活跃在线学生请求吞吐。100 worker 为 393.56 req/s、P95 437.37ms,约对应 1,968 到 7,871 名,可作为本机可用上沿;150 worker 进入压力区,为 371.29 req/s、P95 633.46ms,约对应 1,856 到 7,426 名。2026-07-01 06:53 同时限制 API 2C/4G 和 DB 2C/8G 时,30 worker 只读和 50 worker 混合读写仍 0 错误,但 P95 分别升至 1183.29ms 和 996.01ms,未通过上线延迟门禁,这是“DB 受限但未调 PostgreSQL 参数”的悲观观察;50 worker 109.61 req/s 只可粗略折算约 548 到 2,192 名活跃在线学生的下限吞吐,不能作为可售 SLA。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、`pg_stat_statements`、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见: - -```text -docs/refactor/performance-benchmark-summary-20260630.md -docs/refactor/backend-open-items-and-capacity-20260701.md -``` - -正式切换前建议使用 production 严格模式: +正式部署目录还必须放入真实的公开 `runtime-config.json`,并使用严格模式: ```bash -npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings +node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config +npm run manifest:taro:h5 -- --require-dist --require-runtime-config ``` -导入后校验建议在预生产/生产切换前把 warning 也作为阻断: +Taro 固定为 `4.2.0`。workspace postinstall 会按精确版本和源码 hash 原子应用 H5 Input/Button runtime patch;不要使用 `npm ci --ignore-scripts`。Taro CLI/构建工具链尚有已审核 allowlist 内的 audit 告警,任何新增高危依赖或进入 H5/小程序 bundle 的风险都必须重新审查。 -```powershell -$env:FAIL_ON_WARNINGS="true" -npm run pb:import:validate -Remove-Item Env:\FAIL_ON_WARNINGS -``` +## Git 与 Gitea -## API 模块 - -当前 API 目录: +远端仓库: ```text -apps/api/src/features/ - auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录 - catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口 - commerce/ 订单、支付确认、退款、激活码、优惠券规则/核销、权益、资金对账和差错工单 - health/ 健康检查 - learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜 - platform-admin/ 平台方权限、租户、SaaS 套餐、订阅、订阅账单候选/批量生成、账单、用量 - profile/ 学生个人中心、勋章 - referral/ 销售/代理客资追踪、CRM 队列 - referral/commission.ts - 分佣设置、汇总、来源明细、结算单、审核/打款、导出和凭证复核 - scoreline/ 分数线 - tenant/ 租户解析 - tenant-admin/ 租户后台配置、主题、成员权限、班级学生、活动、勋章和审计 - tenant-content/ 租户内容导航、题库维护、资源管理、批量导入和题库导出 - video/ 题目视频讲解 +https://git.gongxue100.com/chenhaogxjy/tiku-supabase.git ``` -API 身份上下文: +Gitea SSH 端口为 `2222`: -- 推荐:`Authorization: Bearer `,可配合 `x-tenant-id` 提供当前租户上下文。 -- 本地/迁移期:`Authorization: Bearer `。 -- 兼容旧测试:`x-user-id`、`x-platform-admin-key` 仅允许在 `ALLOW_LEGACY_AUTH_HEADERS=true`、`ALLOW_PLATFORM_ADMIN_KEY=true` 的非生产环境使用。 - -生产环境必须设置 `ALLOW_LEGACY_AUTH_HEADERS=false` 和 `ALLOW_PLATFORM_ADMIN_KEY=false`,前端不能再传 `x-user-id` 代表当前用户。 - -平台后台权限: - -- 平台账号以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。 -- 平台账号通过 `platform_users.platform_permissions` 控制细粒度能力,`{"*":true}` 表示超级管理员。 -- 平台员工通过 `GET/PUT/PATCH /api/platform-admin/staff` 管理,必须绑定 Supabase Auth 用户 ID;禁用员工会使 `status='disabled'`,后续 Supabase JWT 映射和迁移期 session 都会被拒绝。 -- 平台后台启动后可调用 `GET /api/platform-admin/permissions` 获取 `catalog/effective`,用于隐藏不可见菜单和按钮。 -- 后端接口继续按 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant/ops` 等权限点强制校验。 -- `x-platform-admin-key` 只允许本地兼容,生产必须关闭。 - -## 重要安全约定 - -- 租户公开配置和主题配置不能存放密钥;主题 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` 后再签名下载/预览。 -- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定时运行 provider-bills worker 下载官方账单核对本地订单。官方账单下载任务只保存下载域名、hash 和对账批次 ID,不向前端暴露下载 URL 或商户密钥。优惠券状态、最低金额、封顶、单用户限次、首单、适用套餐/地区和订单抵扣都由后端重新校验,前端只能展示后端返回金额。对账差错工单和人工调整凭证只允许记录财务处理结论、附件引用和审计事件,不允许前端、工单接口或凭证审批接口直接篡改订单、支付、退款或权益状态。 - -## 最近一次验证 - -最近本地验证命令: - -```text -node --check scripts/launch-persona-smoke.js -npm run test:readiness -npm run security:repo -npm run check:taro -LAUNCH_SMOKE_AUTH_MODE=app_session npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md -PERF_AUTH_MODE=app_session PERF_DURATION_SECONDS=120 PERF_CONCURRENCY=30 PERF_RAMP_SECONDS=15 PERF_INCLUDE_WRITES=false npm run perf:api:local -PERF_AUTH_MODE=app_session PERF_DURATION_SECONDS=60 PERF_CONCURRENCY=50 PERF_RAMP_SECONDS=10 PERF_INCLUDE_WRITES=true PERF_PRACTICE_FLOW_RATIO=0.1 npm run perf:api:local -PERF_AUTH_MODE=app_session PERF_DURATION_SECONDS=60 PERF_CONCURRENCY=100 PERF_RAMP_SECONDS=10 PERF_INCLUDE_WRITES=true PERF_PRACTICE_FLOW_RATIO=0.08 npm run perf:api:local -PERF_AUTH_MODE=app_session PERF_DURATION_SECONDS=60 PERF_CONCURRENCY=150 PERF_RAMP_SECONDS=10 PERF_INCLUDE_WRITES=true PERF_PRACTICE_FLOW_RATIO=0.06 npm run perf:api:local -npx supabase db reset -npm run check:api -npm run check:worker -npm run test:worker:commerce -npm run test:worker:platform-billing -npm run test:worker:platform-usage -npm run test:worker:platform-usage-overage -npm run test:worker:platform-dunning -npm run test:worker:platform-dunning-notifications -npm run test:worker:platform-audit-alerts -npm run test:worker:platform-audit-notifications -npm run test:worker:assets -npm run test:worker:exports -npm run test:auth:remote-smoke -npm run test:rls -npm run test:api -npm run check:refactor -npm run audit:runtime -git diff --check +```bash +git clone ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git ``` -结果:通过。最近一轮上线前本地复核已通过 `npm run test:readiness`、`npm run security:repo`、`npm run check:taro`、`node --check scripts/launch-persona-smoke.js`、`node scripts/production-launch-gate-test.js` 和 `git diff --check`;`security:repo` 为 0 finding,`test:readiness` 已包含生产配置 fail-fast、Taro runtime/API/persona/route contract、H5 发布守卫、自动勋章并发、PostgreSQL 调参证据 helper、Docker 压测资源证据、远程 Auth smoke 脚本、launch persona 稳定证据 CLI 和上线门禁测试。`LAUNCH_SMOKE_AUTH_MODE=app_session npm run smoke:launch-persona -- --write ...` 已通过,生成带时间戳报告和稳定 `launch-persona-smoke.json/md`,覆盖普通学生 SVIP 后刷题、收藏、错题/收藏复习入口,租户管理员 dashboard/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及学生越权后台和跨租户访问拒绝。 +开发流程: -真实迁移库本地 API 压测已跑 30/50/100/150 阶梯,均 0 错误;50 并发混合读写为 410.49 req/s、P95 237.41ms,100 并发混合读写为 393.56 req/s、P95 437.37ms,150 并发进入压力观察区。该结果用于证明当前后端代码和索引在本机真实数据下可跑通高并发刷题读写链路,不作为云服务器 SLA。 +```bash +git switch main +git pull --ff-only origin main +git switch -c codex/ -最近一轮真实迁移专项验证已通过 `npx supabase db reset`、`npm run pb:import:json`、`npm run pb:import:validate`、`npm run pb:import:sample` 和 `npm run check:importer`;`pb:import:sample` 当前为 0 failures、6 warnings、1 skipped、39 passed;production dry-run 仍按预期返回非 0,因为旧数据本身还剩订单缺用户和手册章节缺归属两个 blocker。`npm run test:auth:remote-smoke` 覆盖远程 Auth/JWKS 验收脚本自身。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖平台细粒度权限、公共题库跨租户同步运营状态、资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、销售/代理转化报表本人/全局权限、平台账单逾期 dry-run/催缴记录、平台审计告警查询/状态更新/越权拒绝/敏感 details 脱敏、平台审计告警通知渠道/事件查询和密钥不回显、平台催缴通知渠道/事件查询和密钥不回显、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:platform-billing` 覆盖平台 SaaS 订阅自动计费、重复开票保护、账单明细和审计。`npm run test:worker:platform-usage` 覆盖平台 SaaS 月度用量自动采集、11 类指标、手工调整记录不覆盖和重复运行幂等。`npm run test:worker:platform-usage-overage` 覆盖平台 SaaS 超额账单明细、重复开票保护、非法账期失败审计、审计告警生成和敏感错误信息脱敏。`npm run test:worker:platform-dunning` 覆盖平台 SaaS 逾期账单标记、内部催缴记录、租户 `past_due` 状态和每日催缴幂等。`npm run test:worker:platform-dunning-notifications` 覆盖平台 SaaS 催缴外部通知入队、generic webhook 发送、幂等、防重复、联系方式掩码、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:platform-audit-alerts` 覆盖平台审计告警生成、规则匹配、幂等、防重复和告警 details 脱敏。`npm run test:worker:platform-audit-notifications` 覆盖平台审计告警外部通知入队、generic webhook 发送、幂等、防重复、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:public-banks` 覆盖公共题库自动同步、失败通知和恢复自动关闭。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 当前为 0 vulnerabilities;Excel 解析已从 `exceljs` 切换为 `read-excel-file`,避免生产运行时携带 `exceljs -> uuid` 的已知中危依赖。 +# 修改、验证、提交后 +git push -u origin codex/ +``` -注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。 +在 Gitea 创建 `codex/ -> main` 的 PR。不要 force-push `main`。生产部署必须固定到已评审的 commit SHA,而不是在验证过程中继续改动分支。 -## 下一步建议 +本轮 production foundation 已按最终整体状态完成验证,可以作为一个受控 baseline 合入 `main`。合并前置不是继续等待功能开发,而是确保待提交文件完整、最终 commit 与验证/证据对应,并让服务器升级按新 runbook 执行。 -优先继续补: +凭据要求: -1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收;生产 `.env` 和数据库 provider 配置必须先通过 `readiness:production` 与 `readiness:production:db`,再开放前端联调域名。 -2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。 -3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 -4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。 -5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控、连续签到奖励深化、销售/代理转化预聚合和更细团队数据范围。排行榜不是默认主线功能,仅在租户显式购买/开启活动并完成压测后,才进入防刷、日/周榜预聚合和运营看板开发。 +- 已在聊天、截图、工单或日志中出现的 token 必须立即吊销。 +- 开发电脑优先使用 SSH key;HTTPS token 应交给系统 credential helper,不能写入 remote URL。 +- 服务器只使用仓库只读 deploy key/token,不得复用开发者可写凭据。 +- 真实 `.env`、`deploy.env`、上线 evidence 和 artifacts 已由既有忽略规则保护;真实 runtime config 只放服务器受控目录,仓库仅提交 `*.example.json` 模板。 -旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro H5/小程序补体验时只作为页面状态、微信平台能力和交互参考,不继承旧 PocketBase 直连和旧鉴权逻辑。 +## 部署选择 + +| 场景 | 推荐入口 | 配置位置 | 说明 | +| --- | --- | --- | --- | +| 全新测试/预生产服务器 | 部署手册“新测试服务器”分阶段 bootstrap | 独立 `/opt/tiku-saas-staging`、`/srv/tiku-saas-staging`、`/etc/tiku-saas-staging` | 当前没有绕过生产门禁的一键 staging 发布入口 | +| 全新生产服务器 | 根目录 `deploy.sh` | `/opt/tiku-saas/shared/` | 必须通过完整生产 evidence | +| 现有云服务器升级 | `scripts/deploy/bin/deploy.sh` 安装到 `/opt/tiku-saas/bin/deploy.sh` | `/etc/tiku-saas/` | 保留原命令,但必须先升级脚本、配置和 systemd units | + +服务器持续更新命令仍可以是: + +```bash +sudo -u deploy /opt/tiku-saas/bin/deploy.sh +``` + +但这次基础升级不能直接运行服务器上的旧脚本。旧脚本会原地构建、直接覆盖 H5,并重启已经废弃的 `tiku-worker.service`;新版 Worker 强制要求显式 `--job`,旧 unit 会失败。首次覆盖部署必须先按 [服务器部署手册](scripts/deploy/README.md) 完成一次性升级。 + +新版发布流程包含: + +- 独立候选 release 构建,应用与三端 Web 原子切换。 +- Taro 供应链、三端 runtime config、manifest、25 项静态与 33 项浏览器交互烟测。 +- 生产 env readiness、可选 migration、迁移后的数据库 readiness。 +- 与候选 commit 和 artifact SHA-256 绑定的真实 launch gate。 +- API healthcheck、线上 H5 hash 校验和代码/Web 回滚。 + +脚本不会自动备份或回滚数据库、对象存储,也不会自动安装更新后的 systemd/Nginx 模板。涉及 migration、`.service/.timer/.target`、Nginx 或 env schema 变化时,必须先按 runbook 做人工变更和恢复演练。 + +## 测试服务器 + +推荐先部署一台生产等价的隔离测试服务器: + +- 独立数据库、域名、对象存储 bucket、Auth 项目和 Provider 测试凭据。 +- 数据库标记为 `staging` 且 `allow_destructive_tests=false`。 +- API/Worker 仍以 `NODE_ENV=production` 运行,验证生产 fail-fast,而不是用 development 配置绕过。 +- 不导入生产密钥,不承接真实用户流量,不复用生产数据库或 bucket。 +- 可以使用脱敏数据快照,但不得运行 destructive smoke seed。 + +当前两套部署器都是 fail-closed 的生产发布器,在 production 模式下强制要求完整 launch evidence。仓库目前没有经过验证的“一键 staging 发布模式”,因此不能通过关闭 gate、伪造 production evidence 或套用生产 `/opt`、`/srv`、`/etc` 路径来抢跑。 + +测试服务器当前采用分阶段 bootstrap:先在隔离目录 clone 固定 commit,完成安装、构建、数据库 bootstrap/migration/readiness、三端 runtime config 和真实测试环境证据;确认系统服务与 Web 路径后,再按照部署手册激活。若只想检查候选构建,停在构建和 smoke 阶段,不切换 Web 和 systemd。后续可以增加带独立路径和独立证据模型的 staging profile,但不能把它实现为生产门禁的弱化开关。 + +## 生产上线门禁 + +真实 evidence 从模板创建,但不提交 Git: + +```bash +cp docs/refactor/production-launch-evidence.template.json /secure/path/production-launch-evidence.json +npm run launch:gate -- --evidence /secure/path/production-launch-evidence.json +``` + +注意: + +- evidence 的 `commit` 必须等于待发布 commit。 +- 每项 artifact 必须存在且 SHA-256 匹配,默认要求在有效时间窗内。 +- 相对 artifact 路径相对于 evidence 文件目录解析,必须把 evidence 与 `launch-artifacts/` 作为完整 bundle 保存。 +- 本地 mock、preview、clean-room 报告不能冒充生产证据。 +- 三端线上 hash/runtime config 验证通过后才能解除回滚保护。 + +上线前硬阻断包括:数据库/对象存储备份恢复、runtime role bootstrap、完整 migration、真实 Auth/短信/支付/存储/CORS、首个平台超管、目标规格压测、真实数据抽样、Worker 调度、告警日志和所有人工 attestation。详细顺序见 [生产地基基线](docs/refactor/production-foundation-baseline-20260712.md)。 + +## 首个平台超管 + +不要直接插入一个绕过 Auth 的管理员。先在真实 Supabase Auth 中创建或确认身份,再 dry-run: + +```bash +DATABASE_URL='' \ +BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID='' \ +BOOTSTRAP_PLATFORM_ADMIN_USERNAME='platform_owner' \ +BOOTSTRAP_PLATFORM_ADMIN_NAME='平台负责人' \ +npm run bootstrap:platform-admin +``` + +确认输出后使用精确短语应用: + +```bash +DATABASE_URL='' \ +BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID='' \ +BOOTSTRAP_PLATFORM_ADMIN_USERNAME='platform_owner' \ +BOOTSTRAP_PLATFORM_ADMIN_NAME='平台负责人' \ +npm run bootstrap:platform-admin -- \ + --apply --confirm BOOTSTRAP_FIRST_PLATFORM_ADMIN +``` + +该脚本只允许创建首个 Auth-bound active platform admin;一旦存在有效绑定管理员,后续 bootstrap 会永久拒绝,日常员工管理应走平台后台权限流。 + +## 关键文档 + +- [重构文档索引](docs/refactor/README.md) +- [架构](docs/refactor/architecture.md) +- [多租户鉴权安全契约](docs/refactor/multitenant-auth-security-contract.md) +- [前端交接索引](docs/refactor/frontend-handoff-index.md) +- [Taro H5 部署](docs/refactor/taro-h5-deployment.md) +- [PocketBase 真实数据迁移](docs/refactor/pocketbase-real-data-migration-runbook.md) +- [4C16G PostgreSQL 调优](docs/refactor/postgresql-4c16g-tuning.md) +- [容量压测](docs/refactor/performance-benchmark-runbook.md) +- [单租户十万学生容量](docs/refactor/tenant-student-capacity-runbook.md) +- [生产上线证据模板](docs/refactor/production-launch-evidence.template.json) + +## 安全红线 + +- 前端只允许持有 Supabase publishable key,禁止 service role、数据库密码、短信密钥、支付私钥和对象存储长期密钥。 +- 生产 API/Worker 不允许 `local_dev` 存储、mock Provider、legacy auth header 或平台本地管理 key。 +- migration 只使用临时注入的标准 migration role;API/Worker 运行角色不能执行 DDL。 +- 破坏性测试只允许明确标记为 local/test/ci 且 `allow_destructive_tests=true` 的隔离数据库;绝不允许 staging/production,并且必须使用精确确认短语。 +- migration 是前向操作,代码/Web 回滚不会回滚数据库;先备份、演练兼容性,再迁移。 +- 生产证据、访问 token、支付/短信/存储密钥和用户隐私数据不得写入 Git、README 或构建日志。 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index eb8d2874..68f68f29 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,4 +1,6 @@ -FROM node:20-alpine AS deps +ARG NODE_IMAGE=node:20.20.2-alpine3.23@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 + +FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY package.json package-lock.json ./ @@ -9,7 +11,11 @@ COPY packages/domain/package.json packages/domain/package.json COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json RUN npm ci --workspaces --include-workspace-root -FROM node:20-alpine AS build +FROM deps AS production-deps +RUN npm prune --omit=dev --workspaces --include-workspace-root \ + && npm cache clean --force + +FROM ${NODE_IMAGE} AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY package.json package-lock.json ./ @@ -17,18 +23,13 @@ COPY apps/api ./apps/api COPY packages ./packages RUN npm run build:api -FROM node:20-alpine AS runner +FROM ${NODE_IMAGE} AS runner ENV NODE_ENV=production WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY --from=build /app/apps/api/dist ./apps/api/dist -COPY package.json package-lock.json ./ -COPY apps/api/package.json apps/api/package.json -COPY packages/config/package.json packages/config/package.json -COPY packages/db/package.json packages/db/package.json -COPY packages/domain/package.json packages/domain/package.json -COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json +COPY --from=production-deps --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/apps/api/dist ./apps/api/dist EXPOSE 8787 -CMD ["npm", "--workspace", "@tiku-saas/api", "run", "start"] +USER node +CMD ["node", "apps/api/dist/apps/api/src/server.js"] diff --git a/apps/api/src/core/auth-context.ts b/apps/api/src/core/auth-context.ts index 1e45e728..832b6bdb 100644 --- a/apps/api/src/core/auth-context.ts +++ b/apps/api/src/core/auth-context.ts @@ -43,9 +43,11 @@ export function hashSessionToken(token: string) { return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex'); } -export async function findUserBySessionToken(token: string) { +type QueryOne = (sql: string, params?: unknown[]) => Promise; + +export async function findUserBySessionToken(token: string, queryUser: QueryOne = queryOne) { const tokenHash = hashSessionToken(token); - return queryOne( + return queryUser( ` select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.created_at as "createdAt", @@ -55,10 +57,24 @@ export async function findUserBySessionToken(token: string) { u.platform_permissions as "platformPermissions" from app_private.auth_sessions s join public.platform_users u on u.id = s.user_id + join public.tenants t on t.id = s.tenant_id where s.token_hash = $1 and u.status = 'active' and s.revoked_at is null and s.expires_at > now() + and ( + u.primary_role = 'platform_admin' + or ( + t.status = 'active' + and exists ( + select 1 + from public.tenant_memberships tm + where tm.tenant_id = s.tenant_id + and tm.user_id = s.user_id + and tm.status = 'active' + ) + ) + ) limit 1 `, [tokenHash], @@ -105,66 +121,48 @@ function tenantClaimFrom(payload: JWTPayload) { return typeof claim === 'string' && claim.trim() ? claim.trim() : ''; } -function appRoleClaimFrom(payload: JWTPayload) { - const claim = payload.app_role || objectClaim(payload, 'app_metadata').app_role || payload.role; - return typeof claim === 'string' && claim.trim() ? claim.trim() : ''; -} - function sessionExpiryFrom(payload: JWTPayload) { return payload.exp ? new Date(payload.exp * 1000).toISOString() : new Date(Date.now() + 60_000).toISOString(); } -export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') { - let payload: JWTPayload; - try { - payload = await verifySupabaseJwt(token); - } catch { - return null; - } - +export async function findUserByVerifiedSupabasePayload( + payload: JWTPayload, + requestedTenantContext = '', + queryUser: QueryOne = queryOne, +) { const authUserId = typeof payload.sub === 'string' && payload.sub ? payload.sub : ''; if (!authUserId) return null; const tenantClaim = tenantClaimFrom(payload); - if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null; const requestedTenantId = tenantClaim || requestedTenantContext; - const appRole = appRoleClaimFrom(payload); - const platformUser = await queryOne( + const platformUser = await queryUser( ` select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.created_at as "createdAt", - coalesce($2::uuid, tm.tenant_id) as "tenantId", + null::uuid as "tenantId", $1::text as "sessionId", - $3::timestamptz as "sessionExpiresAt", + $2::timestamptz as "sessionExpiresAt", 'supabase_jwt'::text as "authSource", u.auth_user_id as "authUserId", u.platform_permissions as "platformPermissions" from public.platform_users u - left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active' where u.auth_user_id = $1::uuid and u.status = 'active' and u.primary_role = 'platform_admin' - and ($2::uuid is null or exists ( - select 1 - from public.tenant_memberships scoped_tm - where scoped_tm.user_id = u.id - and scoped_tm.tenant_id = $2::uuid - and scoped_tm.status = 'active' - )) - order by tm.created_at asc nulls last limit 1 `, - [authUserId, requestedTenantId || null, sessionExpiryFrom(payload)], + [authUserId, sessionExpiryFrom(payload)], ); - if (platformUser && (!appRole || appRole === 'platform_admin' || appRole === 'service_role')) { - return platformUser; - } + // Supabase's top-level role is normally "authenticated". Platform authority + // comes exclusively from the active database user, never from JWT role claims. + if (platformUser) return platformUser; + if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null; if (!requestedTenantId) return null; - return queryOne( + return queryUser( ` select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.created_at as "createdAt", @@ -176,8 +174,10 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex u.platform_permissions as "platformPermissions" from public.platform_users u join public.tenant_memberships tm on tm.user_id = u.id + join public.tenants t on t.id = tm.tenant_id where u.auth_user_id = $1::uuid and u.status = 'active' + and t.status = 'active' and tm.status = 'active' and tm.tenant_id = $2::uuid order by case @@ -192,6 +192,16 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex ); } +export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') { + let payload: JWTPayload; + try { + payload = await verifySupabaseJwt(token); + } catch { + return null; + } + return findUserByVerifiedSupabasePayload(payload, requestedTenantContext); +} + export async function hydrateRequestAuth(ctx: RequestContext) { const cached = requestAuthState.get(ctx); if (cached?.sessionResolved) return cached; diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts index 51e73955..de837e48 100644 --- a/apps/api/src/core/config.ts +++ b/apps/api/src/core/config.ts @@ -6,6 +6,10 @@ export interface ApiConfig { databaseUrl: string; defaultTenantSlug: string; corsOrigins: string[]; + corsTenantDomainsEnabled: boolean; + corsTenantDomainCacheTtlMs: number; + corsTenantDomainNegativeCacheTtlMs: number; + corsTenantDomainCacheMaxEntries: number; maxJsonBodyBytes: number; maxImportJsonBodyBytes: number; authCodePepper: string; @@ -17,7 +21,16 @@ export interface ApiConfig { authJwtJwksUrl: string; authCodeTtlSeconds: number; authSmsCooldownSeconds: number; + authSmsTenantDailyLimit: number; + authSmsPhoneDailyLimit: number; + authSmsIpHourlyLimit: number; + authSmsDeviceHourlyLimit: number; authSessionTtlSeconds: number; + apiHeadersTimeoutMs: number; + apiRequestTimeoutMs: number; + apiKeepAliveTimeoutMs: number; + apiShutdownGracePeriodMs: number; + apiMaxRequestsPerSocket: number; allowLegacyAuthHeaders: boolean; allowPlatformAdminKey: boolean; platformAdminApiKey: string; @@ -66,6 +79,12 @@ function boundedBytes(key: string, fallback: number, hardMax = HARD_MAX_JSON_BOD return Math.min(Math.trunc(value), hardMax); } +function boundedPositiveNumber(key: string, fallback: number, min: number, max: number) { + const value = envNumber(key, fallback); + if (!Number.isFinite(value)) return fallback; + return Math.max(min, Math.min(max, Math.trunc(value))); +} + function isUnsafeSecret(value: string, defaultValue: string) { const normalized = value.trim().toLowerCase(); return ( @@ -100,11 +119,35 @@ function isAllowedHost(value: string, allowedHosts: string[]) { return allowedHosts.some(allowed => host === allowed || host.endsWith(`.${allowed}`)); } +function isProductionCorsOrigin(value: string) { + try { + const parsed = new URL(value.trim()); + const host = parsed.hostname.toLowerCase(); + const localHost = isLocalHost(host) || host.endsWith('.localhost') || /^127\./.test(host); + return ( + parsed.protocol === 'https:' + && !parsed.username + && !parsed.password + && parsed.pathname === '/' + && !parsed.search + && !parsed.hash + && Boolean(host) + && !localHost + ); + } catch { + return false; + } +} + function validateProductionConfig(nextConfig: ApiConfig) { if (!nextConfig.isProduction) return; const failures: string[] = []; if (nextConfig.corsOrigins.includes('*')) failures.push('CORS_ORIGIN must not include * in production'); + if (nextConfig.corsOrigins.length === 0 || nextConfig.corsOrigins.some(origin => !isProductionCorsOrigin(origin))) { + failures.push('CORS_ORIGIN must contain only production HTTPS origins without paths, query strings or credentials'); + } + if (!nextConfig.corsTenantDomainsEnabled) failures.push('CORS_TENANT_DOMAINS_ENABLED must be true in production'); if (!PRODUCTION_SMS_PROVIDERS.has(nextConfig.authSmsProvider.trim().toLowerCase().replace(/[_\s]/g, '-'))) { failures.push('AUTH_SMS_PROVIDER must be aliyun-pnvs in production'); } @@ -185,6 +228,10 @@ const loadedConfig: ApiConfig = { databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL), defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG), corsOrigins: envList('CORS_ORIGIN', '*'), + corsTenantDomainsEnabled: envBoolean('CORS_TENANT_DOMAINS_ENABLED', false), + corsTenantDomainCacheTtlMs: boundedPositiveNumber('CORS_TENANT_DOMAIN_CACHE_TTL_MS', 60_000, 1_000, 600_000), + corsTenantDomainNegativeCacheTtlMs: boundedPositiveNumber('CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS', 10_000, 1_000, 300_000), + corsTenantDomainCacheMaxEntries: boundedPositiveNumber('CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES', 10_000, 100, 100_000), maxJsonBodyBytes: boundedBytes('MAX_JSON_BODY_BYTES', DEFAULT_MAX_JSON_BODY_BYTES), maxImportJsonBodyBytes: boundedBytes('MAX_IMPORT_JSON_BODY_BYTES', DEFAULT_MAX_IMPORT_JSON_BODY_BYTES), authCodePepper: envString('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER), @@ -196,7 +243,16 @@ const loadedConfig: ApiConfig = { authJwtJwksUrl: envString('AUTH_JWT_JWKS_URL', ''), authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300), authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60), + authSmsTenantDailyLimit: boundedPositiveNumber('AUTH_SMS_TENANT_DAILY_LIMIT', 20_000, 1, 10_000_000), + authSmsPhoneDailyLimit: boundedPositiveNumber('AUTH_SMS_PHONE_DAILY_LIMIT', 10, 1, 10_000), + authSmsIpHourlyLimit: boundedPositiveNumber('AUTH_SMS_IP_HOURLY_LIMIT', 120, 1, 1_000_000), + authSmsDeviceHourlyLimit: boundedPositiveNumber('AUTH_SMS_DEVICE_HOURLY_LIMIT', 10, 1, 100_000), authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7), + apiHeadersTimeoutMs: boundedPositiveNumber('API_HEADERS_TIMEOUT_MS', 15_000, 1_000, 120_000), + apiRequestTimeoutMs: boundedPositiveNumber('API_REQUEST_TIMEOUT_MS', 120_000, 5_000, 600_000), + apiKeepAliveTimeoutMs: boundedPositiveNumber('API_KEEP_ALIVE_TIMEOUT_MS', 5_000, 1_000, 120_000), + apiShutdownGracePeriodMs: boundedPositiveNumber('API_SHUTDOWN_GRACE_PERIOD_MS', 30_000, 1_000, 300_000), + apiMaxRequestsPerSocket: boundedPositiveNumber('API_MAX_REQUESTS_PER_SOCKET', 1_000, 1, 100_000), allowLegacyAuthHeaders: envBoolean('ALLOW_LEGACY_AUTH_HEADERS', !isProduction), allowPlatformAdminKey: envBoolean('ALLOW_PLATFORM_ADMIN_KEY', !isProduction), platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY), diff --git a/apps/api/src/core/cors.ts b/apps/api/src/core/cors.ts new file mode 100644 index 00000000..339d0199 --- /dev/null +++ b/apps/api/src/core/cors.ts @@ -0,0 +1,225 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { queryOne } from './db.js'; +import { isLocalTenantHost } from '../features/tenant/locator.js'; + +export type CorsDecisionReason = + | 'no-origin' + | 'wildcard' + | 'static-origin' + | 'tenant-domain' + | 'invalid-origin' + | 'tenant-domain-disabled' + | 'tenant-domain-lookup-failed'; + +export interface CorsDecision { + allowed: boolean; + allowOrigin: string | null; + reason: CorsDecisionReason; +} + +export interface CorsPolicyOptions { + staticOrigins: string[]; + tenantDomainsEnabled: boolean; + positiveCacheTtlMs: number; + negativeCacheTtlMs: number; + maxCacheEntries: number; + lookupTenantDomain?: (host: string) => Promise; + now?: () => number; + onLookupError?: (error: unknown, host: string) => void; +} + +interface NormalizedOrigin { + origin: string; + hostname: string; + protocol: 'http:' | 'https:'; + hasNonDefaultPort: boolean; +} + +interface CachedTenantDomain { + allowed: boolean; + expiresAt: number; + lookupFailed: boolean; +} + +const CORS_ALLOW_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS'; +const CORS_ALLOW_HEADERS = 'content-type,authorization,x-request-id,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key'; + +function firstHeader(req: IncomingMessage, name: string) { + if (name.toLowerCase() === 'origin') { + const originHeaders = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'origin'); + if (originHeaders.length > 1) return '__multiple_origin_headers__'; + } + const value = req.headers[name.toLowerCase()]; + if (Array.isArray(value)) return value[0] || ''; + return value || ''; +} + +function addVaryHeader(res: ServerResponse, value: string) { + const current = res.getHeader('vary'); + const values = (Array.isArray(current) ? current : String(current || '').split(',')) + .map(item => String(item).trim()) + .filter(Boolean); + if (!values.some(item => item.toLowerCase() === value.toLowerCase())) values.push(value); + res.setHeader('vary', values.join(', ')); +} + +export function normalizeCorsOrigin(value: string): NormalizedOrigin | null { + const raw = value.trim(); + if (!raw || raw === 'null' || raw.includes(',') || /\s/.test(raw)) return null; + + try { + const parsed = new URL(raw); + if (!['http:', 'https:'].includes(parsed.protocol)) return null; + if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null; + if (!parsed.hostname || parsed.hostname.endsWith('.')) return null; + + const protocol = parsed.protocol as 'http:' | 'https:'; + const defaultPort = protocol === 'https:' ? '443' : '80'; + return { + origin: parsed.origin.toLowerCase(), + hostname: parsed.hostname.toLowerCase(), + protocol, + hasNonDefaultPort: Boolean(parsed.port && parsed.port !== defaultPort), + }; + } catch { + return null; + } +} + +async function lookupActiveTenantDomain(host: string) { + const row = await queryOne<{ allowed: boolean }>( + ` + select exists ( + select 1 + from public.tenant_domains d + join public.tenants t on t.id = d.tenant_id + where d.host = $1 + and d.status = 'active' + and t.status = 'active' + ) as allowed + `, + [host], + ); + return row?.allowed === true; +} + +export class CorsPolicy { + private readonly staticOrigins: Set; + private readonly allowAll: boolean; + private readonly tenantDomainsEnabled: boolean; + private readonly positiveCacheTtlMs: number; + private readonly negativeCacheTtlMs: number; + private readonly maxCacheEntries: number; + private readonly lookupTenantDomain: (host: string) => Promise; + private readonly now: () => number; + private readonly onLookupError?: (error: unknown, host: string) => void; + private readonly cache = new Map(); + private readonly pendingLookups = new Map>(); + + constructor(options: CorsPolicyOptions) { + this.allowAll = options.staticOrigins.includes('*'); + this.staticOrigins = new Set( + options.staticOrigins + .filter(origin => origin !== '*') + .map(origin => normalizeCorsOrigin(origin)?.origin || '') + .filter(Boolean), + ); + this.tenantDomainsEnabled = options.tenantDomainsEnabled; + this.positiveCacheTtlMs = options.positiveCacheTtlMs; + this.negativeCacheTtlMs = options.negativeCacheTtlMs; + this.maxCacheEntries = options.maxCacheEntries; + this.lookupTenantDomain = options.lookupTenantDomain || lookupActiveTenantDomain; + this.now = options.now || Date.now; + this.onLookupError = options.onLookupError; + } + + async evaluate(rawOrigin: string): Promise { + if (!rawOrigin.trim()) return { allowed: true, allowOrigin: null, reason: 'no-origin' }; + + const normalized = normalizeCorsOrigin(rawOrigin); + if (!normalized) return { allowed: false, allowOrigin: null, reason: 'invalid-origin' }; + if (this.allowAll) return { allowed: true, allowOrigin: '*', reason: 'wildcard' }; + if (this.staticOrigins.has(normalized.origin)) { + return { allowed: true, allowOrigin: normalized.origin, reason: 'static-origin' }; + } + + // Tenant browser domains are production HTTPS hosts. Development ports belong + // in the explicit static allowlist and must never be inferred from Host headers. + if ( + !this.tenantDomainsEnabled + || normalized.protocol !== 'https:' + || normalized.hasNonDefaultPort + || isLocalTenantHost(normalized.hostname) + ) { + return { allowed: false, allowOrigin: null, reason: 'tenant-domain-disabled' }; + } + + const lookup = await this.lookupWithCache(normalized.hostname); + if (!lookup.allowed) { + return { + allowed: false, + allowOrigin: null, + reason: lookup.lookupFailed ? 'tenant-domain-lookup-failed' : 'tenant-domain-disabled', + }; + } + + return { allowed: true, allowOrigin: normalized.origin, reason: 'tenant-domain' }; + } + + private async lookupWithCache(host: string) { + const now = this.now(); + const cached = this.cache.get(host); + if (cached && cached.expiresAt > now) { + this.cache.delete(host); + this.cache.set(host, cached); + return { allowed: cached.allowed, lookupFailed: cached.lookupFailed }; + } + if (cached) this.cache.delete(host); + + const pending = this.pendingLookups.get(host); + if (pending) return pending; + + const lookup = this.performLookup(host); + this.pendingLookups.set(host, lookup); + try { + return await lookup; + } finally { + this.pendingLookups.delete(host); + } + } + + private async performLookup(host: string) { + let allowed = false; + let lookupFailed = false; + try { + allowed = await this.lookupTenantDomain(host); + } catch (error) { + lookupFailed = true; + this.onLookupError?.(error, host); + } + + const ttlMs = allowed ? this.positiveCacheTtlMs : this.negativeCacheTtlMs; + this.storeCache(host, { allowed, expiresAt: this.now() + ttlMs, lookupFailed }); + return { allowed, lookupFailed }; + } + + private storeCache(host: string, entry: CachedTenantDomain) { + this.cache.delete(host); + while (this.cache.size >= this.maxCacheEntries) { + const oldest = this.cache.keys().next().value as string | undefined; + if (!oldest) break; + this.cache.delete(oldest); + } + this.cache.set(host, entry); + } +} + +export async function authorizeCorsRequest(req: IncomingMessage, res: ServerResponse, policy: CorsPolicy) { + const decision = await policy.evaluate(firstHeader(req, 'origin')); + res.setHeader('access-control-allow-methods', CORS_ALLOW_METHODS); + res.setHeader('access-control-allow-headers', CORS_ALLOW_HEADERS); + res.setHeader('access-control-expose-headers', 'x-request-id'); + if (decision.allowOrigin) res.setHeader('access-control-allow-origin', decision.allowOrigin); + if (decision.reason !== 'wildcard') addVaryHeader(res, 'Origin'); + return decision; +} diff --git a/apps/api/src/core/db.ts b/apps/api/src/core/db.ts index 4b4e33eb..789178f4 100644 --- a/apps/api/src/core/db.ts +++ b/apps/api/src/core/db.ts @@ -4,7 +4,7 @@ import { DEFAULT_DATABASE_URL } from '../../../../packages/config/src/index.js'; export const pool = createPool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, - max: 10, + applicationName: 'tiku-api', }); export async function query(sql: string, params: unknown[] = []): Promise { diff --git a/apps/api/src/core/http.ts b/apps/api/src/core/http.ts index 7b0977dc..94d34165 100644 --- a/apps/api/src/core/http.ts +++ b/apps/api/src/core/http.ts @@ -9,10 +9,29 @@ export interface RequestContext { req: IncomingMessage; res: ServerResponse; url: URL; + requestId: string; } export type Handler = (ctx: RequestContext) => Promise; +export interface ApiResponseMeta { + requestId: string; +} + +export function withResponseMeta(body: unknown, requestId: string) { + if (body && typeof body === 'object' && !Array.isArray(body)) { + const record = body as Record; + const existingMeta = record.meta && typeof record.meta === 'object' && !Array.isArray(record.meta) + ? record.meta as Record + : {}; + return { + ...record, + meta: { ...existingMeta, requestId }, + }; + } + return { data: body, meta: { requestId } }; +} + export function sendJson(res: ServerResponse, statusCode: number, body: unknown) { res.statusCode = statusCode; res.setHeader('content-type', 'application/json; charset=utf-8'); @@ -25,19 +44,6 @@ export function getHeader(req: IncomingMessage, name: string): string { return value || ''; } -export function applyCors(req: IncomingMessage, res: ServerResponse) { - const origin = getHeader(req, 'origin'); - const allowAll = config.corsOrigins.includes('*'); - if (allowAll) { - res.setHeader('access-control-allow-origin', '*'); - } else if (origin && config.corsOrigins.includes(origin)) { - res.setHeader('access-control-allow-origin', origin); - res.setHeader('vary', 'origin'); - } - res.setHeader('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); - res.setHeader('access-control-allow-headers', 'content-type,authorization,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key'); -} - export function routeKey(method: string | undefined, pathname: string) { return `${method || 'GET'} ${pathname}`; } diff --git a/apps/api/src/core/request-id.ts b/apps/api/src/core/request-id.ts new file mode 100644 index 00000000..f146e1d8 --- /dev/null +++ b/apps/api/src/core/request-id.ts @@ -0,0 +1,10 @@ +import crypto from 'node:crypto'; +import type { IncomingMessage } from 'node:http'; +import { getHeader } from './http.js'; + +const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +export function requestIdFrom(req: IncomingMessage) { + const provided = getHeader(req, 'x-request-id').trim(); + return REQUEST_ID_PATTERN.test(provided) ? provided : crypto.randomUUID(); +} diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 61ae3c1e..850ee27a 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -26,6 +26,7 @@ import { writeLoginEvent, type PlatformUserSummary, } from './service.js'; +import { hashSmsDeviceId, normalizeSmsDeviceId, reserveSmsSend } from './sms-limits.js'; interface SmsCodeRow { id: string; @@ -37,10 +38,6 @@ interface SmsCodeRow { metadata: Record | null; } -interface CooldownRow { - createdAt: string; -} - type SmsVerifyResult = | { ok: false; @@ -265,29 +262,8 @@ export async function sendSmsCodeRoute(ctx: RequestContext) { const ipAddress = clientIpFrom(ctx); const userAgent = userAgentFrom(ctx); const metadata = jsonObject(body.metadata); - - const cooldownRows = await query( - ` - select created_at as "createdAt" - from public.sms_verification_codes - where tenant_id = $1 - and phone = $2 - and purpose = $3 - and consumed_at is null - and status in ('pending', 'sent') - and created_at > now() - ($4::text || ' seconds')::interval - order by created_at desc - limit 1 - `, - [tenantId, phone, purpose, config.authSmsCooldownSeconds], - ); - - const cooldownRow = cooldownRows[0]; - if (cooldownRow) { - const elapsedSeconds = Math.floor((Date.now() - new Date(cooldownRow.createdAt).getTime()) / 1000); - const cooldown = Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds); - throw new HttpError(429, `SMS code was sent too frequently. Retry after ${cooldown} seconds.`, 'SMS_COOLDOWN'); - } + const deviceId = normalizeSmsDeviceId(optionalString(body, 'deviceId')); + const deviceHash = hashSmsDeviceId(deviceId); const providerChoice = await activeSmsProvider(tenantId); const provider = createSmsProvider(providerChoice.name, providerChoice.providerConfig); @@ -297,50 +273,89 @@ export async function sendSmsCodeRoute(ctx: RequestContext) { const code = generateSmsCode(); const outId = crypto.randomUUID(); - const providerResult = await provider.send({ + const codeHash = hashSmsCode(tenantId, phone, purpose, code); + const reservedExpiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString(); + const reservation = await transaction(client => reserveSmsSend(client, { tenantId, phone, - code, purpose, + codeHash, + provider: provider.name, + expiresAt: reservedExpiresAt, + ipAddress, + userAgent, + deviceHash, outId, - ttlSeconds: config.authCodeTtlSeconds, - cooldownSeconds: config.authSmsCooldownSeconds, metadata, - }); + })); + + let providerResult; + try { + providerResult = await provider.send({ + tenantId, + phone, + code, + purpose, + outId, + ttlSeconds: config.authCodeTtlSeconds, + cooldownSeconds: config.authSmsCooldownSeconds, + metadata, + }); + } catch (error) { + await transaction(async client => { + await client.query( + ` + update public.sms_verification_codes + set metadata = metadata || $3::jsonb + where tenant_id = $1 and id = $2 and status = 'pending' + `, + [tenantId, reservation.id, JSON.stringify({ providerSendFailedAt: new Date().toISOString() })], + ); + await writeLoginEvent(client, { + tenantId, + provider: `sms:${provider.name}`, + identifier: phone, + result: 'failed', + failureCode: 'SMS_PROVIDER_SEND_FAILED', + ipAddress, + userAgent, + metadata: { purpose, reservationId: reservation.id }, + }); + }).catch(() => undefined); + throw error; + } const ttlSeconds = Number.isFinite(providerResult.ttlSeconds) && Number(providerResult.ttlSeconds) > 0 ? Math.trunc(Number(providerResult.ttlSeconds)) : config.authCodeTtlSeconds; - const codeHash = hashSmsCode(tenantId, phone, purpose, code); const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString(); const item = await transaction(async client => { - const insertResult = await client.query( + const updateResult = await client.query( ` - insert into public.sms_verification_codes ( - tenant_id, phone, purpose, code_hash, provider, status, expires_at, - ip_address, user_agent, metadata - ) - values ($1, $2, $3, $4, $5, 'sent', $6::timestamptz, $7, $8, $9::jsonb) + update public.sms_verification_codes + set provider = $3, + status = 'sent', + expires_at = $4::timestamptz, + metadata = metadata || $5::jsonb + where tenant_id = $1 and id = $2 and status = 'pending' returning id, phone, purpose, provider, status, expires_at as "expiresAt", created_at as "createdAt" `, [ tenantId, - phone, - purpose, - codeHash, + reservation.id, providerResult.provider, expiresAt, - ipAddress || null, - userAgent || null, JSON.stringify({ - ...metadata, - outId, verification: providerResult.verification || 'local', providerStatus: providerResult.status, providerMessageId: providerResult.providerMessageId || null, + reservation: false, }), ], ); + if (!updateResult.rows[0]) { + throw new HttpError(409, 'SMS send reservation is no longer active', 'SMS_RESERVATION_LOST'); + } await writeLoginEvent(client, { tenantId, @@ -352,7 +367,7 @@ export async function sendSmsCodeRoute(ctx: RequestContext) { metadata: { purpose }, }); - return insertResult.rows[0]; + return updateResult.rows[0]; }); return { diff --git a/apps/api/src/features/auth/service.ts b/apps/api/src/features/auth/service.ts index f718e486..58a26493 100644 --- a/apps/api/src/features/auth/service.ts +++ b/apps/api/src/features/auth/service.ts @@ -21,7 +21,12 @@ export interface LoginSessionSummary { export function clientIpFrom(ctx: RequestContext) { const forwarded = getHeader(ctx.req, 'x-forwarded-for'); - return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim(); + const remoteAddress = (ctx.req.socket.remoteAddress || '').trim(); + const trustedProxy = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(remoteAddress); + if (trustedProxy) { + return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || remoteAddress).trim(); + } + return remoteAddress; } export function userAgentFrom(ctx: RequestContext) { @@ -101,6 +106,44 @@ export async function createLoginSession( metadata?: Record; }, ): Promise { + const authority = await client.query<{ tenantStatus: string; userStatus: string; primaryRole: string }>( + ` + select t.status as "tenantStatus", + u.status as "userStatus", + u.primary_role as "primaryRole" + from public.tenants t + join public.platform_users u on u.id = $2::uuid + where t.id = $1::uuid + for update of t, u + `, + [input.tenantId, input.userId], + ); + const authorityRow = authority.rows[0]; + if (!authorityRow || authorityRow.userStatus !== 'active') { + throw new HttpError(403, 'Account is disabled', 'AUTH_USER_INACTIVE'); + } + if (authorityRow.primaryRole !== 'platform_admin') { + if (authorityRow.tenantStatus !== 'active') { + throw new HttpError(403, 'Tenant is not active', 'AUTH_TENANT_INACTIVE'); + } + const membership = await client.query<{ status: string }>( + ` + select status + from public.tenant_memberships + where tenant_id = $1::uuid + and user_id = $2::uuid + and status = 'active' + order by created_at asc + limit 1 + for update + `, + [input.tenantId, input.userId], + ); + if (!membership.rows[0]) { + throw new HttpError(403, 'Tenant membership is not active', 'AUTH_MEMBERSHIP_INACTIVE'); + } + } + const token = createSessionToken(); const tokenHash = hashSessionToken(token); const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString(); @@ -393,15 +436,51 @@ export async function upsertOAuthUser( } export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) { - await client.query( + const authority = await client.query<{ tenantStatus: string; userStatus: string }>( + ` + select t.status as "tenantStatus", u.status as "userStatus" + from public.tenants t + join public.platform_users u on u.id = $2::uuid + where t.id = $1::uuid + for update of t, u + `, + [tenantId, userId], + ); + const authorityRow = authority.rows[0]; + if (!authorityRow || authorityRow.userStatus !== 'active') { + throw new HttpError(403, 'Account is disabled', 'AUTH_USER_INACTIVE'); + } + if (authorityRow.tenantStatus !== 'active') { + throw new HttpError(403, 'Tenant is not active', 'AUTH_TENANT_INACTIVE'); + } + + const insertedMembership = await client.query<{ status: string }>( ` insert into public.tenant_memberships (tenant_id, user_id, role, status) values ($1, $2, 'student', 'active') on conflict (tenant_id, user_id, role) - do update set status = 'active', updated_at = now() + do nothing + returning status `, [tenantId, userId], ); + const membership = insertedMembership.rows[0] + ? insertedMembership + : await client.query<{ status: string }>( + ` + select status + from public.tenant_memberships + where tenant_id = $1::uuid + and user_id = $2::uuid + and role = 'student' + limit 1 + for update + `, + [tenantId, userId], + ); + if (membership.rows[0]?.status !== 'active') { + throw new HttpError(403, 'Tenant membership is not active', 'AUTH_MEMBERSHIP_INACTIVE'); + } await client.query( ` diff --git a/apps/api/src/features/auth/sms-limits.ts b/apps/api/src/features/auth/sms-limits.ts new file mode 100644 index 00000000..c99c7b6b --- /dev/null +++ b/apps/api/src/features/auth/sms-limits.ts @@ -0,0 +1,216 @@ +import crypto from 'node:crypto'; +import type pg from 'pg'; +import { config } from '../../core/config.js'; +import { HttpError } from '../../core/http.js'; + +interface SmsSendReservation { + id: string; + createdAt: string; +} + +function retryAfterSeconds(createdAt: string) { + const elapsedSeconds = Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000); + return Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds); +} + +function quotaError(code: string, message: string) { + return new HttpError(429, message, code); +} + +function quotaScopeHash(dimension: string, value: string) { + return crypto.createHmac('sha256', config.authCodePepper).update(`sms-quota:${dimension}:${value}`).digest('hex'); +} + +async function consumeQuota( + client: pg.PoolClient, + input: { + tenantId: string; + dimension: 'tenant' | 'phone' | 'ip' | 'device'; + scopeValue: string; + bucket: 'hour' | 'day'; + limit: number; + code: string; + message: string; + }, +) { + if (!input.scopeValue) return; + const result = await client.query( + ` + insert into app_private.sms_send_rate_limits ( + tenant_id, dimension, scope_hash, bucket_start, request_count + ) + values ( + $1, $2, $3, + case + when $4 = 'day' then date_trunc('day', now() at time zone 'Asia/Shanghai') at time zone 'Asia/Shanghai' + else date_trunc('hour', now()) + end, + 1 + ) + on conflict (tenant_id, dimension, scope_hash, bucket_start) + do update set request_count = app_private.sms_send_rate_limits.request_count + 1, + updated_at = now() + where app_private.sms_send_rate_limits.request_count < $5 + returning request_count + `, + [ + input.tenantId, + input.dimension, + quotaScopeHash(input.dimension, input.scopeValue), + input.bucket, + input.limit, + ], + ); + if (!result.rows[0]) throw quotaError(input.code, input.message); +} + +export function normalizeSmsDeviceId(value: string) { + const normalized = value.trim(); + if (!normalized) return ''; + if (normalized.length > 200 || !/^[A-Za-z0-9._:-]+$/.test(normalized)) { + throw new HttpError(400, 'Invalid device identifier', 'INVALID_DEVICE_ID'); + } + return normalized; +} + +export function hashSmsDeviceId(value: string) { + const normalized = normalizeSmsDeviceId(value); + if (!normalized) return ''; + return crypto.createHmac('sha256', config.authCodePepper).update(`sms-device:${normalized}`).digest('hex'); +} + +export async function reserveSmsSend( + client: pg.PoolClient, + input: { + tenantId: string; + phone: string; + purpose: string; + codeHash: string; + provider: string; + expiresAt: string; + ipAddress: string; + userAgent: string; + deviceHash: string; + outId: string; + metadata: Record; + }, +): Promise { + await client.query( + `select pg_advisory_xact_lock(hashtextextended($1, 0))`, + [`sms-send:${input.tenantId}:${input.phone}:${input.purpose}`], + ); + + await client.query( + ` + with stale as ( + select ctid + from app_private.sms_send_rate_limits + where updated_at < now() - interval '3 days' + order by updated_at asc + limit 32 + for update skip locked + ) + delete from app_private.sms_send_rate_limits limits + using stale + where limits.ctid = stale.ctid + `, + ); + + const existing = await client.query<{ createdAt: string }>( + ` + select created_at as "createdAt" + from public.sms_verification_codes + where tenant_id = $1 + and phone = $2 + and purpose = $3 + and consumed_at is null + and status in ('pending', 'sent') + and created_at > now() - ($4::text || ' seconds')::interval + order by created_at desc + limit 1 + `, + [input.tenantId, input.phone, input.purpose, config.authSmsCooldownSeconds], + ); + if (existing.rows[0]) { + const cooldown = retryAfterSeconds(existing.rows[0].createdAt); + throw quotaError('SMS_COOLDOWN', `SMS code was sent too frequently. Retry after ${cooldown} seconds.`); + } + + await client.query( + ` + update public.sms_verification_codes + set status = 'expired' + where tenant_id = $1 + and phone = $2 + and purpose = $3 + and consumed_at is null + and status in ('pending', 'sent') + `, + [input.tenantId, input.phone, input.purpose], + ); + + await consumeQuota(client, { + tenantId: input.tenantId, + dimension: 'tenant', + scopeValue: input.tenantId, + bucket: 'day', + limit: config.authSmsTenantDailyLimit, + code: 'SMS_TENANT_DAILY_LIMIT', + message: 'Tenant SMS daily quota exceeded', + }); + await consumeQuota(client, { + tenantId: input.tenantId, + dimension: 'phone', + scopeValue: input.phone, + bucket: 'day', + limit: config.authSmsPhoneDailyLimit, + code: 'SMS_PHONE_DAILY_LIMIT', + message: 'SMS daily quota exceeded for this phone number', + }); + await consumeQuota(client, { + tenantId: input.tenantId, + dimension: 'ip', + scopeValue: input.ipAddress, + bucket: 'hour', + limit: config.authSmsIpHourlyLimit, + code: 'SMS_IP_HOURLY_LIMIT', + message: 'SMS hourly quota exceeded for this network', + }); + await consumeQuota(client, { + tenantId: input.tenantId, + dimension: 'device', + scopeValue: input.deviceHash, + bucket: 'hour', + limit: config.authSmsDeviceHourlyLimit, + code: 'SMS_DEVICE_HOURLY_LIMIT', + message: 'SMS hourly quota exceeded for this device', + }); + + const result = await client.query( + ` + insert into public.sms_verification_codes ( + tenant_id, phone, purpose, code_hash, provider, status, expires_at, + ip_address, user_agent, metadata + ) + values ($1, $2, $3, $4, $5, 'pending', $6::timestamptz, $7, $8, $9::jsonb) + returning id, created_at as "createdAt" + `, + [ + input.tenantId, + input.phone, + input.purpose, + input.codeHash, + input.provider, + input.expiresAt, + input.ipAddress || null, + input.userAgent || null, + JSON.stringify({ + ...input.metadata, + outId: input.outId, + deviceHash: input.deviceHash || undefined, + reservation: true, + }), + ], + ); + return result.rows[0]; +} diff --git a/apps/api/src/features/catalog/navigation.ts b/apps/api/src/features/catalog/navigation.ts index 0200acbe..7400678f 100644 --- a/apps/api/src/features/catalog/navigation.ts +++ b/apps/api/src/features/catalog/navigation.ts @@ -233,7 +233,10 @@ export async function collectionQuestionsRoute(ctx: RequestContext) { q.created_at as "createdAt", q.updated_at as "updatedAt" from public.question_collection_items ci join public.questions q on q.id = ci.question_id and q.tenant_id = ci.tenant_id - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where ci.tenant_id = $1 and ci.collection_id = $2 and q.status = 'published' diff --git a/apps/api/src/features/catalog/routes.ts b/apps/api/src/features/catalog/routes.ts index 64b1af8f..7a8d8874 100644 --- a/apps/api/src/features/catalog/routes.ts +++ b/apps/api/src/features/catalog/routes.ts @@ -314,7 +314,10 @@ export async function questionsRoute(ctx: RequestContext) { v.code_lang as "codeLang", v.code_template as "codeTemplate", q.created_at as "createdAt", q.updated_at as "updatedAt" from public.questions q - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where ${filters.join(' and ')} order by q.created_at desc limit $${params.length} diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index 8efc2500..758b07a3 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -1195,7 +1195,10 @@ export async function submitAnswerRoute(ctx: RequestContext) { q.type, v.correct_option_index, v.correct_option_indices, v.answer_text, v.sub_questions from public.questions q - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where q.tenant_id = $1 and q.id = $2 and q.status = 'published' limit 1 `, @@ -1431,7 +1434,10 @@ async function buildPracticeSessionReport( v.correct_option_indices as "correctOptionIndices", v.answer_text as "answerText", v.sub_questions as "subQuestions" from public.questions q - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id left join public.question_collection_items ci on ci.tenant_id = q.tenant_id and ci.question_id = q.id @@ -1772,7 +1778,10 @@ export async function practiceSessionDetailRoute(ctx: RequestContext) { v.code_lang as "codeLang", v.code_template as "codeTemplate", q.created_at as "createdAt", q.updated_at as "updatedAt" from public.questions q - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where q.tenant_id = $1 and q.id = any($2::uuid[]) `, [tenantId, questionIds], @@ -2171,7 +2180,10 @@ export async function wrongQuestionReviewPlanRoute(ctx: RequestContext) { ) as "suggestedReviewAt" from public.wrong_questions wq join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id @@ -2222,7 +2234,10 @@ export async function favoriteQuestionsRoute(ctx: RequestContext) { q.type, q.type_label as "typeLabel", v.content from public.favorite_questions fq join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where fq.tenant_id = $1 and fq.user_id = $2 order by fq.created_at desc limit $3 @@ -2276,7 +2291,10 @@ export async function wrongQuestionsRoute(ctx: RequestContext) { q.type, q.type_label as "typeLabel", v.content from public.wrong_questions wq join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where wq.tenant_id = $1 and wq.user_id = $2 and ($3::boolean = false or wq.resolved_at is null) order by wq.last_wrong_at desc diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts index b2047c57..744220fe 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -57,6 +57,7 @@ function truncate(value: unknown, max = 1900) { } const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const TENANT_STATUSES = new Set(['draft', 'active', 'suspended', 'archived']); const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']); const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']); const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']); @@ -701,16 +702,13 @@ export async function upsertPlatformStaffRoute(ctx: RequestContext) { const item = await transaction(async client => { let targetStaffId = staffId || null; - const authUser = await client.query( + const authUser = await client.query<{ exists: boolean }>( ` - select id - from auth.users - where id = $1::uuid - limit 1 + select app.auth_user_exists($1::uuid) as exists `, [authUserId], ); - if (authUser.rowCount === 0) { + if (!authUser.rows[0]?.exists) { throw new HttpError(404, 'Supabase Auth user not found', 'AUTH_USER_NOT_FOUND'); } @@ -2241,6 +2239,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) { const status = optionalString(body, 'status'); const billingStatus = optionalString(body, 'billingStatus'); if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD'); + if (status && !TENANT_STATUSES.has(status)) throw new HttpError(400, 'Unsupported tenant status', 'INVALID_STATUS'); const item = await transaction(async client => { const result = await client.query( @@ -2258,6 +2257,25 @@ export async function updateTenantStatusRoute(ctx: RequestContext) { ); if (!result.rows[0]) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND'); + if (status && status !== 'active') { + await client.query( + ` + update app_private.auth_sessions + set revoked_at = now(), + updated_at = now(), + metadata = metadata || $2::jsonb + where tenant_id = $1 + and revoked_at is null + `, + [ + tenantId, + JSON.stringify({ + revokedBy: 'platform.tenant.status_updated', + tenantStatus: status, + }), + ], + ); + } await recordPlatformAudit(client, ctx, 'platform.tenant.status_updated', 'tenant', tenantId, { status: status || null, billingStatus: billingStatus || null, diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 28dc1267..fa0a0ba5 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -35,6 +35,11 @@ export interface TenantAdminAuth { dataScope: Record; } +export type TenantPermissionContext = Pick< + TenantAdminAuth, + 'role' | 'permissions' | 'templatePermissions' +>; + function permissionKeys(permission: string) { const parts = permission.split(':').filter(Boolean); const keys = [permission]; @@ -53,15 +58,23 @@ function explicitPermission(permissions: Record, permission: st return null; } -export function hasTenantPermission(auth: TenantAdminAuth, permission: string) { +export function hasResolvedTenantPermission( + auth: TenantPermissionContext, + permission: string, + roleDefaultAllowed: boolean, +) { const explicit = explicitPermission(auth.permissions, permission); if (explicit !== null) return explicit; const templateExplicit = explicitPermission(auth.templatePermissions, permission); if (templateExplicit !== null) return templateExplicit; + return roleDefaultAllowed; +} + +export function hasTenantPermission(auth: TenantPermissionContext, permission: string) { const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || []; - return defaults.some(defaultPermission => { + const roleDefaultAllowed = defaults.some(defaultPermission => { if (defaultPermission === '*') return true; if (defaultPermission === permission) return true; if (defaultPermission.endsWith(':*')) { @@ -69,6 +82,7 @@ export function hasTenantPermission(auth: TenantAdminAuth, permission: string) { } return false; }); + return hasResolvedTenantPermission(auth, permission, roleDefaultAllowed); } export function requireTenantPermission(auth: TenantAdminAuth, permission: string) { diff --git a/apps/api/src/features/tenant-admin/classes.ts b/apps/api/src/features/tenant-admin/classes.ts index 15136ae8..c586ceb3 100644 --- a/apps/api/src/features/tenant-admin/classes.ts +++ b/apps/api/src/features/tenant-admin/classes.ts @@ -21,6 +21,11 @@ import { scopedSupervisionClassIds, supervisionBatchKey, } from './supervision.js'; +import { + containsSearchPattern, + decodeTenantStudentsCursor, + encodeTenantStudentsCursor, +} from './student-cursor.js'; type JsonBody = Record; @@ -938,6 +943,7 @@ export async function tenantStudentsRoute(ctx: RequestContext) { const status = stringParam(ctx, 'status') || 'active'; const regionId = stringParam(ctx, 'regionId'); const classId = stringParam(ctx, 'classId'); + const cursor = decodeTenantStudentsCursor(stringParam(ctx, 'cursor')); const scopedIds = await scopedClassIds(auth); if (classId) await ensureReadableClass(auth, classId); if (!STUDENT_MEMBER_STATUSES.includes(status)) { @@ -952,8 +958,13 @@ export async function tenantStudentsRoute(ctx: RequestContext) { } const filters = ['tm.tenant_id = $1', `tm.role = 'student'`, 'tm.status = $2']; if (keyword) { - params.push(`%${keyword}%`); - filters.push(`(u.username ilike $${params.length} or u.name ilike $${params.length} or u.phone ilike $${params.length} or u.email::text ilike $${params.length})`); + params.push(containsSearchPattern(keyword)); + filters.push(`( + coalesce(u.username, '') || ' ' || + coalesce(u.name, '') || ' ' || + coalesce(u.phone, '') || ' ' || + coalesce(u.email::text, '') + ) ilike $${params.length} escape '\\'`); } if (regionId) { params.push(regionId); @@ -981,11 +992,24 @@ export async function tenantStudentsRoute(ctx: RequestContext) { and scoped_cm.status = 'active' )`); } - params.push(limit); + if (cursor) { + params.push(cursor.createdAt, cursor.membershipId); + filters.push(`(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`); + } + params.push(limit + 1); - const items = await query>( + const rows = await query>( ` - with class_agg as ( + with student_page as materialized ( + select tm.id, tm.tenant_id, tm.user_id, tm.status, tm.created_at, tm.updated_at + from public.tenant_memberships tm + join public.platform_users u on u.id = tm.user_id + left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id + where ${filters.join(' and ')} + order by tm.created_at desc, tm.id desc + limit $${params.length} + ), + class_agg as ( select tcm.tenant_id, tcm.user_id, jsonb_agg( jsonb_build_object( @@ -998,13 +1022,15 @@ export async function tenantStudentsRoute(ctx: RequestContext) { order by tc.sort_order asc, tc.created_at desc ) filter (where tcm.status = 'active') as classes from public.tenant_class_members tcm + join student_page page on page.tenant_id = tcm.tenant_id and page.user_id = tcm.user_id join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id where tcm.tenant_id = $1 and tcm.member_type = 'student' ${classAggScopeSql} group by tcm.tenant_id, tcm.user_id ) select tm.id as "membershipId", tm.user_id as "userId", tm.status, - tm.created_at as "memberCreatedAt", tm.updated_at as "memberUpdatedAt", + tm.created_at as "memberCreatedAt", tm.created_at::text as "cursorCreatedAt", + tm.updated_at as "memberUpdatedAt", u.username, u.email::text as email, u.phone, u.name, null::text as "avatarUrl", u.primary_role as "primaryRole", u.last_seen_at as "lastSeenAt", @@ -1016,21 +1042,37 @@ export async function tenantStudentsRoute(ctx: RequestContext) { sp.last_check_in_date as "lastCheckInDate", sp.stats, sp.progress, sp.module_selections as "moduleSelections", coalesce(ca.classes, '[]'::jsonb) as classes - from public.tenant_memberships tm + from student_page tm join public.platform_users u on u.id = tm.user_id left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id - where ${filters.join(' and ')} - order by tm.created_at desc - limit $${params.length} + order by tm.created_at desc, tm.id desc `, params, ); - return { items: items.map(item => maskStudentFields(auth, item)), scoped: scopedIds !== null }; + const hasMore = rows.length > limit; + const pageItems = rows.slice(0, limit); + const lastItem = pageItems.at(-1); + const nextCursor = hasMore && lastItem + ? encodeTenantStudentsCursor({ + createdAt: String(lastItem.cursorCreatedAt || ''), + membershipId: String(lastItem.membershipId || ''), + }) + : null; + + return { + items: pageItems.map(item => { + const { cursorCreatedAt: _cursorCreatedAt, ...publicItem } = item; + return maskStudentFields(auth, publicItem); + }), + scoped: scopedIds !== null, + hasMore, + nextCursor, + }; } export async function upsertTenantStudentRoute(ctx: RequestContext) { @@ -1049,6 +1091,16 @@ export async function upsertTenantStudentRoute(ctx: RequestContext) { await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND'); await ensureTenantMembership(client, auth.tenantId, userId, 'student', status); + if (status !== 'active') { + await client.query( + ` + update app_private.auth_sessions + set revoked_at = now(), updated_at = now() + where tenant_id = $1 and user_id = $2 and revoked_at is null + `, + [auth.tenantId, userId], + ); + } const profile = await client.query( ` insert into public.student_profiles ( @@ -1115,6 +1167,27 @@ export async function updateTenantStudentStatusRoute(ctx: RequestContext) { [auth.tenantId, userId, status], ); if (!result.rows[0]) throw new HttpError(404, 'Student membership not found', 'STUDENT_NOT_FOUND'); + if (status !== 'active') { + await client.query( + ` + update app_private.auth_sessions + set revoked_at = now(), + updated_at = now(), + metadata = metadata || $3::jsonb + where tenant_id = $1 + and user_id = $2 + and revoked_at is null + `, + [ + auth.tenantId, + userId, + JSON.stringify({ + revokedBy: 'tenant.student.status_updated', + membershipStatus: status, + }), + ], + ); + } await recordAudit(client, auth, 'tenant.student.status_updated', 'tenant_memberships', result.rows[0].membershipId, { userId, status, @@ -1148,6 +1221,16 @@ export async function bulkUpsertTenantStudentsRoute(ctx: RequestContext) { await ensureTenantReference(client, 'schools', auth.tenantId, selectedSchoolId, 'SCHOOL_NOT_FOUND'); await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND'); await ensureTenantMembership(client, auth.tenantId, userId, 'student', status); + if (status !== 'active') { + await client.query( + ` + update app_private.auth_sessions + set revoked_at = now(), updated_at = now() + where tenant_id = $1 and user_id = $2 and revoked_at is null + `, + [auth.tenantId, userId], + ); + } const profile = await client.query( ` diff --git a/apps/api/src/features/tenant-admin/operations.ts b/apps/api/src/features/tenant-admin/operations.ts index 03657444..5779a143 100644 --- a/apps/api/src/features/tenant-admin/operations.ts +++ b/apps/api/src/features/tenant-admin/operations.ts @@ -509,7 +509,10 @@ export async function tenantFeedbackReportRoute(ctx: RequestContext) { max(r.created_at) as "latestAt" from public.reports r join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where r.tenant_id = $1 and r.question_id is not null and r.created_at >= $2::timestamptz diff --git a/apps/api/src/features/tenant-admin/routes.ts b/apps/api/src/features/tenant-admin/routes.ts index dd433a41..fb1d8d79 100644 --- a/apps/api/src/features/tenant-admin/routes.ts +++ b/apps/api/src/features/tenant-admin/routes.ts @@ -2686,6 +2686,28 @@ export async function upsertTenantMemberRoute(ctx: RequestContext) { if (!result.rows[0]) throw new HttpError(404, 'Tenant member not found', 'TENANT_MEMBER_NOT_FOUND'); + if (status !== 'active') { + await client.query( + ` + update app_private.auth_sessions + set revoked_at = now(), + updated_at = now(), + metadata = metadata || $3::jsonb + where tenant_id = $1 + and user_id = $2 + and revoked_at is null + `, + [ + auth.tenantId, + userId, + JSON.stringify({ + revokedBy: 'tenant.member.upserted', + membershipStatus: status, + }), + ], + ); + } + await recordAudit(client, auth, 'tenant.member.upserted', 'tenant_memberships', result.rows[0].id, { userId, role, @@ -2735,6 +2757,26 @@ export async function disableTenantMemberRoute(ctx: RequestContext) { [auth.tenantId, membershipId], ); + await client.query( + ` + update app_private.auth_sessions + set revoked_at = now(), + updated_at = now(), + metadata = metadata || $3::jsonb + where tenant_id = $1 + and user_id = $2 + and revoked_at is null + `, + [ + auth.tenantId, + result.rows[0].userId, + JSON.stringify({ + revokedBy: 'tenant.member.disabled', + membershipStatus: 'disabled', + }), + ], + ); + await recordAudit(client, auth, 'tenant.member.disabled', 'tenant_memberships', membershipId, { userId: result.rows[0].userId, role: result.rows[0].role, diff --git a/apps/api/src/features/tenant-admin/student-cursor.ts b/apps/api/src/features/tenant-admin/student-cursor.ts new file mode 100644 index 00000000..108a8d1b --- /dev/null +++ b/apps/api/src/features/tenant-admin/student-cursor.ts @@ -0,0 +1,39 @@ +import { HttpError } from '../../core/http.js'; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/; + +export interface TenantStudentsCursor { + createdAt: string; + membershipId: string; +} + +export function encodeTenantStudentsCursor(cursor: TenantStudentsCursor) { + return Buffer.from(JSON.stringify({ + version: 1, + createdAt: cursor.createdAt, + membershipId: cursor.membershipId, + }), 'utf8').toString('base64url'); +} + +export function decodeTenantStudentsCursor(value: string): TenantStudentsCursor | null { + if (!value) return null; + if (value.length > 512 || !CURSOR_PATTERN.test(value)) { + throw new HttpError(400, 'Invalid student list cursor', 'INVALID_STUDENT_CURSOR'); + } + try { + const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Record; + const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : ''; + const membershipId = typeof parsed.membershipId === 'string' ? parsed.membershipId : ''; + if (parsed.version !== 1 || !createdAt || !Number.isFinite(Date.parse(createdAt)) || !UUID_PATTERN.test(membershipId)) { + throw new Error('invalid cursor payload'); + } + return { createdAt, membershipId }; + } catch { + throw new HttpError(400, 'Invalid student list cursor', 'INVALID_STUDENT_CURSOR'); + } +} + +export function containsSearchPattern(value: string) { + return `%${value.replace(/[\\%_]/g, match => `\\${match}`)}%`; +} diff --git a/apps/api/src/features/tenant-content/auth.ts b/apps/api/src/features/tenant-content/auth.ts index eb32b173..f4c651bf 100644 --- a/apps/api/src/features/tenant-content/auth.ts +++ b/apps/api/src/features/tenant-content/auth.ts @@ -1,6 +1,7 @@ import { HttpError, type RequestContext } from '../../core/http.js'; import { queryOne } from '../../core/db.js'; import { tenantIdFrom, userIdFrom } from '../../core/request.js'; +import { hasResolvedTenantPermission } from '../tenant-admin/auth.js'; const CONTENT_ROLES = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher']); @@ -12,14 +13,6 @@ export interface TenantContentAuth { templatePermissions: Record; } -function hasContentPermission(permissions: Record) { - return permissions['*'] === true || permissions['content:*'] === true; -} - -function hasPermission(permissions: Record, permissionKey: string) { - return permissions['*'] === true || permissions['content:*'] === true || permissions[permissionKey] === true; -} - export async function requireTenantContentEditor(ctx: RequestContext): Promise { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); @@ -36,13 +29,6 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise( }); } -async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) { +async function loadPreviewJob( + client: pg.PoolClient, + auth: TenantContentAuth, + jobId: string, + leaseToken?: string, +) { const result = await client.query<{ id: string; status: string; @@ -1846,16 +1851,18 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo target_entry_id: string | null; target_content_node_id: string | null; target_collection_id: string | null; + lease_token: string | null; }>( ` select id, status, total_count, valid_count, error_count, warning_count, target_region_id, target_subject_id, target_category_id, target_node_id, target_question_bank_id, - target_entry_id, target_content_node_id, target_collection_id + target_entry_id, target_content_node_id, target_collection_id, + lease_token from public.content_import_jobs where tenant_id = $1 and id = $2 and import_type = 'questions' limit 1 - for update + ${leaseToken ? '' : 'for update'} `, [auth.tenantId, jobId], ); @@ -1864,7 +1871,24 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo if (!job) { throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND'); } - if (['importing', 'failed'].includes(job.status)) { + if (leaseToken) { + const lease = await client.query( + ` + select 1 + from public.content_import_jobs + where tenant_id = $1 + and id = $2 + and status = 'importing' + and lease_token = $3::uuid + and lease_expires_at > now() + `, + [auth.tenantId, jobId, leaseToken], + ); + if (lease.rowCount !== 1) { + throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST'); + } + } + if (!leaseToken && ['importing', 'failed'].includes(job.status)) { throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY'); } return job; @@ -1875,6 +1899,7 @@ async function loadGenericPreviewJob( auth: TenantContentAuth, jobId: string, importType: ContentImportType, + leaseToken?: string, ) { const result = await client.query<{ id: string; @@ -1886,21 +1911,39 @@ async function loadGenericPreviewJob( target_region_id: string | null; target_entry_id: string | null; target_content_node_id: string | null; + lease_token: string | null; }>( ` select id, status, total_count, valid_count, error_count, warning_count, - target_region_id, target_entry_id, target_content_node_id + target_region_id, target_entry_id, target_content_node_id, lease_token from public.content_import_jobs where tenant_id = $1 and id = $2 and import_type = $3 limit 1 - for update + ${leaseToken ? '' : 'for update'} `, [auth.tenantId, jobId, importType], ); const job = result.rows[0]; if (!job) throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND'); - if (['importing', 'failed'].includes(job.status)) { + if (leaseToken) { + const lease = await client.query( + ` + select 1 + from public.content_import_jobs + where tenant_id = $1 + and id = $2 + and status = 'importing' + and lease_token = $3::uuid + and lease_expires_at > now() + `, + [auth.tenantId, jobId, leaseToken], + ); + if (lease.rowCount !== 1) { + throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST'); + } + } + if (!leaseToken && ['importing', 'failed'].includes(job.status)) { throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY'); } return job; @@ -2118,7 +2161,10 @@ async function currentVersionHash(client: pg.PoolClient, questionId: string) { ` select v.source_hash from public.questions q - join public.question_versions v on v.id = q.current_version_id + join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where q.id = $1 limit 1 `, @@ -3099,6 +3145,7 @@ interface ContentImportExecutionOptions { importType: ExecutableContentImportType; allowPartial?: boolean; allowQueuedJob?: boolean; + leaseToken?: string; } interface ContentImportExecutionResult { @@ -3112,15 +3159,40 @@ interface ContentImportExecutionResult { warningCount?: number; } +type ContentImportLeaseOptions = Pick; + +function asyncLeaseWhereClause(input: ContentImportLeaseOptions, firstParam: number) { + return input.allowQueuedJob === true + ? ` and status = 'importing' and lease_token = $${firstParam}::uuid and lease_expires_at > now()` + : ''; +} + +function asyncLeaseParams(input: ContentImportLeaseOptions) { + if (input.allowQueuedJob !== true) return []; + if (!input.leaseToken) { + throw new HttpError(409, 'Import worker lease is required', 'IMPORT_WORKER_LEASE_REQUIRED'); + } + return [input.leaseToken]; +} + +function assertImportLeaseUpdate(rowCount: number | null, input: ContentImportLeaseOptions) { + if (input.allowQueuedJob === true && rowCount !== 1) { + throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST'); + } +} + async function executeQuestionsImportJob( auth: TenantContentAuth, input: Omit, ) { const allowPartial = input.allowPartial === true; return transaction(async client => { - const job = await loadPreviewJob(client, auth, input.jobId); + const job = await loadPreviewJob(client, auth, input.jobId, input.leaseToken); if (job.status === 'completed' || job.status === 'completed_with_errors') { + if (input.allowQueuedJob === true) { + throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST'); + } return { jobId: job.id, status: job.status, @@ -3142,6 +3214,9 @@ async function executeQuestionsImportJob( error_message = 'Preview contains validation errors', locked_at = null, locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, updated_at = now() where tenant_id = $1 and id = $2 `, @@ -3150,14 +3225,16 @@ async function executeQuestionsImportJob( throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS'); } - await client.query( - ` - update public.content_import_jobs - set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now() - where tenant_id = $1 and id = $2 - `, - [auth.tenantId, job.id], - ); + if (input.allowQueuedJob !== true) { + await client.query( + ` + update public.content_import_jobs + set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now() + where tenant_id = $1 and id = $2 + `, + [auth.tenantId, job.id], + ); + } const itemResult = await client.query<{ id: string; @@ -3185,7 +3262,7 @@ async function executeQuestionsImportJob( } const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed'; - await client.query( + const completed = await client.query( ` update public.content_import_jobs set status = $3, @@ -3196,8 +3273,13 @@ async function executeQuestionsImportJob( finished_at = now(), locked_at = null, locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, updated_at = now() where tenant_id = $1 and id = $2 + ${asyncLeaseWhereClause(input, 8)} + returning id `, [ auth.tenantId, @@ -3207,8 +3289,10 @@ async function executeQuestionsImportJob( updatedCount, skippedCount, JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }), + ...asyncLeaseParams(input), ], ); + assertImportLeaseUpdate(completed.rowCount, input); await client.query( ` @@ -3251,8 +3335,11 @@ async function executeGenericImportJob( ) { const allowPartial = input.allowPartial === true; return transaction(async client => { - const job = await loadGenericPreviewJob(client, auth, input.jobId, input.importType); + const job = await loadGenericPreviewJob(client, auth, input.jobId, input.importType, input.leaseToken); if (job.status === 'completed' || job.status === 'completed_with_errors') { + if (input.allowQueuedJob === true) { + throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST'); + } return { jobId: job.id, status: job.status, @@ -3274,6 +3361,9 @@ async function executeGenericImportJob( error_message = 'Preview contains validation errors', locked_at = null, locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, updated_at = now() where tenant_id = $1 and id = $2 `, @@ -3282,14 +3372,16 @@ async function executeGenericImportJob( throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS'); } - await client.query( - ` - update public.content_import_jobs - set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now() - where tenant_id = $1 and id = $2 - `, - [auth.tenantId, job.id], - ); + if (input.allowQueuedJob !== true) { + await client.query( + ` + update public.content_import_jobs + set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now() + where tenant_id = $1 and id = $2 + `, + [auth.tenantId, job.id], + ); + } const itemResult = await client.query<{ id: string; @@ -3329,7 +3421,7 @@ async function executeGenericImportJob( } const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed'; - await client.query( + const completed = await client.query( ` update public.content_import_jobs set status = $3, @@ -3340,8 +3432,13 @@ async function executeGenericImportJob( finished_at = now(), locked_at = null, locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, updated_at = now() where tenant_id = $1 and id = $2 + ${asyncLeaseWhereClause(input, 8)} + returning id `, [ auth.tenantId, @@ -3351,8 +3448,10 @@ async function executeGenericImportJob( updatedCount, skippedCount, JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }), + ...asyncLeaseParams(input), ], ); + assertImportLeaseUpdate(completed.rowCount, input); await client.query( ` @@ -3477,6 +3576,9 @@ async function queueContentImportJob( queued_at = coalesce(queued_at, now()), locked_at = null, locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, next_attempt_at = now(), summary = coalesce(summary, '{}'::jsonb) || $3::jsonb, updated_at = now() @@ -3676,6 +3778,7 @@ const importJobSelect = ` updated_count as "updatedCount", skipped_count as "skippedCount", execution_mode as "executionMode", queued_at as "queuedAt", locked_at as "lockedAt", locked_by as "lockedBy", + lease_expires_at as "leaseExpiresAt", last_heartbeat_at as "lastHeartbeatAt", attempt_count as "attemptCount", max_attempts as "maxAttempts", next_attempt_at as "nextAttemptAt", parser_metadata as "parserMetadata", summary, error_message as "errorMessage", diff --git a/apps/api/src/features/tenant-content/public-banks.ts b/apps/api/src/features/tenant-content/public-banks.ts index ff35cd53..8e211230 100644 --- a/apps/api/src/features/tenant-content/public-banks.ts +++ b/apps/api/src/features/tenant-content/public-banks.ts @@ -395,7 +395,10 @@ async function sourceQuestionSnapshots(client: pg.PoolClient, input: { v.answer_text, v.explanation, v.sub_questions, v.code_lang, v.code_template, v.source_hash from public.questions q - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where q.tenant_id = $1 and q.question_bank_id = $2 and q.status = 'published' @@ -446,7 +449,10 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: { ` select q.id, v.source_hash from public.questions q - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where q.tenant_id = $1 and q.legacy_id = $2 limit 1 for update of q @@ -1440,7 +1446,10 @@ export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext ` select q.id, v.source_hash from public.questions q - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where q.tenant_id = $1 and q.id = $2 limit 1 for update of q @@ -1795,7 +1804,10 @@ async function resolvePublicQuestionBankConflictsBatch(auth: TenantContentAuth, ` select q.id, v.source_hash from public.questions q - left join public.question_versions v on v.id = q.current_version_id + left join public.question_versions v + on v.tenant_id = q.tenant_id + and v.question_id = q.id + and v.id = q.current_version_id where q.tenant_id = $1 and q.id = $2 limit 1 for update of q diff --git a/apps/api/src/features/tenant/locator.ts b/apps/api/src/features/tenant/locator.ts new file mode 100644 index 00000000..25bd3123 --- /dev/null +++ b/apps/api/src/features/tenant/locator.ts @@ -0,0 +1,153 @@ +import { isIP } from 'node:net'; + +export interface TenantLocatorInput { + origin?: string; + referer?: string; + requestedHost?: string; + requestHost?: string; + tenantCode?: string; + isProduction: boolean; +} + +export interface TenantLocatorError { + ok: false; + statusCode: number; + code: string; + message: string; +} + +export interface TenantHostLocator { + kind: 'host'; + host: string; + expectedTenantCode: string | null; + source: 'browser' | 'local-development'; +} + +export interface TenantCodeLocator { + kind: 'tenantCode'; + tenantCode: string; + source: 'headless-client' | 'local-development'; +} + +export type TenantLocatorDecision = TenantLocatorError | { + ok: true; + locator: TenantHostLocator | TenantCodeLocator; +}; + +function normalizedHostname(value: string) { + return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); +} + +export function normalizeTenantHost(value: string) { + const raw = value.trim(); + if (!raw || /[\s,]/.test(raw)) return ''; + + const directIp = normalizedHostname(raw); + if (isIP(directIp)) return directIp; + + try { + const parsed = new URL(raw.includes('://') ? raw : `http://${raw}`); + if (parsed.username || parsed.password) return ''; + const hostname = normalizedHostname(parsed.hostname); + if (!hostname) return ''; + if (isIP(hostname)) return hostname; + if (!/^[a-z0-9.-]+$/.test(hostname)) return ''; + if (hostname.startsWith('.') || hostname.includes('..')) return ''; + return hostname; + } catch { + return ''; + } +} + +export function isLocalTenantHost(value: string) { + const hostname = normalizeTenantHost(value); + if (!hostname) return false; + if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true; + if (hostname === '::1' || hostname === '0.0.0.0') return true; + return isIP(hostname) === 4 && hostname.startsWith('127.'); +} + +function hostFromBrowserUrl(value: string) { + const raw = value.trim(); + if (!raw || raw === 'null' || raw.includes(',')) return ''; + try { + const parsed = new URL(raw); + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return ''; + return normalizeTenantHost(parsed.host); + } catch { + return ''; + } +} + +function normalizeTenantCode(value: string) { + const normalized = value.trim(); + return /^[A-Za-z0-9._-]{2,64}$/.test(normalized) ? normalized : ''; +} + +function fail(statusCode: number, code: string, message: string): TenantLocatorError { + return { ok: false, statusCode, code, message }; +} + +export function selectTenantLocator(input: TenantLocatorInput): TenantLocatorDecision { + const rawTenantCode = input.tenantCode?.trim() || ''; + const tenantCode = normalizeTenantCode(rawTenantCode); + if (rawTenantCode && !tenantCode) return fail(400, 'TENANT_CODE_INVALID', 'Invalid tenant code'); + + const rawRequestedHost = input.requestedHost?.trim() || ''; + const requestedHost = normalizeTenantHost(rawRequestedHost); + if (rawRequestedHost && !requestedHost) return fail(400, 'TENANT_HOST_INVALID', 'Invalid tenant host'); + + const rawOrigin = input.origin?.trim() || ''; + const rawReferer = input.referer?.trim() || ''; + const browserHost = rawOrigin && rawOrigin !== 'null' + ? hostFromBrowserUrl(rawOrigin) + : hostFromBrowserUrl(rawReferer); + if (rawOrigin && rawOrigin !== 'null' && !browserHost) return fail(400, 'TENANT_ORIGIN_INVALID', 'Invalid browser origin'); + if (!rawOrigin && rawReferer && !browserHost) return fail(400, 'TENANT_ORIGIN_INVALID', 'Invalid browser referer'); + + if (browserHost) { + if (requestedHost && requestedHost !== browserHost) { + return fail(409, 'TENANT_HOST_CONFLICT', 'Requested host does not match browser origin'); + } + if (isLocalTenantHost(browserHost)) { + if (input.isProduction) return fail(400, 'TENANT_LOCAL_HOST_FORBIDDEN', 'Local tenant host is not allowed in production'); + if (tenantCode) { + return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } }; + } + return { ok: true, locator: { kind: 'host', host: browserHost, expectedTenantCode: null, source: 'local-development' } }; + } + return { + ok: true, + locator: { + kind: 'host', + host: browserHost, + expectedTenantCode: tenantCode || null, + source: 'browser', + }, + }; + } + + if (requestedHost) { + if (input.isProduction || !isLocalTenantHost(requestedHost)) { + return fail(400, 'TENANT_HOST_UNTRUSTED', 'Tenant host requires a matching browser origin'); + } + if (tenantCode) { + return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } }; + } + return { ok: true, locator: { kind: 'host', host: requestedHost, expectedTenantCode: null, source: 'local-development' } }; + } + + const requestHost = normalizeTenantHost(input.requestHost || ''); + if (!input.isProduction && isLocalTenantHost(requestHost)) { + if (tenantCode) { + return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } }; + } + return { ok: true, locator: { kind: 'host', host: requestHost, expectedTenantCode: null, source: 'local-development' } }; + } + + if (tenantCode) { + return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'headless-client' } }; + } + + return fail(400, 'TENANT_LOCATOR_REQUIRED', 'Tenant host or tenant code is required'); +} diff --git a/apps/api/src/features/tenant/routes.ts b/apps/api/src/features/tenant/routes.ts index b9a4582b..a4daa664 100644 --- a/apps/api/src/features/tenant/routes.ts +++ b/apps/api/src/features/tenant/routes.ts @@ -1,6 +1,7 @@ -import { config } from '../../core/config.js'; import { queryOne } from '../../core/db.js'; +import { HttpError } from '../../core/errors.js'; import { getHeader, type RequestContext } from '../../core/http.js'; +import { selectTenantLocator } from './locator.js'; interface TenantResolveRow { id: string; @@ -23,24 +24,33 @@ interface TenantResolveRow { public_config: Record; } -function normalizeHost(host: string) { - return host.split(':')[0]?.trim().toLowerCase() || ''; -} +type TenantLookup = (sql: string, params: unknown[]) => Promise; -export async function resolveTenantRoute(ctx: RequestContext) { +const defaultTenantLookup: TenantLookup = (sql, params) => queryOne(sql, params); + +export async function resolveTenantRoute(ctx: RequestContext, lookup: TenantLookup = defaultTenantLookup) { const hostParam = ctx.url.searchParams.get('host') || ''; const tenantCode = ctx.url.searchParams.get('tenantCode') || getHeader(ctx.req, 'x-tenant-code'); - const requestHost = normalizeHost(hostParam || getHeader(ctx.req, 'x-forwarded-host') || getHeader(ctx.req, 'host')); + const decision = selectTenantLocator({ + origin: getHeader(ctx.req, 'origin'), + referer: getHeader(ctx.req, 'referer'), + requestedHost: hostParam, + requestHost: getHeader(ctx.req, 'host'), + tenantCode, + isProduction: process.env.NODE_ENV === 'production', + }); + if (!decision.ok) throw new HttpError(decision.statusCode, decision.message, decision.code); + const locator = decision.locator; - const row = tenantCode - ? await queryOne( + const row = locator.kind === 'tenantCode' + ? await lookup( ` select t.id, t.slug, t.name, t.status, t.mode, null::text as host, b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url, b.service_wechat, b.service_account_name, - coalesce(tc.active_theme, b.theme, '{}'::jsonb) as theme, - coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets, + coalesce(nullif(tc.active_theme, '{}'::jsonb), b.theme, '{}'::jsonb) as theme, + coalesce(nullif(tc.active_public_assets, '{}'::jsonb), b.public_assets, '{}'::jsonb) as public_assets, coalesce(s.feature_flags, '{}'::jsonb) as feature_flags, coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags, coalesce(s.public_config, '{}'::jsonb) as public_config @@ -51,16 +61,16 @@ export async function resolveTenantRoute(ctx: RequestContext) { where t.slug = $1 and t.status = 'active' limit 1 `, - [tenantCode], + [locator.tenantCode], ) - : await queryOne( + : await lookup( ` select t.id, t.slug, t.name, t.status, t.mode, d.host::text, b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url, b.service_wechat, b.service_account_name, - coalesce(tc.active_theme, b.theme, '{}'::jsonb) as theme, - coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets, + coalesce(nullif(tc.active_theme, '{}'::jsonb), b.theme, '{}'::jsonb) as theme, + coalesce(nullif(tc.active_public_assets, '{}'::jsonb), b.public_assets, '{}'::jsonb) as public_assets, coalesce(s.feature_flags, '{}'::jsonb) as feature_flags, coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags, coalesce(s.public_config, '{}'::jsonb) as public_config @@ -72,20 +82,20 @@ export async function resolveTenantRoute(ctx: RequestContext) { where d.host = $1 and d.status = 'active' and t.status = 'active' limit 1 `, - [requestHost || 'localhost'], + [locator.host], ); - if (!row && requestHost !== 'localhost') { - ctx.url.searchParams.set('tenantCode', config.defaultTenantSlug); - return resolveTenantRoute(ctx); + if (!row) { + if (locator.kind === 'host') throw new HttpError(404, 'Tenant domain is not bound', 'TENANT_DOMAIN_NOT_BOUND'); + throw new HttpError(404, 'Tenant code was not found', 'TENANT_CODE_NOT_FOUND'); } - if (!row) { - return { - found: false, - message: 'Tenant not found', - lookup: { host: requestHost, tenantCode: tenantCode || null }, - }; + if ( + locator.kind === 'host' + && locator.expectedTenantCode + && row.slug.toLowerCase() !== locator.expectedTenantCode.toLowerCase() + ) { + throw new HttpError(409, 'Tenant code does not match the resolved domain', 'TENANT_LOCATOR_CONFLICT'); } return { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index b1cec644..a56fee38 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,6 +1,9 @@ import http from 'node:http'; import { config } from './core/config.js'; -import { applyCors, publicErrorBody, routeKey, sendJson } from './core/http.js'; +import { authorizeCorsRequest, CorsPolicy } from './core/cors.js'; +import { closePool } from './core/db.js'; +import { publicErrorBody, routeKey, sendJson, withResponseMeta } from './core/http.js'; +import { requestIdFrom } from './core/request-id.js'; import { createRouter } from './core/router.js'; const routes = createRouter(); @@ -18,8 +21,58 @@ function resolveHandler(method: string | undefined, url: URL) { return null; } -const server = http.createServer(async (req, res) => { - applyCors(req, res); +function writeLog(event: Record, error = false) { + const line = JSON.stringify({ timestamp: new Date().toISOString(), service: 'tiku-saas-api', ...event }); + if (error) console.error(line); + else console.log(line); +} + +let shuttingDown = false; + +const corsPolicy = new CorsPolicy({ + staticOrigins: config.corsOrigins, + tenantDomainsEnabled: config.corsTenantDomainsEnabled, + positiveCacheTtlMs: config.corsTenantDomainCacheTtlMs, + negativeCacheTtlMs: config.corsTenantDomainNegativeCacheTtlMs, + maxCacheEntries: config.corsTenantDomainCacheMaxEntries, + onLookupError(error, host) { + writeLog({ + event: 'cors_tenant_domain_lookup_failed', + host, + error: error instanceof Error ? error.message : 'unknown', + }, true); + }, +}); + +export const server = http.createServer(async (req, res) => { + const requestId = requestIdFrom(req); + const startedAt = process.hrtime.bigint(); + res.setHeader('x-request-id', requestId); + res.once('finish', () => { + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; + writeLog({ + event: 'http_request', + requestId, + method: req.method || 'GET', + path: (() => { + try { return new URL(req.url || '/', 'http://localhost').pathname; } catch { return '/'; } + })(), + status: res.statusCode, + durationMs: Number(durationMs.toFixed(2)), + }, res.statusCode >= 500); + }); + + if (shuttingDown) { + res.setHeader('connection', 'close'); + sendJson(res, 503, withResponseMeta({ error: 'Service is shutting down', code: 'SERVICE_UNAVAILABLE', requestId }, requestId)); + return; + } + + const corsDecision = await authorizeCorsRequest(req, res, corsPolicy); + if (!corsDecision.allowed) { + sendJson(res, 403, withResponseMeta({ error: 'Request origin is not allowed', code: 'CORS_ORIGIN_DENIED', requestId }, requestId)); + return; + } if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); @@ -30,19 +83,66 @@ const server = http.createServer(async (req, res) => { const handler = resolveHandler(req.method, url); if (!handler) { - sendJson(res, 404, { error: 'Not found', path: url.pathname }); + sendJson(res, 404, withResponseMeta({ error: 'Not found', code: 'NOT_FOUND', path: url.pathname, requestId }, requestId)); return; } try { - const result = await handler({ req, res, url }); - sendJson(res, 200, result); + const result = await handler({ req, res, url, requestId }); + sendJson(res, 200, withResponseMeta(result, requestId)); } catch (error) { const { statusCode, body } = publicErrorBody(error); - sendJson(res, statusCode, body); + sendJson(res, statusCode, withResponseMeta({ ...body, requestId }, requestId)); } }); -server.listen(config.port, () => { - console.log(`[api] listening on http://127.0.0.1:${config.port}`); +server.headersTimeout = config.apiHeadersTimeoutMs; +server.requestTimeout = config.apiRequestTimeoutMs; +server.keepAliveTimeout = config.apiKeepAliveTimeoutMs; +server.maxRequestsPerSocket = config.apiMaxRequestsPerSocket; + +server.on('clientError', (error, socket) => { + writeLog({ event: 'http_client_error', code: (error as NodeJS.ErrnoException).code || 'CLIENT_ERROR' }, true); + if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); }); + +let shutdownPromise: Promise | null = null; + +export function shutdown(signal: string) { + if (shutdownPromise) return shutdownPromise; + shuttingDown = true; + shutdownPromise = new Promise(resolve => { + writeLog({ event: 'shutdown_started', signal }); + const forceTimer = setTimeout(() => { + writeLog({ event: 'shutdown_deadline_reached', signal }, true); + server.closeAllConnections(); + }, config.apiShutdownGracePeriodMs); + forceTimer.unref(); + server.close(() => { + clearTimeout(forceTimer); + closePool() + .catch(error => writeLog({ event: 'database_pool_close_failed', error: error instanceof Error ? error.message : 'unknown' }, true)) + .finally(() => { + writeLog({ event: 'shutdown_complete', signal }); + resolve(); + }); + }); + server.closeIdleConnections(); + }); + return shutdownPromise; +} + +server.listen(config.port, () => { + writeLog({ event: 'server_listening', host: '127.0.0.1', port: config.port }); +}); + +for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.once(signal, () => { + shutdown(signal) + .then(() => { process.exitCode = 0; }) + .catch(error => { + writeLog({ event: 'shutdown_failed', signal, error: error instanceof Error ? error.message : 'unknown' }, true); + process.exitCode = 1; + }); + }); +} diff --git a/apps/taro/README.md b/apps/taro/README.md index 5c0a1abe..fe54e507 100644 --- a/apps/taro/README.md +++ b/apps/taro/README.md @@ -21,10 +21,13 @@ apps/taro/dist/h5-platform-admin 可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`。 +Taro 固定稳定版 `4.2.0`。安装时 workspace postinstall 会对精确版本和源码 hash 应用两项 H5-only runtime patch:Input watcher 在 ref 未就绪时安全退出,Button loading 始终保留同一 loading 节点并只切换显示状态。所有 H5 build/dev 命令都会先运行 fail-closed 检查;不要使用 `npm ci --ignore-scripts`。微信小程序使用原生组件,不依赖这两项 H5 patch。 + H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前从仓库根目录运行: ```bash npm run smoke:taro:h5 +npm run smoke:taro:h5:interaction npm run manifest:taro:h5 node scripts/taro-h5-release-guardrails-test.js --require-dist ``` @@ -104,6 +107,17 @@ TARO_APP_SUPABASE_PUBLISHABLE_KEY= TARO_APP_TENANT_CODE=<可选,小程序/预览环境使用> ``` +微信小程序本地预览使用 `npm run build:taro:weapp:student`。正式上传前必须使用严格命令,并注入公开的 HTTPS API、固定租户码和真实 AppID: + +```bash +TARO_APP_API_BASE_URL=https://api.example.com \ +TARO_APP_TENANT_CODE=tenant-code \ +WECHAT_MINIAPP_APP_ID=wx0000000000000000 \ +npm run build:taro:weapp:student:production +``` + +共享 SaaS 小程序可改用 `TARO_APP_WEAPP_TENANT_MODE=launch`,由小程序码 query/scene 或 `referrerInfo.extraData.tenantCode` 传入租户码,无需为每个租户重新打包。严格构建会启用微信合法域名检查,并拒绝本地 API、测试 AppID、固定模式缺失 tenantCode 或超出包体预算的产物;launch 模式启动时缺少租户码会明确报错,不会回退默认租户。 + 禁止把 service role、数据库连接串、支付私钥、对象存储密钥放进 Taro 构建环境。 ## 视觉规范 diff --git a/apps/taro/config/index.ts b/apps/taro/config/index.ts index 4e15e1d6..fa5e2b31 100644 --- a/apps/taro/config/index.ts +++ b/apps/taro/config/index.ts @@ -2,14 +2,75 @@ import type { UserConfigExport } from '@tarojs/cli'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -const portal = process.env.TARO_APP_PORTAL || 'student'; +const portal = process.env.TARO_APP_PORTAL?.trim() || 'student'; +const taroEnv = process.env.TARO_ENV?.trim() || 'h5'; +const releaseMode = process.env.TARO_APP_RELEASE_MODE?.trim().toLowerCase() || 'preview'; const configDir = path.dirname(fileURLToPath(import.meta.url)); -const distDirByPortal: Record = { - student: 'dist/h5-student', - 'tenant-admin': 'dist/h5-tenant-admin', - 'platform-admin': 'dist/h5-platform-admin', +const supportedPortals = new Set(['student', 'tenant-admin', 'platform-admin']); +if (!supportedPortals.has(portal)) throw new Error(`Unsupported Taro portal: ${portal}`); +if (!/^[a-z0-9-]+$/i.test(taroEnv)) throw new Error(`Unsupported Taro target: ${taroEnv}`); +if (releaseMode !== 'preview' && releaseMode !== 'production') throw new Error(`Unsupported Taro release mode: ${releaseMode}`); +const outputRoot = `dist/${taroEnv}-${portal}`; + +function publicBuildValue(name: string) { + return process.env[name]?.trim() || ''; +} + +function normalizedHostname(value: string) { + return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); +} + +function isLoopbackHostname(value: string) { + const hostname = normalizedHostname(value); + return hostname === 'localhost' + || hostname.endsWith('.localhost') + || hostname === '::1' + || hostname === '0.0.0.0' + || /^127(?:\.\d{1,3}){3}$/.test(hostname); +} + +function isPlaceholderHostname(value: string) { + const hostname = normalizedHostname(value); + return ['example', 'test', 'local'].some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`)); +} + +function isProductionWeappApiBaseUrl(value: string) { + try { + const parsed = new URL(value); + return parsed.protocol === 'https:' + && Boolean(parsed.hostname) + && !isLoopbackHostname(parsed.hostname) + && !isPlaceholderHostname(parsed.hostname); + } catch { + return false; + } +} + +const apiBaseUrl = publicBuildValue('TARO_APP_API_BASE_URL') + || (releaseMode === 'production' ? '' : 'http://127.0.0.1:8787'); +const configuredTenantCode = publicBuildValue('TARO_APP_TENANT_CODE'); +const requestedWeappTenantMode = publicBuildValue('TARO_APP_WEAPP_TENANT_MODE').toLowerCase(); +if (taroEnv === 'weapp' && requestedWeappTenantMode && !['fixed', 'launch'].includes(requestedWeappTenantMode)) { + throw new Error('TARO_APP_WEAPP_TENANT_MODE must be fixed or launch'); +} +const weappTenantMode = taroEnv === 'weapp' + ? requestedWeappTenantMode || (releaseMode === 'production' || configuredTenantCode ? 'fixed' : 'launch') + : ''; +const tenantCode = taroEnv === 'weapp' && weappTenantMode === 'launch' ? '' : configuredTenantCode; +if (taroEnv === 'weapp' && releaseMode === 'production' && !isProductionWeappApiBaseUrl(apiBaseUrl)) { + throw new Error('TARO_APP_API_BASE_URL must use a non-placeholder HTTPS host for a production WeApp build'); +} + +const publicBuildConfig = { + portal, + target: taroEnv, + releaseMode, + weappTenantMode, + apiBaseUrl, + supabaseUrl: publicBuildValue('TARO_APP_SUPABASE_URL'), + supabasePublishableKey: publicBuildValue('TARO_APP_SUPABASE_PUBLISHABLE_KEY'), + tenantCode, }; -const outputRoot = distDirByPortal[portal] || distDirByPortal.student; export default { projectName: 'tiku-saas-taro', @@ -32,7 +93,9 @@ export default { alias: { '@': path.resolve(configDir, '..', 'src'), }, - defineConstants: {}, + defineConstants: { + __TARO_PUBLIC_BUILD_CONFIG__: JSON.stringify(publicBuildConfig), + }, copy: { patterns: [ { @@ -45,6 +108,10 @@ export default { h5: { publicPath: '/', staticDirectory: 'static', + devServer: { + host: '127.0.0.1', + allowedHosts: ['localhost', '127.0.0.1'], + }, output: { filename: 'js/[name].[contenthash:8].js', chunkFilename: 'js/[name].[contenthash:8].js', diff --git a/apps/taro/package.json b/apps/taro/package.json index 5e19ae75..30f6d4e4 100644 --- a/apps/taro/package.json +++ b/apps/taro/package.json @@ -4,11 +4,18 @@ "private": true, "type": "module", "scripts": { - "build:h5": "taro build --type h5", - "build:h5:student": "cross-env TARO_APP_PORTAL=student taro build --type h5", - "build:h5:tenant": "cross-env TARO_APP_PORTAL=tenant-admin taro build --type h5", - "build:h5:platform": "cross-env TARO_APP_PORTAL=platform-admin taro build --type h5", - "dev:h5": "taro build --type h5 --watch", + "postinstall": "node ../../scripts/taro-components-h5-runtime-patch.js --apply", + "build:h5": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=production taro build --type h5", + "build:h5:student": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=production taro build --type h5", + "build:h5:tenant": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=tenant-admin TARO_APP_RELEASE_MODE=production taro build --type h5", + "build:h5:platform": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=platform-admin TARO_APP_RELEASE_MODE=production taro build --type h5", + "build:h5:student:preview": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=preview taro build --type h5", + "build:h5:tenant:preview": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=tenant-admin TARO_APP_RELEASE_MODE=preview taro build --type h5", + "build:h5:platform:preview": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=platform-admin TARO_APP_RELEASE_MODE=preview taro build --type h5", + "build:weapp:student": "node ../../scripts/build-weapp-student.js", + "build:weapp:student:production": "node ../../scripts/build-weapp-student.js --production && node ../../scripts/taro-weapp-release-guardrails.js --production", + "dev:h5": "node ../../scripts/taro-components-h5-runtime-patch.js --check && cross-env TARO_ENV=h5 TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=preview taro build --type h5 --watch", + "dev:weapp:student": "cross-env TARO_ENV=weapp TARO_APP_PORTAL=student TARO_APP_RELEASE_MODE=preview taro build --type weapp --watch", "check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/apps/taro/project.config.json b/apps/taro/project.config.json index cb3e3968..fcd91b62 100644 --- a/apps/taro/project.config.json +++ b/apps/taro/project.config.json @@ -1,5 +1,5 @@ { - "miniprogramRoot": "dist/weapp/", + "miniprogramRoot": "dist/weapp-student/", "projectname": "tiku-saas-taro", "description": "工学教育 SaaS 题库 Taro 多端前端", "appid": "touristappid", diff --git a/apps/taro/src/app.config.ts b/apps/taro/src/app.config.ts index 510a18f9..bd2b1f23 100644 --- a/apps/taro/src/app.config.ts +++ b/apps/taro/src/app.config.ts @@ -2,8 +2,10 @@ declare const process: { env: Record; }; -const allPageRoutes = [ - 'pages/bootstrap/index', +const bootstrapRoute = 'pages/bootstrap/index'; +const loginRoute = 'pages/student/login/index'; + +const studentPageRoutes = [ 'pages/student/login/index', 'pages/student/home/index', 'pages/student/region/index', @@ -21,6 +23,9 @@ const allPageRoutes = [ 'pages/student/assets/index', 'pages/student/notifications/index', 'pages/student/profile/index', +]; + +const tenantAdminPageRoutes = [ 'pages/tenant-admin/workbench/index', 'pages/tenant-admin/dashboard/index', 'pages/tenant-admin/students/index', @@ -28,6 +33,9 @@ const allPageRoutes = [ 'pages/tenant-admin/marketing/index', 'pages/tenant-admin/finance/index', 'pages/tenant-admin/settings/index', +]; + +const platformAdminPageRoutes = [ 'pages/platform-admin/workbench/index', 'pages/platform-admin/tenants/index', 'pages/platform-admin/billing/index', @@ -35,22 +43,52 @@ const allPageRoutes = [ 'pages/platform-admin/staff/index', ]; +const portalPageRoutes: Record = { + student: studentPageRoutes, + 'tenant-admin': tenantAdminPageRoutes, + 'platform-admin': platformAdminPageRoutes, +}; + const portalLandingRoutes: Record = { student: 'pages/student/home/index', 'tenant-admin': 'pages/tenant-admin/workbench/index', 'platform-admin': 'pages/platform-admin/workbench/index', }; -function pagesForPortal(portal: string | undefined) { - const landingRoute = portalLandingRoutes[portal || 'student'] || portalLandingRoutes.student; +function normalizedPortal(portal: string | undefined) { + const normalized = portal?.trim() || 'student'; + if (!portalPageRoutes[normalized]) throw new Error(`Unsupported Taro portal: ${normalized}`); + return normalized; +} + +function h5PagesForPortal(portal: string) { + const landingRoute = portalLandingRoutes[portal]; return [ landingRoute, - ...allPageRoutes.filter(route => route !== landingRoute), - ]; + bootstrapRoute, + loginRoute, + ...portalPageRoutes[portal], + ].filter((route, index, routes) => routes.indexOf(route) === index); +} + +const portal = normalizedPortal(process.env.TARO_APP_PORTAL); +const isStudentWeapp = process.env.TARO_ENV === 'weapp' && portal === 'student'; + +if (process.env.TARO_ENV === 'weapp' && portal !== 'student') { + throw new Error(`Unsupported WeApp portal: ${portal}. Tenant and platform administration are H5-only.`); } export default defineAppConfig({ - pages: pagesForPortal(process.env.TARO_APP_PORTAL), + pages: isStudentWeapp ? [bootstrapRoute] : h5PagesForPortal(portal), + ...(isStudentWeapp ? { + subPackages: [ + { + root: 'pages/student', + pages: studentPageRoutes.map(route => route.replace(/^pages\/student\//, '')), + }, + ], + lazyCodeLoading: 'requiredComponents' as const, + } : {}), window: { backgroundTextStyle: 'light', navigationBarBackgroundColor: '#0f172a', diff --git a/apps/taro/src/app.css b/apps/taro/src/app.css index 17323768..ecf49e0b 100644 --- a/apps/taro/src/app.css +++ b/apps/taro/src/app.css @@ -32,6 +32,12 @@ body { background-size: 40px 40px; } +.tiku-theme-root { + min-height: 100vh; + background: var(--tiku-page); + color: var(--tiku-text); +} + view, text, input, @@ -146,3 +152,15 @@ textarea { line-height: 1.6; letter-spacing: 0; } + +.route-guard-retry { + align-self: flex-start; + min-width: 148px; + margin-top: 8px; + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: var(--tiku-radius-sm); + background: #fff; + color: var(--tiku-primary); + font-size: 22px; + font-weight: 800; +} diff --git a/apps/taro/src/app.tsx b/apps/taro/src/app.tsx index d0f4621f..428cd87a 100644 --- a/apps/taro/src/app.tsx +++ b/apps/taro/src/app.tsx @@ -1,10 +1,21 @@ -import { PropsWithChildren, useEffect, useState } from 'react'; -import { Text, View } from '@tarojs/components'; -import 'katex/dist/katex.min.css'; +import { PropsWithChildren, useEffect, useRef, useState } from 'react'; +import { useRouter } from '@tarojs/taro'; +import { Button, Text, View } from '@tarojs/components'; +import { AppProvider, useApp } from '@/app/AppProvider'; import { AdminLegacyShell } from '@/components/AdminLegacyShell'; import { StudentLegacyShell } from '@/components/StudentLegacyShell'; import { appEnv, isH5Runtime } from '@/env'; -import { currentPagePath, guardCurrentRoute, isPathAllowedForPortal, shouldGuardPath } from '@/services/routeGuard'; +import { + currentPagePath, + isPathAllowedForPortal, + landingPath, + normalizePagePath, + redirectToForbidden, + redirectToLogin, + shouldGuardPath, +} from '@/services/routeGuard'; +import { applyWeappLaunchTenant, replaceLocation } from '@/capabilities/navigation'; +import { ThemeProvider, useTheme } from '@/theme/ThemeProvider'; import './app.css'; const routeChangeEvent = 'tiku-route-change'; @@ -60,7 +71,7 @@ function shouldUseAdminShell(path: string) { return false; } -function RouteGuardOverlay() { +function RouteGuardOverlay({ error, onRetry }: { error?: string; onRetry: () => void }) { const copy = appEnv.portal === 'tenant-admin' ? { kicker: 'Tenant Admin', @@ -85,32 +96,40 @@ function RouteGuardOverlay() { {copy.kicker} {copy.title} - {copy.subtitle} + {error || copy.subtitle} + {error ? : null} ); } -export default function App({ children }: PropsWithChildren) { - const [routeReady, setRouteReady] = useState(false); - const [path, setPath] = useState(() => currentPagePath()); - - function verifyCurrentRoute() { - const nextPath = currentPagePath(); - setPath(nextPath); - const allowedRoute = isPathAllowedForPortal(nextPath); - const protectedRoute = shouldGuardPath(nextPath); - setRouteReady(allowedRoute && !protectedRoute); - guardCurrentRoute() - .then(ok => setRouteReady(Boolean(ok))) - .catch(() => setRouteReady(false)); - } +function AppFrame({ children, path }: PropsWithChildren<{ path: string }>) { + const { bootstrapError, bootstrapStatus, currentUser, refresh, tenant } = useApp(); + const { rootStyle } = useTheme(); + const redirectKeyRef = useRef(''); + const allowedRoute = isPathAllowedForPortal(path); + const protectedRoute = shouldGuardPath(path); + const routeReady = allowedRoute && (protectedRoute + ? bootstrapStatus === 'ready' + : (appEnv.portal === 'platform-admin' || Boolean(tenant)) && bootstrapStatus !== 'error'); + const identityKey = `${tenant?.tenantId || 'unresolved'}:${currentUser?.id || 'anonymous'}`; useEffect(() => { - verifyCurrentRoute(); - return installH5RouteListener(verifyCurrentRoute); - }, []); + let redirectKey = ''; + if (!allowedRoute) { + redirectKey = `portal:${path}`; + if (redirectKeyRef.current !== redirectKey) void replaceLocation(landingPath()); + } else if (protectedRoute && bootstrapStatus === 'unauthenticated') { + redirectKey = `login:${path}`; + if (redirectKeyRef.current !== redirectKey) redirectToLogin(path); + } else if (protectedRoute && bootstrapStatus === 'forbidden') { + redirectKey = `forbidden:${path}`; + const reason = appEnv.portal === 'platform-admin' ? '当前账号不是平台管理员' : '当前账号没有后台访问权限'; + if (redirectKeyRef.current !== redirectKey) redirectToForbidden(reason, path); + } + redirectKeyRef.current = redirectKey; + }, [allowedRoute, bootstrapStatus, path, protectedRoute]); const content = shouldUseStudentShell(path) ? {children} @@ -119,11 +138,36 @@ export default function App({ children }: PropsWithChildren) { : children; return ( - <> - + + {content} - {!routeReady ? : null} - + {!routeReady ? ( + void refresh({ path, forceTenant: bootstrapStatus === 'error' })} + /> + ) : null} + + ); +} + +export default function App({ children }: PropsWithChildren) { + applyWeappLaunchTenant(); + const router = useRouter(true); + const [path, setPath] = useState(() => currentPagePath()); + + useEffect(() => installH5RouteListener(() => setPath(currentPagePath())), []); + useEffect(() => { + const dynamicPath = normalizePagePath(router.path || ''); + if (dynamicPath) setPath(dynamicPath); + }, [router.path]); + + return ( + + + {children} + + ); } diff --git a/apps/taro/src/app/AppProvider.tsx b/apps/taro/src/app/AppProvider.tsx new file mode 100644 index 00000000..03c63b6b --- /dev/null +++ b/apps/taro/src/app/AppProvider.tsx @@ -0,0 +1,368 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isWeappRuntime, type AppEnv } from '@/env'; +import type { ApiEnvelope, ApiSession, CurrentUser, TenantContext } from '@/types'; +import { ApiError, clearSession, clearTenantContext, getSession, getTenantContext, resolveTenant } from '@/services/api'; +import { loadCurrentUser, logout as logoutRequest, subscribeAuthChanges } from '@/services/auth'; +import { emitSessionChange } from './session-events'; +import { loadPlatformPermissions } from '@/services/platformAdmin'; +import { loadTenantPermissions, type TenantPermissionsPayload } from '@/services/tenantAdmin'; +import { currentPagePath, isPathAllowedForPortal, shouldGuardPath } from '@/services/routeGuard'; +import { runtimeHost } from '@/capabilities/navigation'; +import { activateStorageUser, clearActiveStorageUserData } from '@/capabilities/storage'; +import { + hasPlatformPermission, + hasTenantMenuAccess, + hasTenantPermission, + objectRecord, + type PlatformAccessSnapshot, + type TenantAccessSnapshot, +} from './permissions'; +import { visiblePortalNavigation, type PortalNavigationItem } from './portal-navigation'; + +export type BootstrapStatus = + | 'idle' + | 'loading-runtime' + | 'resolving-tenant' + | 'authenticating' + | 'ready' + | 'unauthenticated' + | 'forbidden' + | 'error'; + +interface AppState { + runtimeConfig: AppEnv; + tenant: TenantContext | null; + currentUser: CurrentUser | null; + session: ApiSession | null; + tenantAccess: TenantAccessSnapshot | null; + platformAccess: PlatformAccessSnapshot | null; + bootstrapStatus: BootstrapStatus; + bootstrapError: string; +} + +interface RefreshOptions { + path?: string; + forceTenant?: boolean; + authenticatePublic?: boolean; + silent?: boolean; +} + +interface AppContextValue extends AppState { + currentPath: string; + refresh: (options?: RefreshOptions) => Promise; + refreshTenant: () => Promise; + switchTenant: (tenantCode: string) => Promise; + signOut: () => Promise; + canTenant: (permission?: string) => boolean; + canPlatform: (permission?: string) => boolean; + canTenantMenu: (input: { menuKey?: string; moduleKey?: string; permission?: string }) => boolean; + navigationItems: PortalNavigationItem[]; +} + +function initialState(): AppState { + return { + runtimeConfig: { ...appEnv }, + tenant: appEnv.portal === 'platform-admin' ? null : getTenantContext(), + currentUser: null, + session: appEnv.portal === 'platform-admin' ? null : getSession(), + tenantAccess: null, + platformAccess: null, + bootstrapStatus: 'idle', + bootstrapError: '', + }; +} + +const AppContext = createContext(null); + +function currentUserFrom(payload: ApiEnvelope | null) { + return payload?.user || payload?.item || null; +} + +function currentSessionFrom(payload: ApiEnvelope | null) { + const stored = getSession(); + if (!payload?.session) return stored; + if (payload.session.source !== 'app_session' || stored?.source !== 'app_session') return payload.session; + return { + ...payload.session, + ...(stored?.token ? { token: stored.token } : {}), + }; +} + +function tenantAccessFrom(payload: TenantPermissionsPayload): TenantAccessSnapshot { + const current = payload.current || {}; + return { + role: String(current.role || ''), + permissions: objectRecord(current.permissions), + templatePermissions: objectRecord(current.templatePermissions), + effectivePermissions: objectRecord(current.effectivePermissions), + menuPermissions: objectRecord(current.menuPermissions), + modulePermissions: objectRecord(current.modulePermissions), + fieldPermissions: objectRecord(current.fieldPermissions), + dataScope: objectRecord(current.dataScope), + roleDefaults: payload.roleDefaults || {}, + }; +} + +function bootstrapFailureStatus(error: unknown): BootstrapStatus { + if (error instanceof ApiError && error.status === 401) return 'unauthenticated'; + if (error instanceof ApiError && error.status === 403) return 'forbidden'; + return 'error'; +} + +function bootstrapFailureMessage(error: unknown) { + return error instanceof Error ? error.message : '应用初始化失败'; +} + +export function AppProvider({ children, path }: PropsWithChildren<{ path: string }>) { + const [state, setState] = useState(initialState); + const stateRef = useRef(state); + const requestIdRef = useRef(0); + const pathRef = useRef(path); + const authenticatedAtRef = useRef(0); + + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + pathRef.current = path; + }, [path]); + + const refresh = useCallback(async (options: RefreshOptions = {}) => { + const requestId = ++requestIdRef.current; + const targetPath = options.path || pathRef.current || currentPagePath(); + if (!options.silent) { + setState(previous => ({ + ...previous, + bootstrapStatus: 'loading-runtime', + bootstrapError: '', + })); + } + + try { + assertFrontendSecretsAreAbsent(); + await ensureRuntimeConfigLoaded(); + if (isWeappRuntime() && !appEnv.tenantCode) throw new Error('小程序启动参数缺少 tenantCode'); + if (requestId !== requestIdRef.current) return false; + if (!options.silent) { + setState(previous => ({ + ...previous, + runtimeConfig: { ...appEnv }, + bootstrapStatus: 'resolving-tenant', + })); + } + + let tenant = appEnv.portal === 'platform-admin' + ? null + : (options.forceTenant ? null : getTenantContext()); + if (appEnv.portal !== 'platform-admin' && !tenant) tenant = await resolveTenant({ host: runtimeHost() }); + if (requestId !== requestIdRef.current) return false; + + if (!isPathAllowedForPortal(targetPath)) { + setState(previous => ({ + ...previous, + runtimeConfig: { ...appEnv }, + tenant, + bootstrapStatus: 'forbidden', + bootstrapError: '当前构建入口不包含该页面', + })); + return false; + } + + const protectedPath = shouldGuardPath(targetPath); + const shouldAuthenticate = protectedPath || options.authenticatePublic; + if (!shouldAuthenticate) { + const storedSession = getSession(); + setState(previous => ({ + ...previous, + runtimeConfig: { ...appEnv }, + tenant, + currentUser: storedSession ? previous.currentUser : null, + session: storedSession, + tenantAccess: null, + platformAccess: null, + bootstrapStatus: 'ready', + bootstrapError: '', + })); + return true; + } + + if (!options.silent) setState(previous => ({ ...previous, tenant, bootstrapStatus: 'authenticating' })); + let userPayload: ApiEnvelope | null = null; + let tenantAccess: TenantAccessSnapshot | null = null; + let platformAccess: PlatformAccessSnapshot | null = null; + + if (appEnv.portal === 'tenant-admin') { + const [permissionPayload, optionalUserPayload] = await Promise.all([ + loadTenantPermissions(), + loadCurrentUser().catch(() => null), + ]); + tenantAccess = tenantAccessFrom(permissionPayload); + userPayload = optionalUserPayload; + const tenantUserId = String(permissionPayload.current?.userId || ''); + if (!userPayload && tenantUserId) { + userPayload = { + user: { + id: tenantUserId, + primaryRole: tenantAccess.role, + roles: [tenantAccess.role], + }, + }; + } + } else if (appEnv.portal === 'platform-admin') { + const permissionPayload = await loadPlatformPermissions(); + const item = permissionPayload.item; + platformAccess = { + permissions: objectRecord(item?.permissions), + effectivePermissions: objectRecord(item?.effective), + }; + if (item?.userId) { + userPayload = { + user: { + id: item.userId, + primaryRole: item.primaryRole || 'platform_admin', + roles: ['platform_admin'], + }, + }; + } + } else { + userPayload = await loadCurrentUser(); + } + + if (requestId !== requestIdRef.current) return false; + let currentUser = currentUserFrom(userPayload); + if (currentUser && tenantAccess?.role) { + currentUser = { + ...currentUser, + roles: Array.from(new Set([...(currentUser.roles || []), tenantAccess.role])), + }; + } + if (currentUser?.id && tenant?.tenantId) activateStorageUser(tenant.tenantId, currentUser.id); + authenticatedAtRef.current = Date.now(); + setState({ + runtimeConfig: { ...appEnv }, + tenant, + currentUser, + session: appEnv.portal === 'platform-admin' ? null : currentSessionFrom(userPayload), + tenantAccess, + platformAccess, + bootstrapStatus: 'ready', + bootstrapError: '', + }); + return true; + } catch (error) { + if (requestId !== requestIdRef.current) return false; + setState(previous => ({ + ...previous, + runtimeConfig: { ...appEnv }, + tenant: appEnv.portal === 'platform-admin' ? null : getTenantContext(), + currentUser: null, + session: appEnv.portal === 'platform-admin' ? null : getSession(), + tenantAccess: null, + platformAccess: null, + bootstrapStatus: bootstrapFailureStatus(error), + bootstrapError: bootstrapFailureMessage(error), + })); + return false; + } + }, []); + + useEffect(() => { + const current = stateRef.current; + const canReuseAuthenticatedState = shouldGuardPath(path) + && current.bootstrapStatus === 'ready' + && current.tenant + && current.currentUser + && Date.now() - authenticatedAtRef.current < 60_000; + if (!canReuseAuthenticatedState) void refresh({ path }); + }, [path, refresh]); + + useEffect(() => subscribeAuthChanges(() => { + void refresh({ path: pathRef.current, authenticatePublic: true }); + }), [refresh]); + + useEffect(() => { + let disposed = false; + let unsubscribe: () => void = () => undefined; + import('@/services/supabase') + .then(({ subscribeSupabaseAuthChanges }) => subscribeSupabaseAuthChanges((event) => { + if (event === 'SIGNED_IN') clearSession({ emit: false }); + emitSessionChange('supabase'); + })) + .then(nextUnsubscribe => { + if (disposed) nextUnsubscribe(); + else unsubscribe = nextUnsubscribe; + }) + .catch(() => undefined); + return () => { + disposed = true; + unsubscribe(); + }; + }, [state.runtimeConfig.supabasePublishableKey, state.runtimeConfig.supabaseUrl]); + + const refreshTenant = useCallback(() => refresh({ path: pathRef.current, forceTenant: true, silent: true }), [refresh]); + + const switchTenant = useCallback(async (tenantCode: string) => { + if (appEnv.portal === 'platform-admin') throw new Error('平台后台不使用业务租户启动上下文'); + if (runtimeHost() && !isWeappRuntime()) throw new Error('H5 租户由当前域名确定,不能使用 tenantCode 覆盖'); + const normalizedCode = tenantCode.trim(); + if (!normalizedCode) throw new Error('tenantCode 不能为空'); + clearTenantContext(); + appEnv.tenantCode = normalizedCode; + return refresh({ path: pathRef.current, forceTenant: true }); + }, [refresh]); + + const signOut = useCallback(async () => { + requestIdRef.current += 1; + try { + await logoutRequest(); + } finally { + requestIdRef.current += 1; + authenticatedAtRef.current = 0; + if (stateRef.current.tenant?.tenantId) clearActiveStorageUserData(stateRef.current.tenant.tenantId); + setState(previous => ({ + ...previous, + currentUser: null, + session: null, + tenantAccess: null, + platformAccess: null, + bootstrapStatus: 'unauthenticated', + bootstrapError: '', + })); + } + }, []); + + const value = useMemo(() => ({ + ...state, + currentPath: path, + refresh, + refreshTenant, + switchTenant, + signOut, + canTenant: permission => hasTenantPermission(state.tenantAccess, permission), + canPlatform: permission => hasPlatformPermission(state.platformAccess, permission), + canTenantMenu: input => hasTenantMenuAccess(state.tenantAccess, input), + navigationItems: visiblePortalNavigation({ + portal: state.runtimeConfig.portal, + tenantAccess: state.tenantAccess, + platformAccess: state.platformAccess, + }), + }), [state, path, refresh, refreshTenant, switchTenant, signOut]); + + return {children}; +} + +export function useApp() { + const value = useContext(AppContext); + if (!value) throw new Error('useApp must be used inside AppProvider'); + return value; +} diff --git a/apps/taro/src/app/permissions.ts b/apps/taro/src/app/permissions.ts new file mode 100644 index 00000000..57eaa81c --- /dev/null +++ b/apps/taro/src/app/permissions.ts @@ -0,0 +1,81 @@ +export type PermissionMap = Record; + +export interface TenantAccessSnapshot { + role: string; + permissions: PermissionMap; + templatePermissions: PermissionMap; + effectivePermissions: PermissionMap; + menuPermissions: PermissionMap; + modulePermissions: PermissionMap; + fieldPermissions: PermissionMap; + dataScope: PermissionMap; + roleDefaults: Record; +} + +export interface PlatformAccessSnapshot { + permissions: PermissionMap; + effectivePermissions: PermissionMap; +} + +export function objectRecord(value: unknown): PermissionMap { + return value && typeof value === 'object' && !Array.isArray(value) ? value as PermissionMap : {}; +} + +function permissionCandidates(permission: string) { + const parts = permission.split(':').filter(Boolean); + const candidates = [permission]; + for (let index = parts.length - 1; index >= 1; index -= 1) { + candidates.push(`${parts.slice(0, index).join(':')}:*`); + } + candidates.push('*'); + return candidates; +} + +export function explicitPermission(permissions: PermissionMap, permission: string) { + for (const key of permissionCandidates(permission)) { + if (typeof permissions[key] === 'boolean') return permissions[key] as boolean; + } + return null; +} + +function defaultPermissionAllowed(defaults: string[], permission: string) { + return defaults.some(item => { + if (item === '*' || item === permission) return true; + return item.endsWith(':*') && permission.startsWith(item.slice(0, -1)); + }); +} + +export function hasTenantPermission(access: TenantAccessSnapshot | null, permission?: string) { + if (!permission) return true; + if (!access) return false; + + const direct = explicitPermission(access.permissions, permission); + if (direct !== null) return direct; + const template = explicitPermission(access.templatePermissions, permission); + if (template !== null) return template; + const effective = explicitPermission(access.effectivePermissions, permission); + if (effective !== null) return effective; + return defaultPermissionAllowed(access.roleDefaults[access.role] || [], permission); +} + +export function hasTenantMenuAccess( + access: TenantAccessSnapshot | null, + input: { menuKey?: string; moduleKey?: string; permission?: string }, +) { + if (!access) return false; + if (input.menuKey && typeof access.menuPermissions[input.menuKey] === 'boolean') { + return access.menuPermissions[input.menuKey] as boolean; + } + if (input.moduleKey && typeof access.modulePermissions[input.moduleKey] === 'boolean') { + return access.modulePermissions[input.moduleKey] as boolean; + } + return hasTenantPermission(access, input.permission); +} + +export function hasPlatformPermission(access: PlatformAccessSnapshot | null, permission?: string) { + if (!permission) return true; + if (!access) return false; + const effective = explicitPermission(access.effectivePermissions, permission); + if (effective !== null) return effective; + return explicitPermission(access.permissions, permission) === true; +} diff --git a/apps/taro/src/app/portal-navigation.ts b/apps/taro/src/app/portal-navigation.ts new file mode 100644 index 00000000..dcbf4231 --- /dev/null +++ b/apps/taro/src/app/portal-navigation.ts @@ -0,0 +1,62 @@ +import type { Portal } from '@/env'; +import type { PlatformAccessSnapshot, TenantAccessSnapshot } from './permissions'; +import { hasPlatformPermission, hasTenantMenuAccess } from './permissions'; + +export interface PortalNavigationItem { + name: string; + path: string; + mark: string; + group?: string; + menuKey?: string; + moduleKey?: string; + permission?: string; + mobile?: boolean; +} + +export const portalNavigation: Record = { + student: [ + { name: '学习工作台', path: '/pages/student/home/index', mark: '台', mobile: true }, + { name: '背单词', path: '/pages/student/vocabulary/index', mark: '词', mobile: true }, + { name: '知识手册', path: '/pages/student/handbook/index', mark: '册', mobile: true }, + { name: '购买', path: '/pages/student/checkout/index', mark: '购', mobile: true }, + { name: '分数线', path: '/pages/student/scoreline/index', mark: '线', mobile: true }, + { name: '个人中心', path: '/pages/student/profile/index', mark: '我' }, + ], + 'tenant-admin': [ + { group: '运营概览', name: '工作台', path: '/pages/tenant-admin/workbench/index', mark: '台' }, + { name: '数据看板', path: '/pages/tenant-admin/dashboard/index', mark: '数', menuKey: 'dashboard', permission: 'dashboard:read' }, + { group: '业务管理', name: '学生运营', path: '/pages/tenant-admin/students/index', mark: '生', menuKey: 'students', permission: 'students:read' }, + { name: '题库内容', path: '/pages/tenant-admin/content/index', mark: '题', menuKey: 'content', permission: 'content:*' }, + { name: '营销中心', path: '/pages/tenant-admin/marketing/index', mark: '销', menuKey: 'marketing', permission: 'marketing:read' }, + { name: '财务运营', path: '/pages/tenant-admin/finance/index', mark: '财', menuKey: 'commerce', permission: 'tenant:reconciliation:read' }, + { group: '系统', name: '租户设置', path: '/pages/tenant-admin/settings/index', mark: '设', menuKey: 'settings', permission: 'tenant:overview:read' }, + ], + 'platform-admin': [ + { group: '平台概览', name: '工作台', path: '/pages/platform-admin/workbench/index', mark: '台', permission: 'platform:overview:read' }, + { group: 'SaaS 管理', name: '租户管理', path: '/pages/platform-admin/tenants/index', mark: '租', permission: 'platform:tenant:read' }, + { name: '账务中心', path: '/pages/platform-admin/billing/index', mark: '账', permission: 'platform:billing:read' }, + { name: '公共题库', path: '/pages/platform-admin/question-banks/index', mark: '库', permission: 'platform:question_bank:read' }, + { group: '权限', name: '平台员工', path: '/pages/platform-admin/staff/index', mark: '员', permission: 'platform:staff:read' }, + ], +}; + +export function visiblePortalNavigation(input: { + portal: Portal; + tenantAccess: TenantAccessSnapshot | null; + platformAccess: PlatformAccessSnapshot | null; +}) { + let currentGroup = ''; + return portalNavigation[input.portal].flatMap(item => { + if (item.group) currentGroup = item.group; + const allowed = (() => { + if (input.portal === 'tenant-admin') { + return hasTenantMenuAccess(input.tenantAccess, item); + } + if (input.portal === 'platform-admin') { + return hasPlatformPermission(input.platformAccess, item.permission); + } + return true; + })(); + return allowed ? [{ ...item, ...(currentGroup ? { group: currentGroup } : {}) }] : []; + }); +} diff --git a/apps/taro/src/app/route-path.ts b/apps/taro/src/app/route-path.ts new file mode 100644 index 00000000..1c40b186 --- /dev/null +++ b/apps/taro/src/app/route-path.ts @@ -0,0 +1,40 @@ +export function normalizePagePath(rawPath: string) { + const path = String(rawPath || '') + .replace(/^#!?/, '') + .split('?')[0] + .split('#')[0] + .replace(/^\/+/, ''); + const pageIndex = path.indexOf('pages/'); + const normalized = pageIndex < 0 + ? path ? `/${path}` : '' + : `/${path.slice(pageIndex)}`; + + if (!normalized.startsWith('/pages/')) return normalized; + const trimmed = normalized.replace(/\/+$/, ''); + return trimmed.endsWith('/index') ? trimmed : `${trimmed}/index`; +} + +export function safePageRedirectPath( + rawPath: string, + portal: 'student' | 'tenant-admin' | 'platform-admin', + fallbackPath: string, +) { + const path = String(rawPath || '').split('#')[0]; + const queryIndex = path.indexOf('?'); + const normalizedPath = normalizePagePath(queryIndex >= 0 ? path.slice(0, queryIndex) : path); + const query = queryIndex >= 0 ? path.slice(queryIndex) : ''; + + if ( + !normalizedPath.startsWith('/pages/') + || normalizedPath === '/pages/student/login/index' + || normalizedPath.startsWith('/pages/student/login/') + || normalizedPath === '/pages/bootstrap/index' + || normalizedPath.startsWith('/pages/bootstrap/') + ) { + return fallbackPath; + } + if (portal === 'tenant-admin' && !normalizedPath.startsWith('/pages/tenant-admin/')) return fallbackPath; + if (portal === 'platform-admin' && !normalizedPath.startsWith('/pages/platform-admin/')) return fallbackPath; + if (portal === 'student' && !normalizedPath.startsWith('/pages/student/')) return fallbackPath; + return `${normalizedPath}${query}`; +} diff --git a/apps/taro/src/app/session-events.ts b/apps/taro/src/app/session-events.ts new file mode 100644 index 00000000..9af039a6 --- /dev/null +++ b/apps/taro/src/app/session-events.ts @@ -0,0 +1,50 @@ +export type SessionChangeReason = 'saved' | 'cleared' | 'expired' | 'tenant-changed' | 'supabase'; +export type SessionChangeListener = (reason: SessionChangeReason) => void; + +const listeners = new Set(); +let authorizationInvalidated = false; +let h5BridgeInstalled = false; +const h5SessionEventKey = 'tiku:v2:session-event'; + +function notifyListeners(reason: SessionChangeReason) { + listeners.forEach(listener => listener(reason)); +} + +function installH5SessionBridge() { + if (h5BridgeInstalled || typeof window === 'undefined') return; + h5BridgeInstalled = true; + window.addEventListener('storage', event => { + if (event.key !== h5SessionEventKey || !event.newValue) return; + try { + const payload = JSON.parse(event.newValue) as { reason?: SessionChangeReason }; + if (payload.reason) notifyListeners(payload.reason); + } catch { + // Ignore malformed events from unrelated scripts. + } + }); +} + +export function emitSessionChange(reason: SessionChangeReason) { + if (reason === 'cleared' || reason === 'expired') { + if (authorizationInvalidated) return; + authorizationInvalidated = true; + } else { + authorizationInvalidated = false; + } + notifyListeners(reason); + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(h5SessionEventKey, JSON.stringify({ reason, nonce: `${Date.now()}:${Math.random()}` })); + } catch { + // Cross-tab synchronization is best effort when storage is unavailable. + } + } +} + +export function subscribeSessionChanges(listener: SessionChangeListener) { + installH5SessionBridge(); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/apps/taro/src/app/storage-scope.ts b/apps/taro/src/app/storage-scope.ts new file mode 100644 index 00000000..9139ce2f --- /dev/null +++ b/apps/taro/src/app/storage-scope.ts @@ -0,0 +1,40 @@ +import type { Portal } from '@/env'; + +export interface StorageScopeInput { + portal: Portal; + host?: string; + tenantCode?: string; +} + +function normalizeSegment(value: string) { + return encodeURIComponent(value.trim().toLowerCase() || 'default'); +} + +export function createStorageScope(input: StorageScopeInput) { + const tenantLocator = input.host?.trim() || (input.tenantCode?.trim() ? `tenant:${input.tenantCode}` : 'default'); + return `${normalizeSegment(input.portal)}:${normalizeSegment(tenantLocator)}`; +} + +export function tenantContextStorageKey(input: StorageScopeInput) { + return `tiku:v2:${createStorageScope(input)}:tenant`; +} + +export function sessionStorageKey(input: StorageScopeInput, tenantId: string) { + return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId)}:session`; +} + +export function activeUserStorageKey(input: StorageScopeInput, tenantId: string) { + return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId || 'unresolved')}:active-user`; +} + +export function userDataStoragePrefix(input: StorageScopeInput, tenantId: string, userId: string) { + return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId || 'unresolved')}:user:${normalizeSegment(userId || 'anonymous')}:data:`; +} + +export function legacyTenantDataStoragePrefix(input: StorageScopeInput, tenantId: string) { + return `tiku:v2:${createStorageScope(input)}:tenant:${normalizeSegment(tenantId || 'unresolved')}:data:`; +} + +export function tenantDataStorageKey(input: StorageScopeInput, tenantId: string, userId: string, key: string) { + return `${userDataStoragePrefix(input, tenantId, userId)}${encodeURIComponent(key)}`; +} diff --git a/apps/taro/src/app/tenant-launch.ts b/apps/taro/src/app/tenant-launch.ts new file mode 100644 index 00000000..c7a4bc85 --- /dev/null +++ b/apps/taro/src/app/tenant-launch.ts @@ -0,0 +1,40 @@ +export interface TenantLaunchInput { + query?: Record; + referrerExtraData?: Record; +} + +function normalizedTenantCode(value: unknown) { + if (typeof value !== 'string') return ''; + const normalized = value.trim(); + return /^[A-Za-z0-9._-]{2,64}$/.test(normalized) ? normalized : ''; +} + +function tenantCodeFromScene(value: unknown) { + if (typeof value !== 'string' || !value.trim()) return ''; + let decoded = value.trim(); + try { + decoded = decodeURIComponent(decoded); + } catch { + return ''; + } + if (!decoded.includes('=')) return normalizedTenantCode(decoded); + for (const segment of decoded.split('&')) { + const separator = segment.indexOf('='); + if (separator < 0) continue; + const key = segment.slice(0, separator).trim(); + if (key !== 'tenantCode' && key !== 'tenant') continue; + return normalizedTenantCode(segment.slice(separator + 1)); + } + return ''; +} + +export function tenantCodeFromLaunch(input: TenantLaunchInput) { + const query = input.query || {}; + const extraData = input.referrerExtraData || {}; + return normalizedTenantCode(query.tenantCode) + || normalizedTenantCode(query.tenant) + || tenantCodeFromScene(query.scene) + || normalizedTenantCode(extraData.tenantCode) + || normalizedTenantCode(extraData.tenant) + || ''; +} diff --git a/apps/taro/src/app/tenant-resolution.ts b/apps/taro/src/app/tenant-resolution.ts new file mode 100644 index 00000000..2d981ddc --- /dev/null +++ b/apps/taro/src/app/tenant-resolution.ts @@ -0,0 +1,32 @@ +export interface TenantResolveQueryInput { + host?: string; + tenantCode?: string; +} + +function normalizedHostname(value: string) { + const raw = value.trim(); + if (!raw) return ''; + try { + return new URL(`http://${raw}`).hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); + } catch { + return ''; + } +} + +export function isLocalRuntimeHost(value: string) { + const hostname = normalizedHostname(value); + return hostname === 'localhost' + || hostname.endsWith('.localhost') + || hostname === '::1' + || hostname === '0.0.0.0' + || /^127(?:\.\d{1,3}){3}$/.test(hostname); +} + +export function tenantResolveQuery(input: TenantResolveQueryInput) { + const host = input.host?.trim() || ''; + const tenantCode = input.tenantCode?.trim() || ''; + return { + host: host || undefined, + tenantCode: !host || isLocalRuntimeHost(host) ? (tenantCode || undefined) : undefined, + }; +} diff --git a/apps/taro/src/capabilities/file.ts b/apps/taro/src/capabilities/file.ts new file mode 100644 index 00000000..d10538d5 --- /dev/null +++ b/apps/taro/src/capabilities/file.ts @@ -0,0 +1,165 @@ +import Taro from '@tarojs/taro'; +import { isH5Runtime } from '@/env'; +import { copyText } from './share'; +import { openExternalUrl } from './navigation'; + +export interface PickLocalFileOptions { + accept: string; + extensions?: string[]; + readAs: 'text' | 'base64'; +} + +export interface PickedLocalFile { + fileName: string; + text?: string; + base64?: string; +} + +function readBrowserFile(file: File, readAs: PickLocalFileOptions['readAs']) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : ''; + resolve(readAs === 'base64' && result.includes(',') ? result.slice(result.indexOf(',') + 1) : result); + }; + reader.onerror = () => reject(new Error('文件读取失败')); + if (readAs === 'base64') reader.readAsDataURL(file); + else reader.readAsText(file, 'utf-8'); + }); +} + +async function pickBrowserFile(options: PickLocalFileOptions): Promise { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = options.accept; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) { + reject(new Error('未选择文件')); + return; + } + try { + const content = await readBrowserFile(file, options.readAs); + resolve({ + fileName: file.name, + ...(options.readAs === 'base64' ? { base64: content } : { text: content }), + }); + } catch (error) { + reject(error); + } + }; + input.click(); + }); +} + +function readMiniProgramFile(filePath: string, encoding: 'utf8' | 'base64') { + return new Promise((resolve, reject) => { + Taro.getFileSystemManager().readFile({ + filePath, + encoding, + success: result => resolve(String(result.data || '')), + fail: result => reject(new Error(result.errMsg || '文件读取失败')), + }); + }); +} + +export async function pickLocalFile(options: PickLocalFileOptions): Promise { + if (isH5Runtime() && typeof document !== 'undefined') return pickBrowserFile(options); + if (typeof Taro.chooseMessageFile !== 'function') throw new Error('当前端暂不支持文件选择'); + + const result = await Taro.chooseMessageFile({ + count: 1, + type: 'file', + extension: options.extensions, + }); + const file = result.tempFiles[0]; + if (!file?.path) throw new Error('未选择文件'); + const content = await readMiniProgramFile(file.path, options.readAs === 'base64' ? 'base64' : 'utf8'); + return { + fileName: file.name || 'import-file', + ...(options.readAs === 'base64' ? { base64: content } : { text: content }), + }; +} + +function safeFileName(fileName: string) { + return fileName.replace(/[\\/:*?"<>|]/g, '-').slice(0, 120) || 'download'; +} + +function triggerBrowserDownload(href: string, fileName: string) { + const link = document.createElement('a'); + link.href = href; + link.download = fileName; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +function writeMiniProgramFile(input: { fileName: string; data: string; encoding: 'utf8' | 'base64' }) { + return new Promise((resolve, reject) => { + const filePath = `${Taro.env.USER_DATA_PATH}/${safeFileName(input.fileName)}`; + Taro.getFileSystemManager().writeFile({ + filePath, + data: input.data, + encoding: input.encoding, + success: () => resolve(filePath), + fail: result => reject(new Error(result.errMsg || '文件保存失败')), + }); + }); +} + +async function openMiniProgramFile(filePath: string) { + try { + await Taro.openDocument({ filePath, showMenu: true }); + return 'opened' as const; + } catch { + return 'saved' as const; + } +} + +export async function downloadBase64File(fileName: string, contentBase64: string, mimeType = 'application/octet-stream') { + if (!contentBase64) throw new Error('文件内容为空'); + if (isH5Runtime() && typeof document !== 'undefined') { + triggerBrowserDownload(`data:${mimeType};base64,${contentBase64}`, safeFileName(fileName)); + return 'downloaded' as const; + } + const filePath = await writeMiniProgramFile({ fileName, data: contentBase64, encoding: 'base64' }); + return openMiniProgramFile(filePath); +} + +export async function downloadTextFile(fileName: string, content: string, mimeType = 'text/plain;charset=utf-8') { + if (isH5Runtime() && typeof document !== 'undefined') { + const blobUrl = URL.createObjectURL(new Blob([content], { type: mimeType })); + try { + triggerBrowserDownload(blobUrl, safeFileName(fileName)); + } finally { + URL.revokeObjectURL(blobUrl); + } + return 'downloaded' as const; + } + if (typeof Taro.getFileSystemManager === 'function' && Taro.env?.USER_DATA_PATH) { + const filePath = await writeMiniProgramFile({ fileName, data: content, encoding: 'utf8' }); + return openMiniProgramFile(filePath); + } + await copyText(content); + return 'copied' as const; +} + +export async function openRemoteFile(url: string) { + if (!url) throw new Error('文件链接为空'); + if (isH5Runtime()) { + openExternalUrl(url); + return 'opened' as const; + } + if (typeof Taro.downloadFile === 'function') { + try { + const result = await Taro.downloadFile({ url }); + if (result.statusCode >= 200 && result.statusCode < 300) return openMiniProgramFile(result.tempFilePath); + } catch { + // Fall back to copying signed URLs when the mini program domain is not whitelisted yet. + } + } + await copyText(url); + return 'copied' as const; +} diff --git a/apps/taro/src/capabilities/media.ts b/apps/taro/src/capabilities/media.ts new file mode 100644 index 00000000..f2c79ceb --- /dev/null +++ b/apps/taro/src/capabilities/media.ts @@ -0,0 +1,19 @@ +import Taro from '@tarojs/taro'; + +export async function chooseImages(count = 1) { + const result = await Taro.chooseImage({ count, sizeType: ['compressed'], sourceType: ['album', 'camera'] }); + return result.tempFilePaths; +} + +export function previewImage(current: string, urls: string[] = [current]) { + return Taro.previewImage({ current, urls }); +} + +export async function chooseVideo() { + const result = await Taro.chooseVideo({ sourceType: ['album', 'camera'], compressed: true }); + return { + tempFilePath: result.tempFilePath, + duration: result.duration, + size: result.size, + }; +} diff --git a/apps/taro/src/capabilities/navigation.ts b/apps/taro/src/capabilities/navigation.ts new file mode 100644 index 00000000..333b2a7e --- /dev/null +++ b/apps/taro/src/capabilities/navigation.ts @@ -0,0 +1,61 @@ +import Taro from '@tarojs/taro'; +import { appEnv, isH5Runtime, isWeappRuntime, taroWeappTenantMode } from '@/env'; +import { tenantCodeFromLaunch } from '@/app/tenant-launch'; + +let launchTenantApplied = false; + +export function applyWeappLaunchTenant() { + if (launchTenantApplied || !isWeappRuntime() || taroWeappTenantMode() !== 'launch') return appEnv.tenantCode; + launchTenantApplied = true; + appEnv.tenantCode = ''; + if (typeof Taro.getLaunchOptionsSync !== 'function') return appEnv.tenantCode; + const options = Taro.getLaunchOptionsSync(); + const tenantCode = tenantCodeFromLaunch({ + query: options.query as Record, + referrerExtraData: options.referrerInfo?.extraData as Record | undefined, + }); + if (tenantCode) appEnv.tenantCode = tenantCode; + return appEnv.tenantCode; +} + +export function runtimeHost() { + return isH5Runtime() && typeof window !== 'undefined' ? window.location.host : ''; +} + +export function currentLocationUrl() { + return isH5Runtime() && typeof window !== 'undefined' ? window.location.href : ''; +} + +export function navigateTo(url: string) { + return Taro.navigateTo({ url }); +} + +export function redirectTo(url: string) { + return Taro.redirectTo({ url }); +} + +export function reLaunch(url: string) { + return Taro.reLaunch({ url }); +} + +export function navigateBack(delta = 1) { + return Taro.navigateBack({ delta }); +} + +export function replaceLocation(url: string) { + if (isH5Runtime() && typeof window !== 'undefined') { + window.location.replace(url); + return Promise.resolve(); + } + return redirectTo(url).then(() => undefined); +} + +export function openExternalUrl(url: string, target: 'same-window' | 'new-window' = 'new-window') { + if (!/^https?:\/\//i.test(url)) throw new Error('只允许打开 http(s) 链接'); + if (isH5Runtime() && typeof window !== 'undefined') { + if (target === 'same-window') window.location.assign(url); + else window.open(url, '_blank', 'noopener,noreferrer'); + return true; + } + return false; +} diff --git a/apps/taro/src/capabilities/payment.ts b/apps/taro/src/capabilities/payment.ts new file mode 100644 index 00000000..de9eac1a --- /dev/null +++ b/apps/taro/src/capabilities/payment.ts @@ -0,0 +1,29 @@ +import Taro from '@tarojs/taro'; +import { isH5Runtime, isWeappRuntime } from '@/env'; +import { currentLocationUrl, openExternalUrl } from './navigation'; +import { copyText } from './share'; + +export function paymentReturnUrl() { + return currentLocationUrl() || undefined; +} + +export async function launchPayment(input: { + provider?: string | null; + paymentParams?: Record | null; + paymentUrl?: string; +}) { + if (input.provider === 'wechat_pay' && isWeappRuntime()) { + await Taro.requestPayment((input.paymentParams || {}) as unknown as Taro.requestPayment.Option); + return 'completed' as const; + } + if (input.paymentUrl && isH5Runtime()) { + openExternalUrl(input.paymentUrl, 'same-window'); + return 'redirected' as const; + } + if (input.paymentUrl) { + await copyText(input.paymentUrl); + return 'copied-url' as const; + } + await copyText(JSON.stringify(input.paymentParams || {})); + return 'copied-params' as const; +} diff --git a/apps/taro/src/capabilities/share.ts b/apps/taro/src/capabilities/share.ts new file mode 100644 index 00000000..2e423015 --- /dev/null +++ b/apps/taro/src/capabilities/share.ts @@ -0,0 +1,21 @@ +import Taro from '@tarojs/taro'; +import { isH5Runtime } from '@/env'; + +export async function copyText(content: string) { + await Taro.setClipboardData({ data: content }); +} + +export async function shareText(input: { title: string; text?: string; url?: string }) { + if (isH5Runtime() && typeof navigator !== 'undefined' && typeof navigator.share === 'function') { + await navigator.share({ title: input.title, text: input.text, url: input.url }); + return 'shared' as const; + } + await copyText([input.title, input.text, input.url].filter(Boolean).join('\n')); + return 'copied' as const; +} + +export async function enableNativeShareMenu() { + if (typeof Taro.showShareMenu !== 'function') return false; + await Taro.showShareMenu({ withShareTicket: true }); + return true; +} diff --git a/apps/taro/src/capabilities/storage.ts b/apps/taro/src/capabilities/storage.ts new file mode 100644 index 00000000..b59850af --- /dev/null +++ b/apps/taro/src/capabilities/storage.ts @@ -0,0 +1,104 @@ +import Taro from '@tarojs/taro'; +import { appEnv, isH5Runtime } from '@/env'; +import { + activeUserStorageKey, + legacyTenantDataStoragePrefix, + sessionStorageKey, + tenantContextStorageKey, + tenantDataStorageKey, + type StorageScopeInput, + userDataStoragePrefix, +} from '@/app/storage-scope'; + +export function runtimeStorageScope(): StorageScopeInput { + const host = isH5Runtime() && typeof window !== 'undefined' + ? window.location.host + : ''; + return { + portal: appEnv.portal, + host, + tenantCode: appEnv.tenantCode, + }; +} + +export function getJsonStorage(key: string): T | null { + try { + const value = Taro.getStorageSync(key); + if (!value) return null; + return JSON.parse(value) as T; + } catch { + return null; + } +} + +export function setJsonStorage(key: string, value: T) { + Taro.setStorageSync(key, JSON.stringify(value)); +} + +export function removeJsonStorage(key: string) { + Taro.removeStorageSync(key); +} + +export function removeStorageByPrefix(prefix: string) { + if (!prefix) return; + try { + const keys = Taro.getStorageInfoSync().keys || []; + keys.filter(key => key.startsWith(prefix)).forEach(key => Taro.removeStorageSync(key)); + } catch { + // Storage cleanup is best effort on constrained runtimes. + } +} + +export function currentTenantContextStorageKey() { + return tenantContextStorageKey(runtimeStorageScope()); +} + +export function currentSessionStorageKey(tenantId: string) { + return sessionStorageKey(runtimeStorageScope(), tenantId); +} + +export function getActiveStorageUserId(tenantId: string) { + return getJsonStorage(activeUserStorageKey(runtimeStorageScope(), tenantId)) || ''; +} + +export function setActiveStorageUserId(tenantId: string, userId: string) { + if (!tenantId || !userId) return; + setJsonStorage(activeUserStorageKey(runtimeStorageScope(), tenantId), userId); +} + +export function clearActiveStorageUserId(tenantId: string) { + if (!tenantId) return; + removeJsonStorage(activeUserStorageKey(runtimeStorageScope(), tenantId)); +} + +export function clearStorageUserData(tenantId: string, userId: string) { + if (!tenantId || !userId) return; + removeStorageByPrefix(userDataStoragePrefix(runtimeStorageScope(), tenantId, userId)); +} + +export function clearActiveStorageUserData(tenantId: string) { + if (!tenantId) return; + const userId = getActiveStorageUserId(tenantId); + if (userId) clearStorageUserData(tenantId, userId); + clearActiveStorageUserId(tenantId); +} + +export function activateStorageUser(tenantId: string, userId: string) { + if (!tenantId || !userId) return; + const previousUserId = getActiveStorageUserId(tenantId); + if (previousUserId && previousUserId !== userId) clearStorageUserData(tenantId, previousUserId); + removeStorageByPrefix(legacyTenantDataStoragePrefix(runtimeStorageScope(), tenantId)); + removeStorageByPrefix('tiku:practice:'); + removeStorageByPrefix('tiku:vocabulary:'); + setActiveStorageUserId(tenantId, userId); +} + +export function currentTenantDataStorageKey(key: string) { + const tenant = getJsonStorage<{ tenantId?: string }>(currentTenantContextStorageKey()); + const tenantId = tenant?.tenantId || 'unresolved'; + return tenantDataStorageKey(runtimeStorageScope(), tenantId, getActiveStorageUserId(tenantId) || 'anonymous', key); +} + +export function scopedTenantDataStorageKey(tenantId: string, userId: string, key: string) { + return tenantDataStorageKey(runtimeStorageScope(), tenantId || 'unresolved', userId || 'anonymous', key); +} diff --git a/apps/taro/src/components/AdminLegacyShell.css b/apps/taro/src/components/AdminLegacyShell.css index cfb6ed70..be54e48a 100644 --- a/apps/taro/src/components/AdminLegacyShell.css +++ b/apps/taro/src/components/AdminLegacyShell.css @@ -67,6 +67,11 @@ white-space: nowrap; } +.backoffice-brand-image { + width: 100%; + height: 100%; +} + .backoffice-legacy-main .admin-page, .backoffice-legacy-main .platform-page { min-height: auto; diff --git a/apps/taro/src/components/AdminLegacyShell.tsx b/apps/taro/src/components/AdminLegacyShell.tsx index 64a4c6b8..743d30eb 100644 --- a/apps/taro/src/components/AdminLegacyShell.tsx +++ b/apps/taro/src/components/AdminLegacyShell.tsx @@ -1,45 +1,16 @@ import { PropsWithChildren } from 'react'; -import Taro from '@tarojs/taro'; -import { Text, View } from '@tarojs/components'; -import { appEnv } from '@/env'; -import { currentPagePath } from '@/services/routeGuard'; +import { Image, Text, View } from '@tarojs/components'; +import { useApp } from '@/app/AppProvider'; +import { redirectTo } from '@/capabilities/navigation'; +import { useTheme } from '@/theme/ThemeProvider'; import './AdminLegacyShell.css'; -type AdminNavItem = { - name: string; - path: string; - mark: string; - group?: string; -}; - -const tenantNavItems: AdminNavItem[] = [ - { group: '运营概览', name: '工作台', path: '/pages/tenant-admin/workbench/index', mark: '台' }, - { name: '数据看板', path: '/pages/tenant-admin/dashboard/index', mark: '数' }, - { group: '业务管理', name: '学生运营', path: '/pages/tenant-admin/students/index', mark: '生' }, - { name: '题库内容', path: '/pages/tenant-admin/content/index', mark: '题' }, - { name: '营销中心', path: '/pages/tenant-admin/marketing/index', mark: '销' }, - { name: '财务运营', path: '/pages/tenant-admin/finance/index', mark: '财' }, - { group: '系统', name: '租户设置', path: '/pages/tenant-admin/settings/index', mark: '设' }, -]; - -const platformNavItems: AdminNavItem[] = [ - { group: '平台概览', name: '工作台', path: '/pages/platform-admin/workbench/index', mark: '台' }, - { group: 'SaaS 管理', name: '租户管理', path: '/pages/platform-admin/tenants/index', mark: '租' }, - { name: '账务中心', path: '/pages/platform-admin/billing/index', mark: '账' }, - { name: '公共题库', path: '/pages/platform-admin/question-banks/index', mark: '库' }, - { group: '权限', name: '平台员工', path: '/pages/platform-admin/staff/index', mark: '员' }, -]; - function isActivePath(currentPath: string, itemPath: string) { return currentPath === itemPath; } -function navItemsForPortal() { - return appEnv.portal === 'platform-admin' ? platformNavItems : tenantNavItems; -} - -function titleForPortal() { - if (appEnv.portal === 'platform-admin') { +function titleForPortal(portal: 'tenant-admin' | 'platform-admin', brandName?: string) { + if (portal === 'platform-admin') { return { title: '平台管理后台', subtitle: '租户、账务、题库授权、员工权限', @@ -48,7 +19,7 @@ function titleForPortal() { }; } return { - title: '租户运营后台', + title: brandName || '租户运营后台', subtitle: '学生、题库、营销、财务与设置', badge: 'Admin', mark: '题', @@ -56,17 +27,22 @@ function titleForPortal() { } export function AdminLegacyShell({ children }: PropsWithChildren) { - const currentPath = currentPagePath(); - const navItems = navItemsForPortal(); - const title = titleForPortal(); + const { currentPath, currentUser, navigationItems, runtimeConfig, tenant } = useApp(); + const { assets } = useTheme(); + const portal = runtimeConfig.portal === 'platform-admin' ? 'platform-admin' : 'tenant-admin'; + const navItems = navigationItems; + const title = titleForPortal(portal, tenant?.branding.brandName || tenant?.branding.shortName); + const userName = currentUser?.name || currentUser?.username || currentUser?.phone || '管理账号'; let activeGroup = ''; return ( - + - {title.mark} + {assets.logoUrl + ? + : {title.mark}} {title.title} @@ -88,7 +64,7 @@ export function AdminLegacyShell({ children }: PropsWithChildren) { return ( {nextGroup ? {nextGroup} : null} - Taro.redirectTo({ url: item.path })}> + void redirectTo(item.path)}> {item.mark} {item.name} @@ -99,18 +75,18 @@ export function AdminLegacyShell({ children }: PropsWithChildren) { - + {userName.slice(0, 1)} - 管理账号 - 已启用权限校验 + {userName} + {currentUser?.primaryRole || '已启用权限校验'} {navItems.map(item => ( - Taro.redirectTo({ url: item.path })}> + void redirectTo(item.path)}> {item.mark} {item.name} diff --git a/apps/taro/src/components/RichContent.tsx b/apps/taro/src/components/RichContent.tsx index bce0ff07..44952963 100644 --- a/apps/taro/src/components/RichContent.tsx +++ b/apps/taro/src/components/RichContent.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import Taro from '@tarojs/taro'; import { Image, RichText, Text, View } from '@tarojs/components'; +import './katex-platform.css'; import { signAssetPreview, type AssetWatermarkContext, type SignedAssetLink } from '@/services/catalog'; import './rich-content.css'; diff --git a/apps/taro/src/components/StudentLegacyShell.css b/apps/taro/src/components/StudentLegacyShell.css index 85673d9b..85345bb3 100644 --- a/apps/taro/src/components/StudentLegacyShell.css +++ b/apps/taro/src/components/StudentLegacyShell.css @@ -7,6 +7,11 @@ min-width: 0; } +.legacy-brand-image { + width: 100%; + height: 100%; +} + .student-legacy-main .student-page { padding-bottom: 132px; } diff --git a/apps/taro/src/components/StudentLegacyShell.tsx b/apps/taro/src/components/StudentLegacyShell.tsx index ea7d5578..88aecde0 100644 --- a/apps/taro/src/components/StudentLegacyShell.tsx +++ b/apps/taro/src/components/StudentLegacyShell.tsx @@ -1,33 +1,25 @@ import { PropsWithChildren } from 'react'; -import Taro from '@tarojs/taro'; -import { Button, Text, View } from '@tarojs/components'; -import { getTenantContext } from '@/services/api'; -import { logout } from '@/services/auth'; -import { currentPagePath } from '@/services/routeGuard'; +import { Button, Image, Text, View } from '@tarojs/components'; +import { useApp } from '@/app/AppProvider'; +import { navigateTo, reLaunch } from '@/capabilities/navigation'; +import { useTheme } from '@/theme/ThemeProvider'; import './StudentLegacyShell.css'; -const navItems = [ - { name: '学习工作台', path: '/pages/student/home/index', mark: '台' }, - { name: '背单词', path: '/pages/student/vocabulary/index', mark: '词' }, - { name: '知识手册', path: '/pages/student/handbook/index', mark: '册' }, - { name: '购买', path: '/pages/student/checkout/index', mark: '购' }, - { name: '分数线', path: '/pages/student/scoreline/index', mark: '线' }, - { name: '个人中心', path: '/pages/student/profile/index', mark: '我' }, -]; - function isActivePath(currentPath: string, itemPath: string) { if (itemPath === '/pages/student/home/index') return currentPath === itemPath; return currentPath.startsWith(itemPath); } export function StudentLegacyShell({ children }: PropsWithChildren) { - const tenant = getTenantContext(); - const currentPath = currentPagePath(); + const { currentPath, currentUser, navigationItems, signOut, tenant } = useApp(); + const { assets } = useTheme(); const brandName = tenant?.branding.brandName || tenant?.branding.shortName || '工学题库'; + const navItems = navigationItems; + const userName = currentUser?.name || currentUser?.username || currentUser?.phone || '学习账号'; async function handleLogout() { - await logout(); - Taro.reLaunch({ url: '/pages/student/login/index' }); + await signOut(); + await reLaunch('/pages/student/login/index'); } return ( @@ -35,7 +27,9 @@ export function StudentLegacyShell({ children }: PropsWithChildren) { - + {assets.logoUrl + ? + : } {brandName} @@ -44,7 +38,7 @@ export function StudentLegacyShell({ children }: PropsWithChildren) { {navItems.map(item => ( - Taro.navigateTo({ url: item.path })}> + void navigateTo(item.path)}> {item.mark} {item.name} @@ -55,7 +49,7 @@ export function StudentLegacyShell({ children }: PropsWithChildren) { - 学习账号 + {userName} SVIP @@ -63,8 +57,8 @@ export function StudentLegacyShell({ children }: PropsWithChildren) { {children} - {navItems.slice(0, 5).map(item => ( - Taro.navigateTo({ url: item.path })}> + {navItems.filter(item => item.mobile).map(item => ( + void navigateTo(item.path)}> {item.mark} {item.name === '学习工作台' ? '首页' : item.name} diff --git a/apps/taro/src/components/katex-platform.css b/apps/taro/src/components/katex-platform.css new file mode 100644 index 00000000..7c0924cd --- /dev/null +++ b/apps/taro/src/components/katex-platform.css @@ -0,0 +1 @@ +/* WeApp uses the lightweight formula styles in rich-content.css without bundling web fonts. */ diff --git a/apps/taro/src/components/katex-platform.h5.css b/apps/taro/src/components/katex-platform.h5.css new file mode 100644 index 00000000..81226498 --- /dev/null +++ b/apps/taro/src/components/katex-platform.h5.css @@ -0,0 +1 @@ +@import "katex/dist/katex.min.css"; diff --git a/apps/taro/src/env.ts b/apps/taro/src/env.ts index 20f1d5a9..cd224ddb 100644 --- a/apps/taro/src/env.ts +++ b/apps/taro/src/env.ts @@ -1,4 +1,5 @@ export type Portal = 'student' | 'tenant-admin' | 'platform-admin'; +export type WeappTenantMode = 'fixed' | 'launch'; export interface AppEnv { portal: Portal; @@ -21,6 +22,19 @@ export interface RuntimeConfigInput { TARO_APP_TENANT_CODE?: string; } +interface PublicBuildConfig { + portal?: string; + target?: string; + releaseMode?: string; + weappTenantMode?: string; + apiBaseUrl?: string; + supabaseUrl?: string; + supabasePublishableKey?: string; + tenantCode?: string; +} + +declare const __TARO_PUBLIC_BUILD_CONFIG__: Readonly; + declare const process: { env: Record; }; @@ -68,6 +82,38 @@ function normalizePortal(value: unknown): Portal | null { return null; } +function normalizedHostname(value: string) { + return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); +} + +function isLoopbackHostname(value: string) { + const hostname = normalizedHostname(value); + return hostname === 'localhost' + || hostname.endsWith('.localhost') + || hostname === '::1' + || hostname === '0.0.0.0' + || /^127(?:\.\d{1,3}){3}$/.test(hostname); +} + +function isProductionApiBaseUrl(value: string) { + try { + const parsed = new URL(value); + return parsed.protocol === 'https:' + && Boolean(parsed.hostname) + && !isLoopbackHostname(parsed.hostname); + } catch { + return false; + } +} + +const publicBuildConfig = typeof __TARO_PUBLIC_BUILD_CONFIG__ === 'undefined' + ? null + : __TARO_PUBLIC_BUILD_CONFIG__; + +function publicBuildConfigValue(key: keyof PublicBuildConfig, envKey: string) { + return normalizeString(publicBuildConfig ? publicBuildConfig[key] : envValue(envKey)); +} + function assertNoForbiddenKeys(input: Record, source: string) { const leaked = forbiddenFrontendKeys.filter(key => Object.prototype.hasOwnProperty.call(input, key)); if (leaked.length) { @@ -81,11 +127,13 @@ function assertNoForbiddenKeys(input: Record, source: string) { } export const appEnv: AppEnv = { - portal: (envValue('TARO_APP_PORTAL') || 'student') as Portal, - apiBaseUrl: envValue('TARO_APP_API_BASE_URL') || 'http://127.0.0.1:8787', - supabaseUrl: envValue('TARO_APP_SUPABASE_URL') || '', - supabasePublishableKey: envValue('TARO_APP_SUPABASE_PUBLISHABLE_KEY') || '', - tenantCode: envValue('TARO_APP_TENANT_CODE') || '', + portal: normalizePortal(publicBuildConfigValue('portal', 'TARO_APP_PORTAL')) || 'student', + apiBaseUrl: publicBuildConfigValue('apiBaseUrl', 'TARO_APP_API_BASE_URL'), + supabaseUrl: publicBuildConfigValue('supabaseUrl', 'TARO_APP_SUPABASE_URL'), + supabasePublishableKey: publicBuildConfigValue('supabasePublishableKey', 'TARO_APP_SUPABASE_PUBLISHABLE_KEY'), + tenantCode: publicBuildConfigValue('target', 'TARO_ENV') === 'weapp' && taroWeappTenantMode() === 'launch' + ? '' + : publicBuildConfigValue('tenantCode', 'TARO_APP_TENANT_CODE'), }; let runtimeConfigPromise: Promise | null = null; @@ -105,14 +153,23 @@ export function applyRuntimeConfig(input: RuntimeConfigInput, source = 'runtime const supabasePublishableKey = normalizeString(input.supabasePublishableKey || input.TARO_APP_SUPABASE_PUBLISHABLE_KEY); if (supabasePublishableKey) appEnv.supabasePublishableKey = supabasePublishableKey; - const tenantCode = normalizeString(input.tenantCode || input.TARO_APP_TENANT_CODE); - if (tenantCode) appEnv.tenantCode = tenantCode; + if (Object.prototype.hasOwnProperty.call(input, 'tenantCode')) { + appEnv.tenantCode = normalizeString(input.tenantCode); + } else if (Object.prototype.hasOwnProperty.call(input, 'TARO_APP_TENANT_CODE')) { + appEnv.tenantCode = normalizeString(input.TARO_APP_TENANT_CODE); + } return appEnv; } export async function loadRuntimeConfig() { - if (!isH5Runtime() || typeof window === 'undefined' || typeof window.fetch !== 'function') { + if (!isH5Runtime()) { + return appEnv; + } + + const strictRuntimeConfig = taroReleaseMode() === 'production'; + if (typeof window === 'undefined' || typeof window.fetch !== 'function') { + if (strictRuntimeConfig) throw new Error('Production H5 runtime-config.json loader is unavailable'); return appEnv; } @@ -123,13 +180,22 @@ export async function loadRuntimeConfig() { cache: 'no-store', credentials: 'same-origin', }); - } catch { + } catch (error) { + if (strictRuntimeConfig) { + throw new Error(`Production H5 runtime-config.json request failed: ${(error as Error).message || 'unknown error'}`); + } + return appEnv; + } + if (!response.ok) { + if (strictRuntimeConfig) throw new Error(`Production H5 runtime-config.json request failed with status ${response.status}`); return appEnv; } - if (!response.ok) return appEnv; const text = (await response.text()).trim(); - if (!text || !text.startsWith('{')) return appEnv; + if (!text || !text.startsWith('{')) { + if (strictRuntimeConfig) throw new Error('Production H5 runtime-config.json is empty or unreadable'); + return appEnv; + } let config: RuntimeConfigInput; try { @@ -138,6 +204,17 @@ export async function loadRuntimeConfig() { throw new Error(`Invalid Taro runtime-config.json: ${(error as Error).message}`); } + if (strictRuntimeConfig) { + const runtimePortal = normalizePortal(config.portal || config.TARO_APP_PORTAL); + if (runtimePortal !== appEnv.portal) throw new Error(`Production H5 runtime-config.json portal must be ${appEnv.portal}`); + const runtimeApiBaseUrl = normalizeString(config.apiBaseUrl || config.TARO_APP_API_BASE_URL); + if (!isProductionApiBaseUrl(runtimeApiBaseUrl)) { + throw new Error('Production H5 runtime-config.json apiBaseUrl must be an absolute HTTPS URL and must not use localhost or loopback'); + } + const runtimeTenantCode = normalizeString(config.tenantCode || config.TARO_APP_TENANT_CODE); + if (runtimeTenantCode) throw new Error('Production H5 runtime-config.json tenantCode must be empty; tenant is resolved from the browser origin'); + } + return applyRuntimeConfig(config, 'runtime-config.json'); } @@ -154,7 +231,15 @@ export function assertFrontendSecretsAreAbsent() { } export function taroRuntimeEnv() { - return envValue('TARO_ENV') || ''; + return publicBuildConfigValue('target', 'TARO_ENV'); +} + +export function taroReleaseMode() { + return publicBuildConfigValue('releaseMode', 'TARO_APP_RELEASE_MODE') === 'production' ? 'production' : 'preview'; +} + +export function taroWeappTenantMode(): WeappTenantMode { + return publicBuildConfigValue('weappTenantMode', 'TARO_APP_WEAPP_TENANT_MODE') === 'launch' ? 'launch' : 'fixed'; } export function isH5Runtime() { diff --git a/apps/taro/src/pages/bootstrap/index.tsx b/apps/taro/src/pages/bootstrap/index.tsx index 2fc04b4a..45b136ee 100644 --- a/apps/taro/src/pages/bootstrap/index.tsx +++ b/apps/taro/src/pages/bootstrap/index.tsx @@ -1,40 +1,28 @@ import { useEffect, useState } from 'react'; -import Taro from '@tarojs/taro'; import { Button, Text, View } from '@tarojs/components'; -import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env'; -import { resolveTenant } from '@/services/api'; -import { currentRouteParams, landingPath, requirePlatformAdmin, requireSignedIn, requireTenantAdmin, safeRedirectPath } from '@/services/routeGuard'; +import { useApp } from '@/app/AppProvider'; +import { reLaunch, replaceLocation } from '@/capabilities/navigation'; +import { currentRouteParams, landingPath, redirectToLogin, safeRedirectPath } from '@/services/routeGuard'; import './index.css'; -function hostFromRuntime() { - if (isH5Runtime() && typeof window !== 'undefined') return window.location.host; - return ''; -} - export default function BootstrapPage() { + const { refresh } = useApp(); const [status, setStatus] = useState('正在解析租户'); const [error, setError] = useState(''); useEffect(() => { const params = currentRouteParams(); const redirectPath = safeRedirectPath(params.redirect ? decodeURIComponent(String(params.redirect)) : landingPath()); - assertFrontendSecretsAreAbsent(); - ensureRuntimeConfigLoaded() - .then(() => resolveTenant({ host: hostFromRuntime() })) - .then(async () => { - setStatus('正在校验登录状态'); - if (appEnv.portal === 'student') return requireSignedIn(redirectPath); - if (appEnv.portal === 'platform-admin') return requirePlatformAdmin(redirectPath); - return requireTenantAdmin(redirectPath); - }) - .then(authPayload => { - if (!authPayload) return; - setStatus('租户解析完成'); - if (isH5Runtime() && typeof window !== 'undefined') { - window.location.replace(redirectPath); + setStatus('正在校验登录状态'); + refresh({ path: redirectPath }) + .then(authorized => { + if (!authorized) { + setStatus('正在前往登录'); + redirectToLogin(redirectPath); return; } - Taro.redirectTo({ url: redirectPath }); + setStatus('租户解析完成'); + void replaceLocation(redirectPath); }) .catch((nextError: Error) => { setError(nextError.message); @@ -50,7 +38,7 @@ export default function BootstrapPage() { {status} {error ? {error} : null} {error ? ( - ) : null} diff --git a/apps/taro/src/pages/platform-admin/workbench/index.tsx b/apps/taro/src/pages/platform-admin/workbench/index.tsx index 2d814d77..3b129805 100644 --- a/apps/taro/src/pages/platform-admin/workbench/index.tsx +++ b/apps/taro/src/pages/platform-admin/workbench/index.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Text, View } from '@tarojs/components'; +import { useApp } from '@/app/AppProvider'; +import { downloadBase64File } from '@/capabilities/file'; import { exportPlatformAuditLogs, loadPlatformAuditAlerts, @@ -27,25 +29,14 @@ import { type PlatformQuestionBankItem, type PlatformTenantItem, } from '@/services/platformAdmin'; -import { requirePlatformAdmin } from '@/services/routeGuard'; import '../platform.css'; function money(cents: unknown) { return `¥${(Number(cents || 0) / 100).toFixed(2)}`; } -function downloadBase64File(filename: string, contentBase64: string, mimeType: string) { - if (typeof document === 'undefined') return false; - const link = document.createElement('a'); - link.href = `data:${mimeType};base64,${contentBase64}`; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - return true; -} - export default function PlatformWorkbenchPage() { + const { canPlatform } = useApp(); const [overview, setOverview] = useState(null); const [tenants, setTenants] = useState([]); const [invoices, setInvoices] = useState([]); @@ -59,13 +50,9 @@ export default function PlatformWorkbenchPage() { const [grants, setGrants] = useState([]); const [error, setError] = useState(''); const [busy, setBusy] = useState(''); - const [authorized, setAuthorized] = useState(false); useEffect(() => { - requirePlatformAdmin('/pages/platform-admin/workbench/index').then(payload => { - if (!payload) return; - setAuthorized(true); - return Promise.all([ + Promise.all([ loadPlatformOverview().catch(() => ({ item: null })), loadPlatformTenants({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformInvoices({ limit: 6 }).catch(() => ({ items: [] })), @@ -77,9 +64,7 @@ export default function PlatformWorkbenchPage() { loadPlatformAuditNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformDunningNotificationChannels({ enabled: true, limit: 6 }).catch(() => ({ items: [] })), loadPlatformDunningNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })), - ]); - }).then(result => { - if (!result) return; + ]).then(result => { const [overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload, channelPayload, eventPayload, dunningChannelPayload, dunningEventPayload] = result; setOverview(overviewPayload.item || null); setTenants(tenantPayload.items || []); @@ -96,25 +81,11 @@ export default function PlatformWorkbenchPage() { }, []); const modules = [ - { name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期' }, - { name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量' }, - { name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' }, - { name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复' }, - ]; - - if (!authorized) { - return ( - - - - Platform Admin - 正在校验平台权限 - 请先完成登录,系统会确认当前账号是否拥有平台管理员权限。 - - - - ); - } + { name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期', permission: 'platform:tenant:read' }, + { name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量', permission: 'platform:billing:read' }, + { name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围', permission: 'platform:question_bank:read' }, + { name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复', permission: 'platform:staff:read' }, + ].filter(item => canPlatform(item.permission)); async function exportAuditLogs() { setBusy('audit-export'); @@ -123,8 +94,8 @@ export default function PlatformWorkbenchPage() { const payload = await exportPlatformAuditLogs({ format: 'csv', limit: 1000 }); const item = payload.item; if (item?.contentBase64 && item.filename) { - const ok = downloadBase64File(item.filename, item.contentBase64, item.mimeType || 'text/csv'); - Taro.showToast({ title: ok ? '已导出' : '已生成', icon: 'success' }); + await downloadBase64File(item.filename, item.contentBase64, item.mimeType || 'text/csv'); + Taro.showToast({ title: '已导出', icon: 'success' }); } } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '审计导出失败'); diff --git a/apps/taro/src/pages/student/ai-school/index.tsx b/apps/taro/src/pages/student/ai-school/index.tsx index bd087d1b..5b548104 100644 --- a/apps/taro/src/pages/student/ai-school/index.tsx +++ b/apps/taro/src/pages/student/ai-school/index.tsx @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Input, Picker, Text, Textarea, View } from '@tarojs/components'; +import { downloadTextFile } from '@/capabilities/file'; import { exportSchoolRecommendationReport, generateSchoolRecommendation, loadSchoolRecommendationReports, type SchoolRecommendationReport, } from '@/services/ai'; -import { isH5Runtime } from '@/env'; import { loadProfile, type StudentProfile } from '@/services/profile'; import '../student.css'; @@ -25,24 +25,6 @@ function recommendationRows(report: SchoolRecommendationReport | null) { return report?.resultPayload?.recommendedSchools || []; } -function saveExportFile(fileName: string, content: string, mimeType: string) { - if (isH5Runtime() && typeof window !== 'undefined' && typeof document !== 'undefined') { - const blob = new Blob([content], { type: mimeType }); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - link.rel = 'noopener noreferrer'; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - return; - } - Taro.setClipboardData({ data: content }); - Taro.showToast({ title: '报告内容已复制', icon: 'none' }); -} - export default function StudentAiSchoolPage() { const [profile, setProfile] = useState(null); const [reports, setReports] = useState([]); @@ -94,8 +76,8 @@ export default function StudentAiSchoolPage() { try { const payload = await exportSchoolRecommendationReport(current.id, format); if (!payload.item?.contentText) throw new Error('后端未返回报告内容'); - saveExportFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType); - if (isH5Runtime()) Taro.showToast({ title: '报告已导出', icon: 'success' }); + const result = await downloadTextFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType); + Taro.showToast({ title: result === 'copied' ? '报告内容已复制' : '报告已导出', icon: 'success' }); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '导出失败'); } finally { diff --git a/apps/taro/src/pages/student/assets/index.tsx b/apps/taro/src/pages/student/assets/index.tsx index 31781d4d..8423ddd3 100644 --- a/apps/taro/src/pages/student/assets/index.tsx +++ b/apps/taro/src/pages/student/assets/index.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Text, View, WebView } from '@tarojs/components'; +import { openRemoteFile } from '@/capabilities/file'; import { loadContentAssets, signAssetDownload, @@ -19,14 +20,10 @@ type PreviewState = { watermark?: AssetWatermarkContext; }; -function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') { +async function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') { if (!url) return; - if (isH5Runtime() && typeof window !== 'undefined') { - window.open(url, '_blank', 'noopener,noreferrer'); - return; - } - Taro.setClipboardData({ data: url }); - Taro.showToast({ title: copiedText, icon: 'none' }); + const result = await openRemoteFile(url); + if (result === 'copied') Taro.showToast({ title: copiedText, icon: 'none' }); } function isImageAsset(asset: ContentAsset) { @@ -92,7 +89,7 @@ export default function StudentAssetsPage() { Taro.showToast({ title: '请确认水印追踪码后下载', icon: 'none' }); return; } - openUrl(payload.download?.url, '下载签名已复制,请及时使用'); + await openUrl(payload.download?.url, '下载签名已复制,请及时使用'); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '下载失败'); } finally { @@ -100,8 +97,8 @@ export default function StudentAssetsPage() { } } - function openSignedLink() { - openUrl(previewState?.link.url, previewState?.kind === 'download' ? '下载签名已复制,请及时使用' : '预览签名已复制,请及时打开'); + async function openSignedLink() { + await openUrl(previewState?.link.url, previewState?.kind === 'download' ? '下载签名已复制,请及时使用' : '预览签名已复制,请及时打开'); } return ( diff --git a/apps/taro/src/pages/student/checkout/index.tsx b/apps/taro/src/pages/student/checkout/index.tsx index 40405bb7..82a7fc07 100644 --- a/apps/taro/src/pages/student/checkout/index.tsx +++ b/apps/taro/src/pages/student/checkout/index.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import Taro, { useRouter } from '@tarojs/taro'; import { Button, Input, Text, View } from '@tarojs/components'; +import { launchPayment, paymentReturnUrl } from '@/capabilities/payment'; import { claimCoupon, createOrder, @@ -12,7 +13,6 @@ import { type PaymentCreateResult, type SvipPlan, } from '@/services/commerce'; -import { isH5Runtime, isWeappRuntime } from '@/env'; import { loadProfile, type StudentProfile } from '@/services/profile'; import '../student.css'; @@ -55,16 +55,6 @@ function buildPaymentUrl(result?: PaymentCreateResult) { return ''; } -function tryOpenPaymentUrl(url: string) { - if (!url) return false; - if (isH5Runtime() && typeof window !== 'undefined') { - window.location.href = url; - return true; - } - Taro.setClipboardData({ data: url }); - return true; -} - export default function StudentCheckoutPage() { const router = useRouter(); const params = router.params || {}; @@ -168,8 +158,8 @@ export default function StudentCheckoutPage() { const paymentPayload = await createPayment({ orderNo: nextOrder.orderNo, provider, - returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined, - quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined, + returnUrl: paymentReturnUrl(), + quitUrl: paymentReturnUrl(), }); setPayment(paymentPayload.item || null); setMessage(provider === 'manual' ? '已生成线下支付记录,请联系教务或客服确认。' : '支付参数已生成,请继续完成支付。'); @@ -182,19 +172,18 @@ export default function StudentCheckoutPage() { async function handleOpenPayment() { if (!payment) return; - if (payment.provider === 'wechat_pay' && isWeappRuntime()) { - const paramsForWeapp = payment.paymentParams || {}; - Taro.requestPayment(paramsForWeapp as unknown as Taro.requestPayment.Option) - .then(() => order?.orderNo ? pollStatus(order.orderNo) : undefined) - .catch(nextError => setError(nextError instanceof Error ? nextError.message : '微信支付未完成')); - return; + try { + const result = await launchPayment({ + provider: payment.provider, + paymentParams: payment.paymentParams, + paymentUrl, + }); + if (result === 'completed' && order?.orderNo) await pollStatus(order.orderNo); + if (result === 'copied-url') setMessage('支付链接已复制,请在支持的支付容器中打开。'); + if (result === 'copied-params') setMessage('支付参数已复制,请交给支付容器或客服处理。'); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '支付未完成'); } - if (!paymentUrl) { - await Taro.setClipboardData({ data: JSON.stringify(payment.paymentParams || {}) }); - setMessage('支付参数已复制,请交给支付容器或客服处理。'); - return; - } - tryOpenPaymentUrl(paymentUrl); } return ( diff --git a/apps/taro/src/pages/student/home/index.tsx b/apps/taro/src/pages/student/home/index.tsx index 3cf4ea3a..15a06586 100644 --- a/apps/taro/src/pages/student/home/index.tsx +++ b/apps/taro/src/pages/student/home/index.tsx @@ -1,25 +1,21 @@ import { useEffect, useMemo, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Text, View } from '@tarojs/components'; -import { getTenantContext } from '@/services/api'; +import { useApp } from '@/app/AppProvider'; import { loadStudentDashboard, type DashboardSnapshot } from '@/services/catalog'; -import { requireSignedIn } from '@/services/routeGuard'; import './index.css'; export default function StudentHomePage() { - const tenant = getTenantContext(); + const { currentUser, tenant } = useApp(); const [snapshot, setSnapshot] = useState(null); - const [userName, setUserName] = useState(''); useEffect(() => { - requireSignedIn('/pages/student/home/index').then(payload => { - if (payload) setUserName(payload.user?.name || payload.item?.name || ''); - }).catch(() => undefined); loadStudentDashboard().then(setSnapshot).catch(() => setSnapshot({ entries: [], banners: [], announcements: [], profile: null })); }, []); const entryNames = useMemo(() => (snapshot?.entries || []).slice(0, 6), [snapshot]); const brandName = tenant?.branding.brandName || tenant?.branding.shortName || '工学题库'; + const userName = currentUser?.name || currentUser?.username || ''; const profile = snapshot?.profile && typeof snapshot.profile === 'object' ? snapshot.profile as Record : {}; const stats = profile.stats && typeof profile.stats === 'object' ? profile.stats as Record : {}; const answerStats = stats.answers && typeof stats.answers === 'object' ? stats.answers as Record : {}; diff --git a/apps/taro/src/pages/student/login/index.tsx b/apps/taro/src/pages/student/login/index.tsx index c225477d..6a4e0c0a 100644 --- a/apps/taro/src/pages/student/login/index.tsx +++ b/apps/taro/src/pages/student/login/index.tsx @@ -1,16 +1,13 @@ import { useEffect, useState } from 'react'; import { Button, Input, Text, View } from '@tarojs/components'; -import { appEnv, ensureRuntimeConfigLoaded, type Portal } from '@/env'; -import { getTenantContext } from '@/services/api'; -import { loadCurrentUser, sendSmsCode, verifySmsCode } from '@/services/auth'; -import { currentRouteParams, ensureTenantResolved, landingPath, redirectAfterLogin } from '@/services/routeGuard'; +import { useApp } from '@/app/AppProvider'; +import { sendSmsCode, verifySmsCode } from '@/services/auth'; +import { currentRouteParams, landingPath, redirectAfterLogin } from '@/services/routeGuard'; import '../student.css'; export default function StudentLoginPage() { - const tenant = getTenantContext(); + const { bootstrapError, bootstrapStatus, currentUser, refresh, runtimeConfig, tenant } = useApp(); const params = currentRouteParams(); - const [portal, setPortal] = useState(appEnv.portal); - const [runtimeReady, setRuntimeReady] = useState(false); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [debugCode, setDebugCode] = useState(''); @@ -22,8 +19,11 @@ export default function StudentLoginPage() { const [verifying, setVerifying] = useState(false); const phoneValue = phone.trim(); const codeValue = code.trim(); + const portal = runtimeConfig.portal; + const runtimeReady = Boolean(tenant) && !['idle', 'loading-runtime', 'resolving-tenant'].includes(bootstrapStatus); const canSendCode = runtimeReady && /^1\d{10}$/.test(phoneValue) && !sending; const canSubmit = runtimeReady && /^1\d{10}$/.test(phoneValue) && /^\d{4,8}$/.test(codeValue || debugCode) && !verifying; + const displayError = error || (bootstrapStatus === 'forbidden' ? bootstrapError : ''); function setPhoneDigits(value: string) { setPhone(value.replace(/\D/g, '').slice(0, 11)); @@ -34,25 +34,22 @@ export default function StudentLoginPage() { } useEffect(() => { - ensureRuntimeConfigLoaded() - .then(async () => { - await ensureTenantResolved(); - setPortal(appEnv.portal); - setRuntimeReady(true); - if (params.reason) { - setReason(decodeURIComponent(String(params.reason))); - return; - } - loadCurrentUser() - .then(() => redirectAfterLogin(params.redirect || landingPath())) - .catch(() => undefined); - }) - .catch(() => { - setPortal(appEnv.portal); - setRuntimeReady(true); + if (params.reason) { + setReason(decodeURIComponent(String(params.reason))); + return; + } + refresh({ path: landingPath(), authenticatePublic: true }) + .then(authorized => { + if (authorized) redirectAfterLogin(params.redirect || landingPath()); }); }, []); + useEffect(() => { + if (bootstrapStatus === 'ready' && currentUser && !reason) { + redirectAfterLogin(params.redirect || landingPath()); + } + }, [bootstrapStatus, currentUser, reason]); + const isPlatformAdmin = portal === 'platform-admin'; const isTenantAdmin = portal === 'tenant-admin'; const isAdminPortal = isPlatformAdmin || isTenantAdmin; @@ -92,7 +89,6 @@ export default function StudentLoginPage() { setError(''); setMessage(''); try { - await ensureTenantResolved(); const result = await sendSmsCode(phoneValue); const nextCode = typeof result.debugCode === 'string' ? result.debugCode : ''; setDebugCode(nextCode); @@ -119,7 +115,9 @@ export default function StudentLoginPage() { setError(''); try { await verifySmsCode(phoneValue, codeValue || debugCode); - redirectAfterLogin(params.redirect || landingPath()); + const redirectPath = params.redirect || landingPath(); + const authorized = await refresh({ path: redirectPath, authenticatePublic: true }); + if (authorized) redirectAfterLogin(redirectPath); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '登录失败'); } finally { @@ -161,7 +159,7 @@ export default function StudentLoginPage() { - {error ? {error} : null} + {displayError ? {displayError} : null} ); diff --git a/apps/taro/src/pages/student/order-detail/index.tsx b/apps/taro/src/pages/student/order-detail/index.tsx index 424b1130..d4247771 100644 --- a/apps/taro/src/pages/student/order-detail/index.tsx +++ b/apps/taro/src/pages/student/order-detail/index.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; import Taro, { useRouter } from '@tarojs/taro'; import { Button, Text, View } from '@tarojs/components'; +import { useApp } from '@/app/AppProvider'; +import { launchPayment, paymentReturnUrl } from '@/capabilities/payment'; +import { copyText } from '@/capabilities/share'; import { createPayment, loadOrderDetail, @@ -9,8 +12,6 @@ import { type OrderStatus, type PaymentCreateResult, } from '@/services/commerce'; -import { getTenantContext } from '@/services/api'; -import { isH5Runtime } from '@/env'; import '../student.css'; function centsToYuan(value?: number | null) { @@ -49,7 +50,7 @@ function paymentUrl(result?: PaymentCreateResult | null) { export default function StudentOrderDetailPage() { const router = useRouter(); const orderNo = router.params?.orderNo || ''; - const tenant = getTenantContext(); + const { tenant } = useApp(); const [detail, setDetail] = useState(null); const [status, setStatus] = useState(null); const [payment, setPayment] = useState(null); @@ -95,20 +96,24 @@ export default function StudentOrderDetailPage() { const payload = await createPayment({ orderNo, provider: provider || detail?.payProvider || status?.payProvider || 'alipay', - returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined, - quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined, + returnUrl: paymentReturnUrl(), + quitUrl: paymentReturnUrl(), }); const nextPayment = payload.item || null; setPayment(nextPayment); const url = paymentUrl(nextPayment); - if (url && isH5Runtime() && typeof window !== 'undefined') { - window.location.href = url; - } else if (url) { - await Taro.setClipboardData({ data: url }); - setMessage('支付链接已复制。'); - } else { + if (nextPayment?.provider === 'manual' && !url) { setMessage(nextPayment?.provider === 'manual' ? '线下支付订单已生成,请联系教务或客服确认。' : '支付参数已生成。'); + return; } + const result = await launchPayment({ + provider: nextPayment?.provider, + paymentParams: nextPayment?.paymentParams, + paymentUrl: url, + }); + if (result === 'completed') reload(); + if (result === 'copied-url') setMessage('支付链接已复制。'); + if (result === 'copied-params') setMessage('支付参数已生成并复制。'); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '继续支付失败'); } finally { @@ -118,9 +123,7 @@ export default function StudentOrderDetailPage() { async function copyAfterSalesInfo() { const serviceText = tenant?.branding?.slogan || tenant?.branding?.brandName || '请联系当前租户客服处理售后'; - await Taro.setClipboardData({ - data: `订单号:${orderNo}\n售后说明:${serviceText}`, - }); + await copyText(`订单号:${orderNo}\n售后说明:${serviceText}`); setMessage('订单售后信息已复制。'); } diff --git a/apps/taro/src/pages/student/practice/index.tsx b/apps/taro/src/pages/student/practice/index.tsx index de84ddee..4c2078ce 100644 --- a/apps/taro/src/pages/student/practice/index.tsx +++ b/apps/taro/src/pages/student/practice/index.tsx @@ -17,7 +17,8 @@ import { type QuestionItem, } from '@/services/learning'; import { submitFeedback } from '@/services/profile'; -import { getStorage, setStorage } from '@/services/storage'; +import { createUserStorage } from '@/services/storage'; +import { useApp } from '@/app/AppProvider'; import '../student.css'; type AnswerState = { @@ -148,7 +149,12 @@ function secondsUntil(value?: string | null) { export default function StudentPracticePage() { const router = useRouter(); + const { currentUser, tenant } = useApp(); const params = router.params || {}; + const userStorage = useMemo( + () => createUserStorage({ tenantId: tenant?.tenantId || 'unresolved', userId: currentUser?.id || 'anonymous' }), + [tenant?.tenantId, currentUser?.id], + ); const [session, setSession] = useState(null); const [questions, setQuestions] = useState([]); const [index, setIndex] = useState(0); @@ -185,7 +191,7 @@ export default function StudentPracticePage() { setSession(nextSession); setQuestions(nextSession.questions || []); setAnswerByQuestion(backendAnswers); - const savedIndex = getStorage(indexStorageKey(nextSession.id)); + const savedIndex = userStorage.get(indexStorageKey(nextSession.id)); const firstUnanswered = (nextSession.questionIds || []).findIndex(questionId => !backendAnswers[questionId]); setIndex(typeof savedIndex === 'number' ? savedIndex : Math.max(0, firstUnanswered)); const remaining = secondsUntil(nextSession.expiresAt); @@ -202,8 +208,8 @@ export default function StudentPracticePage() { const nextSession = payload.item; setSession(nextSession); if (nextSession.durationMinutes) setTimeLeft(nextSession.durationMinutes * 60); - const savedIndex = getStorage(indexStorageKey(nextSession.id)); - const savedAnswers = getStorage>(answerStorageKey(nextSession.id)); + const savedIndex = userStorage.get(indexStorageKey(nextSession.id)); + const savedAnswers = userStorage.get>(answerStorageKey(nextSession.id)); if (savedAnswers) setAnswerByQuestion(savedAnswers); if (typeof savedIndex === 'number') setIndex(savedIndex); const questionPayload = collectionId @@ -238,13 +244,13 @@ export default function StudentPracticePage() { useEffect(() => { if (!session) return; - setStorage(indexStorageKey(session.id), index); - }, [index, session?.id]); + userStorage.set(indexStorageKey(session.id), index); + }, [index, session?.id, userStorage]); useEffect(() => { if (!session) return; - setStorage(answerStorageKey(session.id), answerByQuestion); - }, [answerByQuestion, session?.id]); + userStorage.set(answerStorageKey(session.id), answerByQuestion); + }, [answerByQuestion, session?.id, userStorage]); useEffect(() => { if (!current) return; diff --git a/apps/taro/src/pages/student/profile/index.tsx b/apps/taro/src/pages/student/profile/index.tsx index 30e5ff48..cae93d09 100644 --- a/apps/taro/src/pages/student/profile/index.tsx +++ b/apps/taro/src/pages/student/profile/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Input, Text, View } from '@tarojs/components'; -import { logout } from '@/services/auth'; +import { useApp } from '@/app/AppProvider'; import { checkActivationCode, loadEntitlements, loadOrders, loadSvipPlans, redeemActivationCode, type OrderItem, type SvipPlan } from '@/services/commerce'; import { loadLearningStats, @@ -143,6 +143,7 @@ function avatarPresetLabel(preset?: string | null) { } export default function StudentProfilePage() { + const { signOut } = useApp(); const [profile, setProfile] = useState(null); const [plans, setPlans] = useState([]); const [orders, setOrders] = useState([]); @@ -236,7 +237,7 @@ export default function StudentProfilePage() { } async function handleLogout() { - await logout(); + await signOut(); Taro.redirectTo({ url: '/pages/student/login/index' }); } diff --git a/apps/taro/src/pages/student/vocabulary/index.tsx b/apps/taro/src/pages/student/vocabulary/index.tsx index 104fee2f..dce84652 100644 --- a/apps/taro/src/pages/student/vocabulary/index.tsx +++ b/apps/taro/src/pages/student/vocabulary/index.tsx @@ -11,7 +11,8 @@ import { type VocabularyWord, } from '@/services/learning'; import { playWordPronunciation, type AccentType } from '@/services/pronunciation'; -import { getStorage, removeStorage, setStorage } from '@/services/storage'; +import { createUserStorage } from '@/services/storage'; +import { useApp } from '@/app/AppProvider'; import '../student.css'; type StudyMode = 'plan' | 'unit' | 'favorites'; @@ -56,6 +57,11 @@ function modeLabel(mode: StudyMode) { } export default function StudentVocabularyPage() { + const { currentUser, tenant } = useApp(); + const userStorage = useMemo( + () => createUserStorage({ tenantId: tenant?.tenantId || 'unresolved', userId: currentUser?.id || 'anonymous' }), + [tenant?.tenantId, currentUser?.id], + ); const [units, setUnits] = useState([]); const [unitId, setUnitId] = useState(''); const [mode, setMode] = useState('plan'); @@ -110,7 +116,7 @@ export default function StudentVocabularyPage() { newCount: Number(planPayload.item?.newCount || 0), totalPlanned: Number(planPayload.item?.totalPlanned || planned.length || 0), }); - const savedIndex = getStorage(progressStorageKey(unitId, mode)); + const savedIndex = userStorage.get(progressStorageKey(unitId, mode)); setWords(nextWords); setFavoriteIds(Object.fromEntries((favoritesForStatus.items || []).map(item => wordRecordId(item)).filter(Boolean).map(id => [id, true]))); setStats(statPayload.item || null); @@ -118,12 +124,12 @@ export default function StudentVocabularyPage() { }) .catch(nextError => setError(nextError instanceof Error ? nextError.message : '单词加载失败')) .finally(() => setLoading(false)); - }, [unitId, mode]); + }, [unitId, mode, userStorage]); useEffect(() => { if (!unitId || !words.length) return; - setStorage(progressStorageKey(unitId, mode), index); - }, [index, mode, unitId, words.length]); + userStorage.set(progressStorageKey(unitId, mode), index); + }, [index, mode, unitId, userStorage, words.length]); const current = words[index] || null; const progressPercent = words.length ? Math.round(((Math.min(index + 1, words.length)) / words.length) * 100) : 0; @@ -141,7 +147,7 @@ export default function StudentVocabularyPage() { const unitName = useMemo(() => units.find(item => item.id === unitId)?.name || '单词单元', [units, unitId]); function resetPosition(nextMode = mode) { - removeStorage(progressStorageKey(unitId, nextMode)); + userStorage.remove(progressStorageKey(unitId, nextMode)); setIndex(0); setShowAnswer(false); setCompleted(false); diff --git a/apps/taro/src/pages/tenant-admin/content/index.tsx b/apps/taro/src/pages/tenant-admin/content/index.tsx index d6654e5c..b1050563 100644 --- a/apps/taro/src/pages/tenant-admin/content/index.tsx +++ b/apps/taro/src/pages/tenant-admin/content/index.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Input, Text, Textarea, View } from '@tarojs/components'; +import { downloadBase64File, pickLocalFile } from '@/capabilities/file'; import { adoptPublicQuestionBank, executeContentImport, @@ -34,7 +35,6 @@ import { type PublicQuestionBankItem, type TenantContentNotificationItem, } from '@/services/tenantAdmin'; -import { isH5Runtime } from '@/env'; import '../admin.css'; function displayBankName(item: PublicQuestionBankItem) { @@ -52,83 +52,6 @@ function adoptionIdOf(item: PublicQuestionBankItem) { const importTypes: ImportType[] = ['questions', 'vocabulary', 'handbook', 'scoreline', 'videos']; const importFormats: ImportSourceFormat[] = ['json', 'csv', 'excel']; -function readLocalFileAsBase64(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = typeof reader.result === 'string' ? reader.result : ''; - resolve(result.includes(',') ? result.slice(result.indexOf(',') + 1) : result); - }; - reader.onerror = () => reject(new Error('文件读取失败')); - reader.readAsDataURL(file); - }); -} - -function readLocalFileAsText(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); - reader.onerror = () => reject(new Error('文件读取失败')); - reader.readAsText(file, 'utf-8'); - }); -} - -function openH5FilePicker(format: ImportSourceFormat) { - return new Promise<{ fileName: string; text?: string; fileBase64?: string }>((resolve, reject) => { - if (!isH5Runtime() || typeof document === 'undefined') { - reject(new Error('当前端暂未接入文件选择,请粘贴 JSON/CSV 内容后预览导入。')); - return; - } - const input = document.createElement('input'); - input.type = 'file'; - input.accept = format === 'excel' ? '.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : format === 'csv' ? '.csv,text/csv,text/plain' : '.json,application/json,text/plain'; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) { - reject(new Error('未选择文件')); - return; - } - try { - if (format === 'excel') { - resolve({ fileName: file.name, fileBase64: await readLocalFileAsBase64(file) }); - } else { - resolve({ fileName: file.name, text: await readLocalFileAsText(file) }); - } - } catch (error) { - reject(error); - } - }; - input.click(); - }); -} - -function base64ToUint8Array(base64: string) { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -} - -function downloadTemplateFile(template: ImportTemplateItem) { - if (!isH5Runtime() || typeof document === 'undefined') { - throw new Error('当前端暂不支持直接下载模板,请先使用模板预览。'); - } - if (!template.contentBase64) throw new Error('模板内容为空,请重新加载模板。'); - const blob = new Blob([base64ToUint8Array(template.contentBase64)], { - type: template.mimeType || 'application/octet-stream', - }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = template.fileName || `import-template.${template.format || 'json'}`; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); -} - function safeJsonPreview(value: unknown, maxLength = 360) { try { return JSON.stringify(value, null, 2).slice(0, maxLength); @@ -316,7 +239,12 @@ export default function TenantContentPage() { ? { item: template } : await loadImportTemplate(selectedImportType, sourceFormat === 'excel' ? 'csv' : sourceFormat); if (!payload.item) throw new Error('模板不存在。'); - downloadTemplateFile(payload.item); + if (!payload.item.contentBase64) throw new Error('模板内容为空,请重新加载模板。'); + await downloadBase64File( + payload.item.fileName || `import-template.${payload.item.format || 'json'}`, + payload.item.contentBase64, + payload.item.mimeType || 'application/octet-stream', + ); setTemplate(payload.item); Taro.showToast({ title: '已下载', icon: 'success' }); } catch (nextError) { @@ -327,14 +255,22 @@ export default function TenantContentPage() { async function chooseImportFile() { setError(''); try { - const file = await openH5FilePicker(sourceFormat); + const file = await pickLocalFile({ + accept: sourceFormat === 'excel' + ? '.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + : sourceFormat === 'csv' + ? '.csv,text/csv,text/plain' + : '.json,application/json,text/plain', + extensions: sourceFormat === 'excel' ? ['xlsx'] : sourceFormat === 'csv' ? ['csv', 'txt'] : ['json', 'txt'], + readAs: sourceFormat === 'excel' ? 'base64' : 'text', + }); setSourceName(file.fileName); if (file.text !== undefined) { setImportText(file.text); setFileBase64(''); } - if (file.fileBase64 !== undefined) { - setFileBase64(file.fileBase64); + if (file.base64 !== undefined) { + setFileBase64(file.base64); setImportText(''); } } catch (nextError) { diff --git a/apps/taro/src/pages/tenant-admin/marketing/index.tsx b/apps/taro/src/pages/tenant-admin/marketing/index.tsx index d159c5d5..70c8189f 100644 --- a/apps/taro/src/pages/tenant-admin/marketing/index.tsx +++ b/apps/taro/src/pages/tenant-admin/marketing/index.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Input, Text, View } from '@tarojs/components'; +import { downloadBase64File } from '@/capabilities/file'; import { createCommissionSettlementProof, exportCommissionSettlement, @@ -166,17 +167,6 @@ function shortDate(value?: string | null) { return value.replace('T', ' ').slice(0, 16); } -function downloadBase64File(filename: string, contentBase64: string, mimeType: string) { - if (typeof document === 'undefined') return false; - const link = document.createElement('a'); - link.href = `data:${mimeType};base64,${contentBase64}`; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - return true; -} - interface CouponFormState { id: string; code: string; @@ -964,8 +954,8 @@ export default function TenantMarketingPage() { const payload = await exportCommissionSettlement(item.id, 'csv'); const exportItem = payload.item; if (exportItem?.contentBase64 && exportItem.filename) { - const downloaded = downloadBase64File(exportItem.filename, exportItem.contentBase64, exportItem.mimeType || 'text/csv'); - Taro.showToast({ title: downloaded ? '导出已下载' : '导出已生成', icon: 'success' }); + await downloadBase64File(exportItem.filename, exportItem.contentBase64, exportItem.mimeType || 'text/csv'); + Taro.showToast({ title: '导出已下载', icon: 'success' }); } } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '结算导出失败'); diff --git a/apps/taro/src/pages/tenant-admin/settings/index.tsx b/apps/taro/src/pages/tenant-admin/settings/index.tsx index 770853b6..0b87d561 100644 --- a/apps/taro/src/pages/tenant-admin/settings/index.tsx +++ b/apps/taro/src/pages/tenant-admin/settings/index.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Input, Text, Textarea, View } from '@tarojs/components'; +import { useApp } from '@/app/AppProvider'; import { disableTenantMember, disableRoleTemplate, @@ -228,6 +229,7 @@ function catalogLabel(item: TenantPermissionCatalogItem) { } export default function TenantSettingsPage() { + const { refreshTenant } = useApp(); const [overview, setOverview] = useState(null); const [domains, setDomains] = useState[]>([]); const [payments, setPayments] = useState[]>([]); @@ -541,6 +543,7 @@ export default function TenantSettingsPage() { ...(themeForm.shareCardStyle.trim() ? { shareCardStyle: themeForm.shareCardStyle.trim() } : {}), }, }); + await refreshTenant(); Taro.showToast({ title: '主题已发布', icon: 'success' }); await reloadSettings(selectedRoleId); } catch (nextError) { diff --git a/apps/taro/src/pages/tenant-admin/students/index.tsx b/apps/taro/src/pages/tenant-admin/students/index.tsx index b958d07e..cd09ff49 100644 --- a/apps/taro/src/pages/tenant-admin/students/index.tsx +++ b/apps/taro/src/pages/tenant-admin/students/index.tsx @@ -145,6 +145,8 @@ export default function TenantStudentsPage() { const [supervisionRulesList, setSupervisionRulesList] = useState([]); const [supervisionRules, setSupervisionRules] = useState(defaultSupervisionRules); const [scoped, setScoped] = useState(false); + const [studentHasMore, setStudentHasMore] = useState(false); + const [nextStudentCursor, setNextStudentCursor] = useState(''); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); @@ -174,12 +176,40 @@ export default function TenantStudentsPage() { setCrmAssignees((memberPayload.items || []).filter(item => crmAssignableRoles.includes(String(item.role || '')))); setStudents(studentPayload.items || []); setScoped(studentPayload.scoped === true); + setStudentHasMore(studentPayload.hasMore === true); + setNextStudentCursor(studentPayload.nextCursor || ''); setFollowups(followupPayload.items || []); setFollowupReport(reportPayload.item || null); setSupervisionRulesList(supervisionRulePayload.items || []); }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '学生数据加载失败')); } + async function loadMoreStudents() { + if (!studentHasMore || !nextStudentCursor || busy === 'studentsMore') return; + setBusy('studentsMore'); + setError(''); + try { + const payload = await loadTenantStudents({ + classId: selectedClassId || undefined, + keyword: keyword || undefined, + status: studentStatus || undefined, + cursor: nextStudentCursor, + limit: 80, + }); + setStudents(previous => { + const byUserId = new Map(previous.map(item => [item.userId, item])); + for (const item of payload.items || []) byUserId.set(item.userId, item); + return Array.from(byUserId.values()); + }); + setStudentHasMore(payload.hasMore === true); + setNextStudentCursor(payload.nextCursor || ''); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '加载更多学生失败'); + } finally { + setBusy(''); + } + } + useEffect(() => { reload('', ''); }, []); @@ -636,6 +666,11 @@ export default function TenantStudentsPage() { ))} {!students.length ? 暂无学生,或当前角色没有可见学生范围。 : null} + {studentHasMore ? ( + + + + ) : null} diff --git a/apps/taro/src/pages/tenant-admin/workbench/index.tsx b/apps/taro/src/pages/tenant-admin/workbench/index.tsx index 0e662012..7904ed5e 100644 --- a/apps/taro/src/pages/tenant-admin/workbench/index.tsx +++ b/apps/taro/src/pages/tenant-admin/workbench/index.tsx @@ -1,16 +1,13 @@ import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Text, View } from '@tarojs/components'; -import { getTenantContext } from '@/services/api'; +import { useApp } from '@/app/AppProvider'; import { loadTenantDashboard, loadTenantOverview, - loadTenantPermissions, type TenantDashboard, type TenantOverview, - type TenantPermissionsPayload, } from '@/services/tenantAdmin'; -import { requireTenantAdmin } from '@/services/routeGuard'; import '../admin.css'; interface AdminModule { @@ -30,79 +27,24 @@ const MODULES: AdminModule[] = [ { key: 'settings', name: '租户设置', path: '/pages/tenant-admin/settings/index', meta: '品牌、域名、支付、登录、角色', permission: 'tenant:overview:read' }, ]; -function boolRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; -} - -function explicitPermission(permissions: Record, permission: string) { - const parts = permission.split(':').filter(Boolean); - const candidates = [permission]; - for (let index = parts.length - 1; index >= 1; index -= 1) { - candidates.push(`${parts.slice(0, index).join(':')}:*`); - } - candidates.push('*'); - for (const key of candidates) { - if (typeof permissions[key] === 'boolean') return permissions[key] as boolean; - } - return null; -} - -function canOpenModule(payload: TenantPermissionsPayload, item: AdminModule) { - const current = payload.current || {}; - const menuPermissions = boolRecord(current.menuPermissions); - if (typeof menuPermissions[item.key] === 'boolean') return menuPermissions[item.key] as boolean; - - if (!item.permission) return true; - const effectivePermissions = boolRecord(current.effectivePermissions); - const explicit = explicitPermission(effectivePermissions, item.permission); - if (explicit !== null) return explicit; - - const role = String(current.role || ''); - const defaults = payload.roleDefaults?.[role] || []; - return defaults.some(permission => { - if (permission === '*') return true; - if (permission === item.permission) return true; - if (permission.endsWith(':*')) return item.permission?.startsWith(permission.slice(0, -1)); - return false; - }); -} - export default function TenantWorkbenchPage() { - const tenant = getTenantContext(); + const { canTenantMenu, tenant } = useApp(); const [dashboard, setDashboard] = useState(null); const [overview, setOverview] = useState(null); - const [permissions, setPermissions] = useState({}); - const [authorized, setAuthorized] = useState(false); useEffect(() => { - requireTenantAdmin('/pages/tenant-admin/workbench/index') - .then(payload => { - if (!payload) return; - setAuthorized(true); - loadTenantDashboard('30d').then(next => setDashboard(next.item || null)).catch(() => setDashboard(null)); - loadTenantOverview().then(next => setOverview(next.item || null)).catch(() => setOverview(null)); - loadTenantPermissions().then(next => setPermissions(next)).catch(() => setPermissions({})); - }) - .catch(() => setPermissions({})); + Promise.all([ + loadTenantDashboard('30d').catch(() => ({ item: null })), + loadTenantOverview().catch(() => ({ item: null })), + ]).then(([dashboardPayload, overviewPayload]) => { + setDashboard(dashboardPayload.item || null); + setOverview(overviewPayload.item || null); + }); }, []); const cards = dashboard?.cards || {}; const payment = dashboard?.paymentStats || {}; - const modules = MODULES.filter(item => canOpenModule(permissions, item)); - - if (!authorized) { - return ( - - - - Tenant Admin - 正在校验后台权限 - 请先完成登录,系统会确认当前账号是否拥有租户后台权限。 - - - - ); - } + const modules = MODULES.filter(item => canTenantMenu({ menuKey: item.key, permission: item.permission })); return ( diff --git a/apps/taro/src/services/api-auth.ts b/apps/taro/src/services/api-auth.ts index e2eb9913..c1856352 100644 --- a/apps/taro/src/services/api-auth.ts +++ b/apps/taro/src/services/api-auth.ts @@ -1,4 +1,4 @@ -import { isH5Runtime } from '../env'; +import { appEnv, isH5Runtime } from '../env'; export type ApiAuthMode = 'auto' | 'none' | 'supabase' | 'legacy'; @@ -21,6 +21,7 @@ export async function resolveApiAuthorization(input: { hasTokenOverride: boolean; explicitToken?: string | null; legacyToken?: string | null; + legacySource?: string | null; }) { const authMode = input.authMode || 'auto'; if (authMode === 'none') return null; @@ -33,10 +34,15 @@ export async function resolveApiAuthorization(input: { return input.legacyToken || null; } - const supabaseToken = await supabaseAccessTokenProvider(); - if (supabaseToken) return supabaseToken; + if (authMode === 'auto' && input.legacyToken && input.legacySource === 'app_session') { + return input.legacyToken; + } + + const supabaseConfigured = isH5Runtime() && Boolean(appEnv.supabaseUrl && appEnv.supabasePublishableKey); + if (supabaseConfigured || authMode === 'supabase') { + return await supabaseAccessTokenProvider(); + } - if (authMode === 'supabase') return null; return input.legacyToken || null; } diff --git a/apps/taro/src/services/api.ts b/apps/taro/src/services/api.ts index e0f79895..4d6f0cd3 100644 --- a/apps/taro/src/services/api.ts +++ b/apps/taro/src/services/api.ts @@ -3,14 +3,24 @@ import { appEnv, ensureRuntimeConfigLoaded } from '@/env'; import type { ApiErrorPayload, ApiSession, TenantContext } from '@/types'; import { buildApiHeaders, resolveApiAuthorization } from './api-auth'; import type { ApiAuthMode } from './api-auth'; -import { getStorage, removeStorage, setStorage } from './storage'; +import { + clearActiveStorageUserData, + currentSessionStorageKey, + currentTenantContextStorageKey, + getJsonStorage, + removeJsonStorage, + setJsonStorage, +} from '@/capabilities/storage'; +import { emitSessionChange } from '@/app/session-events'; +import { tenantResolveQuery } from '@/app/tenant-resolution'; -const TENANT_KEY = 'tiku:tenant'; -const SESSION_KEY = 'tiku:session'; +const LEGACY_TENANT_KEY = 'tiku:tenant'; +const LEGACY_SESSION_KEY = 'tiku:session'; export class ApiError extends Error { status: number; code: string; + requestId?: string; details?: unknown; constructor(payload: ApiErrorPayload) { @@ -18,37 +28,110 @@ export class ApiError extends Error { this.name = 'ApiError'; this.status = payload.status; this.code = payload.code; + this.requestId = payload.requestId; this.details = payload.details; } } export function getTenantContext() { - return getStorage(TENANT_KEY); + return getJsonStorage(currentTenantContextStorageKey()); } export function saveTenantContext(tenant: TenantContext) { - setStorage(TENANT_KEY, tenant); + const previous = getTenantContext(); + if (previous?.tenantId && previous.tenantId !== tenant.tenantId) { + removeJsonStorage(currentSessionStorageKey(previous.tenantId)); + clearActiveStorageUserData(previous.tenantId); + emitSessionChange('tenant-changed'); + } + setJsonStorage(currentTenantContextStorageKey(), tenant); + removeJsonStorage(LEGACY_TENANT_KEY); + removeJsonStorage(LEGACY_SESSION_KEY); } export function clearTenantContext() { - removeStorage(TENANT_KEY); + const tenant = getTenantContext(); + if (tenant?.tenantId) { + removeJsonStorage(currentSessionStorageKey(tenant.tenantId)); + clearActiveStorageUserData(tenant.tenantId); + } + removeJsonStorage(currentTenantContextStorageKey()); + removeJsonStorage(LEGACY_TENANT_KEY); + removeJsonStorage(LEGACY_SESSION_KEY); + emitSessionChange('tenant-changed'); +} + +function discardRejectedTenantContext() { + const tenant = getTenantContext(); + if (tenant?.tenantId) { + removeJsonStorage(currentSessionStorageKey(tenant.tenantId)); + clearActiveStorageUserData(tenant.tenantId); + } + removeJsonStorage(currentTenantContextStorageKey()); + removeJsonStorage(LEGACY_TENANT_KEY); + removeJsonStorage(LEGACY_SESSION_KEY); } export function getSession() { - return getStorage(SESSION_KEY); + const tenant = getTenantContext(); + if (!tenant?.tenantId) return null; + const key = currentSessionStorageKey(tenant.tenantId); + const session = getJsonStorage(key); + if (session?.expiresAt) { + const expiresAt = Date.parse(session.expiresAt); + if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) { + removeJsonStorage(key); + clearActiveStorageUserData(tenant.tenantId); + emitSessionChange('expired'); + return null; + } + } + return session; } export function saveSession(session: ApiSession) { - setStorage(SESSION_KEY, session); + const tenant = getTenantContext(); + if (!tenant?.tenantId) throw new Error('保存会话前必须先解析租户'); + setJsonStorage(currentSessionStorageKey(tenant.tenantId), session); + removeJsonStorage(LEGACY_SESSION_KEY); + emitSessionChange('saved'); } -export function clearSession() { - removeStorage(SESSION_KEY); +export function clearSession(options: { emit?: boolean } = {}) { + const tenant = getTenantContext(); + if (tenant?.tenantId) { + removeJsonStorage(currentSessionStorageKey(tenant.tenantId)); + clearActiveStorageUserData(tenant.tenantId); + } + removeJsonStorage(LEGACY_SESSION_KEY); + if (options.emit !== false) emitSessionChange('cleared'); } export type { ApiAuthMode, SupabaseAccessTokenProvider } from './api-auth'; export { setSupabaseAccessTokenProviderForTest } from './api-auth'; +async function clearRejectedAuthentication(rejectedToken: string | null) { + const currentSession = getSession(); + const { resolveApiAuthorization: resolveCurrentAuthorization } = await import('./api-auth'); + const currentToken = await resolveCurrentAuthorization({ + authMode: 'auto', + hasTokenOverride: false, + legacyToken: currentSession?.token, + legacySource: currentSession?.source, + }); + if ((rejectedToken || null) !== (currentToken || null)) return; + clearSession({ emit: false }); + try { + const { ensureSupabaseClient } = await import('./supabase'); + const supabase = await ensureSupabaseClient(); + if (supabase) await supabase.auth.signOut({ scope: 'local' }); + } catch { + // The scoped legacy session is already removed; SDK cleanup is best effort. + } finally { + emitSessionChange('cleared'); + } +} + function normalizeBaseUrl(baseUrl: string) { return baseUrl.replace(/\/+$/, ''); } @@ -61,6 +144,15 @@ function buildUrl(path: string, query?: Record | undefined, name: string) { + if (!headers) return ''; + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) return String(value || '').trim(); + } + return ''; +} + export async function apiRequest( path: string, options: { @@ -85,6 +177,7 @@ export async function apiRequest( hasTokenOverride, explicitToken: options.token, legacyToken: session?.token, + legacySource: session?.source, }); const headers = buildApiHeaders({ tenantId, token, extraHeaders: options.headers }); @@ -95,12 +188,17 @@ export async function apiRequest( header: headers, }); const payload = (response.data || {}) as Record; + const responseMeta = payload.meta && typeof payload.meta === 'object' && !Array.isArray(payload.meta) + ? payload.meta as Record + : {}; + const requestId = String(responseMeta.requestId || payload.requestId || responseHeaderValue(response.header, 'x-request-id') || '') || undefined; if (response.statusCode < 200 || response.statusCode >= 300) { - if (response.statusCode === 401) clearSession(); + if (response.statusCode === 401) await clearRejectedAuthentication(token); throw new ApiError({ status: response.statusCode, code: String(payload.code || 'API_ERROR'), message: String(payload.message || payload.error || '请求失败'), + requestId, details: payload, }); } @@ -108,7 +206,12 @@ export async function apiRequest( } export async function resolveTenant(input: { host?: string; tenantCode?: string } = {}) { - const payload = await apiRequest<{ + const runtimeHost = input.host?.trim() || ''; + const resolveQuery = tenantResolveQuery({ + host: runtimeHost, + tenantCode: input.tenantCode || appEnv.tenantCode, + }); + let payload: { item?: TenantContext; tenant?: { id?: string; @@ -119,20 +222,28 @@ export async function resolveTenant(input: { host?: string; tenantCode?: string features?: TenantContext['features']; adminFeatures?: TenantContext['adminFeatures']; publicConfig?: TenantContext['publicConfig']; - }>('/api/tenant/resolve', { - query: { - host: input.host, - tenantCode: input.tenantCode || appEnv.tenantCode, - }, - tenantId: null, - authMode: 'none', - }); + }; + try { + payload = await apiRequest('/api/tenant/resolve', { + query: resolveQuery, + tenantId: null, + authMode: 'none', + }); + } catch (error) { + if ( + error instanceof ApiError + && ['TENANT_DOMAIN_NOT_BOUND', 'TENANT_CODE_NOT_FOUND', 'TENANT_HOST_CONFLICT', 'TENANT_LOCATOR_CONFLICT'].includes(error.code) + ) { + discardRejectedTenantContext(); + } + throw error; + } const tenantId = payload.item?.tenantId || payload.tenant?.tenantId || payload.tenant?.id; - if (!tenantId) throw new ApiError({ status: 500, code: 'TENANT_RESOLVE_INVALID', message: '租户解析结果缺少 tenantId' }); + if (!tenantId) throw new ApiError({ status: 502, code: 'TENANT_RESOLVE_INVALID', message: '租户解析结果缺少 tenantId' }); const context: TenantContext = { tenantId, tenantSlug: payload.item?.tenantSlug || payload.tenant?.slug, - host: input.host, + host: runtimeHost || undefined, branding: payload.item?.branding || payload.branding || {}, features: payload.item?.features || payload.features || {}, adminFeatures: payload.item?.adminFeatures || payload.adminFeatures || {}, diff --git a/apps/taro/src/services/auth.ts b/apps/taro/src/services/auth.ts index f21d8e4b..06a1ca02 100644 --- a/apps/taro/src/services/auth.ts +++ b/apps/taro/src/services/auth.ts @@ -1,10 +1,22 @@ -import { apiRequest, clearSession, saveSession } from './api'; +import { apiRequest, clearSession, getTenantContext, saveSession } from './api'; import type { ApiEnvelope, CurrentUser } from '@/types'; +import { activateStorageUser, getJsonStorage, setJsonStorage } from '@/capabilities/storage'; +export { subscribeSessionChanges as subscribeAuthChanges } from '@/app/session-events'; + +const SMS_DEVICE_ID_KEY = 'tiku:auth:sms-device-id'; + +function smsDeviceId() { + const existing = getJsonStorage(SMS_DEVICE_ID_KEY); + if (existing) return existing; + const generated = `${Date.now().toString(36)}-${Array.from({ length: 4 }, () => Math.random().toString(36).slice(2)).join('')}`.slice(0, 96); + setJsonStorage(SMS_DEVICE_ID_KEY, generated); + return generated; +} export async function sendSmsCode(phone: string, purpose: 'login' | 'bind_phone' = 'login') { return apiRequest>('/api/auth/sms/send', { method: 'POST', - body: { phone, purpose }, + body: { phone, purpose, deviceId: smsDeviceId() }, authMode: 'none', }); } @@ -15,7 +27,19 @@ export async function verifySmsCode(phone: string, code: string, purpose: 'login body: { phone, code, purpose }, authMode: 'none', }); - if (payload.session?.token) saveSession(payload.session); + if (payload.session?.token) { + try { + const { ensureSupabaseClient } = await import('./supabase'); + const supabase = await ensureSupabaseClient(); + if (supabase) await supabase.auth.signOut({ scope: 'local' }); + } catch { + // The app session remains authoritative even if SDK cleanup is unavailable. + } + const tenant = getTenantContext(); + const user = payload.user || payload.item; + if (tenant?.tenantId && user?.id) activateStorageUser(tenant.tenantId, user.id); + saveSession({ ...payload.session, source: 'app_session' }); + } return payload; } @@ -27,6 +51,14 @@ export async function logout() { try { await apiRequest('/api/auth/logout', { method: 'POST' }); } finally { - clearSession(); + clearSession({ emit: false }); + try { + const { ensureSupabaseClient } = await import('./supabase'); + const supabase = await ensureSupabaseClient(); + if (supabase) await supabase.auth.signOut(); + } finally { + const { emitSessionChange } = await import('@/app/session-events'); + emitSessionChange('cleared'); + } } } diff --git a/apps/taro/src/services/routeGuard.ts b/apps/taro/src/services/routeGuard.ts index 6c753883..a703cfce 100644 --- a/apps/taro/src/services/routeGuard.ts +++ b/apps/taro/src/services/routeGuard.ts @@ -1,21 +1,22 @@ import Taro from '@tarojs/taro'; import { appEnv, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env'; import type { ApiEnvelope, CurrentUser } from '@/types'; -import { getTenantContext, resolveTenant } from './api'; +import { ApiError, getTenantContext, resolveTenant } from './api'; import { loadCurrentUser } from './auth'; +import { loadPlatformPermissions } from './platformAdmin'; +import { replaceLocation, runtimeHost } from '@/capabilities/navigation'; +import { normalizePagePath, safePageRedirectPath } from '@/app/route-path'; + +export { normalizePagePath } from '@/app/route-path'; let pendingGuardPath = ''; function hostFromRuntime() { - if (isH5Runtime() && typeof window !== 'undefined') return window.location.host; - return ''; + return runtimeHost(); } export function safeRedirectPath(path: string) { - if (!path.startsWith('/pages/') || path.startsWith('/pages/student/login/') || path.startsWith('/pages/bootstrap/')) return landingPath(); - if (appEnv.portal === 'tenant-admin') return path.startsWith('/pages/tenant-admin/') ? path : landingPath(); - if (appEnv.portal === 'platform-admin') return path.startsWith('/pages/platform-admin/') ? path : landingPath(); - return path.startsWith('/pages/student/') ? path : landingPath(); + return safePageRedirectPath(path, appEnv.portal, landingPath()); } function loginUrl(redirectPath: string) { @@ -27,27 +28,28 @@ function forbiddenUrl(reason: string, redirectPath: string) { } function redirectToAuthUrl(url: string) { - if (isH5Runtime() && typeof window !== 'undefined') { - window.location.replace(url); - return; - } - Taro.redirectTo({ url }); + void replaceLocation(url); +} + +export function redirectToLogin(redirectPath: string) { + redirectToAuthUrl(loginUrl(redirectPath)); +} + +export function redirectToForbidden(reason: string, redirectPath: string) { + redirectToAuthUrl(forbiddenUrl(reason, redirectPath)); } export function currentPagePath() { if (isH5Runtime() && typeof window !== 'undefined') { - const hashPath = (window.location.hash || '').replace(/^#!?/, '').split('?')[0]; + const hashPath = normalizePagePath(window.location.hash || ''); if (hashPath.startsWith('/pages/')) return hashPath; - if (hashPath.startsWith('pages/')) return `/${hashPath}`; const pathname = window.location.pathname || ''; const pageIndex = pathname.indexOf('/pages/'); - if (pageIndex >= 0) return pathname.slice(pageIndex).split('?')[0]; + if (pageIndex >= 0) return normalizePagePath(pathname.slice(pageIndex)); if (!pathname || pathname === '/' || pathname.endsWith('/index.html')) return landingPath(); } const instance = Taro.getCurrentInstance(); - const path = instance.router?.path || ''; - const normalizedPath = path.startsWith('/') ? path : `/${path}`; - return normalizedPath; + return normalizePagePath(instance.router?.path || ''); } export function currentRouteParams() { @@ -87,28 +89,32 @@ export function landingPath() { export async function ensureTenantResolved() { await ensureRuntimeConfigLoaded(); + if (appEnv.portal === 'platform-admin') return null; const current = getTenantContext(); if (current?.tenantId) return current; return resolveTenant({ host: hostFromRuntime() }); } export async function requireSignedIn(redirectPath: string): Promise | null> { - await ensureTenantResolved(); + if (appEnv.portal !== 'platform-admin') await ensureTenantResolved(); try { return await loadCurrentUser(); - } catch { - redirectToAuthUrl(loginUrl(redirectPath)); + } catch (error) { + if (error instanceof ApiError && error.status === 403) redirectToForbidden('当前账号不是平台管理员', redirectPath); + else redirectToLogin(redirectPath); return null; } } export async function requirePlatformAdmin(redirectPath: string) { - const payload = await requireSignedIn(redirectPath); - if (!payload) return null; - const user = payload.user || payload.item; - const roles = user?.roles || []; - if (user?.primaryRole === 'platform_admin' || roles.includes('platform_admin')) return payload; - redirectToAuthUrl(forbiddenUrl('当前账号不是平台管理员', redirectPath)); + try { + const payload = await loadPlatformPermissions(); + if (payload.item?.userId) return payload; + } catch { + redirectToLogin(redirectPath); + return null; + } + redirectToForbidden('当前账号不是平台管理员', redirectPath); return null; } @@ -121,7 +127,7 @@ export async function requireTenantAdmin(redirectPath: string) { if (user?.primaryRole === 'platform_admin') return payload; if (user?.primaryRole && allowedRoles.has(user.primaryRole)) return payload; if (roles.some(role => allowedRoles.has(role) || role === 'platform_admin')) return payload; - redirectToAuthUrl(forbiddenUrl('当前账号没有租户后台权限', redirectPath)); + redirectToForbidden('当前账号没有租户后台权限', redirectPath); return null; } @@ -154,9 +160,5 @@ export async function guardCurrentRoute() { export function redirectAfterLogin(rawRedirect?: string) { const redirectPath = rawRedirect ? decodeURIComponent(rawRedirect) : landingPath(); const url = safeRedirectPath(redirectPath); - if (isH5Runtime() && typeof window !== 'undefined') { - window.location.replace(url); - return; - } - Taro.redirectTo({ url }); + void replaceLocation(url); } diff --git a/apps/taro/src/services/storage.ts b/apps/taro/src/services/storage.ts index 108f3283..d10a6e70 100644 --- a/apps/taro/src/services/storage.ts +++ b/apps/taro/src/services/storage.ts @@ -1,19 +1,44 @@ -import Taro from '@tarojs/taro'; +import { + currentTenantDataStorageKey, + getActiveStorageUserId, + getJsonStorage, + removeJsonStorage, + scopedTenantDataStorageKey, + setJsonStorage, +} from '@/capabilities/storage'; + +export interface UserStorageScope { + tenantId: string; + userId: string; +} + +export function createUserStorage(scope: UserStorageScope) { + const storageKey = (key: string) => scopedTenantDataStorageKey(scope.tenantId, scope.userId, key); + const isActive = () => getActiveStorageUserId(scope.tenantId) === scope.userId; + return { + get(key: string) { + if (!isActive()) return null; + return getJsonStorage(storageKey(key)); + }, + set(key: string, value: T) { + if (!isActive()) return; + setJsonStorage(storageKey(key), value); + }, + remove(key: string) { + if (!isActive()) return; + removeJsonStorage(storageKey(key)); + }, + }; +} export function getStorage(key: string): T | null { - try { - const value = Taro.getStorageSync(key); - if (!value) return null; - return JSON.parse(value) as T; - } catch { - return null; - } + return getJsonStorage(currentTenantDataStorageKey(key)); } export function setStorage(key: string, value: T) { - Taro.setStorageSync(key, JSON.stringify(value)); + setJsonStorage(currentTenantDataStorageKey(key), value); } export function removeStorage(key: string) { - Taro.removeStorageSync(key); + removeJsonStorage(currentTenantDataStorageKey(key)); } diff --git a/apps/taro/src/services/supabase.ts b/apps/taro/src/services/supabase.ts index 5d06c0f5..25d17f0a 100644 --- a/apps/taro/src/services/supabase.ts +++ b/apps/taro/src/services/supabase.ts @@ -1,4 +1,4 @@ -import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import { createClient, type AuthChangeEvent, type Session, type SupabaseClient } from '@supabase/supabase-js'; import { appEnv, ensureRuntimeConfigLoaded } from '@/env'; let client: SupabaseClient | null = null; @@ -31,3 +31,10 @@ export async function getSupabaseAccessToken() { const { data } = await supabase.auth.getSession(); return data.session?.access_token || null; } + +export async function subscribeSupabaseAuthChanges(listener: (event: AuthChangeEvent, session: Session | null) => void) { + const supabase = await ensureSupabaseClient(); + if (!supabase) return () => undefined; + const { data } = supabase.auth.onAuthStateChange((event, session) => listener(event, session)); + return () => data.subscription.unsubscribe(); +} diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index 6aa2db47..13796fd2 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -1196,8 +1196,8 @@ export async function loadTenantClasses(limit = 50) { return apiRequest<{ items?: TenantClassItem[]; scoped?: boolean }>('/api/tenant-admin/classes', { query: { limit } }); } -export async function loadTenantStudents(query: { keyword?: string; classId?: string; status?: string; limit?: number } = {}) { - return apiRequest<{ items?: TenantStudentItem[]; scoped?: boolean }>('/api/tenant-admin/students', { +export async function loadTenantStudents(query: { keyword?: string; classId?: string; status?: string; cursor?: string; limit?: number } = {}) { + return apiRequest<{ items?: TenantStudentItem[]; scoped?: boolean; hasMore?: boolean; nextCursor?: string | null }>('/api/tenant-admin/students', { query: { ...query, limit: query.limit || 50 }, }); } diff --git a/apps/taro/src/theme/ThemeProvider.tsx b/apps/taro/src/theme/ThemeProvider.tsx new file mode 100644 index 00000000..006b9287 --- /dev/null +++ b/apps/taro/src/theme/ThemeProvider.tsx @@ -0,0 +1,115 @@ +import { createContext, type CSSProperties, type PropsWithChildren, useContext, useEffect, useMemo } from 'react'; +import { isH5Runtime } from '@/env'; +import { useApp } from '@/app/AppProvider'; +import { resolveTheme, themeCssVariables, type ThemeAssets, type ThemeCustomCssVars, type ThemeTokens } from './tokens'; + +interface ThemeContextValue { + tokens: ThemeTokens; + assets: ThemeAssets; + customCssVars: ThemeCustomCssVars; + rootStyle: CSSProperties; +} + +const initialTheme = resolveTheme(null); +const ThemeContext = createContext({ + ...initialTheme, + rootStyle: themeCssVariables(initialTheme.tokens) as CSSProperties, +}); + +const managedAttribute = 'data-tiku-theme-managed'; +const createdAttribute = 'data-tiku-theme-created'; +const originalContentAttribute = 'data-tiku-theme-original-content'; +const originalHrefAttribute = 'data-tiku-theme-original-href'; +let managedCustomCssVars = new Set(); + +function updateManagedMeta(selector: string, attributes: Record, content?: string) { + let element = document.head.querySelector(selector); + if (!content) { + if (!element?.hasAttribute(managedAttribute)) return; + if (element.getAttribute(createdAttribute) === 'true') element.remove(); + else { + element.setAttribute('content', element.getAttribute(originalContentAttribute) || ''); + element.removeAttribute(managedAttribute); + element.removeAttribute(originalContentAttribute); + } + return; + } + if (!element) { + element = document.createElement('meta'); + Object.entries(attributes).forEach(([key, value]) => element?.setAttribute(key, value)); + element.setAttribute(createdAttribute, 'true'); + document.head.appendChild(element); + } else if (!element.hasAttribute(managedAttribute)) { + element.setAttribute(originalContentAttribute, element.getAttribute('content') || ''); + } + element.setAttribute(managedAttribute, 'true'); + element.setAttribute('content', content); +} + +function updateManagedFavicon(faviconUrl?: string) { + let favicon = document.head.querySelector('link[rel="icon"]'); + if (!faviconUrl) { + if (!favicon?.hasAttribute(managedAttribute)) return; + if (favicon.getAttribute(createdAttribute) === 'true') favicon.remove(); + else { + const originalHref = favicon.getAttribute(originalHrefAttribute) || ''; + if (originalHref) favicon.setAttribute('href', originalHref); + else favicon.removeAttribute('href'); + favicon.removeAttribute(managedAttribute); + favicon.removeAttribute(originalHrefAttribute); + } + return; + } + if (!favicon) { + favicon = document.createElement('link'); + favicon.rel = 'icon'; + favicon.setAttribute(createdAttribute, 'true'); + document.head.appendChild(favicon); + } else if (!favicon.hasAttribute(managedAttribute)) { + favicon.setAttribute(originalHrefAttribute, favicon.getAttribute('href') || ''); + } + favicon.setAttribute(managedAttribute, 'true'); + favicon.href = faviconUrl; +} + +function applyDocumentTheme(tokens: ThemeTokens, assets: ThemeAssets, customCssVars: ThemeCustomCssVars) { + const nextCustomCssVarKeys = new Set(Object.keys(customCssVars)); + managedCustomCssVars.forEach(key => { + if (!nextCustomCssVarKeys.has(key)) document.documentElement.style.removeProperty(key); + }); + const variables = themeCssVariables(tokens); + Object.entries(variables).forEach(([key, value]) => document.documentElement.style.setProperty(key, value)); + Object.entries(customCssVars).forEach(([key, value]) => document.documentElement.style.setProperty(key, value)); + managedCustomCssVars = nextCustomCssVarKeys; + updateManagedMeta('meta[name="theme-color"]', { name: 'theme-color' }, tokens.primary); + updateManagedMeta('meta[property="og:image"]', { property: 'og:image' }, assets.shareImageUrl); + updateManagedFavicon(assets.faviconUrl); +} + +export function ThemeProvider({ children }: PropsWithChildren) { + const { tenant } = useApp(); + const value = useMemo(() => { + const resolved = resolveTheme(tenant?.branding); + return { + ...resolved, + rootStyle: { + ...themeCssVariables(resolved.tokens), + ...resolved.customCssVars, + minHeight: '100%', + backgroundColor: resolved.tokens.page, + color: resolved.tokens.text, + } as CSSProperties, + }; + }, [tenant?.tenantId, tenant?.branding]); + + useEffect(() => { + if (!isH5Runtime() || typeof document === 'undefined') return; + applyDocumentTheme(value.tokens, value.assets, value.customCssVars); + }, [value.tokens, value.assets, value.customCssVars]); + + return {children}; +} + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/apps/taro/src/theme/tokens.ts b/apps/taro/src/theme/tokens.ts new file mode 100644 index 00000000..1c6443e7 --- /dev/null +++ b/apps/taro/src/theme/tokens.ts @@ -0,0 +1,139 @@ +import type { TenantBranding } from '@/types'; + +export interface ThemeTokens { + primary: string; + primaryStrong: string; + primarySoft: string; + accent: string; + page: string; + card: string; + cardSoft: string; + text: string; + muted: string; + border: string; + borderStrong: string; + danger: string; + success: string; + warning: string; + radius: string; + radiusSmall: string; +} + +export interface ThemeAssets { + logoUrl: string; + faviconUrl: string; + shareImageUrl: string; + iconSet: string; + shareCardStyle: string; +} + +export type ThemeCustomCssVars = Record; + +export const defaultThemeTokens: ThemeTokens = { + primary: '#1152d4', + primaryStrong: '#1d4ed8', + primarySoft: '#eef5ff', + accent: '#0f766e', + page: '#f4f6f9', + card: '#ffffff', + cardSoft: '#f8fafc', + text: '#111827', + muted: '#64748b', + border: '#e2e8f0', + borderStrong: '#cbd5e1', + danger: '#dc2626', + success: '#059669', + warning: '#d97706', + radius: '8px', + radiusSmall: '6px', +}; + +function record(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function safeColor(value: unknown, fallback: string) { + if (typeof value !== 'string') return fallback; + const color = value.trim(); + if (/^#[0-9a-f]{3,8}$/i.test(color)) return color; + if (/^(?:rgb|hsl)a?\([\d\s.,%+-]+\)$/i.test(color)) return color; + return fallback; +} + +function safeRadius(value: unknown, fallback: string) { + if (typeof value === 'number' && Number.isFinite(value)) return `${Math.max(0, Math.min(32, value))}px`; + if (typeof value !== 'string') return fallback; + const match = value.trim().match(/^(\d+(?:\.\d+)?)(px|rpx|rem)$/i); + if (!match) return fallback; + const amount = Math.max(0, Math.min(32, Number(match[1]))); + return `${amount}${match[2].toLowerCase()}`; +} + +function stringAsset(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function safeCustomCssVars(value: unknown): ThemeCustomCssVars { + const result: ThemeCustomCssVars = {}; + for (const [key, raw] of Object.entries(record(value))) { + if (!/^--tiku-[a-z0-9-]{1,48}$/i.test(key) || typeof raw !== 'string') continue; + const text = raw.trim(); + if (!text || text.length > 96 || /[{};]/.test(text)) continue; + if (/(; publicAssets?: Record; @@ -18,7 +19,9 @@ export interface TenantContext { } export interface ApiSession { - token: string; + token?: string; + id?: string; + source?: string; expiresAt?: string; } @@ -42,12 +45,25 @@ export interface ApiEnvelope { session?: ApiSession; code?: string; message?: string; - [key: string]: unknown; + ok?: boolean; + verified?: boolean; + purpose?: string; + phone?: string; + isNewUser?: boolean; + expireIn?: number; + cooldown?: number; + debugCode?: string; + meta?: ApiResponseMeta; +} + +export interface ApiResponseMeta { + requestId: string; } export interface ApiErrorPayload { status: number; code: string; message: string; + requestId?: string; details?: unknown; } diff --git a/apps/worker/package.json b/apps/worker/package.json index 0c390297..f64fd8fa 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx watch src/index.ts --loop", - "start": "node dist/apps/worker/src/index.js --loop", + "dev": "tsx watch src/index.ts --loop --job crm", + "start": "node dist/apps/worker/src/index.js --loop --job crm", "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json", "check": "tsc -p tsconfig.json --noEmit", "crm:once": "tsx src/index.ts --once --job crm", @@ -21,7 +21,8 @@ "assets:once": "tsx src/index.ts --once --job assets", "imports:once": "tsx src/index.ts --once --job imports", "public-banks:once": "tsx src/index.ts --once --job public-banks", - "exports:once": "tsx src/index.ts --once --job exports" + "exports:once": "tsx src/index.ts --once --job exports", + "student-supervision:once": "tsx src/index.ts --once --job student-supervision" }, "dependencies": { "@resvg/resvg-js": "^2.6.2", diff --git a/apps/worker/src/cli.ts b/apps/worker/src/cli.ts new file mode 100644 index 00000000..95633491 --- /dev/null +++ b/apps/worker/src/cli.ts @@ -0,0 +1,146 @@ +export const WORKER_JOBS = [ + 'crm', + 'commerce', + 'provider-bills', + 'platform-billing', + 'platform-usage', + 'platform-usage-overage', + 'platform-dunning', + 'platform-dunning-notifications', + 'platform-audit-alerts', + 'platform-audit-notifications', + 'assets', + 'imports', + 'public-banks', + 'exports', + 'student-supervision', +] as const; + +export type WorkerJob = typeof WORKER_JOBS[number]; + +export const CONTINUOUS_WORKER_JOBS = [ + 'crm', + 'commerce', + 'provider-bills', + 'platform-dunning-notifications', + 'platform-audit-notifications', + 'assets', + 'imports', + 'public-banks', + 'exports', +] as const satisfies readonly WorkerJob[]; + +export type ContinuousWorkerJob = typeof CONTINUOUS_WORKER_JOBS[number]; + +export const PERIODIC_WORKER_JOBS = [ + 'platform-billing', + 'platform-usage', + 'platform-usage-overage', + 'platform-dunning', + 'platform-audit-alerts', + 'student-supervision', +] as const satisfies readonly WorkerJob[]; + +export interface WorkerCliOptions { + job: WorkerJob; + loop: boolean; + month?: string; +} + +function optionValues(argv: string[], name: string) { + const values: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] !== name) continue; + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${name} requires a value`); + } + values.push(value); + index += 1; + } + return values; +} + +function optionTokenIndexes(argv: string[]) { + const indexes = new Set(); + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value.startsWith('--')) continue; + indexes.add(index); + if (value === '--job' || value === '--month') { + if (argv[index + 1]) indexes.add(index + 1); + index += 1; + } + } + return indexes; +} + +function isWorkerJob(value: string): value is WorkerJob { + return (WORKER_JOBS as readonly string[]).includes(value); +} + +export function isContinuousWorkerJob(value: WorkerJob): value is ContinuousWorkerJob { + return (CONTINUOUS_WORKER_JOBS as readonly string[]).includes(value); +} + +function previousShanghaiMonth(now: Date) { + const currentMonth = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + }).format(now); + const [year, month] = currentMonth.split('-').map(Number); + return new Date(Date.UTC(year, month - 2, 1)).toISOString().slice(0, 7); +} + +export function resolveWorkerMonth(value: string, now = new Date()) { + const normalized = value.trim().toLowerCase(); + if (normalized === 'previous') return previousShanghaiMonth(now); + if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(normalized)) { + throw new Error('--month must be previous or a valid YYYY-MM value'); + } + return normalized; +} + +export function parseWorkerCli(argv: string[]): WorkerCliOptions { + const loop = argv.includes('--loop'); + const once = argv.includes('--once'); + if (loop && once) throw new Error('Choose exactly one worker mode: --loop or --once'); + + const jobValues = optionValues(argv, '--job'); + if (jobValues.length !== 1) { + throw new Error('--job is required exactly once; the worker has no implicit default job'); + } + const [job] = jobValues; + if (!isWorkerJob(job)) { + throw new Error(`Unsupported worker job: ${job}. Expected one of: ${WORKER_JOBS.join(', ')}`); + } + if (loop && !isContinuousWorkerJob(job)) { + throw new Error(`Worker job ${job} is periodic and must be scheduled with --once`); + } + + const monthValues = optionValues(argv, '--month'); + if (monthValues.length > 1) throw new Error('--month may only be provided once'); + if (monthValues.length > 0 && !['platform-usage', 'platform-usage-overage'].includes(job)) { + throw new Error('--month is only supported by platform-usage and platform-usage-overage'); + } + if (loop && monthValues.length > 0) throw new Error('--month cannot be used with --loop'); + + const recognizedOptions = new Set(['--loop', '--once', '--job', '--month']); + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value.startsWith('--')) continue; + if (!recognizedOptions.has(value)) throw new Error(`Unknown worker option: ${value}`); + if (value === '--job' || value === '--month') index += 1; + } + const consumedIndexes = optionTokenIndexes(argv); + for (let index = 0; index < argv.length; index += 1) { + if (!consumedIndexes.has(index)) throw new Error(`Unexpected worker argument: ${argv[index]}`); + } + + return { + job, + loop, + month: monthValues[0] ? resolveWorkerMonth(monthValues[0]) : undefined, + }; +} diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts index 57dba627..de94db71 100644 --- a/apps/worker/src/config.ts +++ b/apps/worker/src/config.ts @@ -13,9 +13,11 @@ export interface WorkerConfig { crmRequestTimeoutMs: number; crmAllowInsecureLocalhost: boolean; commerceBatchSize: number; + commercePollIntervalMs: number; commerceMinAgeSeconds: number; commerceRequestTimeoutMs: number; providerBillBatchSize: number; + providerBillPollIntervalMs: number; providerBillWorkerId: string; providerBillClaimStaleSeconds: number; platformBillingBatchSize: number; @@ -32,6 +34,7 @@ export interface WorkerConfig { platformDunningBatchSize: number; platformDunningWorkerId: string; platformDunningNotificationBatchSize: number; + platformDunningNotificationPollIntervalMs: number; platformDunningNotificationMaxAttempts: number; platformDunningNotificationBackoffSeconds: number[]; platformDunningNotificationRequestTimeoutMs: number; @@ -40,11 +43,13 @@ export interface WorkerConfig { platformAuditAlertWorkerId: string; platformAuditAlertLookbackDays: number; platformAuditNotificationBatchSize: number; + platformAuditNotificationPollIntervalMs: number; platformAuditNotificationMaxAttempts: number; platformAuditNotificationBackoffSeconds: number[]; platformAuditNotificationRequestTimeoutMs: number; platformAuditNotificationAllowInsecureLocalhost: boolean; assetBatchSize: number; + assetPollIntervalMs: number; assetMinAgeSeconds: number; assetRecheckIntervalSeconds: number; assetRequestTimeoutMs: number; @@ -54,13 +59,18 @@ export interface WorkerConfig { assetSecurityScanHttpTimeoutMs: number; assetSecurityScanFailOpen: boolean; importBatchSize: number; + importPollIntervalMs: number; importWorkerId: string; + importLeaseSeconds: number; + importHeartbeatIntervalMs: number; importBackoffSeconds: number[]; publicBankSyncBatchSize: number; + publicBankSyncPollIntervalMs: number; publicBankSyncCopyLimit: number; publicBankSyncWorkerId: string; publicBankSyncClaimStaleSeconds: number; exportBatchSize: number; + exportPollIntervalMs: number; exportWorkerId: string; exportBackoffSeconds: number[]; studentSupervisionBatchSize: number; @@ -161,6 +171,32 @@ function validateProductionConfig(nextConfig: WorkerConfig) { if (nextConfig.platformDunningNotificationAllowInsecureLocalhost) { failures.push('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true is not allowed in production workers'); } + const pollIntervals = [ + ['WORKER_CRM_POLL_INTERVAL_MS', nextConfig.crmPollIntervalMs], + ['WORKER_COMMERCE_POLL_INTERVAL_MS', nextConfig.commercePollIntervalMs], + ['WORKER_PROVIDER_BILL_POLL_INTERVAL_MS', nextConfig.providerBillPollIntervalMs], + ['WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS', nextConfig.platformDunningNotificationPollIntervalMs], + ['WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS', nextConfig.platformAuditNotificationPollIntervalMs], + ['WORKER_ASSET_POLL_INTERVAL_MS', nextConfig.assetPollIntervalMs], + ['WORKER_IMPORT_POLL_INTERVAL_MS', nextConfig.importPollIntervalMs], + ['WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS', nextConfig.publicBankSyncPollIntervalMs], + ['WORKER_EXPORT_POLL_INTERVAL_MS', nextConfig.exportPollIntervalMs], + ] as const; + for (const [name, value] of pollIntervals) { + if (!Number.isFinite(value) || value < 1_000 || value > 3_600_000) { + failures.push(`${name} must be between 1000 and 3600000`); + } + } + if (!Number.isFinite(nextConfig.importLeaseSeconds) || nextConfig.importLeaseSeconds < 10 || nextConfig.importLeaseSeconds > 86_400) { + failures.push('WORKER_IMPORT_LEASE_SECONDS must be between 10 and 86400'); + } + if ( + !Number.isFinite(nextConfig.importHeartbeatIntervalMs) + || nextConfig.importHeartbeatIntervalMs < 1_000 + || nextConfig.importHeartbeatIntervalMs >= nextConfig.importLeaseSeconds * 500 + ) { + failures.push('WORKER_IMPORT_HEARTBEAT_INTERVAL_MS must be at least 1000 and less than half the lease duration'); + } const scannerModes = nextConfig.assetSecurityScanner .split(',') .map(item => item.trim().toLowerCase()) @@ -229,9 +265,11 @@ const loadedConfig: WorkerConfig = { crmRequestTimeoutMs: envNumber('WORKER_CRM_REQUEST_TIMEOUT_MS', 10_000), crmAllowInsecureLocalhost: envBoolean('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false), commerceBatchSize: envNumber('WORKER_COMMERCE_BATCH_SIZE', 20), + commercePollIntervalMs: envNumber('WORKER_COMMERCE_POLL_INTERVAL_MS', 30_000), commerceMinAgeSeconds: envNumber('WORKER_COMMERCE_MIN_AGE_SECONDS', 300), commerceRequestTimeoutMs: envNumber('WORKER_COMMERCE_REQUEST_TIMEOUT_MS', 10_000), providerBillBatchSize: envNumber('WORKER_PROVIDER_BILL_BATCH_SIZE', 5), + providerBillPollIntervalMs: envNumber('WORKER_PROVIDER_BILL_POLL_INTERVAL_MS', 60_000), providerBillWorkerId: envString('WORKER_PROVIDER_BILL_ID', `provider-bills-${process.pid}`), providerBillClaimStaleSeconds: envNumber('WORKER_PROVIDER_BILL_CLAIM_STALE_SECONDS', 15 * 60), platformBillingBatchSize: envNumber('WORKER_PLATFORM_BILLING_BATCH_SIZE', 50), @@ -248,6 +286,7 @@ const loadedConfig: WorkerConfig = { platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100), platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`), platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50), + platformDunningNotificationPollIntervalMs: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS', 30_000), platformDunningNotificationMaxAttempts: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS', 5), platformDunningNotificationBackoffSeconds: envList('WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS', '10,60,300,900,1800') .map((value: string) => Number(value)) @@ -258,6 +297,7 @@ const loadedConfig: WorkerConfig = { platformAuditAlertWorkerId: envString('WORKER_PLATFORM_AUDIT_ALERT_ID', `platform-audit-alerts-${process.pid}`), platformAuditAlertLookbackDays: envNumber('WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS', 14), platformAuditNotificationBatchSize: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE', 50), + platformAuditNotificationPollIntervalMs: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS', 30_000), platformAuditNotificationMaxAttempts: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS', 5), platformAuditNotificationBackoffSeconds: envList('WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS', '10,60,300,900,1800') .map((value: string) => Number(value)) @@ -265,6 +305,7 @@ const loadedConfig: WorkerConfig = { platformAuditNotificationRequestTimeoutMs: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS', 10_000), platformAuditNotificationAllowInsecureLocalhost: envBoolean('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false), assetBatchSize: envNumber('WORKER_ASSET_BATCH_SIZE', 50), + assetPollIntervalMs: envNumber('WORKER_ASSET_POLL_INTERVAL_MS', 30_000), assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300), assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24), assetRequestTimeoutMs: envNumber('WORKER_ASSET_REQUEST_TIMEOUT_MS', 10_000), @@ -274,15 +315,20 @@ const loadedConfig: WorkerConfig = { assetSecurityScanHttpTimeoutMs: envNumber('WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS', 10_000), assetSecurityScanFailOpen: envBoolean('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN', false), importBatchSize: envNumber('WORKER_IMPORT_BATCH_SIZE', 5), + importPollIntervalMs: envNumber('WORKER_IMPORT_POLL_INTERVAL_MS', 10_000), importWorkerId: envString('WORKER_IMPORT_ID', `imports-${process.pid}`), + importLeaseSeconds: envNumber('WORKER_IMPORT_LEASE_SECONDS', 120), + importHeartbeatIntervalMs: envNumber('WORKER_IMPORT_HEARTBEAT_INTERVAL_MS', 30_000), importBackoffSeconds: envList('WORKER_IMPORT_BACKOFF_SECONDS', '30,120,600,1800') .map((value: string) => Number(value)) .filter((value: number) => Number.isFinite(value) && value > 0), publicBankSyncBatchSize: envNumber('WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE', 5), + publicBankSyncPollIntervalMs: envNumber('WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS', 60_000), publicBankSyncCopyLimit: envNumber('WORKER_PUBLIC_BANK_SYNC_COPY_LIMIT', 1000), publicBankSyncWorkerId: envString('WORKER_PUBLIC_BANK_SYNC_ID', `public-banks-${process.pid}`), publicBankSyncClaimStaleSeconds: envNumber('WORKER_PUBLIC_BANK_SYNC_CLAIM_STALE_SECONDS', 15 * 60), exportBatchSize: envNumber('WORKER_EXPORT_BATCH_SIZE', 5), + exportPollIntervalMs: envNumber('WORKER_EXPORT_POLL_INTERVAL_MS', 10_000), exportWorkerId: envString('WORKER_EXPORT_ID', `exports-${process.pid}`), exportBackoffSeconds: envList('WORKER_EXPORT_BACKOFF_SECONDS', '30,120,600,1800') .map((value: string) => Number(value)) diff --git a/apps/worker/src/db.ts b/apps/worker/src/db.ts index 882bdb59..0247d422 100644 --- a/apps/worker/src/db.ts +++ b/apps/worker/src/db.ts @@ -3,7 +3,7 @@ import { config } from './config.js'; export const pool = createPool({ connectionString: config.databaseUrl, - max: 5, + applicationName: 'tiku-worker', }); export async function closePool() { diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 23a1ca3d..6ff7db8f 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -3,20 +3,15 @@ import { config } from './config.js'; import { processCrmBatch } from './jobs/crm.js'; import { processCommerceBatch } from './jobs/commerce.js'; import { processAssetBatch } from './jobs/assets.js'; +import { + parseWorkerCli, + type ContinuousWorkerJob, + type WorkerJob, +} from './cli.js'; const extraClosers = new Set<() => Promise>(); -function hasArg(name: string) { - return process.argv.includes(name); -} - -function argValue(name: string, fallback = '') { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] || fallback : fallback; -} - -async function runOnce() { - const job = argValue('--job', 'crm'); +async function runOnce(job: WorkerJob, month?: string) { if (job === 'crm') { const result = await processCrmBatch(); console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`); @@ -53,7 +48,7 @@ async function runOnce() { } if (job === 'platform-usage') { const { processPlatformUsageBatch } = await import('./jobs/platform-usage.js'); - const result = await processPlatformUsageBatch(); + const result = await processPlatformUsageBatch({ month }); console.log( `[worker] platform-usage batch processed=${result.processed}` + ` metrics=${result.metrics} created=${result.created}` @@ -63,7 +58,7 @@ async function runOnce() { } if (job === 'platform-usage-overage') { const { processPlatformUsageOverageBatch } = await import('./jobs/platform-usage-overage.js'); - const result = await processPlatformUsageOverageBatch(); + const result = await processPlatformUsageOverageBatch({ month }); console.log( `[worker] platform-usage-overage batch processed=${result.processed}` + ` created=${result.created} skipped=${result.skipped}` @@ -125,7 +120,8 @@ async function runOnce() { console.log( `[worker] imports batch processed=${result.processed}` + ` completed=${result.completed} completedWithErrors=${result.completedWithErrors}` - + ` failed=${result.failed} retrying=${result.retrying} skipped=${result.skipped}`, + + ` failed=${result.failed} retrying=${result.retrying}` + + ` leaseLost=${result.leaseLost} skipped=${result.skipped}`, ); return; } @@ -166,8 +162,21 @@ async function runOnce() { throw new Error(`Unsupported worker job: ${job}`); } -async function runLoop() { - console.log('[worker] started'); +function loopPollIntervalMs(job: ContinuousWorkerJob) { + if (job === 'crm') return config.crmPollIntervalMs; + if (job === 'commerce') return config.commercePollIntervalMs; + if (job === 'provider-bills') return config.providerBillPollIntervalMs; + if (job === 'platform-dunning-notifications') return config.platformDunningNotificationPollIntervalMs; + if (job === 'platform-audit-notifications') return config.platformAuditNotificationPollIntervalMs; + if (job === 'assets') return config.assetPollIntervalMs; + if (job === 'imports') return config.importPollIntervalMs; + if (job === 'public-banks') return config.publicBankSyncPollIntervalMs; + return config.exportPollIntervalMs; +} + +async function runLoop(job: ContinuousWorkerJob) { + const pollIntervalMs = loopPollIntervalMs(job); + console.log(`[worker] started job=${job} pollIntervalMs=${pollIntervalMs}`); let stopped = false; const stop = () => { stopped = true; @@ -177,19 +186,21 @@ async function runLoop() { while (!stopped) { try { - await runOnce(); + await runOnce(job); } catch (error) { - console.error('[worker] job failed', error); + console.error(`[worker] job=${job} failed`, error); } - await new Promise(resolve => setTimeout(resolve, config.crmPollIntervalMs)); + if (!stopped) await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); } } +const cli = parseWorkerCli(process.argv.slice(2)); + try { - if (hasArg('--loop')) { - await runLoop(); + if (cli.loop) { + await runLoop(cli.job as ContinuousWorkerJob); } else { - await runOnce(); + await runOnce(cli.job, cli.month); } } finally { for (const closeExtra of extraClosers) { diff --git a/apps/worker/src/jobs/imports.ts b/apps/worker/src/jobs/imports.ts index 8001da15..60a285f4 100644 --- a/apps/worker/src/jobs/imports.ts +++ b/apps/worker/src/jobs/imports.ts @@ -3,7 +3,7 @@ import { config } from '../config.js'; import { executeContentImportJob, type ExecutableContentImportType } from '../../../api/src/features/tenant-content/imports.js'; import { closePool as closeApiImportPool } from '../../../api/src/core/db.js'; -interface ImportJobRow { +export interface ImportJobRow { id: string; tenantId: string; createdBy: string | null; @@ -12,6 +12,8 @@ interface ImportJobRow { attemptCount: number; maxAttempts: number; summary: Record; + leaseToken: string; + leaseExpiresAt: Date; } interface ImportWorkerResult { @@ -20,9 +22,22 @@ interface ImportWorkerResult { completedWithErrors: number; failed: number; retrying: number; + leaseLost: number; skipped: number; } +interface ImportLeaseOptions { + workerId?: string; + batchSize?: number; + leaseSeconds?: number; + heartbeatIntervalMs?: number; +} + +interface ImportLeaseHeartbeat { + stop: () => Promise; + ownershipLost: () => boolean; +} + function objectValue(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } @@ -31,11 +46,6 @@ function boolValue(value: unknown, fallback: boolean) { return typeof value === 'boolean' ? value : fallback; } -function numberValue(value: unknown, fallback: number) { - const parsed = Number(value ?? fallback); - return Number.isFinite(parsed) ? parsed : fallback; -} - function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } @@ -62,155 +72,341 @@ function importOptions(summary: Record) { }; } -async function claimImportJobs() { +function leaseSettings(options: ImportLeaseOptions = {}) { + return { + workerId: options.workerId || config.importWorkerId, + batchSize: options.batchSize ?? config.importBatchSize, + leaseSeconds: options.leaseSeconds ?? config.importLeaseSeconds, + heartbeatIntervalMs: options.heartbeatIntervalMs ?? config.importHeartbeatIntervalMs, + }; +} + +export async function claimImportJobs(options: ImportLeaseOptions = {}) { + const settings = leaseSettings(options); const client = await pool.connect(); try { await client.query('begin'); - const result = await client.query( + + const exhausted = await client.query<{ + id: string; + tenantId: string; + createdBy: string | null; + importType: ExecutableContentImportType; + attemptCount: number; + maxAttempts: number; + lockedBy: string | null; + }>( ` - select id, - tenant_id as "tenantId", - created_by as "createdBy", - import_type as "importType", - status, - attempt_count as "attemptCount", - max_attempts as "maxAttempts", - summary - from public.content_import_jobs + update public.content_import_jobs + set status = 'failed', + error_message = 'Import worker lease expired after the final attempt', + summary = coalesce(summary, '{}'::jsonb) || jsonb_build_object( + 'lastWorkerError', jsonb_build_object( + 'code', 'IMPORT_WORKER_LEASE_EXPIRED', + 'message', 'Import worker lease expired after the final attempt', + 'workerId', locked_by, + 'failedAt', now(), + 'attemptCount', attempt_count, + 'maxAttempts', max_attempts, + 'willRetry', false + ) + ), + next_attempt_at = null, + locked_at = null, + locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, + finished_at = now(), + updated_at = now() where execution_mode = 'async' - and status = 'pending' - and attempt_count < max_attempts - and (next_attempt_at is null or next_attempt_at <= now()) - order by created_at asc - limit $1 - for update skip locked + and status = 'importing' + and lease_expires_at <= now() + and attempt_count >= max_attempts + returning id, + tenant_id as "tenantId", + created_by as "createdBy", + import_type as "importType", + attempt_count as "attemptCount", + max_attempts as "maxAttempts", + summary #>> '{lastWorkerError,workerId}' as "lockedBy" `, - [config.importBatchSize], ); - const ids = result.rows.map(row => row.id); - if (ids.length > 0) { + for (const job of exhausted.rows) { await client.query( ` - update public.content_import_jobs - set locked_at = now(), - locked_by = $2, - attempt_count = attempt_count + 1, - updated_at = now() - where id = any($1::uuid[]) + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, $3, 'content_import_job', $4, $5::jsonb) `, - [ids, config.importWorkerId], + [ + job.tenantId, + job.createdBy, + `content.import.${job.importType}.failed`, + job.id, + JSON.stringify({ + code: 'IMPORT_WORKER_LEASE_EXPIRED', + workerId: job.lockedBy, + attemptCount: job.attemptCount, + maxAttempts: job.maxAttempts, + }), + ], ); } + + const result = await client.query( + ` + with candidates as ( + select id + from public.content_import_jobs + where execution_mode = 'async' + and attempt_count < max_attempts + and ( + ( + status = 'pending' + and (next_attempt_at is null or next_attempt_at <= now()) + ) + or ( + status = 'importing' + and lease_expires_at <= now() + ) + ) + order by + case when status = 'importing' then 0 else 1 end, + coalesce(lease_expires_at, next_attempt_at, created_at) asc, + created_at asc, + id asc + limit $1 + for update skip locked + ) + update public.content_import_jobs job + set status = 'importing', + dry_run = false, + locked_at = now(), + locked_by = $2, + lease_token = gen_random_uuid(), + lease_expires_at = now() + make_interval(secs => $3::integer), + last_heartbeat_at = now(), + attempt_count = job.attempt_count + 1, + next_attempt_at = null, + error_message = null, + started_at = coalesce(job.started_at, now()), + finished_at = null, + updated_at = now() + from candidates + where job.id = candidates.id + returning job.id, + job.tenant_id as "tenantId", + job.created_by as "createdBy", + job.import_type as "importType", + job.status, + job.attempt_count as "attemptCount", + job.max_attempts as "maxAttempts", + job.summary, + job.lease_token as "leaseToken", + job.lease_expires_at as "leaseExpiresAt" + `, + [settings.batchSize, settings.workerId, settings.leaseSeconds], + ); + await client.query('commit'); return result.rows; } catch (error) { - await client.query('rollback'); + await client.query('rollback').catch(() => undefined); throw error; } finally { client.release(); } } -async function markImportFailed(job: ImportJobRow, error: unknown) { - const nextAttempt = job.attemptCount + 1; - const willRetry = nextAttempt < job.maxAttempts; +export function startImportLeaseHeartbeat( + job: Pick, + options: ImportLeaseOptions = {}, +): ImportLeaseHeartbeat { + const settings = leaseSettings(options); + let stopped = false; + let lost = false; + let inFlight: Promise | null = null; + + const heartbeat = async () => { + try { + const result = await pool.query( + ` + update public.content_import_jobs + set lease_expires_at = now() + make_interval(secs => $4::integer), + last_heartbeat_at = now(), + updated_at = now() + where tenant_id = $1 + and id = $2 + and status = 'importing' + and lease_token = $3::uuid + and lease_expires_at > now() + returning id + `, + [job.tenantId, job.id, job.leaseToken, settings.leaseSeconds], + ); + if (result.rowCount !== 1) lost = true; + } catch (error) { + console.error(`[worker] import lease heartbeat failed jobId=${job.id}`, error); + } + }; + + const timer = setInterval(() => { + if (stopped || inFlight) return; + inFlight = heartbeat().finally(() => { + inFlight = null; + }); + }, settings.heartbeatIntervalMs); + timer.unref(); + + return { + ownershipLost: () => lost, + stop: async () => { + stopped = true; + clearInterval(timer); + if (inFlight) await inFlight; + }, + }; +} + +export async function markImportFailed(job: ImportJobRow, error: unknown) { + const willRetry = job.attemptCount < job.maxAttempts; const status = willRetry ? 'pending' : 'failed'; - await pool.query( - ` - update public.content_import_jobs - set status = $3, - error_message = $4, - summary = coalesce(summary, '{}'::jsonb) || $5::jsonb, - next_attempt_at = case when $6::boolean then now() + make_interval(secs => $7::integer) else null end, - locked_at = null, - locked_by = null, - finished_at = case when $3 = 'failed' then now() else finished_at end, - updated_at = now() - where tenant_id = $1 and id = $2 - `, - [ - job.tenantId, - job.id, - status, - truncate(errorMessage(error)), - JSON.stringify({ - lastWorkerError: { + const client = await pool.connect(); + try { + await client.query('begin'); + const updated = await client.query( + ` + update public.content_import_jobs + set status = $4, + error_message = $5, + summary = coalesce(summary, '{}'::jsonb) || $6::jsonb, + next_attempt_at = case when $7::boolean then now() + make_interval(secs => $8::integer) else null end, + locked_at = null, + locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, + finished_at = case when $4 = 'failed' then now() else null end, + updated_at = now() + where tenant_id = $1 + and id = $2 + and status = 'importing' + and lease_token = $3::uuid + and lease_expires_at > now() + returning id + `, + [ + job.tenantId, + job.id, + job.leaseToken, + status, + truncate(errorMessage(error)), + JSON.stringify({ + lastWorkerError: { + code: errorCode(error), + message: truncate(errorMessage(error)), + workerId: config.importWorkerId, + failedAt: new Date().toISOString(), + attemptCount: job.attemptCount, + maxAttempts: job.maxAttempts, + willRetry, + }, + }), + willRetry, + backoffSeconds(job.attemptCount), + ], + ); + + if (updated.rowCount !== 1) { + await client.query('rollback'); + return 'lease_lost' as const; + } + + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, $3, 'content_import_job', $4, $5::jsonb) + `, + [ + job.tenantId, + job.createdBy, + willRetry ? `content.import.${job.importType}.retry_scheduled` : `content.import.${job.importType}.failed`, + job.id, + JSON.stringify({ code: errorCode(error), message: truncate(errorMessage(error)), workerId: config.importWorkerId, - failedAt: new Date().toISOString(), - nextAttempt, + attemptCount: job.attemptCount, maxAttempts: job.maxAttempts, - willRetry, - }, - }), - willRetry, - backoffSeconds(nextAttempt), - ], - ); - - await pool.query( - ` - insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) - values ($1, $2, $3, 'content_import_job', $4, $5::jsonb) - `, - [ - job.tenantId, - job.createdBy, - willRetry ? `content.import.${job.importType}.retry_scheduled` : `content.import.${job.importType}.failed`, - job.id, - JSON.stringify({ - code: errorCode(error), - message: truncate(errorMessage(error)), - workerId: config.importWorkerId, - nextAttempt, - maxAttempts: job.maxAttempts, - }), - ], - ); - - return willRetry ? 'retrying' : 'failed'; + }), + ], + ); + await client.query('commit'); + return willRetry ? 'retrying' as const : 'failed' as const; + } catch (failure) { + await client.query('rollback').catch(() => undefined); + throw failure; + } finally { + client.release(); + } } export async function processImportBatch(): Promise { const jobs = await claimImportJobs(); + const heartbeats = new Map( + jobs.map(job => [job.id, startImportLeaseHeartbeat(job)]), + ); const result: ImportWorkerResult = { processed: jobs.length, completed: 0, completedWithErrors: 0, failed: 0, retrying: 0, + leaseLost: 0, skipped: 0, }; - for (const job of jobs) { - try { - const execution = await executeContentImportJob( - { - tenantId: job.tenantId, - userId: job.createdBy || job.tenantId, - role: 'system_worker', - permissions: { 'content:*': true }, - templatePermissions: {}, - }, - { - jobId: job.id, - importType: job.importType, - allowPartial: importOptions(job.summary).allowPartial, - allowQueuedJob: true, - }, - ); + try { + for (const job of jobs) { + const heartbeat = heartbeats.get(job.id); + try { + const execution = await executeContentImportJob( + { + tenantId: job.tenantId, + userId: job.createdBy || job.tenantId, + role: 'system_worker', + permissions: { 'content:*': true }, + templatePermissions: {}, + }, + { + jobId: job.id, + importType: job.importType, + allowPartial: importOptions(job.summary).allowPartial, + allowQueuedJob: true, + leaseToken: job.leaseToken, + }, + ); - if (execution.idempotent) result.skipped += 1; - else if (execution.status === 'completed_with_errors') result.completedWithErrors += 1; - else if (execution.status === 'completed') result.completed += 1; - else result.skipped += 1; - } catch (error) { - const state = await markImportFailed(job, error); - if (state === 'retrying') result.retrying += 1; - else result.failed += 1; + if (execution.idempotent) result.skipped += 1; + else if (execution.status === 'completed_with_errors') result.completedWithErrors += 1; + else if (execution.status === 'completed') result.completed += 1; + else result.skipped += 1; + } catch (error) { + const state = await markImportFailed(job, error); + if (state === 'retrying') result.retrying += 1; + else if (state === 'failed') result.failed += 1; + else { + result.leaseLost += 1; + result.skipped += 1; + } + } finally { + await heartbeat?.stop(); + heartbeats.delete(job.id); + } } + } finally { + await Promise.all([...heartbeats.values()].map(heartbeat => heartbeat.stop())); } return result; diff --git a/deploy.env.example b/deploy.env.example new file mode 100644 index 00000000..37baa288 --- /dev/null +++ b/deploy.env.example @@ -0,0 +1,93 @@ +# Copy this file to /opt/tiku-saas/shared/deploy.env on the server. +# Do not commit the real deploy.env. + +APP_NAME=tiku-supabase +REPO_URL=https://git.gongxue100.com/chenhaogxjy/tiku-supabase.git +BRANCH=main +DEPLOY_ROOT=/opt/tiku-saas +KEEP_RELEASES=5 + +# Private Gitea repository access. +# Prefer a short-lived token with read-only repository scope. +# Do not put the token in REPO_URL. +# GIT_USERNAME=chenhaogxjy +# GITEA_TOKEN=replace-with-rotated-readonly-token + +# Install/build/check gates. +NPM_INSTALL_COMMAND="npm ci --workspaces --include-workspace-root --include=dev" +# Audit metadata must come from an npm registry that implements the audit API. +# Keep this on the official registry even if package downloads use a mirror. +NPM_AUDIT_REGISTRY=https://registry.npmjs.org/ +RUN_CHECKS=true +CHECK_COMMANDS="npm run check:api +npm run check:worker +npm run check:taro" +RUN_API_BUILD=true +RUN_WORKER_BUILD=true +RUN_TARO_H5_BUILD=true +RUN_SECURITY_REPO_SCAN=true +RUN_RUNTIME_AUDIT=true +# This audit covers dependencies that ship in the Taro H5/miniapp bundle. +RUN_TARO_SUPPLY_CHAIN_AUDIT=true + +# Production gates. +# auto runs readiness:production when NODE_ENV=production. +NODE_ENV=production +RUN_PRODUCTION_READINESS=auto +RUN_DB_READINESS=true + +# Database migrations are intentionally opt-in. Enable only after backup and rehearsal. +# When enabled, the deploy script runs readiness:production:db again after db push +# and before launch:gate; production refuses to disable that post-migration check. +RUN_DB_MIGRATIONS=false +# Do not assign this in the file; inject DATABASE_MIGRATION_URL for the standard +# migration role from a secret manager immediately before deployment. +DB_MIGRATION_COMMAND='supabase db push --db-url "$DATABASE_MIGRATION_URL"' + +# H5 runtime configs stay in shared/ and are symlinked into each release dist. +# Production deployment blocks until these files exist and pass strict validation. +STRICT_H5_RUNTIME_CONFIG=true +RUN_H5_SMOKE=true +H5_STUDENT_RUNTIME_CONFIG=/opt/tiku-saas/shared/h5-student.runtime-config.json +H5_TENANT_RUNTIME_CONFIG=/opt/tiku-saas/shared/h5-tenant-admin.runtime-config.json +H5_PLATFORM_RUNTIME_CONFIG=/opt/tiku-saas/shared/h5-platform-admin.runtime-config.json + +# Nginx serves /srv/tiku-saas/www/{student,tenant-admin,platform-admin}. +# The deployer stages versioned Web roots beside this path and atomically switches +# /srv/tiku-saas/www after all candidate checks pass. +WWW_ROOT=/srv/tiku-saas/www +WWW_RELEASES_DIR=/srv/tiku-saas/www-releases + +# The bundled systemd units run from /opt/tiku-saas/repo. The deployer explicitly +# synchronizes the selected current release into this runtime directory before restart. +SERVICE_REPO_DIR=/opt/tiku-saas/repo +SYNC_SERVICE_REPO=true + +# Real production evidence is generated out-of-band and stays in shared/, never in Git. +RUN_LAUNCH_GATE=true +PRODUCTION_LAUNCH_EVIDENCE=/opt/tiku-saas/shared/production-launch-evidence.json + +# Production defaults are fail-closed: a real restart strategy and healthcheck are required. +SERVICE_MODE=systemd +SYSTEMD_UNITS="tiku-api.service tiku-workers.target" + +# systemd example: +# SERVICE_MODE=systemd +# SYSTEMD_UNITS="tiku-api.service tiku-workers.target" + +# pm2 example: +# SERVICE_MODE=pm2 +# PM2_ECOSYSTEM=/opt/tiku-saas/current/ecosystem.config.cjs +# PM2_PROCESS_NAMES="tiku-api tiku-worker" + +# docker compose example: +# SERVICE_MODE=compose +# COMPOSE_FILE=/opt/tiku-saas/current/docker-compose.api.yml + +# Fully custom restart hook. Runs after current symlink switches. +# RESTART_COMMAND='systemctl restart tiku-api.service tiku-workers.target' + +# Production refuses to report success without this healthcheck. +HEALTHCHECK_URL=http://127.0.0.1:8787/health +HEALTHCHECK_TIMEOUT_SECONDS=60 +HEALTHCHECK_INTERVAL_SECONDS=2 diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 00000000..4bbee8f0 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,686 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Safe release-style deploy script for tiku-supabase. +# +# Server layout: +# DEPLOY_ROOT/ +# current -> releases/- +# releases/ +# shared/ +# deploy.env # deploy settings, not committed +# .env # API/worker production env, not committed +# h5-student.runtime-config.json +# h5-tenant-admin.runtime-config.json +# h5-platform-admin.runtime-config.json +# +# Recommended first server run: +# mkdir -p /opt/tiku-saas/shared +# cp deploy.env.example /opt/tiku-saas/shared/deploy.env +# vim /opt/tiku-saas/shared/deploy.env +# vim /opt/tiku-saas/shared/.env +# bash deploy.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +log() { + printf '[deploy] %s\n' "$*" +} + +warn() { + printf '[deploy][warn] %s\n' "$*" >&2 +} + +die() { + printf '[deploy][error] %s\n' "$*" >&2 + exit 1 +} + +truthy() { + case "${1:-}" in + 1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +run() { + log "+ $*" + "$@" +} + +run_shell() { + log "+ $*" + bash -lc "$*" +} + +source_if_exists() { + local file="$1" + if [[ -f "$file" ]]; then + # shellcheck source=/dev/null + source "$file" + fi +} + +export_dotenv_if_exists() { + local file="$1" + local line key value + + [[ -f "$file" ]] || return 0 + + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + line="${line#export }" + [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue + + key="${line%%=*}" + value="${line#*=}" + if [[ "$value" == \"*\" && "$value" == *\" ]]; then + value="${value:1:${#value}-2}" + elif [[ "$value" == \'*\' && "$value" == *\' ]]; then + value="${value:1:${#value}-2}" + fi + + if [[ -z "${!key+x}" ]]; then + export "$key=$value" + fi + done < "$file" +} + +if [[ -n "${DEPLOY_CONFIG:-}" ]]; then + source_if_exists "$DEPLOY_CONFIG" +else + source_if_exists "$SCRIPT_DIR/deploy.env" + source_if_exists "$SCRIPT_DIR/.deploy.env" +fi + +: "${APP_NAME:=tiku-supabase}" +: "${REPO_URL:=https://git.gongxue100.com/chenhaogxjy/tiku-supabase.git}" +: "${BRANCH:=main}" +: "${DEPLOY_ROOT:=/opt/tiku-saas}" + +source_if_exists "$DEPLOY_ROOT/shared/deploy.env" + +: "${RELEASES_DIR:=$DEPLOY_ROOT/releases}" +: "${SHARED_DIR:=$DEPLOY_ROOT/shared}" +: "${CURRENT_LINK:=$DEPLOY_ROOT/current}" +: "${KEEP_RELEASES:=5}" +: "${GIT_DEPTH:=1}" +: "${NODE_ENV:=production}" +: "${NPM_INSTALL_COMMAND:=npm ci --workspaces --include-workspace-root --include=dev}" +: "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}" +: "${RUN_CHECKS:=true}" +: "${CHECK_COMMANDS:=npm run check:api +npm run check:worker +npm run check:taro}" +: "${RUN_API_BUILD:=true}" +: "${RUN_WORKER_BUILD:=true}" +: "${RUN_TARO_H5_BUILD:=true}" +: "${RUN_SECURITY_REPO_SCAN:=true}" +: "${RUN_RUNTIME_AUDIT:=true}" +: "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}" +: "${RUN_PRODUCTION_READINESS:=auto}" +: "${RUN_DB_READINESS:=true}" +: "${RUN_DB_MIGRATIONS:=false}" +: "${DATABASE_MIGRATION_URL:=}" +: "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}" +: "${STRICT_H5_RUNTIME_CONFIG:=true}" +: "${RUN_H5_SMOKE:=true}" +: "${RUN_LAUNCH_GATE:=true}" +: "${PRODUCTION_LAUNCH_EVIDENCE:=$SHARED_DIR/production-launch-evidence.json}" +: "${H5_STUDENT_RUNTIME_CONFIG:=$SHARED_DIR/h5-student.runtime-config.json}" +: "${H5_TENANT_RUNTIME_CONFIG:=$SHARED_DIR/h5-tenant-admin.runtime-config.json}" +: "${H5_PLATFORM_RUNTIME_CONFIG:=$SHARED_DIR/h5-platform-admin.runtime-config.json}" +: "${WWW_ROOT:=/srv/tiku-saas/www}" +: "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}" +: "${WWW_CURRENT_LINK:=$WWW_ROOT}" +: "${SERVICE_REPO_DIR:=/opt/tiku-saas/repo}" +: "${SYNC_SERVICE_REPO:=true}" +: "${SERVICE_MODE:=systemd}" +: "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}" +: "${PM2_ECOSYSTEM:=}" +: "${PM2_PROCESS_NAMES:=}" +: "${COMPOSE_FILE:=}" +: "${RESTART_COMMAND:=}" +: "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}" +: "${HEALTHCHECK_TIMEOUT_SECONDS:=60}" +: "${HEALTHCHECK_INTERVAL_SECONDS:=2}" +: "${GIT_TERMINAL_PROMPT:=0}" +export GIT_TERMINAL_PROMPT +export DATABASE_MIGRATION_URL + +LOCK_DIR="$DEPLOY_ROOT/.deploy.lock" +LOCK_ACQUIRED=false +PREVIOUS_RELEASE="" +NEW_RELEASE="" +PREVIOUS_WWW_RELEASE="" +NEW_WWW_RELEASE="" +PREVIOUS_SERVICE_BACKUP="" +ROLLBACK_ARMED=false +APP_SWITCHED=false +SERVICE_SYNC_STARTED=false +WWW_SWITCH_STARTED=false +ASKPASS_FILE="" + +cleanup() { + local status=$? + if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then + ROLLBACK_ARMED=false + rollback || true + fi + if [[ -n "$ASKPASS_FILE" && -f "$ASKPASS_FILE" ]]; then + rm -f "$ASKPASS_FILE" + fi + if [[ "$LOCK_ACQUIRED" == "true" && -d "$LOCK_DIR" ]]; then + rmdir "$LOCK_DIR" 2>/dev/null || true + fi +} +trap cleanup EXIT + +prepare_askpass() { + if [[ -z "${GIT_TOKEN:-}" && -z "${GITEA_TOKEN:-}" ]]; then + return 0 + fi + + local token="${GIT_TOKEN:-${GITEA_TOKEN:-}}" + local username="${GIT_USERNAME:-oauth2}" + + ASKPASS_FILE="$(mktemp "${TMPDIR:-/tmp}/tiku-git-askpass.XXXXXX")" + chmod 700 "$ASKPASS_FILE" + cat > "$ASKPASS_FILE" <<'EOF' +#!/usr/bin/env bash +case "$1" in + *Username*) printf '%s\n' "$GIT_ASKPASS_USERNAME" ;; + *Password*) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;; + *) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;; +esac +EOF + export GIT_ASKPASS="$ASKPASS_FILE" + export GIT_ASKPASS_USERNAME="$username" + export GIT_ASKPASS_TOKEN="$token" +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +validate_deploy_contract() { + [[ "$WWW_RELEASES_DIR" != "$WWW_ROOT" ]] || die "WWW_RELEASES_DIR must be outside WWW_ROOT" + case "${WWW_RELEASES_DIR%/}/" in + "${WWW_ROOT%/}/"*) die "WWW_RELEASES_DIR must not be nested under WWW_ROOT" ;; + esac + [[ "$SERVICE_REPO_DIR" != "$RELEASES_DIR" ]] || die "SERVICE_REPO_DIR must not equal RELEASES_DIR" + + if [[ "$NODE_ENV" != "production" ]]; then + return 0 + fi + + truthy "$RUN_TARO_H5_BUILD" || die "Production deployment requires RUN_TARO_H5_BUILD=true" + truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \ + || die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true" + truthy "$STRICT_H5_RUNTIME_CONFIG" || die "Production deployment requires STRICT_H5_RUNTIME_CONFIG=true" + truthy "$RUN_H5_SMOKE" || die "Production deployment requires RUN_H5_SMOKE=true" + truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=true" + truthy "$RUN_DB_READINESS" \ + || die "Production deployment requires RUN_DB_READINESS=true before launch gate" + if truthy "$RUN_DB_MIGRATIONS"; then + [[ -n "$DATABASE_MIGRATION_URL" ]] \ + || die "Production database migrations require a separate DATABASE_MIGRATION_URL" + fi + [[ -n "$RESTART_COMMAND" || "$SERVICE_MODE" != "none" ]] \ + || die "Production deployment requires a service restart strategy" + [[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL" + + if [[ "$SERVICE_MODE" == "systemd" ]]; then + truthy "$SYNC_SERVICE_REPO" || die "Production systemd deployment requires SYNC_SERVICE_REPO=true" + [[ -n "$SERVICE_REPO_DIR" ]] || die "Production systemd deployment requires SERVICE_REPO_DIR" + fi +} + +acquire_lock() { + mkdir -p "$DEPLOY_ROOT" + if ! mkdir "$LOCK_DIR" 2>/dev/null; then + die "Another deployment appears to be running: $LOCK_DIR" + fi + LOCK_ACQUIRED=true +} + +link_shared_env() { + local release="$1" + local env_file="$SHARED_DIR/.env" + if [[ -f "$env_file" ]]; then + ln -sfn "$env_file" "$release/.env" + else + warn "No $env_file found. Production readiness and runtime may fail until it exists." + fi +} + +load_runtime_env() { + local env_file="$SHARED_DIR/.env" + [[ -r "$env_file" ]] || die "Missing API runtime config: $env_file" + export_dotenv_if_exists "$env_file" +} + +link_runtime_config() { + local source_file="$1" + local target_dir="$2" + local label="$3" + + if [[ ! -d "$target_dir" ]]; then + warn "H5 dist directory missing for $label: $target_dir" + return 0 + fi + + if [[ -f "$source_file" ]]; then + ln -sfn "$source_file" "$target_dir/runtime-config.json" + elif truthy "$STRICT_H5_RUNTIME_CONFIG"; then + die "Missing $label runtime config: $source_file" + else + warn "Missing $label runtime config: $source_file" + fi +} + +link_h5_runtime_configs() { + local release="$1" + link_runtime_config "$H5_STUDENT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-student" "student" + link_runtime_config "$H5_TENANT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-tenant-admin" "tenant-admin" + link_runtime_config "$H5_PLATFORM_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-platform-admin" "platform-admin" +} + +should_run_production_readiness() { + case "$RUN_PRODUCTION_READINESS" in + true|TRUE|1|yes|YES|on|ON) return 0 ;; + false|FALSE|0|no|NO|off|OFF) return 1 ;; + auto) + if [[ "$NODE_ENV" == "production" ]]; then + return 0 + fi + if [[ -f "$SHARED_DIR/.env" ]] && grep -Eq '^NODE_ENV=production($|[[:space:]]*)' "$SHARED_DIR/.env"; then + return 0 + fi + return 1 + ;; + *) die "RUN_PRODUCTION_READINESS must be true, false, or auto" ;; + esac +} + +run_build_and_checks() { + local release="$1" + cd "$release" + + [[ "$NPM_INSTALL_COMMAND" == *"npm ci"* ]] \ + || die "NPM_INSTALL_COMMAND must use npm ci for a locked production install" + [[ "$NPM_INSTALL_COMMAND" == *"--include=dev"* ]] \ + || die "NPM_INSTALL_COMMAND must include Taro build dependencies with --include=dev" + [[ "$NPM_INSTALL_COMMAND" != *"--ignore-scripts"* ]] \ + || die "NPM_INSTALL_COMMAND must allow the reviewed Taro workspace postinstall patches" + run_shell "$NPM_INSTALL_COMMAND" + + if truthy "$RUN_CHECKS"; then + while IFS= read -r command_line; do + [[ -z "$command_line" ]] && continue + run_shell "$command_line" + done <<< "$CHECK_COMMANDS" + fi + + if truthy "$RUN_API_BUILD"; then + run npm run build:api + fi + + if truthy "$RUN_WORKER_BUILD"; then + run npm run build:worker + fi + + if truthy "$RUN_TARO_H5_BUILD"; then + if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then + run npm run audit:taro:supply-chain + fi + run npm run build:taro:h5:student + run npm run build:taro:h5:tenant + run npm run build:taro:h5:platform + link_h5_runtime_configs "$release" + if truthy "$STRICT_H5_RUNTIME_CONFIG"; then + run node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config + run npm run manifest:taro:h5 -- --require-dist --require-runtime-config + else + run node scripts/taro-h5-release-guardrails-test.js --require-dist + run npm run manifest:taro:h5 -- --require-dist + fi + if truthy "$RUN_H5_SMOKE"; then + run npm run smoke:taro:h5 + run npm run smoke:taro:h5:interaction + fi + fi + + if truthy "$RUN_SECURITY_REPO_SCAN"; then + run npm run security:repo + fi + + if truthy "$RUN_RUNTIME_AUDIT"; then + run env NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime + fi + + link_shared_env "$release" + load_runtime_env + + if should_run_production_readiness; then + run npm run readiness:production + fi + + if truthy "$RUN_DB_MIGRATIONS"; then + run_shell "$DB_MIGRATION_COMMAND" + fi + + if truthy "$RUN_DB_READINESS"; then + log "Running production database readiness after the optional migration step" + run npm run readiness:production:db + fi + + if truthy "$RUN_LAUNCH_GATE"; then + [[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE" + run env \ + DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \ + DEPLOY_RELEASE_ROOT="$release" \ + npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" + fi +} + +verify_live_h5_release() { + local release="$1" + truthy "$RUN_LAUNCH_GATE" || return 0 + [[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE" + + log "Verifying the activated H5 release against production URLs" + ( + cd "$release" + env \ + DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \ + DEPLOY_RELEASE_ROOT="$release" \ + npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5 + ) +} + +stage_h5_release() { + local release="$1" + local release_name + release_name="$(basename "$release")" + local staging="$WWW_RELEASES_DIR/.tmp-$release_name" + + require_command rsync + rm -rf "$staging" + mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin" + run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-student/" "$staging/student/" + run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/" + run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/" + + NEW_WWW_RELEASE="$WWW_RELEASES_DIR/$release_name" + rm -rf "$NEW_WWW_RELEASE" + mv "$staging" "$NEW_WWW_RELEASE" +} + +atomic_symlink() { + local target="$1" + local link="$2" + local next_link="${link}.next.$$" + + rm -f "$next_link" + ln -s "$target" "$next_link" + if [[ -L "$link" || ! -e "$link" ]]; then + mv -Tf "$next_link" "$link" + return 0 + fi + rm -f "$next_link" + return 1 +} + +switch_www_release() { + [[ -n "$NEW_WWW_RELEASE" ]] || return 0 + WWW_SWITCH_STARTED=true + + if [[ -L "$WWW_CURRENT_LINK" ]]; then + PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_CURRENT_LINK")" + elif [[ -d "$WWW_CURRENT_LINK" ]]; then + PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-$(date +%Y%m%d%H%M%S)" + log "Moving existing Web root to $PREVIOUS_WWW_RELEASE" + mv "$WWW_CURRENT_LINK" "$PREVIOUS_WWW_RELEASE" + elif [[ -e "$WWW_CURRENT_LINK" ]]; then + die "WWW root exists but is not a directory or symlink: $WWW_CURRENT_LINK" + fi + + atomic_symlink "$NEW_WWW_RELEASE" "$WWW_CURRENT_LINK" \ + || die "$WWW_CURRENT_LINK exists and cannot be replaced by the release symlink" +} + +snapshot_service_repo_for_rollback() { + if [[ -n "$PREVIOUS_RELEASE" || ! -d "$SERVICE_REPO_DIR" || -L "$SERVICE_REPO_DIR" ]]; then + return 0 + fi + + require_command rsync + PREVIOUS_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)" + mkdir -p "$PREVIOUS_SERVICE_BACKUP" + run rsync -a --delete --exclude .git "$SERVICE_REPO_DIR/" "$PREVIOUS_SERVICE_BACKUP/" +} + +sync_service_repo() { + local release="$1" + truthy "$SYNC_SERVICE_REPO" || return 0 + [[ -n "$release" && -d "$release" ]] || return 1 + + require_command rsync + if [[ -L "$SERVICE_REPO_DIR" ]]; then + [[ "$(readlink -f "$SERVICE_REPO_DIR")" == "$(readlink -f "$release")" ]] \ + || die "SERVICE_REPO_DIR symlink must resolve to the selected current release" + return 0 + fi + mkdir -p "$SERVICE_REPO_DIR" + run rsync -a --delete --exclude .git "$release/" "$SERVICE_REPO_DIR/" +} + +restart_services() { + if [[ -d "$CURRENT_LINK" ]]; then + cd "$CURRENT_LINK" + elif [[ -d "$SERVICE_REPO_DIR" ]]; then + cd "$SERVICE_REPO_DIR" + else + die "Neither CURRENT_LINK nor SERVICE_REPO_DIR is available for service restart" + fi + + if [[ -n "$RESTART_COMMAND" ]]; then + run_shell "$RESTART_COMMAND" || return 1 + return 0 + fi + + case "$SERVICE_MODE" in + none) + warn "SERVICE_MODE=none; release switched but no service was restarted." + ;; + systemd) + [[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required when SERVICE_MODE=systemd" + for unit in $SYSTEMD_UNITS; do + run systemctl restart "$unit" || return 1 + done + for unit in $SYSTEMD_UNITS; do + run systemctl is-active --quiet "$unit" || return 1 + done + ;; + pm2) + require_command pm2 + if [[ -n "$PM2_ECOSYSTEM" ]]; then + run pm2 startOrReload "$PM2_ECOSYSTEM" --update-env || return 1 + elif [[ -n "$PM2_PROCESS_NAMES" ]]; then + for name in $PM2_PROCESS_NAMES; do + run pm2 restart "$name" --update-env || return 1 + done + else + die "PM2_ECOSYSTEM or PM2_PROCESS_NAMES is required when SERVICE_MODE=pm2" + fi + ;; + compose) + require_command docker + [[ -n "$COMPOSE_FILE" ]] || die "COMPOSE_FILE is required when SERVICE_MODE=compose" + run docker compose -f "$COMPOSE_FILE" up -d --build || return 1 + ;; + *) + die "Unknown SERVICE_MODE: $SERVICE_MODE" + ;; + esac +} + +healthcheck() { + if [[ -z "$HEALTHCHECK_URL" ]]; then + return 0 + fi + + require_command curl + + local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS)) + log "Waiting for healthcheck: $HEALTHCHECK_URL" + while (( SECONDS < deadline )); do + if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then + log "Healthcheck passed." + return 0 + fi + sleep "$HEALTHCHECK_INTERVAL_SECONDS" + done + + return 1 +} + +switch_current() { + local release="$1" + + if [[ -e "$CURRENT_LINK" && ! -L "$CURRENT_LINK" ]]; then + die "$CURRENT_LINK exists and is not a symlink; refusing to replace it" + fi + + if [[ -L "$CURRENT_LINK" ]]; then + PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")" + fi + + atomic_symlink "$release" "$CURRENT_LINK" || die "Failed to switch $CURRENT_LINK" +} + +rollback() { + local rollback_failed=false + + if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then + warn "Rolling Web root back to $PREVIOUS_WWW_RELEASE" + atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_CURRENT_LINK" || rollback_failed=true + elif [[ "$WWW_SWITCH_STARTED" == "true" && -n "$NEW_WWW_RELEASE" ]]; then + warn "No previous Web release recorded; restoring an absent Web root." + rm -f "$WWW_CURRENT_LINK" || rollback_failed=true + fi + + local service_rollback_release="$PREVIOUS_RELEASE" + if [[ -z "$service_rollback_release" ]]; then + service_rollback_release="$PREVIOUS_SERVICE_BACKUP" + fi + + if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_RELEASE" ]]; then + warn "Rolling application release back to $PREVIOUS_RELEASE" + atomic_symlink "$PREVIOUS_RELEASE" "$CURRENT_LINK" || rollback_failed=true + elif [[ "$APP_SWITCHED" == "true" && -n "$NEW_RELEASE" ]]; then + warn "No previous application current release recorded; restoring an absent current link." + rm -f "$CURRENT_LINK" || rollback_failed=true + fi + + if [[ "$SERVICE_SYNC_STARTED" == "true" ]] && truthy "$SYNC_SERVICE_REPO" && [[ -n "$service_rollback_release" ]]; then + sync_service_repo "$service_rollback_release" || rollback_failed=true + fi + if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then + restart_services || rollback_failed=true + healthcheck || rollback_failed=true + fi + + [[ "$rollback_failed" == "false" ]] +} + +prune_releases() { + local keep="$1" + local directory="${2:-$RELEASES_DIR}" + [[ "$keep" =~ ^[0-9]+$ ]] || return 0 + (( keep > 0 )) || return 0 + + find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \ + | sort -r \ + | tail -n +"$((keep + 1))" \ + | while IFS= read -r old_release; do + if [[ "$old_release" == "$NEW_RELEASE" || "$old_release" == "$PREVIOUS_RELEASE" \ + || "$old_release" == "$NEW_WWW_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]]; then + continue + fi + log "Pruning old release: $old_release" + rm -rf "$old_release" + done +} + +main() { + require_command git + require_command npm + + acquire_lock + validate_deploy_contract + mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$WWW_RELEASES_DIR" + prepare_askpass + + local timestamp + timestamp="$(date +%Y%m%d%H%M%S)" + local tmp_release="$RELEASES_DIR/.tmp-$timestamp" + + log "Cloning $REPO_URL#$BRANCH" + run git clone --depth "$GIT_DEPTH" --branch "$BRANCH" "$REPO_URL" "$tmp_release" + + local commit + commit="$(git -C "$tmp_release" rev-parse --short=12 HEAD)" + NEW_RELEASE="$RELEASES_DIR/$timestamp-$commit" + mv "$tmp_release" "$NEW_RELEASE" + + log "Building release $NEW_RELEASE" + run_build_and_checks "$NEW_RELEASE" + + log "Staging H5 release for $WWW_ROOT" + stage_h5_release "$NEW_RELEASE" + + if [[ -L "$CURRENT_LINK" ]]; then + PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")" + fi + snapshot_service_repo_for_rollback + + ROLLBACK_ARMED=true + log "Switching current release to $NEW_RELEASE" + switch_current "$NEW_RELEASE" + APP_SWITCHED=true + + log "Synchronizing service runtime to $SERVICE_REPO_DIR" + SERVICE_SYNC_STARTED=true + if ! sync_service_repo "$NEW_RELEASE"; then + ROLLBACK_ARMED=false + rollback || true + die "Service runtime synchronization failed; rollback attempted." + fi + + if ! restart_services; then + ROLLBACK_ARMED=false + rollback || true + die "Service restart failed; rollback attempted." + fi + + if ! healthcheck; then + ROLLBACK_ARMED=false + rollback || true + die "Healthcheck failed; rollback attempted." + fi + + log "Switching Web root to $NEW_WWW_RELEASE" + switch_www_release + + verify_live_h5_release "$NEW_RELEASE" + + ROLLBACK_ARMED=false + prune_releases "$KEEP_RELEASES" "$RELEASES_DIR" + prune_releases "$KEEP_RELEASES" "$WWW_RELEASES_DIR" + log "Deploy complete: $APP_NAME @ $commit" +} + +main "$@" diff --git a/docs/refactor/README.md b/docs/refactor/README.md index 262b5169..04f3b1c6 100644 --- a/docs/refactor/README.md +++ b/docs/refactor/README.md @@ -1,5 +1,12 @@ # SaaS 重构工作区 +当前接管先读: + +- `production-foundation-baseline-20260712.md`:后端/API 冻结结论、前端启动边界、生产硬阻断和正式上线顺序。 +- `clean-room-migration-audit-20260712.md`:官方 Supabase PG15 空库 78 个迁移、扩展/ACL、运行角色、RLS、约束和幂等审计证据。 +- `taro-h5-browser-qa-20260712.md`:学生端、租户后台、平台后台桌面/移动浏览器和主交互验收记录。 +- `taro-supply-chain-baseline-20260712.md`:Taro 4.2.0 安全 override、H5 runtime patch、干净安装与构建工具链风险边界。 + 这个目录记录从 PocketBase 单体项目迁移到 Supabase/PostgreSQL + 新 API + Taro 学生端的重构过程。 当前阶段目标: @@ -18,7 +25,7 @@ - `scripts/import-pocketbase`:PocketBase schema/数据导入工具。 - `docker-compose.api.yml`、`docker-compose.api.benchmark.yml`、`apps/api/Dockerfile`:本地 Docker API 和受限资源压测入口。 - `scripts/deploy/README.md`:云服务器部署 runbook,覆盖 `tjszsb.com` 六域名规划、服务器目录、Gitea 安全部署、Nginx、systemd 和更新脚本。 -- `scripts/deploy/bin/deploy.sh`:服务器端发布脚本模板,负责拉取 Gitea、构建 API/worker/Taro H5、发布静态文件和重启服务;真实密钥只从 `/etc/tiku-saas/*.env` 读取。 +- 根目录 `deploy.sh`:推荐的 release/symlink 原子发布入口,包含严格 runtime config、readiness、安全、H5 smoke、manifest 和真实 production launch gate;`scripts/deploy/bin/deploy.sh` 仅保留为旧服务器兼容入口并同步执行同类门禁。真实密钥只从服务器受控 env 读取。 - `docs/refactor/architecture.md`:新重构目录边界和工程规范。 - `docs/refactor/ai-development-guardrails.md`:后续 AI/开发者必须遵守的 Supabase-first 架构和安全守则。 - `docs/refactor/content-import-contract.md`:题目、单词、知识手册导入契约,明确后端校验、旧格式转换和前端职责。 @@ -32,10 +39,11 @@ - `docs/refactor/taro-h5-deployment.md`:Taro H5 三域名部署、运行时配置、Nginx、CSP、缓存和 CORS 边界。 - `docs/refactor/postgresql-4c16g-tuning.md`:4 核 16G 自托管 PostgreSQL 起步调参、观察 SQL 和回滚方式。 - `docs/refactor/performance-benchmark-runbook.md`:本地/云端 API 压测、4 核 16G 阶梯并发矩阵、Docker 受限资源预演和报告归档方式。 +- `docs/refactor/tenant-student-capacity-runbook.md`:单租户最多 10 万学生的安全合成夹具、cursor/搜索 SQL 基准、EXPLAIN 证据和专用命名空间清理流程。 - `docs/refactor/performance-benchmark-summary-20260630.md`:真实迁移数据压测脱敏摘要。 - `docs/refactor/backend-open-items-and-capacity-20260701.md`:后端剩余功能、已定稿产品口径和最新在线容量估算。 - `docs/refactor/multitenant-auth-security-contract.md`:多租户隔离、鉴权、权限和资源安全红线。 -- `docs/refactor/production-launch-evidence.template.json`:生产上线证据模板;真实证据填入本地 `production-launch-evidence.json` 后运行 `npm run launch:gate`。 +- `docs/refactor/production-launch-evidence.template.json`:生产上线证据模板;真实 evidence 与 `launch-artifacts/` 作为完整 bundle 放在服务器受控目录,不进入 Git,再运行 `npm run launch:gate -- --evidence `。 下一步优先级: diff --git a/docs/refactor/architecture.md b/docs/refactor/architecture.md index f45bb19f..25cba319 100644 --- a/docs/refactor/architecture.md +++ b/docs/refactor/architecture.md @@ -48,7 +48,7 @@ docs/refactor/ ```bash npm run supabase:start npm run supabase:reset -npm run db:smoke-seed +npm run db:smoke-seed -- --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY npm run dev:api ``` @@ -60,7 +60,7 @@ npm run docker:api:build npm run docker:api:up ``` -如果 Docker 拉取 `node:20-alpine` 超时,先配置 Docker Desktop 镜像源或代理,再重试 `npm run docker:api:build`。 +API Dockerfile 锁定 `node:20.20.2-alpine3.23` 多架构 manifest,最终镜像以 `node` 用户运行,只复制生产依赖和编译产物。若 Docker Hub 超时,先配置受信镜像源/代理并确认拉取到相同 digest,再重试 `npm run docker:api:build`,不要移除 digest 锁定。 本地容量预演可以使用专门的 benchmark override: @@ -89,10 +89,10 @@ npm run pb:import:validate - Docker Desktop 可用。 - Supabase 本地容器可启动。 -- API Dockerfile 已验证可构建;benchmark override 可启动受限 API 容器并通过短压测 smoke。 +- API Dockerfile 已验证可构建;最终镜像约 `53 MB`、生产 `node_modules` 约 `24.3 MB`,不含 TypeScript/tsx,UID 为 `1000(node)`,连接隔离测试库通过 `/health`;benchmark override 可启动受限 API 容器并通过短压测 smoke。 - `supabase db reset` 可完整执行三份 migration 和 seed。 - `supabase db reset` 可完整执行全部 migration 和 seed。 -- `npm run db:smoke-seed` 可恢复最小业务烟测数据。 +- `npm run db:smoke-seed -- --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY` 只能在已标记为 `local/test/ci` 的隔离库恢复最小业务烟测数据。 - `platform-admin` 可完成平台概览、租户创建、订阅、账单生成、人工收款确认、使用量记录。 - API `/health` 可连 PostgreSQL 并返回 `db: ok`。 - API `/api/tenant/resolve?host=localhost` 可解析主租户。 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index dfe09039..1a4cdee6 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -1,6 +1,8 @@ # 后端进度同步与前端接入路线图 -更新时间:2026-06-30 +更新时间:2026-07-12 + +> 权威决策已迁移到 `production-foundation-baseline-20260712.md`。本文继续保留详细能力清单,凡与 2026-07-12 基线冲突的历史表述,以该基线为准。 这份文档用于在进入 Taro 前端开发前,快速确认新 Supabase/PostgreSQL 后端已经做到哪里、还缺什么、前端应如何接入,以及后续继续开发的优先级。 @@ -14,7 +16,7 @@ - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、销售/代理转化报表、CRM 配置、入队、worker 推送、失败死信运营、手动重试/忽略和分佣结算基础闭环。 - 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 -因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载、字段映射 API 和导入任务详情已可联调,Taro 租户内容页已接入上传/粘贴预览、字段别名覆盖、同步/异步执行、异步轮询和复检详情第一版,租户营销中心已接入 CRM 配置/队列、分佣结算、积分任务/兑换和积分风控只读摘要第一版。 +因此,后端现在已经具备在现有 `apps/taro` 中开始正式前端重构的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载、字段映射 API 和导入任务详情已可联调,Taro 租户内容页已接入上传/粘贴预览、字段别名覆盖、同步/异步执行、异步轮询和复检详情第一版,租户营销中心已接入 CRM 配置/队列、分佣结算、积分任务/兑换和积分风控只读摘要第一版。 ## 后端模块进度 @@ -39,7 +41,7 @@ ## 前端接入建议 -建议新建 `apps/taro`,不要在旧 React Web 上继续堆大量兼容。旧项目继续作为样式、页面和交互参照,真正的新业务调用以 `apps/api` 为准。 +继续在已建立的 `apps/taro` 中开发,不新建第二套前端,也不在旧 React Web 上继续堆大量兼容。旧项目只作为样式、页面状态和微信能力参照,新业务调用以 `apps/api` 和受控兼容基线为准。学生端共享 H5/小程序/后续 App;租户后台和平台后台首发仅做响应式 H5。 前端第一阶段应该先做能跑完整学生链路的页面: diff --git a/docs/refactor/clean-room-migration-audit-20260712.md b/docs/refactor/clean-room-migration-audit-20260712.md new file mode 100644 index 00000000..bfa834f0 --- /dev/null +++ b/docs/refactor/clean-room-migration-audit-20260712.md @@ -0,0 +1,127 @@ +# Clean-room migration audit - 2026-07-12 + +## Audit scope + +- Isolated project ID: `tiku-clean-final-20260712-v2` +- Database image: official Supabase PostgreSQL `15.8` +- Isolated database endpoint used during verification: `127.0.0.1:55522` +- Migration role: standard non-superuser `postgres` +- Privileged bootstrap/test role: `supabase_admin`, used for the runtime-role bootstrap and controlled role-boundary probes +- The existing databases on ports `5432`, `54322`, and `55432` were explicitly excluded and were not modified. + +The clean-room startup log reported that no `supabase/seed.sql` matched. The seed was therefore not executed. At the end of migration verification, `public.tenants`, `app_private.environment_safety`, and `auth.users` all contained zero rows. + +## Migration result + +All 78 migrations were applied successfully in filename order to the empty database. `supabase migration list --local` subsequently showed every local version paired with the applied version through `202607120019`. + +Migration history verification returned: + +- Rows: `78` +- Distinct versions: `78` +- Duplicate versions: `0` +- Maximum version: `202607120019` + +The privileged `scripts/deploy/sql/bootstrap-backend-runtime-roles.sql` bootstrap ran before the normal migrations. Migrations `202607120013_backend_runtime_roles.sql`, `202607120018_auth_user_reference_boundary.sql`, and `202607120019_production_migration_history_boundary.sql` were later replayed directly by the non-superuser `postgres` role. All three replays succeeded. A normalized fingerprint covering role attributes, memberships, schema/table/sequence/function ACLs, default ACLs, and security-definer attributes remained `1691|7aee08a0b05a30fc49fd278748d4c4e3` before and after replay. + +## Extensions and function ACLs + +The required extensions were installed in the `extensions` schema: + +- `citext` +- `ltree` +- `pg_trgm` +- `pgcrypto` + +No client-facing role can execute a function exposed through `public`. The final extension-function matrix was: + +| Role | `citext` | `ltree` | `pg_trgm` | `pgcrypto` | +| --- | ---: | ---: | ---: | ---: | +| `anon` | 0/45 | 0/78 | 0/31 | 0/36 | +| `authenticated` | 0/45 | 0/78 | 0/31 | 0/36 | +| `tiku_api` | 45/45 | 78/78 | 31/31 | 0/36 | +| `tiku_worker` | 45/45 | 78/78 | 31/31 | 0/36 | + +`citext` also owns two aggregates; when all `pg_proc` extension members are counted, API and worker have all 47 required `citext` members while client roles still have zero. + +## Runtime roles and Auth boundary + +Both backend roles were verified as: + +- `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS` +- No parent-role memberships +- `search_path=pg_catalog, public, extensions` + +Runtime identity checks confirmed that both roles can execute `citext` comparisons, `ltree` operators, and `extensions.similarity()`. + +The API role could execute exactly five reviewed `app` functions: + +- `app.auth_user_exists(uuid)` +- `app.production_migration_history(text)` +- `app.uuid_array_from_jsonb(jsonb)` +- `app.public_question_bank_grant_allows(uuid[], uuid[], uuid, uuid[])` +- `app.public_question_bank_subscription_allows(jsonb, jsonb, uuid, uuid, uuid[])` + +Direct `tiku_api` access to `supabase_migrations.schema_migrations` was denied. The API-only `app.production_migration_history('202607120019')` boundary returned `latest_version=202607120019`, `applied_count=78`, `distinct_version_count=78`, and `expected_version_applied=true`. Worker and anonymous execution were denied. + +The worker has no `app` schema usage and can execute no `app` functions. Both API-only boundary functions are stable `SECURITY DEFINER` functions with an empty search path. `app.auth_user_exists(uuid)` returned `false` for an absent UUID and `true` for a temporary clean-room Auth user; that user was deleted immediately after the probe. Worker and anonymous execution were denied. Direct reads of `auth.users` were denied for both API and worker. + +Supabase internal compatibility checks also passed: + +- `supabase_auth_admin` could query `auth.users`. +- `supabase_storage_admin` could query `storage.objects`. +- `authenticator` could still `SET ROLE anon` and `SET ROLE authenticated`. + +## RLS, constraints, and indexes + +All 140 `public` tables had RLS enabled. Across `public` and `app_private`, all 134 tables containing a `tenant_id` column had RLS enabled. + +The only checked internal table without RLS was `app_private.environment_safety`. It is not tenant data, is intentionally fail-closed, and explicitly revokes access from `public`, `anon`, and `authenticated`. + +The project tenant-foreign-key audit matched all `189/189` expected relations and its expected SHA-256 fingerprint, found all three reviewed exceptions, found no unvalidated relations, and reported `0` data violations. + +All 22 explicitly checked critical foreign keys, unique constraints, and check constraints introduced by migrations `202607120010` through `202607120017` existed with `convalidated=true`. All 16 explicitly checked critical indexes existed with `indisvalid=true` and `indisready=true`, including tenant-safe question/version relations, answer semantics, tenant student keyset/search indexes, SMS reservation limits, audit-log capacity indexes, and import-job lease indexes. + +Supabase lint completed successfully: + +```text +Linting schema: public +Linting schema: app +Linting schema: app_private + +No schema errors found +``` + +The command used was: + +```bash +npx --no-install supabase db lint \ + --local \ + --workdir /tmp/tiku-clean-final-20260712-v2 \ + --schema public,app,app_private \ + --level error \ + --fail-on error +``` + +## Residual foreign-key index risk + +The application schemas (`public` and `app_private`) contain 465 foreign keys. The structural audit found: + +- 146 with an unconditional complete left-prefix index +- 6 covered only by a partial left-prefix index +- 313 without a complete left-prefix index + +The newly hardened `202607120015` and `202607120017` hot paths are substantially covered. The remaining count is a capacity and operations backlog, not a migration correctness or tenant-isolation failure. + +Adding 313 indexes blindly is not recommended. Every index increases storage, write amplification, vacuum work, cache pressure, migration time, and lock risk. Some foreign keys are low-volume, rarely joined, never cascaded in normal operations, or already served by a more useful query-specific index. Production indexing should therefore be prioritized from representative capacity tests, cascade/delete behavior, slow-query evidence, and `pg_stat_statements`, then introduced in controlled batches. + +## Cleanup + +After all checks passed, the isolated Supabase project was stopped with `--no-backup`. The following were verified absent: + +- `/tmp/tiku-clean-final-20260712-v2` +- Clean-room containers +- Clean-room Docker volumes +- Clean-room Docker networks + +The pre-existing databases on ports `5432`, `54322`, and `55432` remained running after cleanup. diff --git a/docs/refactor/content-import-contract.md b/docs/refactor/content-import-contract.md index 97de280c..14cdb986 100644 --- a/docs/refactor/content-import-contract.md +++ b/docs/refactor/content-import-contract.md @@ -1,6 +1,6 @@ # 内容导入契约 -更新时间:2026-06-29 +更新时间:2026-07-12 ## 结论 @@ -98,6 +98,24 @@ npm --workspace @tiku-saas/worker run imports:once 前端提交异步导入后不要重复同步执行同一 job;只需要轮询 `GET /api/tenant-content/imports` 并用 `GET /api/tenant-content/imports/issues` 展示问题行。worker 会按 `attempt_count/max_attempts` 记录重试,失败时写入 `errorMessage` 和审计日志。 +异步 worker 使用数据库持久 lease,不能只依赖进程内状态: + +- claim 是单条 `UPDATE ... FROM (SELECT ... FOR UPDATE SKIP LOCKED)`,多实例不会领取同一个 job。 +- 每次 claim 都生成新的 `lease_token` fencing token,并写入 `locked_by`、`locked_at`、`lease_expires_at`、`last_heartbeat_at`。 +- 长任务按 `WORKER_IMPORT_HEARTBEAT_INTERVAL_MS` 续租;该值必须小于 `WORKER_IMPORT_LEASE_SECONDS` 的一半。 +- worker 崩溃后,其他实例可在 lease 过期后重新领取,并原子增加 `attempt_count`。 +- 完成、失败和重试提交都必须同时匹配 job、`status=importing`、未过期 lease 和 `lease_token`。旧实例丢失 lease 后,其导入事务整体回滚,不能覆盖接管者的结果或审计。 +- attempt 已耗尽的过期 job 会直接转为 `failed`,不会额外执行一次。 + +生产建议先保持默认配置: + +```env +WORKER_IMPORT_LEASE_SECONDS=120 +WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000 +``` + +lease 应覆盖数据库短暂抖动,但不应长到显著拖慢崩溃恢复;调整时必须同时运行 `test:worker:imports` 和 production readiness。 + ## 模板、字段映射和导入后复检 租户后台前端不要把导入字段写死在页面里。导入页初始化时先读取字段映射,下载模板时调用模板接口: diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index c0f7dca6..d8854b5d 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -1,15 +1,18 @@ # 前端交接索引 -更新时间:2026-07-02 +更新时间:2026-07-12 -这份文件是给 Taro/H5/小程序前端同事的入口。当前仓库的前端重构建议从 `apps/taro` 新建工程开始,不再把旧 React/Vite 前端搬回根目录继续开发。 +这份文件是给 Taro/H5/小程序前端同事的入口。`apps/taro` 已经是唯一的新前端工程,后续应在该工程内重构,不要重新新建第二套 Taro 工程,也不要把旧 React/Vite 前端搬回根目录。 -## 2026-07-02 接管重点 +开始设计或改页面前先读 `production-foundation-baseline-20260712.md`。该文件冻结了当前 API、身份和状态机边界,并区分了“可以开始前端”与“已经可切生产流量”。浏览器现状证据见 `taro-h5-browser-qa-20260712.md`。 -当前 `main` 已包含旧题库视觉对齐版本,最新提交是 `f54421f test: align Taro visual guardrails with legacy UI`。另一台工作机接管后,先确认本地代码至少包含该提交: +## 2026-07-12 接管重点 + +接管后先确认当前候选分支和工作区,不要依赖历史 commit 文案判断是否最新: ```bash -git log -2 --oneline +git status --short --branch +git log -3 --oneline ``` 本轮前端变化的边界: @@ -87,6 +90,7 @@ node scripts/taro-h5-release-guardrails-test.js - H5 构建完成后必须运行 `npm run smoke:taro:h5:interaction` 做真实浏览器点击验证。它会覆盖学生首页到题库练习、答题、收藏、会员收银台下单/支付参数/订单状态,租户后台工作台到题库内容/财务运营,以及平台后台工作台到租户管理/账务中心;如果 Chrome/Edge 缺失,可设置 `TARO_H5_SMOKE_BROWSER` 指向 Chromium 浏览器。 - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。 - H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。 +- 学生端当前 production 入口约 `500 KiB`,前端重构必须先建立路由拆包、延迟加载和资源预算;视觉组件不得无约束进入首包。 - 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、`npm run smoke:taro:h5:interaction` 真实浏览器交互烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config`、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账、`security:repo` 和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。 - 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。 - 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 51b58ceb..063a99ac 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -1,6 +1,8 @@ # Supabase 重构功能进度矩阵 -更新时间:2026-06-30 +更新时间:2026-07-12 + +> 2026-07-12 权威基线:多租户 RLS、运行角色/Data API 权限、Auth 用户最小边界、动态 CORS、短信限流、Worker lease/fencing、生产 readiness 和十万学生容量证据已经完成本地及独立 clean-room 验证,后端/API 可以进入受控冻结并开始正式前端重构。正式上线仍需目标云服务器的真实 provider、数据迁移、备份恢复、首个平台超管、systemd、真实压测、三套 H5 runtime config 和 launch evidence。完整结论见 `production-foundation-baseline-20260712.md`;下方长表保留历史功能明细,若与该基线冲突,以 7 月 12 日基线为准。 ## 当前结论 @@ -23,7 +25,7 @@ | 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 | | --- | --- | --- | --- | --- | --- | -| 多租户隔离 | 已建 `tenants`、`tenant_domains`、`tenant_branding`、`tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 | +| 多租户隔离 | 已建 `tenants`、`tenant_domains`、`tenant_branding`、`tenant_settings`、完整 RLS/ACL/运行角色边界 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API、动态 RLS、Data API ACL、clean-room migration 和 readiness 均有自动化证据 | 本地与 clean-room 闭环已完成;目标生产环境仍需真实 Auth/JWKS、运行角色 bootstrap 和远程隔离验收 | | 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表、租户内容通知表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、每日一练九宫格 metadata、PDF/Word 运营版式、ZIP 图片素材包、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、每日一练导出 metadata、异步 PDF/Word/每日一练 ZIP job 创建、exports worker、公共题库授权、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言;Taro 类型检查覆盖 RichContent 接入 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染和逐题复盘第一版可联调,公共题库采纳/手动/自动同步、同步通知、冲突查询/处理、导入后复检、模板下载、字段映射 API、JSON/PDF/Word/每日一练 ZIP 基础导出可联调;真正 KaTeX/小程序公式方案、私有题图签名映射、公共题库生产调度/失败告警、更精细导出模板和更完整运营消息仍需补齐 | | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | diff --git a/docs/refactor/import-worker-lease-verification.md b/docs/refactor/import-worker-lease-verification.md new file mode 100644 index 00000000..9978ba08 --- /dev/null +++ b/docs/refactor/import-worker-lease-verification.md @@ -0,0 +1,42 @@ +# Import worker 持久 lease 验证报告 + +更新时间:2026-07-12 + +## 结论 + +`content_import_jobs` 已具备多实例和进程重启所需的持久 lease 与 fencing 语义。验证只在专用测试库 `127.0.0.1:55432` 执行,没有连接生产环境。 + +## 实现边界 + +- migration `202607120016_content_import_job_leases.sql` 增加 `lease_token`、`lease_expires_at`、`last_heartbeat_at`、一致性约束和 pending/expired 部分索引。 +- claim 使用原子 `SKIP LOCKED`,可同时领取 ready pending job 和 lease 已过期的 importing job。 +- 每次 claim 只增加一次 `attempt_count` 并生成新 token;失败调度只写 `next_attempt_at`,不会重复增加 attempt。 +- worker 在执行期间续租;续租、完成、失败和重试都要求 token 匹配且 lease 未过期。 +- API executor 的业务写入和终态更新处于同一事务。fencing 校验失败会回滚题目、版本、集合绑定、item 和 audit 写入。 +- 最后一次 attempt 的 lease 过期后由 claim/reaper 路径直接标记 failed,避免第 `max_attempts + 1` 次执行。 + +## 动态覆盖 + +`scripts/import-worker-integration-test.js` 在 destructive-test database guard 后验证: + +1. 两个并发 worker 对三个 job 原子 claim,没有重复领取。 +2. 心跳推进 `last_heartbeat_at` 并延长 `lease_expires_at`。 +3. 模拟崩溃后,过期 job 被新 worker 接管,token 旋转且 attempt 从 1 变为 2。 +4. 旧 token 的执行在业务写入前被拒绝,旧 token 的失败提交也不能覆盖新 lease。 +5. 新 lease 可完成 job,终态清空 lease 字段并保持准确 attempt。 +6. retry 保持 pending、持久 `next_attempt_at`,再次 claim 才增加 attempt。 +7. 最后 attempt 过期后进入 failed,不再重新执行。 + +## 验证命令 + +```bash +npm run check:worker +npm run check:api +npm run build:worker +DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/postgres \ + node scripts/import-worker-integration-test.js \ + --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY +node scripts/production-readiness-check-test.js +``` + +完整 worker integration 会先跑 smoke seed;执行时必须明确指向允许 destructive tests 的本地/CI 数据库。 diff --git a/docs/refactor/local-supabase.md b/docs/refactor/local-supabase.md index 30b8882f..00674b7e 100644 --- a/docs/refactor/local-supabase.md +++ b/docs/refactor/local-supabase.md @@ -37,9 +37,13 @@ Inbucket: http://127.0.0.1:54324 ```bash npm run supabase:reset -npm run db:smoke-seed +npm run db:smoke-seed -- --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY ``` +`supabase:reset` 执行本地 `supabase/seed.sql`,会在 `app_private.environment_safety` 写入唯一的 `local/true` 标记。`smoke-seed` 还要求精确确认短语,两者缺一即在事务和任何持久化写入前拒绝。`npm run test:api`、`npm run test:rls` 和 `test:worker:*` 会通过受控 npm script 传入确认值,但仍必须通过数据库标记。 + +CI 或生产快照的隔离克隆库需由初始化流程显式写入 `environment='ci'` 或 `test`、`allow_destructive_tests=true`,并只授予测试数据库角色读取该标记的权限。生产和预发不得设置放行标记,也不得执行 `supabase/seed.sql`。 + ## API 服务 ```bash diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index d3bfec37..4604a1a8 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -1,6 +1,6 @@ # 多租户与鉴权安全契约 -更新时间:2026-06-30 +更新时间:2026-07-11 这个系统后续要卖给同行作为题库 SaaS,因此租户隔离、鉴权、资源权限和审计是商用红线。前端可以先按迁移期接口联调,也可以按 Supabase 官方推荐使用 publishable key + RLS 的客户端能力管理 Auth/session,但正式上云验收前必须完成本文件的 P0 项。 @@ -20,7 +20,8 @@ 当前后端已经进入“session 优先、迁移头受控兼容”的状态: - `Authorization: Bearer ` 会优先解析 `app_private.auth_sessions`,并作为用户身份来源。 -- `Authorization: Bearer ` 已支持服务端验签,后端通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射到业务用户和租户成员。 +- `Authorization: Bearer ` 已支持服务端验签,后端通过 `auth.users.id -> platform_users.auth_user_id` 映射平台身份;普通租户用户再通过 `tenant_memberships` 映射当前租户角色。 +- Supabase JWT 顶层 `role=authenticated` 不会覆盖数据库中的平台管理员身份;平台权限只认 active 的 `platform_users.primary_role='platform_admin'` 和 `platform_permissions`,也不要求平台管理员先加入某个租户。 - Supabase JWT 支持 `AUTH_JWT_SECRET` 或 `AUTH_JWT_JWKS_URL`;生产推荐优先配置 Supabase Auth JWKS 和 `AUTH_JWT_ISSUER`,或在自托管兼容模式下配置强随机 JWT secret。配置 JWKS 但缺少 issuer 会被生产 fail-fast 阻断。 - JWT 可以在 `app_metadata.tenant_id` 或请求租户上下文中确定当前租户;如果两者冲突,后端拒绝,不允许前端覆盖 token 中的租户声明。 - 登录后如果请求中的 `x-user-id`、query/body `userId` 与 session 用户不一致,后端返回 `AUTH_USER_MISMATCH`。 @@ -31,12 +32,15 @@ - 本地短信 provider 可使用 `mock`。 - 真实短信 provider 已支持阿里云和腾讯云,密钥只能从 `app_private.tenant_secrets` 读取。 - 微信小程序登录已由后端调用 `code2Session`,前端不得接触 AppSecret 或 session_key。 -- `NODE_ENV=production` 下禁止 `ALLOW_LEGACY_AUTH_HEADERS=true`、`ALLOW_PLATFORM_ADMIN_KEY=true`、`AUTH_SMS_PROVIDER=mock`、默认/弱密钥和 `CORS_ORIGIN=*`。 +- `NODE_ENV=production` 下禁止 `ALLOW_LEGACY_AUTH_HEADERS=true`、`ALLOW_PLATFORM_ADMIN_KEY=true`、`AUTH_SMS_PROVIDER=mock`、默认/弱密钥、`CORS_ORIGIN=*` 或 `CORS_TENANT_DOMAINS_ENABLED=false`。 +- `CORS_ORIGIN` 只列少量中央平台/运维 Origin;租户 H5 Origin 必须命中 `active tenant_domains + active tenants`。动态查询使用有界正负 TTL 缓存与同 host 并发去重,查询故障、未知/禁用域名和非标准 HTTPS Origin 一律 fail closed。CORS 不得信任请求 `Host`/`X-Forwarded-Host` 或租户头进行准入。 这些只允许用于本地开发和内网联调,不允许作为正式云端验收方案。 Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 grant 和 JWT 权限模型都正确。本项目的核心业务表默认不开放给 Taro 直写;任何新增直连表都必须先通过 RLS、跨租户、权限和性能评审。 +`202607110001_data_api_acl_rls_hardening.sql` 把这个约定落到数据库:`anon/authenticated` 对 `public` 表、视图、序列和 RPC 默认无权限,后续新建对象也不会自动获得 Data API 权限。当前 Taro 只用 Supabase Auth,业务数据统一走 `apps/api`。如未来需要前端直连,必须在独立 migration 中逐对象写明 policy、`TO`、命令类型和最小 grant,并补同租户垂直越权测试。 + ## P0:正式云端测试前必须完成 1. 正式用户鉴权 @@ -64,6 +68,19 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 - 已支持平台管理员 Supabase JWT,且以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。 - 平台管理员已支持 `platform_users.platform_permissions` 细粒度权限,`{"*":true}` 为超级管理员;接口按 `platform:staff:*`、`platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验。 - 平台员工管理已落到 `GET/PUT/PATCH /api/platform-admin/staff` 和 Taro 平台员工页;员工必须绑定 Supabase Auth 用户 ID,`platform_users.status='disabled'` 后不能再通过 Supabase JWT 映射为平台管理员,禁用时也会默认撤销迁移期 session。 + - 首个超级管理员只能通过服务器侧 CLI 绑定一个已经存在的 Supabase Auth UUID,不能提供公开“创建首个超管”接口。先在 Auth 控制台或受控后台创建/确认账号,再在生产运维终端执行 dry-run: + ```bash + DATABASE_URL='' \ + BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID='' \ + BOOTSTRAP_PLATFORM_ADMIN_USERNAME='' \ + BOOTSTRAP_PLATFORM_ADMIN_NAME='' \ + npm run bootstrap:platform-admin + ``` + - 审核 dry-run 的脱敏结果后,才允许在同一受控终端执行: + ```bash + npm run bootstrap:platform-admin -- --apply --confirm BOOTSTRAP_FIRST_PLATFORM_ADMIN + ``` + - CLI 使用事务级 advisory lock;已有 active 且已绑定 Auth 的平台管理员后永久拒绝再次引导。迁移库只允许绑定唯一一条未绑定的历史 `platform_admin`,多个候选会拒绝并要求人工消歧。成功后固定写入 `status='active'`、`platform_permissions={"*":true}` 和脱敏审计事件。 - 生产前继续补平台后台关键操作审计报表。 4. 生产配置 fail-fast @@ -76,7 +93,8 @@ 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`。 + - 上云前必须运行 `npm run readiness:production`;连接生产数据库后再运行 `npm run readiness:production:db`,并确认 `db.environment.destructive_tests_disabled` 通过。 + - 数据库门禁会阻断 active 租户 `public_config` 中非空但不是生产 HTTPS 的 `*Url/*Uri` 字段,并对尚未发布租户主题的 active 租户给出 warning;允许继续使用平台默认主题,但必须在上线审批中确认品牌表现。 5. 请求体大小限制 - 普通 JSON API 必须有默认上限。 @@ -95,8 +113,11 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 - API SQL 必须显式带 `tenant_id`。 - 测试必须覆盖跨租户读取、写入、下载、后台权限越权。 - `npm run readiness:production:db` 会阻断带 `tenant_id` 但未启用 RLS、没有 policy、或 public policy 未包含 `app.current_tenant_id()` 的表。 - - `npm run test:rls` 会在本地 smoke seed 后模拟 Supabase `authenticated/anon/platform_admin` JWT claims,动态验证主租户和合作商租户代表性表不会跨租户读写泄露,并验证无 `tenant_id` claim 不能读取租户数据。 - - `test:rls` 为了模拟 PostgREST 角色会在事务内临时授予 `authenticated/anon` 查询探针权限,所有 grant、写入探针和跨租户插入都会回滚;它验证的是 RLS policy 行为,不代表生产要开放核心业务表直连。 + - 同一门禁还会阻断 `anon/authenticated` 对 `public` 表/视图/序列/RPC 的直接权限、危险的默认 ACL、`platform_users` 客户端写 policy,以及仅信任陈旧 JWT `platform_admin` claim 的 RLS 旁路。 + - `npm run test:data-api:security` 在不启动数据库时检查 deny-by-default migration、readiness 门禁和 Taro 不直连业务表的源码合同。 + - `npm run test:rls` 会先提交破坏性 smoke seed,再模拟 Supabase `authenticated/anon/platform_admin` JWT claims;只允许连接 `app_private.environment_safety` 标记为 `local/test/ci` 且显式放行的隔离库。 + - `test:rls` 为了模拟 PostgREST 角色会在事务内临时授予 `authenticated/anon` 查询探针权限,该事务内的 grant、写入探针和跨租户插入会回滚;但前置 seed 不会回滚,所以禁止连接生产或预发。生产上线的动态 RLS 证据必须来自生产 schema/脱敏快照克隆库。 + - 禁止在 `source /etc/tiku-saas/api.env` 后运行 `test:rls`、`test:api` 或 `test:worker:*`;真实生产库只运行只读 readiness 或专门设计的无 seed 远程探针。 - 新增租户表时必须同时提交 migration、RLS policy、API 权限测试或明确说明只允许平台级访问的原因。 ## 前端必须遵守 @@ -109,6 +130,7 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 - 不允许“切换销售归属”这类破坏首绑保护的入口,除非后端提供带权限的管理接口。 - 不在前端直接判断“这个用户能不能看某题/某视频/某资料”的最终结果;必须请求后端。 - 切换租户、退出登录、登录新账号时,清理旧租户缓存和用户缓存。 +- Taro 会话按 portal、域名或 tenantCode、tenantId 隔离,业务缓存再增加 userId;显式退出、确认失效、租户切换和账号切换会删除旧用户数据前缀。H5 通过跨标签事件同步身份变化,页面存储句柄固定 tenantId/userId,避免另一个标签切号后写入新账号空间。Supabase 与短信 app session 具有明确当前来源,不允许某一来源失效后静默回退到上一账号。 ## 后端接口约定 @@ -224,10 +246,12 @@ GET /api/platform-admin/permissions - `npm run audit:runtime` 为 0 high/critical 漏洞;Taro 构建工具链 audit 单独跟踪,不能用破坏性降级绕过。 - `npm run check:refactor` 通过。 +- `npm run test:auth:foundation` 通过,覆盖标准 Supabase JWT 的平台身份映射、恶意 JWT 角色不提权和首个超管 CLI 的 dry-run/拒绝/审计契约。 - `npm run smoke:auth:remote` 在预生产/生产 API 上通过,并使用真实 Supabase Auth access token 覆盖学生、租户管理员、平台管理员、坏 token 和错租户上下文。 -- `npm run test:rls` 通过;必须确认主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝都有运行时证据。 +- 在生产 schema/脱敏快照的隔离克隆库上 `npm run test:rls` 通过;必须确认主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝都有运行时证据。 - `npm run readiness:production` 没有 blocker。 - `npm run readiness:production:db` 没有 blocker,尤其是 `db.rls.tenant_tables_enabled`、`db.rls.tenant_tables_policy`、`db.rls.public_tenant_context` 必须通过。 +- `db.platform_admin_active`、`db.platform_admin_auth_binding`、`db.platform_admin_permissions` 必须通过;`db.tenant_public_urls` 必须通过,`db.tenant_theme_published` warning 必须有上线审批结论。 - 生产环境启动时默认密钥 fail-fast 生效。 - 跨租户学生读取题目/订单/资料返回拒绝。 - 销售只能查看自己权限范围内客资。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 6d7a689c..f79145f6 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -1,6 +1,17 @@ # 后续开发 TODO -更新时间:2026-07-01 +更新时间:2026-07-12 + +## 当前优先级(2026-07-12) + +后端地基不再以“继续补齐所有可能功能”为主线。当前优先级调整为: + +1. 按 `production-foundation-baseline-20260712.md` 冻结现有 API/状态机兼容边界。 +2. 启动 Taro 正式前端重构,先做设计 token、跨端基础组件、信息架构、权限驱动导航、拆包和性能预算,再按学生端、租户后台、平台后台推进垂直切片。 +3. 同步准备目标生产环境的真实上线证据:备份恢复、Auth/provider/storage、首个平台超管、systemd、数据迁移、目标规格压测、runtime config 和 launch gate。 +4. 只有前端发现确定的契约缺口、生产 readiness 暴露阻断或监控数据证明需要时,才继续修改后端;所有破坏性变更必须走兼容迁移和 contract test。 + +本文件后续条目是历史能力清单和增强 backlog,不代表前端启动前都必须完成。 ## 当前后端基线 @@ -267,7 +278,7 @@ 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、男女预设头像选择、收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、小程序支付容器、分享场景和状态管理。前端不得实现头像上传、头像裁剪或第三方头像同步。 3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 -5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`,先执行 `npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist` 做本地发布目录烟测、真实浏览器交互烟测和发布守卫;写入生产上线证据时必须执行 `npm --silent run smoke:taro:h5 -- --json`、`npm --silent run smoke:taro:h5:interaction -- --json` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json`,确保 warning 为 0。当前 H5 交互烟测为 32/32 通过,已经覆盖租户后台和平台后台真实写操作;随后仍要人工打开学生端/租户后台/平台后台域名,确认入口、租户解析、登录态和 `Authorization + x-tenant-id` 请求正常。当前构建仍有 webpack 体积 warning,后续做首屏拆包、按入口拆页面和 Supabase client 引入优化。 +5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`,先执行 `npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist` 做本地发布目录烟测、真实浏览器交互烟测和发布守卫;写入生产上线证据时必须执行 `npm --silent run smoke:taro:h5 -- --json`、`npm --silent run smoke:taro:h5:interaction -- --json` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json`,确保 warning 为 0。当前 H5 交互烟测为 33/33 通过,已经覆盖租户后台和平台后台真实写操作;随后仍要人工打开学生端/租户后台/平台后台域名,确认入口、租户解析、登录态和 `Authorization + x-tenant-id` 请求正常。当前学生端入口约 500 KiB,正式视觉重构先做首屏拆包、按入口延迟加载和 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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/performance-benchmark-runbook.md b/docs/refactor/performance-benchmark-runbook.md index 1a2fc83c..a4017c80 100644 --- a/docs/refactor/performance-benchmark-runbook.md +++ b/docs/refactor/performance-benchmark-runbook.md @@ -73,7 +73,7 @@ npm run perf:api:local ```powershell $env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" -npm run db:smoke-seed +npm run db:smoke-seed -- --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY npm run perf:api:local ``` diff --git a/docs/refactor/production-foundation-baseline-20260712.md b/docs/refactor/production-foundation-baseline-20260712.md new file mode 100644 index 00000000..773cb41f --- /dev/null +++ b/docs/refactor/production-foundation-baseline-20260712.md @@ -0,0 +1,84 @@ +# SaaS 题库生产地基基线(2026-07-12) + +## 决策 + +当前仓库的后端代码、数据库迁移、租户隔离和三类角色业务契约可以进入受控冻结,前端可以在现有 `apps/taro` 上开始正式重构。 + +这不等于已经可以直接切生产流量。正式上线仍以目标云服务器上的真实配置、真实 provider、真实数据迁移和 `production-launch-evidence.json` 全量通过为准。任何本地 mock、clean-room 或预览构建都不能替代生产证据。 + +## 已验证的地基 + +- 官方 Supabase PostgreSQL 15.8 空库中,特权 bootstrap 后由非 superuser migration role 成功应用全部 78 个 migration;未执行 seed,后期安全迁移重放幂等,官方 schema lint 通过。详见 `clean-room-migration-audit-20260712.md`。 +- 140 张 `public` 表全部启用 RLS;134 张包含 `tenant_id` 的业务表全部启用 RLS。`anon/authenticated` 无 `public` RPC 或扩展函数执行权。 +- `tiku_api/tiku_worker` 是独立最小权限运行角色;均不能直接读取 `auth.users`,只有 API 可通过 `app.auth_user_exists(uuid)` 获得布尔存在性。 +- Data API、动态 CORS、短信限流、审计容量索引、导入 Worker lease/fencing、租户外键与关键查询索引已经进入 readiness 门禁。 +- 单租户 100,000 学生容量证据通过:首屏查询 P95 `11.245ms`,深游标 P95 `3.114ms`,关键 keyset/trigram 索引被使用并完成清理。 +- `npm run test:readiness`、API TypeScript 检查、后端生产依赖审计和仓库自带安全扫描通过;仓库扫描结果为 0 findings。 +- API Docker 运行镜像已锁定 Node `20.20.2`/Alpine `3.23` 多架构摘要,以 `node` 用户运行,只含生产依赖和编译产物;本机验证镜像约 `53 MB`,`node_modules` 约 `24.3 MB`,不含 `typescript/tsx`,并连接 `55432` 隔离库通过 `/health`。 +- Taro 固定稳定版 `4.2.0`;两项 H5 运行时补丁由 workspace postinstall 原子应用并按源码 hash fail closed。仓库外干净 `npm ci`、供应链门禁和补丁契约均通过。 +- 学生端、租户后台、平台后台三套 H5 production 构建、静态烟测 `25/25` 和真实 Chrome 业务交互烟测 `33/33` 通过;Input 挂载前后值同步和 Button loading 200 次稳定节点探针通过。 +- 三端桌面 `1440x900` 与移动 `390x844` 浏览器验收通过,无控制台 error/warn、无页面级横向溢出,并验证每个门户至少一个主入口。详见 `taro-h5-browser-qa-20260712.md`。 + +## API 冻结边界 + +前端重构默认只能消费现有契约,以下内容从本基线起视为兼容性边界: + +- 路由、HTTP method、状态码、错误 `code` 和分页 cursor 语义。 +- `GET /api/tenant/resolve` 的 Origin/tenantCode 解析规则和公开配置字段。 +- Supabase access token、迁移期 `tk_` session、`Authorization` 与 `x-tenant-id` 的统一 client 行为。 +- 学生、租户成员、平台员工三类身份和权限目录;页面可隐藏入口,但后端仍是最终授权源。 +- 练习 session、导入 job、导出 job、支付/退款、CRM、账单和 Worker 状态机。 +- 私有资料、图片、PDF、视频的短签名、水印和访问审计结果。 + +需要修改上述契约时,必须同时更新后端注册表、Taro service、类型、route/API/persona contract test、交接文档和版本说明。破坏性字段变更应采用新增字段、双读/双写或版本化接口,不允许直接让已有三端同时失效。 + +当前冻结属于受控兼容基线,不是覆盖全部请求/响应字段的完整 OpenAPI v1 冻结。仓库已机器锁定 method/path、统一 `meta.requestId`、错误 `code`、租户解析、认证头、十万学生 keyset 分页和订单/退款/导入/CRM/佣金等核心状态值;开发新垂直切片时,还必须为本切片的请求字段、响应字段、HTTP 状态和业务错误码补 contract snapshot,直到这些 snapshot 汇总为完整 schema。 + +页面不得直接写 Supabase 业务表,不得自行拼接对象存储 URL,也不得在页面层手写身份头或长期保存另一套租户/会话状态。 + +## 多端产品边界 + +- 学生端:继续使用 Taro,共享 H5、微信小程序和后续 App 的页面、services、capabilities 与领域类型。 +- 租户后台、平台后台:首发只做响应式 H5。批量导入、复杂表格、财务和运营工作流不强行迁入小程序。 +- 微信小程序:只发布学生端。数百租户共用包时使用 launch tenant mode,由受控 scene/query 解析租户;缺租户码必须 fail closed。 +- 后续 App:优先复用学生端业务组件和 API client,支付、文件、音视频、推送、分享等能力通过 capabilities 层逐项替换并真机验收。 + +## 前端启动门禁 + +前端可以开始,但第一阶段必须先完成以下工程约束,再批量做页面视觉: + +1. 冻结三类 persona 的信息架构、导航和权限矩阵,不删现有业务入口。 +2. 建立跨端设计 token、基础组件和状态规范;租户品牌只能覆盖已批准 token,不允许注入任意 CSS。 +3. 保持 `api.ts`、全局 App Provider、租户解析和缓存隔离为唯一基础设施。 +4. 每个垂直切片同时完成桌面 H5、移动 H5和学生小程序兼容检查;后台无需小程序化。 +5. 把包体和首屏性能设为硬门禁。当前 production 入口约为学生端 `501 KiB`、租户后台 `477 KiB`、平台后台 `450 KiB`;重构前应先规划路由拆包、延迟加载和资源预算,避免继续扩大首包。 +6. 每次关键页面改动运行 Taro 类型检查、route/API/persona/compatibility contract、Taro 供应链审计、视觉守卫、三套构建、学生小程序生产构建、静态烟测和交互烟测。 + +## 生产硬阻断 + +以下项目只能在目标生产环境完成,未完成前 `launch:gate` 应继续阻断: + +- 立即撤销曾在对话中暴露的临时 Git 令牌,并重新生成最小权限凭据。 +- 由数据库 superuser 执行 runtime role/extension bootstrap,再由标准 migration role 应用迁移;API/Worker 使用独立强密码运行角色。 +- 配置真实 Supabase Auth/JWKS、阿里云 PNVS、微信/QQ OAuth、微信/支付宝支付和回调域名。 +- 配置真实对象存储、外部 AV/内容安全扫描、CDN 边界、水印和生命周期策略。 +- 在 Supabase Auth 创建或确认首个管理员身份后,通过 `npm run bootstrap:platform-admin` dry-run 和精确确认短语创建唯一首个平台超管,再运行数据库 readiness。 +- 在目标 Linux 主机验证 API、Worker target、job service、timer、日志、告警和重启恢复。 +- 生成生产备份/快照并完成至少一次隔离恢复演练;保留旧 PocketBase 只读快照和回滚步骤。 +- 用真实完整数据执行 PocketBase production dry-run、导入校验和用户/题目/订单/资源抽样。 +- 在目标 4C16G 配置收集 PostgreSQL tuning evidence、真实数据 API 读/混合压测和 100k 学生证据。 +- 为三套 H5 放置仅含公开值的真实 `runtime-config.json`,生成 release manifest,并校验线上 index/app hash。 +- 保持 `swiper@12.1.2`、`lodash-es@4.18.1` 和两个 Taro H5 runtime patch 的精确版本/hash,运行 `npm run audit:taro:supply-chain`;剩余 Taro CLI/构建工具链漏洞必须与已审查 allowlist 一致,不得扩展到 H5/小程序 bundle 运行路径。 +- 完成真实 Auth、短信、CORS、三类 persona、支付/退款对账和对象存储抽样。 +- 完成真实 Codex Security 扫描。仓库自带扫描不能替代该项,工具不可用时只能保持待补。 +- 填写所有 artifact hash 和人工 attestations,最后运行 `npm run launch:gate -- --evidence ... --verify-live-h5`。 + +## 上线顺序 + +1. 撤销暴露凭据并冻结候选 commit。 +2. 备份、bootstrap、migration、运行角色和数据库 readiness。 +3. 配置 Auth/provider/storage,创建首个超管,验证 systemd/Worker。 +4. 执行真实数据迁移演练、抽样、目标规格压测和远程安全烟测。 +5. 生成三套 production H5、注入真实公开 runtime config、发布候选目录并生成 hash manifest。 +6. 填写生产证据和人工签字,通过离线 gate 后灰度发布。 +7. 对线上 H5 执行 hash/runtime config 校验,再逐步放量并观察错误率、P95、数据库连接、锁等待和 Worker backlog。 diff --git a/docs/refactor/production-launch-evidence.template.json b/docs/refactor/production-launch-evidence.template.json index d0537fcc..c5fd7f29 100644 --- a/docs/refactor/production-launch-evidence.template.json +++ b/docs/refactor/production-launch-evidence.template.json @@ -1,12 +1,20 @@ { "schemaVersion": 1, "environment": "production", + "releaseTargets": [ + "h5" + ], + "artifactIntegrityNote": "Every check must record artifactSha256. Generate it with sha256sum or shasum -a 256; launch gate rejects missing, empty, or hash-mismatched artifacts. The H5 release manifest also records full-directory treeSha256 values, which deployment verifies against the candidate release.", "commit": "replace-with-deployed-git-sha", "target": { - "apiBaseUrl": "https://api.example.com", - "studentH5Url": "https://www.example.com", - "tenantAdminH5Url": "https://admin.example.com", - "platformAdminH5Url": "https://console.example.com" + "apiBaseUrl": "replace-with-real-production-api-https-url", + "studentH5Url": "replace-with-real-production-student-h5-https-url", + "tenantAdminH5Url": "replace-with-real-production-tenant-admin-h5-https-url", + "platformAdminH5Url": "replace-with-real-production-platform-admin-h5-https-url" + }, + "liveH5": { + "releaseManifestArtifact": "launch-artifacts/taro-h5-release-manifest.json", + "releaseManifestSha256": "replace-with-64-char-sha256" }, "checks": [ { @@ -15,6 +23,7 @@ "command": "npm run readiness:production -- --json > docs/refactor/launch-artifacts/readiness-production.json", "completedAt": "2026-06-30T10:00:00+08:00", "artifact": "launch-artifacts/readiness-production.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "blocker": 0 } @@ -25,16 +34,35 @@ "command": "npm run readiness:production:db -- --json > docs/refactor/launch-artifacts/readiness-production-db.json", "completedAt": "2026-06-30T10:05:00+08:00", "artifact": "launch-artifacts/readiness-production-db.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "blocker": 0 } }, + { + "id": "db.migration-history", + "status": "pass", + "command": "node -e \"const fs=require('fs'),crypto=require('crypto');const source='docs/refactor/launch-artifacts/readiness-production-db.json';const bytes=fs.readFileSync(source);const readiness=JSON.parse(bytes);const check=readiness.checks.find(item=>item.id==='db.migrations.current'&&item.status==='pass');if(!check)throw new Error('readiness db.migrations.current pass evidence is missing');const d=check.details||{};const payload={schemaVersion:1,status:'pass',failed:0,latestRepositoryMigration:String(d.expectedVersion||''),latestAppliedMigration:String(d.latestAppliedVersion||''),missingMigrations:d.expectedVersionApplied===true?[]:[String(d.expectedVersion||'')],readinessArtifactSha256:crypto.createHash('sha256').update(bytes).digest('hex')};fs.writeFileSync('docs/refactor/launch-artifacts/db-migration-history.json',JSON.stringify(payload,null,2)+'\\n')\"", + "completedAt": "2026-06-30T10:06:00+08:00", + "artifact": "launch-artifacts/db-migration-history.json", + "artifactSha256": "replace-with-64-char-sha256", + "summary": { + "schemaVersion": 1, + "status": "pass", + "failed": 0, + "latestRepositoryMigration": "replace-with-repository-latest-migration-version", + "latestAppliedMigration": "replace-with-production-latest-applied-migration-version", + "missingMigrations": [], + "readinessArtifactSha256": "replace-with-64-char-sha256" + } + }, { "id": "postgres.tuning-evidence", "status": "pass", "command": "PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json > docs/refactor/launch-artifacts/postgres-tuning-evidence.json", "completedAt": "2026-06-30T10:08:00+08:00", "artifact": "launch-artifacts/postgres-tuning-evidence.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "status": "pass", "profile": "shared-host", @@ -46,20 +74,46 @@ { "id": "auth.remote-smoke", "status": "pass", - "command": "AUTH_SMOKE_REQUIRE_ADMIN_TOKENS=true npm run smoke:auth:remote > docs/refactor/launch-artifacts/auth-remote-smoke.log", + "command": "AUTH_SMOKE_REQUIRE_ADMIN_TOKENS=true npm run smoke:auth:remote > docs/refactor/launch-artifacts/auth-remote-smoke.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:auth.remote-smoke' >> docs/refactor/launch-artifacts/auth-remote-smoke.log", "completedAt": "2026-06-30T10:10:00+08:00", "artifact": "launch-artifacts/auth-remote-smoke.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0, "requireAdminTokens": true } }, + { + "id": "auth.platform-admin-bootstrap", + "status": "pass", + "command": "Run npm run bootstrap:platform-admin dry-run, then npm run bootstrap:platform-admin -- --apply --confirm BOOTSTRAP_FIRST_PLATFORM_ADMIN, verify the platform.admin.bootstrapped audit event, and run npm run smoke:auth:remote with AUTH_SMOKE_EXPECTED_PLATFORM_ADMIN_USER_ID set to the same Auth identity; save the three raw artifacts and generate this redacted summary with SHA-256 identity bindings", + "completedAt": "2026-06-30T10:10:30+08:00", + "artifact": "launch-artifacts/platform-admin-bootstrap.json", + "artifactSha256": "replace-with-64-char-sha256", + "summary": { + "schemaVersion": 1, + "status": "pass", + "failed": 0, + "dryRunVerified": true, + "applied": true, + "adminUserIdSha256": "replace-with-sha256-of-bootstrap-auth-user-uuid", + "authSmokeExpectedUserIdSha256": "replace-with-same-sha256", + "identityMatches": true, + "auditEvent": "platform.admin.bootstrapped", + "auditVerified": true, + "dryRunArtifactSha256": "replace-with-64-char-sha256", + "applyArtifactSha256": "replace-with-64-char-sha256", + "authSmokeArtifactSha256": "replace-with-64-char-sha256", + "auditArtifactSha256": "replace-with-64-char-sha256" + } + }, { "id": "auth.sms-pnvs-diagnostics", "status": "pass", "command": "PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run diagnose:aliyun-pnvs > docs/refactor/launch-artifacts/sms-pnvs-diagnostics.json", "completedAt": "2026-06-30T10:11:00+08:00", "artifact": "launch-artifacts/sms-pnvs-diagnostics.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "ok": true, "env": { @@ -86,6 +140,7 @@ "command": "SMS_SMOKE_API_BASE_URL=https://api.example.com SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 SMS_SMOKE_PHONE=replace-with-real-phone SMS_SMOKE_ORIGIN=https://admin.example.com npm run smoke:sms-login:remote -- --write docs/refactor/launch-artifacts/sms-pnvs-remote-smoke.json", "completedAt": "2026-06-30T10:12:00+08:00", "artifact": "launch-artifacts/sms-pnvs-remote-smoke.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0, "provider": "aliyun-pnvs", @@ -96,11 +151,38 @@ { "id": "rls.tenant-isolation", "status": "pass", - "command": "npm run test:rls > docs/refactor/launch-artifacts/rls-tenant-isolation.log", + "command": "DATABASE_URL= npm run test:rls > docs/refactor/launch-artifacts/rls-tenant-isolation.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:rls.tenant-isolation' >> docs/refactor/launch-artifacts/rls-tenant-isolation.log", "completedAt": "2026-06-30T10:20:00+08:00", "artifact": "launch-artifacts/rls-tenant-isolation.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { - "failed": 0 + "failed": 0, + "databaseEnvironment": "ci", + "databaseSource": "isolated-production-schema-or-sanitized-snapshot-clone" + } + }, + { + "id": "db.tenant-foreign-key-audit", + "status": "pass", + "command": "DATABASE_URL= npm run audit:tenant-foreign-keys -- --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY --write=docs/refactor/launch-artifacts/tenant-foreign-key-audit.json", + "completedAt": "2026-06-30T10:25:00+08:00", + "artifact": "launch-artifacts/tenant-foreign-key-audit.json", + "artifactSha256": "replace-with-64-char-sha256", + "summary": { + "status": "pass", + "kind": "tenant-foreign-key-audit", + "safety": { + "databaseEnvironment": "ci" + }, + "schema": { + "schemaMatches": true, + "relationCount": 189, + "exceptionCount": 3 + }, + "data": { + "auditedRelations": 189, + "invalidRelations": 0 + } } }, { @@ -109,6 +191,7 @@ "command": "npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings > docs/refactor/launch-artifacts/pb-production-dry-run.json", "completedAt": "2026-06-30T10:30:00+08:00", "artifact": "launch-artifacts/pb-production-dry-run.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "blocker": 0, "warning": 0, @@ -120,9 +203,10 @@ { "id": "migration.pb-import-validate", "status": "pass", - "command": "FAIL_ON_WARNINGS=true npm run pb:import:validate > docs/refactor/launch-artifacts/pb-import-validate.log", + "command": "FAIL_ON_WARNINGS=true npm run pb:import:validate > docs/refactor/launch-artifacts/pb-import-validate.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:migration.pb-import-validate' >> docs/refactor/launch-artifacts/pb-import-validate.log", "completedAt": "2026-06-30T10:40:00+08:00", "artifact": "launch-artifacts/pb-import-validate.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "fail": 0 } @@ -130,9 +214,10 @@ { "id": "migration.pb-import-sample", "status": "pass", - "command": "PB_SAMPLE_WRITE_REPORT=true npm run pb:import:sample > docs/refactor/launch-artifacts/pb-import-sample.log", + "command": "PB_SAMPLE_WRITE_REPORT=true npm run pb:import:sample > docs/refactor/launch-artifacts/pb-import-sample.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:migration.pb-import-sample' >> docs/refactor/launch-artifacts/pb-import-sample.log", "completedAt": "2026-06-30T10:45:00+08:00", "artifact": "launch-artifacts/pb-import-sample.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "fail": 0, "warn": 0, @@ -143,9 +228,10 @@ { "id": "performance.api-real-data-read", "status": "pass", - "command": "PERF_START_SERVER=false PERF_API_BASE=https://api.example.com PERF_DURATION_SECONDS=300 PERF_CONCURRENCY=30 PERF_RAMP_SECONDS=30 PERF_INCLUDE_WRITES=false npm run perf:api:local > docs/refactor/launch-artifacts/api-real-data-read-benchmark.log", + "command": "PERF_START_SERVER=false PERF_API_BASE=https://api.example.com PERF_DURATION_SECONDS=300 PERF_CONCURRENCY=30 PERF_RAMP_SECONDS=30 PERF_INCLUDE_WRITES=false npm run perf:api:local > docs/refactor/launch-artifacts/api-real-data-read-benchmark.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:performance.api-real-data-read' >> docs/refactor/launch-artifacts/api-real-data-read-benchmark.log", "completedAt": "2026-06-30T10:48:00+08:00", "artifact": "launch-artifacts/api-real-data-read-benchmark.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "errors": 0, "errorRate": 0, @@ -159,9 +245,10 @@ { "id": "performance.api-real-data-mixed", "status": "pass", - "command": "PERF_START_SERVER=false PERF_API_BASE=https://api.example.com PERF_DURATION_SECONDS=120 PERF_CONCURRENCY=50 PERF_RAMP_SECONDS=15 PERF_INCLUDE_WRITES=true PERF_PRACTICE_FLOW_RATIO=0.1 PERF_AUTH_MODE=app_session npm run perf:api:local > docs/refactor/launch-artifacts/api-real-data-mixed-benchmark.log", + "command": "PERF_START_SERVER=false PERF_API_BASE=https://api.example.com PERF_DURATION_SECONDS=120 PERF_CONCURRENCY=50 PERF_RAMP_SECONDS=15 PERF_INCLUDE_WRITES=true PERF_PRACTICE_FLOW_RATIO=0.1 PERF_AUTH_MODE=app_session npm run perf:api:local > docs/refactor/launch-artifacts/api-real-data-mixed-benchmark.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:performance.api-real-data-mixed' >> docs/refactor/launch-artifacts/api-real-data-mixed-benchmark.log", "completedAt": "2026-06-30T10:49:00+08:00", "artifact": "launch-artifacts/api-real-data-mixed-benchmark.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "errors": 0, "errorRate": 0, @@ -172,12 +259,88 @@ "includeWrites": true } }, + { + "id": "performance.tenant-students-100k", + "status": "pass", + "command": "output_dir=$(mktemp -d) && DATABASE_URL= npm run perf:tenant-students:evidence -- --count=100000 --batch-size=10000 --iterations=20 --warmup-iterations=3 --output-dir=\"$output_dir\" --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY && cp \"$output_dir\"/tenant-student-capacity-*.json docs/refactor/launch-artifacts/tenant-student-capacity-100k.json", + "completedAt": "2026-06-30T10:49:10+08:00", + "artifact": "launch-artifacts/tenant-student-capacity-100k.json", + "artifactSha256": "replace-with-64-char-sha256", + "summary": { + "databaseEnvironment": "test", + "fixture": { + "platformUsers": 100000, + "tenantMemberships": 100000, + "studentProfiles": 100000 + }, + "caseCount": 5, + "deepCursorApproximateOffset": 90000, + "firstPageP95Ms": 0, + "deepCursorP95Ms": 0, + "searchP95MaxMs": 0, + "keysetIndexUsed": true, + "trigramIndexUsed": true, + "cleanupVerified": true, + "remainingManagedUsers": 0 + } + }, + { + "id": "api.dynamic-tenant-cors-smoke", + "status": "pass", + "command": "TENANT_CORS_API_BASE_URL=https://api.example.com TENANT_CORS_ACTIVE_ORIGIN=https://active-tenant.example.com TENANT_CORS_DISABLED_ORIGIN=https://disabled-tenant.example.com TENANT_CORS_UNKNOWN_ORIGIN=https://unknown-tenant.example.com npm run smoke:tenant-cors:remote -- --write docs/refactor/launch-artifacts/tenant-cors-remote-smoke.json", + "completedAt": "2026-06-30T10:49:15+08:00", + "artifact": "launch-artifacts/tenant-cors-remote-smoke.json", + "artifactSha256": "replace-with-64-char-sha256", + "summary": { + "failed": 0, + "activeTenantOriginAllowed": true, + "unknownOriginDenied": true, + "disabledOriginDenied": true, + "noOriginHealthAllowed": true + } + }, + { + "id": "deploy.linux-systemd-verify", + "status": "pass", + "command": "(systemd-analyze verify /etc/systemd/system/tiku-api.service /etc/systemd/system/tiku-worker@.service /etc/systemd/system/tiku-worker-job@.service /etc/systemd/system/tiku-worker-*.service /etc/systemd/system/tiku-worker-*.timer && systemctl is-active tiku-api.service tiku-workers.target && systemctl list-timers 'tiku-worker-*' --no-legend && echo 'TIKU_LAUNCH_GATE_SUCCESS:deploy.linux-systemd-verify') > docs/refactor/launch-artifacts/linux-systemd-verify.log", + "completedAt": "2026-06-30T10:49:20+08:00", + "artifact": "launch-artifacts/linux-systemd-verify.log", + "artifactSha256": "replace-with-64-char-sha256", + "summary": { + "failed": 0, + "apiServiceActive": true, + "workerTargetActive": true, + "workerJobServices": 9, + "enabledTimers": 6 + } + }, + { + "id": "backup.restore-drill", + "status": "pass", + "command": "RESTORE_DRILL_VERIFY_COMMAND= && eval \"$RESTORE_DRILL_VERIFY_COMMAND\" > docs/refactor/launch-artifacts/backup-restore-verification.log && node -e \"const fs=require('fs'),crypto=require('crypto');const required=name=>{const value=String(process.env[name]||'').trim();if(!value)throw new Error(name+' is required');return value};const number=name=>{const value=Number(required(name));if(!Number.isFinite(value)||value<0)throw new Error(name+' must be non-negative');return value};const verification=fs.readFileSync('docs/refactor/launch-artifacts/backup-restore-verification.log');const payload={schemaVersion:1,status:'pass',failed:0,snapshotId:required('RESTORE_DRILL_SNAPSHOT_ID'),restoreTarget:required('RESTORE_DRILL_TARGET'),isolated:true,integrityVerified:true,rtoMinutes:number('RESTORE_DRILL_RTO_MINUTES'),rpoMinutes:number('RESTORE_DRILL_RPO_MINUTES'),verificationArtifactSha256:crypto.createHash('sha256').update(verification).digest('hex')};fs.writeFileSync('docs/refactor/launch-artifacts/backup-restore-drill.json',JSON.stringify(payload,null,2)+'\\n')\"", + "completedAt": "2026-06-30T10:49:25+08:00", + "artifact": "launch-artifacts/backup-restore-drill.json", + "artifactSha256": "replace-with-64-char-sha256", + "summary": { + "schemaVersion": 1, + "status": "pass", + "failed": 0, + "snapshotId": "replace-with-provider-snapshot-or-backup-id", + "restoreTarget": "replace-with-isolated-restore-target", + "isolated": true, + "integrityVerified": true, + "rtoMinutes": 0, + "rpoMinutes": 0, + "verificationArtifactSha256": "replace-with-64-char-sha256" + } + }, { "id": "api.launch-persona-smoke", "status": "pass", "command": "LAUNCH_SMOKE_AUTH_MODE=app_session npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md > docs/refactor/launch-artifacts/launch-persona-smoke.log", "completedAt": "2026-06-30T10:49:30+08:00", "artifact": "launch-artifacts/launch-persona-smoke.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "status": "pass", "authMode": "app_session", @@ -221,9 +384,10 @@ { "id": "api.integration", "status": "pass", - "command": "npm run test:api > docs/refactor/launch-artifacts/api-integration.log", + "command": "npm run test:api > docs/refactor/launch-artifacts/api-integration.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:api.integration' >> docs/refactor/launch-artifacts/api-integration.log", "completedAt": "2026-06-30T10:50:00+08:00", "artifact": "launch-artifacts/api-integration.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -231,9 +395,10 @@ { "id": "worker.assets", "status": "pass", - "command": "npm run test:worker:assets > docs/refactor/launch-artifacts/worker-assets.log", + "command": "npm run test:worker:assets > docs/refactor/launch-artifacts/worker-assets.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:worker.assets' >> docs/refactor/launch-artifacts/worker-assets.log", "completedAt": "2026-06-30T11:00:00+08:00", "artifact": "launch-artifacts/worker-assets.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -241,9 +406,10 @@ { "id": "worker.commerce", "status": "pass", - "command": "npm run test:worker:commerce > docs/refactor/launch-artifacts/worker-commerce.log", + "command": "npm run test:worker:commerce > docs/refactor/launch-artifacts/worker-commerce.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:worker.commerce' >> docs/refactor/launch-artifacts/worker-commerce.log", "completedAt": "2026-06-30T11:10:00+08:00", "artifact": "launch-artifacts/worker-commerce.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -251,9 +417,10 @@ { "id": "worker.platform-billing", "status": "pass", - "command": "npm run test:worker:platform-billing > docs/refactor/launch-artifacts/worker-platform-billing.log", + "command": "npm run test:worker:platform-billing > docs/refactor/launch-artifacts/worker-platform-billing.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:worker.platform-billing' >> docs/refactor/launch-artifacts/worker-platform-billing.log", "completedAt": "2026-06-30T11:15:00+08:00", "artifact": "launch-artifacts/worker-platform-billing.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -261,9 +428,10 @@ { "id": "worker.platform-dunning", "status": "pass", - "command": "npm run test:worker:platform-dunning > docs/refactor/launch-artifacts/worker-platform-dunning.log", + "command": "npm run test:worker:platform-dunning > docs/refactor/launch-artifacts/worker-platform-dunning.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:worker.platform-dunning' >> docs/refactor/launch-artifacts/worker-platform-dunning.log", "completedAt": "2026-06-30T11:18:00+08:00", "artifact": "launch-artifacts/worker-platform-dunning.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -271,9 +439,10 @@ { "id": "worker.imports", "status": "pass", - "command": "npm run test:worker:imports > docs/refactor/launch-artifacts/worker-imports.log", + "command": "npm run test:worker:imports > docs/refactor/launch-artifacts/worker-imports.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:worker.imports' >> docs/refactor/launch-artifacts/worker-imports.log", "completedAt": "2026-06-30T11:20:00+08:00", "artifact": "launch-artifacts/worker-imports.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -281,9 +450,10 @@ { "id": "worker.public-banks", "status": "pass", - "command": "npm run test:worker:public-banks > docs/refactor/launch-artifacts/worker-public-banks.log", + "command": "npm run test:worker:public-banks > docs/refactor/launch-artifacts/worker-public-banks.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:worker.public-banks' >> docs/refactor/launch-artifacts/worker-public-banks.log", "completedAt": "2026-06-30T11:30:00+08:00", "artifact": "launch-artifacts/worker-public-banks.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -291,19 +461,46 @@ { "id": "taro.check", "status": "pass", - "command": "npm run check:taro > docs/refactor/launch-artifacts/taro-check.log", + "command": "npm run check:taro > docs/refactor/launch-artifacts/taro-check.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:taro.check' >> docs/refactor/launch-artifacts/taro-check.log", "completedAt": "2026-06-30T11:40:00+08:00", "artifact": "launch-artifacts/taro-check.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } }, + { + "id": "taro.supply-chain", + "status": "pass", + "command": "npm --silent run audit:taro:supply-chain -- --json > docs/refactor/launch-artifacts/taro-supply-chain.json", + "completedAt": "2026-06-30T11:45:00+08:00", + "artifact": "launch-artifacts/taro-supply-chain.json", + "artifactSha256": "replace-with-64-char-sha256", + "summary": { + "schemaVersion": 1, + "status": "pass-with-reviewed-toolchain-risk", + "securedBundleDependencies": { + "swiper": "12.1.2", + "lodash-es": "4.18.1" + }, + "audit": { + "counts": { + "critical": 3, + "high": 10 + } + }, + "reviewedInvalidEdgeCount": 4, + "riskBoundaryPresent": true, + "riskControlCount": 4 + } + }, { "id": "taro.build.student", "status": "pass", - "command": "npm run build:taro:h5:student > docs/refactor/launch-artifacts/taro-build-student.log", + "command": "npm run build:taro:h5:student > docs/refactor/launch-artifacts/taro-build-student.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:taro.build.student' >> docs/refactor/launch-artifacts/taro-build-student.log", "completedAt": "2026-06-30T11:50:00+08:00", "artifact": "launch-artifacts/taro-build-student.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -311,9 +508,10 @@ { "id": "taro.build.tenant", "status": "pass", - "command": "npm run build:taro:h5:tenant > docs/refactor/launch-artifacts/taro-build-tenant.log", + "command": "npm run build:taro:h5:tenant > docs/refactor/launch-artifacts/taro-build-tenant.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:taro.build.tenant' >> docs/refactor/launch-artifacts/taro-build-tenant.log", "completedAt": "2026-06-30T12:00:00+08:00", "artifact": "launch-artifacts/taro-build-tenant.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -321,9 +519,10 @@ { "id": "taro.build.platform", "status": "pass", - "command": "npm run build:taro:h5:platform > docs/refactor/launch-artifacts/taro-build-platform.log", + "command": "npm run build:taro:h5:platform > docs/refactor/launch-artifacts/taro-build-platform.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:taro.build.platform' >> docs/refactor/launch-artifacts/taro-build-platform.log", "completedAt": "2026-06-30T12:10:00+08:00", "artifact": "launch-artifacts/taro-build-platform.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "failed": 0 } @@ -334,6 +533,7 @@ "command": "npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json", "completedAt": "2026-06-30T12:12:00+08:00", "artifact": "launch-artifacts/taro-h5-static-smoke.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "fail": 0, "portals": 3, @@ -346,6 +546,7 @@ "command": "npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json", "completedAt": "2026-06-30T12:13:00+08:00", "artifact": "launch-artifacts/taro-h5-interaction-smoke.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "fail": 0, "pass": 32, @@ -372,6 +573,7 @@ "command": "node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json", "completedAt": "2026-06-30T12:15:00+08:00", "artifact": "launch-artifacts/taro-h5-release-guardrails.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "fail": 0, "warn": 0 @@ -383,20 +585,23 @@ "command": "npm --silent run manifest:taro:h5 -- --require-dist --require-runtime-config --json --write docs/refactor/launch-artifacts/taro-h5-release-manifest.json > docs/refactor/launch-artifacts/taro-h5-release-manifest.stdout.json", "completedAt": "2026-06-30T12:16:00+08:00", "artifact": "launch-artifacts/taro-h5-release-manifest.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "fail": 0, "warn": 0, "portals": 3, "distReady": 3, - "runtimeConfigs": 3 + "runtimeConfigs": 3, + "treeHashes": 3 } }, { "id": "audit.runtime", "status": "pass", - "command": "npm run audit:runtime > docs/refactor/launch-artifacts/audit-runtime.log", + "command": "npm run audit:runtime > docs/refactor/launch-artifacts/audit-runtime.log && echo 'TIKU_LAUNCH_GATE_SUCCESS:audit.runtime' >> docs/refactor/launch-artifacts/audit-runtime.log", "completedAt": "2026-06-30T12:20:00+08:00", "artifact": "launch-artifacts/audit-runtime.log", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "critical": 0, "high": 0 @@ -408,6 +613,7 @@ "command": "npm run security:repo -- --json > docs/refactor/launch-artifacts/repo-security-scan.json", "completedAt": "2026-06-30T12:22:00+08:00", "artifact": "launch-artifacts/repo-security-scan.json", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "critical": 0, "high": 0 @@ -416,9 +622,10 @@ { "id": "security.codex-scan", "status": "replace-with-pass-after-real-scan", - "command": "Run the real @codex-security scan only when the tool is exposed; save findings to docs/refactor/launch-artifacts/codex-security.md", + "command": "Run the real @codex-security scan only when the tool is exposed; save findings to docs/refactor/launch-artifacts/codex-security.md and append a final line TIKU_LAUNCH_GATE_SUCCESS:security.codex-scan only after the scan exits successfully", "completedAt": "replace-with-real-scan-time", "artifact": "launch-artifacts/codex-security.md", + "artifactSha256": "replace-with-64-char-sha256", "summary": { "critical": "replace-with-number", "high": "replace-with-number" diff --git a/docs/refactor/taro-h5-browser-qa-20260712.md b/docs/refactor/taro-h5-browser-qa-20260712.md new file mode 100644 index 00000000..b8885523 --- /dev/null +++ b/docs/refactor/taro-h5-browser-qa-20260712.md @@ -0,0 +1,54 @@ +# Taro H5 浏览器验收记录(2026-07-12) + +## 验收范围 + +- 产物:学生端、租户后台、平台后台 H5 +- 浏览器:Codex 应用内 Chromium 浏览器 +- 视口:桌面 `1440x900`、移动 `390x844` +- 数据:本地 mock API,不连接生产数据库或第三方 provider +- 流程:首屏渲染、控制台、页面溢出、每个门户至少一个主入口交互 + +为了让应用内浏览器直接访问本地 API,视觉验收使用相同代码的 preview 构建。验收结束后必须重新生成 production 构建并重跑静态及交互烟测;preview 产物不能用于发布。 + +## 结果 + +| 门户 | 桌面首屏 | 移动首屏 | 控制台 error/warn | 页面级横向溢出 | 主入口交互 | +| --- | --- | --- | --- | --- | --- | +| 学生端 | 通过 | 通过 | 0 | 0 | `开始刷题` 进入题库页 | +| 租户后台 | 通过 | 通过 | 0 | 0 | `学生运营` 进入学生运营页 | +| 平台后台 | 通过 | 通过 | 0 | 0 | `租户管理` 进入租户管理页 | + +独立 Chrome 业务交互烟测另行覆盖三端完整关键链路,最终结果为 `33/33`。Taro H5 runtime 加固后,Input 探针验证挂载前 `before-mount` 和挂载后 `after-mount` 均正确同步;Button 探针连续切换 loading 200 次,loading DOM 节点保持同一引用,子节点数始终为 `1`,显示状态为 `none -> inline-block -> none`。 + +## 发现与前端重构约束 + +1. 当前界面可用且响应式基础成立,但仍是功能原型,不代表视觉设计已达到正式商用标准。 +2. 移动端无页面级横向滚动;顶部导航采用横向可滚动模式,属于既有设计行为。 +3. 学生端构建入口约 `500 KiB`,且存在多个超过 `244 KiB` 的异步资源。前端重构必须建立路由拆包、资源预算和低端移动网络首屏指标,不能在当前入口继续无约束叠加组件。 +4. 租户后台工作台 DOM 中存在位于横向快捷操作容器可视区之外的按钮,但容器自身裁切且页面宽度未溢出;正式重构时应改为明确的滚动、折叠或响应式操作布局。 + +## 复现 + +```bash +# 应用内浏览器视觉 QA 必须先生成三套 preview 产物。 +npm run build:taro:h5:preview + +# 该命令保持运行并打印三个门户 URL;若检测到 production 产物会直接拒绝启动。 +npm run serve:taro:h5:qa + +# 视觉 QA 结束后必须重新生成 production 产物;preview 产物不得发布。 +npm run build:taro:h5:student +npm run build:taro:h5:tenant +npm run build:taro:h5:platform +npm run smoke:taro:h5 +npm run smoke:taro:h5:interaction +``` + +截图证据保存在本机临时目录 `/tmp`,不进入发布仓库: + +- `tiku-student-desktop-1440x900.png` +- `tiku-student-mobile-390x844.png` +- `tiku-tenant-desktop-1440x900.png` +- `tiku-tenant-mobile-390x844.png` +- `tiku-platform-desktop-1440x900.png` +- `tiku-platform-mobile-390x844.png` diff --git a/docs/refactor/taro-h5-deployment.md b/docs/refactor/taro-h5-deployment.md index 97c6375d..31c8d382 100644 --- a/docs/refactor/taro-h5-deployment.md +++ b/docs/refactor/taro-h5-deployment.md @@ -1,6 +1,6 @@ -# Taro H5 三入口部署说明 +# Taro H5 三入口与学生微信小程序部署说明 -更新时间:2026-07-01 +更新时间:2026-07-11 当前 `apps/taro` 采用一个 Taro 4 React 工程、三套 H5 产物的方式交付: @@ -8,7 +8,7 @@ - 租户后台:品牌、主题、域名、题库、导入、学生、订单、营销、销售、CRM、财务和数据看板。 - 平台后台:租户、SaaS 套餐、订阅账单、公共题库授权和平台审计。 -后续微信小程序仍复用同一套业务 services 和页面逻辑,但 H5 是当前优先上线形态。 +学生微信小程序复用同一套业务 services、权限、主题和页面逻辑;复杂租户后台与平台后台仍优先发布桌面 H5。 ## 构建命令 @@ -16,6 +16,7 @@ npm run build:taro:h5:student npm run build:taro:h5:tenant npm run build:taro:h5:platform +npm run build:taro:weapp:student ``` 输出目录: @@ -24,8 +25,11 @@ npm run build:taro:h5:platform apps/taro/dist/h5-student apps/taro/dist/h5-tenant-admin apps/taro/dist/h5-platform-admin +apps/taro/dist/weapp-student ``` +三套 H5 会按 portal 裁剪实际注册页面;微信小程序主包只保留 `pages/bootstrap/index`,学生页面放入 `pages/student` 分包并启用组件按需注入。每种 target/portal 都有独立输出目录,连续构建不会互相覆盖。 + 注意:Taro H5 入口依赖 `apps/taro/src/index.html` 模板生成 `index.html`。如果构建产物目录里只有 `js/css/assets` 而没有 `index.html`,不要发布;重新构建并运行发布守卫脚本。 推荐部署: @@ -75,7 +79,7 @@ portal student | tenant-admin | platform-admin apiBaseUrl apps/api 公开 HTTPS 地址 supabaseUrl Supabase Auth/API 公开 HTTPS 地址 supabasePublishableKey Supabase publishable/anon key -tenantCode 小程序、预览环境或指定租户部署可用 +tenantCode H5 生产必须为空;仅小程序或本地预览可用 ``` 这些字段是前端公开配置,不是密钥。`apps/taro/src/env.ts` 会拒绝 `runtime-config.json` 中出现服务端密钥类字段,例如: @@ -129,18 +133,28 @@ server { ## CORS 和 Cookie -API 的生产 `CORS_ORIGIN` 必须只包含实际前端域名: +API 的生产 `CORS_ORIGIN` 只维护数量很少、由平台自己管理的中央平台/运维 Origin: ```text -CORS_ORIGIN=https://www.example.com,https://admin.example.com,https://console.example.com +CORS_ORIGIN=https://platform-admin.example.com,https://ops.example.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 ``` +学生端和租户后台的业务 Origin 不展开写入 `CORS_ORIGIN`。API 使用 Origin 的规范化 hostname 查询 `active tenant_domains + active tenants`,并使用有界的 LRU TTL 正/负缓存。未知域名、`pending/failed/disabled` 域名、非 active 租户、非 HTTPS 或使用非默认端口的动态租户 Origin 都返回 `403 CORS_ORIGIN_DENIED`。本地开发端口只能作为完整 Origin 显式写入静态列表。 + +正缓存 TTL 是域名禁用后最长的准入传播时间;负缓存 TTL 是新域名启用后最长的生效等待时间。数据库查询异常会 fail closed 并短暂负缓存,不会因数据库故障放开未验证 Origin。紧急禁用域名时,可在修改数据库状态后重启 API 进程立即清空进程内缓存。 + 禁止生产环境使用: ```text CORS_ORIGIN=* ``` +H5 租户解析和 CORS 都以浏览器自动发送的 `Origin` hostname 为权威域名:只查询已启用的 `tenant_domains` 和已启用租户,未绑定域名不回退主租户。CORS 不读取 `Host`/`X-Forwarded-Host`/`x-tenant-code` 来决定准入,伪造这些头无法绕过未知 Origin 拒绝。Nginx/CDN 不得覆盖或伪造 `Origin`,并应在 API 反代层清空客户端传入的 `X-Forwarded-Host`。`host` query 只用于 localhost 开发合同;无浏览器 Origin 的健康检查、服务端客户端和微信小程序不会被 CORS 拦截,小程序使用 `tenantCode` 解析租户。 + 当前前端以 `Authorization: Bearer ` 调用 API,`x-tenant-id` 只作为租户上下文,不作为身份来源。生产建议: ```text @@ -163,13 +177,15 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务 ## 发布步骤 -1. 在新服务器或 CI 环境构建三套 H5: +1. 在新服务器或 CI 环境构建三套 H5 和学生微信小程序: ```bash - npm ci + npm ci --workspaces --include-workspace-root --include=dev + npm run audit:taro:supply-chain npm run build:taro:h5:student npm run build:taro:h5:tenant npm run build:taro:h5:platform + npm run build:taro:weapp:student ``` 2. 拷贝静态产物到对应 Web 根目录。 @@ -197,7 +213,7 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务 node scripts/taro-h5-release-guardrails-test.js --require-dist ``` - `smoke:taro:h5` 会用临时静态服务器检查三套 H5 产物可托管、资源可加载、history fallback 可用,并用 mock API 验证租户解析契约。`smoke:taro:h5:interaction` 会用真实 Chrome/Edge 打开三套发布产物并点击 32 项关键路径:学生刷题、收藏、错题/收藏复习、背单词、知识手册、资料、视频、分数线、AI 择校、消息、会员下单和订单状态,租户后台内容导入、公共题库采纳/同步/冲突处理、学生运营、营销/CRM/分佣、主题/角色/成员写操作,以及平台后台租户、账务、公共题库授权和员工写操作;若服务器没有默认浏览器,可设置 `TARO_H5_SMOKE_BROWSER=/path/to/chrome`。`manifest:taro:h5` 会生成三套 H5 的部署清单,包含构建命令、发布目录、入口路由、`index.html` hash、资源数量、runtime-config 是否存在、租户解析模式和公开配置状态。发布守卫会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning;正式发布前必须在每个 H5 目录根部补齐该文件。 + `smoke:taro:h5` 会用临时静态服务器检查三套 H5 产物可托管、资源可加载、history fallback 可用,并用 mock API 验证租户解析契约。`smoke:taro:h5:interaction` 会用真实 Chrome/Edge 打开三套发布产物并点击 33 项关键路径:学生登录 401、刷题、收藏、错题/收藏复习、背单词、知识手册、资料、视频、分数线、AI 择校、消息、会员下单和订单状态,租户后台内容导入、公共题库采纳/同步/冲突处理、学生运营、营销/CRM/分佣、主题/角色/成员写操作,以及平台后台租户、账务、公共题库授权和员工写操作。它还会跨三个门户切换桌面/移动视口,验证 Input 挂载前后同步,并将 Button loading 连续切换 200 次,要求 loading 节点和子节点数量保持稳定;任何浏览器异常、console error、非允许 HTTP 错误都会失败并记录时间戳、行列号和 stack。若服务器没有默认浏览器,可设置 `TARO_H5_SMOKE_BROWSER=/path/to/chrome`。`manifest:taro:h5` 会生成三套 H5 的部署清单,包含构建命令、发布目录、入口路由、`index.html` hash、资源数量、runtime-config 是否存在、租户解析模式和公开配置状态。发布守卫会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning;正式发布前必须在每个 H5 目录根部补齐该文件。 写入 `production-launch-evidence.json` 的正式证据必须使用严格模式,确保三套发布目录都已放置真实公开 `runtime-config.json` 且没有 warning: @@ -219,6 +235,8 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务 8. 打开三个域名,确认 `index.html` 正常加载,启动页能解析租户,登录后接口请求使用 `Authorization` 和正确的 `x-tenant-id`。 +9. 用微信开发者工具打开 `apps/taro/dist/weapp-student`,再用真机验证租户解析、登录、刷题、支付、文件、音视频和分享。`apps/taro/project.config.json` 的 `miniprogramRoot` 已指向该目录;正式发布前必须替换测试 AppID,并配置 API、下载、上传、媒体和业务回调合法域名。 + ## 安全审计边界 H5 线上只发布 `apps/taro/dist/**` 静态文件和每个目录自己的 `runtime-config.json`,不要把 `apps/taro/node_modules`、源码目录、`.env`、部署脚本缓存放进 Web 根目录。 @@ -229,8 +247,10 @@ H5 线上只发布 `apps/taro/dist/**` 静态文件和每个目录自己的 `run npm run audit:runtime ``` -Taro 4.2.0 当前构建工具链仍可能触发 `npm run audit:taro:toolchain` 的上游 high/critical 告警,主要来自构建期 CLI、webpack、swiper、lodash-es 等传递依赖。不要使用 `npm audit fix --force` 将 Taro 降级到 3.x;应等 Taro 官方升级后再处理,或后续评估 Vite runner 替代方案。上线时以静态产物、前端密钥检查、CORS 域名白名单、CSP 和 API runtime audit 作为阻断项。 +Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 告警;`npm audit --omit=dev` 已验证生产运行时为 0 漏洞。不要使用 `npm audit fix --force` 将 Taro 降级到 3.x。`swiper@12.1.2`、`lodash-es@4.18.1` 和两项 H5 runtime patch 已完成可重复干净安装、三端构建、静态/交互 smoke 和小程序构建验证,正式基线以 `npm run audit:taro:supply-chain` 的精确 allowlist/hash 为准。部署必须允许 workspace postinstall,不得使用 `--ignore-scripts`。构建机必须隔离、不得对公网暴露 dev server、不得处理不可信模板/压缩包或执行不可信 CLI 参数;上线阻断项仍包括 production runtime audit、静态产物与前端密钥检查、CORS/CSP、三端烟测和 release manifest。工具链风险需持续跟踪,不能把运行时 0 漏洞表述成工具链 0 漏洞。 -## 小程序后续兼容 +## 微信小程序发布边界 -当前 `runtime-config.json` 只用于 H5。微信小程序版本应通过编译变量、小程序启动参数或后台小程序配置传入 `tenantCode`,再调用 `GET /api/tenant/resolve?tenantCode=...`。小程序端如 `supabase-js` 兼容性不稳定,保留 `apps/api/auth/*` 登录适配层,H5 继续使用 Supabase client 管理 Auth。 +`runtime-config.json` 只用于 H5。微信小程序通过 `TARO_APP_TENANT_CODE`、小程序启动参数或受控后台配置传入 `tenantCode`,再调用 `GET /api/tenant/resolve?tenantCode=...`。小程序登录优先走 `apps/api/auth/*` 的短信或 `code2Session` 适配层,不把 AppSecret、session_key、service role key 或数据库连接信息放进小程序包。 + +当前只提供学生微信小程序构建。`npm run build:taro:weapp:student` 仅用于本地预览;正式上传必须设置 `TARO_APP_API_BASE_URL=https://...`、`WECHAT_MINIAPP_APP_ID` 并运行 `npm run build:taro:weapp:student:production`。单租户品牌包使用默认 `fixed` 模式并设置 `TARO_APP_TENANT_CODE`;面向数百租户的共享小程序使用 `TARO_APP_WEAPP_TENANT_MODE=launch`,从小程序码 query/scene 或 `referrerInfo.extraData.tenantCode` 解析租户码。launch 模式启动时缺少租户码会直接阻断,不会回退默认租户。严格构建会拒绝 localhost、测试 AppID、关闭合法域名检查或超出 4 MiB 总包/2 MiB 主包预算的产物。租户后台和平台后台包含大表格、批量导入、财务与运营工作流,不应为了“多端一致”强行塞进小程序;后续 App 同样优先复用学生端页面和共享 services,再按原生能力逐项验收。 diff --git a/docs/refactor/taro-production-integration-checklist.md b/docs/refactor/taro-production-integration-checklist.md index fc36bc65..849b1a71 100644 --- a/docs/refactor/taro-production-integration-checklist.md +++ b/docs/refactor/taro-production-integration-checklist.md @@ -1,6 +1,6 @@ # Taro 生产接入检查清单 -更新时间:2026-07-01 +更新时间:2026-07-12 这份清单给前端同事和后续 AI 使用。目标是让 `apps/taro` 的 H5 学生端、租户后台、平台后台按当前 Supabase/PostgreSQL 新后端上线,后续再扩展微信小程序。旧小程序前端文件在 `F:\project\参考\旧题库小程序前端文件`,只作为视觉、交互状态和微信平台能力参考,不继承旧 PocketBase 直连、旧 token、旧安全假设。 @@ -31,10 +31,12 @@ "apiBaseUrl": "https://api.example.com", "supabaseUrl": "https://supabase.example.com", "supabasePublishableKey": "sb_publishable_xxx", - "tenantCode": "optional-tenant-slug" + "tenantCode": "" } ``` +生产 H5 的 `tenantCode` 必须为空,租户以当前浏览器 Origin/绑定域名为权威来源;非本地 H5 不允许用固定 `tenantCode` 覆盖域名。`tenantCode` 只用于无 host 的学生小程序/后端受信客户端,或本地 H5 开发。 + 禁止出现在 `runtime-config.json`、Taro 环境变量、源码和构建产物中的内容: - Supabase service role / secret key。 @@ -72,11 +74,13 @@ node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime npm run readiness:production npm run readiness:production:db npm run smoke:auth:remote -npm run test:rls npm run audit:runtime +npm run audit:taro:supply-chain npm run security:repo ``` +`npm run test:rls` 另行在生产 schema/脱敏快照的隔离克隆库执行并留存证据;它会提交 smoke seed,不得在真实生产 `DATABASE_URL` 上运行。不要在 `source /etc/tiku-saas/api.env` 后执行它。 + 正式上线前,三套 H5 严格发布证据、真实 Auth/RLS、真实 provider 抽样、对象存储控制、支付对账、PostgreSQL 严格调参证据、`security:repo` 和真实数据压测都要写入本地 `docs/refactor/production-launch-evidence.json`,再运行: ```bash diff --git a/docs/refactor/taro-supply-chain-baseline-20260712.md b/docs/refactor/taro-supply-chain-baseline-20260712.md new file mode 100644 index 00000000..990cc62c --- /dev/null +++ b/docs/refactor/taro-supply-chain-baseline-20260712.md @@ -0,0 +1,54 @@ +# Taro supply-chain baseline - 2026-07-12 + +## Decision + +Taro remains pinned to `4.2.0`. The root package now overrides the two vulnerable dependencies that are compiled into the H5 output: + +- `swiper@12.1.2` +- `lodash-es@4.18.1` + +Taro 4.2.0 declares exact older versions for these packages. npm therefore installs the secured overrides but reports `ELSPROBLEMS`. This is an upstream dependency-contract mismatch, not permission to ignore arbitrary dependency-tree errors. + +Taro also remains on stable `4.2.0` because the available `4.2.1-beta.2` still contains the reproduced H5 Input watcher defect and introduces a wider beta regression surface. The Taro workspace postinstall applies two reviewed, fail-closed H5 runtime patches to the exact `@tarojs/components@4.2.0` package: + +- Input watcher: guards `inputRef` before synchronizing `value`. +- Button loading: keeps the loading `` node stable and changes only its `display`, avoiding Stencil child insertion/removal while React changes adjacent buttons. + +The patcher validates the package version, lock integrity, pristine or patched source hashes, and all targets before writing either file. H5 build/dev commands run `--check` first. Student WeApp uses native mini-program components and does not depend on these H5-only patches. + +`npm run audit:taro:supply-chain` fails unless all of the following remain true: + +- `package.json`, `package-lock.json`, and the installed tree use the exact secured versions and expected integrity hashes. +- The only `npm ls` invalid packages are `swiper@12.1.2` and `lodash-es@4.18.1`. +- The only invalid edges are the four reviewed Taro 4.2.0 exact declarations. +- A full `apps/taro` workspace audit, including development dependencies, no longer reports `swiper` or `lodash-es`. +- Every remaining high or critical package is already present in the explicit reviewed build-toolchain allowlist. A new high or critical package fails closed. +- The installed Input target SHA-256 is `260bb8a07d66eaf3398904acb94a7c2cacabe4411b70a01fb0d03931fe95c499`. +- The installed Button target SHA-256 is `428db74e51382c68bc10211ff7815d494b086de465fdef97ca09f5b7ab8368ea`. + +Do not replace this check with `npm audit --omit=dev`. Taro declares much of the frontend stack as development dependencies even though `swiper` and `lodash-es` are compiled into the shipped H5 JavaScript. + +## Reproducibility evidence + +Using npm `11.12.1` and Node.js `24.15.0`, a repository-external clean snapshot completed `npm ci --workspaces --include-workspace-root --include=dev` in `9.77s`. The Taro workspace postinstall automatically produced both reviewed patched hashes; the patch contract and independent supply-chain audit then passed. Deployment must not use `--ignore-scripts`, and must include development dependencies because Taro is a static build workspace. + +Generate a machine-readable launch artifact with `npm --silent run audit:taro:supply-chain -- --json`. The `--silent` flag is required when redirecting stdout because the normal npm script banner is not JSON. + +The secured versions and runtime patches have passed Taro TypeScript, three production H5 builds, static smoke `25/25`, full Chrome interaction smoke `33/33`, and the student WeApp preview build/guard. The browser runtime probe sets the Input value before and after mount, and toggles Button loading 200 times while asserting that the same loading node and child count remain stable. Release checks must continue to run after any Taro upgrade, override, patch hash, or lock-file change. + +## Remaining risk boundary + +The full workspace audit currently reports 38 findings: 3 critical, 10 high, and 25 moderate. The removed bundle findings account for the reduction from 40 findings and eliminate the known `swiper` prototype-pollution and `lodash-es` advisories from the resolved frontend dependency tree. + +The remaining high and critical findings are in the Taro CLI and build chain, including repository/template download helpers, archive extraction, glob processing, minification, serialization, and webpack runner paths. They are not declared resolved and the raw `npm run audit:taro:toolchain` command intentionally remains non-zero. + +Until Taro publishes a compatible upgrade, apply these controls: + +- Build only on an isolated trusted runner with least-privilege credentials and no production database access. +- Do not expose the Taro development server to public networks. +- Do not feed untrusted templates, repositories, archives, configuration, or CLI arguments to the build process. +- Publish only reviewed static files under `apps/taro/dist`; never publish `node_modules`, source files, or build caches. +- Do not run `npm audit fix --force`; its current proposal crosses the Taro major-version contract and can regress the multi-end build. +- Re-run the supply-chain gate, TypeScript checks, H5/WeApp builds, and smoke tests whenever the lock file or any Taro package changes. + +This is a controlled risk acceptance for the isolated build toolchain. It is not a claim that the Taro toolchain has zero vulnerabilities. diff --git a/docs/refactor/tenant-foreign-key-audit.md b/docs/refactor/tenant-foreign-key-audit.md new file mode 100644 index 00000000..f735b0e2 --- /dev/null +++ b/docs/refactor/tenant-foreign-key-audit.md @@ -0,0 +1,37 @@ +# 租户外键完整性审计 + +## 目的 + +RLS 负责控制一行是否可见,但不能保证一条租户内记录引用的父记录也属于同一租户。历史 schema 仍有一批只引用父表 `id` 的外键,因此上线门禁同时执行: + +- 快速 schema 指纹:生产 readiness 查询 `pg_catalog`,发现新增、删除、改名、未验证的租户间外键即阻断。 +- 完整数据审计:只在 `local`、`test`、`ci` 隔离克隆库执行,逐条检查现有数据是否满足租户不变量并输出 JSON 证据。 + +默认规则是子表与父表 `tenant_id` 必须一致。当前只有三个显式例外: + +- 平台审计规则允许 `tenant_id is null`,代表全局规则;租户专属规则必须与告警租户一致。 +- 租户采用公共题库时,源题库可以属于平台租户,但必须是 `source_scope='platform'`。 +- 公共题库通知可以指向平台源题库,同样必须是 `source_scope='platform'`。 + +新增例外必须同时补业务理由、查询不变量、合同测试和 schema 指纹。不得只更新指纹跳过审计。 + +## 执行 + +隔离库必须已应用全部迁移,并在 `app_private.environment_safety` 标记为 `local/test/ci` 且 `allow_destructive_tests=true`。该命令只读,但会扫描相关业务表,因此沿用破坏性测试确认口令,防止误连生产主库: + +```bash +DATABASE_URL='postgresql://postgres:postgres@127.0.0.1:55432/postgres' \ +node scripts/tenant-foreign-key-audit.js \ + --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY \ + --write=docs/refactor/launch-artifacts/tenant-foreign-key-audit.json +``` + +通过条件: + +- `schema.schemaMatches=true` +- `schema.unvalidatedRelations=[]` +- `data.auditedRelations` 等于指纹关系数 +- `data.invalidRelations=0` +- `status=pass` + +生成的 JSON 及 SHA-256 需要写入 `production-launch-evidence.json` 的 `db.tenant-foreign-key-audit` 检查项。 diff --git a/docs/refactor/tenant-student-capacity-runbook.md b/docs/refactor/tenant-student-capacity-runbook.md new file mode 100644 index 00000000..1513a466 --- /dev/null +++ b/docs/refactor/tenant-student-capacity-runbook.md @@ -0,0 +1,106 @@ +# 单租户 10 万学生容量验证手册 + +本工具用合成数据验证租户后台学生列表在单租户最多 100000 名学生时的 PostgreSQL 查询形状、cursor 分页和子串搜索。它不会连接 API,直接使用与 `GET /api/tenant-admin/students` 相同的 SQL 核心和下列索引: + +- `idx_memberships_student_keyset_page` +- `idx_platform_users_identity_search_trgm` + +## 安全边界 + +- 只能用于 `local` / `test` / `ci` 数据库,且 `app_private.environment_safety.allow_destructive_tests` 必须为 `true`。 +- 任何写入、基准或清理模式都必须显式传入 `SMOKE_SEED_LOCAL_OR_CI_ONLY`。 +- 租户 slug 必须以 `capacity-test-` 开头。如果同名租户没有匹配的 `metadata.capacityHarness` 标记,工具会拒绝使用它。 +- 合成用户同时使用 `legacy_id` 和 `raw_profile.capacityHarness` 标记。清理前如果发现真实 `auth_user_id`、其他租户 membership、租户 owner 或专用租户中的非 harness membership,工具会拒绝操作。 +- 禁止使用 `tikupro-pg` 或任何生产数据库。当前本机 `127.0.0.1:5432` 映射到 `tikupro-pg`,因此工具也会对 localhost/loopback 的 5432 端口在连接前硬拒绝。本工具的本地结果 is not a production SLA。 + +不要为了跑工具而在现有服务器数据库上修改环境标记。应该新建专用的非生产 PostgreSQL/Supabase 实例,运行全部迁移,再设置测试环境标记。 + +## 1. 先看计划 + +默认模式不连接数据库、不写数据: + +```bash +npm run perf:tenant-students:plan +``` + +如果希望在计划中显示脱敏后的 host / port / database / user,可以同时提供 `DATABASE_URL`;工具不会输出密码。 + +## 2. 小规模 smoke + +以专用隔离库为例:smoke 使用独立的 `capacity-test-students-smoke` 租户生成 250 名学生,执行 5 组查询、写出 JSON/Markdown 证据,然后在成功或失败路径清理专用租户和合成用户。它不会清理 `capacity-test-students-100k` 中人为保留的基准夹具。 + +```bash +DATABASE_URL='postgresql://postgres:postgres@127.0.0.1:55432/postgres' \ + npm run test:tenant-students:capacity:smoke +``` + +报告默认保存到已忽略的 `docs/refactor/performance-reports/`。smoke 返回后应确认 `cleanup.deletedTenant=1` 且 `cleanup.deletedUsers=250`。 + +## 3. 生成 10 万行并跑基准 + +`run` 会保留夹具,便于重复采样或查看查询计划: + +```bash +DATABASE_URL='postgresql://postgres:postgres@127.0.0.1:55432/postgres' \ + npm run perf:tenant-students:run -- \ + --count=100000 \ + --batch-size=10000 \ + --iterations=20 \ + --warmup-iterations=3 \ + --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY +``` + +写入是 set-based 的 `generate_series` + UPSERT,每批在独立事务中完成。同一个 count 可重复执行;如果已有夹具行数高于新 count,工具会要求先清理,避免隐式删数据。 + +正式上线证据应使用一次性 `evidence` 模式,它会在同一个受保护流程中完成 seed、benchmark、cleanup,并把清理结果与四类残留计数写入同一份 JSON: + +```bash +DATABASE_URL='' \ + npm run perf:tenant-students:evidence -- \ + --count=100000 \ + --batch-size=10000 \ + --iterations=20 \ + --warmup-iterations=3 \ + --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY +``` + +`evidence` 模式成功时必须包含 `cleanup.cleanupVerified=true`,且 `remaining.tenants/platformUsers/memberships/profiles` 全部为 `0`。 + +每组证据包含: + +- 实际 `platform_users` / `tenant_memberships` / `student_profiles` 行数。 +- 工具内部记录的 seed / benchmark / cleanup / total 阶段耗时。 +- 首页和约 90% 深度 cursor 页。 +- 姓名、手机号、email substring 搜索。 +- 应用端观测 P50/P95,以及每组 SQL 的 `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`。 +- 实际使用的 plan node、index name、shared/local/temp buffer 摘要与完整 JSON plan。 + +## 4. 重复基准 + +```bash +DATABASE_URL='postgresql://postgres:postgres@127.0.0.1:55432/postgres' \ + npm run perf:tenant-students:benchmark -- \ + --iterations=30 \ + --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY +``` + +深 cursor 使用约 90% 位置的真实 `(created_at, membership_id)` 锚点,不使用 OFFSET 制造查询。 + +## 5. 清理 + +```bash +DATABASE_URL='postgresql://postgres:postgres@127.0.0.1:55432/postgres' \ + npm run perf:tenant-students:cleanup -- \ + --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY +``` + +清理只会命中: + +- `slug=capacity-test-students-100k` 且带匹配 `metadata.capacityHarness` 的专用租户。 +- `raw_profile.capacityHarness.namespace=tiku.student-capacity.v1` 且 tenant slug 相同的合成用户。 + +自定义租户名时,六个命令都必须传入同一个 `--tenant-slug=capacity-test-...`。 + +## 验收口径 + +这个工具首先是数据量和查询计划验证,不应单独用它承诺线上 SLA。正式验收还需要在与生产同规格的非生产环境复跑,并与 API 并发压测、PostgreSQL 调优证据、连接池和云盘 I/O 指标一起归档。 diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index 9bdf55aa..9817a0b7 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -1,6 +1,6 @@ # Web 版上线前验收清单 -更新时间:2026-07-01 +更新时间:2026-07-11 这份清单用于先上线 H5 Web 题库。Taro 仍然是前端工程,后端以 Supabase Auth/JWT、PostgreSQL/RLS、`apps/api`、worker 为主。前端视觉和交互可以参考 `F:\project\参考\旧题库小程序前端文件` 和旧 Web 版,但不能继承旧 PocketBase 直连、旧鉴权或旧字段模型。 @@ -15,6 +15,24 @@ - 学生头像只做男女预设,不做上传、裁剪或第三方头像同步。 - 排行榜默认不请求、不展示;只有租户购买/开启活动且完成专项压测后再接独立页面。 - H5 构建目录必须包含 `index.html`、`js/`、`css/`,并且每个上线目录根部必须由部署方放置对应 `runtime-config.json`。 +- 三套 H5 只注册本 portal 页面;学生微信小程序使用独立 `dist/weapp-student` 目录和学生分包,不能覆盖 `dist/h5-student`。 +- 租户、会话、权限和主题由全局 App Provider 管理;业务缓存键按 portal、域名或 tenantCode、tenantId、userId 隔离,跨标签账号变化会触发重验,页面不得自行维护另一套长期身份状态。 + +## 首个平台超级管理员 + +生产库没有可登录平台管理员时,先在 Supabase Auth 创建或确认你的账号并取得 `auth.users.id`。只在生产运维终端运行服务器 CLI,先 dry-run,再使用精确确认短语写入: + +```bash +DATABASE_URL='' \ +BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID='' \ +BOOTSTRAP_PLATFORM_ADMIN_USERNAME='' \ +BOOTSTRAP_PLATFORM_ADMIN_NAME='' \ +npm run bootstrap:platform-admin + +npm run bootstrap:platform-admin -- --apply --confirm BOOTSTRAP_FIRST_PLATFORM_ADMIN +``` + +该命令不是公开 API,不接收密码、验证码或 service role key,不输出完整 Auth UUID、邮箱或手机号。写入后立即运行 `npm run readiness:production:db`,确认 active 超管、Auth 绑定和 `{"*":true}` 权限门禁通过;已有可登录超管时命令必须拒绝。 ## 学生端验收旅程 @@ -76,12 +94,16 @@ npm run smoke:launch-persona ```bash npm run test:readiness -npm run test:rls +npm run test:auth:foundation npm run audit:runtime npm run security:repo npm run check:api npm run check:worker npm run check:taro +npm run build:taro:h5:student +npm run build:taro:h5:tenant +npm run build:taro:h5:platform +npm run build:taro:weapp:student npm run readiness:production npm run readiness:production:db npm run smoke:taro:h5 @@ -90,9 +112,18 @@ node scripts/taro-h5-release-guardrails-test.js --require-dist npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md ``` +`test:rls` 和完整 API/worker 集成测试是会写入数据的测试套件,只允许连接本地、CI 或生产 schema/脱敏快照的隔离克隆库。克隆库必须在 `app_private.environment_safety` 显式标记 `environment='test'|'ci'` 和 `allow_destructive_tests=true`,然后单独留存动态 RLS 证据: + +```bash +DATABASE_URL='' \ +npm run test:rls > docs/refactor/launch-artifacts/rls-tenant-isolation.log +``` + +禁止在 `source /etc/tiku-saas/api.env` 后直接运行 `test:rls`、`test:api` 或 `test:worker:*`。真实生产数据库只执行 `readiness:production:db` 等只读门禁;真实 API 验收使用无 seed 的 `smoke:auth:remote`、受控灰度租户 `smoke:launch-persona` 等专用脚本。 + `smoke:launch-persona` 是真实 API 角色旅程烟测,必须进入生产上线证据;正式证据必须使用 `LAUNCH_SMOKE_AUTH_MODE=app_session`,通过 Bearer `tk_` session 验证身份,不使用旧 `x-user-id` 或平台本地 key。普通学生要能在 SVIP 权益下创建练习、答题、收藏题目、进入收藏复习和错题复习入口;租户管理员要能读取看板/主题/学生/销售转化并拒绝学生或跨租户访问;平台管理员要能读取租户/套餐/审计入口并拒绝学生访问平台后台。生产证据命令必须带 `--write docs/refactor/launch-artifacts/launch-persona-smoke.json`,确保 `production-launch-evidence.json` 引用的 artifact 是稳定路径,不是只存在带时间戳的本地报告。 -`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题;当前脚本覆盖 32 项检查,包括学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台内容导入、公共题库采纳/同步/冲突处理、学生运营、营销/CRM/分佣、主题/角色/成员写操作,以及平台后台租户、账务、公共题库授权和员工写操作。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。 +`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题;当前脚本覆盖 33 项检查,包括学生未登录 401、首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台内容导入、公共题库采纳/同步/冲突处理、学生运营、营销/CRM/分佣、主题/角色/成员写操作,以及平台后台租户、账务、公共题库授权和员工写操作;另有跨门户移动视口、Input 挂载竞态和 Button loading 200 次稳定节点探针。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。 写入生产上线证据时,三套正式发布目录必须先放入真实公开 `runtime-config.json`,再运行严格模式: @@ -103,6 +134,15 @@ node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md > docs/refactor/launch-artifacts/launch-persona-smoke.log ``` +每个 `checks[].artifact` 都必须同时填写真实 `artifactSha256`。三端静态目录已经切换到正式域名后,部署脚本应显式再执行严格线上校验: + +```bash +LAUNCH_GATE_VERIFY_LIVE_H5=true \ +npm run launch:gate -- --evidence /etc/tiku-saas/production-launch-evidence.json --verify-live-h5 +``` + +证据中的 `liveH5.releaseManifestArtifact` 指向本次候选 `taro-h5-release-manifest.json`,`liveH5.releaseManifestSha256` 记录该 manifest 文件的 SHA-256。严格模式会请求学生端、租户后台和平台后台各自的 `index.html`、`runtime-config.json` 和主 app bundle,要求 HTTP 成功、portal 正确、`apiBaseUrl` 与证据中的生产 HTTPS API 一致,并验证线上 index/app 哈希与候选发布目录一致。默认 launch gate 保持离线,不带显式开关时不会访问公网。 + PNVS 短信登录上线前要用真实手机号跑一次远程 smoke。脚本不会读取或输出密钥;它只调用公网 API,发送验证码后在终端输入收到的短信验证码,再确认 `/api/auth/me` 可用: ```bash @@ -125,7 +165,19 @@ PNVS 只验收手机号登录/换绑验证码链路。催缴、营销、CRM 等 `security:repo` 是仓库自带的静态安全扫描,会拦截密钥形态、前端旧鉴权头、真实 runtime-config 和生产证据误入 Git。它不能替代真实 `@codex-security`;如插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。 -`readiness:production` 和 `readiness:production:db` 是生产阻断门禁:会拒绝 mock/未知短信 provider、弱密钥、`CORS=*`、旧身份头、local_dev 存储、非 HTTPS 对象存储公开 URL、阿里云 OSS 内网直签、未接外部资源扫描、localhost webhook,以及租户短信/OAuth/支付公开配置缺字段、OAuth redirectUri/支付 notifyUrl 非 HTTPS、公开配置混入密钥、active provider 缺私密 `tenant_secrets` 等问题。 +`readiness:production` 和 `readiness:production:db` 是生产阻断门禁:会拒绝 mock/未知短信 provider、弱密钥、`CORS=*`、旧身份头、local_dev 存储、非 HTTPS 对象存储公开 URL、阿里云 OSS 内网直签、未接外部资源扫描、localhost webhook,以及生产库残留 `local/test/ci` 或 `allow_destructive_tests=true` 标记、运行角色未通过 superuser bootstrap、租户公开配置缺字段/混入密钥、OAuth redirectUri/支付 notifyUrl 非 HTTPS、active provider 缺私密 `tenant_secrets`、active 租户公开 URL 指向 localhost/HTTP、缺少可登录平台管理员等问题。尚未发布主题只 warning 并回退平台默认主题,正式上线前仍要逐租户确认。 + +首次应用数据库迁移前,由 self-hosted PostgreSQL/Supabase 的真正 superuser 执行一次: + +```bash +DATABASE_ADMIN_URL='' \ +npm run bootstrap:db-runtime-roles -- \ + --apply --confirm=BOOTSTRAP_BACKEND_RUNTIME_ROLES +``` + +该管理员连接只用于集群角色和官方 Supabase 基础镜像 `public` 扩展函数 ACL bootstrap,不得进入 API/Worker 配置或发布证据。普通 migration 会验证 `tiku_api/tiku_worker` 已是 `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS` 且无父角色,并在 `anon/authenticated` 仍可执行任何 `public` 函数时直接阻断。每次 Supabase 镜像或扩展升级后都要重跑该幂等 bootstrap 与数据库 readiness。 + +微信小程序产物还必须在微信开发者工具和至少一台真机验证:打开 `apps/taro/dist/weapp-student`,确认主包只保留启动页、学生页面位于分包、tenantCode 能解析正确租户、短信/微信登录能建立当前租户会话,并完成刷题、支付调起、文件预览、音视频和分享能力。租户后台与平台后台本阶段只发布桌面 H5。 生产 API 推荐: @@ -133,9 +185,15 @@ PNVS 只验收手机号登录/换绑验证码链路。催缴、营销、CRM 等 ALLOW_LEGACY_AUTH_HEADERS=false ALLOW_PLATFORM_ADMIN_KEY=false CORS_ORIGIN=https://student.example.com,https://tenant-admin.example.com,https://platform-admin.example.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs ``` +`CORS_ORIGIN` 中的学生/租户后台域名只适用于平台自营的少量固定 Origin;合作租户自定义域名必须由 `active tenant_domains + active tenants` 动态准入。上线验收必须同时验证 active Origin 通过,inactive/unknown Origin 的 OPTIONS 和普通请求返回 `403 CORS_ORIGIN_DENIED`,伪造 Host 无法绕过,且无 Origin 的 `/health` 仍可用。 + 生产 worker 推荐: ```text diff --git a/package-lock.json b/package-lock.json index 227ec2b0..10ac2e3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ ], "devDependencies": { "pg": "^8.16.3", + "selfsigned": "2.4.1", "supabase": "^2.107.0", "write-excel-file": "^4.1.1" } @@ -55,6 +56,7 @@ "apps/taro": { "name": "@tiku-saas/taro", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "katex": "^0.17.0" }, @@ -12514,9 +12516,9 @@ "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "dev": true, "license": "MIT" }, @@ -17381,9 +17383,9 @@ "license": "CC0-1.0" }, "node_modules/swiper": { - "version": "11.1.15", - "resolved": "https://registry.npmjs.org/swiper/-/swiper-11.1.15.tgz", - "integrity": "sha512-IzWeU34WwC7gbhjKsjkImTuCRf+lRbO6cnxMGs88iVNKDwV+xQpBCJxZ4bNH6gSrIbbyVJ1kuGzo3JTtz//CBw==", + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.2.tgz", + "integrity": "sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index e1d04beb..97d029d9 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,13 @@ "packages/*", "scripts/import-pocketbase" ], + "overrides": { + "lodash-es": "4.18.1", + "swiper": "12.1.2" + }, "devDependencies": { "pg": "^8.16.3", + "selfsigned": "2.4.1", "supabase": "^2.107.0", "write-excel-file": "^4.1.1" }, @@ -25,8 +30,9 @@ "check:taro": "npm --workspace @tiku-saas/taro 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:readiness && npm run test:pb:dry-run && npm run test:api", - "audit:runtime": "npm audit --omit=dev --audit-level=high", - "audit:taro:toolchain": "npm audit --workspace @tiku-saas/taro --audit-level=high", + "audit:runtime": "npm audit --registry=${NPM_AUDIT_REGISTRY:-https://registry.npmjs.org/} --omit=dev --audit-level=high", + "audit:taro:toolchain": "npm audit --registry=${NPM_AUDIT_REGISTRY:-https://registry.npmjs.org/} --workspace @tiku-saas/taro --audit-level=high", + "audit:taro:supply-chain": "node scripts/taro-supply-chain-audit.js", "security:repo": "node scripts/repo-security-scan.js", "docker:api:build": "docker compose -f docker-compose.api.yml build", "docker:api:up": "docker compose -f docker-compose.api.yml up api", @@ -36,38 +42,66 @@ "supabase:status": "supabase status", "supabase:reset": "supabase db reset", "db:smoke-seed": "node scripts/smoke-seed.js", + "db:smoke-seed:test": "node scripts/smoke-seed.js --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY", "smoke:core-api": "node scripts/smoke-core-api.js", "smoke:auth:remote": "node scripts/remote-auth-jwt-smoke.js", - "test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server", + "test:api": "npm run db:smoke-seed:test && npm run build:api && node scripts/api-integration-test.js --start-server --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY", "perf:api:local": "npm run build:api && node scripts/api-performance-benchmark.js", "perf:api:docker-4c16g": "node scripts/run-docker-4c16g-benchmark.js", "perf:postgres:evidence": "node scripts/postgres-tuning-evidence.js", "perf:postgres:sql": "node scripts/postgres-tuning-evidence.js --print-sql", - "test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js", - "test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js", - "test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js", - "test:worker:platform-usage": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-usage-worker-integration-test.js", - "test:worker:platform-usage-overage": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-usage-overage-worker-integration-test.js", - "test:worker:platform-dunning": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-worker-integration-test.js", - "test:worker:platform-dunning-notifications": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-notification-worker-integration-test.js", - "test:worker:platform-audit-alerts": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-audit-alert-worker-integration-test.js", - "test:worker:platform-audit-notifications": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-audit-notification-worker-integration-test.js", - "test:worker:assets": "npm run db:smoke-seed && npm run build:worker && node scripts/asset-worker-integration-test.js", - "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: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 scripts/aliyun-pnvs-provider-contract-test.js && node scripts/configure-aliyun-pnvs-provider-test.js && node scripts/diagnose-aliyun-pnvs-provider-test.js && node scripts/disable-legacy-sms-providers-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/product-scope-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/remote-sms-login-smoke-test.js && node scripts/launch-persona-smoke-test.js && node scripts/production-launch-gate-test.js", + "perf:tenant-students:plan": "node scripts/tenant-student-capacity.js --mode=plan", + "perf:tenant-students:seed": "node scripts/tenant-student-capacity.js --mode=seed", + "perf:tenant-students:run": "node scripts/tenant-student-capacity.js --mode=run", + "perf:tenant-students:evidence": "node scripts/tenant-student-capacity.js --mode=evidence", + "perf:tenant-students:benchmark": "node scripts/tenant-student-capacity.js --mode=benchmark", + "perf:tenant-students:cleanup": "node scripts/tenant-student-capacity.js --mode=cleanup", + "test:worker:crm": "npm run db:smoke-seed:test && npm run build:worker && node scripts/crm-worker-integration-test.js", + "test:worker:commerce": "npm run db:smoke-seed:test && npm run build:worker && node scripts/commerce-worker-integration-test.js", + "test:worker:platform-billing": "npm run db:smoke-seed:test && npm run build:worker && node scripts/platform-billing-worker-integration-test.js", + "test:worker:platform-usage": "npm run db:smoke-seed:test && npm run build:worker && node scripts/platform-usage-worker-integration-test.js", + "test:worker:platform-usage-overage": "npm run db:smoke-seed:test && npm run build:worker && node scripts/platform-usage-overage-worker-integration-test.js", + "test:worker:platform-dunning": "npm run db:smoke-seed:test && npm run build:worker && node scripts/platform-dunning-worker-integration-test.js", + "test:worker:platform-dunning-notifications": "npm run db:smoke-seed:test && npm run build:worker && node scripts/platform-dunning-notification-worker-integration-test.js", + "test:worker:platform-audit-alerts": "npm run db:smoke-seed:test && npm run build:worker && node scripts/platform-audit-alert-worker-integration-test.js", + "test:worker:platform-audit-notifications": "npm run db:smoke-seed:test && npm run build:worker && node scripts/platform-audit-notification-worker-integration-test.js", + "test:worker:assets": "npm run db:smoke-seed:test && npm run build:worker && node scripts/asset-worker-integration-test.js", + "test:worker:exports": "npm run db:smoke-seed:test && npm run build:worker && node scripts/export-worker-integration-test.js", + "test:worker:imports": "npm run db:smoke-seed:test && npm run build:worker && node scripts/import-worker-integration-test.js --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY", + "test:worker:public-banks": "npm run db:smoke-seed:test && npm run build:worker && node scripts/public-bank-worker-integration-test.js", + "test:worker:student-supervision": "npm run db:smoke-seed:test && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", + "test:rls": "npm run db:smoke-seed:test && node scripts/rls-tenant-isolation-test.js --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/api-cors-policy-test.js && node scripts/destructive-test-database-guard-test.js && node scripts/bootstrap-backend-runtime-roles-test.js && node scripts/backend-runtime-role-contract-test.js && node scripts/tenant-foreign-key-audit-test.js && node scripts/tenant-student-capacity-contract-test.js && node scripts/aliyun-pnvs-provider-contract-test.js && node scripts/configure-aliyun-pnvs-provider-test.js && node scripts/diagnose-aliyun-pnvs-provider-test.js && node scripts/disable-legacy-sms-providers-test.js && node scripts/sms-rate-limit-contract-test.js && node scripts/audit-log-capacity-contract-test.js && node scripts/data-api-security-contract-test.js && node --import tsx scripts/auth-context-platform-admin-test.js && node --import tsx scripts/tenant-permission-resolution-test.js && node --import tsx scripts/tenant-student-cursor-test.js && node scripts/bootstrap-platform-admin-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node --import tsx scripts/taro-app-foundation-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/product-scope-guardrails-test.js && node --import tsx scripts/taro-route-contract-test.js && node --import tsx scripts/taro-build-matrix-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-api-compatibility-contract-test.js && node scripts/taro-components-h5-runtime-patch-test.js && node scripts/taro-supply-chain-audit-test.js && node --import tsx scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/api-docker-runtime-contract-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/remote-sms-login-smoke-test.js && node scripts/remote-tenant-cors-smoke-test.js && node scripts/launch-persona-smoke-test.js && node scripts/production-launch-gate-test.js && node scripts/deploy-contract-test.js", + "test:destructive-db-guard": "node scripts/destructive-test-database-guard-test.js", + "test:tenant-students:capacity:contract": "node scripts/tenant-student-capacity-contract-test.js", + "audit:tenant-foreign-keys": "node scripts/tenant-foreign-key-audit.js", + "test:tenant-foreign-keys": "node scripts/tenant-foreign-key-audit-test.js", + "test:tenant-students:capacity:smoke": "node scripts/tenant-student-capacity.js --mode=smoke --tenant-slug=capacity-test-students-smoke --count=250 --batch-size=250 --iterations=2 --warmup-iterations=1 --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY", + "test:deploy:contract": "node scripts/deploy-contract-test.js && node --import tsx scripts/worker-scheduling-contract-test.js", + "test:worker:scheduling": "node --import tsx scripts/worker-scheduling-contract-test.js", + "test:api:operations": "npm run build:api && node scripts/api-server-operations-contract-test.js", + "test:api:cors": "node --import tsx scripts/api-cors-policy-test.js && npm run test:api:operations", + "test:auth:foundation": "node --import tsx scripts/auth-context-platform-admin-test.js && node --import tsx scripts/tenant-permission-resolution-test.js && node --import tsx scripts/tenant-resolve-contract-test.js && node scripts/bootstrap-platform-admin-test.js", + "test:data-api:security": "node scripts/data-api-security-contract-test.js", + "test:audit-log:capacity": "node scripts/audit-log-capacity-contract-test.js", + "test:taro:foundation": "node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node --import tsx scripts/taro-app-foundation-test.js && node --import tsx scripts/taro-route-contract-test.js && node --import tsx scripts/taro-build-matrix-contract-test.js && node --import tsx scripts/taro-persona-contract-test.js", + "test:taro:api-compatibility": "node scripts/taro-api-compatibility-contract-test.js", + "test:taro:supply-chain": "node scripts/taro-components-h5-runtime-patch-test.js && node scripts/taro-supply-chain-audit-test.js", + "check:taro:h5-runtime-patches": "node scripts/taro-components-h5-runtime-patch.js --check", + "patch:taro:h5-runtime": "node scripts/taro-components-h5-runtime-patch.js --apply", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", + "bootstrap:platform-admin": "node scripts/bootstrap-platform-admin.js", + "bootstrap:db-runtime-roles": "node scripts/bootstrap-backend-runtime-roles.js", "configure:aliyun-pnvs": "node scripts/configure-aliyun-pnvs-provider.js", "diagnose:aliyun-pnvs": "node scripts/diagnose-aliyun-pnvs-provider.js", "disable:legacy-sms-providers": "node scripts/disable-legacy-sms-providers.js", "smoke:sms-login:remote": "node scripts/remote-sms-login-smoke.js", + "smoke:tenant-cors:remote": "node scripts/remote-tenant-cors-smoke.js", "test:launch-gate": "node scripts/production-launch-gate-test.js", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", "smoke:taro:h5": "node scripts/taro-h5-static-smoke.js", "smoke:taro:h5:interaction": "node scripts/taro-h5-interaction-smoke.js", + "serve:taro:h5:qa": "node scripts/taro-h5-interaction-smoke.js --serve-only", "manifest:taro:h5": "node scripts/taro-h5-release-manifest.js", "guard:taro:visual": "node scripts/taro-visual-guardrails.js", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js", @@ -77,12 +111,19 @@ "readiness:production:db": "node scripts/production-readiness-check.js --check-db", "launch:gate": "node scripts/production-launch-gate.js", "perf:summary": "node scripts/performance-summary.js", - "test:api:remote": "node scripts/api-integration-test.js", "dev:taro:h5": "npm --workspace @tiku-saas/taro run dev:h5", "build:taro:h5": "npm --workspace @tiku-saas/taro run build:h5", "build:taro:h5:student": "npm --workspace @tiku-saas/taro run build:h5:student", "build:taro:h5:tenant": "npm --workspace @tiku-saas/taro run build:h5:tenant", "build:taro:h5:platform": "npm --workspace @tiku-saas/taro run build:h5:platform", + "build:taro:h5:preview": "npm run build:taro:h5:student:preview && npm run build:taro:h5:tenant:preview && npm run build:taro:h5:platform:preview", + "build:taro:h5:student:preview": "npm --workspace @tiku-saas/taro run build:h5:student:preview", + "build:taro:h5:tenant:preview": "npm --workspace @tiku-saas/taro run build:h5:tenant:preview", + "build:taro:h5:platform:preview": "npm --workspace @tiku-saas/taro run build:h5:platform:preview", + "dev:taro:weapp:student": "npm --workspace @tiku-saas/taro run dev:weapp:student", + "build:taro:weapp:student": "npm --workspace @tiku-saas/taro run build:weapp:student", + "build:taro:weapp:student:production": "npm --workspace @tiku-saas/taro run build:weapp:student:production", + "guard:taro:weapp": "node scripts/taro-weapp-release-guardrails.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", "pb:export:sqlite": "npm --workspace @tiku-saas/import-pocketbase run export:sqlite --", diff --git a/packages/db/src/index.js b/packages/db/src/index.js index 7ca2da55..1d20cd20 100644 --- a/packages/db/src/index.js +++ b/packages/db/src/index.js @@ -1,12 +1,35 @@ import pg from 'pg'; import { DEFAULT_DATABASE_URL } from '../../config/src/index.js'; const { Pool } = pg; +function positiveEnvNumber(key, fallback) { + const value = Number(process.env[key]); + if (!Number.isFinite(value) || value <= 0) + return fallback; + return Math.trunc(value); +} export function createPool(options = {}) { - return new Pool({ + const pool = new Pool({ connectionString: options.connectionString || process.env.DATABASE_URL || DEFAULT_DATABASE_URL, - max: options.max || 10, - idleTimeoutMillis: options.idleTimeoutMillis || 30_000, + max: options.max || positiveEnvNumber('DB_POOL_MAX', 10), + idleTimeoutMillis: options.idleTimeoutMillis || positiveEnvNumber('DB_IDLE_TIMEOUT_MS', 30000), + connectionTimeoutMillis: positiveEnvNumber('DB_CONNECTION_TIMEOUT_MS', 5000), + query_timeout: positiveEnvNumber('DB_QUERY_TIMEOUT_MS', 35000), + statement_timeout: positiveEnvNumber('DB_STATEMENT_TIMEOUT_MS', 30000), + lock_timeout: positiveEnvNumber('DB_LOCK_TIMEOUT_MS', 5000), + idle_in_transaction_session_timeout: positiveEnvNumber('DB_IDLE_IN_TRANSACTION_TIMEOUT_MS', 30000), + application_name: options.applicationName || process.env.DB_APPLICATION_NAME || 'tiku-saas', + maxUses: positiveEnvNumber('DB_POOL_MAX_USES', 7500), + maxLifetimeSeconds: positiveEnvNumber('DB_POOL_MAX_LIFETIME_SECONDS', 1800), }); + pool.on('error', error => { + console.error(JSON.stringify({ + timestamp: new Date().toISOString(), + service: options.applicationName || process.env.DB_APPLICATION_NAME || 'tiku-saas', + event: 'postgres_pool_error', + code: error.code || 'PG_POOL_ERROR', + })); + }); + return pool; } export async function query(pool, sql, params = []) { const result = await pool.query(sql, params); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f32a54c9..1c337767 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -7,20 +7,38 @@ export interface DbPoolOptions { connectionString?: string; max?: number; idleTimeoutMillis?: number; + applicationName?: string; } -function envPoolMax() { - const value = Number(process.env.DB_POOL_MAX || 10); - if (!Number.isFinite(value) || value <= 0) return 10; +function positiveEnvNumber(key: string, fallback: number) { + const value = Number(process.env[key]); + if (!Number.isFinite(value) || value <= 0) return fallback; return Math.trunc(value); } export function createPool(options: DbPoolOptions = {}) { - return new Pool({ + const pool = new Pool({ connectionString: options.connectionString || process.env.DATABASE_URL || DEFAULT_DATABASE_URL, - max: options.max || envPoolMax(), - idleTimeoutMillis: options.idleTimeoutMillis || 30_000, + max: options.max || positiveEnvNumber('DB_POOL_MAX', 10), + idleTimeoutMillis: options.idleTimeoutMillis || positiveEnvNumber('DB_IDLE_TIMEOUT_MS', 30_000), + connectionTimeoutMillis: positiveEnvNumber('DB_CONNECTION_TIMEOUT_MS', 5_000), + query_timeout: positiveEnvNumber('DB_QUERY_TIMEOUT_MS', 35_000), + statement_timeout: positiveEnvNumber('DB_STATEMENT_TIMEOUT_MS', 30_000), + lock_timeout: positiveEnvNumber('DB_LOCK_TIMEOUT_MS', 5_000), + idle_in_transaction_session_timeout: positiveEnvNumber('DB_IDLE_IN_TRANSACTION_TIMEOUT_MS', 30_000), + application_name: options.applicationName || process.env.DB_APPLICATION_NAME || 'tiku-saas', + maxUses: positiveEnvNumber('DB_POOL_MAX_USES', 7_500), + maxLifetimeSeconds: positiveEnvNumber('DB_POOL_MAX_LIFETIME_SECONDS', 1_800), }); + pool.on('error', error => { + console.error(JSON.stringify({ + timestamp: new Date().toISOString(), + service: options.applicationName || process.env.DB_APPLICATION_NAME || 'tiku-saas', + event: 'postgres_pool_error', + code: (error as NodeJS.ErrnoException).code || 'PG_POOL_ERROR', + })); + }); + return pool; } export async function query(pool: pg.Pool, sql: string, params: unknown[] = []): Promise { diff --git a/scripts/api-cors-policy-test.js b/scripts/api-cors-policy-test.js new file mode 100644 index 00000000..ec9bfcdf --- /dev/null +++ b/scripts/api-cors-policy-test.js @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { CorsPolicy, normalizeCorsOrigin } from '../apps/api/src/core/cors.ts'; + +assert.deepEqual(normalizeCorsOrigin('HTTPS://Campus-A.Example.com:443'), { + origin: 'https://campus-a.example.com', + hostname: 'campus-a.example.com', + protocol: 'https:', + hasNonDefaultPort: false, +}); +assert.equal(normalizeCorsOrigin('https://campus-a.example.com/path'), null); +assert.equal(normalizeCorsOrigin('https://campus-a.example.com,https://evil.example'), null); +assert.equal(normalizeCorsOrigin('null'), null); + +let now = 1_000; +let lookupCalls = 0; +const domainStates = new Map([ + ['active.example.test', true], + ['inactive.example.test', false], +]); +const policy = new CorsPolicy({ + staticOrigins: ['https://platform.example.test', 'http://localhost:5173'], + tenantDomainsEnabled: true, + positiveCacheTtlMs: 1_000, + negativeCacheTtlMs: 200, + maxCacheEntries: 2, + now: () => now, + lookupTenantDomain: async host => { + lookupCalls += 1; + return domainStates.get(host) === true; + }, +}); + +assert.deepEqual(await policy.evaluate(''), { allowed: true, allowOrigin: null, reason: 'no-origin' }); +assert.equal(lookupCalls, 0, 'non-browser requests without Origin must not query tenant domains'); +assert.deepEqual(await policy.evaluate('HTTPS://PLATFORM.EXAMPLE.TEST:443'), { + allowed: true, + allowOrigin: 'https://platform.example.test', + reason: 'static-origin', +}); +assert.equal(lookupCalls, 0, 'static platform origins must not query tenant domains'); +assert.equal((await policy.evaluate('http://localhost:5173')).allowed, true); +assert.equal((await policy.evaluate('http://active.example.test')).allowed, false, 'dynamic tenant origins must use HTTPS'); +assert.equal((await policy.evaluate('https://active.example.test:8443')).allowed, false, 'dynamic tenant origins must not use custom ports'); +assert.equal((await policy.evaluate('https://127.0.0.1')).allowed, false, 'dynamic tenant origins must not allow loopback hosts'); + +assert.equal((await policy.evaluate('https://active.example.test')).allowed, true); +assert.equal(lookupCalls, 1); +assert.equal((await policy.evaluate('https://active.example.test')).allowed, true); +assert.equal(lookupCalls, 1, 'active tenant domains must use the positive cache'); + +assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, false); +assert.equal(lookupCalls, 2); +domainStates.set('inactive.example.test', true); +now += 199; +assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, false); +assert.equal(lookupCalls, 2, 'inactive tenant domains must use the negative cache before expiry'); +now += 1; +assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, true); +assert.equal(lookupCalls, 3, 'negative cache expiry must refresh the database decision'); + +domainStates.set('active.example.test', false); +now += 799; +assert.equal((await policy.evaluate('https://active.example.test')).allowed, true, 'positive cache must remain stable before expiry'); +now += 1; +assert.equal((await policy.evaluate('https://active.example.test')).allowed, false, 'positive cache expiry must observe a disabled tenant domain'); +assert.equal(lookupCalls, 4); + +assert.equal((await policy.evaluate('https://unknown.example.test')).allowed, false, 'unknown tenant domains must be rejected'); +assert.equal(lookupCalls, 5); +assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, true); +assert.equal(lookupCalls, 6, 'bounded cache must evict the least-recently-used domain after reaching its maximum'); + +let concurrentCalls = 0; +let releaseLookup; +const concurrentPolicy = new CorsPolicy({ + staticOrigins: [], + tenantDomainsEnabled: true, + positiveCacheTtlMs: 1_000, + negativeCacheTtlMs: 200, + maxCacheEntries: 100, + lookupTenantDomain: async () => { + concurrentCalls += 1; + await new Promise(resolve => { releaseLookup = resolve; }); + return true; + }, +}); +const pendingA = concurrentPolicy.evaluate('https://concurrent.example.test'); +const pendingB = concurrentPolicy.evaluate('https://concurrent.example.test'); +await new Promise(resolve => setImmediate(resolve)); +assert.equal(concurrentCalls, 1, 'same-host cache misses must share one database lookup'); +releaseLookup(); +assert.equal((await pendingA).allowed, true); +assert.equal((await pendingB).allowed, true); + +let failureNow = 10_000; +let failureCalls = 0; +let databaseHealthy = false; +const lookupErrors = []; +const failurePolicy = new CorsPolicy({ + staticOrigins: [], + tenantDomainsEnabled: true, + positiveCacheTtlMs: 1_000, + negativeCacheTtlMs: 200, + maxCacheEntries: 100, + now: () => failureNow, + onLookupError: error => lookupErrors.push(error), + lookupTenantDomain: async () => { + failureCalls += 1; + if (!databaseHealthy) throw new Error('database unavailable'); + return true; + }, +}); +const failedLookup = await failurePolicy.evaluate('https://recover.example.test'); +assert.equal(failedLookup.allowed, false, 'tenant domain lookup failures must fail closed'); +assert.equal(failedLookup.reason, 'tenant-domain-lookup-failed'); +assert.equal(lookupErrors.length, 1); +databaseHealthy = true; +failureNow += 199; +const cachedFailure = await failurePolicy.evaluate('https://recover.example.test'); +assert.equal(cachedFailure.allowed, false); +assert.equal(cachedFailure.reason, 'tenant-domain-lookup-failed', 'cached lookup failures must retain their failure reason'); +assert.equal(failureCalls, 1, 'lookup failures must use a short negative cache to avoid database stampedes'); +failureNow += 1; +assert.equal((await failurePolicy.evaluate('https://recover.example.test')).allowed, true); +assert.equal(failureCalls, 2, 'a recovered database must be consulted after negative cache expiry'); + +const disabledPolicy = new CorsPolicy({ + staticOrigins: ['https://platform.example.test'], + tenantDomainsEnabled: false, + positiveCacheTtlMs: 1_000, + negativeCacheTtlMs: 200, + maxCacheEntries: 100, + lookupTenantDomain: async () => true, +}); +assert.equal((await disabledPolicy.evaluate('https://tenant.example.test')).allowed, false); +assert.equal((await disabledPolicy.evaluate('https://platform.example.test')).allowed, true); + +console.log('[PASS] dynamic tenant CORS policy, bounded cache and fail-closed contract'); diff --git a/scripts/api-docker-runtime-contract-test.js b/scripts/api-docker-runtime-contract-test.js new file mode 100644 index 00000000..be6208b5 --- /dev/null +++ b/scripts/api-docker-runtime-contract-test.js @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const dockerfile = fs.readFileSync(path.join(repoRoot, 'apps', 'api', 'Dockerfile'), 'utf8'); + +assert.match( + dockerfile, + /ARG NODE_IMAGE=node:20\.20\.2-alpine3\.23@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293/, + 'API image must pin the reviewed multi-architecture Node/Alpine manifest', +); +assert.match(dockerfile, /FROM deps AS production-deps/); +assert.match(dockerfile, /npm prune --omit=dev --workspaces --include-workspace-root/); +assert.match(dockerfile, /COPY --from=production-deps --chown=node:node \/app\/node_modules \.\/node_modules/); +assert.match(dockerfile, /COPY --from=build --chown=node:node \/app\/apps\/api\/dist \.\/apps\/api\/dist/); +assert.match(dockerfile, /\nUSER node\n/); +assert.match(dockerfile, /CMD \["node", "apps\/api\/dist\/apps\/api\/src\/server\.js"\]/); +assert.doesNotMatch( + dockerfile.slice(dockerfile.lastIndexOf(`FROM \${NODE_IMAGE} AS runner`)), + /package-lock\.json|package\.json|scripts\/import-pocketbase|COPY --from=deps \/app\/node_modules/, + 'runtime stage must contain only production dependencies and compiled API files', +); + +console.log('[PASS] API Docker runtime least-privilege contract'); diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 1c628fff..b59252ad 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -6,8 +6,14 @@ import net from 'node:net'; import pg from 'pg'; import { SignJWT, exportJWK } from 'jose'; import writeXlsxFile from 'write-excel-file/node'; +import { + assertDestructiveTestDatabase, + resolveDestructiveTestConfirmation, +} from './lib/destructive-test-database-guard.js'; const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +const databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL; +const destructiveTestConfirmation = resolveDestructiveTestConfirmation(); const MAIN_TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; const PARTNER_TENANT_ID = process.env.PARTNER_TENANT_ID || '00000000-0000-0000-0000-000000000901'; const USER_ID = process.env.USER_ID || '00000000-0000-0000-0000-000000000101'; @@ -108,6 +114,8 @@ let jwksServer = null; let fakeWechatServer = null; let fakeQqServer = null; let fakeWechatPayServer = null; +let tenantPresentationSnapshot = null; +const providerBillJobIds = new Set(); function buildUrl(path, query = {}) { return buildUrlAt(apiBase, path, query); @@ -250,6 +258,162 @@ async function setTenantFeatureFlag(tenantId, flag, enabled) { } } +async function insertIntegrationAuthSession(tenantId, userId, source) { + const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 }); + try { + const result = await pool.query( + ` + insert into app_private.auth_sessions ( + tenant_id, user_id, token_hash, provider, expires_at, metadata + ) + values ($1, $2, $3, 'integration-test', now() + interval '1 hour', $4::jsonb) + returning id + `, + [ + tenantId, + userId, + crypto.createHash('sha256').update(`${source}:${crypto.randomUUID()}`).digest('hex'), + JSON.stringify({ source: 'api-integration-test', scenario: source }), + ], + ); + return result.rows[0].id; + } finally { + await pool.end(); + } +} + +async function assertIntegrationAuthSessionRevoked(sessionId, message) { + const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 }); + try { + const result = await pool.query( + `select revoked_at as "revokedAt" from app_private.auth_sessions where id = $1`, + [sessionId], + ); + assert.ok(result.rows[0]?.revokedAt, message); + } finally { + await pool.end(); + } +} + +async function cleanupIntegrationAuthSessions() { + const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 }); + try { + await pool.query( + `delete from app_private.auth_sessions where metadata->>'source' = 'api-integration-test'`, + ); + } finally { + await pool.end(); + } +} + +async function cleanupSmsIntegrationRateLimits() { + const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 }); + try { + await pool.query('delete from app_private.sms_send_rate_limits'); + } finally { + await pool.end(); + } +} + +async function assertLocalIntegrationTarget() { + if (!START_SERVER) { + throw new Error( + 'API integration tests are destructive and must use --start-server. Use the dedicated remote smoke commands for deployed environments.', + ); + } + const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 }); + try { + const client = await pool.connect(); + try { + await assertDestructiveTestDatabase({ + client, + databaseUrl, + confirmation: destructiveTestConfirmation, + operation: 'API integration test', + }); + } finally { + client.release(); + } + } finally { + await pool.end(); + } +} + +async function captureTenantPresentationSnapshot() { + const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 }); + try { + const result = await pool.query( + ` + select + (select row_to_json(b) from public.tenant_branding b where b.tenant_id = $1) as branding, + (select row_to_json(c) from public.tenant_theme_configs c where c.tenant_id = $1) as theme + `, + [MAIN_TENANT_ID], + ); + return result.rows[0] || { branding: null, theme: null }; + } finally { + await pool.end(); + } +} + +async function restoreTenantPresentationSnapshot(snapshot) { + if (!snapshot) return; + const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 }); + const client = await pool.connect(); + try { + await client.query('begin'); + await client.query('delete from public.tenant_theme_configs where tenant_id = $1', [MAIN_TENANT_ID]); + await client.query('delete from public.tenant_branding where tenant_id = $1', [MAIN_TENANT_ID]); + if (snapshot.branding) { + await client.query( + `insert into public.tenant_branding select * from json_populate_record(null::public.tenant_branding, $1::json)`, + [JSON.stringify(snapshot.branding)], + ); + } + if (snapshot.theme) { + await client.query( + `insert into public.tenant_theme_configs select * from json_populate_record(null::public.tenant_theme_configs, $1::json)`, + [JSON.stringify(snapshot.theme)], + ); + } + await client.query('commit'); + } catch (error) { + await client.query('rollback').catch(() => undefined); + throw error; + } finally { + client.release(); + await pool.end(); + } +} + +async function cleanupProviderBillJobs(billDate = '') { + const idsToDelete = [...providerBillJobIds]; + if (!billDate && idsToDelete.length === 0) return; + const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 }); + try { + await pool.query( + ` + delete from public.commerce_bill_download_jobs + where tenant_id = $1 + and ( + id = any($2::uuid[]) + or ( + nullif($3::text, '')::date is not null + and provider = 'wechat_pay' + and bill_date = nullif($3::text, '')::date + and bill_type = 'payment' + and metadata->>'source' = 'api-integration-test' + ) + ) + `, + [MAIN_TENANT_ID, idsToDelete, billDate], + ); + idsToDelete.forEach(id => providerBillJobIds.delete(id)); + } finally { + await pool.end(); + } +} + function getFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -313,6 +477,11 @@ async function startServerIfNeeded() { ...process.env, PORT: String(port), DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, + AUTH_SMS_PROVIDER: 'mock', + AUTH_SMS_TENANT_DAILY_LIMIT: '100000', + AUTH_SMS_PHONE_DAILY_LIMIT: '1000', + AUTH_SMS_IP_HOURLY_LIMIT: '100000', + AUTH_SMS_DEVICE_HOURLY_LIMIT: '1000', MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192', MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536', }, @@ -340,6 +509,7 @@ async function startLegacyDisabledServer() { ...process.env, PORT: String(port), DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, + AUTH_SMS_PROVIDER: 'mock', MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192', MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536', ALLOW_LEGACY_AUTH_HEADERS: 'false', @@ -370,6 +540,7 @@ async function startJwksAuthServer(jwksUrl) { ...process.env, PORT: String(port), DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, + AUTH_SMS_PROVIDER: 'mock', MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192', MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536', AUTH_JWT_JWKS_URL: jwksUrl, @@ -873,9 +1044,10 @@ async function testProductionConfigFailFast() { assert.match(logs, /STORAGE_DEFAULT_PROVIDER=local_dev/, 'production fail-fast should reject local_dev storage'); } -async function loginBySms(phone = '13800000000') { +async function loginBySms(phone = '13800000000', options = {}) { const sent = await request('/api/auth/sms/send', { userId: false, + tenantId: options.tenantId, method: 'POST', body: { phone, purpose: 'login' }, }); @@ -883,6 +1055,7 @@ async function loginBySms(phone = '13800000000') { const verified = await request('/api/auth/sms/verify', { userId: false, + tenantId: options.tenantId, method: 'POST', body: { phone, code: sent.debugCode, purpose: 'login' }, }); @@ -900,6 +1073,86 @@ async function sendMockSmsCode(phone, purpose) { return sent.debugCode; } +async function testConcurrentSmsSendReservation() { + const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 }); + const tenantId = crypto.randomUUID(); + const tenantSlug = `sms-concurrency-${Date.now().toString(36)}`; + const phone = `139${String(Date.now()).slice(-8)}`; + const deviceId = `api-integration-sms-${crypto.randomUUID()}`; + + try { + await pool.query( + `insert into public.tenants (id, slug, name, status) values ($1, $2, 'SMS Concurrency Tenant', 'active')`, + [tenantId, tenantSlug], + ); + + const responses = await Promise.all( + Array.from({ length: 8 }, () => fetch(buildUrl('/api/auth/sms/send'), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-tenant-id': tenantId, + 'x-forwarded-for': '198.51.100.27', + }, + body: JSON.stringify({ phone, purpose: 'login', deviceId }), + })), + ); + const results = await Promise.all( + responses.map(async response => ({ + status: response.status, + payload: await response.json().catch(() => ({})), + })), + ); + const accepted = results.filter(result => result.status === 200); + const rejected = results.filter(result => result.status === 429); + + assert.equal(accepted.length, 1, 'concurrent SMS sends must incur provider cost exactly once'); + assert.equal(rejected.length, 7, 'all duplicate concurrent SMS sends must be rate limited'); + assert.ok(accepted[0]?.payload?.debugCode, 'the accepted mock SMS send should expose a debug code'); + assert.ok( + rejected.every(result => result.payload?.code === 'SMS_COOLDOWN'), + 'duplicate concurrent SMS sends must fail through the cooldown reservation', + ); + + const activeReservations = await pool.query( + ` + select count(*)::integer as count + from public.sms_verification_codes + where tenant_id = $1 + and phone = $2 + and purpose = 'login' + and consumed_at is null + and status in ('pending', 'sent') + `, + [tenantId, phone], + ); + assert.equal(activeReservations.rows[0]?.count, 1, 'database must retain one active SMS reservation'); + + const quotaBuckets = await pool.query( + ` + select dimension, request_count as "requestCount" + from app_private.sms_send_rate_limits + where tenant_id = $1 + order by dimension + `, + [tenantId], + ); + assert.deepEqual( + quotaBuckets.rows, + [ + { dimension: 'device', requestCount: 1 }, + { dimension: 'ip', requestCount: 1 }, + { dimension: 'phone', requestCount: 1 }, + { dimension: 'tenant', requestCount: 1 }, + ], + 'rolled-back duplicate sends must not consume extra SMS quota', + ); + } finally { + await pool.query('delete from public.tenants where id = $1', [tenantId]).catch(() => undefined); + await pool.end(); + } +} + async function testTrustedSessionIdentity() { const login = await loginBySms(); assert.equal(login.user?.id, USER_ID, 'smoke phone should log in as smoke user'); @@ -969,6 +1222,136 @@ async function testTrustedSessionIdentity() { assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers'); } +async function testAuthStatusEnforcement() { + const pool = new pg.Pool({ connectionString: databaseUrl }); + const original = await pool.query( + ` + select t.status as "tenantStatus", u.status as "userStatus", tm.status as "membershipStatus" + from public.tenants t + join public.platform_users u on u.id = $2 + join public.tenant_memberships tm + on tm.tenant_id = t.id and tm.user_id = u.id and tm.role = 'student' + where t.id = $1 + `, + [MAIN_TENANT_ID, USER_ID], + ); + const snapshot = original.rows[0]; + assert.ok(snapshot, 'auth status test requires the smoke student membership'); + + try { + const membershipLogin = await loginBySms(); + await pool.query( + `update public.tenant_memberships set status = 'disabled', updated_at = now() + where tenant_id = $1 and user_id = $2 and role = 'student'`, + [MAIN_TENANT_ID, USER_ID], + ); + const disabledMembershipSession = await request('/api/auth/me', { + userId: false, + headers: { authorization: `Bearer ${membershipLogin.session.token}` }, + expectStatus: 401, + }); + assert.equal(disabledMembershipSession.code, 'AUTH_SESSION_INVALID', 'disabled membership must invalidate existing app sessions'); + + const sentForDisabledMembership = await request('/api/auth/sms/send', { + userId: false, + method: 'POST', + body: { phone: '13800000000', purpose: 'login' }, + }); + const disabledMembershipLogin = await request('/api/auth/sms/verify', { + userId: false, + method: 'POST', + body: { phone: '13800000000', code: sentForDisabledMembership.debugCode, purpose: 'login' }, + expectStatus: 403, + }); + assert.equal(disabledMembershipLogin.code, 'AUTH_MEMBERSHIP_INACTIVE', 'login must not reactivate a disabled membership'); + const membershipAfterLogin = await pool.query( + `select status from public.tenant_memberships where tenant_id = $1 and user_id = $2 and role = 'student'`, + [MAIN_TENANT_ID, USER_ID], + ); + assert.equal(membershipAfterLogin.rows[0]?.status, 'disabled', 'failed login must preserve disabled membership status'); + + await pool.query( + `update public.tenant_memberships set status = 'invited', updated_at = now() + where tenant_id = $1 and user_id = $2 and role = 'student'`, + [MAIN_TENANT_ID, USER_ID], + ); + const invitedMembershipLogin = await request('/api/auth/sms/verify', { + userId: false, + method: 'POST', + body: { phone: '13800000000', code: sentForDisabledMembership.debugCode, purpose: 'login' }, + expectStatus: 403, + }); + assert.equal(invitedMembershipLogin.code, 'AUTH_MEMBERSHIP_INACTIVE', 'login must not activate an invited membership'); + await pool.query( + ` + delete from public.sms_verification_codes + where tenant_id = $1 + and phone = '13800000000' + and consumed_at is null + `, + [MAIN_TENANT_ID], + ); + + await pool.query( + `update public.tenant_memberships set status = 'active', updated_at = now() + where tenant_id = $1 and user_id = $2 and role = 'student'`, + [MAIN_TENANT_ID, USER_ID], + ); + await pool.query(`update public.platform_users set status = 'disabled', updated_at = now() where id = $1`, [USER_ID]); + const disabledUserJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' }); + const disabledUserDenied = await request('/api/auth/me', { + userId: false, + headers: { authorization: `Bearer ${disabledUserJwt}` }, + expectStatus: 401, + }); + assert.equal(disabledUserDenied.code, 'AUTH_SESSION_INVALID', 'disabled platform user must not authenticate with Supabase JWT'); + + await pool.query(`update public.platform_users set status = 'active', updated_at = now() where id = $1`, [USER_ID]); + await pool.query(`update public.tenants set status = 'suspended', updated_at = now() where id = $1`, [MAIN_TENANT_ID]); + const suspendedTenantJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' }); + const suspendedTenantDenied = await request('/api/auth/me', { + userId: false, + headers: { authorization: `Bearer ${suspendedTenantJwt}` }, + expectStatus: 401, + }); + assert.equal(suspendedTenantDenied.code, 'AUTH_SESSION_INVALID', 'suspended tenant must reject non-platform JWT identity'); + + const newTenantId = crypto.randomUUID(); + const newTenantSlug = `auth-status-${Date.now().toString(36)}`; + const newPhone = `137${String(Date.now()).slice(-8)}`; + await pool.query( + `insert into public.tenants (id, slug, name, status) values ($1, $2, 'Auth Status Tenant', 'active')`, + [newTenantId, newTenantSlug], + ); + const firstLogin = await loginBySms(newPhone, { tenantId: newTenantId }); + assert.equal(firstLogin.isNewUser, true, 'first tenant login should create an active student membership'); + const firstMembership = await pool.query( + `select status from public.tenant_memberships where tenant_id = $1 and user_id = $2 and role = 'student'`, + [newTenantId, firstLogin.user.id], + ); + assert.equal(firstMembership.rows[0]?.status, 'active', 'new login membership must start active'); + await pool.query('delete from public.tenants where id = $1', [newTenantId]); + await pool.query('delete from public.platform_users where id = $1', [firstLogin.user.id]); + + const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, { phone: '13999999999' }); + const platformOverview = await request('/api/platform-admin/overview', { + tenantId: false, + userId: false, + headers: { authorization: `Bearer ${platformAdminJwt}` }, + }); + assert.ok(platformOverview.item?.tenants?.total >= 1, 'global platform admin must remain available while a tenant is suspended'); + } finally { + await pool.query(`update public.tenants set status = $2, updated_at = now() where id = $1`, [MAIN_TENANT_ID, snapshot.tenantStatus]); + await pool.query(`update public.platform_users set status = $2, updated_at = now() where id = $1`, [USER_ID, snapshot.userStatus]); + await pool.query( + `update public.tenant_memberships set status = $3, updated_at = now() + where tenant_id = $1 and user_id = $2 and role = 'student'`, + [MAIN_TENANT_ID, USER_ID, snapshot.membershipStatus], + ); + await pool.end(); + } +} + async function testPhoneBinding() { const phoneSuffix = String(Date.now()).slice(-6); const oldPhone = `13920${phoneSuffix}`; @@ -1082,14 +1465,42 @@ async function testSupabaseJwtIdentity() { const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, { phone: '13999999999', - appRole: 'platform_admin', }); const platformOverview = await request('/api/platform-admin/overview', { tenantId: false, userId: false, headers: { authorization: `Bearer ${platformAdminJwt}` }, }); - assert.ok(platformOverview.item?.tenants?.total >= 1, 'platform admin Supabase JWT should access platform overview'); + assert.ok( + platformOverview.item?.tenants?.total >= 1, + 'database platform admin should accept a standard Supabase role=authenticated JWT without app_role', + ); + + const globallyScopedPlatformOverview = await request('/api/platform-admin/overview', { + tenantId: PARTNER_TENANT_ID, + userId: false, + headers: { authorization: `Bearer ${platformAdminJwt}` }, + }); + assert.ok( + globallyScopedPlatformOverview.item?.tenants?.total >= 1, + 'global platform admin should not require membership in a requested tenant context', + ); + + const elevatedStudentJwt = await createSupabaseJwt(AUTH_USER_ID, { + phone: '13800000000', + appRole: 'platform_admin', + }); + const elevatedStudentDenied = await request('/api/platform-admin/overview', { + tenantId: false, + userId: false, + headers: { authorization: `Bearer ${elevatedStudentJwt}` }, + expectStatus: 403, + }); + assert.equal( + elevatedStudentDenied.code, + 'PLATFORM_ADMIN_REQUIRED', + 'JWT app_role must not elevate a non-platform database user', + ); const studentPlatformDenied = await request('/api/platform-admin/overview', { tenantId: false, @@ -1570,6 +1981,7 @@ async function testPlatformTenantOperationsAndAudit() { assert.ok(created.item?.id, 'platform admin should create a tenant'); const tenantId = created.item.id; + const tenantSessionId = await insertIntegrationAuthSession(tenantId, USER_ID, 'platform-tenant-suspend'); const detail = await request('/api/platform-admin/tenants/detail', { tenantId: false, userId: false, @@ -1600,6 +2012,24 @@ async function testPlatformTenantOperationsAndAudit() { }); assert.equal(billing.item?.billingName, '集成测试更新主体', 'platform admin should update tenant billing profile'); + const suspended = await request('/api/platform-admin/tenants/status', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'PATCH', + body: { + tenantId, + status: 'suspended', + billingStatus: 'active', + reason: 'integration audit coverage', + }, + }); + assert.equal(suspended.item?.status, 'suspended', 'platform admin should suspend a tenant'); + await assertIntegrationAuthSessionRevoked( + tenantSessionId, + 'suspending a tenant must revoke all tenant sessions in the same operation', + ); + const status = await request('/api/platform-admin/tenants/status', { tenantId: false, userId: false, @@ -1608,8 +2038,7 @@ async function testPlatformTenantOperationsAndAudit() { body: { tenantId, status: 'active', - billingStatus: 'active', - reason: 'integration audit coverage', + reason: 'restore after session revocation coverage', }, }); assert.equal(status.item?.billingStatus, 'active', 'platform admin should update tenant billing status'); @@ -4186,6 +4615,7 @@ async function testCommerce() { }); const billDate = shanghaiDateKey(); + await cleanupProviderBillJobs(billDate); const reconciliationRows = [ { transactionType: 'payment', @@ -4441,6 +4871,7 @@ async function testCommerce() { }, }); assert.ok(providerBillJob.item?.id, 'tenant admin should request official provider bill download job'); + providerBillJobIds.add(providerBillJob.item.id); assert.equal(providerBillJob.item?.status, 'queued', 'new provider bill job should be queued'); assert.equal(providerBillJob.item?.provider, 'wechat_pay', 'provider bill job should keep provider'); assert.ok(!JSON.stringify(providerBillJob).includes(paymentFixture.wechatApiV3Key), 'provider bill job response must not leak payment secrets'); @@ -4476,6 +4907,8 @@ async function testCommerce() { }); assert.equal(crossTenantProviderBillJobsDenied.code, 'TENANT_ADMIN_REQUIRED', 'provider bill jobs must be tenant isolated'); + await cleanupProviderBillJobs(billDate); + const operationReconItems = await request('/api/commerce/reconciliation/items', { userId: TENANT_ADMIN_USER_ID, query: { batchId: reconciliationImport.item.id, matchStatus: 'amount_mismatch' }, @@ -7945,12 +8378,48 @@ async function testTenantAdminOps() { assert.equal(tenantThemeAfterPublish.item?.draftTemplateCode, null, 'theme publish should clear draft template'); assert.equal(tenantThemeAfterPublish.item?.activePublicAssets?.iconSet, 'focus', 'theme publish should expose active public assets'); + await request('/api/tenant-admin/branding', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + brandName: '集成测试品牌', + shortName: '集测题库', + theme: { primaryColor: '#0f766e' }, + publicAssets: { iconSet: 'classic' }, + }, + }); + const resolvedTenantTheme = await request('/api/tenant/resolve', { userId: false, tenantId: false, query: { tenantCode: 'master' }, }); assert.equal(resolvedTenantTheme.branding?.theme?.primaryColor, '#123abc', 'tenant resolve should return published theme tokens to frontend'); + assert.equal(resolvedTenantTheme.branding?.publicAssets?.iconSet, 'focus', 'tenant resolve should prefer published theme assets over branding fallback assets'); + + const themePool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL }); + try { + await themePool.query( + ` + update public.tenant_theme_configs + set active_theme = '{}'::jsonb, + active_public_assets = '{}'::jsonb, + status = 'published', + published_at = now() + where tenant_id = $1 + `, + [MAIN_TENANT_ID], + ); + } finally { + await themePool.end(); + } + const resolvedTenantBrandingFallback = await request('/api/tenant/resolve', { + userId: false, + tenantId: false, + query: { tenantCode: 'master' }, + }); + assert.equal(resolvedTenantBrandingFallback.branding?.theme?.primaryColor, '#0f766e', 'empty published theme should fall back to tenant branding tokens'); + assert.equal(resolvedTenantBrandingFallback.branding?.publicAssets?.iconSet, 'classic', 'empty published assets should fall back to tenant branding assets'); const themeAuditLogs = await request('/api/tenant-admin/audit-logs', { userId: TENANT_ADMIN_USER_ID, @@ -9139,6 +9608,8 @@ async function testTenantMemberPermissionsAndAudit() { }); assert.ok(salesBatch.item?.id, 'sales role should create code batch'); + const salesSessionId = await insertIntegrationAuthSession(MAIN_TENANT_ID, TENANT_SALES_USER_ID, 'tenant-member-disable'); + const salesBrandingDenied = await request('/api/tenant-admin/branding', { userId: TENANT_SALES_USER_ID, method: 'PUT', @@ -9155,6 +9626,10 @@ async function testTenantMemberPermissionsAndAudit() { body: { membershipId: sales.item.id }, }); assert.equal(disableSales.item?.status, 'disabled', 'tenant admin should disable sales membership'); + await assertIntegrationAuthSessionRevoked( + salesSessionId, + 'disabling a tenant member must revoke that user\'s tenant sessions in the same operation', + ); const disabledSalesDenied = await request('/api/tenant-admin/code-batches', { userId: TENANT_SALES_USER_ID, @@ -9274,7 +9749,7 @@ async function testTenantClassStudentScopes() { userId: USER_ID, username: 'smoke_student', phone: '13800000000', - name: 'Smoke Student', + name: 'Cursor Cohort Smoke Student', regionId: ids.region, status: 'active', }, @@ -9288,13 +9763,40 @@ async function testTenantClassStudentScopes() { userId: SECOND_STUDENT_USER_ID, username: 'integration_second_student', phone: '13800000016', - name: 'Integration Second Student', + name: 'Cursor Cohort Integration Student', regionId: ids.region, status: 'active', }, }); assert.equal(secondStudent.item?.userId, SECOND_STUDENT_USER_ID, 'tenant admin should upsert another student'); + const studentPageOne = await request('/api/tenant-admin/students', { + userId: TENANT_ADMIN_USER_ID, + query: { keyword: 'Cursor Cohort', limit: 1 }, + }); + assert.equal(studentPageOne.items?.length, 1, 'student cursor page should honor its limit'); + assert.equal(studentPageOne.hasMore, true, 'student cursor page should report more matching rows'); + assert.ok(studentPageOne.nextCursor, 'student cursor page should return an opaque next cursor'); + + const studentPageTwo = await request('/api/tenant-admin/students', { + userId: TENANT_ADMIN_USER_ID, + query: { keyword: 'Cursor Cohort', limit: 1, cursor: studentPageOne.nextCursor }, + }); + assert.equal(studentPageTwo.items?.length, 1, 'second student cursor page should contain the next row'); + assert.notEqual( + studentPageTwo.items?.[0]?.membershipId, + studentPageOne.items?.[0]?.membershipId, + 'student cursor pages must not repeat the boundary membership', + ); + assert.equal(studentPageTwo.hasMore, false, 'second student cursor page should reach the end of the fixture'); + + const invalidStudentCursor = await request('/api/tenant-admin/students', { + userId: TENANT_ADMIN_USER_ID, + query: { cursor: 'not-a-valid-cursor' }, + expectStatus: 400, + }); + assert.equal(invalidStudentCursor.code, 'INVALID_STUDENT_CURSOR', 'student list should reject malformed cursors'); + const teacherAssignment = await request('/api/tenant-admin/classes/members', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', @@ -9509,6 +10011,8 @@ async function testTenantStudentOperations() { }); assert.ok(adminClassStudents.items?.some(item => item.userId === bulkStudentUserId), 'bulk assigned student should appear in class student list'); + const studentSessionId = await insertIntegrationAuthSession(MAIN_TENANT_ID, bulkStudentUserId, 'tenant-student-disable'); + const disabled = await request('/api/tenant-admin/students/status', { userId: TENANT_ADMIN_USER_ID, method: 'POST', @@ -9519,6 +10023,10 @@ async function testTenantStudentOperations() { }, }); assert.equal(disabled.item?.status, 'disabled', 'tenant admin should disable student membership'); + await assertIntegrationAuthSessionRevoked( + studentSessionId, + 'disabling a student must revoke that student\'s tenant sessions in the same operation', + ); const disabledStudents = await request('/api/tenant-admin/students', { userId: TENANT_ADMIN_USER_ID, @@ -10620,13 +11128,22 @@ async function testReferralAndCrmGrowth() { } async function main() { + let primaryError = null; + let destructiveTargetApproved = false; try { + await assertLocalIntegrationTarget(); + destructiveTargetApproved = true; + tenantPresentationSnapshot = await captureTenantPresentationSnapshot(); + await cleanupIntegrationAuthSessions(); + await cleanupSmsIntegrationRateLimits(); await check('production config fail-fast', testProductionConfigFailFast); await startServerIfNeeded(); console.log(`[INFO] API integration target: ${apiBase}`); await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true))); + await check('concurrent SMS send reservation', testConcurrentSmsSendReservation); await check('trusted session identity', testTrustedSessionIdentity); + await check('auth status enforcement', testAuthStatusEnforcement); await check('phone binding', testPhoneBinding); await check('Supabase JWT identity', testSupabaseJwtIdentity); await check('platform admin permissions', testPlatformAdminPermissions); @@ -10656,9 +11173,27 @@ async function main() { await check('referral and CRM growth', testReferralAndCrmGrowth); console.log('API integration tests complete.'); + } catch (error) { + primaryError = error; } finally { stopServer(); + const cleanupResults = destructiveTargetApproved + ? await Promise.allSettled([ + cleanupProviderBillJobs(shanghaiDateKey()), + cleanupIntegrationAuthSessions(), + cleanupSmsIntegrationRateLimits(), + restoreTenantPresentationSnapshot(tenantPresentationSnapshot), + ]) + : []; + const cleanupFailures = cleanupResults.filter(result => result.status === 'rejected'); + if (cleanupFailures.length > 0) { + primaryError = new AggregateError( + [primaryError, ...cleanupFailures.map(result => result.reason)].filter(Boolean), + 'API integration cleanup failed', + ); + } } + if (primaryError) throw primaryError; } main().catch(error => { diff --git a/scripts/api-server-operations-contract-test.js b/scripts/api-server-operations-contract-test.js new file mode 100644 index 00000000..82536f68 --- /dev/null +++ b/scripts/api-server-operations-contract-test.js @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import net from 'node:net'; +import { spawn } from 'node:child_process'; + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close(error => error ? reject(error) : resolve(port)); + }); + }); +} + +function waitFor(predicate, timeoutMs = 10_000) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const timer = setInterval(() => { + const value = predicate(); + if (value) { + clearInterval(timer); + resolve(value); + } else if (Date.now() - startedAt > timeoutMs) { + clearInterval(timer); + reject(new Error('timed out waiting for API server operation')); + } + }, 25); + }); +} + +function rawRequest(port, { method = 'GET', path = '/', headers = {} } = {}) { + return new Promise((resolve, reject) => { + const request = http.request({ hostname: '127.0.0.1', port, method, path, headers }, response => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { body += chunk; }); + response.on('end', () => resolve({ statusCode: response.statusCode, headers: response.headers, body })); + }); + request.once('error', reject); + request.end(); + }); +} + +const port = await freePort(); +const child = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { + cwd: process.cwd(), + env: { + ...process.env, + NODE_ENV: 'development', + PORT: String(port), + API_SHUTDOWN_GRACE_PERIOD_MS: '2000', + CORS_ORIGIN: 'https://platform.example.test', + CORS_TENANT_DOMAINS_ENABLED: 'false', + }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let output = ''; +child.stdout.on('data', chunk => { output += chunk.toString(); }); +child.stderr.on('data', chunk => { output += chunk.toString(); }); + +try { + await waitFor(() => output.includes('"event":"server_listening"')); + const providedRequestId = 'operations-contract-123'; + const response = await fetch(`http://127.0.0.1:${port}/not-found?secret=query-value`, { + headers: { 'x-request-id': providedRequestId }, + }); + const body = await response.json(); + assert.equal(response.status, 404); + assert.equal(response.headers.get('x-request-id'), providedRequestId); + assert.equal(body.requestId, providedRequestId); + assert.equal(body.meta.requestId, providedRequestId); + await waitFor(() => output.includes('"event":"http_request"')); + assert.match(output, /"requestId":"operations-contract-123"/); + assert.match(output, /"path":"\/not-found"/); + assert.ok(!output.includes('query-value'), 'structured access logs must not record query strings'); + + const allowedPreflight = await rawRequest(port, { + method: 'OPTIONS', + path: '/api/platform-admin/tenants', + headers: { origin: 'https://platform.example.test', 'access-control-request-method': 'GET' }, + }); + assert.equal(allowedPreflight.statusCode, 204); + assert.equal(allowedPreflight.headers['access-control-allow-origin'], 'https://platform.example.test'); + assert.match(String(allowedPreflight.headers.vary || ''), /origin/i); + + const deniedPreflight = await rawRequest(port, { + method: 'OPTIONS', + path: '/health', + headers: { + origin: 'https://unknown.example.test', + host: 'platform.example.test', + 'x-forwarded-host': 'platform.example.test', + 'x-tenant-code': 'master', + 'access-control-request-method': 'GET', + }, + }); + assert.equal(deniedPreflight.statusCode, 403, 'unknown Origin preflight must be explicitly rejected'); + assert.equal(deniedPreflight.headers['access-control-allow-origin'], undefined); + assert.equal(JSON.parse(deniedPreflight.body).code, 'CORS_ORIGIN_DENIED'); + + const deniedRequest = await rawRequest(port, { + path: '/not-found', + headers: { + origin: 'https://unknown.example.test', + host: 'platform.example.test', + 'x-forwarded-host': 'platform.example.test', + }, + }); + assert.equal(deniedRequest.statusCode, 403, 'spoofed Host headers must not bypass Origin validation'); + assert.equal(JSON.parse(deniedRequest.body).code, 'CORS_ORIGIN_DENIED'); + + const duplicateOriginRequest = await rawRequest(port, { + path: '/not-found', + headers: { origin: ['https://platform.example.test', 'https://unknown.example.test'] }, + }); + assert.equal(duplicateOriginRequest.statusCode, 403, 'duplicate Origin headers must be rejected'); + assert.equal(JSON.parse(duplicateOriginRequest.body).code, 'CORS_ORIGIN_DENIED'); + + const originlessHealthPreflight = await rawRequest(port, { method: 'OPTIONS', path: '/health' }); + assert.equal(originlessHealthPreflight.statusCode, 204, 'originless health checks must not be blocked by CORS'); + assert.equal(originlessHealthPreflight.headers['access-control-allow-origin'], undefined); + + child.kill('SIGTERM'); + const exit = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('API server did not exit after SIGTERM')), 7_000); + child.once('exit', (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + }); + assert.equal(exit.code, 0, `API server should gracefully exit: ${output}`); + assert.match(output, /"event":"shutdown_started"/); + assert.match(output, /"event":"shutdown_complete"/); +} finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); +} + +console.log('[PASS] API operations, fail-closed CORS and graceful shutdown contract'); diff --git a/scripts/audit-log-capacity-contract-test.js b/scripts/audit-log-capacity-contract-test.js new file mode 100644 index 00000000..37c2111e --- /dev/null +++ b/scripts/audit-log-capacity-contract-test.js @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const migration = fs.readFileSync( + 'supabase/migrations/202607120014_audit_log_capacity_indexes.sql', + 'utf8', +); +const tenantRoutes = fs.readFileSync('apps/api/src/features/tenant-admin/routes.ts', 'utf8'); +const platformRoutes = fs.readFileSync('apps/api/src/features/platform-admin/routes.ts', 'utf8'); +const alertWorker = fs.readFileSync('apps/worker/src/jobs/platform-audit-alerts.ts', 'utf8'); + +for (const indexName of [ + 'idx_audit_logs_created', + 'idx_audit_logs_tenant_created', + 'idx_audit_logs_tenant_actor_created', + 'idx_audit_logs_tenant_target_created', + 'idx_audit_logs_platform_created', +]) { + assert.match(migration, new RegExp(`create index if not exists ${indexName}\\b`, 'i')); +} + +assert.match(migration, /\(tenant_id, created_at desc, id desc\)/i); +assert.match(migration, /\(tenant_id, actor_user_id, created_at desc, id desc\)/i); +assert.match(migration, /\(tenant_id, target_type, created_at desc, id desc\)/i); +assert.match(migration, /where action like 'platform\.%'/i); + +assert.match(tenantRoutes, /const filters = \['al\.tenant_id = \$1'\]/i); +assert.match(tenantRoutes, /from public\.audit_logs al[\s\S]+order by al\.created_at desc/i); +assert.match(platformRoutes, /from public\.audit_logs al[\s\S]+order by al\.created_at desc/i); +assert.match(alertWorker, /from public\.audit_logs al[\s\S]+al\.action like 'platform\.%'[\s\S]+al\.created_at >=/i); + +console.log('[PASS] audit log capacity index contract'); diff --git a/scripts/auth-context-platform-admin-test.js b/scripts/auth-context-platform-admin-test.js new file mode 100644 index 00000000..6321e049 --- /dev/null +++ b/scripts/auth-context-platform-admin-test.js @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; + +process.env.NODE_ENV = 'development'; +const { findUserBySessionToken, findUserByVerifiedSupabasePayload } = await import('../apps/api/src/core/auth-context.ts'); + +const AUTH_USER_ID = '11111111-1111-4111-8111-111111111111'; +const TENANT_ID = '22222222-2222-4222-8222-222222222222'; +const OTHER_TENANT_ID = '33333333-3333-4333-8333-333333333333'; +const platformSession = { + id: '44444444-4444-4444-8444-444444444444', + username: 'platform-admin', + phone: null, + name: 'Platform Admin', + avatarUrl: null, + primaryRole: 'platform_admin', + createdAt: new Date(0).toISOString(), + tenantId: null, + sessionId: AUTH_USER_ID, + sessionExpiresAt: new Date(Date.now() + 60_000).toISOString(), + authSource: 'supabase_jwt', + authUserId: AUTH_USER_ID, + platformPermissions: { '*': true }, +}; + +{ + const calls = []; + const tenantSession = { ...platformSession, primaryRole: 'student', tenantId: TENANT_ID, authSource: 'app_session' }; + const session = await findUserBySessionToken('tk_test_session', async (sql, params) => { + calls.push({ sql, params }); + return tenantSession; + }); + assert.equal(session, tenantSession); + assert.match(calls[0].sql, /join public\.tenants t on t\.id = s\.tenant_id/); + assert.match(calls[0].sql, /t\.status = 'active'/); + assert.match(calls[0].sql, /tm\.status = 'active'/); + assert.match(calls[0].sql, /u\.status = 'active'/); +} + +{ + const calls = []; + const session = await findUserByVerifiedSupabasePayload({ + sub: AUTH_USER_ID, + role: 'authenticated', + app_metadata: { provider: 'phone' }, + }, '', async (sql, params) => { + calls.push({ sql, params }); + return platformSession; + }); + assert.equal(session, platformSession, 'standard Supabase role=authenticated must not downgrade a database platform admin'); + assert.equal(calls.length, 1); + assert.equal(calls[0].sql.includes('tenant_memberships'), false, 'global platform lookup must not require tenant membership'); +} + +{ + const calls = []; + const session = await findUserByVerifiedSupabasePayload({ + sub: AUTH_USER_ID, + role: 'authenticated', + }, OTHER_TENANT_ID, async (sql, params) => { + calls.push({ sql, params }); + return platformSession; + }); + assert.equal(session?.primaryRole, 'platform_admin'); + assert.equal(session?.tenantId, null, 'global platform identity must not acquire tenant membership from request context'); + assert.equal(calls.length, 1, 'platform admin should resolve before tenant membership lookup'); +} + +{ + const calls = []; + const session = await findUserByVerifiedSupabasePayload({ + sub: AUTH_USER_ID, + role: 'service_role', + app_role: 'platform_admin', + app_metadata: { app_role: 'platform_admin' }, + }, '', async (sql, params) => { + calls.push({ sql, params }); + return null; + }); + assert.equal(session, null, 'JWT role claims alone must never create platform authority'); + assert.equal(calls.length, 1, 'JWT-only elevation must stop after authoritative platform lookup fails'); +} + +{ + const calls = []; + const studentSession = { ...platformSession, primaryRole: 'student', tenantId: TENANT_ID, platformPermissions: {} }; + const session = await findUserByVerifiedSupabasePayload({ + sub: AUTH_USER_ID, + role: 'authenticated', + app_metadata: { app_role: 'platform_admin' }, + }, TENANT_ID, async (sql, params) => { + calls.push({ sql, params }); + return calls.length === 1 ? null : studentSession; + }); + assert.equal(session?.primaryRole, 'student', 'a malicious app_role claim must not elevate a tenant member'); + assert.equal(calls.length, 2); + assert.match(calls[1].sql, /join public\.tenants t on t\.id = tm\.tenant_id/); + assert.match(calls[1].sql, /t\.status = 'active'/); + assert.match(calls[1].sql, /tm\.status = 'active'/); + assert.match(calls[1].sql, /u\.status = 'active'/); +} + +{ + const calls = []; + const session = await findUserByVerifiedSupabasePayload({ + sub: AUTH_USER_ID, + role: 'authenticated', + app_metadata: { tenant_id: TENANT_ID }, + }, OTHER_TENANT_ID, async (sql, params) => { + calls.push({ sql, params }); + return platformSession; + }); + assert.equal(session?.primaryRole, 'platform_admin', 'database platform admin may select a tenant beyond its JWT default claim'); + assert.equal(session?.tenantId, null, 'JWT tenant defaults must not become implicit platform membership'); + assert.equal(calls.length, 1); +} + +{ + let queryCount = 0; + const session = await findUserByVerifiedSupabasePayload({ + sub: AUTH_USER_ID, + role: 'authenticated', + app_metadata: { tenant_id: TENANT_ID }, + }, OTHER_TENANT_ID, async () => { + queryCount += 1; + return null; + }); + assert.equal(session, null, 'signed tenant claim must not be overwritten by request context'); + assert.equal(queryCount, 1, 'tenant mismatch may perform only the authoritative global platform lookup'); +} + +console.log('[PASS] Supabase JWT platform authority contract'); diff --git a/scripts/auto-badge-concurrency-test.js b/scripts/auto-badge-concurrency-test.js index 460c5df2..6df92440 100644 --- a/scripts/auto-badge-concurrency-test.js +++ b/scripts/auto-badge-concurrency-test.js @@ -1,11 +1,16 @@ import assert from 'node:assert/strict'; import pg from 'pg'; import { autoGrantBadges } from '../apps/api/src/features/profile/badges.ts'; +import { + assertDestructiveTestDatabase, + resolveDestructiveTestConfirmation, +} from './lib/destructive-test-database-guard.js'; const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; const USER_ID = process.env.USER_ID || '00000000-0000-0000-0000-000000000101'; const BADGE_ID = '00000000-0000-0000-0000-00000000b881'; +const destructiveTestConfirmation = resolveDestructiveTestConfirmation(); const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 8 }); @@ -76,6 +81,12 @@ async function grantOnce(index) { async function main() { try { + await assertDestructiveTestDatabase({ + client: pool, + databaseUrl: DATABASE_URL, + confirmation: destructiveTestConfirmation, + operation: 'auto badge concurrency test', + }); await resetFixture(); const results = await Promise.all(Array.from({ length: 20 }, (_, index) => grantOnce(index))); const grantedRows = results.flat().filter(item => item.badgeId === BADGE_ID); diff --git a/scripts/backend-runtime-role-contract-test.js b/scripts/backend-runtime-role-contract-test.js new file mode 100644 index 00000000..bd67c6e8 --- /dev/null +++ b/scripts/backend-runtime-role-contract-test.js @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const migration = fs.readFileSync( + path.join(repoRoot, 'supabase', 'migrations', '202607120013_backend_runtime_roles.sql'), + 'utf8', +); +const authBoundaryMigration = fs.readFileSync( + path.join(repoRoot, 'supabase', 'migrations', '202607120018_auth_user_reference_boundary.sql'), + 'utf8', +); +const migrationHistoryBoundaryMigration = fs.readFileSync( + path.join(repoRoot, 'supabase', 'migrations', '202607120019_production_migration_history_boundary.sql'), + 'utf8', +); +const safetyMigration = fs.readFileSync( + path.join(repoRoot, 'supabase', 'migrations', '202607120001_destructive_test_environment_safety.sql'), + 'utf8', +); +const readiness = fs.readFileSync( + path.join(repoRoot, 'scripts', 'production-readiness-check.js'), + 'utf8', +); +const apiEnv = fs.readFileSync( + path.join(repoRoot, 'scripts', 'deploy', 'env', 'api.env.example'), + 'utf8', +); +const workerEnv = fs.readFileSync( + path.join(repoRoot, 'scripts', 'deploy', 'env', 'worker.env.example'), + 'utf8', +); +const destructiveGuard = fs.readFileSync( + path.join(repoRoot, 'scripts', 'lib', 'destructive-test-database-guard.js'), + 'utf8', +); +const platformAdminRoutes = fs.readFileSync( + path.join(repoRoot, 'apps', 'api', 'src', 'features', 'platform-admin', 'routes.ts'), + 'utf8', +); +const platformAdminBootstrap = fs.readFileSync( + path.join(repoRoot, 'scripts', 'bootstrap-platform-admin.js'), + 'utf8', +); +const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + +for (const role of ['tiku_api', 'tiku_worker']) { + assert.match(destructiveGuard, new RegExp(`['"]${role}['"]`)); +} +assert.doesNotMatch( + migration, + /create role tiku_(?:api|worker)|alter role tiku_(?:api|worker)/i, + 'normal Supabase migrations must not require superuser-only cluster role changes', +); +assert.match(migration, /not role_state\.rolbypassrls/i); +assert.match(migration, /role_state\.has_parent_roles/i); + +assert.match(migration, /search_path=pg_catalog, public, extensions/i); +assert.match(migration, /revoke create on schema public, app, app_private, extensions from public/i); +assert.match(migration, /revoke all privileges on schema public, app, app_private, extensions from tiku_api, tiku_worker/i); +assert.match(migration, /grant usage on schema public, app_private, extensions to tiku_api, tiku_worker/i); +assert.match(migration, /grant usage on schema app to tiku_api/i); +assert.doesNotMatch(migration, /grant usage on schema app to tiku_worker/i); + +assert.match( + migration, + /grant select, insert, update, delete[\s\S]*on all tables in schema public[\s\S]*to tiku_api, tiku_worker/i, +); +assert.match( + migration, + /grant select[\s\S]*on all tables in schema app_private[\s\S]*to tiku_api, tiku_worker/i, +); +assert.match( + migration, + /grant insert, update[\s\S]*on app_private\.auth_sessions,[\s\S]*app_private\.tenant_secrets,[\s\S]*app_private\.platform_secrets[\s\S]*to tiku_api/i, +); +assert.match( + migration, + /grant insert, update, delete[\s\S]*on app_private\.sms_send_rate_limits[\s\S]*to tiku_api/i, +); +assert.doesNotMatch(migration, /grant[^;]*truncate[^;]*to tiku_api|grant[^;]*truncate[^;]*to tiku_worker/i); + +assert.match(migration, /revoke execute on all functions in schema app from public/i); +assert.match( + migration, + /alter table %I\.%I alter column %I set default pg_catalog\.gen_random_uuid\(\)/i, + 'UUID defaults must be independent of the pgcrypto extension schema', +); +assert.doesNotMatch( + migration, + /grant execute on function (?:public|extensions)\.gen_random_uuid\(\)/i, + 'runtime roles must rely on the PostgreSQL core UUID function instead of an extension wrapper', +); +assert.match(migration, /grant execute on function app\.public_question_bank_grant_allows[\s\S]*to tiku_api/i); +assert.doesNotMatch( + migration, + /grant execute[\s\S]*on all functions in schema (?:public|app|app_private)[\s\S]*to tiku_api/i, +); +assert.match( + migration, + /pg_has_role\(current_user, owner_role\.oid, 'MEMBER'\)/i, + 'default ACL loops must skip Supabase-owned roles the migration user cannot SET ROLE into', +); + +assert.match(migration, /from pg_auth_members membership/i); +assert.match(migration, /tiku_api\/tiku_worker must not own database objects/i); +assert.doesNotMatch(migration, /password\s+['"]/i, 'role passwords must be provisioned outside migrations'); + +assert.match(authBoundaryMigration, /create or replace function app\.auth_user_exists\(target_user_id uuid\)/i); +assert.match(authBoundaryMigration, /security definer[\s\S]*set search_path = ''/i); +assert.match(authBoundaryMigration, /from auth\.users auth_user[\s\S]*auth_user\.id = target_user_id/i); +assert.match( + authBoundaryMigration, + /revoke all on function app\.auth_user_exists\(uuid\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i, +); +assert.match(authBoundaryMigration, /grant execute on function app\.auth_user_exists\(uuid\) to tiku_api/i); +assert.doesNotMatch(authBoundaryMigration, /grant[^;]*to tiku_worker/i); +assert.match( + migrationHistoryBoundaryMigration, + /create or replace function app\.production_migration_history\(expected_version text\)/i, +); +assert.match( + migrationHistoryBoundaryMigration, + /security definer[\s\S]*set search_path = ''/i, +); +assert.match( + migrationHistoryBoundaryMigration, + /from supabase_migrations\.schema_migrations/i, +); +assert.match( + migrationHistoryBoundaryMigration, + /revoke all on function app\.production_migration_history\(text\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i, +); +assert.match( + migrationHistoryBoundaryMigration, + /grant execute on function app\.production_migration_history\(text\) to tiku_api/i, +); +assert.doesNotMatch(migrationHistoryBoundaryMigration, /grant[^;]*to tiku_worker/i); +for (const [label, source] of [ + ['platform staff API', platformAdminRoutes], + ['platform admin bootstrap CLI', platformAdminBootstrap], +]) { + assert.match(source, /app\.auth_user_exists\(\$1::uuid\)/, `${label} must use the boolean Auth boundary`); + assert.doesNotMatch(source, /\bfrom\s+auth\.users\b/i, `${label} must not read auth.users directly`); + assert.doesNotMatch(source, /\bjoin\s+auth\.users\b/i, `${label} must not join auth.users directly`); +} + +const retiredSharedRole = ['tiku', 'app'].join('_'); +assert.equal( + safetyMigration.includes(retiredSharedRole), + false, + 'safety migration must not retain the retired shared runtime role', +); +assert.match(apiEnv, /DATABASE_URL=postgresql:\/\/tiku_api:/); +assert.match(apiEnv, /^DB_EXPECTED_RUNTIME_ROLE=tiku_api$/m); +assert.match(workerEnv, /DATABASE_URL=postgresql:\/\/tiku_worker:/); +assert.match(workerEnv, /^DB_EXPECTED_RUNTIME_ROLE=tiku_worker$/m); + +for (const gateId of [ + 'db.runtime_role.identity', + 'db.runtime_role.attributes', + 'db.runtime_role.schema_acl', + 'db.runtime_role.table_acl', + 'db.runtime_role.function_acl', + 'db.runtime_role.auth_acl', + 'db.extensions.isolation', + 'db.runtime_role.ownership', + 'db.runtime_role.ddl_denied', + 'db.migrations.current', +]) { + assert.ok(readiness.includes(gateId), `readiness must enforce ${gateId}`); +} + +assert.ok( + packageJson.scripts?.['test:readiness']?.includes('backend-runtime-role-contract-test.js'), + 'the production readiness contract suite must run the backend runtime role test', +); + +console.log('[PASS] backend runtime role least-privilege contract'); diff --git a/scripts/bootstrap-backend-runtime-roles-test.js b/scripts/bootstrap-backend-runtime-roles-test.js new file mode 100644 index 00000000..4abda085 --- /dev/null +++ b/scripts/bootstrap-backend-runtime-roles-test.js @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { + parseBackendRuntimeRoleBootstrapOptions, +} from './bootstrap-backend-runtime-roles.js'; + +const sql = fs.readFileSync('scripts/deploy/sql/bootstrap-backend-runtime-roles.sql', 'utf8'); +const migration = fs.readFileSync('supabase/migrations/202607120013_backend_runtime_roles.sql', 'utf8'); + +assert.deepEqual( + parseBackendRuntimeRoleBootstrapOptions([], {}), + { apply: false, adminUrl: '', confirmation: '', json: false }, +); +assert.throws( + () => parseBackendRuntimeRoleBootstrapOptions(['--apply'], {}), + /DATABASE_ADMIN_URL is required/, +); +assert.throws( + () => parseBackendRuntimeRoleBootstrapOptions(['--apply'], { + DATABASE_ADMIN_URL: ['postgresql:', '//admin:secret@db.test/postgres'].join(''), + }), + /BOOTSTRAP_BACKEND_RUNTIME_ROLES/, +); +assert.match(sql, /alter role tiku_api[\s\S]*bypassrls/i); +assert.match(sql, /alter role tiku_worker[\s\S]*bypassrls/i); +for (const extensionName of ['pgcrypto', 'citext', 'ltree', 'pg_trgm']) { + assert.ok(sql.includes(`'${extensionName}'::name`), `${extensionName} must be managed by the privileged bootstrap`); +} +assert.match( + sql, + /if not found then[\s\S]*create extension %I with schema extensions/i, + 'privileged bootstrap must install extensions before normal migrations can create them with unsafe defaults', +); +assert.match(sql, /alter extension %I set schema extensions/i); +assert.match(sql, /grant usage on schema extensions to tiku_api, tiku_worker/i); +assert.match(sql, /search_path = pg_catalog, public, extensions/i); +assert.match(sql, /pg_auth_members[\s\S]*revoke %I from %I/i); +assert.match( + sql, + /revoke execute on all functions in schema public[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i, +); +assert.match( + sql, + /revoke execute on all functions in schema extensions[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i, +); +for (const trustedRole of [ + 'postgres', + 'service_role', + 'dashboard_user', + 'supabase_auth_admin', + 'supabase_storage_admin', + 'supabase_realtime_admin', + 'supabase_functions_admin', +]) { + assert.ok(sql.includes(`'${trustedRole}'::name`), `${trustedRole} must retain extension execution when present`); +} +assert.match( + sql, + /alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker/i, +); +assert.match(sql, /extension\.extname in \('citext', 'ltree', 'pg_trgm'\)[\s\S]*grant execute on function %s to tiku_api, tiku_worker/i); +assert.doesNotMatch(sql, /password\s+['"]/i, 'bootstrap must preserve externally managed passwords'); + +assert.doesNotMatch(migration, /create role tiku_api|alter role tiku_api/i); +assert.match(migration, /Runtime role % is missing[\s\S]*bootstrap-backend-runtime-roles\.js/i); +assert.match(migration, /not role_state\.rolbypassrls/i); + +console.log('[PASS] privileged backend runtime role bootstrap contract'); diff --git a/scripts/bootstrap-backend-runtime-roles.js b/scripts/bootstrap-backend-runtime-roles.js new file mode 100644 index 00000000..e7e156bd --- /dev/null +++ b/scripts/bootstrap-backend-runtime-roles.js @@ -0,0 +1,251 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import pg from 'pg'; +import { describeDatabaseTarget } from './lib/destructive-test-database-guard.js'; + +const { Client } = pg; +const CONFIRMATION = 'BOOTSTRAP_BACKEND_RUNTIME_ROLES'; +const sqlPath = fileURLToPath(new URL('./deploy/sql/bootstrap-backend-runtime-roles.sql', import.meta.url)); + +function argumentValue(argv, name) { + const index = argv.indexOf(name); + if (index >= 0) return String(argv[index + 1] || '').trim(); + const prefix = `${name}=`; + const item = argv.find(value => value.startsWith(prefix)); + return item ? item.slice(prefix.length).trim() : ''; +} + +export function parseBackendRuntimeRoleBootstrapOptions( + argv = process.argv.slice(2), + env = process.env, +) { + const apply = argv.includes('--apply'); + const adminUrl = String(env.DATABASE_ADMIN_URL || '').trim(); + const confirmation = argumentValue(argv, '--confirm'); + if (apply && !adminUrl) throw new Error('DATABASE_ADMIN_URL is required with --apply'); + if (apply && confirmation !== CONFIRMATION) { + throw new Error(`--confirm=${CONFIRMATION} is required with --apply`); + } + return { apply, adminUrl, confirmation, json: argv.includes('--json') }; +} + +function roleIsSafe(row) { + const config = Array.isArray(row?.rolconfig) ? row.rolconfig.map(String) : []; + return row + && row.rolcanlogin === true + && row.rolsuper === false + && row.rolinherit === false + && row.rolcreatedb === false + && row.rolcreaterole === false + && row.rolreplication === false + && row.rolbypassrls === true + && row.hasParentRoles === false + && config.includes('search_path=pg_catalog, public, extensions'); +} + +async function loadRoleState(client) { + const result = await client.query(` + select role_row.rolname, + role_row.rolcanlogin, + role_row.rolsuper, + role_row.rolinherit, + role_row.rolcreatedb, + role_row.rolcreaterole, + role_row.rolreplication, + role_row.rolbypassrls, + role_row.rolconfig, + exists ( + select 1 from pg_auth_members membership + where membership.member = role_row.oid + ) as "hasParentRoles" + from pg_roles role_row + where role_row.rolname = any(array['tiku_api', 'tiku_worker']::name[]) + order by role_row.rolname + `); + return result.rows; +} + +async function loadPublicFunctionExecutionState(client) { + const result = await client.query(` + select requested_role.role_name, + role_row.oid is not null as role_exists, + coalesce(( + select count(*)::integer + from pg_proc function_row + join pg_namespace namespace on namespace.oid = function_row.pronamespace + where namespace.nspname = 'public' + and role_row.oid is not null + and has_function_privilege(role_row.oid, function_row.oid, 'EXECUTE') + ), 0)::integer as executable_function_count + from unnest(array['anon', 'authenticated', 'tiku_api', 'tiku_worker']::name[]) + as requested_role(role_name) + left join pg_roles role_row on role_row.rolname = requested_role.role_name + order by requested_role.role_name + `); + return result.rows; +} + +async function loadExtensionState(client) { + const result = await client.query(` + select extension.extname, + namespace.nspname as schema_name, + count(procedure_row.oid)::integer as function_count, + count(procedure_row.oid) filter ( + where has_function_privilege('anon', procedure_row.oid, 'EXECUTE') + )::integer as anon_execute_count, + count(procedure_row.oid) filter ( + where has_function_privilege('authenticated', procedure_row.oid, 'EXECUTE') + )::integer as authenticated_execute_count, + count(procedure_row.oid) filter ( + where has_function_privilege('tiku_api', procedure_row.oid, 'EXECUTE') + )::integer as api_execute_count, + count(procedure_row.oid) filter ( + where has_function_privilege('tiku_worker', procedure_row.oid, 'EXECUTE') + )::integer as worker_execute_count + from pg_extension extension + join pg_namespace namespace on namespace.oid = extension.extnamespace + left join pg_depend dependency + on dependency.refclassid = 'pg_extension'::regclass + and dependency.refobjid = extension.oid + and dependency.classid = 'pg_proc'::regclass + and dependency.deptype = 'e' + left join pg_proc procedure_row on procedure_row.oid = dependency.objid + where extension.extname = any(array['pgcrypto', 'citext', 'ltree', 'pg_trgm']::name[]) + group by extension.extname, namespace.nspname + order by extension.extname + `); + return result.rows; +} + +export async function bootstrapBackendRuntimeRoles(options) { + if (!options.apply) { + return { + status: 'plan', + apply: false, + confirmation: CONFIRMATION, + sqlPath: path.relative(process.cwd(), sqlPath), + changes: [ + 'Create tiku_api and tiku_worker if missing without assigning passwords', + 'Enforce LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS', + 'Move required extensions out of public and set search_path=pg_catalog,public,extensions', + 'Close client extension RPC execution while preserving backend citext/ltree operations', + ], + }; + } + + const target = describeDatabaseTarget(options.adminUrl); + const client = new Client({ + connectionString: options.adminUrl, + application_name: 'tiku-runtime-role-bootstrap', + }); + await client.connect(); + try { + const identityResult = await client.query(` + select current_user, + current_setting('server_version_num')::integer as server_version_num, + rolsuper + from pg_roles + where rolname = current_user + `); + const identity = identityResult.rows[0]; + if (!identity?.rolsuper) { + throw new Error(`DATABASE_ADMIN_URL must connect as a PostgreSQL superuser; ${identity?.current_user || 'current role'} is not superuser`); + } + if (Number(identity.server_version_num) < 130000) { + throw new Error('PostgreSQL 13 or newer is required'); + } + + const sql = await fs.readFile(sqlPath, 'utf8'); + await client.query('begin'); + try { + await client.query(sql); + await client.query('commit'); + } catch (error) { + await client.query('rollback').catch(() => undefined); + throw error; + } + + const roles = await loadRoleState(client); + if (roles.length !== 2 || roles.some(row => !roleIsSafe(row))) { + throw new Error('Runtime role bootstrap verification failed'); + } + const publicFunctionExecution = await loadPublicFunctionExecutionState(client); + if ( + publicFunctionExecution.length !== 4 + || publicFunctionExecution.some(row => !row.role_exists || Number(row.executable_function_count) !== 0) + ) { + throw new Error('Public extension function execution bootstrap verification failed'); + } + const extensions = await loadExtensionState(client); + const expectedExtensions = new Set(['pgcrypto', 'citext', 'ltree', 'pg_trgm']); + if ( + extensions.length !== expectedExtensions.size + || extensions.some(row => !expectedExtensions.has(row.extname) || row.schema_name !== 'extensions') + ) { + throw new Error('Required extension schema bootstrap verification failed'); + } + for (const row of extensions) { + const functionCount = Number(row.function_count); + const backendExecuteCount = row.extname === 'pgcrypto' ? 0 : functionCount; + if ( + functionCount <= 0 + || Number(row.anon_execute_count) !== 0 + || Number(row.authenticated_execute_count) !== 0 + || Number(row.api_execute_count) !== backendExecuteCount + || Number(row.worker_execute_count) !== backendExecuteCount + ) { + throw new Error(`Extension function ACL bootstrap verification failed for ${row.extname}`); + } + } + return { + status: 'pass', + apply: true, + target, + administrator: identity.current_user, + roles: roles.map(row => ({ + name: row.rolname, + login: row.rolcanlogin, + bypassRls: row.rolbypassrls, + noInherit: row.rolinherit === false, + hasParentRoles: row.hasParentRoles, + searchPath: row.rolconfig, + })), + publicFunctionExecution: publicFunctionExecution.map(row => ({ + role: row.role_name, + executableFunctionCount: Number(row.executable_function_count), + })), + extensions: extensions.map(row => ({ + name: row.extname, + schema: row.schema_name, + functionCount: Number(row.function_count), + })), + }; + } finally { + await client.end(); + } +} + +async function main() { + let options; + try { + options = parseBackendRuntimeRoleBootstrapOptions(); + const result = await bootstrapBackendRuntimeRoles(options); + if (options.json) console.log(JSON.stringify(result, null, 2)); + else if (result.status === 'plan') { + console.log('Backend runtime role bootstrap plan'); + result.changes.forEach(item => console.log(`- ${item}`)); + console.log(`Apply with --apply --confirm=${CONFIRMATION} and DATABASE_ADMIN_URL.`); + } else { + console.log(`Backend runtime role bootstrap complete for ${result.target.host}:${result.target.port}/${result.target.database}`); + } + } catch (error) { + const failure = { status: 'fail', error: error instanceof Error ? error.message : String(error) }; + if (options?.json || process.argv.includes('--json')) console.log(JSON.stringify(failure, null, 2)); + else console.error(failure.error); + process.exitCode = 1; + } +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main(); diff --git a/scripts/bootstrap-platform-admin-test.js b/scripts/bootstrap-platform-admin-test.js new file mode 100644 index 00000000..a9874266 --- /dev/null +++ b/scripts/bootstrap-platform-admin-test.js @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { + APPLY_CONFIRMATION, + BOOTSTRAP_LOCK_KEY, + bootstrapPlatformAdmin, + buildConfig, + helpText, + publicResult, + sanitizeErrorMessage, +} from './bootstrap-platform-admin.js'; + +const AUTH_USER_ID = '11111111-1111-4111-8111-111111111111'; +const PLATFORM_USER_ID = '22222222-2222-4222-8222-222222222222'; +const LEGACY_USER_ID = '33333333-3333-4333-8333-333333333333'; + +function result(rows = []) { + return { rows, rowCount: rows.length }; +} + +function mockPool(responses) { + const queries = []; + const client = { + async query(sql, params = []) { + const normalized = String(sql).replace(/\s+/g, ' ').trim(); + queries.push({ sql: normalized, params }); + if (normalized === 'begin' || normalized === 'commit' || normalized === 'rollback') return result(); + const response = responses.shift(); + if (response instanceof Error) throw response; + if (!response) throw new Error(`Unexpected query: ${normalized}`); + return response; + }, + release() {}, + }; + return { + queries, + pool: { async connect() { return client; } }, + assertExhausted() { assert.equal(responses.length, 0, 'all expected database queries should run'); }, + }; +} + +function config(overrides = {}) { + return { + databaseUrl: 'test-database-url', + authUserId: AUTH_USER_ID, + apply: false, + confirmation: APPLY_CONFIRMATION, + username: 'owner.admin', + email: 'owner@example.test', + phone: '13800001234', + name: 'Owner Admin', + ...overrides, + }; +} + +const parsed = buildConfig({ + DATABASE_URL: 'test-database-url', + BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID: AUTH_USER_ID, + BOOTSTRAP_PLATFORM_ADMIN_USERNAME: 'owner.admin', + BOOTSTRAP_PLATFORM_ADMIN_NAME: 'Owner Admin', +}, []); +assert.equal(parsed.apply, false, 'bootstrap should default to dry-run'); +assert.throws( + () => buildConfig({ + DATABASE_URL: 'test-database-url', + BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID: AUTH_USER_ID, + }, ['--apply']), + new RegExp(APPLY_CONFIRMATION), + 'apply must require the exact confirmation phrase', +); +assert.equal( + buildConfig({ + DATABASE_URL: 'test-database-url', + BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID: AUTH_USER_ID, + }, ['--apply', '--confirm', APPLY_CONFIRMATION]).apply, + true, +); +await assert.rejects( + () => bootstrapPlatformAdmin(config({ apply: true, confirmation: '' }), { pool: mockPool([]).pool }), + new RegExp(APPLY_CONFIRMATION), + 'direct callers must not bypass the apply confirmation gate', +); +assert.match(helpText(), /active, Auth-bound platform admin exists/); +assert.match(helpText(), /exactly one unbound legacy platform_admin/); +const sensitiveDatabaseUrl = ['postgresql:', '', 'bootstrap_user:bootstrap_password@db.internal:5432/tiku'].join('/'); +const safeError = sanitizeErrorMessage( + new Error(`connection failed for ${sensitiveDatabaseUrl}`), + sensitiveDatabaseUrl, +); +assert.equal(safeError.includes(sensitiveDatabaseUrl), false, 'failure output must redact the configured database URL'); +assert.equal(safeError.includes('bootstrap_password'), false, 'failure output must not expose database credentials'); + +{ + const mock = mockPool([ + result(), + result([{ exists: true }]), + result(), + result(), + result([{ id: LEGACY_USER_ID, username: 'legacy.admin', email: null, phone: null, name: 'Legacy Admin' }]), + ]); + const dryRun = await bootstrapPlatformAdmin(config(), { pool: mock.pool }); + mock.assertExhausted(); + assert.equal(dryRun.dryRun, true); + assert.equal(dryRun.action, 'bind_legacy'); + assert.equal(dryRun.platformUserId, LEGACY_USER_ID); + assert.equal(mock.queries[0].sql, 'begin'); + assert.match(mock.queries[1].sql, /pg_advisory_xact_lock/); + assert.deepEqual(mock.queries[1].params, [BOOTSTRAP_LOCK_KEY]); + assert.equal(mock.queries.at(-1).sql, 'commit', 'dry-run should retain its transaction lock through normal commit'); + assert.equal(mock.queries.some(query => query.sql === 'rollback'), false, 'successful dry-run should not break the transaction abstraction'); + assert.equal( + mock.queries.some(query => /^(insert|update|delete)\b/i.test(query.sql)), + false, + 'dry-run must execute no write statement', + ); + assert.match(mock.queries[2].sql, /app\.auth_user_exists/); + assert.doesNotMatch(mock.queries[2].sql, /auth\.users/); +} + +{ + const saved = { + id: PLATFORM_USER_ID, + authUserId: AUTH_USER_ID, + username: 'owner.admin', + email: 'owner@example.test', + phone: '13800001234', + }; + const mock = mockPool([ + result(), + result([{ exists: true }]), + result(), + result(), + result(), + result([saved]), + result(), + ]); + const applied = await bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }); + mock.assertExhausted(); + assert.equal(applied.action, 'create'); + assert.equal(applied.auditAction, 'platform.admin.bootstrapped'); + assert.equal(mock.queries.at(-1).sql, 'commit'); + + const userWrite = mock.queries.find(query => query.sql.includes('insert into public.platform_users')); + assert.ok(userWrite, 'apply should create the platform user inside the transaction'); + assert.match(userWrite.sql, /platform_permissions/); + assert.match(userWrite.sql, /'\{"\*":true\}'::jsonb/); + + const auditWrite = mock.queries.find(query => query.sql.includes('insert into public.audit_logs')); + assert.ok(auditWrite, 'apply should write an audit event in the same transaction'); + assert.deepEqual(auditWrite.params, [PLATFORM_USER_ID, 'platform.admin.bootstrapped', 'create']); + assert.match(auditWrite.sql, /values \( null, null, \$2, 'platform_user', \$1::text/); + assert.match(auditWrite.sql, /'invokedBy', 'system_cli'/); + const auditPayload = JSON.stringify(auditWrite); + assert.equal(auditPayload.includes('owner@example.test'), false, 'audit must not contain email'); + assert.equal(auditPayload.includes('13800001234'), false, 'audit must not contain phone'); + assert.equal(auditPayload.includes(AUTH_USER_ID), false, 'audit must not contain the Supabase Auth user ID'); + + const output = JSON.stringify(publicResult(applied)); + assert.equal(output.includes(AUTH_USER_ID), false, 'CLI output must mask Auth user ID'); + assert.equal(output.includes(PLATFORM_USER_ID), false, 'CLI output must mask platform user ID'); + assert.equal(output.includes('owner@example.test'), false, 'CLI output must mask email'); + assert.equal(output.includes('13800001234'), false, 'CLI output must mask phone'); + assert.equal(output.includes('owner.admin'), false, 'CLI output must mask username'); + + const emailUsernameOutput = JSON.stringify(publicResult({ ...applied, username: 'owner@example.test' })); + assert.equal(emailUsernameOutput.includes('owner@example.test'), false, 'email-shaped username must be masked'); + const phoneUsernameOutput = JSON.stringify(publicResult({ ...applied, username: '13800001234' })); + assert.equal(phoneUsernameOutput.includes('13800001234'), false, 'phone-shaped username must be masked'); +} + +{ + const mock = mockPool([ + result(), + result([{ exists: true }]), + result([{ id: PLATFORM_USER_ID }]), + ]); + await assert.rejects( + () => bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }), + /active, Auth-bound platform admin already exists/, + ); + mock.assertExhausted(); + assert.equal(mock.queries.at(-1).sql, 'rollback'); + assert.equal(mock.queries.some(query => query.sql.startsWith('insert into')), false); +} + +{ + const mock = mockPool([ + result(), + result([{ exists: true }]), + result(), + result(), + result([ + { id: LEGACY_USER_ID, username: 'legacy.one' }, + { id: PLATFORM_USER_ID, username: 'legacy.two' }, + ]), + ]); + await assert.rejects( + () => bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }), + /Multiple unbound legacy platform admins/, + ); + mock.assertExhausted(); + assert.equal(mock.queries.at(-1).sql, 'rollback'); +} + +{ + const mock = mockPool([ + result(), + result([{ exists: true }]), + result(), + result(), + result([{ id: LEGACY_USER_ID, username: 'legacy.admin', email: null, phone: null, name: 'Legacy Admin' }]), + result([{ + id: LEGACY_USER_ID, + authUserId: AUTH_USER_ID, + username: 'legacy.admin', + email: null, + phone: null, + }]), + result(), + ]); + const applied = await bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }); + mock.assertExhausted(); + assert.equal(applied.action, 'bind_legacy'); + const userWrite = mock.queries.find(query => query.sql.includes('insert into public.platform_users')); + assert.equal(userWrite.params[0], LEGACY_USER_ID, 'unique legacy row should be bound instead of creating a duplicate'); + const auditWrite = mock.queries.find(query => query.sql.includes('insert into public.audit_logs')); + assert.deepEqual(auditWrite.params, [LEGACY_USER_ID, 'platform.admin.bootstrapped', 'bind_legacy']); +} + +{ + const mock = mockPool([ + result(), + result([{ exists: false }]), + ]); + await assert.rejects( + () => bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }), + /Supabase Auth user not found/, + ); + mock.assertExhausted(); + assert.equal(mock.queries.at(-1).sql, 'rollback'); + assert.equal(mock.queries.some(query => /^(insert|update|delete)\b/i.test(query.sql)), false); +} + +console.log('[PASS] first platform admin bootstrap safety contract'); diff --git a/scripts/bootstrap-platform-admin.js b/scripts/bootstrap-platform-admin.js new file mode 100644 index 00000000..88f456cd --- /dev/null +++ b/scripts/bootstrap-platform-admin.js @@ -0,0 +1,351 @@ +import { fileURLToPath, pathToFileURL } from 'node:url'; +import pg from 'pg'; + +const APPLY_CONFIRMATION = 'BOOTSTRAP_FIRST_PLATFORM_ADMIN'; +const BOOTSTRAP_LOCK_KEY = 'tiku-saas:first-platform-admin:v1'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function envString(env, key, fallback = '') { + return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback; +} + +function argumentValue(argv, name) { + const directIndex = argv.indexOf(name); + if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim(); + const prefix = `${name}=`; + return String(argv.find(value => value.startsWith(prefix)) || '').slice(prefix.length).trim(); +} + +function normalizeOptionalEmail(value) { + if (!value) return null; + const email = value.toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) { + throw new Error('BOOTSTRAP_PLATFORM_ADMIN_EMAIL must be a valid email address'); + } + return email; +} + +function normalizeOptionalPhone(value) { + if (!value) return null; + const phone = value.replace(/\s+/g, ''); + if (!/^\+?[0-9-]{6,32}$/.test(phone)) { + throw new Error('BOOTSTRAP_PLATFORM_ADMIN_PHONE must be a valid phone number'); + } + return phone; +} + +function normalizeUsername(value, fallback) { + const username = (value || fallback).trim(); + if (!/^[a-zA-Z0-9_.@-]{3,80}$/.test(username)) { + throw new Error('BOOTSTRAP_PLATFORM_ADMIN_USERNAME must contain 3-80 safe characters'); + } + return username; +} + +function buildConfig(env = process.env, argv = process.argv.slice(2)) { + const databaseUrl = envString(env, 'DATABASE_URL'); + const authUserId = argumentValue(argv, '--auth-user-id') || envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID'); + const apply = argv.includes('--apply'); + const confirmation = argumentValue(argv, '--confirm') || envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_CONFIRM'); + const email = normalizeOptionalEmail(envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_EMAIL')); + const phone = normalizeOptionalPhone(envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_PHONE')); + const username = normalizeUsername( + envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_USERNAME'), + `platform_${authUserId.slice(0, 8)}`, + ); + const name = envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_NAME', 'Platform Administrator'); + + if (!databaseUrl) throw new Error('Missing required env: DATABASE_URL'); + if (!UUID_RE.test(authUserId)) { + throw new Error('BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID or --auth-user-id must be a valid UUID'); + } + if (!name || name.length > 120) throw new Error('BOOTSTRAP_PLATFORM_ADMIN_NAME must contain 1-120 characters'); + if (apply && confirmation !== APPLY_CONFIRMATION) { + throw new Error(`--apply requires --confirm ${APPLY_CONFIRMATION}`); + } + + return { databaseUrl, authUserId, apply, confirmation, username, email, phone, name }; +} + +function maskEmail(value) { + if (!value) return null; + const [local = '', domain = ''] = String(value).split('@'); + return `${local.slice(0, 2)}***@${domain}`; +} + +function maskPhone(value) { + if (!value) return null; + const phone = String(value); + return phone.length > 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : '***'; +} + +function maskUuid(value) { + if (!value) return null; + const id = String(value); + return `${id.slice(0, 8)}...${id.slice(-4)}`; +} + +function maskUsername(value) { + if (!value) return null; + const username = String(value); + if (username.includes('@')) return maskEmail(username); + if (/^\+?[0-9-]{6,32}$/.test(username)) return maskPhone(username); + if (username.length <= 3) return '***'; + return `${username.slice(0, 2)}***${username.slice(-1)}`; +} + +function publicResult(result) { + return { + dryRun: result.dryRun, + action: result.action, + platformUserId: maskUuid(result.platformUserId), + authUserId: maskUuid(result.authUserId), + username: maskUsername(result.username), + email: maskEmail(result.email), + phone: maskPhone(result.phone), + primaryRole: 'platform_admin', + status: 'active', + permissions: ['*'], + auditAction: result.auditAction || null, + }; +} + +function sanitizeErrorMessage(error, databaseUrl = '') { + let message = error instanceof Error ? error.message : String(error); + if (databaseUrl) message = message.split(databaseUrl).join('[DATABASE_URL_REDACTED]'); + return message + .replace(/postgres(?:ql)?:\/\/[^\s'"<>]+/gi, '[DATABASE_URL_REDACTED]') + .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, '$1[REDACTED]@'); +} + +async function withTransaction(client, callback) { + await client.query('begin'); + try { + await client.query('select pg_advisory_xact_lock(hashtextextended($1, 0))', [BOOTSTRAP_LOCK_KEY]); + const result = await callback(); + await client.query('commit'); + return result; + } catch (error) { + await client.query('rollback').catch(() => undefined); + throw error; + } +} + +async function bootstrapPlatformAdmin(inputConfig, options = {}) { + const config = inputConfig?.databaseUrl + ? inputConfig + : buildConfig(options.env || process.env, options.argv || process.argv.slice(2)); + if (config.apply && config.confirmation !== APPLY_CONFIRMATION) { + throw new Error(`Apply requires confirmation ${APPLY_CONFIRMATION}`); + } + const pool = options.pool || new pg.Pool({ connectionString: config.databaseUrl, max: 1 }); + const closePool = !options.pool; + + try { + const client = await pool.connect(); + try { + return await withTransaction(client, async () => { + const authUserResult = await client.query( + ` + select app.auth_user_exists($1::uuid) as exists + `, + [config.authUserId], + ); + if (!authUserResult.rows[0]?.exists) throw new Error('Supabase Auth user not found'); + + const activeBoundResult = await client.query( + ` + select id + from public.platform_users + where primary_role = 'platform_admin' + and status = 'active' + and auth_user_id is not null + order by created_at asc + limit 2 + for update + `, + ); + if (activeBoundResult.rowCount > 0) { + throw new Error('An active, Auth-bound platform admin already exists; bootstrap is permanently refused'); + } + + const boundUserResult = await client.query( + ` + select id, primary_role as "primaryRole" + from public.platform_users + where auth_user_id = $1::uuid + limit 1 + for update + `, + [config.authUserId], + ); + if (boundUserResult.rows[0]?.primaryRole !== undefined) { + throw new Error('Supabase Auth user is already bound to a platform user'); + } + + const legacyResult = await client.query( + ` + select id, username, email::text, phone, name + from public.platform_users + where primary_role = 'platform_admin' + and auth_user_id is null + order by created_at asc + limit 2 + for update + `, + ); + if (legacyResult.rowCount > 1) { + throw new Error('Multiple unbound legacy platform admins exist; resolve the ambiguity manually'); + } + + const legacy = legacyResult.rows[0] || null; + const action = legacy ? 'bind_legacy' : 'create'; + const platformUserId = legacy?.id || null; + const resolvedEmail = legacy?.email || config.email || null; + const resolvedPhone = legacy?.phone || config.phone || null; + const resolvedUsername = legacy?.username || config.username; + const resolvedName = legacy?.name || config.name; + + if (!config.apply) { + return { + dryRun: true, + action, + platformUserId, + authUserId: config.authUserId, + username: resolvedUsername, + email: resolvedEmail, + phone: resolvedPhone, + auditAction: null, + }; + } + + const savedResult = await client.query( + ` + insert into public.platform_users ( + id, auth_user_id, username, email, phone, name, + primary_role, status, platform_permissions, raw_profile + ) + values ( + coalesce($1::uuid, pg_catalog.gen_random_uuid()), $2::uuid, $3, $4::extensions.citext, $5, $6, + 'platform_admin', 'active', '{"*":true}'::jsonb, + jsonb_build_object('source', 'server-bootstrap', 'bootstrapVersion', 1) + ) + on conflict (id) + do update set auth_user_id = excluded.auth_user_id, + username = coalesce(public.platform_users.username, excluded.username), + email = coalesce(public.platform_users.email, excluded.email), + phone = coalesce(public.platform_users.phone, excluded.phone), + name = coalesce(public.platform_users.name, excluded.name), + primary_role = 'platform_admin', + status = 'active', + platform_permissions = '{"*":true}'::jsonb, + raw_profile = jsonb_strip_nulls(public.platform_users.raw_profile || excluded.raw_profile), + updated_at = now() + returning id, auth_user_id as "authUserId", username, email::text, phone + `, + [platformUserId, config.authUserId, resolvedUsername, resolvedEmail, resolvedPhone, resolvedName], + ); + const saved = savedResult.rows[0]; + const auditAction = 'platform.admin.bootstrapped'; + + await client.query( + ` + insert into public.audit_logs ( + tenant_id, actor_user_id, action, target_type, target_id, details + ) + values ( + null, null, $2, 'platform_user', $1::text, + jsonb_build_object( + 'source', 'server-bootstrap', + 'invokedBy', 'system_cli', + 'bootstrapVersion', 1, + 'mode', $3::text, + 'authUserBound', true, + 'permissions', jsonb_build_array('*') + ) + ) + `, + [saved.id, auditAction, action], + ); + + return { + dryRun: false, + action, + platformUserId: saved.id, + authUserId: saved.authUserId, + username: saved.username, + email: saved.email, + phone: saved.phone, + auditAction, + }; + }); + } finally { + client.release(); + } + } finally { + if (closePool) await pool.end(); + } +} + +function helpText() { + return ` +Bootstrap the first production platform administrator from an existing Supabase Auth user. + +Safety contract: + - Runs as a local server CLI and talks directly to DATABASE_URL. + - Defaults to dry-run. Writing requires --apply and the exact confirmation phrase. + - Refuses once any active, Auth-bound platform admin exists. + - May bind exactly one unbound legacy platform_admin row; multiple candidates are refused. + - Uses a boolean Auth existence boundary and never reads auth.users profile fields. + - Provide optional email/phone explicitly when a new business profile needs them. + - Grants {"*":true} and writes a redacted system-CLI audit event in the same locked transaction. + +Usage: + DATABASE_URL= \\ + BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID= \\ + BOOTSTRAP_PLATFORM_ADMIN_USERNAME= \\ + BOOTSTRAP_PLATFORM_ADMIN_NAME= \\ + npm run bootstrap:platform-admin + + npm run bootstrap:platform-admin -- --apply --confirm ${APPLY_CONFIRMATION} + +Optional identity fields: + BOOTSTRAP_PLATFORM_ADMIN_EMAIL + BOOTSTRAP_PLATFORM_ADMIN_PHONE +`; +} + +async function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + console.log(helpText().trim()); + return; + } + let config; + try { + config = buildConfig(); + const result = await bootstrapPlatformAdmin(config); + console.log(JSON.stringify({ ok: true, ...publicResult(result) }, null, 2)); + if (result.dryRun) { + console.error(`Dry-run only. Re-run with --apply --confirm ${APPLY_CONFIRMATION} after reviewing the target.`); + } + } catch (error) { + console.error(sanitizeErrorMessage(error, config?.databaseUrl || envString(process.env, 'DATABASE_URL'))); + console.error(helpText()); + process.exitCode = 1; + } +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) { + await main(); +} + +export { + APPLY_CONFIRMATION, + BOOTSTRAP_LOCK_KEY, + bootstrapPlatformAdmin, + buildConfig, + helpText, + publicResult, + sanitizeErrorMessage, +}; diff --git a/scripts/build-weapp-student.js b/scripts/build-weapp-student.js new file mode 100644 index 00000000..99e0628a --- /dev/null +++ b/scripts/build-weapp-student.js @@ -0,0 +1,173 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(scriptPath), '..'); +const taroRoot = path.join(repoRoot, 'apps', 'taro'); +const distProjectConfigPath = path.join(taroRoot, 'dist', 'weapp-student', 'project.config.json'); + +const placeholderAppIds = new Set([ + 'wx0000000000000000', + 'wx0123456789abcdef', + 'wx1234567890abcdef', + 'wxabcdef0123456789', +]); + +const placeholderTenantCodes = new Set([ + 'changeme', + 'demo', + 'example', + 'placeholder', + 'production-tenant', + 'replace-with-tenant-code', + 'smoke', + 'tenant-production', + 'test', +]); + +function stringValue(value) { + return String(value || '').trim(); +} + +function normalizedHostname(value) { + return stringValue(value).toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); +} + +export function isLoopbackHostname(value) { + const hostname = normalizedHostname(value); + return hostname === 'localhost' + || hostname.endsWith('.localhost') + || hostname === '::1' + || hostname === '0.0.0.0' + || /^127(?:\.\d{1,3}){3}$/.test(hostname); +} + +export function isPlaceholderHostname(value) { + const hostname = normalizedHostname(value); + return ['example', 'test', 'local'].some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`)); +} + +export function validateProductionApiBaseUrl(value) { + const apiBaseUrl = stringValue(value); + if (!apiBaseUrl) throw new Error('TARO_APP_API_BASE_URL is required for a production WeApp build'); + + let parsed; + try { + parsed = new URL(apiBaseUrl); + } catch { + throw new Error('TARO_APP_API_BASE_URL must be an absolute HTTPS URL for a production WeApp build'); + } + if (parsed.protocol !== 'https:' || !parsed.hostname) { + throw new Error('TARO_APP_API_BASE_URL must use HTTPS for a production WeApp build'); + } + if (isLoopbackHostname(parsed.hostname)) { + throw new Error('TARO_APP_API_BASE_URL must not use localhost or a loopback address for a production WeApp build'); + } + if (isPlaceholderHostname(parsed.hostname)) { + throw new Error('TARO_APP_API_BASE_URL must not use a .example, .test, or .local host for a production WeApp build'); + } + return apiBaseUrl.replace(/\/+$/, ''); +} + +export function validateProductionWechatAppId(value) { + const appId = stringValue(value); + if (!appId) throw new Error('WECHAT_MINIAPP_APP_ID is required for a production WeApp build'); + if (!/^wx[0-9a-f]{16}$/i.test(appId)) { + throw new Error('WECHAT_MINIAPP_APP_ID must be a real 18-character WeChat AppID'); + } + const normalized = appId.toLowerCase(); + if (placeholderAppIds.has(normalized) || /^wx([0-9a-f])\1{15}$/i.test(normalized)) { + throw new Error('WECHAT_MINIAPP_APP_ID must not use a placeholder AppID'); + } + return appId; +} + +export function validateTenantCodeFormat(value) { + const tenantCode = stringValue(value); + if (!tenantCode) throw new Error('TARO_APP_TENANT_CODE is required when TARO_APP_WEAPP_TENANT_MODE=fixed'); + if (!/^[A-Za-z0-9._-]{2,64}$/.test(tenantCode)) throw new Error('TARO_APP_TENANT_CODE has an invalid format'); + return tenantCode; +} + +export function validateProductionTenantCode(value) { + const tenantCode = validateTenantCodeFormat(value); + const normalized = tenantCode.toLowerCase(); + if (placeholderTenantCodes.has(normalized) + || /^(?:tenant[-_])?(?:example|test|demo|smoke|production|placeholder)(?:[-_]tenant)?$/.test(normalized)) { + throw new Error('TARO_APP_TENANT_CODE must not use a placeholder tenant code'); + } + return tenantCode; +} + +export function resolveWeappTenantMode(env = {}, production = false) { + const configuredMode = stringValue(env.TARO_APP_WEAPP_TENANT_MODE).toLowerCase(); + if (configuredMode && configuredMode !== 'fixed' && configuredMode !== 'launch') { + throw new Error('TARO_APP_WEAPP_TENANT_MODE must be fixed or launch'); + } + if (configuredMode) return configuredMode; + return production || stringValue(env.TARO_APP_TENANT_CODE) ? 'fixed' : 'launch'; +} + +export function resolveWeappBuildConfig(env = {}, { production = false } = {}) { + const tenantMode = resolveWeappTenantMode(env, production); + const configuredTenantCode = stringValue(env.TARO_APP_TENANT_CODE); + let tenantCode = ''; + + if (tenantMode === 'fixed') { + tenantCode = production + ? validateProductionTenantCode(configuredTenantCode) + : validateTenantCodeFormat(configuredTenantCode); + } else if (production && configuredTenantCode) { + throw new Error('TARO_APP_TENANT_CODE must be empty when TARO_APP_WEAPP_TENANT_MODE=launch'); + } + + return { + production, + tenantMode, + tenantCode, + apiBaseUrl: production + ? validateProductionApiBaseUrl(env.TARO_APP_API_BASE_URL) + : stringValue(env.TARO_APP_API_BASE_URL), + appId: production ? validateProductionWechatAppId(env.WECHAT_MINIAPP_APP_ID) : '', + }; +} + +export function runWeappBuild({ argv = process.argv.slice(2), env = process.env } = {}) { + const production = argv.includes('--production'); + const buildConfig = resolveWeappBuildConfig(env, { production }); + const result = spawnSync(process.execPath, [path.join(repoRoot, 'node_modules', '@tarojs', 'cli', 'bin', 'taro'), 'build', '--type', 'weapp'], { + cwd: taroRoot, + env: { + ...env, + TARO_ENV: 'weapp', + TARO_APP_PORTAL: 'student', + TARO_APP_RELEASE_MODE: production ? 'production' : 'preview', + TARO_APP_API_BASE_URL: buildConfig.apiBaseUrl, + TARO_APP_WEAPP_TENANT_MODE: buildConfig.tenantMode, + TARO_APP_TENANT_CODE: buildConfig.tenantCode, + }, + stdio: 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0) return result.status || 1; + + if (production) { + const projectConfig = JSON.parse(fs.readFileSync(distProjectConfigPath, 'utf8')); + projectConfig.appid = buildConfig.appId; + projectConfig.setting = { ...(projectConfig.setting || {}), urlCheck: true }; + fs.writeFileSync(distProjectConfigPath, `${JSON.stringify(projectConfig, null, 2)}\n`, 'utf8'); + } + return 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + process.exitCode = runWeappBuild(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/configure-aliyun-pnvs-provider.js b/scripts/configure-aliyun-pnvs-provider.js index 1b157275..a0e152ae 100644 --- a/scripts/configure-aliyun-pnvs-provider.js +++ b/scripts/configure-aliyun-pnvs-provider.js @@ -165,7 +165,7 @@ async function main() { console.error(error.message); console.error(` Required example: - DATABASE_URL=postgresql://tiku_app:***@127.0.0.1:54322/postgres + DATABASE_URL=postgresql://tiku_api:***@127.0.0.1:54322/postgres PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 ALIYUN_ACCESS_KEY_ID= ALIYUN_ACCESS_KEY_SECRET= diff --git a/scripts/data-api-security-contract-test.js b/scripts/data-api-security-contract-test.js new file mode 100644 index 00000000..6f09d003 --- /dev/null +++ b/scripts/data-api-security-contract-test.js @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const migrationPath = path.join( + repoRoot, + 'supabase', + 'migrations', + '202607110001_data_api_acl_rls_hardening.sql', +); +const authBoundaryMigrationPath = path.join( + repoRoot, + 'supabase', + 'migrations', + '202607120018_auth_user_reference_boundary.sql', +); +const readinessPath = path.join(repoRoot, 'scripts', 'production-readiness-check.js'); +const privilegedBootstrapPath = path.join( + repoRoot, + 'scripts', + 'deploy', + 'sql', + 'bootstrap-backend-runtime-roles.sql', +); +const packagePath = path.join(repoRoot, 'package.json'); +const taroSourceRoot = path.join(repoRoot, 'apps', 'taro', 'src'); + +function read(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function walk(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const filePath = path.join(dir, entry.name); + return entry.isDirectory() ? walk(filePath) : [filePath]; + }); +} + +const migration = read(migrationPath); +const authBoundaryMigration = read(authBoundaryMigrationPath); +const privilegedBootstrap = read(privilegedBootstrapPath); +assert.match( + privilegedBootstrap, + /revoke execute on all functions in schema public[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i, + 'the privileged bootstrap must close Supabase base-image extension RPC execution', +); +assert.match( + privilegedBootstrap, + /alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker/i, + 'the privileged bootstrap must keep future extension-owner functions closed', +); +assert.match( + migration, + /revoke all privileges on all tables in schema public from public, anon, authenticated/i, + 'existing public tables must not be exposed to client Data API roles', +); +assert.match( + authBoundaryMigration, + /revoke all on function app\.auth_user_exists\(uuid\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i, + 'the Auth existence boundary must be denied to every Data API role before the API-only grant', +); +assert.doesNotMatch( + authBoundaryMigration, + /grant execute on function app\.auth_user_exists\(uuid\) to (?:anon|authenticated|service_role)/i, + 'the Auth existence boundary must never be exposed through PostgREST roles', +); +assert.match( + migration, + /revoke all privileges on all sequences in schema public from public, anon, authenticated/i, + 'existing public sequences must not be exposed to client Data API roles', +); +assert.match( + migration, + /revoke all privileges on all functions in schema public from public, anon, authenticated/i, + 'existing public RPC functions must not be exposed to client Data API roles', +); +assert.match( + migration, + /public functions remain executable by anon\/authenticated; run the privileged backend runtime role bootstrap before migrations/i, + 'normal migrations must fail closed when Supabase-owned extension functions remain exposed', +); +assert.match( + migration, + /alter default privileges for role %I in schema public revoke all privileges on tables from public, anon, authenticated/i, + 'future public tables must default to no client Data API grant', +); +assert.match( + migration, + /alter default privileges for role %I in schema public revoke all privileges on sequences from public, anon, authenticated/i, + 'future public sequences must default to no client Data API grant', +); +assert.match( + migration, + /revoke create on schema public from public, anon, authenticated/i, + 'client roles must not create objects in the exposed public schema', +); +assert.match( + migration, + /alter default privileges for role %I revoke execute on functions from public, anon, authenticated/i, + 'future public RPC functions must require an explicit execute grant', +); +assert.match( + migration, + /select distinct owner_role\.rolname[\s\S]*pg_has_role\(current_user, owner_role\.oid, 'MEMBER'\)/i, + 'default privileges must cover every public owner the migration role is authorized to manage', +); +assert.match(migration, /drop policy if exists platform_admin_platform_users/i); +assert.match( + migration, + /create policy platform_users_self_read[\s\S]*for select[\s\S]*to authenticated[\s\S]*auth_user_id\s*=\s*\(select auth\.uid\(\)\)/i, + 'platform_users may expose only an explicit self-read policy to authenticated users', +); +assert.doesNotMatch( + migration, + /create policy [^;]+ on public\.platform_users[\s\S]*?for\s+(?:all|insert|update|delete)/i, + 'platform_users must not have a client write policy', +); +assert.match( + migration, + /create or replace function app\.is_platform_admin\(\)[\s\S]*security definer[\s\S]*set search_path = ''[\s\S]*from public\.platform_users[\s\S]*status = 'active'/i, + 'RLS platform authority must come from an active database identity', +); +assert.doesNotMatch( + migration, + /select\s+app\.current_role\(\)\s+in\s*\([^)]*platform_admin/i, + 'a platform_admin JWT role claim must not be sufficient for RLS authority', +); + +const readiness = read(readinessPath); +for (const gateId of [ + 'db.data_api.public_table_acl', + 'db.data_api.public_sequence_acl', + 'db.data_api.public_function_acl', + 'db.data_api.public_default_acl', + 'db.data_api.platform_users_write_policy', + 'db.rls.platform_admin_authority', +]) { + assert.ok(readiness.includes(gateId), `production database readiness must include ${gateId}`); +} + +const sdkImportViolations = []; +const dataApiCallViolations = []; +for (const filePath of walk(taroSourceRoot).filter(file => /\.(?:ts|tsx)$/.test(file))) { + const source = read(filePath); + const relative = path.relative(repoRoot, filePath).replace(/\\/g, '/'); + if (source.includes('@supabase/supabase-js') && relative !== 'apps/taro/src/services/supabase.ts') { + sdkImportViolations.push(relative); + } + if (!source.includes('ensureSupabaseClient') && !source.includes('getSupabaseClient') && !source.includes('@supabase/supabase-js')) { + continue; + } + for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\.(from|rpc)\s*\(/g)) { + if (match[1] !== 'Array' && match[1] !== 'Buffer') { + dataApiCallViolations.push(`${relative}:${match[0]}`); + } + } +} +assert.deepEqual(sdkImportViolations, [], 'Supabase SDK ownership must stay centralized in services/supabase.ts'); +assert.deepEqual(dataApiCallViolations, [], 'Taro must not access business tables or RPCs through the Data API'); + +const rootPackage = JSON.parse(read(packagePath)); +assert.ok(rootPackage.scripts?.['test:data-api:security'], 'root package must expose the Data API security contract test'); +assert.ok( + rootPackage.scripts?.['test:readiness']?.includes('data-api-security-contract-test.js'), + 'the production readiness contract suite must run the Data API security test', +); + +console.log('[PASS] Supabase Data API deny-by-default security contract'); diff --git a/scripts/deploy-contract-test.js b/scripts/deploy-contract-test.js new file mode 100644 index 00000000..85a952d3 --- /dev/null +++ b/scripts/deploy-contract-test.js @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8').replace(/\r\n/g, '\n'); +} + +function section(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + assert.notEqual(start, -1, `missing section start: ${startMarker}`); + const end = source.indexOf(endMarker, start + startMarker.length); + assert.notEqual(end, -1, `missing section end: ${endMarker}`); + return source.slice(start, end); +} + +function ordered(source, markers, label) { + let cursor = 0; + for (const marker of markers) { + const index = source.indexOf(marker, cursor); + assert.notEqual(index, -1, `${label}: missing or out-of-order marker: ${marker}`); + cursor = index + marker.length; + } +} + +const rootDeploy = read('deploy.sh'); +const compatDeploy = read('scripts/deploy/bin/deploy.sh'); +const rootEnv = read('deploy.env.example'); +const compatEnv = read('scripts/deploy/env/deploy.env.example'); +const packageJson = read('package.json'); +const taroPackageJson = read('apps/taro/package.json'); +const apiService = read('scripts/deploy/systemd/tiku-api.service'); +const workerService = read('scripts/deploy/systemd/tiku-worker@.service'); +const workerTarget = read('scripts/deploy/systemd/tiku-workers.target'); +const apiEnv = read('scripts/deploy/env/api.env.example'); +const workerEnv = read('scripts/deploy/env/worker.env.example'); +const runtimeRoleBootstrap = read('scripts/deploy/sql/bootstrap-backend-runtime-roles.sql'); +const deployReadme = read('scripts/deploy/README.md'); + +assert.match(rootDeploy, /: "\$\{WWW_ROOT:=\/srv\/tiku-saas\/www\}"/); +assert.match(rootDeploy, /: "\$\{SERVICE_REPO_DIR:=\/opt\/tiku-saas\/repo\}"/); +assert.match(rootDeploy, /: "\$\{SERVICE_MODE:=systemd\}"/); +assert.match(rootDeploy, /: "\$\{HEALTHCHECK_URL:=http:\/\/127\.0\.0\.1:8787\/health\}"/); +assert.match(rootDeploy, /Production deployment requires a service restart strategy/); +assert.match(rootDeploy, /Production deployment requires HEALTHCHECK_URL/); +assert.match(rootDeploy, /Production systemd deployment requires SYNC_SERVICE_REPO=true/); +assert.match(rootDeploy, /Production deployment requires RUN_DB_READINESS=true before launch gate/); +assert.match(compatDeploy, /Production deployment requires RUN_DB_READINESS=true before launch gate/); +assert.match(rootDeploy, /Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true/); +assert.match(compatDeploy, /Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true/); +assert.match(rootDeploy, /Production database migrations require a separate DATABASE_MIGRATION_URL/); +assert.match(compatDeploy, /Production database migrations require a separate DATABASE_MIGRATION_URL/); +assert.match(rootDeploy, /supabase db push --db-url \\"\\\$DATABASE_MIGRATION_URL\\"/); +assert.match(compatDeploy, /supabase db push --db-url \\"\\\$DATABASE_MIGRATION_URL\\"/); +assert.match(compatDeploy, /source "\$API_ENV_FILE"/); +assert.ok(rootDeploy.includes('LOCK_DIR="$DEPLOY_ROOT/.deploy.lock"')); +assert.ok(compatDeploy.includes('LOCK_DIR="${LOCK_DIR:-$APP_ROOT/.deploy.lock}"')); +assert.match(rootDeploy, /LOCK_ACQUIRED=false/); +assert.match(compatDeploy, /LOCK_ACQUIRED=false/); +assert.match(compatDeploy, /export GIT_TERMINAL_PROMPT=0/); +assert.match(rootDeploy, /NPM_INSTALL_COMMAND:=npm ci --workspaces --include-workspace-root --include=dev/); +assert.match(rootDeploy, /NPM_INSTALL_COMMAND must allow the reviewed Taro workspace postinstall patches/); +assert.doesNotMatch(rootEnv, /--ignore-scripts/); +assert.match(rootEnv, /^NPM_INSTALL_COMMAND="npm ci --workspaces --include-workspace-root --include=dev"$/m); +assert.match(compatDeploy, /npm --prefix "\$SOURCE_REPO_DIR" ci[\s\S]*--workspaces[\s\S]*--include-workspace-root[\s\S]*--include=dev/); +assert.doesNotMatch(compatDeploy, /npm --prefix "\$SOURCE_REPO_DIR" ci[\s\S]*--ignore-scripts/); +assert.match(taroPackageJson, /"postinstall": "node \.\.\/\.\.\/scripts\/taro-components-h5-runtime-patch\.js --apply"/); +assert.match(apiService, /WorkingDirectory=\/opt\/tiku-saas\/repo/); +assert.match(workerService, /WorkingDirectory=\/opt\/tiku-saas\/repo/); +assert.match(workerService, /ExecStart=.*--loop --job %i/); +assert.match(workerTarget, /Requires=tiku-worker@crm\.service/); +assert.match(workerTarget, /Wants=tiku-worker-monthly-usage\.timer/); + +for (const [dist, target] of [ + ['h5-student', 'student'], + ['h5-tenant-admin', 'tenant-admin'], + ['h5-platform-admin', 'platform-admin'], +]) { + assert.ok( + rootDeploy.includes(`$release/apps/taro/dist/${dist}/\" \"$staging/${target}/`), + `root deploy must stage ${dist} as ${target}`, + ); + assert.ok( + compatDeploy.includes(`$CANDIDATE_RELEASE/apps/taro/dist/${dist}/\" \"$staging/${target}/`), + `compat deploy must stage ${dist} as ${target}`, + ); +} + +const rootMain = section(rootDeploy, 'main() {', '\n}\n\nmain "$@"'); +ordered(rootMain, [ + 'run_build_and_checks "$NEW_RELEASE"', + 'stage_h5_release "$NEW_RELEASE"', + 'ROLLBACK_ARMED=true', + 'switch_current "$NEW_RELEASE"', + 'sync_service_repo "$NEW_RELEASE"', + 'restart_services', + 'healthcheck', + 'switch_www_release', + 'verify_live_h5_release "$NEW_RELEASE"', +], 'root activation order'); +assert.ok(rootDeploy.includes('DEPLOY_RELEASE_ROOT="$release"'), 'root launch gate must bind the candidate release root'); +assert.ok(rootDeploy.includes('--verify-live-h5'), 'root deploy must verify the activated production H5 release'); + +const candidateChecks = section(compatDeploy, 'build_and_validate_candidate() {', '\n}\n\nstage_www_candidate() {'); +ordered(candidateChecks, [ + 'npm run audit:taro:supply-chain', + 'npm run build:taro:h5:student', + 'npm run build:taro:h5:platform', + 'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json"', + 'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json"', + 'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json"', + 'node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config', + 'npm run manifest:taro:h5 -- --require-dist --require-runtime-config', + 'npm run smoke:taro:h5', + 'npm run audit:runtime', + 'load_runtime_env', + 'npm run readiness:production', + 'run_shell "$DB_MIGRATION_COMMAND"', + 'npm run readiness:production:db', + 'npm run launch:gate', +], 'compat candidate validation order'); +assert.ok(compatDeploy.includes('DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE"'), 'compat launch gate must bind the candidate release root'); + +const rootChecks = section(rootDeploy, 'run_build_and_checks() {', '\n}\n\nverify_live_h5_release() {'); +ordered(rootChecks, [ + 'npm run audit:taro:supply-chain', + 'npm run build:taro:h5:student', + 'npm run audit:runtime', + 'link_shared_env "$release"', + 'load_runtime_env', + 'npm run readiness:production', + 'run_shell "$DB_MIGRATION_COMMAND"', + 'npm run readiness:production:db', + 'npm run launch:gate', +], 'root database gate order'); + +const compatMain = section(compatDeploy, 'main() {', '\n}\n\nmain "$@"'); +assert.doesNotMatch( + compatMain, + /load_runtime_env/, + 'production secrets must not be loaded before dependency installation and candidate builds', +); +ordered(compatMain, [ + 'stage_candidate "$release_name"', + 'build_and_validate_candidate', + 'stage_www_candidate "$release_name"', + 'ROLLBACK_ARMED=true', + 'atomic_symlink "$CANDIDATE_RELEASE" "$CURRENT_LINK"', + 'sync_service_repo "$CANDIDATE_RELEASE"', + 'restart_services', + 'healthcheck', + 'switch_www_release "$candidate_www"', + 'verify_live_h5_release', +], 'compat activation order'); +assert.ok(compatDeploy.includes('--verify-live-h5'), 'compat deploy must verify the activated production H5 release'); +assert.ok(!compatDeploy.includes('"$WWW_ROOT/student/"'), 'compat deploy must not overwrite the live student directory'); +assert.match(compatDeploy, /WWW_RELEASES_DIR:\=\$\{WWW_ROOT%\/\}-releases/); +assert.match(compatDeploy, /\.tmp-\$release_name/); + +const compatRollback = section(compatDeploy, 'rollback() {', '\n}\n\ncleanup() {'); +assert.match(compatRollback, /atomic_symlink "\$PREVIOUS_WWW_RELEASE" "\$WWW_ROOT"/); +assert.match(compatRollback, /sync_service_repo "\$PREVIOUS_APP_RELEASE"/); +assert.match(compatRollback, /restart_services/); + +assert.match(rootEnv, /^WWW_ROOT=\/srv\/tiku-saas\/www$/m); +assert.match(rootEnv, /^SERVICE_REPO_DIR=\/opt\/tiku-saas\/repo$/m); +assert.match(rootEnv, /^SERVICE_MODE=systemd$/m); +assert.match(rootEnv, /^SYSTEMD_UNITS="tiku-api\.service tiku-workers\.target"$/m); +assert.match(rootEnv, /^HEALTHCHECK_URL=http:\/\/127\.0\.0\.1:8787\/health$/m); +assert.match(compatEnv, /^SOURCE_REPO_DIR=\/opt\/tiku-saas\/source$/m); +assert.match(compatEnv, /^REPO_DIR=\/opt\/tiku-saas\/repo$/m); +assert.match(rootEnv, /^NPM_AUDIT_REGISTRY=https:\/\/registry\.npmjs\.org\/$/m); +assert.match(compatEnv, /^NPM_AUDIT_REGISTRY=https:\/\/registry\.npmjs\.org\/$/m); +assert.match(rootEnv, /^RUN_TARO_SUPPLY_CHAIN_AUDIT=true$/m); +assert.match(compatEnv, /^RUN_TARO_SUPPLY_CHAIN_AUDIT=true$/m); +assert.match(compatEnv, /^SYSTEMD_UNITS="tiku-api\.service tiku-workers\.target"$/m); +assert.match(workerEnv, /^STORAGE_DEFAULT_BUCKET=/m); +assert.match(workerEnv, /^WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=/m); +assert.match(workerEnv, /^WORKER_CRM_POLL_INTERVAL_MS=/m); +assert.match(apiEnv, /chown root:deploy, and chmod 640/); +assert.doesNotMatch(workerEnv, /^ALIYUN_OSS_BUCKET=/m); +assert.doesNotMatch(workerEnv, /^ASSET_SECURITY_SCAN_ENDPOINT=/m); +assert.doesNotMatch(workerEnv, /^WORKER_POLL_INTERVAL_MS=/m); +assert.match(runtimeRoleBootstrap, /alter role tiku_api[\s\S]*bypassrls/i); +assert.match(runtimeRoleBootstrap, /alter role tiku_worker[\s\S]*bypassrls/i); +assert.doesNotMatch(runtimeRoleBootstrap, /password\s+['"]/i); +assert.match(deployReadme, /bootstrap:db-runtime-roles/); +assert.match(deployReadme, /BOOTSTRAP_BACKEND_RUNTIME_ROLES/); +assert.ok( + packageJson.includes('npm audit --registry=${NPM_AUDIT_REGISTRY:-https://registry.npmjs.org/}'), + 'npm audit scripts must default to the official audit registry while remaining configurable', +); + +console.log('deploy contract: ok'); diff --git a/scripts/deploy/README.md b/scripts/deploy/README.md index d44bdff3..f86c90f3 100644 --- a/scripts/deploy/README.md +++ b/scripts/deploy/README.md @@ -1,385 +1,470 @@ -# tiku-supabase 云服务器部署说明 +# SaaS 题库服务器部署手册 -本文档用于把当前仓库部署到云服务器,并和已经解析好的域名打通。仓库内只保存安全模板,真实密钥、数据库密码、支付密钥、短信密钥、对象存储密钥和 Gitea 部署凭证必须放在服务器 `/etc/tiku-saas/` 下,不能提交到 Git。 +本手册覆盖两类场景: -## 域名规划 +1. 全新测试/预生产服务器的隔离 bootstrap。 +2. 现有云服务器从旧原地覆盖脚本升级到版本化原子发布。 -建议先按下面 6 个域名落地: +生产发布器默认 fail closed。缺少真实数据库 readiness、三端 runtime config、生产 evidence、浏览器烟测环境或服务重启权限时,发布必须失败,不能通过关闭门禁或伪造 evidence 绕过。 -| 域名 | 用途 | 服务器转发 | -| --- | --- | --- | -| `api.tjszsb.com` | 自研业务 API,Taro/H5/小程序统一调用 | `127.0.0.1:8787` | -| `app.tjszsb.com` | 学生 H5 题库端 | `/srv/tiku-saas/www/student` | -| `admin.tjszsb.com` | 租户后台 H5 | `/srv/tiku-saas/www/tenant-admin` | -| `console.tjszsb.com` | SaaS 平台后台 H5 | `/srv/tiku-saas/www/platform-admin` | -| `supabase.tjszsb.com` | Supabase API gateway/Auth/Storage/PostgREST | Supabase gateway,通常是 `127.0.0.1:8000` | -| `studio.tjszsb.com` | Supabase Studio 运维后台 | 仅允许固定 IP/VPN/内网访问 | +## 先选择入口 -`studio.tjszsb.com` 不建议裸露给公网。若必须临时开放,至少要加 Nginx IP 白名单、强密码、服务器防火墙和访问日志审计。 +| 场景 | 入口 | 配置模型 | 状态 | +| --- | --- | --- | --- | +| 全新生产服务器 | 仓库根目录 `deploy.sh` | `/opt/tiku-saas/shared/` | 推荐 | +| 现有云服务器 | `scripts/deploy/bin/deploy.sh` 安装到 `/opt/tiku-saas/bin/deploy.sh` | `/etc/tiku-saas/` | 兼容升级入口 | +| 全新测试/预生产服务器 | 本手册的 staging bootstrap | 完全独立目录、数据库、域名和凭据 | 当前无一键 staging profile | -## 服务器目录 +两套生产脚本的变量名不同,不能混用 env 模板: -推荐使用固定目录,方便后续脚本和 AI 协作不漂移: +- 根脚本:`REPO_URL`、`BRANCH`、`DEPLOY_ROOT`,模板为根目录 `deploy.env.example`。 +- 兼容脚本:`GIT_REPO`、`GIT_BRANCH`、`APP_ROOT`,模板为 `scripts/deploy/env/deploy.env.example`。 -```text -/opt/tiku-saas/repo Git 工作副本 -/opt/tiku-saas/bin 服务器本地执行脚本 -/srv/tiku-saas/www/student 学生端 H5 静态文件 -/srv/tiku-saas/www/tenant-admin 租户后台 H5 静态文件 -/srv/tiku-saas/www/platform-admin 平台后台 H5 静态文件 -/srv/tiku-saas/data 运行期数据 -/srv/tiku-saas/backups 数据库和对象存储备份 -/etc/tiku-saas/deploy.env 部署脚本配置,含 Gitea 只读部署凭证 -/etc/tiku-saas/api.env API 生产环境变量 -/etc/tiku-saas/worker.env worker 生产环境变量 -/etc/tiku-saas/runtime-config/ 三套 H5 公开运行时配置 -``` - -建议创建独立低权限用户: - -```bash -sudo useradd --system --create-home --shell /bin/bash deploy -sudo mkdir -p /opt/tiku-saas/bin /srv/tiku-saas/www/student /srv/tiku-saas/www/tenant-admin /srv/tiku-saas/www/platform-admin /srv/tiku-saas/data /srv/tiku-saas/backups /etc/tiku-saas/runtime-config -sudo chown -R deploy:deploy /opt/tiku-saas /srv/tiku-saas -sudo chmod 750 /etc/tiku-saas -``` - -## 宝塔服务器实际落地记录 - -2026-07-01 首次上云使用的是 Alibaba Cloud Linux 3 + 宝塔面板环境。该服务器的 80/443 已由宝塔 Nginx 接管,主配置不在 `/etc/nginx`,而在: - -```text -/www/server/nginx/conf/nginx.conf -/www/server/panel/vhost/nginx/*.conf -``` - -因此在这类服务器上不要执行 `systemctl start nginx`、不要写 `/etc/nginx/sites-available`,也不要覆盖宝塔生成的站点配置。宝塔 Nginx 的测试和重载命令是: - -```bash -/www/server/nginx/sbin/nginx -t -c /www/server/nginx/conf/nginx.conf -/www/server/nginx/sbin/nginx -s reload -``` - -本次保留企业目录隔离方案: - -```text -/opt/tiku-saas/repo Gitea 工作副本 -/opt/tiku-saas/bin 服务器部署脚本 -/srv/tiku-saas/www H5 发布产物 -/srv/tiku-saas/data 运行数据 -/srv/tiku-saas/backups 备份 -/etc/tiku-saas 真实 env、Gitea token、运行时配置 -``` - -宝塔新增站点时会拦截 `/srv` 作为网站根目录。不要因此把密钥、仓库或运行数据搬进 `/www`。只为 H5 静态站点创建 `/www/wwwroot` 下的软链接: - -```bash -mkdir -p /www/wwwroot/tiku-saas -ln -sfn /srv/tiku-saas/www/student /www/wwwroot/tiku-saas/student -ln -sfn /srv/tiku-saas/www/tenant-admin /www/wwwroot/tiku-saas/tenant-admin -ln -sfn /srv/tiku-saas/www/platform-admin /www/wwwroot/tiku-saas/platform-admin -chown -h deploy:deploy /www/wwwroot/tiku-saas/student -chown -h deploy:deploy /www/wwwroot/tiku-saas/tenant-admin -chown -h deploy:deploy /www/wwwroot/tiku-saas/platform-admin -``` - -宝塔面板中新增三个纯静态站点: - -| 域名 | 宝塔根目录 | -| --- | --- | -| `app.tjszsb.com` | `/www/wwwroot/tiku-saas/student` | -| `admin.tjszsb.com` | `/www/wwwroot/tiku-saas/tenant-admin` | -| `console.tjszsb.com` | `/www/wwwroot/tiku-saas/platform-admin` | - -每个站点需要保留 H5 history fallback,并禁止缓存公开运行时配置: - -```nginx -location / { - try_files $uri $uri/ /index.html; -} - -location = /runtime-config.json { - add_header Cache-Control "no-store" always; - try_files $uri =404; -} -``` - -当前服务器已经验证过的基础环境: - -```text -Node.js: v20.20.2,系统级安装在 /usr/bin/node,deploy 用户可用 -npm: 10.8.2,deploy 用户可用 -Docker: 26.1.3 -Docker Compose: v2.27.0 -Nginx: 宝塔 /www/server/nginx/sbin/nginx,1.30.1 -``` - -不要使用 root 的 nvm Node 路径作为生产运行时。若 `deploy` 用户看不到 Node/NPM,应安装系统级 NodeSource Node.js 20: - -```bash -curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - -dnf install -y nodejs -sudo -u deploy bash -lc 'command -v node; command -v npm; node -v; npm -v' -``` - -大陆服务器 `npm ci` 可能访问 npm 官方源超时。本次部署在 `/etc/tiku-saas/deploy.env` 中使用可配置 npm registry 和重试参数: - -```bash -NPM_REGISTRY=https://registry.npmmirror.com -NPM_FETCH_RETRIES=5 -NPM_FETCH_RETRY_MINTIMEOUT=20000 -NPM_FETCH_RETRY_MAXTIMEOUT=120000 -NPM_FETCH_TIMEOUT=300000 -``` - -截至 2026-07-01 21:39,`sudo -u deploy /opt/tiku-saas/bin/deploy.sh` 已完成: - -- Gitea `main` 拉取到 `/opt/tiku-saas/repo`。 -- `npm ci` 安装依赖。 -- `npm run security:repo`,结果 0 finding。 -- `node scripts/production-launch-gate-test.js`,通过。 -- API 和 worker 构建通过。 -- 学生端、租户后台、平台后台三套 Taro H5 构建通过。 -- H5 发布到 `/srv/tiku-saas/www/student`、`/srv/tiku-saas/www/tenant-admin`、`/srv/tiku-saas/www/platform-admin`。 -- 三个 `runtime-config.json` 已安装到各自 H5 根目录。 - -Taro H5 构建存在 webpack asset size warning,这是前端包体优化事项,不影响当前部署继续进行。后续可做拆包、按需加载和 KaTeX 字体裁剪。 - -## 服务器接管和故障恢复 - -2026-07-03 最新接管状态: - -- Gitea `main` 已包含 PNVS 短信认证、后台登录修复、PNVS provider 配置/诊断脚本和旧题库视觉对齐版本,最新提交应至少是 `4fb4125`。 -- 服务器仓库仍在 `/opt/tiku-saas/repo`,归属用户应为 `deploy:deploy`。 -- 生产 API 已能启动,`https://api.tjszsb.com/api/tenant/resolve?host=app.tjszsb.com` 已返回 `master` 租户。 -- Supabase self-hosted 运行在 `/opt/tiku-saas/supabase-project`,Kong 通过 Nginx 暴露到 `https://supabase.tjszsb.com`。 -- 线上 H5 公开配置文件在 `/srv/tiku-saas/www/*/runtime-config.json`,密钥只允许放 `supabasePublishableKey` 这类公开 key。 -- 短信验证码登录生产必须使用 `AUTH_SMS_PROVIDER=aliyun-pnvs`。阿里云 AccessKey/Secret 只写入 `app_private.tenant_secrets(secret_scope='sms', secret_key='aliyun-pnvs')`,不要写进 `/etc/tiku-saas/api.env` 或 H5 `runtime-config.json`。 - -配置 PNVS provider 推荐用仓库脚本写入数据库,避免手写 SQL 时把密钥打进命令历史。生产环境建议临时关闭 shell history,再用 `read -s` 输入 AccessKeySecret: - -```bash -cd /opt/tiku-saas/repo -set -a -source /etc/tiku-saas/api.env -set +a -set +o history -read -r -p 'Aliyun AccessKeyId: ' ALIYUN_ACCESS_KEY_ID -read -r -s -p 'Aliyun AccessKeySecret: ' ALIYUN_ACCESS_KEY_SECRET; echo -read -r -p 'PNVS SignName: ' ALIYUN_PNVS_SIGN_NAME -read -r -p 'PNVS TemplateCode: ' ALIYUN_PNVS_TEMPLATE_CODE -PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 \ -ALIYUN_ACCESS_KEY_ID="$ALIYUN_ACCESS_KEY_ID" \ -ALIYUN_ACCESS_KEY_SECRET="$ALIYUN_ACCESS_KEY_SECRET" \ -ALIYUN_PNVS_SIGN_NAME="$ALIYUN_PNVS_SIGN_NAME" \ -ALIYUN_PNVS_TEMPLATE_CODE="$ALIYUN_PNVS_TEMPLATE_CODE" \ -npm run configure:aliyun-pnvs -unset ALIYUN_ACCESS_KEY_ID ALIYUN_ACCESS_KEY_SECRET ALIYUN_PNVS_SIGN_NAME ALIYUN_PNVS_TEMPLATE_CODE -set -o history -``` - -配置后先跑只读诊断,确认 env、`tenant_auth_providers` 和 `tenant_secrets` 对齐;输出只包含 AccessKey 长度和脱敏前后缀,不会打印密钥明文: - -```bash -cd /opt/tiku-saas/repo -set -a -source /etc/tiku-saas/api.env -set +a -PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run diagnose:aliyun-pnvs -``` - -如果 `readiness:production:db` 报 `legacy_sms_provider`,先 dry-run 查看仍处于 `active/testing` 的传统短信 provider,再确认停用。这个脚本只会处理 `aliyun`、`aliyun-sms`、`tencent`、`tencent-sms` 等旧短信 auth provider,不会改 PNVS 行: - -```bash -cd /opt/tiku-saas/repo -set -a -source /etc/tiku-saas/api.env -set +a -PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run disable:legacy-sms-providers -PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run disable:legacy-sms-providers -- --apply -``` - -接管服务器时先做只读检查: - -```bash -cd /opt/tiku-saas/repo -sudo -u deploy git status --short -sudo -u deploy git log -3 --oneline -sudo -u deploy git remote -v -systemctl status tiku-api --no-pager -l -systemctl status tiku-worker --no-pager -l -docker compose -f /opt/tiku-saas/supabase-project/docker-compose.yml ps -``` - -Gitea SSH 使用 `2222` 端口,推荐服务器 `deploy` 用户的 remote 使用完整 SSH URL: - -```bash -sudo -u deploy git -C /opt/tiku-saas/repo remote set-url origin ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git -sudo -u deploy ssh -o BatchMode=yes -T -p 2222 git@git.gongxue100.com -sudo -u deploy git -C /opt/tiku-saas/repo pull origin main -``` - -也可以写 `/home/deploy/.ssh/config`,但必须包含 `Port 2222`: - -```sshconfig -Host git.gongxue100.com - HostName git.gongxue100.com - User git - Port 2222 - IdentityFile /home/deploy/.ssh/tiku_saas_deploy - IdentitiesOnly yes -``` - -如果 Taro 构建长时间没有输出,先判断它是真在编译还是已经卡死。构建中的 Taro/webpack 可能会有一段时间安静,但如果 `dist` 目录大小和最近修改时间 30 秒以上都不变,就按卡住处理: - -```bash -ps -eo pid,ppid,user,stat,etime,%cpu,%mem,cmd | grep -E 'npm|node|taro|webpack' | grep -v grep -du -sh /opt/tiku-saas/repo/apps/taro/dist/h5-student -sleep 30 -du -sh /opt/tiku-saas/repo/apps/taro/dist/h5-student -find /opt/tiku-saas/repo/apps/taro/dist/h5-student -type f -mmin -5 | head -20 -``` - -确认卡住后,先在原终端 `Ctrl+C`。如果仍有残留 Taro 构建进程,再只结束这条构建链路,不要杀生产 API、Supabase 或其它 Node 服务: - -```bash -ps -eo pid,ppid,user,stat,etime,%cpu,%mem,cmd | grep -E 'npm run build:taro|taro build --type h5|webpack' | grep -v grep -kill -sleep 3 -kill -9 -``` - -然后清理单端产物并带 CI/内存参数重跑。先单独跑学生端,成功后再跑另外两端: - -```bash -cd /opt/tiku-saas/repo -rm -rf apps/taro/dist/h5-student -sudo -u deploy env CI=1 NODE_OPTIONS="--max-old-space-size=4096" npm run build:taro:h5:student - -rm -rf apps/taro/dist/h5-tenant-admin apps/taro/dist/h5-platform-admin -sudo -u deploy env CI=1 NODE_OPTIONS="--max-old-space-size=4096" npm run build:taro:h5:tenant -sudo -u deploy env CI=1 NODE_OPTIONS="--max-old-space-size=4096" npm run build:taro:h5:platform -``` - -三端构建成功后再发布: +服务器长期更新命令可以继续保持: ```bash sudo -u deploy /opt/tiku-saas/bin/deploy.sh ``` -如果只是想接管代码开发,不要从服务器 `apps/taro/dist` 或 `/srv/tiku-saas/www` 拷贝产物;它们只是发布结果,源码以 Gitea `main` 为准。 +但首次发布当前基线前,必须先升级 `/opt/tiku-saas/bin/deploy.sh`、部署配置、API/Worker env 和 systemd units。发布脚本不会自动更新自己,也不会自动安装 Nginx 或 systemd 模板。 -## 首次安装 +## 发布能力与边界 -1. 安装基础组件:Docker、Docker Compose、Node.js 20+、Nginx、Certbot、Git、rsync、flock。 -2. 按 Supabase 官方 self-hosting Docker 文档部署 Supabase。生产必须启用 HTTPS 反向代理,Supabase 官方也要求生产自托管部署使用 HTTPS。 -3. 把本目录模板复制到服务器: +新版生产部署器会: -```bash -sudo mkdir -p /opt/tiku-saas/bin /etc/tiku-saas/runtime-config -sudo cp scripts/deploy/bin/deploy.sh /opt/tiku-saas/bin/deploy.sh -sudo cp scripts/deploy/env/deploy.env.example /etc/tiku-saas/deploy.env -sudo cp scripts/deploy/env/api.env.example /etc/tiku-saas/api.env -sudo cp scripts/deploy/env/worker.env.example /etc/tiku-saas/worker.env -sudo cp scripts/deploy/runtime-config/h5-student.runtime-config.example.json /etc/tiku-saas/runtime-config/h5-student.runtime-config.json -sudo cp scripts/deploy/runtime-config/h5-tenant-admin.runtime-config.example.json /etc/tiku-saas/runtime-config/h5-tenant-admin.runtime-config.json -sudo cp scripts/deploy/runtime-config/h5-platform-admin.runtime-config.example.json /etc/tiku-saas/runtime-config/h5-platform-admin.runtime-config.json -sudo chmod 700 /opt/tiku-saas/bin/deploy.sh -sudo chmod 600 /etc/tiku-saas/*.env /etc/tiku-saas/runtime-config/*.json +1. 获取固定分支的最新 commit,并在独立候选 release 安装锁定依赖。 +2. 运行 TypeScript、仓库安全扫描、runtime audit 和 Taro 供应链审计。 +3. 构建 API、Worker、学生 H5、租户后台 H5、平台后台 H5。 +4. 注入三端公开 runtime config,运行严格 guard、manifest、25 项静态 smoke 和 33 项真实浏览器交互 smoke。 +5. 运行生产 env readiness;可选执行 migration;随后运行数据库 readiness。 +6. 使用与 commit、artifact 和 SHA-256 绑定的真实 production launch evidence。 +7. 原子切换应用与 Web release,重启服务,验证 API `/health` 和线上 H5 hash。 +8. 失败时恢复上一份代码和 Web release。 + +脚本不会: + +- 创建数据库或对象存储备份。 +- 回滚已经提交的 migration。 +- 自动更新 `/etc/systemd/system`、宝塔 Nginx 或 `/etc/tiku-saas/*.env`。 +- 逐一确认所有 Worker backlog、Provider、外部告警和业务数据正确性。 +- 自动创建首个平台超管。 + +因此 migration 必须先备份并演练向后兼容。代码/Web 回滚不能被当作数据库回滚。 + +## 域名与目录 + +参考域名: + +```text +api.tjszsb.com Node.js API +app.tjszsb.com 学生 H5 +admin.tjszsb.com 租户后台 H5 +console.tjszsb.com 平台后台 H5 +supabase.tjszsb.com Supabase Gateway/Auth/Data API +studio.tjszsb.com Supabase Studio,必须限制来源 ``` -4. 编辑 `/etc/tiku-saas/*.env` 和 `/etc/tiku-saas/runtime-config/*.json`,填入真实生产配置。 -5. 安装 systemd 服务: +现有生产兼容布局: -```bash -sudo cp scripts/deploy/systemd/tiku-api.service /etc/systemd/system/tiku-api.service -sudo cp scripts/deploy/systemd/tiku-worker.service /etc/systemd/system/tiku-worker.service -sudo systemctl daemon-reload -sudo systemctl enable tiku-api tiku-worker +```text +/opt/tiku-saas/source Gitea 源码副本,只用于 fetch/build +/opt/tiku-saas/releases 版本化候选应用 +/opt/tiku-saas/current 当前应用 release 软链接 +/opt/tiku-saas/repo systemd 当前运行副本 +/opt/tiku-saas/bin/deploy.sh 服务器外置兼容部署器 +/srv/tiku-saas/www 当前三端 Web release 软链接 +/srv/tiku-saas/www-releases 版本化 Web release +/etc/tiku-saas/deploy.env 部署配置和只读 Gitea 凭据 +/etc/tiku-saas/api.env API 生产 env +/etc/tiku-saas/worker.env Worker 生产 env +/etc/tiku-saas/runtime-config/ 三端公开 runtime config +/etc/tiku-saas/production-launch-evidence.json +/etc/tiku-saas/launch-artifacts/ 与 evidence 配套的证据文件 ``` -6. 安装 Nginx 配置: +根部署器使用 `/opt/tiku-saas/shared/` 保存 deploy env、readiness env、runtime config 和 evidence。systemd 模板目前仍从 `/etc/tiku-saas/api.env` 与 `/etc/tiku-saas/worker.env` 读取运行配置,因此使用根脚本时也必须维护这两个文件;`shared/.env` 只用于候选 release 的 readiness,关键 API 配置必须与 `/etc/tiku-saas/api.env` 保持一致,避免双配置漂移。现有服务器优先使用兼容入口,减少这项差异。 -```bash -sudo cp scripts/deploy/nginx/tjszsb.com.conf.example /etc/nginx/sites-available/tiku-saas.conf -sudo ln -s /etc/nginx/sites-available/tiku-saas.conf /etc/nginx/sites-enabled/tiku-saas.conf -sudo nginx -t -sudo systemctl reload nginx +## 凭据 + +- 已经出现在聊天、工单、截图或日志里的 token 必须吊销。 +- 服务器优先使用只读 SSH deploy key;Gitea SSH 端口为 `2222`。 +- 使用 HTTPS 时,token 只放权限为 `600/640` 的服务器 env,由临时 `GIT_ASKPASS` 注入。 +- token 不得写入 Git remote、脚本、README 或命令历史。 +- `DATABASE_ADMIN_URL`、`DATABASE_MIGRATION_URL` 只从密码管理器临时注入,不长期写入 deploy/API/Worker env。 + +SSH remote: + +```text +ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git ``` -7. 申请 HTTPS 证书: +## 新测试服务器 -```bash -sudo certbot --nginx -d api.tjszsb.com -d app.tjszsb.com -d admin.tjszsb.com -d console.tjszsb.com -d supabase.tjszsb.com -d studio.tjszsb.com +测试服务器必须与生产完全隔离: + +```text +/opt/tiku-saas-staging +/srv/tiku-saas-staging +/etc/tiku-saas-staging +staging-api.example.com +staging-app.example.com +staging-admin.example.com +staging-console.example.com +独立 PostgreSQL/Supabase、Auth、bucket 和 Provider 测试账号 ``` -## Gitea 凭证 +数据库安全标记必须是: -优先推荐 SSH deploy key。若暂时使用 Gitea token,必须新建一个只读部署 token,并写入 `/etc/tiku-saas/deploy.env`,不要把 token 写进脚本、Git remote、命令历史或 README。 - -已经在聊天、工单、截图里出现过的 token 都应当视为暴露,正式上云前请立即吊销并重新生成。 - -`deploy.sh` 会通过临时 `GIT_ASKPASS` 给 `git clone/fetch` 提供账号和 token,避免 token 出现在 `git remote -v` 里。 - -## 更新发布 - -服务器上执行: - -```bash -sudo -u deploy /opt/tiku-saas/bin/deploy.sh +```text +environment=staging +allow_destructive_tests=false ``` -脚本会执行: +API 与 Worker 应继续使用 `NODE_ENV=production`,这样能验证生产配置 fail-fast。staging 不能使用 production 数据库、bucket、支付/短信密钥或真实用户流量,也不能运行 `supabase db reset`、`db:smoke-seed:test`、`test:api`、`test:rls` 和会写入集成夹具的 Worker 测试。 -1. 获取 `main` 最新代码。 -2. `npm ci` 安装锁定依赖。 -3. 运行仓库安全扫描和生产上线门禁测试。 -4. 构建 API、worker、学生 H5、租户后台 H5、平台后台 H5。 -5. 用 `rsync --delete` 发布静态产物。 -6. 复制服务器本地 `runtime-config.json` 到对应 Web 根目录。 -7. 重启 `tiku-api` 和 `tiku-worker`。 -8. 输出当前发布的 Git commit。 +当前仓库没有经过验证的一键 staging profile。两套 deploy 脚本在 production 模式下都会强制真实 launch gate,launch evidence 也不能用本地 mock 伪造。首次测试服务器采用下面的分阶段 bootstrap;在 staging profile 被单独实现和验证前,不要直接套用生产 `/opt/tiku-saas`、`/srv/tiku-saas` 和 `/etc/tiku-saas` 路径。 -## 上线前检查 - -每次正式放量前至少执行: +### 1. 固定候选 commit ```bash -npm run security:repo -node scripts/production-launch-gate-test.js -node scripts/launch-persona-smoke-test.js +sudo install -d -o deploy -g deploy /opt/tiku-saas-staging/source +sudo -u deploy git clone \ + ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git \ + /opt/tiku-saas-staging/source +sudo -u deploy git -C /opt/tiku-saas-staging/source checkout +``` + +不要在验证期间继续移动候选分支。 + +### 2. 安装与构建预检 + +```bash +cd /opt/tiku-saas-staging/source +sudo -u deploy npm ci --workspaces --include-workspace-root --include=dev +sudo -u deploy npm run check:api +sudo -u deploy npm run check:worker +sudo -u deploy npm run check:taro +sudo -u deploy npm run security:repo +sudo -u deploy npm run audit:runtime +sudo -u deploy npm run audit:taro:supply-chain +sudo -u deploy npm run build:api +sudo -u deploy npm run build:worker +sudo -u deploy npm run build:taro:h5:student +sudo -u deploy npm run build:taro:h5:tenant +sudo -u deploy npm run build:taro:h5:platform +``` + +服务器必须安装 Chrome/Chromium,或设置 `TARO_H5_SMOKE_BROWSER` 指向受支持浏览器。随后运行: + +```bash +sudo -u deploy npm run smoke:taro:h5 +sudo -u deploy env TARO_H5_INTERACTION_OUTPUT_DIR=/tmp/tiku-h5-staging \ + npm run smoke:taro:h5:interaction +``` + +这一步只证明候选产物和本地 mock 旅程,不是生产 evidence。 + +### 3. 数据库 bootstrap 与 migration + +先创建数据库和对象存储快照,再 dry-run runtime role 计划: + +```bash +cd /opt/tiku-saas-staging/source +npm run bootstrap:db-runtime-roles +``` + +由真正 PostgreSQL superuser 应用一次: + +```bash +DATABASE_ADMIN_URL='' \ +npm run bootstrap:db-runtime-roles -- \ + --apply --confirm=BOOTSTRAP_BACKEND_RUNTIME_ROLES +``` + +bootstrap 不设置角色密码。用密码管理器为 `tiku_api`、`tiku_worker` 设置独立强密码,再把相应连接分别写入 staging API/Worker env。 + +全新数据库在 migrations 后没有业务租户数据,生产 readiness 会因没有 active tenant 而阻断。使用受控管理流程创建 staging 的 master tenant、active domain、branding/settings 和必要 Provider 配置;不要执行 `supabase/seed.sql`,它是本地开发 seed,会把数据库标为 `local` 并写入 mock 数据。 + +由标准 migration role 应用迁移: + +```bash +DATABASE_MIGRATION_URL='' \ +supabase db push --db-url "$DATABASE_MIGRATION_URL" +``` + +然后使用 API 运行角色验证: + +```bash +set -a +source /etc/tiku-saas-staging/api.env +set +a npm run readiness:production -``` - -接入真实生产配置后,还要在服务器上补跑: - -```bash npm run readiness:production:db -npm run smoke:auth:remote -SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 SMS_SMOKE_PHONE=replace-with-real-phone SMS_SMOKE_ORIGIN=https://admin.tjszsb.com npm run smoke:sms-login:remote -- --write docs/refactor/launch-artifacts/sms-pnvs-remote-smoke.json -npm run perf:api:local ``` -如果要同时验证手机号绑定也走 PNVS provider verification,准备一个未绑定测试手机号后执行: +migration 是前向操作。失败时按备份恢复方案处理,不依赖 deploy symlink 回滚。 + +### 4. runtime config 与受控激活 + +从 `scripts/deploy/runtime-config/*.example.json` 创建 staging 文件。浏览器 H5 使用 Origin 解析租户时,`tenantCode` 保持空字符串;只允许公开 HTTPS URL 和 Supabase publishable key。 + +在尚未具备 staging 原子发布 profile 时,先由运维在隔离路径安装 systemd/Nginx,明确每一个 WorkingDirectory、EnvironmentFile、端口、域名和 Web root 都指向 `*-staging`。不得直接复制当前生产 unit 后仍保留 `/opt/tiku-saas/repo` 或 `/etc/tiku-saas`。 + +完成真实 staging Auth/CORS/Provider/三类 persona、Worker 和线上 H5 检查后,将报告保存到 staging 自己的 evidence bundle。production launch evidence 仍只能由最终生产候选生成,不能从 staging 复制冒充。 + +## 现有服务器一次性升级 + +### 1. 冻结与备份 + +1. 将完整基线合并到 Gitea,记录待部署 SHA。 +2. 备份 PostgreSQL、对象存储、`/etc/tiku-saas`、旧部署脚本、旧运行目录和宝塔 Nginx 配置。 +3. 验证备份可读,并记录数据库恢复与代码/Web 回滚步骤。 +4. 首次 migration 推荐独立执行“备份 -> migration -> DB readiness -> evidence”,正式应用发布时恢复 `RUN_DB_MIGRATIONS=false`。 + +### 2. 准备 source checkout + +从一个不依赖旧部署脚本的临时目录获取新代码: ```bash -SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 SMS_SMOKE_PHONE=replace-with-login-phone SMS_SMOKE_BIND_PHONE=replace-with-bind-phone SMS_SMOKE_ORIGIN=https://admin.tjszsb.com npm run smoke:sms-login:remote -- --write docs/refactor/launch-artifacts/sms-pnvs-remote-smoke.json +sudo install -d -o deploy -g deploy /opt/tiku-saas/source +sudo -u deploy git clone \ + ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git \ + /opt/tiku-saas/source +sudo -u deploy git -C /opt/tiku-saas/source checkout ``` -压测必须在目标云服务器、目标数据库参数、目标对象存储和目标 Nginx 配置下重新计算,本地 Windows 压测数据只能作为开发参考。 +若目录已存在,只允许 clean checkout: -## 关键安全要求 +```bash +sudo -u deploy git -C /opt/tiku-saas/source status --short +sudo -u deploy git -C /opt/tiku-saas/source fetch origin main --prune +sudo -u deploy git -C /opt/tiku-saas/source checkout main +sudo -u deploy git -C /opt/tiku-saas/source merge --ff-only origin/main +``` -- 前端只保存 `supabasePublishableKey`,严禁出现 service role、数据库密码、短信密钥、支付私钥。 -- 自研业务 API 默认只接受 Supabase JWT 或迁移期受控 app session,不允许前端携带平台管理密钥。 -- API、worker、Supabase、Nginx 日志要开启轮转,避免磁盘被日志打满。 -- 数据库至少每日备份,正式放量前要完成一次恢复演练。 -- 支付回调、短信回调、对象存储回调必须使用 HTTPS 域名,并在 API 层校验签名和租户归属。 -- Supabase Studio 必须限制访问来源。 +### 3. 升级外置部署脚本 + +先备份,再安装兼容入口: + +```bash +sudo cp -a /opt/tiku-saas/bin/deploy.sh \ + /opt/tiku-saas/bin/deploy.sh.backup-$(date +%Y%m%d%H%M%S) + +sudo install -o root -g deploy -m 0750 \ + /opt/tiku-saas/source/scripts/deploy/bin/deploy.sh \ + /opt/tiku-saas/bin/deploy.sh +``` + +不要期待仓库 pull 自动更新 `/opt/tiku-saas/bin/deploy.sh`。 + +### 4. 更新配置 + +备份 `/etc/tiku-saas/deploy.env`、`api.env` 和 `worker.env`,再逐项 diff 模板,不要覆盖真实 secrets: + +```bash +diff -u /etc/tiku-saas/deploy.env \ + /opt/tiku-saas/source/scripts/deploy/env/deploy.env.example || true +diff -u /etc/tiku-saas/api.env \ + /opt/tiku-saas/source/scripts/deploy/env/api.env.example || true +diff -u /etc/tiku-saas/worker.env \ + /opt/tiku-saas/source/scripts/deploy/env/worker.env.example || true +``` + +必须确认: + +```text +SOURCE_REPO_DIR=/opt/tiku-saas/source +REPO_DIR=/opt/tiku-saas/repo +RELEASES_DIR=/opt/tiku-saas/releases +WWW_ROOT=/srv/tiku-saas/www +WWW_RELEASES_DIR=/srv/tiku-saas/www-releases +RUN_TARO_SUPPLY_CHAIN_AUDIT=true +RUN_DB_READINESS=true +RUN_LAUNCH_GATE=true +SYSTEMD_UNITS="tiku-api.service tiku-workers.target" +HEALTHCHECK_URL=http://127.0.0.1:8787/health +``` + +API `DATABASE_URL` 必须使用 `tiku_api`,Worker 必须使用 `tiku_worker`。真实 env 的权限推荐为 `root:deploy 0640`。 + +Provider 密钥不要仅凭 env 模板判断“已经配置完成”。PNVS、OAuth、支付和租户级密钥以 `app_private.tenant_secrets` 及对应 Provider 配置为真实来源,必须通过仓库配置/诊断脚本和 production readiness 验证。对象存储及 Worker scanner 等进程级配置才由 API/Worker env 提供。 + +连接池要按进程总量预算:默认 9 个常驻 Worker 若每个 `DB_POOL_MAX=5`,仅 Worker 上限约 45 个连接;再加 API、timer/oneshot、Supabase 内部服务和运维连接。正式启用前应结合 PostgreSQL `max_connections` 和 PgBouncer 配额调整,而不是逐个进程孤立设置。 + +### 5. 升级 Worker 调度 + +不要使用通配复制后遗留旧 unit。显式安装当前清单: + +```bash +sudo systemctl stop tiku-worker.service 2>/dev/null || true +sudo systemctl disable tiku-worker.service 2>/dev/null || true +sudo rm -f /etc/systemd/system/tiku-worker.service + +for unit in \ + tiku-api.service \ + tiku-worker@.service \ + tiku-worker-job@.service \ + tiku-worker-monthly-usage.service \ + tiku-worker-monthly-usage.timer \ + tiku-worker-platform-audit-alerts.timer \ + tiku-worker-platform-billing.timer \ + tiku-worker-platform-dunning.timer \ + tiku-worker-platform-usage.timer \ + tiku-worker-student-supervision.timer \ + tiku-workers.target +do + sudo install -o root -g root -m 0644 \ + "/opt/tiku-saas/source/scripts/deploy/systemd/$unit" \ + "/etc/systemd/system/$unit" +done + +sudo systemctl daemon-reload +sudo systemctl enable tiku-api.service tiku-workers.target +``` + +不要在新的应用 release 尚未构建并同步到 `/opt/tiku-saas/repo` 前启动 `tiku-workers.target`。 + +连续 Worker 为 CRM、commerce、provider bills、催缴通知、审计通知、assets、imports、public banks 和 exports;定时任务负责计费、用量、月结超额、催缴、审计告警和学习督导。 + +### 6. systemd 权限 + +部署器以 `deploy` 用户运行,但需要重启 `tiku-api.service` 和 `tiku-workers.target`。首次升级前检查: + +```bash +sudo -u deploy systemctl is-active tiku-api.service +sudo -u deploy systemctl restart tiku-api.service +``` + +第二条如果要求交互认证,部署会在切换阶段失败。不要给 `deploy` `NOPASSWD: ALL`。选择其一: + +- 用受审 root wrapper 只允许 restart/is-active 这两个顶层 unit,并把 `RESTART_COMMAND` 指向 wrapper。 +- 配置精确的 PolicyKit 规则,只允许 deploy 管理本项目 unit。 +- 由 root 执行受控部署器,同时确保 clone/npm/release 文件所有权仍为 deploy,并重新验证脚本权限模型。 + +完成最小权限方案后,必须在非交互会话中验证。 + +### 7. 数据库、runtime config 与 evidence + +按“新测试服务器”中的数据库步骤完成: + +1. superuser runtime role/extension bootstrap。 +2. 为 `tiku_api/tiku_worker` 设置独立密码。 +3. 标准 migration role 应用 migration。 +4. `readiness:production` 和 `readiness:production:db`。 + +三端 runtime config 路径: + +```text +/etc/tiku-saas/runtime-config/h5-student.runtime-config.json +/etc/tiku-saas/runtime-config/h5-tenant-admin.runtime-config.json +/etc/tiku-saas/runtime-config/h5-platform-admin.runtime-config.json +``` + +`tenantCode` 默认留空,由三个生产 Origin 解析租户;只有明确的固定租户预览/小程序模式才填写。 + +production evidence 必须绑定待发布 commit。兼容脚本默认读取: + +```text +/etc/tiku-saas/production-launch-evidence.json +``` + +相对 artifact 路径从 evidence 所在目录解析,因此完整 bundle 应为: + +```text +/etc/tiku-saas/production-launch-evidence.json +/etc/tiku-saas/launch-artifacts/* +``` + +只复制 evidence JSON 而不复制 artifacts 会 fail closed。staging、本地 mock 或旧 commit 的 evidence 不能复用。 + +### 8. 宝塔/Nginx + +宝塔服务器继续保留它管理的站点文件,不要直接套用普通 `/etc/nginx/sites-available` 命令。人工对照 `scripts/deploy/nginx/tjszsb.com.conf.example` 同步: + +- `/srv/tiku-saas/www/{student,tenant-admin,platform-admin}` 三端路径。 +- SPA history fallback。 +- `runtime-config.json` `no-store`。 +- 带 hash 静态资源长期缓存。 +- API forwarded headers、body limit、超时和必要限流。 +- Supabase Gateway/Studio HTTPS 与 Studio 来源限制。 + +若宝塔站点根目录必须位于 `/www/wwwroot`,用受控软链接指向 `/srv/tiku-saas/www/*`,不要复制第二份静态文件形成漂移。 + +### 9. 正式执行 + +服务器需有 Node.js 20、npm、Git、rsync、curl、Supabase CLI,以及 Chrome/Chromium。浏览器不在标准路径时设置 `TARO_H5_SMOKE_BROWSER`。 + +先确认 source clean、evidence commit 和待发布 SHA 一致,再运行: + +```bash +sudo -u deploy /opt/tiku-saas/bin/deploy.sh +``` + +如果本次数据库已独立迁移完成,保持: + +```text +RUN_DB_MIGRATIONS=false +``` + +部署器在任何激活前完成候选构建和门禁;应用/Web 切换、服务重启、API health 或线上 H5 hash 失败会触发代码/Web回滚。 + +## 发布后验收 + +脚本只检查顶层 target active 和 API `/health`,发布后还必须人工运行: + +```bash +systemctl --failed --no-pager +systemctl is-active tiku-api.service tiku-workers.target +systemctl list-dependencies tiku-workers.target --no-pager +systemctl list-timers 'tiku-worker-*' --all --no-pager +systemctl status 'tiku-worker@*.service' --no-pager +journalctl -u tiku-api.service -n 100 --no-pager +journalctl -u 'tiku-worker@*.service' -n 200 --no-pager +curl -fsS http://127.0.0.1:8787/health +``` + +随后验证: + +- 三个生产域名的 `index.html`、runtime config、登录和主入口。 +- `/api/tenant/resolve` 的 Origin/CORS。 +- 真实 Auth/JWKS、PNVS 短信、支付/退款回调、对象存储签名与扫描。 +- Worker backlog、失败重试、timer 下次执行时间和外部告警渠道。 +- 错误率、P95/P99、数据库连接池、慢 SQL、锁等待、磁盘和日志轮转。 + +## 回滚 + +自动回滚只覆盖应用代码和三端 Web: + +```text +/opt/tiku-saas/current +/opt/tiku-saas/repo +/srv/tiku-saas/www +``` + +数据库 migration、对象存储写入、外部 Provider 状态和已产生业务事件不会自动回滚。数据库恢复必须使用发布前已验证的快照/备份,并由负责人单独决策。 + +手工代码/Web 回滚前,先记录失败 release 和日志,再将软链接切回上一 release、同步运行目录、重启服务并复核 API/H5。不要使用 `git reset --hard` 处理服务器运行目录。 + +## 安全检查清单 + +- [ ] 暴露 token 已吊销,服务器只读凭据已轮换。 +- [ ] 待发布 commit 已冻结,source checkout clean。 +- [ ] PostgreSQL、对象存储和 `/etc/tiku-saas` 已备份并验证可读。 +- [ ] `tiku_api/tiku_worker` bootstrap、独立密码和 migration role 已完成。 +- [ ] 三端 runtime config 只有公开字段,`tenantCode` 策略正确。 +- [ ] 旧 `tiku-worker.service` 已停止、禁用并删除。 +- [ ] 新 Worker units/timers/target 已安装,deploy 重启权限最小化。 +- [ ] Chrome/Chromium 可用于 33 项 H5 交互 smoke。 +- [ ] evidence commit、artifacts、hash 和人工 attestation 完整。 +- [ ] 宝塔/Nginx history fallback、缓存、CORS、CSP 和 forwarded headers 已复核。 +- [ ] 首个平台超管通过 Auth-bound bootstrap 创建并留有审计。 +- [ ] 发布后逐 Worker、timer、Provider、日志和监控验收完成。 ## 参考 -- Supabase self-hosting Docker: https://supabase.com/docs/guides/self-hosting/docker -- Supabase reverse proxy and HTTPS: https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https -- Supabase Auth self-hosting config: https://supabase.com/docs/guides/self-hosting/auth/config -- Supabase self-hosted S3 storage: https://supabase.com/docs/guides/self-hosting/self-hosted-s3 +- [根 README](../../README.md) +- [生产地基基线](../../docs/refactor/production-foundation-baseline-20260712.md) +- [Taro H5 部署](../../docs/refactor/taro-h5-deployment.md) +- [上线 evidence 模板](../../docs/refactor/production-launch-evidence.template.json) +- [Supabase self-hosting](https://supabase.com/docs/guides/self-hosting/docker) +- [Supabase HTTPS reverse proxy](https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https) diff --git a/scripts/deploy/bin/deploy.sh b/scripts/deploy/bin/deploy.sh index 1339dd26..2d576956 100644 --- a/scripts/deploy/bin/deploy.sh +++ b/scripts/deploy/bin/deploy.sh @@ -2,6 +2,7 @@ set -Eeuo pipefail export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}" +export GIT_TERMINAL_PROMPT=0 CONFIG_FILE="${CONFIG_FILE:-/etc/tiku-saas/deploy.env}" @@ -16,35 +17,180 @@ source "$CONFIG_FILE" : "${GIT_REPO:?GIT_REPO is required}" : "${GIT_BRANCH:=main}" : "${APP_ROOT:=/opt/tiku-saas}" +: "${SOURCE_REPO_DIR:=$APP_ROOT/source}" : "${REPO_DIR:=$APP_ROOT/repo}" +: "${RELEASES_DIR:=$APP_ROOT/releases}" +: "${CURRENT_LINK:=$APP_ROOT/current}" : "${WWW_ROOT:=/srv/tiku-saas/www}" +: "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}" : "${RUNTIME_CONFIG_DIR:=/etc/tiku-saas/runtime-config}" +: "${KEEP_RELEASES:=5}" : "${RUN_SECURITY_CHECKS:=true}" +: "${RUN_RUNTIME_AUDIT:=true}" +: "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}" : "${RUN_LAUNCH_GATE:=true}" +: "${RUN_DB_MIGRATIONS:=false}" +: "${RUN_DB_READINESS:=true}" +: "${DATABASE_MIGRATION_URL:=}" +: "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}" +: "${API_ENV_FILE:=/etc/tiku-saas/api.env}" : "${RESTART_SERVICES:=true}" +: "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}" +: "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}" +: "${HEALTHCHECK_TIMEOUT_SECONDS:=60}" +: "${HEALTHCHECK_INTERVAL_SECONDS:=2}" : "${NPM_REGISTRY:=https://registry.npmjs.org/}" +: "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}" : "${NPM_FETCH_RETRIES:=5}" : "${NPM_FETCH_RETRY_MINTIMEOUT:=20000}" : "${NPM_FETCH_RETRY_MAXTIMEOUT:=120000}" : "${NPM_FETCH_TIMEOUT:=300000}" -LOCK_FILE="${LOCK_FILE:-/tmp/tiku-saas-deploy.lock}" -mkdir -p "$APP_ROOT" "$WWW_ROOT/student" "$WWW_ROOT/tenant-admin" "$WWW_ROOT/platform-admin" +export DATABASE_MIGRATION_URL -exec 9>"$LOCK_FILE" -if ! flock -n 9; then - echo "Another deployment is already running." >&2 - exit 1 -fi +LOCK_DIR="${LOCK_DIR:-$APP_ROOT/.deploy.lock}" +LOCK_ACQUIRED=false +ASKPASS_FILE="" +CANDIDATE_RELEASE="" +PREVIOUS_APP_RELEASE="" +PREVIOUS_WWW_RELEASE="" +BOOTSTRAP_SERVICE_BACKUP="" +ROLLBACK_ARMED=false +APP_SWITCHED=false +SERVICE_SYNC_STARTED=false +WWW_SWITCH_STARTED=false log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" } +die() { + printf '[deploy][error] %s\n' "$*" >&2 + exit 1 +} + +truthy() { + case "${1:-}" in + 1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +run_shell() { + log "+ $*" + bash -lc "$*" +} + +load_runtime_env() { + [[ -r "$API_ENV_FILE" ]] || die "Missing API runtime config: $API_ENV_FILE" + set -a + # shellcheck disable=SC1090 + source "$API_ENV_FILE" + set +a +} + +atomic_symlink() { + local target="$1" + local link="$2" + local next_link="${link}.next.$$" + + rm -f "$next_link" + ln -s "$target" "$next_link" + if [[ -L "$link" || ! -e "$link" ]]; then + mv -Tf "$next_link" "$link" + return 0 + fi + rm -f "$next_link" + return 1 +} + +restart_services() { + truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true" + [[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required" + + local unit + for unit in $SYSTEMD_UNITS; do + systemctl restart "$unit" || return 1 + done + for unit in $SYSTEMD_UNITS; do + systemctl is-active --quiet "$unit" || return 1 + done +} + +healthcheck() { + [[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL" + local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS)) + + log "Waiting for API healthcheck: $HEALTHCHECK_URL" + while (( SECONDS < deadline )); do + if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then + log "API healthcheck passed." + return 0 + fi + sleep "$HEALTHCHECK_INTERVAL_SECONDS" + done + return 1 +} + +sync_service_repo() { + local release="$1" + [[ -d "$release" ]] || return 1 + if [[ -L "$REPO_DIR" ]]; then + [[ "$(readlink -f "$REPO_DIR")" == "$(readlink -f "$release")" ]] \ + || die "REPO_DIR symlink must resolve to the selected current release" + return 0 + fi + mkdir -p "$REPO_DIR" + rsync -a --delete --exclude .git "$release/" "$REPO_DIR/" +} + +rollback() { + local failed=false + ROLLBACK_ARMED=false + + if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then + log "Rolling Web root back to $PREVIOUS_WWW_RELEASE" + atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_ROOT" || failed=true + elif [[ "$WWW_SWITCH_STARTED" == "true" ]]; then + rm -f "$WWW_ROOT" || failed=true + fi + + if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then + log "Rolling application current link back to $PREVIOUS_APP_RELEASE" + atomic_symlink "$PREVIOUS_APP_RELEASE" "$CURRENT_LINK" || failed=true + elif [[ "$APP_SWITCHED" == "true" ]]; then + rm -f "$CURRENT_LINK" || failed=true + fi + + if [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then + sync_service_repo "$PREVIOUS_APP_RELEASE" || failed=true + elif [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$BOOTSTRAP_SERVICE_BACKUP" ]]; then + log "Restoring pre-release service runtime" + sync_service_repo "$BOOTSTRAP_SERVICE_BACKUP" || failed=true + fi + + if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then + restart_services || failed=true + healthcheck || failed=true + fi + [[ "$failed" == "false" ]] +} + cleanup() { - if [[ -n "${ASKPASS_FILE:-}" && -f "$ASKPASS_FILE" ]]; then + local status=$? + if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then + rollback || true + fi + if [[ -n "$ASKPASS_FILE" && -f "$ASKPASS_FILE" ]]; then rm -f "$ASKPASS_FILE" fi + if [[ "$LOCK_ACQUIRED" == "true" && -d "$LOCK_DIR" ]]; then + rmdir "$LOCK_DIR" 2>/dev/null || true + fi } trap cleanup EXIT @@ -53,14 +199,11 @@ prepare_git_auth() { export GIT_SSH_COMMAND return fi - if [[ -z "${GIT_USERNAME:-}" || -z "${GITEA_TOKEN:-}" ]]; then return fi - export GIT_USERNAME - export GITEA_TOKEN - + export GIT_USERNAME GITEA_TOKEN ASKPASS_FILE="$(mktemp)" chmod 700 "$ASKPASS_FILE" cat > "$ASKPASS_FILE" <<'ASKPASS' @@ -75,81 +218,225 @@ ASKPASS export GIT_TERMINAL_PROMPT=0 } -prepare_git_auth - -if [[ ! -d "$REPO_DIR/.git" ]]; then - log "Cloning repository..." - git clone --branch "$GIT_BRANCH" "$GIT_REPO" "$REPO_DIR" -fi - -cd "$REPO_DIR" - -log "Fetching $GIT_BRANCH..." -git fetch origin "$GIT_BRANCH" --prune -git checkout "$GIT_BRANCH" - -if ! git diff --quiet || ! git diff --cached --quiet; then - echo "Repository has local changes. Refusing to deploy until the server checkout is clean." >&2 - exit 1 -fi - -git merge --ff-only "origin/$GIT_BRANCH" - -CURRENT_SHA="$(git rev-parse --short=12 HEAD)" -log "Deploying commit $CURRENT_SHA" - -log "Installing dependencies with npm ci..." -npm ci \ - --registry="$NPM_REGISTRY" \ - --fetch-retries="$NPM_FETCH_RETRIES" \ - --fetch-retry-mintimeout="$NPM_FETCH_RETRY_MINTIMEOUT" \ - --fetch-retry-maxtimeout="$NPM_FETCH_RETRY_MAXTIMEOUT" \ - --fetch-timeout="$NPM_FETCH_TIMEOUT" - -if [[ "$RUN_SECURITY_CHECKS" == "true" ]]; then - log "Running repository security scan..." - npm run security:repo -fi - -if [[ "$RUN_LAUNCH_GATE" == "true" ]]; then - log "Running production launch gate test..." - node scripts/production-launch-gate-test.js -fi - -log "Building API and worker..." -npm run build:api -npm run build:worker - -log "Building H5 portals..." -npm run build:taro:h5:student -npm run build:taro:h5:tenant -npm run build:taro:h5:platform - -log "Publishing H5 static assets..." -rsync -a --delete apps/taro/dist/h5-student/ "$WWW_ROOT/student/" -rsync -a --delete apps/taro/dist/h5-tenant-admin/ "$WWW_ROOT/tenant-admin/" -rsync -a --delete apps/taro/dist/h5-platform-admin/ "$WWW_ROOT/platform-admin/" - install_runtime_config() { local source_file="$1" local target_dir="$2" - if [[ ! -r "$source_file" ]]; then - echo "Missing runtime config: $source_file" >&2 - exit 1 - fi + [[ -r "$source_file" ]] || die "Missing runtime config: $source_file" install -m 0644 "$source_file" "$target_dir/runtime-config.json" } -log "Installing H5 runtime config files..." -install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json" "$WWW_ROOT/student" -install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json" "$WWW_ROOT/tenant-admin" -install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json" "$WWW_ROOT/platform-admin" +stage_candidate() { + local release_name="$1" + local staging="$RELEASES_DIR/.tmp-$release_name" -if [[ "$RESTART_SERVICES" == "true" ]]; then - log "Restarting systemd services..." - systemctl restart tiku-api.service - systemctl restart tiku-worker.service - systemctl --no-pager --full status tiku-api.service tiku-worker.service >/dev/null -fi + rm -rf "$staging" + mkdir -p "$staging" + rsync -a --delete --exclude .git --exclude node_modules "$SOURCE_REPO_DIR/" "$staging/" + if ! cp -al "$SOURCE_REPO_DIR/node_modules" "$staging/node_modules"; then + rm -rf "$staging/node_modules" + rsync -a "$SOURCE_REPO_DIR/node_modules/" "$staging/node_modules/" + fi + CANDIDATE_RELEASE="$RELEASES_DIR/$release_name" + rm -rf "$CANDIDATE_RELEASE" + mv "$staging" "$CANDIDATE_RELEASE" +} -log "Deployment finished: $CURRENT_SHA" +build_and_validate_candidate() { + cd "$CANDIDATE_RELEASE" + + if truthy "$RUN_SECURITY_CHECKS"; then + log "Running repository security scan against the candidate..." + GIT_DIR="$SOURCE_REPO_DIR/.git" GIT_WORK_TREE="$CANDIDATE_RELEASE" npm run security:repo + fi + + log "Building API, worker and H5 portals..." + npm run build:api + npm run build:worker + npm run check:taro + if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then + npm run audit:taro:supply-chain + fi + npm run build:taro:h5:student + npm run build:taro:h5:tenant + npm run build:taro:h5:platform + + log "Installing candidate H5 runtime configs..." + install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json" "apps/taro/dist/h5-student" + install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json" "apps/taro/dist/h5-tenant-admin" + install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json" "apps/taro/dist/h5-platform-admin" + + log "Validating candidate H5 artifacts before touching $WWW_ROOT..." + node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config + npm run manifest:taro:h5 -- --require-dist --require-runtime-config + npm run smoke:taro:h5 + TARO_H5_INTERACTION_OUTPUT_DIR="${TARO_H5_INTERACTION_OUTPUT_DIR:-/tmp/tiku-h5-smoke}" npm run smoke:taro:h5:interaction + + if truthy "$RUN_RUNTIME_AUDIT"; then + NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime + fi + + load_runtime_env + log "Running production environment readiness before the database migration step..." + npm run readiness:production + + if truthy "$RUN_DB_MIGRATIONS"; then + log "Applying Supabase database migrations..." + run_shell "$DB_MIGRATION_COMMAND" + fi + + if truthy "$RUN_DB_READINESS"; then + log "Running production database readiness after the optional migration step..." + npm run readiness:production:db + fi + + if truthy "$RUN_LAUNCH_GATE"; then + : "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}" + log "Running production launch gate against the candidate..." + DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \ + DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \ + npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" + fi +} + +verify_live_h5_release() { + truthy "$RUN_LAUNCH_GATE" || return 0 + : "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}" + + log "Verifying the activated H5 release against production URLs..." + ( + cd "$CANDIDATE_RELEASE" + DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \ + DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \ + npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5 + ) +} + +stage_www_candidate() { + local release_name="$1" + local staging="$WWW_RELEASES_DIR/.tmp-$release_name" + local final="$WWW_RELEASES_DIR/$release_name" + + rm -rf "$staging" + mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin" + rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-student/" "$staging/student/" + rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/" + rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/" + rm -rf "$final" + mv "$staging" "$final" + printf '%s\n' "$final" +} + +switch_www_release() { + local candidate_www="$1" + WWW_SWITCH_STARTED=true + + if [[ -L "$WWW_ROOT" ]]; then + PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_ROOT")" + elif [[ -d "$WWW_ROOT" ]]; then + PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-www-$(date +%Y%m%d%H%M%S)" + mv "$WWW_ROOT" "$PREVIOUS_WWW_RELEASE" + elif [[ -e "$WWW_ROOT" ]]; then + die "WWW_ROOT exists but is not a directory or symlink: $WWW_ROOT" + fi + atomic_symlink "$candidate_www" "$WWW_ROOT" +} + +prune_releases() { + local directory="$1" + [[ "$KEEP_RELEASES" =~ ^[0-9]+$ ]] || return 0 + (( KEEP_RELEASES > 0 )) || return 0 + + find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \ + | sort -r \ + | tail -n +"$((KEEP_RELEASES + 1))" \ + | while IFS= read -r old_release; do + [[ "$old_release" == "$CANDIDATE_RELEASE" || "$old_release" == "$PREVIOUS_APP_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]] && continue + rm -rf "$old_release" + done +} + +main() { + require_command git + require_command npm + require_command rsync + require_command curl + [[ "$WWW_RELEASES_DIR" != "$WWW_ROOT" ]] || die "WWW_RELEASES_DIR must be outside WWW_ROOT" + case "${WWW_RELEASES_DIR%/}/" in + "${WWW_ROOT%/}/"*) die "WWW_RELEASES_DIR must not be nested under WWW_ROOT" ;; + esac + [[ "$SOURCE_REPO_DIR" != "$REPO_DIR" ]] || die "SOURCE_REPO_DIR must be separate from the systemd REPO_DIR" + truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true" + [[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL" + truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=true" + truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \ + || die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true" + truthy "$RUN_DB_READINESS" \ + || die "Production deployment requires RUN_DB_READINESS=true before launch gate" + if truthy "$RUN_DB_MIGRATIONS"; then + [[ -n "$DATABASE_MIGRATION_URL" ]] \ + || die "Production database migrations require a separate DATABASE_MIGRATION_URL" + fi + + mkdir -p "$APP_ROOT" "$RELEASES_DIR" "$WWW_RELEASES_DIR" + mkdir "$LOCK_DIR" 2>/dev/null || die "Another deployment is already running: $LOCK_DIR" + LOCK_ACQUIRED=true + prepare_git_auth + + if [[ ! -d "$SOURCE_REPO_DIR/.git" ]]; then + log "Cloning source repository..." + git clone --branch "$GIT_BRANCH" "$GIT_REPO" "$SOURCE_REPO_DIR" + fi + + log "Fetching $GIT_BRANCH..." + if [[ -n "$(git -C "$SOURCE_REPO_DIR" status --porcelain --untracked-files=normal)" ]]; then + die "Source repository has local changes; refusing to deploy." + fi + git -C "$SOURCE_REPO_DIR" fetch origin "$GIT_BRANCH" --prune + git -C "$SOURCE_REPO_DIR" checkout "$GIT_BRANCH" + git -C "$SOURCE_REPO_DIR" merge --ff-only "origin/$GIT_BRANCH" + + local commit release_name + commit="$(git -C "$SOURCE_REPO_DIR" rev-parse --short=12 HEAD)" + release_name="$(date +%Y%m%d%H%M%S)-$commit" + log "Preparing candidate commit $commit" + + log "Installing locked dependencies in the source checkout..." + npm --prefix "$SOURCE_REPO_DIR" ci \ + --workspaces \ + --include-workspace-root \ + --include=dev \ + --registry="$NPM_REGISTRY" \ + --fetch-retries="$NPM_FETCH_RETRIES" \ + --fetch-retry-mintimeout="$NPM_FETCH_RETRY_MINTIMEOUT" \ + --fetch-retry-maxtimeout="$NPM_FETCH_RETRY_MAXTIMEOUT" \ + --fetch-timeout="$NPM_FETCH_TIMEOUT" + + stage_candidate "$release_name" + build_and_validate_candidate + local candidate_www + candidate_www="$(stage_www_candidate "$release_name")" + + if [[ -L "$CURRENT_LINK" ]]; then + PREVIOUS_APP_RELEASE="$(readlink -f "$CURRENT_LINK")" + elif [[ -d "$REPO_DIR" ]]; then + BOOTSTRAP_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)" + mkdir -p "$BOOTSTRAP_SERVICE_BACKUP" + rsync -a --delete --exclude .git "$REPO_DIR/" "$BOOTSTRAP_SERVICE_BACKUP/" + fi + ROLLBACK_ARMED=true + atomic_symlink "$CANDIDATE_RELEASE" "$CURRENT_LINK" || die "Failed to switch application current release" + APP_SWITCHED=true + SERVICE_SYNC_STARTED=true + sync_service_repo "$CANDIDATE_RELEASE" + restart_services + healthcheck + switch_www_release "$candidate_www" || die "Failed to switch Web release" + verify_live_h5_release + ROLLBACK_ARMED=false + + prune_releases "$RELEASES_DIR" + prune_releases "$WWW_RELEASES_DIR" + log "Deployment finished: $commit" +} + +main "$@" diff --git a/scripts/deploy/env/api.env.example b/scripts/deploy/env/api.env.example index 47916051..f6e5594c 100644 --- a/scripts/deploy/env/api.env.example +++ b/scripts/deploy/env/api.env.example @@ -1,12 +1,32 @@ -# Copy to /etc/tiku-saas/api.env and chmod 600. +# Copy to /etc/tiku-saas/api.env, chown root:deploy, and chmod 640. # This file is read by systemd. Do not commit the real file. NODE_ENV=production PORT=8787 +API_HEADERS_TIMEOUT_MS=15000 +API_REQUEST_TIMEOUT_MS=120000 +API_KEEP_ALIVE_TIMEOUT_MS=5000 +API_SHUTDOWN_GRACE_PERIOD_MS=30000 +API_MAX_REQUESTS_PER_SOCKET=1000 -DATABASE_URL=postgresql://tiku_app:replace-with-password@127.0.0.1:5432/postgres +DATABASE_URL=postgresql://tiku_api:replace-with-password@127.0.0.1:5432/postgres +DB_EXPECTED_RUNTIME_ROLE=tiku_api +DB_POOL_MAX=10 +DB_CONNECTION_TIMEOUT_MS=5000 +DB_QUERY_TIMEOUT_MS=35000 +DB_STATEMENT_TIMEOUT_MS=30000 +DB_LOCK_TIMEOUT_MS=5000 +DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=30000 +DB_IDLE_TIMEOUT_MS=30000 +DB_POOL_MAX_USES=7500 +DB_POOL_MAX_LIFETIME_SECONDS=1800 +DB_APPLICATION_NAME=tiku-api DEFAULT_TENANT_SLUG=master CORS_ORIGIN=https://app.tjszsb.com,https://admin.tjszsb.com,https://console.tjszsb.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 ALLOW_LEGACY_AUTH_HEADERS=false ALLOW_PLATFORM_ADMIN_KEY=false @@ -21,6 +41,11 @@ PLATFORM_ADMIN_API_KEY=replace-with-strong-random-platform-admin-key # Production SMS verification uses aliyun-pnvs. Traditional aliyun/tencent adapters are compatibility paths only. # Tenant-level SMS AccessKey/SecretKey live in app_private.tenant_secrets, not in this env file. AUTH_SMS_PROVIDER=aliyun-pnvs +AUTH_SMS_COOLDOWN_SECONDS=60 +AUTH_SMS_TENANT_DAILY_LIMIT=20000 +AUTH_SMS_PHONE_DAILY_LIMIT=10 +AUTH_SMS_IP_HOURLY_LIMIT=120 +AUTH_SMS_DEVICE_HOURLY_LIMIT=10 WECHAT_MINIAPP_APP_ID=replace-with-miniapp-app-id WECHAT_MINIAPP_APP_SECRET=replace-with-miniapp-app-secret @@ -43,14 +68,16 @@ ALIPAY_PUBLIC_KEY=replace-with-alipay-public-key ALIPAY_NOTIFY_URL=https://api.tjszsb.com/api/commerce/webhooks/alipay STORAGE_DEFAULT_PROVIDER=aliyun_oss +STORAGE_DEFAULT_BUCKET=replace-with-bucket +STORAGE_REQUIRE_TENANT_PREFIX=true ALIYUN_OSS_REGION=oss-cn-beijing ALIYUN_OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com -ALIYUN_OSS_BUCKET=replace-with-bucket ALIYUN_OSS_ACCESS_KEY_ID=replace-with-access-key-id ALIYUN_OSS_ACCESS_KEY_SECRET=replace-with-access-key-secret ASSET_SIGNING_SECRET=replace-with-strong-random-asset-secret WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false -ASSET_SECURITY_SCAN_ENDPOINT=https://replace-with-security-scanner.example.com/scan -ASSET_SECURITY_SCAN_TOKEN=replace-with-scanner-token +WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://replace-with-security-scanner.example.com/scan +WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=replace-with-strong-scanner-token +WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000 diff --git a/scripts/deploy/env/deploy.env.example b/scripts/deploy/env/deploy.env.example index c876d95a..e32f739a 100644 --- a/scripts/deploy/env/deploy.env.example +++ b/scripts/deploy/env/deploy.env.example @@ -9,16 +9,42 @@ GIT_USERNAME=replace-with-readonly-deploy-user GITEA_TOKEN=replace-with-rotated-readonly-token APP_ROOT=/opt/tiku-saas +# The source checkout is isolated from the systemd runtime directory. This keeps +# candidate builds and npm ci from mutating the currently running release. +SOURCE_REPO_DIR=/opt/tiku-saas/source REPO_DIR=/opt/tiku-saas/repo +RELEASES_DIR=/opt/tiku-saas/releases WWW_ROOT=/srv/tiku-saas/www +WWW_RELEASES_DIR=/srv/tiku-saas/www-releases RUNTIME_CONFIG_DIR=/etc/tiku-saas/runtime-config RUN_SECURITY_CHECKS=true +RUN_RUNTIME_AUDIT=true +RUN_TARO_SUPPLY_CHAIN_AUDIT=true RUN_LAUNCH_GATE=true -RESTART_SERVICES=true +PRODUCTION_LAUNCH_EVIDENCE=/etc/tiku-saas/production-launch-evidence.json -# Use https://registry.npmmirror.com on mainland China servers if npmjs times out. +# Database migrations are opt-in and run only after the environment/build gates. +# The deploy script always reruns DB readiness after this step and before launch:gate. +RUN_DB_MIGRATIONS=false +RUN_DB_READINESS=true +# Do not assign this in the file; inject DATABASE_MIGRATION_URL for the standard +# migration role from a secret manager immediately before deployment. +DB_MIGRATION_COMMAND='supabase db push --db-url "$DATABASE_MIGRATION_URL"' +API_ENV_FILE=/etc/tiku-saas/api.env +RESTART_SERVICES=true +SYSTEMD_UNITS="tiku-api.service tiku-workers.target" +HEALTHCHECK_URL=http://127.0.0.1:8787/health +HEALTHCHECK_TIMEOUT_SECONDS=60 +HEALTHCHECK_INTERVAL_SECONDS=2 +KEEP_RELEASES=5 + +# Package downloads may use a mirror when npmjs times out. +# The deploy script always installs workspace dev dependencies and runs lifecycle +# scripts because the locked Taro toolchain and reviewed Input patch require both. NPM_REGISTRY=https://registry.npmjs.org/ +# npm audit must use a registry with the audit API; npmmirror does not provide it. +NPM_AUDIT_REGISTRY=https://registry.npmjs.org/ NPM_FETCH_RETRIES=5 NPM_FETCH_RETRY_MINTIMEOUT=20000 NPM_FETCH_RETRY_MAXTIMEOUT=120000 diff --git a/scripts/deploy/env/worker.env.example b/scripts/deploy/env/worker.env.example index 3092b5d0..f934bb96 100644 --- a/scripts/deploy/env/worker.env.example +++ b/scripts/deploy/env/worker.env.example @@ -1,32 +1,124 @@ # Copy to /etc/tiku-saas/worker.env and chmod 600. -# This file is read by systemd. Do not commit the real file. +# This file is read by every worker systemd unit. Do not commit the real file. NODE_ENV=production -DATABASE_URL=postgresql://tiku_app:replace-with-password@127.0.0.1:5432/postgres - -SUPABASE_URL=https://supabase.tjszsb.com +DATABASE_URL=postgresql://tiku_worker:replace-with-password@127.0.0.1:5432/postgres +DB_EXPECTED_RUNTIME_ROLE=tiku_worker +# There are nine continuous worker processes; budget total PostgreSQL connections accordingly. +DB_POOL_MAX=5 +DB_CONNECTION_TIMEOUT_MS=5000 +DB_QUERY_TIMEOUT_MS=35000 +DB_STATEMENT_TIMEOUT_MS=30000 +DB_LOCK_TIMEOUT_MS=5000 +DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=30000 +DB_IDLE_TIMEOUT_MS=30000 +DB_POOL_MAX_USES=7500 +DB_POOL_MAX_LIFETIME_SECONDS=1800 +DB_APPLICATION_NAME=tiku-worker +# Object storage settings read by apps/worker/src/config.ts. STORAGE_DEFAULT_PROVIDER=aliyun_oss +STORAGE_DEFAULT_BUCKET=replace-with-bucket +STORAGE_PUBLIC_BASE_URL=https://replace-with-public-assets.example.com +STORAGE_MAX_UPLOAD_BYTES=524288000 +STORAGE_ALLOWED_MIME_PREFIXES=image/,video/,audio/ +STORAGE_ALLOWED_MIME_TYPES=application/pdf,application/json,application/zip,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/plain,text/markdown,text/csv +STORAGE_REQUIRE_TENANT_PREFIX=true + ALIYUN_OSS_REGION=oss-cn-beijing ALIYUN_OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com -ALIYUN_OSS_BUCKET=replace-with-bucket ALIYUN_OSS_ACCESS_KEY_ID=replace-with-access-key-id ALIYUN_OSS_ACCESS_KEY_SECRET=replace-with-access-key-secret -ASSET_SIGNING_SECRET=replace-with-strong-random-asset-secret +ALIYUN_OSS_STS_TOKEN= +ALIYUN_OSS_INTERNAL=false +# Production asset scans are fail-closed and use the WORKER_* names below. WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http +WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://replace-with-security-scanner.example.com/scan +WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=replace-with-strong-scanner-token +WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000 WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false -ASSET_SECURITY_SCAN_ENDPOINT=https://replace-with-security-scanner.example.com/scan -ASSET_SECURITY_SCAN_TOKEN=replace-with-scanner-token -WECHAT_PAY_MCH_ID=replace-with-merchant-id -WECHAT_PAY_APP_ID=replace-with-pay-app-id -WECHAT_PAY_API_V3_KEY=replace-with-api-v3-key -WECHAT_PAY_PRIVATE_KEY=replace-with-private-key-path-or-kms-id +# Continuous queue consumers managed by tiku-worker@.service. +WORKER_CRM_BATCH_SIZE=20 +WORKER_CRM_POLL_INTERVAL_MS=10000 +WORKER_CRM_MAX_ATTEMPTS=5 +WORKER_CRM_BACKOFF_SECONDS=5,30,120,600,1800 +WORKER_CRM_REQUEST_TIMEOUT_MS=10000 +WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false -ALIPAY_APP_ID=replace-with-alipay-app-id -ALIPAY_APP_PRIVATE_KEY=replace-with-private-key-path-or-kms-id -ALIPAY_PUBLIC_KEY=replace-with-alipay-public-key +WORKER_COMMERCE_BATCH_SIZE=20 +WORKER_COMMERCE_POLL_INTERVAL_MS=30000 +WORKER_COMMERCE_MIN_AGE_SECONDS=300 +WORKER_COMMERCE_REQUEST_TIMEOUT_MS=10000 -CRM_WEBHOOK_TIMEOUT_MS=5000 -WORKER_POLL_INTERVAL_MS=5000 +WORKER_PROVIDER_BILL_BATCH_SIZE=5 +WORKER_PROVIDER_BILL_POLL_INTERVAL_MS=60000 +WORKER_PROVIDER_BILL_ID=provider-bills-prod-1 +WORKER_PROVIDER_BILL_CLAIM_STALE_SECONDS=900 + +WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE=50 +WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS=30000 +WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS=5 +WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800 +WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS=10000 +WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false + +WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE=50 +WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS=30000 +WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS=5 +WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800 +WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS=10000 +WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false + +WORKER_ASSET_BATCH_SIZE=50 +WORKER_ASSET_POLL_INTERVAL_MS=30000 +WORKER_ASSET_MIN_AGE_SECONDS=300 +WORKER_ASSET_RECHECK_INTERVAL_SECONDS=86400 +WORKER_ASSET_REQUEST_TIMEOUT_MS=10000 + +WORKER_IMPORT_BATCH_SIZE=5 +WORKER_IMPORT_POLL_INTERVAL_MS=10000 +WORKER_IMPORT_ID=imports-prod-1 +WORKER_IMPORT_LEASE_SECONDS=120 +WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000 +WORKER_IMPORT_BACKOFF_SECONDS=30,120,600,1800 + +WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE=5 +WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS=60000 +WORKER_PUBLIC_BANK_SYNC_COPY_LIMIT=1000 +WORKER_PUBLIC_BANK_SYNC_ID=public-banks-prod-1 +WORKER_PUBLIC_BANK_SYNC_CLAIM_STALE_SECONDS=900 + +WORKER_EXPORT_BATCH_SIZE=5 +WORKER_EXPORT_POLL_INTERVAL_MS=10000 +WORKER_EXPORT_ID=exports-prod-1 +WORKER_EXPORT_BACKOFF_SECONDS=30,120,600,1800 +EXPORT_LOCAL_STORAGE_ROOT=/srv/tiku-saas/data/exports +EXPORT_PDF_FONT_PATH= + +# Periodic jobs managed by tiku-worker-job@.service and timer units. +WORKER_PLATFORM_BILLING_BATCH_SIZE=50 +WORKER_PLATFORM_BILLING_DAYS_AHEAD=45 +WORKER_PLATFORM_BILLING_DUE_DAYS=15 +WORKER_PLATFORM_BILLING_ID=platform-billing-prod-1 + +# Leave month overrides empty for normal timers. Use CLI --month for backfills. +WORKER_PLATFORM_USAGE_BATCH_SIZE=100 +WORKER_PLATFORM_USAGE_ID=platform-usage-prod-1 +WORKER_PLATFORM_USAGE_MONTH= +WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE=100 +WORKER_PLATFORM_USAGE_OVERAGE_ID=platform-usage-overage-prod-1 +WORKER_PLATFORM_USAGE_OVERAGE_MONTH= +WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS=15 + +WORKER_PLATFORM_DUNNING_BATCH_SIZE=100 +WORKER_PLATFORM_DUNNING_ID=platform-dunning-prod-1 + +WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE=200 +WORKER_PLATFORM_AUDIT_ALERT_ID=platform-audit-alerts-prod-1 +WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS=14 + +WORKER_STUDENT_SUPERVISION_BATCH_SIZE=20 +WORKER_STUDENT_SUPERVISION_ID=student-supervision-prod-1 +WORKER_STUDENT_SUPERVISION_CLAIM_STALE_SECONDS=900 diff --git a/scripts/deploy/nginx/tjszsb.com.conf.example b/scripts/deploy/nginx/tjszsb.com.conf.example index 21d47d9f..3cffad0a 100644 --- a/scripts/deploy/nginx/tjszsb.com.conf.example +++ b/scripts/deploy/nginx/tjszsb.com.conf.example @@ -6,6 +6,9 @@ map $http_upgrade $connection_upgrade { '' close; } +limit_req_zone $binary_remote_addr zone=tiku_auth:10m rate=50r/s; +limit_req_zone $binary_remote_addr zone=tiku_sms_send:10m rate=10r/s; + server { listen 80; server_name app.tjszsb.com; @@ -90,12 +93,45 @@ server { client_max_body_size 50m; + location = /api/auth/sms/send { + limit_req zone=tiku_sms_send burst=30 nodelay; + limit_req_status 429; + + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host ""; + proxy_set_header X-Real-IP $remote_addr; + # Overwrite, rather than append to, any client-supplied forwarding chain. + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + } + + location ^~ /api/auth/ { + limit_req zone=tiku_auth burst=100 nodelay; + limit_req_status 429; + + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + } + location / { proxy_pass http://127.0.0.1:8787; proxy_http_version 1.1; proxy_set_header Host $host; + # H5 tenant resolution trusts the browser Origin header. Do not accept a client-supplied forwarded host. + proxy_set_header X-Forwarded-Host ""; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; diff --git a/scripts/deploy/runtime-config/h5-platform-admin.runtime-config.example.json b/scripts/deploy/runtime-config/h5-platform-admin.runtime-config.example.json index 3467a8bf..6bc4f1c6 100644 --- a/scripts/deploy/runtime-config/h5-platform-admin.runtime-config.example.json +++ b/scripts/deploy/runtime-config/h5-platform-admin.runtime-config.example.json @@ -3,5 +3,5 @@ "apiBaseUrl": "https://api.tjszsb.com", "supabaseUrl": "https://supabase.tjszsb.com", "supabasePublishableKey": "replace-with-supabase-publishable-key", - "tenantCode": "master" + "tenantCode": "" } diff --git a/scripts/deploy/runtime-config/h5-student.runtime-config.example.json b/scripts/deploy/runtime-config/h5-student.runtime-config.example.json index 91d66dc5..a92dba40 100644 --- a/scripts/deploy/runtime-config/h5-student.runtime-config.example.json +++ b/scripts/deploy/runtime-config/h5-student.runtime-config.example.json @@ -3,5 +3,5 @@ "apiBaseUrl": "https://api.tjszsb.com", "supabaseUrl": "https://supabase.tjszsb.com", "supabasePublishableKey": "replace-with-supabase-publishable-key", - "tenantCode": "master" + "tenantCode": "" } diff --git a/scripts/deploy/runtime-config/h5-tenant-admin.runtime-config.example.json b/scripts/deploy/runtime-config/h5-tenant-admin.runtime-config.example.json index b33210ba..d33fd831 100644 --- a/scripts/deploy/runtime-config/h5-tenant-admin.runtime-config.example.json +++ b/scripts/deploy/runtime-config/h5-tenant-admin.runtime-config.example.json @@ -3,5 +3,5 @@ "apiBaseUrl": "https://api.tjszsb.com", "supabaseUrl": "https://supabase.tjszsb.com", "supabasePublishableKey": "replace-with-supabase-publishable-key", - "tenantCode": "master" + "tenantCode": "" } diff --git a/scripts/deploy/sql/bootstrap-backend-runtime-roles.sql b/scripts/deploy/sql/bootstrap-backend-runtime-roles.sql new file mode 100644 index 00000000..e6aff237 --- /dev/null +++ b/scripts/deploy/sql/bootstrap-backend-runtime-roles.sql @@ -0,0 +1,188 @@ +do $$ +begin + if not exists (select 1 from pg_roles where rolname = 'tiku_api') then + create role tiku_api login; + end if; + if not exists (select 1 from pg_roles where rolname = 'tiku_worker') then + create role tiku_worker login; + end if; +end +$$; + +alter role tiku_api + login nosuperuser noinherit nocreatedb nocreaterole noreplication bypassrls; + +alter role tiku_worker + login nosuperuser noinherit nocreatedb nocreaterole noreplication bypassrls; + +create schema if not exists extensions; +revoke create on schema extensions from public, tiku_api, tiku_worker; +grant usage on schema extensions to tiku_api, tiku_worker; + +-- Install or relocate required extensions before normal migrations. Otherwise +-- citext/ltree can be created later by supabase_admin with PostgreSQL's default +-- PUBLIC EXECUTE and the migration role cannot close that RPC surface. +do $$ +declare + extension_name name; + extension_schema name; + extension_relocatable boolean; +begin + foreach extension_name in array array[ + 'pgcrypto'::name, + 'citext'::name, + 'ltree'::name, + 'pg_trgm'::name + ] + loop + select namespace.nspname, extension.extrelocatable + into extension_schema, extension_relocatable + from pg_extension extension + join pg_namespace namespace on namespace.oid = extension.extnamespace + where extension.extname = extension_name; + + if not found then + execute format('create extension %I with schema extensions', extension_name); + elsif extension_schema <> 'extensions' then + if not extension_relocatable then + raise exception 'Required extension % cannot be relocated from schema %', extension_name, extension_schema; + end if; + execute format('alter extension %I set schema extensions', extension_name); + end if; + end loop; +end +$$; + +alter role tiku_api set search_path = pg_catalog, public, extensions; +alter role tiku_worker set search_path = pg_catalog, public, extensions; + +-- Official Supabase images can preinstall relocatable extensions such as +-- citext and ltree in public. Their functions are owned by supabase_admin, so +-- the normal migration role cannot remove PostgreSQL's default PUBLIC EXECUTE. +-- Seal that inherited RPC surface while a real superuser is available. +do $$ +declare + required_role name; +begin + foreach required_role in array array['anon'::name, 'authenticated'::name] + loop + if not exists (select 1 from pg_roles where rolname = required_role) then + raise exception 'Required Supabase Data API role % is missing', required_role; + end if; + end loop; +end +$$; + +revoke execute on all functions in schema public + from public, anon, authenticated, tiku_api, tiku_worker; +revoke execute on all functions in schema extensions + from public, anon, authenticated, tiku_api, tiku_worker; + +-- Supabase's internal services use dedicated trusted database roles. Preserve +-- their extension execution when those roles exist, without reopening the +-- surface to the client-facing anon/authenticated roles. +do $$ +declare + trusted_role name; +begin + foreach trusted_role in array array[ + 'postgres'::name, + 'service_role'::name, + 'dashboard_user'::name, + 'supabase_auth_admin'::name, + 'supabase_storage_admin'::name, + 'supabase_realtime_admin'::name, + 'supabase_functions_admin'::name + ] + loop + if exists (select 1 from pg_roles where rolname = trusted_role) then + execute format('grant execute on all functions in schema extensions to %I', trusted_role); + end if; + end loop; +end +$$; + +-- citext and ltree operators, casts and tree helpers call extension-owned +-- functions. pg_trgm index support is similarly needed by backend search. +-- Backend roles receive only those pure type/search helpers; pgcrypto stays +-- unavailable and Data API client roles receive no extension execution. +do $$ +declare + extension_function record; +begin + for extension_function in + select procedure_row.oid::regprocedure::text as signature + from pg_proc procedure_row + join pg_depend dependency + on dependency.classid = 'pg_proc'::regclass + and dependency.objid = procedure_row.oid + and dependency.refclassid = 'pg_extension'::regclass + and dependency.deptype = 'e' + join pg_extension extension on extension.oid = dependency.refobjid + where extension.extname in ('citext', 'ltree', 'pg_trgm') + loop + execute format( + 'revoke execute on function %s from public, anon, authenticated, tiku_api, tiku_worker', + extension_function.signature + ); + execute format( + 'grant execute on function %s to tiku_api, tiku_worker', + extension_function.signature + ); + end loop; +end +$$; + +-- Extension upgrades may add more public functions under the extension owner. +-- PostgreSQL's function default ACL is global, so this must not use IN SCHEMA. +do $$ +declare + owner_name name; +begin + for owner_name in + select bootstrap_owner.owner_name + from ( + select current_user::name as owner_name + union + select distinct owner_role.rolname + from pg_proc function_row + join pg_namespace namespace on namespace.oid = function_row.pronamespace + join pg_roles owner_role on owner_role.oid = function_row.proowner + where namespace.nspname in ('public', 'extensions') + ) bootstrap_owner + loop + execute format( + 'alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker', + owner_name + ); + end loop; +end +$$; + +-- Remove all inherited or SET ROLE paths. Runtime roles receive object ACLs +-- directly from the normal migration and never inherit another database role. +do $$ +declare + runtime_role name; + parent_role name; +begin + foreach runtime_role in array array['tiku_api'::name, 'tiku_worker'::name] + loop + for parent_role in + select parent.rolname + from pg_auth_members membership + join pg_roles member on member.oid = membership.member + join pg_roles parent on parent.oid = membership.roleid + where member.rolname = runtime_role + loop + execute format('revoke %I from %I', parent_role, runtime_role); + end loop; + end loop; +end +$$; + +comment on role tiku_api is + 'Trusted API-only runtime. Cluster attributes are provisioned by the database administrator; object ACLs are managed by migration 202607120013.'; + +comment on role tiku_worker is + 'Trusted background-worker runtime. Cluster attributes are provisioned by the database administrator; object ACLs are managed by migration 202607120013.'; diff --git a/scripts/deploy/systemd/tiku-api.service b/scripts/deploy/systemd/tiku-api.service index 5aa95598..a73fde1b 100644 --- a/scripts/deploy/systemd/tiku-api.service +++ b/scripts/deploy/systemd/tiku-api.service @@ -13,7 +13,7 @@ ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/api/dist/apps/api/src/server.js Restart=always RestartSec=5 KillSignal=SIGTERM -TimeoutStopSec=30 +TimeoutStopSec=40 NoNewPrivileges=true PrivateTmp=true diff --git a/scripts/deploy/systemd/tiku-worker-job@.service b/scripts/deploy/systemd/tiku-worker-job@.service new file mode 100644 index 00000000..10746079 --- /dev/null +++ b/scripts/deploy/systemd/tiku-worker-job@.service @@ -0,0 +1,22 @@ +[Unit] +Description=tiku-supabase periodic worker job (%i) +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=oneshot +User=deploy +Group=deploy +WorkingDirectory=/opt/tiku-saas/repo +EnvironmentFile=/etc/tiku-saas/worker.env +ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --once --job %i +TimeoutStartSec=1h + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=/srv/tiku-saas /opt/tiku-saas/repo +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=true diff --git a/scripts/deploy/systemd/tiku-worker-monthly-usage.service b/scripts/deploy/systemd/tiku-worker-monthly-usage.service new file mode 100644 index 00000000..22359192 --- /dev/null +++ b/scripts/deploy/systemd/tiku-worker-monthly-usage.service @@ -0,0 +1,23 @@ +[Unit] +Description=Collect and invoice previous-month SaaS usage +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=oneshot +User=deploy +Group=deploy +WorkingDirectory=/opt/tiku-saas/repo +EnvironmentFile=/etc/tiku-saas/worker.env +ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --once --job platform-usage --month previous +ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --once --job platform-usage-overage --month previous +TimeoutStartSec=3h + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=/srv/tiku-saas /opt/tiku-saas/repo +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=true diff --git a/scripts/deploy/systemd/tiku-worker-monthly-usage.timer b/scripts/deploy/systemd/tiku-worker-monthly-usage.timer new file mode 100644 index 00000000..66892fcb --- /dev/null +++ b/scripts/deploy/systemd/tiku-worker-monthly-usage.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Collect and invoice previous-month SaaS usage monthly + +[Timer] +OnCalendar=*-*-01 04:00:00 Asia/Shanghai +Persistent=true +AccuracySec=1m +RandomizedDelaySec=5m +Unit=tiku-worker-monthly-usage.service + +[Install] +WantedBy=tiku-workers.target diff --git a/scripts/deploy/systemd/tiku-worker-platform-audit-alerts.timer b/scripts/deploy/systemd/tiku-worker-platform-audit-alerts.timer new file mode 100644 index 00000000..6cd40718 --- /dev/null +++ b/scripts/deploy/systemd/tiku-worker-platform-audit-alerts.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Convert high-risk platform audit events into alerts + +[Timer] +OnBootSec=2m +OnUnitInactiveSec=5m +AccuracySec=30s +RandomizedDelaySec=30s +Unit=tiku-worker-job@platform-audit-alerts.service + +[Install] +WantedBy=tiku-workers.target diff --git a/scripts/deploy/systemd/tiku-worker-platform-billing.timer b/scripts/deploy/systemd/tiku-worker-platform-billing.timer new file mode 100644 index 00000000..43d077f9 --- /dev/null +++ b/scripts/deploy/systemd/tiku-worker-platform-billing.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Generate upcoming SaaS subscription invoices daily + +[Timer] +OnCalendar=*-*-* 01:30:00 Asia/Shanghai +Persistent=true +RandomizedDelaySec=15m +Unit=tiku-worker-job@platform-billing.service + +[Install] +WantedBy=tiku-workers.target diff --git a/scripts/deploy/systemd/tiku-worker-platform-dunning.timer b/scripts/deploy/systemd/tiku-worker-platform-dunning.timer new file mode 100644 index 00000000..7324a832 --- /dev/null +++ b/scripts/deploy/systemd/tiku-worker-platform-dunning.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Process overdue SaaS invoices daily + +[Timer] +OnCalendar=*-*-* 03:00:00 Asia/Shanghai +Persistent=true +RandomizedDelaySec=15m +Unit=tiku-worker-job@platform-dunning.service + +[Install] +WantedBy=tiku-workers.target diff --git a/scripts/deploy/systemd/tiku-worker-platform-usage.timer b/scripts/deploy/systemd/tiku-worker-platform-usage.timer new file mode 100644 index 00000000..b53920cc --- /dev/null +++ b/scripts/deploy/systemd/tiku-worker-platform-usage.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Collect current-month SaaS usage snapshots + +[Timer] +OnCalendar=*-*-* 02:00:00 Asia/Shanghai +Persistent=true +RandomizedDelaySec=15m +Unit=tiku-worker-job@platform-usage.service + +[Install] +WantedBy=tiku-workers.target diff --git a/scripts/deploy/systemd/tiku-worker-student-supervision.timer b/scripts/deploy/systemd/tiku-worker-student-supervision.timer new file mode 100644 index 00000000..ee7aada9 --- /dev/null +++ b/scripts/deploy/systemd/tiku-worker-student-supervision.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Generate due student supervision follow-ups + +[Timer] +OnBootSec=5m +OnUnitInactiveSec=15m +AccuracySec=1m +RandomizedDelaySec=1m +Unit=tiku-worker-job@student-supervision.service + +[Install] +WantedBy=tiku-workers.target diff --git a/scripts/deploy/systemd/tiku-worker.service b/scripts/deploy/systemd/tiku-worker@.service similarity index 72% rename from scripts/deploy/systemd/tiku-worker.service rename to scripts/deploy/systemd/tiku-worker@.service index ab520920..8dd62611 100644 --- a/scripts/deploy/systemd/tiku-worker.service +++ b/scripts/deploy/systemd/tiku-worker@.service @@ -1,7 +1,8 @@ [Unit] -Description=tiku-supabase worker -After=network-online.target +Description=tiku-supabase continuous worker (%i) +After=network-online.target postgresql.service Wants=network-online.target +PartOf=tiku-workers.target [Service] Type=simple @@ -9,7 +10,7 @@ User=deploy Group=deploy WorkingDirectory=/opt/tiku-saas/repo EnvironmentFile=/etc/tiku-saas/worker.env -ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --loop +ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --loop --job %i Restart=always RestartSec=5 KillSignal=SIGTERM @@ -25,4 +26,4 @@ AmbientCapabilities= LockPersonality=true [Install] -WantedBy=multi-user.target +WantedBy=tiku-workers.target diff --git a/scripts/deploy/systemd/tiku-workers.target b/scripts/deploy/systemd/tiku-workers.target new file mode 100644 index 00000000..e94dd568 --- /dev/null +++ b/scripts/deploy/systemd/tiku-workers.target @@ -0,0 +1,20 @@ +[Unit] +Description=tiku-supabase production worker scheduler +Requires=tiku-worker@crm.service +Requires=tiku-worker@commerce.service +Requires=tiku-worker@provider-bills.service +Requires=tiku-worker@platform-dunning-notifications.service +Requires=tiku-worker@platform-audit-notifications.service +Requires=tiku-worker@assets.service +Requires=tiku-worker@imports.service +Requires=tiku-worker@public-banks.service +Requires=tiku-worker@exports.service +Wants=tiku-worker-platform-billing.timer +Wants=tiku-worker-platform-usage.timer +Wants=tiku-worker-platform-dunning.timer +Wants=tiku-worker-platform-audit-alerts.timer +Wants=tiku-worker-student-supervision.timer +Wants=tiku-worker-monthly-usage.timer + +[Install] +WantedBy=multi-user.target diff --git a/scripts/destructive-test-database-guard-test.js b/scripts/destructive-test-database-guard-test.js new file mode 100644 index 00000000..96fb3a85 --- /dev/null +++ b/scripts/destructive-test-database-guard-test.js @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + DESTRUCTIVE_TEST_CONFIRMATION, + assertDestructiveTestDatabase, + describeDatabaseTarget, + resolveDestructiveTestConfirmation, +} from './lib/destructive-test-database-guard.js'; + +const LOCAL_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +const CONFIRMATION = DESTRUCTIVE_TEST_CONFIRMATION; + +function clientWithMarker(environment, allowDestructiveTests) { + const queries = []; + return { + queries, + client: { + async query(sql) { + queries.push(String(sql).trim().replace(/\s+/g, ' ')); + return { rows: [{ environment, allowDestructiveTests }], rowCount: 1 }; + }, + }, + }; +} + +async function rejects(options, pattern) { + await assert.rejects(() => assertDestructiveTestDatabase(options), pattern); +} + +{ + const mock = clientWithMarker('local', true); + const result = await assertDestructiveTestDatabase({ + client: mock.client, + databaseUrl: LOCAL_URL, + confirmation: CONFIRMATION, + operation: 'smoke seed', + }); + assert.equal(result.environment, 'local'); + assert.equal(mock.queries.length, 1); + assert.match(mock.queries[0], /^select environment,/i); +} + +{ + const mock = clientWithMarker('ci', true); + const result = await assertDestructiveTestDatabase({ + client: mock.client, + databaseUrl: 'postgresql://ci_runner:secret@postgres-ci.internal:6432/tiku_ci', + confirmation: CONFIRMATION, + }); + assert.equal(result.environment, 'ci'); +} + +for (const runtimeUser of ['tiku_api', 'tiku_worker']) { + const mock = clientWithMarker('local', true); + await rejects( + { + client: mock.client, + databaseUrl: `postgresql://${runtimeUser}:prod-secret@127.0.0.1:5432/postgres`, + confirmation: CONFIRMATION, + }, + /reserved for production runtime/, + ); + assert.equal(mock.queries.length, 0, 'known production targets must be rejected before SQL'); +} + +{ + const missingMarkerClient = { query: async () => ({ rows: [], rowCount: 0 }) }; + await rejects( + { + client: missingMarkerClient, + databaseUrl: 'postgresql://postgres:secret@127.0.0.1:15432/postgres', + confirmation: CONFIRMATION, + }, + /marker row is missing/, + ); +} + +for (const environment of ['production', 'staging']) { + const mock = clientWithMarker(environment, true); + await rejects( + { client: mock.client, databaseUrl: LOCAL_URL, confirmation: CONFIRMATION }, + new RegExp(`environment ${environment} is not approved`), + ); +} + +{ + const mock = clientWithMarker('local', false); + await rejects( + { client: mock.client, databaseUrl: LOCAL_URL, confirmation: CONFIRMATION }, + /does not allow destructive tests/, + ); +} + +for (const confirmation of ['', 'wrong-confirmation']) { + const mock = clientWithMarker('local', true); + await rejects( + { client: mock.client, databaseUrl: LOCAL_URL, confirmation }, + /explicit confirmation/, + ); + assert.equal(mock.queries.length, 0, 'confirmation must be checked before SQL'); +} + +{ + const queryErrorClient = { query: async () => { throw new Error('password=do-not-print'); } }; + let error; + try { + await assertDestructiveTestDatabase({ + client: queryErrorClient, + databaseUrl: LOCAL_URL, + confirmation: CONFIRMATION, + }); + } catch (caught) { + error = caught; + } + assert.match(error?.message || '', /marker is unavailable or unreadable/); + assert.equal(error.message.includes('do-not-print'), false); +} + +for (const malformed of ['', 'not-a-url', 'https://example.com/database', 'postgresql://localhost']) { + await rejects( + { client: clientWithMarker('local', true).client, databaseUrl: malformed, confirmation: CONFIRMATION }, + /Refusing destructive database test/, + ); +} + +{ + const target = describeDatabaseTarget('postgresql://user:super-secret@db.example.test:5439/tiku_test'); + assert.deepEqual(target, { + host: 'db.example.test', + port: '5439', + database: 'tiku_test', + user: 'user', + }); + assert.equal(JSON.stringify(target).includes('super-secret'), false); + assert.equal(resolveDestructiveTestConfirmation({}, ['--confirm', CONFIRMATION]), CONFIRMATION); + assert.equal(resolveDestructiveTestConfirmation({ SMOKE_SEED_CONFIRM: CONFIRMATION }, []), CONFIRMATION); +} + +const repoRoot = process.cwd(); +const smokeSeed = fs.readFileSync(path.join(repoRoot, 'scripts', 'smoke-seed.js'), 'utf8'); +const apiIntegration = fs.readFileSync(path.join(repoRoot, 'scripts', 'api-integration-test.js'), 'utf8'); +const rlsTest = fs.readFileSync(path.join(repoRoot, 'scripts', 'rls-tenant-isolation-test.js'), 'utf8'); +const autoBadgeConcurrency = fs.readFileSync( + path.join(repoRoot, 'scripts', 'auto-badge-concurrency-test.js'), + 'utf8', +); +const migration = fs.readFileSync( + path.join(repoRoot, 'supabase', 'migrations', '202607120001_destructive_test_environment_safety.sql'), + 'utf8', +); +const seed = fs.readFileSync(path.join(repoRoot, 'supabase', 'seed.sql'), 'utf8'); +const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + +for (const [name, source] of [ + ['smoke seed', smokeSeed], + ['API integration test', apiIntegration], + ['RLS isolation test', rlsTest], + ['auto badge concurrency test', autoBadgeConcurrency], +]) { + assert.match(source, /assertDestructiveTestDatabase\s*\(/, `${name} must invoke the shared database guard`); +} + +const guardCall = smokeSeed.indexOf('assertDestructiveTestDatabase('); +const firstBegin = smokeSeed.search(/client\.query\(['"]begin['"]\)/i); +const firstWrite = smokeSeed.search(/\b(?:update|delete from|insert into)\s+public\./i); +assert.ok(guardCall >= 0, 'smoke seed must call the shared guard'); +assert.ok(firstBegin > guardCall, 'smoke seed guard must run before BEGIN'); +assert.ok(firstWrite > guardCall, 'smoke seed guard must run before persistent SQL writes'); + +assert.match(migration, /create table if not exists app_private\.environment_safety/i); +assert.match(migration, /environment in \('local', 'test', 'ci', 'staging', 'production'\)/i); +assert.match(migration, /allow_destructive_tests boolean not null default false/i); +assert.doesNotMatch( + migration, + /insert into app_private\.environment_safety/i, + 'migrations must not automatically authorize destructive tests', +); +assert.match( + seed, + /insert into app_private\.environment_safety[\s\S]*values \(true, 'local', true\)/i, + 'local Supabase seed must provision the local-only marker', +); + +assert.equal(packageJson.scripts?.['test:api:remote'], undefined, 'full API integration must not expose a remote mode'); +for (const scriptName of ['db:smoke-seed:test', 'test:api', 'test:rls']) { + assert.match( + packageJson.scripts?.[scriptName] || '', + new RegExp(DESTRUCTIVE_TEST_CONFIRMATION), + `${scriptName} must carry the exact destructive-test confirmation`, + ); +} +assert.match( + packageJson.scripts?.['test:readiness'] || '', + /auto-badge-concurrency-test\.js --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY/, + 'readiness must explicitly confirm its destructive concurrency test', +); +assert.ok( + packageJson.scripts?.['test:readiness']?.includes('destructive-test-database-guard-test.js'), + 'readiness contracts must include the destructive database guard test', +); + +console.log('[PASS] destructive test database fail-closed guard'); diff --git a/scripts/diagnose-aliyun-pnvs-provider-test.js b/scripts/diagnose-aliyun-pnvs-provider-test.js index 67c4498c..acd2b046 100644 --- a/scripts/diagnose-aliyun-pnvs-provider-test.js +++ b/scripts/diagnose-aliyun-pnvs-provider-test.js @@ -6,6 +6,7 @@ import { } from './diagnose-aliyun-pnvs-provider.js'; const tenantId = '00000000-0000-0000-0000-000000000001'; +const localDatabaseUrl = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; function queryFixture({ providerRows = [], secretRows = [] }) { return async (sql, params) => { @@ -57,7 +58,7 @@ const goodSecretRows = [ ]; const ok = await diagnoseAliyunPnvsProvider( - { databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun-pnvs' }, + { databaseUrl: localDatabaseUrl, tenantId, authSmsProvider: 'aliyun-pnvs' }, { query: queryFixture({ providerRows: goodProviderRows, secretRows: goodSecretRows }) }, ); @@ -71,14 +72,14 @@ assert.ok(!JSON.stringify(ok).includes(goodSecretRows[0].accessKeyId), 'diagnost assert.match(ok.secret.accessKeyIdMasked, /^LTAI\.\.\./); const missingSecret = await diagnoseAliyunPnvsProvider( - { databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun-pnvs' }, + { databaseUrl: localDatabaseUrl, tenantId, authSmsProvider: 'aliyun-pnvs' }, { query: queryFixture({ providerRows: goodProviderRows, secretRows: [] }) }, ); assert.equal(missingSecret.ok, false); assert.ok(missingSecret.checks.some(item => item.id === 'db.tenant_secret' && item.status === 'blocker')); const wrongEnvProvider = await diagnoseAliyunPnvsProvider( - { databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun' }, + { databaseUrl: localDatabaseUrl, tenantId, authSmsProvider: 'aliyun' }, { query: queryFixture({ providerRows: goodProviderRows, secretRows: goodSecretRows }) }, ); assert.equal(wrongEnvProvider.ok, false); diff --git a/scripts/disable-legacy-sms-providers-test.js b/scripts/disable-legacy-sms-providers-test.js index 921e8aa8..847fa3da 100644 --- a/scripts/disable-legacy-sms-providers-test.js +++ b/scripts/disable-legacy-sms-providers-test.js @@ -6,6 +6,7 @@ import { } from './disable-legacy-sms-providers.js'; const tenantId = '00000000-0000-0000-0000-000000000001'; +const localDatabaseUrl = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const rows = [ { id: 'legacy-aliyun', tenantId, provider: 'aliyun', status: 'active', displayName: '旧阿里云短信' }, { id: 'legacy-tencent', tenantId, provider: 'tencent-sms', status: 'testing', displayName: '旧腾讯云短信' }, @@ -29,14 +30,14 @@ function queryFixture() { } const cfg = buildConfig( - { DATABASE_URL: 'postgresql://example', PNVS_TENANT_ID: tenantId }, + { DATABASE_URL: localDatabaseUrl, PNVS_TENANT_ID: tenantId }, [], ); assert.equal(cfg.apply, false); assert.equal(cfg.tenantId, tenantId); const applyCfg = buildConfig( - { DATABASE_URL: 'postgresql://example', PNVS_TENANT_ID: tenantId }, + { DATABASE_URL: localDatabaseUrl, PNVS_TENANT_ID: tenantId }, ['--apply'], ); assert.equal(applyCfg.apply, true); @@ -45,7 +46,7 @@ const found = await findLegacySmsProviderRows(queryFixture(), tenantId); assert.deepEqual(found.map(row => row.id), ['legacy-aliyun', 'legacy-tencent']); const dryRun = await disableLegacySmsProviders( - { databaseUrl: 'postgresql://example', tenantId, apply: false }, + { databaseUrl: localDatabaseUrl, tenantId, apply: false }, { query: queryFixture() }, ); assert.equal(dryRun.dryRun, true); @@ -53,7 +54,7 @@ assert.equal(dryRun.changed, 0); assert.deepEqual(dryRun.rows.map(row => row.id), ['legacy-aliyun', 'legacy-tencent']); const applyRun = await disableLegacySmsProviders( - { databaseUrl: 'postgresql://example', tenantId, apply: true }, + { databaseUrl: localDatabaseUrl, tenantId, apply: true }, { query: queryFixture() }, ); assert.equal(applyRun.dryRun, false); diff --git a/scripts/import-pocketbase/src/sqlite-exporter.py b/scripts/import-pocketbase/src/sqlite-exporter.py index 4b87d198..1e1be506 100644 --- a/scripts/import-pocketbase/src/sqlite-exporter.py +++ b/scripts/import-pocketbase/src/sqlite-exporter.py @@ -64,6 +64,12 @@ def utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") +def write_json_file(file_path: pathlib.Path, payload: Any) -> None: + with file_path.open("w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Export PocketBase SQLite collections to JSON.") parser.add_argument("--data-db", required=True, help="Path to PocketBase data.db") @@ -371,19 +377,11 @@ def main() -> int: "generatedAt": utc_now_iso(), "collections": collections, } - (export_dir / "pb_schema.sqlite.json").write_text( - json.dumps(schema_payload, ensure_ascii=False, indent=2), - encoding="utf-8", - newline="\n", - ) + write_json_file(export_dir / "pb_schema.sqlite.json", schema_payload) storage_manifest = build_storage_manifest(storage_dir, collections) if storage_dir else None if storage_manifest: - (export_dir / "storage-manifest.json").write_text( - json.dumps(storage_manifest, ensure_ascii=False, indent=2), - encoding="utf-8", - newline="\n", - ) + write_json_file(export_dir / "storage-manifest.json", storage_manifest) aux = auxiliary_summary(aux_db, args.include_aux_logs) if aux_db else None @@ -416,11 +414,7 @@ def main() -> int: "auxiliary": aux, } - (export_dir / "sqlite-export-manifest.json").write_text( - json.dumps(manifest, ensure_ascii=False, indent=2), - encoding="utf-8", - newline="\n", - ) + write_json_file(export_dir / "sqlite-export-manifest.json", manifest) print(json.dumps(manifest, ensure_ascii=False)) return 0 diff --git a/scripts/import-worker-integration-test.js b/scripts/import-worker-integration-test.js index 7eff4985..241361f0 100644 --- a/scripts/import-worker-integration-test.js +++ b/scripts/import-worker-integration-test.js @@ -1,64 +1,70 @@ import assert from 'node:assert/strict'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import pg from 'pg'; -import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; +import { + assertDestructiveTestDatabase, + resolveDestructiveTestConfirmation, +} from './lib/destructive-test-database-guard.js'; const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const tenantId = '00000000-0000-0000-0000-000000000001'; const adminUserId = '00000000-0000-0000-0000-000000000102'; +const confirmation = resolveDestructiveTestConfirmation(); const ids = { region: '00000000-0000-0000-0000-000000000301', subject: '00000000-0000-0000-0000-000000000501', category: '00000000-0000-0000-0000-000000000601', - contentEntry: '00000000-0000-0000-0000-000000000611', contentNodeSchoolTarget: '00000000-0000-0000-0000-000000000614', questionCollection: '00000000-0000-0000-0000-000000000615', }; -function runWorkerOnce() { - const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'imports'], { - cwd: process.cwd(), - env: { - ...process.env, - DATABASE_URL: databaseUrl, - WORKER_IMPORT_BATCH_SIZE: '5', - WORKER_IMPORT_ID: 'imports-integration-test', - }, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - let output = ''; - child.stdout.on('data', chunk => { - output += chunk.toString(); - }); - child.stderr.on('data', chunk => { - output += chunk.toString(); - }); - return new Promise((resolve, reject) => { - child.on('error', reject); - child.on('exit', code => { - try { - assert.equal(code, 0, `worker should exit 0\n${output}`); - assert.match(output, /imports batch processed=\d+/, 'worker output should include imports summary'); - resolve(output); - } catch (error) { - reject(error); - } - }); - }); +process.env.DATABASE_URL = databaseUrl; +process.env.WORKER_IMPORT_BATCH_SIZE = '5'; +process.env.WORKER_IMPORT_ID = 'imports-integration-default'; +process.env.WORKER_IMPORT_LEASE_SECONDS = '120'; +process.env.WORKER_IMPORT_HEARTBEAT_INTERVAL_MS = '30000'; + +const workerModuleUrl = pathToFileURL(fileURLToPath(new URL('../apps/worker/dist/apps/worker/src/jobs/imports.js', import.meta.url))).href; +const apiModuleUrl = pathToFileURL(fileURLToPath(new URL('../apps/worker/dist/apps/api/src/features/tenant-content/imports.js', import.meta.url))).href; +const worker = await import(workerModuleUrl); +const { executeContentImportJob } = await import(apiModuleUrl); + +function auth() { + return { + tenantId, + userId: adminUserId, + role: 'system_worker', + permissions: { 'content:*': true }, + templatePermissions: {}, + }; } async function cleanup(pool) { + await pool.query( + ` + update public.content_import_jobs + set status = 'pending', + locked_at = null, + locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, + next_attempt_at = now(), + updated_at = now() + where tenant_id = $1 + and source_name like 'worker-lease-%' + and status = 'importing' + `, + [tenantId], + ); await pool.query( ` delete from public.question_collection_items where tenant_id = $1 and question_id in ( select id from public.questions - where tenant_id = $1 - and ( - legacy_id like 'worker-import-question-%' - or legacy_id like 'integration-import-async-choice-%' - ) + where tenant_id = $1 and legacy_id like 'worker-lease-question-%' ) `, [tenantId], @@ -69,24 +75,13 @@ async function cleanup(pool) { where tenant_id = $1 and question_id in ( select id from public.questions - where tenant_id = $1 - and ( - legacy_id like 'worker-import-question-%' - or legacy_id like 'integration-import-async-choice-%' - ) + where tenant_id = $1 and legacy_id like 'worker-lease-question-%' ) `, [tenantId], ); await pool.query( - ` - delete from public.questions - where tenant_id = $1 - and ( - legacy_id like 'worker-import-question-%' - or legacy_id like 'integration-import-async-choice-%' - ) - `, + `delete from public.questions where tenant_id = $1 and legacy_id like 'worker-lease-question-%'`, [tenantId], ); await pool.query( @@ -94,24 +89,21 @@ async function cleanup(pool) { delete from public.audit_logs where tenant_id = $1 and target_type = 'content_import_job' - and details::text like '%worker-import%' - `, - [tenantId], - ); - await pool.query( - ` - delete from public.content_import_jobs - where tenant_id = $1 - and ( - source_name like 'worker-import-%' - or source_name = 'async-question-import.json' + and target_id in ( + select id::text from public.content_import_jobs + where tenant_id = $1 and source_name like 'worker-lease-%' ) `, [tenantId], ); + await pool.query( + `delete from public.content_import_jobs where tenant_id = $1 and source_name like 'worker-lease-%'`, + [tenantId], + ); } -async function createQueuedQuestionImport(pool) { +async function createQueuedQuestionImport(pool, suffix, maxAttempts = 3) { + const legacyId = `worker-lease-question-${suffix}`; const preview = await pool.query( ` insert into public.content_import_jobs ( @@ -120,20 +112,21 @@ async function createQueuedQuestionImport(pool) { target_category_id, target_content_node_id, target_collection_id, dry_run, total_count, valid_count, error_count, warning_count, summary, raw_payload, normalized_payload, execution_mode, queued_at, - next_attempt_at, parser_metadata + next_attempt_at, max_attempts, parser_metadata ) values ( $1, $2, 'questions', 'json', 'pending', - 'worker-import-questions.json', 'worker-import-source-hash', - $3::uuid, $4::uuid, $5::uuid, $6::uuid, $7::uuid, + $3, $4, $5::uuid, $6::uuid, $7::uuid, $8::uuid, $9::uuid, false, 1, 1, 0, 0, - $8::jsonb, $9::jsonb, $10::jsonb, 'async', now(), now(), '{}'::jsonb + $10::jsonb, $11::jsonb, $12::jsonb, 'async', now(), now(), $13, '{}'::jsonb ) returning id `, [ tenantId, adminUserId, + `worker-lease-${suffix}.json`, + `worker-lease-source-${suffix}`, ids.region, ids.subject, ids.category, @@ -148,41 +141,29 @@ async function createQueuedQuestionImport(pool) { collectionId: ids.questionCollection, }, importOptions: { allowPartial: false }, - source: 'worker-import-integration', + source: 'worker-import-lease-integration', }), - JSON.stringify([ - { - legacyId: 'worker-import-question-001', - type: 'choice', - content: '异步导入题:worker 应该复用哪套导入规则?', - options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'], - correctOptionIndices: [1], - explanation: 'worker 和 API 必须复用同一套后端导入规则。', - difficulty: 2, - tags: ['worker-import'], - }, - ]), - JSON.stringify([ - { - legacyId: 'worker-import-question-001', - type: 'choice', - typeLabel: null, - content: '异步导入题:worker 应该复用哪套导入规则?', - options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'], - correctOptionIndex: 1, - correctOptionIndices: [1], - answerText: null, - explanation: 'worker 和 API 必须复用同一套后端导入规则。', - difficulty: 2, - tags: ['worker-import'], - mediaUrl: null, - subQuestions: [], - codeLang: null, - codeTemplate: null, - examMarkers: {}, - sourceHash: 'worker-import-question-hash-001', - }, - ]), + JSON.stringify([{ legacyId, type: 'choice', content: `lease test ${suffix}` }]), + JSON.stringify([{ + legacyId, + type: 'choice', + typeLabel: null, + content: `lease test ${suffix}`, + options: ['A', 'B'], + correctOptionIndex: 1, + correctOptionIndices: [1], + answerText: null, + explanation: 'persistent lease integration fixture', + difficulty: 2, + tags: ['worker-lease'], + mediaUrl: null, + subQuestions: [], + codeLang: null, + codeTemplate: null, + examMarkers: {}, + sourceHash: `worker-lease-hash-${suffix}`, + }]), + maxAttempts, ], ); const jobId = preview.rows[0].id; @@ -192,108 +173,236 @@ async function createQueuedQuestionImport(pool) { tenant_id, job_id, row_no, external_id, status, target_type, source_payload, normalized_payload, content_hash, issues_count ) - values ($1, $2, 1, 'worker-import-question-001', 'valid', 'question', $3::jsonb, $4::jsonb, 'worker-import-question-hash-001', 0) + values ($1, $2, 1, $3, 'valid', 'question', $4::jsonb, $5::jsonb, $6, 0) `, [ tenantId, jobId, + legacyId, + JSON.stringify({ legacyId, content: `lease test ${suffix}` }), JSON.stringify({ - legacyId: 'worker-import-question-001', - content: '异步导入题:worker 应该复用哪套导入规则?', - }), - JSON.stringify({ - legacyId: 'worker-import-question-001', + legacyId, type: 'choice', typeLabel: null, - content: '异步导入题:worker 应该复用哪套导入规则?', - options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'], + content: `lease test ${suffix}`, + options: ['A', 'B'], correctOptionIndex: 1, correctOptionIndices: [1], answerText: null, - explanation: 'worker 和 API 必须复用同一套后端导入规则。', + explanation: 'persistent lease integration fixture', difficulty: 2, - tags: ['worker-import'], + tags: ['worker-lease'], mediaUrl: null, subQuestions: [], codeLang: null, codeTemplate: null, examMarkers: {}, - sourceHash: 'worker-import-question-hash-001', + sourceHash: `worker-lease-hash-${suffix}`, }), + `worker-lease-hash-${suffix}`, ], ); - return jobId; + return { jobId, legacyId }; +} + +async function readJob(pool, jobId) { + const result = await pool.query( + ` + select status, attempt_count as "attemptCount", locked_by as "lockedBy", + lease_token as "leaseToken", lease_expires_at as "leaseExpiresAt", + last_heartbeat_at as "lastHeartbeatAt", next_attempt_at as "nextAttemptAt", + inserted_count as "insertedCount", error_message as "errorMessage" + from public.content_import_jobs + where tenant_id = $1 and id = $2 + `, + [tenantId, jobId], + ); + return result.rows[0]; +} + +async function testAtomicClaim(pool) { + const jobs = await Promise.all([ + createQueuedQuestionImport(pool, 'atomic-a'), + createQueuedQuestionImport(pool, 'atomic-b'), + createQueuedQuestionImport(pool, 'atomic-c'), + ]); + const [left, right] = await Promise.all([ + worker.claimImportJobs({ workerId: 'lease-worker-a', batchSize: 2, leaseSeconds: 30 }), + worker.claimImportJobs({ workerId: 'lease-worker-b', batchSize: 2, leaseSeconds: 30 }), + ]); + const claimed = [...left, ...right]; + assert.equal(claimed.length, 3, 'concurrent workers should claim all three jobs'); + assert.equal(new Set(claimed.map(job => job.id)).size, 3, 'SKIP LOCKED claim must not duplicate a job'); + assert.deepEqual( + new Set(claimed.map(job => job.id)), + new Set(jobs.map(job => job.jobId)), + 'claims must stay within the ready fixture set', + ); + for (const job of claimed) { + assert.equal(job.attemptCount, 1, 'claim should atomically increment attempt_count once'); + assert.ok(job.leaseToken, 'claim should persist a fencing token'); + } +} + +async function testHeartbeat(pool) { + const fixture = await createQueuedQuestionImport(pool, 'heartbeat'); + const [claimed] = await worker.claimImportJobs({ + workerId: 'lease-heartbeat-worker', + batchSize: 1, + leaseSeconds: 3, + }); + assert.equal(claimed.id, fixture.jobId); + const before = await readJob(pool, fixture.jobId); + const heartbeat = worker.startImportLeaseHeartbeat(claimed, { + leaseSeconds: 3, + heartbeatIntervalMs: 250, + }); + await delay(700); + await heartbeat.stop(); + const after = await readJob(pool, fixture.jobId); + assert.ok(after.lastHeartbeatAt > before.lastHeartbeatAt, 'heartbeat should advance last_heartbeat_at'); + assert.ok(after.leaseExpiresAt > before.leaseExpiresAt, 'heartbeat should extend lease expiry'); +} + +async function testExpiredTakeoverAndFencing(pool) { + const fixture = await createQueuedQuestionImport(pool, 'takeover'); + const [first] = await worker.claimImportJobs({ workerId: 'lease-old-worker', batchSize: 1, leaseSeconds: 30 }); + await pool.query( + ` + update public.content_import_jobs + set locked_at = now() - interval '10 seconds', + lease_expires_at = now() - interval '1 second', + last_heartbeat_at = now() - interval '10 seconds' + where tenant_id = $1 and id = $2 + `, + [tenantId, fixture.jobId], + ); + + const [second] = await worker.claimImportJobs({ workerId: 'lease-new-worker', batchSize: 1, leaseSeconds: 30 }); + assert.equal(second.id, fixture.jobId, 'expired importing job should be reclaimed'); + assert.notEqual(second.leaseToken, first.leaseToken, 'takeover must rotate fencing token'); + assert.equal(second.attemptCount, 2, 'takeover should consume exactly one additional attempt'); + + await assert.rejects( + executeContentImportJob(auth(), { + jobId: fixture.jobId, + importType: 'questions', + allowPartial: false, + allowQueuedJob: true, + leaseToken: first.leaseToken, + }), + error => error?.code === 'IMPORT_WORKER_LEASE_LOST', + 'old worker must be fenced before it can write imported content', + ); + assert.equal( + (await pool.query(`select count(*)::int as count from public.questions where tenant_id = $1 and legacy_id = $2`, [tenantId, fixture.legacyId])).rows[0].count, + 0, + 'fenced old worker must leave no business writes', + ); + + const staleFailure = await worker.markImportFailed(first, new Error('stale worker failure')); + assert.equal(staleFailure, 'lease_lost', 'old worker must not schedule retry or failure after takeover'); + const afterStaleFailure = await readJob(pool, fixture.jobId); + assert.equal(afterStaleFailure.leaseToken, second.leaseToken, 'stale failure must not overwrite current lease'); + + const execution = await executeContentImportJob(auth(), { + jobId: fixture.jobId, + importType: 'questions', + allowPartial: false, + allowQueuedJob: true, + leaseToken: second.leaseToken, + }); + assert.equal(execution.status, 'completed', 'current lease owner should complete import'); + const completed = await readJob(pool, fixture.jobId); + assert.equal(completed.status, 'completed'); + assert.equal(completed.attemptCount, 2, 'successful takeover must preserve exact attempt count'); + assert.equal(completed.leaseToken, null, 'terminal transition should release fencing token'); + assert.equal(Number(completed.insertedCount), 1); + + await assert.rejects( + executeContentImportJob(auth(), { + jobId: fixture.jobId, + importType: 'questions', + allowPartial: false, + allowQueuedJob: true, + leaseToken: first.leaseToken, + }), + error => error?.code === 'IMPORT_WORKER_LEASE_LOST', + 'old worker must not turn a completed takeover into an idempotent success', + ); +} + +async function testRetryState(pool) { + const fixture = await createQueuedQuestionImport(pool, 'retry'); + const [first] = await worker.claimImportJobs({ workerId: 'lease-retry-worker', batchSize: 1, leaseSeconds: 30 }); + const state = await worker.markImportFailed(first, Object.assign(new Error('retry fixture'), { code: 'RETRY_FIXTURE' })); + assert.equal(state, 'retrying'); + const pending = await readJob(pool, fixture.jobId); + assert.equal(pending.status, 'pending'); + assert.equal(pending.attemptCount, 1, 'retry scheduling must not increment attempt count'); + assert.equal(pending.leaseToken, null, 'retry scheduling must release lease'); + assert.ok(pending.nextAttemptAt, 'retry scheduling should persist next_attempt_at'); + + await pool.query( + `update public.content_import_jobs set next_attempt_at = now() - interval '1 second' where tenant_id = $1 and id = $2`, + [tenantId, fixture.jobId], + ); + const [second] = await worker.claimImportJobs({ workerId: 'lease-retry-worker-2', batchSize: 1, leaseSeconds: 30 }); + assert.equal(second.id, fixture.jobId); + assert.equal(second.attemptCount, 2, 'retry claim should increment attempt exactly once'); + assert.notEqual(second.leaseToken, first.leaseToken, 'each attempt must receive a new fencing token'); +} + +async function testExpiredFinalAttempt(pool) { + const fixture = await createQueuedQuestionImport(pool, 'exhausted', 1); + await worker.claimImportJobs({ workerId: 'lease-crashed-final-worker', batchSize: 1, leaseSeconds: 30 }); + await pool.query( + ` + update public.content_import_jobs + set locked_at = now() - interval '10 seconds', + lease_expires_at = now() - interval '1 second', + last_heartbeat_at = now() - interval '10 seconds' + where tenant_id = $1 and id = $2 + `, + [tenantId, fixture.jobId], + ); + const claimed = await worker.claimImportJobs({ workerId: 'lease-reaper-worker', batchSize: 1, leaseSeconds: 30 }); + assert.equal(claimed.length, 0, 'expired final attempt must not be executed again'); + const failed = await readJob(pool, fixture.jobId); + assert.equal(failed.status, 'failed', 'expired final attempt should become terminal failed'); + assert.equal(failed.attemptCount, 1, 'reaping final attempt must not inflate attempts'); + assert.equal(failed.leaseToken, null, 'terminal reaper should release lease'); + assert.match(failed.errorMessage, /lease expired/i); } async function main() { - const pool = new pg.Pool({ connectionString: databaseUrl }); + const pool = new pg.Pool({ connectionString: databaseUrl, max: 12 }); try { + await assertDestructiveTestDatabase({ + client: pool, + databaseUrl, + confirmation, + operation: 'import worker lease integration test', + }); await cleanup(pool); - const jobId = await createQueuedQuestionImport(pool); - - const output = await runWorkerOnce(); - assert.match(output, /completed=1/, 'worker should complete exactly the queued import job after cleanup'); - - const job = await pool.query( - ` - select status, execution_mode, inserted_count, updated_count, skipped_count, - locked_at, locked_by, attempt_count, error_message - from public.content_import_jobs - where tenant_id = $1 and id = $2 - `, - [tenantId, jobId], - ); - assert.equal(job.rows[0]?.status, 'completed', 'queued import job should be completed'); - assert.equal(job.rows[0]?.execution_mode, 'async', 'job should keep async execution mode'); - assert.equal(Number(job.rows[0]?.inserted_count), 1, 'worker should insert one question'); - assert.equal(job.rows[0]?.locked_at, null, 'completed job should release lock'); - assert.equal(job.rows[0]?.locked_by, null, 'completed job should clear lock owner'); - assert.equal(Number(job.rows[0]?.attempt_count), 1, 'worker should record one attempt'); - assert.equal(job.rows[0]?.error_message, null, 'completed job should not retain error message'); - - const question = await pool.query( - ` - select q.id, v.content - from public.questions q - join public.question_versions v on v.id = q.current_version_id - where q.tenant_id = $1 and q.legacy_id = 'worker-import-question-001' - limit 1 - `, - [tenantId], - ); - assert.equal(question.rows[0]?.content, '异步导入题:worker 应该复用哪套导入规则?', 'worker should import question content'); - - const collectionItem = await pool.query( - ` - select 1 - from public.question_collection_items - where tenant_id = $1 and collection_id = $2 and question_id = $3 - limit 1 - `, - [tenantId, ids.questionCollection, question.rows[0]?.id], - ); - assert.equal(collectionItem.rowCount, 1, 'worker should bind imported question to collection'); - - const audit = await pool.query( - ` - select action - from public.audit_logs - where tenant_id = $1 and target_type = 'content_import_job' and target_id = $2 - order by created_at desc - limit 1 - `, - [tenantId, jobId], - ); - assert.equal(audit.rows[0]?.action, 'content.import.questions.completed', 'worker import should write completion audit'); - - console.log('Import worker integration test complete.'); + await testAtomicClaim(pool); + await cleanup(pool); + await testHeartbeat(pool); + await cleanup(pool); + await testExpiredTakeoverAndFencing(pool); + await cleanup(pool); + await testRetryState(pool); + await cleanup(pool); + await testExpiredFinalAttempt(pool); + console.log('Import worker lease integration test complete.'); } finally { - await cleanup(pool).catch(() => {}); + await cleanup(pool).catch(() => undefined); + await worker.closeImportExecutorPool().catch(() => undefined); await pool.end(); } } main().catch(error => { console.error(error); - process.exit(1); + process.exitCode = 1; }); diff --git a/scripts/lib/destructive-test-database-guard.js b/scripts/lib/destructive-test-database-guard.js new file mode 100644 index 00000000..33c201a4 --- /dev/null +++ b/scripts/lib/destructive-test-database-guard.js @@ -0,0 +1,148 @@ +export const DESTRUCTIVE_TEST_CONFIRMATION = 'SMOKE_SEED_LOCAL_OR_CI_ONLY'; + +const ALLOWED_ENVIRONMENTS = new Set(['local', 'test', 'ci']); +const KNOWN_PRODUCTION_DATABASE_USERS = new Set(['tiku_api', 'tiku_worker']); + +function argumentValue(argv, name) { + const directIndex = argv.indexOf(name); + if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim(); + const prefix = `${name}=`; + return String(argv.find(value => value.startsWith(prefix)) || '').slice(prefix.length).trim(); +} + +export function resolveDestructiveTestConfirmation( + env = process.env, + argv = process.argv.slice(2), +) { + return argumentValue(argv, '--confirm') || String(env.SMOKE_SEED_CONFIRM || '').trim(); +} + +export function describeDatabaseTarget(databaseUrl) { + if (!databaseUrl || typeof databaseUrl !== 'string') { + throw new Error('DATABASE_URL is required'); + } + + let parsed; + try { + parsed = new URL(databaseUrl); + } catch { + throw new Error('DATABASE_URL must be a valid PostgreSQL URL'); + } + if (!['postgres:', 'postgresql:'].includes(parsed.protocol)) { + throw new Error('DATABASE_URL must use the postgres or postgresql protocol'); + } + if (!parsed.hostname || !parsed.pathname || parsed.pathname === '/') { + throw new Error('DATABASE_URL must include a host and database name'); + } + + return { + host: parsed.hostname, + port: parsed.port || '5432', + database: decodeURIComponent(parsed.pathname.slice(1)), + user: decodeURIComponent(parsed.username || ''), + }; +} + +function targetText(target, environment = 'unavailable') { + const safe = value => String(value || '[missing]') + .replace(/[\u0000-\u001f\u007f\s]+/g, '_') + .slice(0, 160); + return [ + `host=${safe(target.host)}`, + `port=${safe(target.port)}`, + `database=${safe(target.database)}`, + `user=${safe(target.user)}`, + `databaseEnvironment=${safe(environment)}`, + ].join(' '); +} + +function refusal(operation, reason, target, environment) { + return new Error( + `Refusing ${operation}: ${reason}. Target: ${targetText(target, environment)}`, + ); +} + +function knownProductionReason(target) { + const user = target.user.toLowerCase(); + const host = target.host.toLowerCase(); + if (KNOWN_PRODUCTION_DATABASE_USERS.has(user)) { + return 'database user is reserved for production runtime'; + } + if (host === 'tjszsb.com' || host.endsWith('.tjszsb.com')) { + return 'database host belongs to the production domain'; + } + return ''; +} + +export async function assertDestructiveTestDatabase({ + client, + databaseUrl, + confirmation = resolveDestructiveTestConfirmation(), + operation = 'destructive database test', +} = {}) { + let target; + try { + target = describeDatabaseTarget(databaseUrl); + } catch (error) { + throw new Error(`Refusing ${operation}: ${error.message}`); + } + + if (confirmation !== DESTRUCTIVE_TEST_CONFIRMATION) { + throw refusal( + operation, + `explicit confirmation ${DESTRUCTIVE_TEST_CONFIRMATION} is required`, + target, + ); + } + + const productionReason = knownProductionReason(target); + if (productionReason) { + throw refusal(operation, productionReason, target); + } + if (!client || typeof client.query !== 'function') { + throw refusal(operation, 'a connected PostgreSQL client is required', target); + } + + let result; + try { + result = await client.query( + ` + select environment, + allow_destructive_tests as "allowDestructiveTests" + from app_private.environment_safety + where id = true + limit 1 + `, + ); + } catch { + throw refusal( + operation, + 'database safety marker is unavailable or unreadable', + target, + ); + } + + const marker = result?.rows?.[0]; + const environment = String(marker?.environment || 'missing').toLowerCase(); + if (!marker) { + throw refusal(operation, 'database safety marker row is missing', target, environment); + } + if (!ALLOWED_ENVIRONMENTS.has(environment)) { + throw refusal( + operation, + `database environment ${environment} is not approved for destructive tests`, + target, + environment, + ); + } + if (marker.allowDestructiveTests !== true) { + throw refusal( + operation, + 'database marker does not allow destructive tests', + target, + environment, + ); + } + + return { target, environment, allowDestructiveTests: true }; +} diff --git a/scripts/lib/tenant-foreign-key-audit.js b/scripts/lib/tenant-foreign-key-audit.js new file mode 100644 index 00000000..b5fbf658 --- /dev/null +++ b/scripts/lib/tenant-foreign-key-audit.js @@ -0,0 +1,249 @@ +import crypto from 'node:crypto'; + +export const TENANT_FOREIGN_KEY_AUDIT_KIND = 'tenant-foreign-key-audit'; +export const EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT = 189; +export const EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256 = '884a5a59c101299551c27bde83f82b9738074a8729da9284775f75537f615868'; + +const TENANT_FOREIGN_KEY_EXCEPTIONS = new Map([ + [ + 'platform_audit_alerts.platform_audit_alerts_rule_id_fkey', + { + mode: 'global-or-same-tenant-parent', + reason: 'Platform audit rules may be global (tenant_id is null) or scoped to the alert tenant.', + childColumn: 'rule_id', + parentTable: 'platform_audit_alert_rules', + parentColumn: 'id', + }, + ], + [ + 'tenant_question_bank_adoptions.tenant_question_bank_adoptions_source_question_bank_id_fkey', + { + mode: 'platform-source-or-same-tenant-parent', + reason: 'A tenant adoption may reference a platform-owned public question bank.', + childColumn: 'source_question_bank_id', + parentTable: 'question_banks', + parentColumn: 'id', + }, + ], + [ + 'tenant_content_notifications.tenant_content_notifications_source_question_bank_id_fkey', + { + mode: 'platform-source-or-same-tenant-parent', + reason: 'A tenant notification may identify the platform-owned public question bank that triggered it.', + childColumn: 'source_question_bank_id', + parentTable: 'question_banks', + parentColumn: 'id', + }, + ], +]); + +const RELATION_QUERY = ` + with tenant_tables as ( + select cls.oid, cls.relname + from pg_class cls + join pg_namespace ns on ns.oid = cls.relnamespace + where ns.nspname = 'public' + and cls.relkind in ('r', 'p') + and exists ( + select 1 + from pg_attribute attribute + where attribute.attrelid = cls.oid + and attribute.attname = 'tenant_id' + and not attribute.attisdropped + ) + ) + select child.relname as "childTable", + constraint_row.conname as "constraintName", + parent.relname as "parentTable", + array( + select attribute.attname + from unnest(constraint_row.conkey) with ordinality key_column(attnum, ordinal) + join pg_attribute attribute + on attribute.attrelid = constraint_row.conrelid + and attribute.attnum = key_column.attnum + order by key_column.ordinal + ) as "childColumns", + array( + select attribute.attname + from unnest(constraint_row.confkey) with ordinality key_column(attnum, ordinal) + join pg_attribute attribute + on attribute.attrelid = constraint_row.confrelid + and attribute.attnum = key_column.attnum + order by key_column.ordinal + ) as "parentColumns", + constraint_row.convalidated as validated, + constraint_row.confupdtype as "updateAction", + constraint_row.confdeltype as "deleteAction" + from pg_constraint constraint_row + join tenant_tables child on child.oid = constraint_row.conrelid + join tenant_tables parent on parent.oid = constraint_row.confrelid + where constraint_row.contype = 'f' + and not exists ( + select 1 + from unnest(constraint_row.conkey) key_column(attnum) + join pg_attribute attribute + on attribute.attrelid = constraint_row.conrelid + and attribute.attnum = key_column.attnum + where attribute.attname = 'tenant_id' + ) + order by child.relname, constraint_row.conname +`; + +function textArray(value) { + if (Array.isArray(value)) return value.map(item => String(item)); + if (typeof value !== 'string' || value.length < 2) return []; + return value.slice(1, -1).split(',').filter(Boolean).map(item => item.replace(/^"|"$/g, '')); +} + +export function normalizeTenantForeignKeyRelation(row) { + return { + childTable: String(row.childTable || row.child_table || ''), + constraintName: String(row.constraintName || row.constraint_name || ''), + childColumns: textArray(row.childColumns || row.child_columns), + parentTable: String(row.parentTable || row.parent_table || ''), + parentColumns: textArray(row.parentColumns || row.parent_columns), + validated: row.validated === true, + updateAction: String(row.updateAction || row.update_action || ''), + deleteAction: String(row.deleteAction || row.delete_action || ''), + }; +} + +export function tenantForeignKeyRelationKey(relation) { + return `${relation.childTable}.${relation.constraintName}`; +} + +export function tenantForeignKeyRelationCanonical(relation) { + return [ + relation.childTable, + relation.constraintName, + relation.childColumns.join(','), + relation.parentTable, + relation.parentColumns.join(','), + relation.validated ? 'validated' : 'not-valid', + `update:${relation.updateAction}`, + `delete:${relation.deleteAction}`, + ].join('|'); +} + +export function tenantForeignKeySchemaSha256(relations) { + const canonical = relations + .map(normalizeTenantForeignKeyRelation) + .sort((left, right) => tenantForeignKeyRelationKey(left).localeCompare(tenantForeignKeyRelationKey(right))) + .map(tenantForeignKeyRelationCanonical) + .join('\n'); + return crypto.createHash('sha256').update(canonical).digest('hex'); +} + +function exceptionFor(relation) { + const key = tenantForeignKeyRelationKey(relation); + const exception = TENANT_FOREIGN_KEY_EXCEPTIONS.get(key); + if (!exception) return null; + if ( + relation.childColumns.length !== 1 + || relation.parentColumns.length !== 1 + || relation.childColumns[0] !== exception.childColumn + || relation.parentTable !== exception.parentTable + || relation.parentColumns[0] !== exception.parentColumn + ) { + throw new Error(`Tenant foreign key exception definition no longer matches ${key}`); + } + return exception; +} + +export function summarizeTenantForeignKeySchema(inputRelations) { + const relations = inputRelations.map(normalizeTenantForeignKeyRelation); + const keys = new Set(relations.map(tenantForeignKeyRelationKey)); + const missingExceptions = [...TENANT_FOREIGN_KEY_EXCEPTIONS.keys()].filter(key => !keys.has(key)); + const exceptionRelations = relations.filter(relation => exceptionFor(relation)); + const unvalidatedRelations = relations + .filter(relation => !relation.validated) + .map(tenantForeignKeyRelationKey); + const schemaSha256 = tenantForeignKeySchemaSha256(relations); + const schemaMatches = relations.length === EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT + && schemaSha256 === EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256 + && missingExceptions.length === 0 + && unvalidatedRelations.length === 0; + return { + relationCount: relations.length, + expectedRelationCount: EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT, + schemaSha256, + expectedSchemaSha256: EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256, + schemaMatches, + exceptionCount: exceptionRelations.length, + expectedExceptionCount: TENANT_FOREIGN_KEY_EXCEPTIONS.size, + missingExceptions, + unvalidatedRelations, + }; +} + +export async function loadTenantForeignKeyRelations(queryable) { + const result = await queryable.query(RELATION_QUERY); + return result.rows.map(normalizeTenantForeignKeyRelation); +} + +function quoteIdentifier(value) { + return `"${String(value).replaceAll('"', '""')}"`; +} + +function quoteLiteral(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function violationPredicate(relation) { + const exception = exceptionFor(relation); + if (!exception) return 'child.tenant_id is distinct from parent.tenant_id'; + if (exception.mode === 'global-or-same-tenant-parent') { + return 'parent.tenant_id is not null and child.tenant_id is distinct from parent.tenant_id'; + } + if (exception.mode === 'platform-source-or-same-tenant-parent') { + return "child.tenant_id is distinct from parent.tenant_id and parent.source_scope is distinct from 'platform'"; + } + throw new Error(`Unsupported tenant foreign key exception mode: ${exception.mode}`); +} + +export function buildTenantForeignKeyViolationQuery(inputRelations) { + const relations = inputRelations.map(normalizeTenantForeignKeyRelation); + if (relations.length === 0) { + return `select null::text as "relationKey", null::text as "childTenantId", null::text as "parentTenantId" where false`; + } + return relations.map(relation => { + if (relation.childColumns.length !== relation.parentColumns.length || relation.childColumns.length === 0) { + throw new Error(`Invalid tenant foreign key shape: ${tenantForeignKeyRelationKey(relation)}`); + } + const join = relation.childColumns.map((childColumn, index) => ( + `parent.${quoteIdentifier(relation.parentColumns[index])} = child.${quoteIdentifier(childColumn)}` + )).join(' and '); + return `( + select ${quoteLiteral(tenantForeignKeyRelationKey(relation))}::text as "relationKey", + child.tenant_id::text as "childTenantId", + parent.tenant_id::text as "parentTenantId" + from public.${quoteIdentifier(relation.childTable)} child + join public.${quoteIdentifier(relation.parentTable)} parent on ${join} + where ${violationPredicate(relation)} + limit 1 + )`; + }).join('\nunion all\n'); +} + +export async function auditTenantForeignKeyData(client, inputRelations, statementTimeoutMs = 120_000) { + const relations = inputRelations.map(normalizeTenantForeignKeyRelation); + const timeout = Math.max(1_000, Math.min(900_000, Number(statementTimeoutMs) || 120_000)); + await client.query('begin read only'); + try { + await client.query(`set local statement_timeout = '${timeout}ms'`); + await client.query(`set local lock_timeout = '5s'`); + const result = await client.query(buildTenantForeignKeyViolationQuery(relations)); + await client.query('commit'); + return result.rows; + } catch (error) { + await client.query('rollback').catch(() => undefined); + throw error; + } +} + +export function tenantForeignKeyExceptions() { + return [...TENANT_FOREIGN_KEY_EXCEPTIONS.entries()].map(([relationKey, definition]) => ({ + relationKey, + ...definition, + })); +} diff --git a/scripts/platform-usage-overage-worker-integration-test.js b/scripts/platform-usage-overage-worker-integration-test.js index 6b93d93a..e7fe19d7 100644 --- a/scripts/platform-usage-overage-worker-integration-test.js +++ b/scripts/platform-usage-overage-worker-integration-test.js @@ -86,7 +86,7 @@ function runAuditAlertWorkerOnce() { env: { ...process.env, DATABASE_URL: databaseUrl, - WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE: '20', + WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE: '1000', WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS: '30', WORKER_PLATFORM_AUDIT_ALERT_ID: 'platform-usage-overage-alert-test', }, diff --git a/scripts/product-scope-guardrails-test.js b/scripts/product-scope-guardrails-test.js index 7cabe814..40ee18ca 100644 --- a/scripts/product-scope-guardrails-test.js +++ b/scripts/product-scope-guardrails-test.js @@ -58,9 +58,9 @@ for (const item of lines) { if ( /smoke:taro:h5:interaction/.test(text) && - hasAny(text, [/26 项/, /租户后台六个主模块/, /平台后台四个主模块/, /租户题库内容\/财务、平台租户\/账务中心/]) + hasAny(text, [/26 项/, /32 项/, /租户后台六个主模块/, /平台后台四个主模块/, /租户题库内容\/财务、平台租户\/账务中心/]) ) { - failures.push(`${item.file}:${item.line} H5 交互烟测已经覆盖 32 项和后台真实写操作,不能回退到旧描述:${text}`); + failures.push(`${item.file}:${item.line} H5 交互烟测已经覆盖 33 项、运行时竞态探针和后台真实写操作,不能回退到旧描述:${text}`); } } diff --git a/scripts/production-config-failfast-test.js b/scripts/production-config-failfast-test.js index f17adf75..405cb64c 100644 --- a/scripts/production-config-failfast-test.js +++ b/scripts/production-config-failfast-test.js @@ -27,6 +27,7 @@ const safeBaseEnv = { const safeApiEnv = { ...safeBaseEnv, CORS_ORIGIN: 'https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com', + CORS_TENANT_DOMAINS_ENABLED: 'true', AUTH_SMS_PROVIDER: 'aliyun-pnvs', AUTH_CODE_PEPPER: 's3cure-prod-code-pepper-2026-06-30-abcdef', AUTH_SESSION_SECRET: 's3cure-prod-session-secret-2026-06-30-ghijkl', @@ -73,6 +74,28 @@ assert.match( 'API config should require PNVS for production SMS', ); +const unsafeApiTenantCorsDisabled = runImport(apiConfigUrl, { + ...safeApiEnv, + CORS_TENANT_DOMAINS_ENABLED: 'false', +}); +assert.notEqual(unsafeApiTenantCorsDisabled.status, 0, 'production API config should require dynamic tenant CORS'); +assert.match( + unsafeApiTenantCorsDisabled.output, + /CORS_TENANT_DOMAINS_ENABLED must be true in production/, + 'API config should fail closed when dynamic tenant CORS is disabled', +); + +const unsafeApiCorsPath = runImport(apiConfigUrl, { + ...safeApiEnv, + CORS_ORIGIN: 'https://platform-admin.gongxue100.com/app', +}); +assert.notEqual(unsafeApiCorsPath.status, 0, 'production API config should reject non-Origin CORS URLs'); +assert.match( + unsafeApiCorsPath.output, + /CORS_ORIGIN must contain only production HTTPS origins without paths/, + 'API config should reject CORS entries with URL paths', +); + const unsafeApiTraditionalSmsProvider = runImport(apiConfigUrl, { ...safeApiEnv, AUTH_SMS_PROVIDER: 'aliyun', diff --git a/scripts/production-launch-gate-test.js b/scripts/production-launch-gate-test.js index 9a037cdf..f3d1f6d6 100644 --- a/scripts/production-launch-gate-test.js +++ b/scripts/production-launch-gate-test.js @@ -3,7 +3,16 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { gateChecks, requiredAttestations } from './production-launch-gate.js'; +import crypto from 'node:crypto'; +import { + gateChecks, + parseArgs, + productionUrlFailure, + requiredAttestations, + validateEvidence, + weappAttestations, + weappGateChecks, +} from './production-launch-gate.js'; const repoRoot = process.cwd(); const scriptPath = path.join(repoRoot, 'scripts', 'production-launch-gate.js'); @@ -38,23 +47,189 @@ function sampleSummary(summarySpec) { return result; } +function nestedSummaryPayload(summary) { + return { summary }; +} + +function pocketBaseDryRunPayload(summary) { + return { + migrationProfile: summary.migrationProfile, + summary: { + blockers: summary.blocker, + warnings: summary.warning, + }, + migrationReadiness: { + requiredCollections: Array.from({ length: 3 }, (_, index) => ({ collection: `required-${index}`, present: true })), + criticalFieldCoverage: Array.from({ length: 3 }, (_, index) => ({ collection: `coverage-${index}`, present: true })), + }, + }; +} + +function productionEvidencePayload(spec, summary) { + if (spec.id === 'db.migration-history') return summary; + if (spec.id === 'auth.platform-admin-bootstrap') { + const { identityMatches: _identityMatches, ...artifact } = summary; + return artifact; + } + if (spec.id === 'backup.restore-drill') return summary; + if (spec.id === 'taro.supply-chain') { + const reviewedEdges = [ + { parent: '@tarojs/components', dependency: 'swiper', declared: '11.1.15' }, + { parent: '@tarojs/components-react', dependency: 'swiper', declared: '11.1.15' }, + { parent: '@tarojs/plugin-platform-h5', dependency: 'lodash-es', declared: '4.17.21' }, + { parent: '@tarojs/taro-h5', dependency: 'lodash-es', declared: '4.17.21' }, + ]; + return { + schemaVersion: summary.schemaVersion, + status: summary.status, + securedBundleDependencies: summary.securedBundleDependencies, + npmLs: { + edges: reviewedEdges, + }, + audit: { counts: summary.audit.counts }, + riskBoundary: { + appliesTo: 'Taro 4.2.0 CLI and build toolchain', + controls: Array.from({ length: summary.riskControlCount }, (_, index) => `control-${index}`), + }, + }; + } + return null; +} + +function jsonArtifactPayload(spec, summary) { + const productionPayload = productionEvidencePayload(spec, summary); + if (productionPayload) return productionPayload; + if (spec.id === 'readiness.production.env' || spec.id === 'readiness.production.db') return nestedSummaryPayload(summary); + if (spec.id === 'postgres.tuning-evidence' || spec.id === 'auth.sms-pnvs-diagnostics' || spec.id === 'auth.sms-pnvs-remote-smoke') return summary; + if (spec.id === 'migration.pb-production-dry-run') return pocketBaseDryRunPayload(summary); + if (spec.id === 'api.launch-persona-smoke') return summary; + if (spec.id === 'api.dynamic-tenant-cors-smoke' || spec.id === 'db.tenant-foreign-key-audit') return summary; + if (spec.id === 'taro.h5-static-smoke' || spec.id === 'taro.h5-release-guardrails' || spec.id === 'security.repo-scan') return nestedSummaryPayload(summary); + if (spec.id === 'taro.h5-interaction-smoke') return { summary: { fail: summary.fail, pass: summary.pass }, mockApi: summary.mockApi }; + if (spec.id === 'taro.h5-release-manifest') return { ...nestedSummaryPayload(summary), portals: [] }; + if (spec.id === 'taro.weapp-release-guardrails') return nestedSummaryPayload(summary); + return null; +} + +function writeCheckArtifact(spec, summary, artifactPath) { + const jsonPayload = jsonArtifactPayload(spec, summary); + if (jsonPayload) { + fs.writeFileSync(artifactPath, `${JSON.stringify(jsonPayload, null, 2)}\n`, 'utf8'); + return; + } + fs.writeFileSync(artifactPath, `TIKU_LAUNCH_GATE_SUCCESS:${spec.id}\n`, 'utf8'); +} + +function rewriteArtifact(tempDir, item, payload) { + const artifactPath = path.join(tempDir, item.artifact); + fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'); +} + function createEvidence(tempDir, overrides = {}) { const artifactDir = path.join(tempDir, 'launch-artifacts'); fs.mkdirSync(artifactDir, { recursive: true }); const checks = gateChecks.map(spec => { const artifact = `launch-artifacts/${spec.id}.log`; - fs.writeFileSync(path.join(tempDir, artifact), `[PASS] ${spec.id}\n`, 'utf8'); + const artifactPath = path.join(tempDir, artifact); + const summary = sampleSummary(spec.summary); + if (spec.id === 'db.migration-history') { + const migrationFiles = fs.readdirSync(path.join(repoRoot, 'supabase', 'migrations')) + .filter(file => file.endsWith('.sql')) + .sort(); + const latestMigration = /^(\d+)_/.exec(migrationFiles.at(-1) || '')?.[1]; + summary.latestRepositoryMigration = latestMigration; + summary.latestAppliedMigration = latestMigration; + } + if (spec.id === 'auth.platform-admin-bootstrap') { + const identityHash = crypto.createHash('sha256').update('platform-admin-auth-user').digest('hex'); + summary.adminUserIdSha256 = identityHash; + summary.authSmokeExpectedUserIdSha256 = identityHash; + summary.dryRunArtifactSha256 = crypto.createHash('sha256').update('platform-admin-dry-run-artifact').digest('hex'); + summary.applyArtifactSha256 = crypto.createHash('sha256').update('platform-admin-apply-artifact').digest('hex'); + summary.authSmokeArtifactSha256 = crypto.createHash('sha256').update('platform-admin-auth-smoke-artifact').digest('hex'); + summary.auditArtifactSha256 = crypto.createHash('sha256').update('platform-admin-audit-artifact').digest('hex'); + } + if (spec.id === 'backup.restore-drill') { + summary.verificationArtifactSha256 = crypto.createHash('sha256').update('backup-restore-verification-artifact').digest('hex'); + } + if (spec.id === 'taro.h5-release-manifest') { + const releaseRoot = path.join(tempDir, 'candidate'); + const portalDirs = [ + ['student', 'apps/taro/dist/h5-student'], + ['tenant-admin', 'apps/taro/dist/h5-tenant-admin'], + ['platform-admin', 'apps/taro/dist/h5-platform-admin'], + ]; + const portals = portalDirs.map(([portal, relativeDir]) => { + const dir = path.join(releaseRoot, relativeDir); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'index.html'), `
${portal}
\n`, 'utf8'); + const content = fs.readFileSync(path.join(dir, 'index.html')); + const treeHash = crypto.createHash('sha256') + .update('index.html\0') + .update(String(content.length)) + .update('\0') + .update(content) + .update('\0') + .digest('hex'); + return { portal, dist: { treeSha256: treeHash } }; + }); + fs.writeFileSync(artifactPath, `${JSON.stringify({ summary, portals }, null, 2)}\n`, 'utf8'); + } else if (spec.id === 'performance.tenant-students-100k') { + const caseItem = (id, p95, indexes) => ({ + id, + latencyMs: { p95 }, + explain: { summary: { indexes } }, + }); + fs.writeFileSync(artifactPath, `${JSON.stringify({ + schemaVersion: 1, + kind: 'tenant-student-capacity', + safety: { databaseEnvironment: summary.databaseEnvironment }, + fixture: summary.fixture, + config: { deepCursorApproximateOffset: summary.deepCursorApproximateOffset }, + cases: [ + caseItem('first-page', summary.firstPageP95Ms, ['idx_memberships_student_keyset_page']), + caseItem('deep-cursor', summary.deepCursorP95Ms, ['idx_memberships_student_keyset_page']), + caseItem('name-substring', summary.searchP95MaxMs, ['idx_platform_users_identity_search_trgm']), + caseItem('phone-substring', summary.searchP95MaxMs, ['idx_platform_users_identity_search_trgm']), + caseItem('email-substring', summary.searchP95MaxMs, ['idx_platform_users_identity_search_trgm']), + ], + cleanup: { + cleanupVerified: summary.cleanupVerified, + remaining: { tenants: 0, platformUsers: 0, memberships: 0, profiles: 0 }, + }, + }, null, 2)}\n`, 'utf8'); + } else { + writeCheckArtifact(spec, summary, artifactPath); + } return { id: spec.id, status: 'pass', command: `npm run ${spec.commandIncludes} -- recorded-for-launch-gate`, completedAt: isoNow(), artifact, - summary: sampleSummary(spec.summary), + artifactSha256: crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'), + summary, }; }); + const readinessDbCheck = checks.find(item => item.id === 'readiness.production.db'); + const migrationHistoryCheck = checks.find(item => item.id === 'db.migration-history'); + const readinessDbArtifactPath = path.join(tempDir, readinessDbCheck.artifact); + migrationHistoryCheck.summary.readinessArtifactSha256 = crypto.createHash('sha256') + .update(fs.readFileSync(readinessDbArtifactPath)) + .digest('hex'); + const migrationHistoryArtifactPath = path.join(tempDir, migrationHistoryCheck.artifact); + writeCheckArtifact( + gateChecks.find(spec => spec.id === migrationHistoryCheck.id), + migrationHistoryCheck.summary, + migrationHistoryArtifactPath, + ); + migrationHistoryCheck.artifactSha256 = crypto.createHash('sha256') + .update(fs.readFileSync(migrationHistoryArtifactPath)) + .digest('hex'); + const attestations = requiredAttestations.map(spec => ({ id: spec.id, status: 'approved', @@ -66,6 +241,7 @@ function createEvidence(tempDir, overrides = {}) { return { schemaVersion: 1, environment: 'production', + releaseTargets: ['h5'], commit: '52cef9fabcd1234567890abcdef1234567890abc', target: { apiBaseUrl: 'https://api.gongxue100.com', @@ -83,6 +259,9 @@ function runGate(evidence, options = {}) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-')); const evidencePath = path.join(tempDir, 'evidence.json'); const finalEvidence = typeof evidence === 'function' ? evidence(tempDir) : evidence; + const deployReleaseRoot = options.deployReleaseRoot === '__TEMP_CANDIDATE__' + ? path.join(tempDir, 'candidate') + : (options.deployReleaseRoot || ''); fs.writeFileSync(evidencePath, JSON.stringify(finalEvidence, null, 2), 'utf8'); const result = spawnSync(process.execPath, [scriptPath, '--evidence', evidencePath, '--json', ...(options.args || [])], { @@ -95,6 +274,8 @@ function runGate(evidence, options = {}) { ComSpec: process.env.ComSpec || '', TEMP: process.env.TEMP || os.tmpdir(), TMP: process.env.TMP || os.tmpdir(), + DEPLOY_COMMIT_SHA: options.deployCommitSha || '', + DEPLOY_RELEASE_ROOT: deployReleaseRoot, }, }); @@ -103,10 +284,472 @@ function runGate(evidence, options = {}) { return { ...result, payload }; } +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function liveH5Fixture(tempDir) { + const portalContents = { + student: { + targetKey: 'studentH5Url', + baseUrl: 'https://student.gongxue100.com', + appPath: '/js/app.student.js', + app: 'console.log("student");\n', + }, + 'tenant-admin': { + targetKey: 'tenantAdminH5Url', + baseUrl: 'https://admin.gongxue100.com', + appPath: '/js/app.tenant.js', + app: 'console.log("tenant-admin");\n', + }, + 'platform-admin': { + targetKey: 'platformAdminH5Url', + baseUrl: 'https://console.gongxue100.com', + appPath: '/js/app.platform.js', + app: 'console.log("platform-admin");\n', + }, + }; + const evidence = createEvidence(tempDir); + evidence.liveH5 = { portals: [] }; + const responses = new Map(); + + for (const [portal, fixture] of Object.entries(portalContents)) { + const index = `
`; + const runtime = `${JSON.stringify({ portal, apiBaseUrl: evidence.target.apiBaseUrl })}\n`; + evidence.target[fixture.targetKey] = fixture.baseUrl; + evidence.liveH5.portals.push({ + portal, + indexSha256: sha256(index), + appPath: fixture.appPath, + appSha256: sha256(fixture.app), + }); + responses.set(`${fixture.baseUrl}/index.html`, { body: index, contentType: 'text/html; charset=utf-8' }); + responses.set(`${fixture.baseUrl}/runtime-config.json`, { body: runtime, contentType: 'application/json' }); + responses.set(`${fixture.baseUrl}${fixture.appPath}`, { body: fixture.app, contentType: 'application/javascript' }); + } + + return { evidence, responses }; +} + +function liveH5ManifestFixture(tempDir) { + const fixture = liveH5Fixture(tempDir); + const candidateRoot = path.join(tempDir, 'candidate'); + const manifest = { + schemaVersion: 1, + portals: [], + }; + + for (const portalItem of fixture.evidence.liveH5.portals) { + const portalDir = path.join( + candidateRoot, + 'apps', + 'taro', + 'dist', + portalItem.portal === 'student' ? 'h5-student' : `h5-${portalItem.portal}`, + ); + const baseUrl = portalItem.portal === 'student' + ? 'https://student.gongxue100.com' + : portalItem.portal === 'tenant-admin' + ? 'https://admin.gongxue100.com' + : 'https://console.gongxue100.com'; + const index = fixture.responses.get(`${baseUrl}/index.html`).body; + const app = fixture.responses.get(`${baseUrl}${portalItem.appPath}`).body; + const appFile = path.join(portalDir, `.${portalItem.appPath}`); + fs.mkdirSync(path.dirname(appFile), { recursive: true }); + fs.writeFileSync(path.join(portalDir, 'index.html'), index, 'utf8'); + fs.writeFileSync(appFile, app, 'utf8'); + manifest.portals.push({ + portal: portalItem.portal, + dist: { + dir: '../../must-not-be-used', + indexSha256: sha256(index), + }, + }); + } + + const manifestPath = path.join(tempDir, 'launch-artifacts', 'taro-h5-release-manifest.json'); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + fixture.evidence.liveH5 = { + releaseManifestArtifact: path.relative(tempDir, manifestPath), + releaseManifestSha256: sha256(fs.readFileSync(manifestPath)), + }; + return fixture; +} + +function fixtureFetch(responses) { + return async url => { + const key = String(url); + const item = responses.get(key); + if (!item) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } }); + return new Response(item.body, { + status: 200, + headers: { 'content-type': item.contentType }, + }); + }; +} + +function removeTempDir(tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); +} + const safe = runGate(tempDir => createEvidence(tempDir)); assert.equal(safe.status, 0, `complete launch evidence should pass: ${safe.stdout} ${safe.stderr}`); assert.equal(safe.payload.summary?.blocker, 0, 'complete launch evidence should have no blockers'); +const evidenceTemplate = JSON.parse(fs.readFileSync(path.join(repoRoot, 'docs', 'refactor', 'production-launch-evidence.template.json'), 'utf8')); +const templateCheckIds = evidenceTemplate.checks.map(item => item.id); +const coreGateCheckIds = gateChecks.map(item => item.id); +assert.deepEqual(templateCheckIds, coreGateCheckIds, 'template core check IDs must exactly match gateChecks in order'); +assert.equal(new Set(templateCheckIds).size, templateCheckIds.length, 'template core check IDs must be unique'); +assert.ok(coreGateCheckIds.includes('worker.platform-billing'), 'platform billing worker must be a core launch check'); +assert.ok(coreGateCheckIds.includes('worker.platform-dunning'), 'platform dunning worker must be a core launch check'); +for (const id of ['db.migration-history', 'auth.platform-admin-bootstrap', 'backup.restore-drill', 'taro.supply-chain']) { + assert.ok(coreGateCheckIds.includes(id), `${id} must be a core launch check`); +} +for (const item of evidenceTemplate.checks) { + const spec = gateChecks.find(candidate => candidate.id === item.id); + if (typeof spec?.artifactSummary === 'function') continue; + assert.ok( + String(item.command || '').includes(`TIKU_LAUNCH_GATE_SUCCESS:${item.id}`), + `log-backed template check ${item.id} must append its explicit success sentinel`, + ); +} +assert.equal( + evidenceTemplate.checks.filter(item => item.artifact && !item.artifactSha256).length, + 0, + 'every template check with an artifact must include artifactSha256', +); +assert.ok(evidenceTemplate.liveH5?.releaseManifestSha256, 'template must document the strict live H5 release manifest hash'); + +for (const rejectedUrl of [ + 'http://api.gongxue100.com', + 'https://localhost', + 'https://127.0.0.1', + 'https://portal.test', + 'https://portal.example', + 'https://portal.example.com', + 'https://replace-with-domain.invalid', + 'https://replace-with-real-domain.com', +]) { + assert.ok(productionUrlFailure(rejectedUrl), `production URL validation must reject ${rejectedUrl}`); +} +assert.equal(productionUrlFailure('https://student.gongxue100.com'), '', 'real HTTPS production URL should be accepted'); +assert.equal(parseArgs(['node', 'gate', '--verify-live-h5']).verifyLiveH5, true, 'CLI should explicitly enable strict live H5 validation'); +assert.equal( + parseArgs(['node', 'gate', '--verify-live-h5', '--no-verify-live-h5']).verifyLiveH5, + false, + 'CLI should allow deployment wrappers to explicitly disable inherited live validation', +); + +const placeholderTarget = runGate(tempDir => { + const evidence = createEvidence(tempDir); + evidence.target.studentH5Url = 'https://student.example.com'; + return evidence; +}); +assert.notEqual(placeholderTarget.status, 0, 'placeholder target URL should fail launch gate'); +assert.ok(placeholderTarget.payload.checks?.some(item => item.id === 'target.studentH5Url' && item.status === 'blocker')); + +{ + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-live-')); + try { + const fixture = liveH5Fixture(tempDir); + const checks = await validateEvidence(fixture.evidence, { + evidencePath: path.join(tempDir, 'evidence.json'), + allowStale: false, + maxAgeDays: 14, + verifyLiveH5: true, + liveTimeoutMs: 5_000, + fetchImpl: fixtureFetch(fixture.responses), + deployReleaseRoot: path.join(tempDir, 'candidate'), + }); + assert.equal(checks.some(item => item.status === 'blocker'), false, JSON.stringify(checks.filter(item => item.status === 'blocker'), null, 2)); + assert.equal(checks.filter(item => /^live_h5\..+\.app_hash$/.test(item.id) && item.status === 'pass').length, 3); + } finally { + removeTempDir(tempDir); + } +} + +{ + const tempDir = fs.mkdtempSync(path.join(repoRoot, '.tmp-launch-gate-manifest-')); + try { + const fixture = liveH5ManifestFixture(tempDir); + const checks = await validateEvidence(fixture.evidence, { + evidencePath: path.join(tempDir, 'evidence.json'), + allowStale: false, + maxAgeDays: 14, + verifyLiveH5: true, + liveTimeoutMs: 5_000, + fetchImpl: fixtureFetch(fixture.responses), + deployReleaseRoot: path.join(tempDir, 'candidate'), + }); + assert.equal(checks.some(item => item.status === 'blocker'), false, JSON.stringify(checks.filter(item => item.status === 'blocker'), null, 2)); + assert.equal(checks.filter(item => /^live_h5\..+\.candidate_files$/.test(item.id) && item.status === 'pass').length, 3); + } finally { + removeTempDir(tempDir); + } +} + +{ + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-live-')); + try { + const fixture = liveH5Fixture(tempDir); + const studentApp = fixture.evidence.liveH5.portals.find(item => item.portal === 'student'); + fixture.responses.set('https://student.gongxue100.com/js/app.student.js', { + body: 'console.log("stale-production-bundle");\n', + contentType: 'application/javascript', + }); + const checks = await validateEvidence(fixture.evidence, { + evidencePath: path.join(tempDir, 'evidence.json'), + allowStale: false, + maxAgeDays: 14, + verifyLiveH5: true, + liveTimeoutMs: 5_000, + fetchImpl: fixtureFetch(fixture.responses), + }); + assert.ok(studentApp.appSha256); + assert.ok(checks.some(item => item.id === 'live_h5.student.app_hash' && item.status === 'blocker')); + } finally { + removeTempDir(tempDir); + } +} + +{ + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-live-')); + try { + const fixture = liveH5Fixture(tempDir); + fixture.responses.set('https://admin.gongxue100.com/runtime-config.json', { + body: JSON.stringify({ portal: 'student', apiBaseUrl: 'https://api.other-domain.com' }), + contentType: 'application/json', + }); + const checks = await validateEvidence(fixture.evidence, { + evidencePath: path.join(tempDir, 'evidence.json'), + allowStale: false, + maxAgeDays: 14, + verifyLiveH5: true, + liveTimeoutMs: 5_000, + fetchImpl: fixtureFetch(fixture.responses), + }); + assert.ok(checks.some(item => item.id === 'live_h5.tenant-admin.runtime_portal' && item.status === 'blocker')); + assert.ok(checks.some(item => item.id === 'live_h5.tenant-admin.runtime_api' && item.status === 'blocker')); + } finally { + removeTempDir(tempDir); + } +} + +const wrongCommit = runGate(tempDir => createEvidence(tempDir), { + deployCommitSha: 'aaaaaaaaaaaa', + deployReleaseRoot: path.join(os.tmpdir(), 'missing-release'), +}); +assert.notEqual(wrongCommit.status, 0, 'evidence for another commit should fail launch gate'); +assert.ok(wrongCommit.payload.checks?.some(item => item.id === 'evidence.commit_match' && item.status === 'blocker')); + +const matchingCandidate = runGate(tempDir => createEvidence(tempDir), { + deployCommitSha: '52cef9fabcd1', + deployReleaseRoot: '', +}); +assert.notEqual(matchingCandidate.status, 0, 'deployment gate without a candidate release root should fail'); +assert.ok(matchingCandidate.payload.checks?.some(item => item.id === 'evidence.release_root' && item.status === 'blocker')); + +const candidateTreeMatch = runGate(tempDir => createEvidence(tempDir), { + deployCommitSha: '52cef9fabcd1', + deployReleaseRoot: '__TEMP_CANDIDATE__', +}); +assert.equal(candidateTreeMatch.status, 0, `matching candidate release should pass: ${candidateTreeMatch.stdout}`); + +const candidateTreeMismatch = runGate(tempDir => { + const evidence = createEvidence(tempDir); + fs.appendFileSync(path.join(tempDir, 'candidate/apps/taro/dist/h5-student/index.html'), 'tampered\n', 'utf8'); + return evidence; +}, { + deployCommitSha: '52cef9fabcd1', + deployReleaseRoot: '__TEMP_CANDIDATE__', +}); +assert.notEqual(candidateTreeMismatch.status, 0, 'candidate release that differs from the manifest should fail'); +assert.ok(candidateTreeMismatch.payload.checks?.some(item => item.id === 'check.taro.h5-release-manifest.release_tree' && item.status === 'blocker')); + +const tamperedArtifact = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'auth.remote-smoke'); + fs.appendFileSync(path.join(tempDir, item.artifact), 'tampered\n', 'utf8'); + return evidence; +}); +assert.notEqual(tamperedArtifact.status, 0, 'tampered artifact should fail launch gate'); +assert.ok(tamperedArtifact.payload.checks?.some(item => item.id === 'check.auth.remote-smoke.artifact_hash' && item.status === 'blocker')); + +const missingArtifactHash = runGate(tempDir => { + const evidence = createEvidence(tempDir); + delete evidence.checks.find(check => check.id === 'auth.remote-smoke').artifactSha256; + return evidence; +}); +assert.notEqual(missingArtifactHash.status, 0, 'missing artifact hash should fail launch gate'); +assert.ok(missingArtifactHash.payload.checks?.some(item => item.id === 'check.auth.remote-smoke.artifact_hash' && item.status === 'blocker')); + +const forgedPassLog = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'worker.platform-billing'); + const artifactPath = path.join(tempDir, item.artifact); + fs.writeFileSync(artifactPath, '[PASS] worker.platform-billing\n', 'utf8'); + item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'); + return evidence; +}); +assert.notEqual(forgedPassLog.status, 0, 'a forged generic PASS log must not satisfy a log-backed check'); +assert.ok( + forgedPassLog.payload.checks?.some(item => item.id === 'check.worker.platform-billing.artifact_success' && item.status === 'blocker'), + 'missing check-specific log success sentinel must be reported as a blocker', +); + +const forgedJsonSummary = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'readiness.production.env'); + const artifactPath = path.join(tempDir, item.artifact); + const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8')); + payload.summary.blocker = 1; + fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'); + return evidence; +}); +assert.notEqual(forgedJsonSummary.status, 0, 'evidence summary must not override a failing structured artifact'); +assert.ok( + forgedJsonSummary.payload.checks?.some(item => item.id === 'check.readiness.production.env.artifact_summary' && item.status === 'blocker'), + 'structured artifact mismatch must be reported as a blocker', +); + +const invalidJsonArtifact = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'readiness.production.db'); + const artifactPath = path.join(tempDir, item.artifact); + fs.writeFileSync(artifactPath, '{not-json}\n', 'utf8'); + item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'); + return evidence; +}); +assert.notEqual(invalidJsonArtifact.status, 0, 'invalid JSON artifact must not satisfy a structured check'); +assert.ok( + invalidJsonArtifact.payload.checks?.some(item => item.id === 'check.readiness.production.db.artifact_json' && item.status === 'blocker'), + 'invalid structured artifact must be reported as a JSON blocker', +); + +const unverifiedJsonSummaryField = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'readiness.production.env'); + item.summary.operatorAssertion = 'pass'; + return evidence; +}); +assert.notEqual(unverifiedJsonSummaryField.status, 0, 'structured checks must reject summary fields that are not derived from the artifact gate contract'); +assert.ok( + unverifiedJsonSummaryField.payload.checks?.some(item => item.id === 'check.readiness.production.env.artifact_summary' && item.status === 'blocker'), + 'unverified structured summary fields must be reported as artifact summary blockers', +); + +const staleMigrationArtifact = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'db.migration-history'); + const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary); + payload.latestAppliedMigration = '202607120018'; + rewriteArtifact(tempDir, item, payload); + return evidence; +}); +assert.notEqual(staleMigrationArtifact.status, 0, 'migration evidence older than the repository latest migration must fail'); +assert.ok(staleMigrationArtifact.payload.checks?.some(item => item.id === 'check.db.migration-history.artifact_summary' && item.status === 'blocker')); + +const forgedMigrationReadinessHash = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'db.migration-history'); + const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary); + payload.readinessArtifactSha256 = 'not-a-sha256'; + rewriteArtifact(tempDir, item, payload); + return evidence; +}); +assert.notEqual(forgedMigrationReadinessHash.status, 0, 'migration evidence must bind the readiness artifact by SHA-256'); +assert.ok(forgedMigrationReadinessHash.payload.checks?.some(item => item.id === 'check.db.migration-history.artifact_summary' && item.status === 'blocker')); + +const mismatchedMigrationReadinessArtifact = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'readiness.production.db'); + const artifactPath = path.join(tempDir, item.artifact); + fs.appendFileSync(artifactPath, '\nchanged-after-migration-summary\n', 'utf8'); + item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'); + return evidence; +}); +assert.notEqual(mismatchedMigrationReadinessArtifact.status, 0, 'migration summary must fail when the bound readiness artifact changes'); +assert.ok(mismatchedMigrationReadinessArtifact.payload.checks?.some(item => item.id === 'check.db.migration-history.artifact_summary' && item.status === 'blocker')); + +const mismatchedPlatformAdminIdentity = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'auth.platform-admin-bootstrap'); + const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary); + payload.authSmokeExpectedUserIdSha256 = crypto.createHash('sha256').update('different-auth-user').digest('hex'); + rewriteArtifact(tempDir, item, payload); + return evidence; +}); +assert.notEqual(mismatchedPlatformAdminIdentity.status, 0, 'platform admin bootstrap and Auth smoke identities must match'); +assert.ok(mismatchedPlatformAdminIdentity.payload.checks?.some(item => item.id === 'check.auth.platform-admin-bootstrap.artifact_summary' && item.status === 'blocker')); + +const unverifiedRestoreDrill = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'backup.restore-drill'); + const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary); + payload.integrityVerified = false; + rewriteArtifact(tempDir, item, payload); + return evidence; +}); +assert.notEqual(unverifiedRestoreDrill.status, 0, 'restore drill without verified integrity must fail'); +assert.ok(unverifiedRestoreDrill.payload.checks?.some(item => item.id === 'check.backup.restore-drill.artifact_summary' && item.status === 'blocker')); + +const forgedRestoreVerificationHash = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'backup.restore-drill'); + const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary); + payload.verificationArtifactSha256 = 'not-a-sha256'; + rewriteArtifact(tempDir, item, payload); + return evidence; +}); +assert.notEqual(forgedRestoreVerificationHash.status, 0, 'restore drill must bind its verification output by SHA-256'); +assert.ok(forgedRestoreVerificationHash.payload.checks?.some(item => item.id === 'check.backup.restore-drill.artifact_summary' && item.status === 'blocker')); + +const regressedTaroSupplyChain = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'taro.supply-chain'); + const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary); + payload.securedBundleDependencies.swiper = '11.1.15'; + payload.audit.counts.critical = 4; + rewriteArtifact(tempDir, item, payload); + return evidence; +}); +assert.notEqual(regressedTaroSupplyChain.status, 0, 'Taro supply-chain bundle or vulnerability regression must fail'); +assert.ok(regressedTaroSupplyChain.payload.checks?.some(item => item.id === 'check.taro.supply-chain.artifact_summary' && item.status === 'blocker')); + +const missingWeappEvidence = runGate(tempDir => createEvidence(tempDir, { releaseTargets: ['h5', 'weapp'] })); +assert.notEqual(missingWeappEvidence.status, 0, 'WeApp release target without production evidence should fail launch gate'); +assert.ok(missingWeappEvidence.payload.checks?.some(item => item.id === 'check.taro.build.weapp-student' && item.status === 'blocker')); + +const completeWeappEvidence = runGate(tempDir => { + const evidence = createEvidence(tempDir, { releaseTargets: ['h5', 'weapp'] }); + for (const spec of weappGateChecks) { + const artifact = `launch-artifacts/${spec.id}.log`; + const artifactPath = path.join(tempDir, artifact); + const summary = sampleSummary(spec.summary); + writeCheckArtifact(spec, summary, artifactPath); + evidence.checks.push({ + id: spec.id, + status: 'pass', + command: `npm run ${spec.commandIncludes}`, + completedAt: isoNow(), + artifact, + artifactSha256: crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'), + summary, + }); + } + evidence.attestations.push(...weappAttestations.map(spec => ({ + id: spec.id, + status: 'approved', + approver: 'test-owner', + approvedAt: isoNow(), + notes: spec.label, + }))); + return evidence; +}); +assert.equal(completeWeappEvidence.status, 0, `complete WeApp evidence should pass: ${completeWeappEvidence.stdout}`); + const missingArtifact = runGate(tempDir => { const evidence = createEvidence(tempDir); const item = evidence.checks.find(check => check.id === 'auth.remote-smoke'); @@ -146,6 +789,7 @@ const wrongSmsPnvsDiagnostics = runGate(tempDir => { const item = evidence.checks.find(check => check.id === 'auth.sms-pnvs-diagnostics'); item.summary.env.providerMatchesPnvs = false; item.summary.provider.provider = 'aliyun'; + rewriteArtifact(tempDir, item, item.summary); return evidence; }); assert.notEqual(wrongSmsPnvsDiagnostics.status, 0, 'non-PNVS diagnostics should fail launch gate'); @@ -158,6 +802,7 @@ const wrongSmsProviderSmoke = runGate(tempDir => { const evidence = createEvidence(tempDir); const item = evidence.checks.find(check => check.id === 'auth.sms-pnvs-remote-smoke'); item.summary.provider = 'aliyun'; + rewriteArtifact(tempDir, item, item.summary); return evidence; }); assert.notEqual(wrongSmsProviderSmoke.status, 0, 'non-PNVS SMS smoke should fail launch gate'); @@ -170,6 +815,7 @@ const wrongMigrationProfile = runGate(tempDir => { const evidence = createEvidence(tempDir); const item = evidence.checks.find(check => check.id === 'migration.pb-production-dry-run'); item.summary.migrationProfile = 'development'; + rewriteArtifact(tempDir, item, pocketBaseDryRunPayload(item.summary)); return evidence; }); assert.notEqual(wrongMigrationProfile.status, 0, 'development dry-run evidence should fail launch gate'); @@ -224,6 +870,54 @@ assert.ok( 'slow mixed benchmark should be reported as a blocker', ); +const capacityCleanupMismatch = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'performance.tenant-students-100k'); + const artifactPath = path.join(tempDir, item.artifact); + const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8')); + payload.cleanup.remaining.platformUsers = 1; + fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'); + return evidence; +}); +assert.notEqual(capacityCleanupMismatch.status, 0, 'capacity artifact with managed rows after cleanup should fail'); +assert.ok( + capacityCleanupMismatch.payload.checks?.some(item => item.id === 'check.performance.tenant-students-100k.artifact_summary' && item.status === 'blocker'), + 'capacity cleanup mismatch should be reported from the artifact', +); + +const tenantForeignKeyArtifactMismatch = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'db.tenant-foreign-key-audit'); + const artifactPath = path.join(tempDir, item.artifact); + const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8')); + payload.data.invalidRelations = 1; + fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'); + return evidence; +}); +assert.notEqual(tenantForeignKeyArtifactMismatch.status, 0, 'tenant foreign key artifact with invalid data relations should fail'); +assert.ok( + tenantForeignKeyArtifactMismatch.payload.checks?.some(item => item.id === 'check.db.tenant-foreign-key-audit.artifact_summary' && item.status === 'blocker'), + 'tenant foreign key mismatch must be reported from the raw artifact', +); + +const corsArtifactMismatch = runGate(tempDir => { + const evidence = createEvidence(tempDir); + const item = evidence.checks.find(check => check.id === 'api.dynamic-tenant-cors-smoke'); + const artifactPath = path.join(tempDir, item.artifact); + const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8')); + payload.unknownOriginDenied = false; + fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'); + return evidence; +}); +assert.notEqual(corsArtifactMismatch.status, 0, 'dynamic CORS artifact mismatch should fail'); +assert.ok( + corsArtifactMismatch.payload.checks?.some(item => item.id === 'check.api.dynamic-tenant-cors-smoke.artifact_summary' && item.status === 'blocker'), + 'dynamic CORS mismatch should be reported from the artifact', +); + const missingLaunchPersonaSmoke = runGate(tempDir => { const evidence = createEvidence(tempDir); evidence.checks = evidence.checks.filter(item => item.id !== 'api.launch-persona-smoke'); @@ -239,6 +933,7 @@ const launchPersonaWithoutSvip = runGate(tempDir => { const evidence = createEvidence(tempDir); const item = evidence.checks.find(check => check.id === 'api.launch-persona-smoke'); item.summary.student.result.entitlement.isSvip = false; + rewriteArtifact(tempDir, item, item.summary); return evidence; }); assert.notEqual(launchPersonaWithoutSvip.status, 0, 'launch persona smoke without SVIP should fail launch gate'); @@ -251,6 +946,7 @@ const launchPersonaLegacyAuth = runGate(tempDir => { const evidence = createEvidence(tempDir); const item = evidence.checks.find(check => check.id === 'api.launch-persona-smoke'); item.summary.authMode = 'legacy'; + rewriteArtifact(tempDir, item, item.summary); return evidence; }); assert.notEqual(launchPersonaLegacyAuth.status, 0, 'launch persona smoke with legacy auth should fail launch gate'); @@ -296,6 +992,7 @@ const oldTaroInteractionCoverage = runGate(tempDir => { const evidence = createEvidence(tempDir); const item = evidence.checks.find(check => check.id === 'taro.h5-interaction-smoke'); item.summary.pass = 26; + rewriteArtifact(tempDir, item, jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary)); return evidence; }); assert.notEqual(oldTaroInteractionCoverage.status, 0, 'old 26-check H5 interaction evidence should fail launch gate'); @@ -308,6 +1005,7 @@ const missingAdminWriteCoverage = runGate(tempDir => { const evidence = createEvidence(tempDir); const item = evidence.checks.find(check => check.id === 'taro.h5-interaction-smoke'); item.summary.mockApi.keyRequests.platformAdminWrites = 0; + rewriteArtifact(tempDir, item, jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary)); return evidence; }); assert.notEqual(missingAdminWriteCoverage.status, 0, 'H5 interaction evidence without platform admin writes should fail launch gate'); diff --git a/scripts/production-launch-gate.js b/scripts/production-launch-gate.js index 571bbc0f..f029893a 100644 --- a/scripts/production-launch-gate.js +++ b/scripts/production-launch-gate.js @@ -1,29 +1,56 @@ import fs from 'node:fs'; +import crypto from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { hashArtifactDirectory } from './release-artifact-hash.js'; const defaultEvidencePath = path.resolve(process.cwd(), 'docs/refactor/production-launch-evidence.json'); const defaultMaxAgeDays = 14; +const defaultLiveTimeoutMs = 10_000; + +const h5PortalTargets = [ + { portal: 'student', targetKey: 'studentH5Url' }, + { portal: 'tenant-admin', targetKey: 'tenantAdminH5Url' }, + { portal: 'platform-admin', targetKey: 'platformAdminH5Url' }, +]; const gateChecks = [ { id: 'readiness.production.env', label: 'Production environment readiness', commandIncludes: 'readiness:production', + artifactSummary: nestedJsonSummaryArtifact, summary: { blocker: 0 }, }, { id: 'readiness.production.db', label: 'Production database readiness', commandIncludes: 'readiness:production:db', + artifactSummary: nestedJsonSummaryArtifact, summary: { blocker: 0 }, }, + { + id: 'db.migration-history', + label: 'Production database migration history', + commandIncludes: 'db.migrations.current', + artifactSummary: migrationHistoryArtifactSummary, + summary: { + status: 'pass', + failed: 0, + schemaVersion: 1, + latestRepositoryMigration: { truthy: true }, + latestAppliedMigration: { truthy: true }, + missingMigrations: [], + readinessArtifactSha256: { truthy: true }, + }, + }, { id: 'postgres.tuning-evidence', label: 'PostgreSQL 4c16g tuning evidence', commandIncludes: 'perf:postgres:evidence', + artifactSummary: directJsonArtifactSummary, summary: { status: 'pass', profile: { oneOf: ['shared-host', 'dedicated-db'] }, @@ -38,10 +65,33 @@ const gateChecks = [ commandIncludes: 'smoke:auth:remote', summary: { failed: 0, requireAdminTokens: true }, }, + { + id: 'auth.platform-admin-bootstrap', + label: 'First platform administrator bootstrap and Auth identity binding', + commandIncludes: 'bootstrap:platform-admin', + artifactSummary: platformAdminBootstrapArtifactSummary, + summary: { + status: 'pass', + failed: 0, + schemaVersion: 1, + dryRunVerified: true, + applied: true, + adminUserIdSha256: { truthy: true }, + authSmokeExpectedUserIdSha256: { truthy: true }, + identityMatches: true, + auditEvent: 'platform.admin.bootstrapped', + auditVerified: true, + dryRunArtifactSha256: { truthy: true }, + applyArtifactSha256: { truthy: true }, + authSmokeArtifactSha256: { truthy: true }, + auditArtifactSha256: { truthy: true }, + }, + }, { id: 'auth.sms-pnvs-diagnostics', label: 'Aliyun PNVS SMS provider diagnostics', commandIncludes: 'diagnose:aliyun-pnvs', + artifactSummary: directJsonArtifactSummary, summary: { ok: true, 'env.providerMatchesPnvs': true, @@ -58,6 +108,7 @@ const gateChecks = [ id: 'auth.sms-pnvs-remote-smoke', label: 'Remote Aliyun PNVS SMS login smoke', commandIncludes: 'smoke:sms-login:remote', + artifactSummary: directJsonArtifactSummary, summary: { failed: 0, provider: 'aliyun-pnvs', @@ -71,10 +122,27 @@ const gateChecks = [ commandIncludes: 'test:rls', summary: { failed: 0 }, }, + { + id: 'db.tenant-foreign-key-audit', + label: 'Tenant foreign key full-data integrity audit', + commandIncludes: 'audit:tenant-foreign-keys', + artifactSummary: directJsonArtifactSummary, + summary: { + status: 'pass', + kind: 'tenant-foreign-key-audit', + 'safety.databaseEnvironment': { oneOf: ['local', 'test', 'ci'] }, + 'schema.schemaMatches': true, + 'schema.relationCount': { gte: 189 }, + 'schema.exceptionCount': 3, + 'data.auditedRelations': { gte: 189 }, + 'data.invalidRelations': 0, + }, + }, { id: 'migration.pb-production-dry-run', label: 'PocketBase production dry-run', commandIncludes: 'pb:import:dry-run', + artifactSummary: pocketBaseDryRunArtifactSummary, summary: { blocker: 0, warning: 0, @@ -123,10 +191,75 @@ const gateChecks = [ includeWrites: true, }, }, + { + id: 'performance.tenant-students-100k', + label: 'Single-tenant 100k student list capacity evidence', + commandIncludes: 'perf:tenant-students:evidence', + artifactSummary: tenantStudentCapacityArtifactSummary, + summary: { + databaseEnvironment: { oneOf: ['test', 'ci'] }, + 'fixture.platformUsers': { gte: 100000 }, + 'fixture.tenantMemberships': { gte: 100000 }, + 'fixture.studentProfiles': { gte: 100000 }, + caseCount: { gte: 5 }, + deepCursorApproximateOffset: { gte: 90000 }, + firstPageP95Ms: { lte: 100 }, + deepCursorP95Ms: { lte: 100 }, + searchP95MaxMs: { lte: 250 }, + keysetIndexUsed: true, + trigramIndexUsed: true, + cleanupVerified: true, + remainingManagedUsers: 0, + }, + }, + { + id: 'api.dynamic-tenant-cors-smoke', + label: 'Dynamic active-tenant CORS production smoke', + commandIncludes: 'smoke:tenant-cors:remote', + artifactSummary: directJsonArtifactSummary, + summary: { + failed: 0, + activeTenantOriginAllowed: true, + unknownOriginDenied: true, + disabledOriginDenied: true, + noOriginHealthAllowed: true, + }, + }, + { + id: 'deploy.linux-systemd-verify', + label: 'Linux systemd API, worker and timer installation verification', + commandIncludes: 'systemd-analyze verify', + summary: { + failed: 0, + apiServiceActive: true, + workerTargetActive: true, + workerJobServices: { gte: 9 }, + enabledTimers: { gte: 6 }, + }, + }, + { + id: 'backup.restore-drill', + label: 'Isolated production backup restore drill', + commandIncludes: 'RESTORE_DRILL_VERIFY_COMMAND', + artifactSummary: backupRestoreDrillArtifactSummary, + summary: { + status: 'pass', + failed: 0, + schemaVersion: 1, + snapshotId: { truthy: true }, + restoreTarget: { truthy: true }, + isolated: true, + integrityVerified: true, + rtoMinutes: { gte: 0 }, + rpoMinutes: { gte: 0 }, + verificationArtifactSha256: { truthy: true }, + }, + }, { id: 'api.launch-persona-smoke', label: 'Real API launch persona journey smoke', commandIncludes: 'smoke:launch-persona', + artifactSummary: directJsonArtifactSummary, summary: { status: 'pass', authMode: 'app_session', @@ -161,6 +294,18 @@ const gateChecks = [ commandIncludes: 'test:worker:commerce', summary: { failed: 0 }, }, + { + id: 'worker.platform-billing', + label: 'Platform subscription billing worker regression', + commandIncludes: 'test:worker:platform-billing', + summary: { failed: 0 }, + }, + { + id: 'worker.platform-dunning', + label: 'Platform invoice dunning worker regression', + commandIncludes: 'test:worker:platform-dunning', + summary: { failed: 0 }, + }, { id: 'worker.imports', label: 'Async import worker regression', @@ -179,6 +324,23 @@ const gateChecks = [ commandIncludes: 'check:taro', summary: { failed: 0 }, }, + { + id: 'taro.supply-chain', + label: 'Taro secured bundle dependencies and reviewed toolchain risk', + commandIncludes: 'audit:taro:supply-chain', + artifactSummary: taroSupplyChainArtifactSummary, + summary: { + schemaVersion: 1, + status: 'pass-with-reviewed-toolchain-risk', + 'securedBundleDependencies.swiper': '12.1.2', + 'securedBundleDependencies.lodash-es': '4.18.1', + 'audit.counts.critical': { lte: 3 }, + 'audit.counts.high': { lte: 10 }, + reviewedInvalidEdgeCount: 4, + riskBoundaryPresent: true, + riskControlCount: { gte: 1 }, + }, + }, { id: 'taro.build.student', label: 'Student H5 build', @@ -201,6 +363,7 @@ const gateChecks = [ id: 'taro.h5-static-smoke', label: 'Taro H5 static startup smoke', commandIncludes: 'smoke:taro:h5', + artifactSummary: nestedJsonSummaryArtifact, summary: { fail: 0, portals: 3, @@ -211,6 +374,7 @@ const gateChecks = [ id: 'taro.h5-interaction-smoke', label: 'Taro H5 real browser interaction smoke', commandIncludes: 'smoke:taro:h5:interaction', + artifactSummary: h5InteractionArtifactSummary, summary: { fail: 0, pass: { gte: 32 }, @@ -231,6 +395,7 @@ const gateChecks = [ id: 'taro.h5-release-guardrails', label: 'Taro H5 release artifact guardrails', commandIncludes: 'taro-h5-release-guardrails-test.js', + artifactSummary: nestedJsonSummaryArtifact, summary: { fail: 0, warn: 0, @@ -240,12 +405,14 @@ const gateChecks = [ id: 'taro.h5-release-manifest', label: 'Taro H5 release deployment manifest', commandIncludes: 'manifest:taro:h5', + artifactSummary: nestedJsonSummaryArtifact, summary: { fail: 0, warn: 0, portals: 3, distReady: 3, runtimeConfigs: 3, + treeHashes: 3, }, }, { @@ -258,6 +425,7 @@ const gateChecks = [ id: 'security.repo-scan', label: 'Repository static security scan', commandIncludes: 'security:repo', + artifactSummary: nestedJsonSummaryArtifact, summary: { critical: 0, high: 0 }, }, { @@ -299,17 +467,50 @@ const requiredAttestations = [ }, ]; +const weappGateChecks = [ + { + id: 'taro.build.weapp-student', + label: 'Student production WeApp build', + commandIncludes: 'build:taro:weapp:student:production', + summary: { failed: 0 }, + }, + { + id: 'taro.weapp-release-guardrails', + label: 'Student WeApp release artifact guardrails', + commandIncludes: 'taro-weapp-release-guardrails.js --production', + artifactSummary: nestedJsonSummaryArtifact, + summary: { fail: 0, warn: 0 }, + }, +]; + +const weappAttestations = [ + { + id: 'frontend.weapp-devtools-device-review', + label: 'Student WeApp was verified in WeChat DevTools and on a real device', + }, +]; + +const h5ReleaseDirectories = new Map([ + ['student', 'apps/taro/dist/h5-student'], + ['tenant-admin', 'apps/taro/dist/h5-tenant-admin'], + ['platform-admin', 'apps/taro/dist/h5-platform-admin'], +]); + function parseArgs(argv) { const options = { evidencePath: defaultEvidencePath, json: false, maxAgeDays: defaultMaxAgeDays, allowStale: false, + verifyLiveH5: ['1', 'true', 'yes', 'on'].includes(String(process.env.LAUNCH_GATE_VERIFY_LIVE_H5 || '').trim().toLowerCase()), + liveTimeoutMs: Number(process.env.LAUNCH_GATE_LIVE_TIMEOUT_MS || defaultLiveTimeoutMs), }; for (let index = 2; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--json') options.json = true; else if (arg === '--allow-stale') options.allowStale = true; + else if (arg === '--verify-live-h5') options.verifyLiveH5 = true; + else if (arg === '--no-verify-live-h5') options.verifyLiveH5 = false; else if (arg === '--evidence') { options.evidencePath = path.resolve(process.cwd(), argv[index + 1] || ''); index += 1; @@ -320,9 +521,17 @@ function parseArgs(argv) { index += 1; } else if (arg.startsWith('--max-age-days=')) { options.maxAgeDays = Number(arg.slice('--max-age-days='.length)); + } else if (arg === '--live-timeout-ms') { + options.liveTimeoutMs = Number(argv[index + 1]); + index += 1; + } else if (arg.startsWith('--live-timeout-ms=')) { + options.liveTimeoutMs = Number(arg.slice('--live-timeout-ms='.length)); } } if (!Number.isFinite(options.maxAgeDays) || options.maxAgeDays <= 0) options.maxAgeDays = defaultMaxAgeDays; + if (!Number.isFinite(options.liveTimeoutMs) || options.liveTimeoutMs < 1_000 || options.liveTimeoutMs > 60_000) { + options.liveTimeoutMs = defaultLiveTimeoutMs; + } return options; } @@ -330,6 +539,81 @@ function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +function sha256File(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function sha256Buffer(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function productionUrlFailure(value) { + const text = String(value || '').trim(); + if (!text) return 'URL is missing'; + + let parsed; + try { + parsed = new URL(text); + } catch { + return 'URL is invalid'; + } + + if (parsed.protocol !== 'https:') return 'URL must use HTTPS'; + if (parsed.username || parsed.password) return 'URL must not include credentials'; + if (parsed.hash) return 'URL must not include a fragment'; + if (/[<>]|replace(?:-with)?|placeholder|your[-_. ]?(?:domain|host|url)/i.test(text)) { + return 'URL contains a placeholder value'; + } + + const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + const placeholderHost = + hostname === 'example.com' + || hostname.endsWith('.example.com') + || hostname === 'example' + || hostname.endsWith('.example') + || hostname.endsWith('.test') + || hostname.endsWith('.invalid') + || /(^|\.)(?:replace(?:-with)?|placeholder|your-domain|your-host)(?:\.|$)/i.test(hostname); + if (placeholderHost) return 'URL uses a placeholder or reserved hostname'; + + const localHost = + hostname === 'localhost' + || hostname.endsWith('.localhost') + || hostname === '::1' + || hostname === '0.0.0.0' + || hostname.startsWith('127.'); + if (localHost) return 'URL must not resolve to a local development hostname'; + + return ''; +} + +function normalizedUrl(value) { + const parsed = new URL(String(value || '').trim()); + parsed.hash = ''; + parsed.search = ''; + return parsed.href.replace(/\/+$/, ''); +} + +function portalEvidence(items, portal) { + if (Array.isArray(items)) return items.find(item => item?.portal === portal) || null; + if (items && typeof items === 'object') return items[portal] || null; + return null; +} + +function appAssetPathFromIndex(indexHtml) { + const sources = [...String(indexHtml || '').matchAll(/]*\bsrc=["']([^"']+)["'][^>]*>/gi)].map(match => match[1]); + return sources.find(source => /(?:^|\/)app(?:\.[^/]+)?\.js(?:\?|$)/i.test(source)) || sources.at(-1) || ''; +} + +function normalizeAssetPath(value, baseUrl) { + const resolved = new URL(String(value || ''), baseUrl); + return `${resolved.pathname}${resolved.search}`; +} + +function validateHash(value) { + return /^[0-9a-f]{64}$/i.test(String(value || '')); +} + function normalizeStatus(value) { return String(value || '').trim().toLowerCase(); } @@ -387,6 +671,300 @@ function compareSummary(actualSummary, expectedSummary) { return failures; } +function directJsonArtifactSummary(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('artifact must contain a JSON object'); + } + return payload; +} + +function nestedJsonSummaryArtifact(payload) { + const artifact = directJsonArtifactSummary(payload); + return directJsonArtifactSummary(artifact.summary); +} + +function pocketBaseDryRunArtifactSummary(payload) { + const artifact = directJsonArtifactSummary(payload); + const summary = directJsonArtifactSummary(artifact.summary); + const requiredCollections = artifact.migrationReadiness?.requiredCollections; + const criticalFieldCoverage = artifact.migrationReadiness?.criticalFieldCoverage; + if (!Array.isArray(requiredCollections) || !Array.isArray(criticalFieldCoverage)) { + throw new Error('artifact must contain migrationReadiness requiredCollections and criticalFieldCoverage arrays'); + } + return { + blocker: Number(summary.blockers), + warning: Number(summary.warnings), + migrationProfile: artifact.migrationProfile, + requiredCollectionsMissing: requiredCollections.filter(item => item?.present !== true).length, + criticalFieldCoverageWarnings: criticalFieldCoverage.filter(item => item?.present !== true).length, + }; +} + +function h5InteractionArtifactSummary(payload) { + const artifact = directJsonArtifactSummary(payload); + const summary = directJsonArtifactSummary(artifact.summary); + return { + ...summary, + mockApi: artifact.mockApi, + }; +} + +function latestRepositoryMigrationVersion() { + const migrationsDir = path.resolve(process.cwd(), 'supabase', 'migrations'); + if (!fs.existsSync(migrationsDir)) throw new Error(`repository migration directory is missing: ${migrationsDir}`); + const versions = fs.readdirSync(migrationsDir) + .filter(file => file.endsWith('.sql')) + .map(file => /^(\d+)_.*\.sql$/.exec(file)?.[1] || '') + .filter(Boolean) + .sort((left, right) => left.length - right.length || left.localeCompare(right)); + if (versions.length === 0) throw new Error('repository contains no numeric Supabase migrations'); + return versions.at(-1); +} + +function migrationHistoryArtifactSummary(payload, context = {}) { + const artifact = directJsonArtifactSummary(payload); + const latestRepositoryMigration = String(artifact.latestRepositoryMigration || '').trim(); + const latestAppliedMigration = String(artifact.latestAppliedMigration || '').trim(); + const repositoryLatest = latestRepositoryMigrationVersion(); + if (latestRepositoryMigration !== repositoryLatest) { + throw new Error(`artifact repository migration ${latestRepositoryMigration || '(missing)'} does not match ${repositoryLatest}`); + } + if (!/^\d+$/.test(latestAppliedMigration) || compareMigrationVersion(latestAppliedMigration, repositoryLatest) < 0) { + throw new Error(`artifact applied migration ${latestAppliedMigration || '(missing)'} is older than ${repositoryLatest}`); + } + if (!Array.isArray(artifact.missingMigrations)) throw new Error('artifact missingMigrations must be an array'); + const readinessArtifactSha256 = requireSha256(artifact.readinessArtifactSha256, 'readinessArtifactSha256'); + const readinessEvidence = findById(context.evidence?.checks, 'readiness.production.db'); + if (!readinessEvidence?.artifact) throw new Error('readiness.production.db artifact reference is missing'); + const readinessArtifactPath = resolveArtifact(context.options?.evidencePath || '', readinessEvidence.artifact); + if (!fs.existsSync(readinessArtifactPath) || !fs.statSync(readinessArtifactPath).isFile()) { + throw new Error('readiness.production.db artifact file is missing'); + } + const actualReadinessSha256 = sha256File(readinessArtifactPath); + if (readinessArtifactSha256 !== actualReadinessSha256) { + throw new Error('readinessArtifactSha256 does not match the readiness.production.db artifact'); + } + return { + status: artifact.status, + failed: Number(artifact.failed), + schemaVersion: Number(artifact.schemaVersion), + latestRepositoryMigration, + latestAppliedMigration, + missingMigrations: artifact.missingMigrations, + readinessArtifactSha256, + }; +} + +function compareMigrationVersion(left, right) { + const normalizedLeft = String(left || '').replace(/^0+(?=\d)/, ''); + const normalizedRight = String(right || '').replace(/^0+(?=\d)/, ''); + if (normalizedLeft.length !== normalizedRight.length) return normalizedLeft.length > normalizedRight.length ? 1 : -1; + return normalizedLeft === normalizedRight ? 0 : normalizedLeft > normalizedRight ? 1 : -1; +} + +function requireSha256(value, field) { + const normalized = String(value || '').toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalized)) throw new Error(`${field} must be a SHA-256 value`); + return normalized; +} + +function platformAdminBootstrapArtifactSummary(payload) { + const artifact = directJsonArtifactSummary(payload); + const adminUserIdSha256 = requireSha256(artifact.adminUserIdSha256, 'adminUserIdSha256'); + const authSmokeExpectedUserIdSha256 = requireSha256( + artifact.authSmokeExpectedUserIdSha256, + 'authSmokeExpectedUserIdSha256', + ); + return { + status: artifact.status, + failed: Number(artifact.failed), + schemaVersion: Number(artifact.schemaVersion), + dryRunVerified: artifact.dryRunVerified === true, + applied: artifact.applied === true, + adminUserIdSha256, + authSmokeExpectedUserIdSha256, + identityMatches: adminUserIdSha256 === authSmokeExpectedUserIdSha256, + auditEvent: artifact.auditEvent, + auditVerified: artifact.auditVerified === true, + dryRunArtifactSha256: requireSha256(artifact.dryRunArtifactSha256, 'dryRunArtifactSha256'), + applyArtifactSha256: requireSha256(artifact.applyArtifactSha256, 'applyArtifactSha256'), + authSmokeArtifactSha256: requireSha256(artifact.authSmokeArtifactSha256, 'authSmokeArtifactSha256'), + auditArtifactSha256: requireSha256(artifact.auditArtifactSha256, 'auditArtifactSha256'), + }; +} + +function backupRestoreDrillArtifactSummary(payload) { + const artifact = directJsonArtifactSummary(payload); + const snapshotId = String(artifact.snapshotId || '').trim(); + const restoreTarget = String(artifact.restoreTarget || '').trim(); + if (!snapshotId || !restoreTarget) throw new Error('artifact must identify the snapshot and isolated restore target'); + const rtoMinutes = Number(artifact.rtoMinutes); + const rpoMinutes = Number(artifact.rpoMinutes); + if (!Number.isFinite(rtoMinutes) || !Number.isFinite(rpoMinutes)) { + throw new Error('artifact rtoMinutes and rpoMinutes must be numeric'); + } + return { + status: artifact.status, + failed: Number(artifact.failed), + schemaVersion: Number(artifact.schemaVersion), + snapshotId, + restoreTarget, + isolated: artifact.isolated === true, + integrityVerified: artifact.integrityVerified === true, + rtoMinutes, + rpoMinutes, + verificationArtifactSha256: requireSha256( + artifact.verificationArtifactSha256, + 'verificationArtifactSha256', + ), + }; +} + +function taroSupplyChainArtifactSummary(payload) { + const artifact = directJsonArtifactSummary(payload); + const securedBundleDependencies = directJsonArtifactSummary(artifact.securedBundleDependencies); + const audit = directJsonArtifactSummary(artifact.audit); + const counts = directJsonArtifactSummary(audit.counts); + const riskBoundary = directJsonArtifactSummary(artifact.riskBoundary); + if (!Array.isArray(artifact.npmLs?.edges)) throw new Error('artifact npmLs.edges must be an array'); + const expectedEdges = new Set([ + '@tarojs/components>swiper@11.1.15', + '@tarojs/components-react>swiper@11.1.15', + '@tarojs/plugin-platform-h5>lodash-es@4.17.21', + '@tarojs/taro-h5>lodash-es@4.17.21', + ]); + const actualEdges = new Set(artifact.npmLs.edges.map(edge => `${edge?.parent}>${edge?.dependency}@${edge?.declared}`)); + if (actualEdges.size !== expectedEdges.size || [...expectedEdges].some(edge => !actualEdges.has(edge))) { + throw new Error('artifact npmLs.edges does not match the four reviewed Taro exact-dependency edges'); + } + if (!String(riskBoundary.appliesTo || '').trim()) throw new Error('artifact riskBoundary.appliesTo is required'); + if (!Array.isArray(riskBoundary.controls)) throw new Error('artifact riskBoundary.controls must be an array'); + return { + schemaVersion: Number(artifact.schemaVersion), + status: artifact.status, + securedBundleDependencies, + audit: { counts }, + reviewedInvalidEdgeCount: actualEdges.size, + riskBoundaryPresent: true, + riskControlCount: riskBoundary.controls.filter(item => String(item || '').trim()).length, + }; +} + +function tenantStudentCapacityArtifactSummary(payload) { + if (payload?.schemaVersion !== 1 || payload?.kind !== 'tenant-student-capacity') { + throw new Error('artifact is not tenant-student-capacity schemaVersion=1'); + } + const cases = Array.isArray(payload.cases) ? payload.cases : []; + const byId = new Map(cases.map(item => [item?.id, item])); + const firstPage = byId.get('first-page'); + const deepCursor = byId.get('deep-cursor'); + const searchCases = ['name-substring', 'phone-substring', 'email-substring'].map(id => byId.get(id)); + if (!firstPage || !deepCursor || searchCases.some(item => !item)) { + throw new Error('artifact must contain first-page, deep-cursor and three substring search cases'); + } + const caseIndexes = item => Array.isArray(item?.explain?.summary?.indexes) ? item.explain.summary.indexes : []; + const searchP95Values = searchCases.map(item => Number(item?.latencyMs?.p95)); + if (searchP95Values.some(value => !Number.isFinite(value))) { + throw new Error('artifact search cases must contain numeric latencyMs.p95 values'); + } + const remaining = payload.cleanup?.remaining || {}; + const remainingManagedUsers = [remaining.tenants, remaining.platformUsers, remaining.memberships, remaining.profiles] + .reduce((sum, value) => sum + Number(value || 0), 0); + return { + databaseEnvironment: payload.safety?.databaseEnvironment || '', + fixture: payload.fixture || {}, + caseCount: cases.length, + deepCursorApproximateOffset: Number(payload.config?.deepCursorApproximateOffset || 0), + firstPageP95Ms: Number(firstPage?.latencyMs?.p95), + deepCursorP95Ms: Number(deepCursor?.latencyMs?.p95), + searchP95MaxMs: Math.max(...searchP95Values), + keysetIndexUsed: [firstPage, deepCursor].every(item => caseIndexes(item).includes('idx_memberships_student_keyset_page')), + trigramIndexUsed: searchCases.every(item => caseIndexes(item).includes('idx_platform_users_identity_search_trgm')), + cleanupVerified: payload.cleanup?.cleanupVerified === true, + remainingManagedUsers, + }; +} + +function validateStructuredArtifact(spec, item, artifactPath, evidence, options, collector) { + if (typeof spec.artifactSummary !== 'function') return; + let payload; + try { + payload = readJson(artifactPath); + } catch (error) { + collector.block(`check.${spec.id}.artifact_json`, `${spec.label} artifact must be valid JSON`, { + artifact: item.artifact, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + let derived; + try { + derived = spec.artifactSummary(payload, { evidence, options, item, artifactPath }); + } catch (error) { + collector.block(`check.${spec.id}.artifact_summary`, `${spec.label} artifact structure is invalid`, { + artifact: item.artifact, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + const gateFailures = compareSummary(derived, spec.summary); + const evidenceFailures = []; + for (const key of Object.keys(spec.summary || {})) { + if (!hasPath(item.summary || {}, key)) { + evidenceFailures.push(`${key} is missing from evidence summary`); + continue; + } + if (!hasPath(derived, key) || JSON.stringify(valueAt(item.summary, key)) !== JSON.stringify(valueAt(derived, key))) { + evidenceFailures.push(`${key} does not match the artifact`); + } + } + const comparablePaths = new Set(Object.keys(spec.summary || {})); + for (const key of leafPaths(item.summary || {})) { + if ([...comparablePaths].some(pathKey => key === pathKey || key.startsWith(`${pathKey}.`))) continue; + evidenceFailures.push(`${key} is not derived from an artifact-backed gate field`); + } + if (gateFailures.length || evidenceFailures.length) { + collector.block(`check.${spec.id}.artifact_summary`, `${spec.label} artifact does not satisfy or match evidence`, { + gateFailures, + evidenceFailures, + derived, + }); + } else { + collector.pass(`check.${spec.id}.artifact_summary`, `${spec.label} artifact directly satisfies and matches evidence`); + } +} + +function leafPaths(object, prefix = '') { + if (!object || typeof object !== 'object' || Array.isArray(object)) return prefix ? [prefix] : []; + const paths = []; + for (const [key, value] of Object.entries(object)) { + const current = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && !Array.isArray(value)) paths.push(...leafPaths(value, current)); + else paths.push(current); + } + return paths; +} + +function successSentinel(spec) { + return `TIKU_LAUNCH_GATE_SUCCESS:${spec.id}`; +} + +function validateLogArtifact(spec, item, artifactPath, collector) { + if (typeof spec.artifactSummary === 'function') return; + const sentinel = successSentinel(spec); + const content = fs.readFileSync(artifactPath, 'utf8'); + if (!content.split(/\r?\n/).some(line => line.trim() === sentinel)) { + collector.block(`check.${spec.id}.artifact_success`, `${spec.label} artifact is missing its explicit success sentinel`, { + artifact: item.artifact, + expectedSentinel: sentinel, + }); + } else { + collector.pass(`check.${spec.id}.artifact_success`, `${spec.label} artifact contains its explicit success sentinel`); + } +} + function isComparatorSpec(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; return ['eq', 'lt', 'lte', 'gt', 'gte', 'oneOf', 'truthy'].some(key => Object.prototype.hasOwnProperty.call(value, key)); @@ -394,7 +972,7 @@ function isComparatorSpec(value) { function compareExpectedValue(actualValue, expectedValue) { if (!isComparatorSpec(expectedValue)) { - if (actualValue !== expectedValue) { + if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) { return `expected ${JSON.stringify(expectedValue)} but got ${JSON.stringify(actualValue)}`; } return ''; @@ -466,14 +1044,402 @@ function validateTopLevel(evidence, collector) { collector.pass('evidence.commit', 'Deployment commit is recorded', { commit: evidence.commit }); } + const currentCommit = String(process.env.DEPLOY_COMMIT_SHA || '').trim(); + const deployReleaseRoot = String(process.env.DEPLOY_RELEASE_ROOT || '').trim(); + if (currentCommit) { + const evidenceCommit = String(evidence.commit || '').toLowerCase(); + const deployedCommit = currentCommit.toLowerCase(); + if (!evidenceCommit.startsWith(deployedCommit) && !deployedCommit.startsWith(evidenceCommit)) { + collector.block('evidence.commit_match', 'Evidence commit does not match the commit being deployed', { + evidenceCommit: evidence.commit, + deployedCommit: currentCommit, + }); + } else { + collector.pass('evidence.commit_match', 'Evidence commit matches the commit being deployed'); + } + if (!deployReleaseRoot) { + collector.block('evidence.release_root', 'Deployment gate must provide DEPLOY_RELEASE_ROOT for artifact verification'); + } else if (!fs.existsSync(deployReleaseRoot) || !fs.statSync(deployReleaseRoot).isDirectory()) { + collector.block('evidence.release_root', 'DEPLOY_RELEASE_ROOT must be an existing candidate release directory', { + deployReleaseRoot, + }); + } else { + collector.pass('evidence.release_root', 'Candidate release directory is available for artifact verification', { + deployReleaseRoot, + }); + } + } + const target = evidence.target || {}; const requiredTargets = ['apiBaseUrl', 'studentH5Url', 'tenantAdminH5Url', 'platformAdminH5Url']; for (const key of requiredTargets) { - const value = String(target[key] || ''); - if (!value.startsWith('https://')) { - collector.block(`target.${key}`, `${key} must be an HTTPS production URL`, { value }); + const value = String(target[key] || '').trim(); + const failure = productionUrlFailure(value); + if (failure) { + collector.block(`target.${key}`, `${key} must be a real HTTPS production URL`, { value, failure }); } else { - collector.pass(`target.${key}`, `${key} is HTTPS`); + collector.pass(`target.${key}`, `${key} is a valid production HTTPS URL`); + } + } + + const releaseTargets = Array.isArray(evidence.releaseTargets) ? evidence.releaseTargets : ['h5']; + const invalidTargets = releaseTargets.filter(item => !['h5', 'weapp'].includes(item)); + if (!releaseTargets.includes('h5') || invalidTargets.length) { + collector.block('evidence.release_targets', 'releaseTargets must include h5 and may optionally include weapp', { releaseTargets }); + } else { + collector.pass('evidence.release_targets', 'Release targets are explicit', { releaseTargets }); + } +} + +function validateH5ReleaseManifest(artifactPath, collector) { + const deployReleaseRoot = String(process.env.DEPLOY_RELEASE_ROOT || '').trim(); + if (!deployReleaseRoot) return; + + let manifest; + try { + manifest = readJson(artifactPath); + } catch (error) { + collector.block('check.taro.h5-release-manifest.release_tree', 'H5 release manifest artifact must be valid JSON', { + artifactPath, + error: error.message, + }); + return; + } + + const failures = []; + for (const [portal, relativeDir] of h5ReleaseDirectories) { + const portalManifest = (Array.isArray(manifest.portals) ? manifest.portals : []) + .find(item => item?.portal === portal); + const expectedSha256 = String(portalManifest?.dist?.treeSha256 || '').toLowerCase(); + const candidateDir = path.resolve(deployReleaseRoot, relativeDir); + if (!/^[0-9a-f]{64}$/.test(expectedSha256)) { + failures.push(`${portal}: manifest treeSha256 is missing or invalid`); + continue; + } + if (!fs.existsSync(candidateDir) || !fs.statSync(candidateDir).isDirectory()) { + failures.push(`${portal}: candidate directory is missing (${candidateDir})`); + continue; + } + const actual = hashArtifactDirectory(candidateDir); + if (actual.sha256 !== expectedSha256) { + failures.push(`${portal}: expected ${expectedSha256}, got ${actual.sha256}`); + } + } + + if (failures.length > 0) { + collector.block('check.taro.h5-release-manifest.release_tree', 'Candidate H5 files do not match the reviewed release manifest', { + failures, + }); + } else { + collector.pass('check.taro.h5-release-manifest.release_tree', 'Candidate H5 files match all reviewed release tree hashes'); + } +} + +async function fetchLiveResource(url, options) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.liveTimeoutMs); + try { + const fetchImpl = options.fetchImpl || fetch; + return await fetchImpl(url, { + redirect: 'follow', + signal: controller.signal, + headers: { + accept: '*/*', + 'cache-control': 'no-cache', + 'user-agent': 'tiku-production-launch-gate/1', + }, + }); + } finally { + clearTimeout(timeout); + } +} + +async function liveResponseBytes(url, options, collector, id) { + let response; + try { + response = await fetchLiveResource(url, options); + } catch (error) { + collector.block(id, 'Live H5 request failed', { url, error: error instanceof Error ? error.message : String(error) }); + return null; + } + + const finalUrl = response.url || url; + const finalUrlFailure = productionUrlFailure(finalUrl); + if (finalUrlFailure) { + collector.block(id, 'Live H5 request redirected to a non-production URL', { + requestedUrl: url, + finalUrl, + failure: finalUrlFailure, + }); + return null; + } + if (!response.ok) { + collector.block(id, 'Live H5 request returned a non-success status', { url, finalUrl, status: response.status }); + return null; + } + + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length === 0) { + collector.block(id, 'Live H5 response is empty', { url, finalUrl }); + return null; + } + collector.pass(id, 'Live H5 resource returned a non-empty success response', { + url, + finalUrl, + status: response.status, + bytes: bytes.length, + }); + return { bytes, response }; +} + +function releaseManifestPortals(evidence, options, collector) { + const liveH5 = evidence.liveH5 || {}; + if (!liveH5.releaseManifestArtifact) return null; + + const manifestPath = resolveArtifact(options.evidencePath, liveH5.releaseManifestArtifact); + if (!fs.existsSync(manifestPath) || !fs.statSync(manifestPath).isFile() || fs.statSync(manifestPath).size === 0) { + collector.block('live_h5.release_manifest', 'Live H5 release manifest artifact is missing or empty', { + artifact: liveH5.releaseManifestArtifact, + }); + return null; + } + if (!validateHash(liveH5.releaseManifestSha256)) { + collector.block('live_h5.release_manifest_hash', 'Live H5 release manifest must record artifact SHA-256', { + artifact: liveH5.releaseManifestArtifact, + }); + return null; + } + + const actualHash = sha256File(manifestPath); + if (actualHash !== String(liveH5.releaseManifestSha256).toLowerCase()) { + collector.block('live_h5.release_manifest_hash', 'Live H5 release manifest hash does not match evidence', { + artifact: liveH5.releaseManifestArtifact, + expectedSha256: liveH5.releaseManifestSha256, + actualSha256: actualHash, + }); + return null; + } + + let manifest; + try { + manifest = readJson(manifestPath); + } catch (error) { + collector.block('live_h5.release_manifest', 'Live H5 release manifest is not valid JSON', { + artifact: liveH5.releaseManifestArtifact, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.portals)) { + collector.block('live_h5.release_manifest', 'Live H5 release manifest has an unsupported structure', { + artifact: liveH5.releaseManifestArtifact, + }); + return null; + } + collector.pass('live_h5.release_manifest', 'Live H5 release manifest artifact is valid'); + collector.pass('live_h5.release_manifest_hash', 'Live H5 release manifest hash matches evidence'); + return manifest.portals; +} + +function expectedPortalHashes(evidence, manifestPortals, portal, options, collector) { + const direct = portalEvidence(evidence.liveH5?.portals, portal) || {}; + const manifestPortal = portalEvidence(manifestPortals, portal) || {}; + let indexSha256 = direct.indexSha256 || manifestPortal.dist?.indexSha256 || ''; + let appSha256 = direct.appSha256 || ''; + let appPath = direct.appPath || ''; + + if (manifestPortal.dist) { + const deployReleaseRoot = String(options.deployReleaseRoot || process.env.DEPLOY_RELEASE_ROOT || '').trim(); + const expectedRelativeDir = h5ReleaseDirectories.get(portal); + const candidateDir = expectedRelativeDir + ? path.resolve(deployReleaseRoot || process.cwd(), expectedRelativeDir) + : ''; + if (!candidateDir) { + collector.block(`live_h5.${portal}.candidate_files`, 'Live H5 portal has no fixed candidate directory mapping', { + portal, + }); + return null; + } + const candidateIndexPath = path.join(candidateDir, 'index.html'); + if (!fs.existsSync(candidateIndexPath) || !fs.statSync(candidateIndexPath).isFile()) { + collector.block(`live_h5.${portal}.candidate_files`, 'Release manifest candidate index is unavailable for strict live verification', { + candidateIndexPath, + }); + return null; + } + + const candidateIndexSha256 = sha256File(candidateIndexPath); + if (indexSha256 && candidateIndexSha256 !== String(indexSha256).toLowerCase()) { + collector.block(`live_h5.${portal}.candidate_files`, 'Release manifest index hash does not match the candidate file', { + candidateIndexPath, + expectedSha256: indexSha256, + actualSha256: candidateIndexSha256, + }); + return null; + } + indexSha256 = candidateIndexSha256; + + const candidateIndexHtml = fs.readFileSync(candidateIndexPath, 'utf8'); + const candidateAppPath = appAssetPathFromIndex(candidateIndexHtml); + if (!candidateAppPath) { + collector.block(`live_h5.${portal}.candidate_files`, 'Release manifest candidate index does not reference an app bundle', { + candidateIndexPath, + }); + return null; + } + const candidateAppUrl = new URL(candidateAppPath, 'https://candidate.invalid/index.html'); + const candidateAppFile = path.resolve(candidateDir, `.${candidateAppUrl.pathname}`); + const relativeCandidateApp = path.relative(candidateDir, candidateAppFile); + if (relativeCandidateApp.startsWith('..') || path.isAbsolute(relativeCandidateApp) || !fs.existsSync(candidateAppFile) || !fs.statSync(candidateAppFile).isFile()) { + collector.block(`live_h5.${portal}.candidate_files`, 'Release manifest candidate app bundle is unavailable', { + candidateAppFile, + }); + return null; + } + const candidateAppSha256 = sha256File(candidateAppFile); + if (appSha256 && candidateAppSha256 !== String(appSha256).toLowerCase()) { + collector.block(`live_h5.${portal}.candidate_files`, 'Direct app hash does not match the release manifest candidate bundle', { + candidateAppFile, + expectedSha256: appSha256, + actualSha256: candidateAppSha256, + }); + return null; + } + appPath = candidateAppPath; + appSha256 = candidateAppSha256; + collector.pass(`live_h5.${portal}.candidate_files`, 'Release manifest is bound to the local candidate index and app bundle', { + candidateIndexPath, + candidateAppFile, + }); + } + + if (!validateHash(indexSha256) && !validateHash(appSha256)) { + collector.block(`live_h5.${portal}.candidate_hash`, 'Live H5 evidence must record a candidate index or app SHA-256', { + portal, + source: manifestPortals ? 'release-manifest-or-direct' : 'direct', + }); + return null; + } + if (appSha256 && !validateHash(appSha256)) { + collector.block(`live_h5.${portal}.app_hash`, 'Live H5 appSha256 must be a SHA-256 value', { portal }); + return null; + } + if (indexSha256 && !validateHash(indexSha256)) { + collector.block(`live_h5.${portal}.index_hash`, 'Live H5 indexSha256 must be a SHA-256 value', { portal }); + return null; + } + return { + indexSha256: String(indexSha256).toLowerCase(), + appSha256: String(appSha256).toLowerCase(), + appPath, + }; +} + +async function validateLiveH5(evidence, options, collector) { + if (!options.verifyLiveH5) return; + + const target = evidence.target || {}; + const apiBaseUrl = String(target.apiBaseUrl || '').trim(); + if (productionUrlFailure(apiBaseUrl)) { + collector.block('live_h5.target', 'Strict live H5 validation requires valid production target URLs'); + return; + } + + const manifestPortals = releaseManifestPortals(evidence, options, collector); + for (const spec of h5PortalTargets) { + const baseUrl = String(target[spec.targetKey] || '').trim(); + if (productionUrlFailure(baseUrl)) { + collector.block(`live_h5.${spec.portal}.target`, 'Strict live H5 validation requires a valid portal URL', { baseUrl }); + continue; + } + + const expected = expectedPortalHashes(evidence, manifestPortals, spec.portal, options, collector); + const indexUrl = `${normalizedUrl(baseUrl)}/index.html`; + const runtimeUrl = `${normalizedUrl(baseUrl)}/runtime-config.json`; + const indexResult = await liveResponseBytes(indexUrl, options, collector, `live_h5.${spec.portal}.index_response`); + const runtimeResult = await liveResponseBytes(runtimeUrl, options, collector, `live_h5.${spec.portal}.runtime_response`); + if (!indexResult || !runtimeResult || !expected) continue; + + const indexContentType = indexResult.response.headers.get('content-type') || ''; + const indexHtml = indexResult.bytes.toString('utf8'); + if (!/text\/html/i.test(indexContentType) || !/ { + const message = error instanceof Error ? error.message : String(error); + if (process.argv.includes('--json')) { + console.log(JSON.stringify({ + summary: { blocker: 1, warn: 0, pass: 0 }, + checks: [{ status: 'blocker', id: 'gate.unhandled_error', message }], + }, null, 2)); + } else { + console.error(`Production launch gate failed: ${message}`); + } + process.exitCode = 1; + }); } -export { gateChecks, requiredAttestations, validateEvidence }; +export { + gateChecks, + h5PortalTargets, + parseArgs, + productionUrlFailure, + requiredAttestations, + validateEvidence, + weappGateChecks, + weappAttestations, +}; diff --git a/scripts/production-readiness-check-test.js b/scripts/production-readiness-check-test.js index 7f7e61d6..d7adf027 100644 --- a/scripts/production-readiness-check-test.js +++ b/scripts/production-readiness-check-test.js @@ -17,6 +17,29 @@ function runReadiness(envContent, options = {}) { fs.writeFileSync(fixtureFile, JSON.stringify({ rows: options.providerRows }, null, 2), 'utf8'); args.push('--provider-config-fixture', fixtureFile); } + if (options.tenantRows) { + const fixtureFile = path.join(tempDir, 'tenant-fixture.json'); + fs.writeFileSync(fixtureFile, JSON.stringify({ rows: options.tenantRows }, null, 2), 'utf8'); + args.push('--tenant-config-fixture', fixtureFile); + } + if (Object.prototype.hasOwnProperty.call(options, 'environmentSafetyRow')) { + const fixtureFile = path.join(tempDir, 'environment-safety-fixture.json'); + fs.writeFileSync( + fixtureFile, + JSON.stringify({ row: options.environmentSafetyRow }, null, 2), + 'utf8', + ); + args.push('--environment-safety-fixture', fixtureFile); + } + if (Object.prototype.hasOwnProperty.call(options, 'migrationHistoryRow')) { + const fixtureFile = path.join(tempDir, 'migration-history-fixture.json'); + fs.writeFileSync( + fixtureFile, + JSON.stringify({ row: options.migrationHistoryRow }, null, 2), + 'utf8', + ); + args.push('--migration-history-fixture', fixtureFile); + } const result = spawnSync(process.execPath, args, { cwd: repoRoot, @@ -36,6 +59,39 @@ function runReadiness(envContent, options = {}) { return { ...result, payload }; } +function productionEnv(overrides = '') { + return ` +NODE_ENV=production +${overrides} +DATABASE_URL=postgresql://tiku_api:prod_password@db.prod.internal:5432/tiku +DB_EXPECTED_RUNTIME_ROLE=tiku_api +CORS_ORIGIN=https://platform-admin.gongxue100.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 +AUTH_SMS_PROVIDER=aliyun-pnvs +AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef +AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl +AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json +AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1 +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +PLATFORM_ADMIN_API_KEY=s3cure-platform-admin-key-2026-06-29-mnopqr +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_READINESS_TEST_ONLY +ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-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-29-stuvwx +WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false +`; +} + const unsafe = runReadiness(` NODE_ENV=development DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres @@ -57,6 +113,10 @@ const unsupportedSmsProvider = runReadiness(` NODE_ENV=production DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku CORS_ORIGIN=https://student.gongxue100.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-production AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl @@ -88,6 +148,10 @@ const traditionalAliyunSmsProvider = runReadiness(` NODE_ENV=production DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku CORS_ORIGIN=https://student.gongxue100.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl @@ -123,6 +187,10 @@ 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 +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -150,10 +218,21 @@ WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false WORKER_CRM_BATCH_SIZE=20 +WORKER_CRM_POLL_INTERVAL_MS=10000 WORKER_COMMERCE_BATCH_SIZE=20 +WORKER_COMMERCE_POLL_INTERVAL_MS=30000 +WORKER_PROVIDER_BILL_POLL_INTERVAL_MS=60000 +WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS=30000 +WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS=30000 WORKER_ASSET_BATCH_SIZE=50 +WORKER_ASSET_POLL_INTERVAL_MS=30000 WORKER_IMPORT_BATCH_SIZE=5 +WORKER_IMPORT_POLL_INTERVAL_MS=10000 +WORKER_IMPORT_LEASE_SECONDS=120 +WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000 WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE=5 +WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS=60000 +WORKER_EXPORT_POLL_INTERVAL_MS=10000 `); assert.equal(safe.status, 0, `safe readiness should pass without blockers: ${safe.stdout} ${safe.stderr}`); @@ -163,10 +242,175 @@ assert.ok( 'env-only readiness should explicitly warn that DB checks are skipped', ); +const migrationFiles = fs.readdirSync(path.join(repoRoot, 'supabase', 'migrations')) + .filter(file => file.endsWith('.sql')) + .sort(); +const latestMigrationVersion = /^(\d+)_/.exec(migrationFiles.at(-1) || '')?.[1]; +assert.ok(latestMigrationVersion, 'repository must have a latest numeric Supabase migration'); + +const currentMigrationHistory = runReadiness(productionEnv(), { + migrationHistoryRow: { + latestVersion: latestMigrationVersion, + appliedCount: migrationFiles.length, + distinctVersionCount: migrationFiles.length, + expectedVersionApplied: true, + }, +}); +assert.equal(currentMigrationHistory.status, 0, 'current migration history fixture should pass'); +assert.ok( + currentMigrationHistory.payload.checks?.some(item => ( + item.id === 'db.migrations.current' && item.status === 'pass' + )), + 'readiness should pass migration history that includes the repository latest version', +); + +const staleMigrationHistory = runReadiness(productionEnv(), { + migrationHistoryRow: { + latestVersion: '202607120018', + appliedCount: migrationFiles.length - 1, + distinctVersionCount: migrationFiles.length - 1, + expectedVersionApplied: false, + }, +}); +assert.notEqual(staleMigrationHistory.status, 0, 'stale migration history must fail readiness'); +assert.ok( + staleMigrationHistory.payload.checks?.some(item => ( + item.id === 'db.migrations.current' && item.status === 'blocker' + )), + 'readiness should block a database missing the repository latest migration', +); + +const inconsistentMigrationHistory = runReadiness(productionEnv(), { + migrationHistoryRow: { + latestVersion: latestMigrationVersion, + appliedCount: migrationFiles.length + 1, + distinctVersionCount: migrationFiles.length, + expectedVersionApplied: true, + }, +}); +assert.notEqual(inconsistentMigrationHistory.status, 0, 'duplicate migration history must fail readiness'); + +const truncatedMigrationHistory = runReadiness(productionEnv(), { + migrationHistoryRow: { + latestVersion: latestMigrationVersion, + appliedCount: migrationFiles.length - 1, + distinctVersionCount: migrationFiles.length - 1, + expectedVersionApplied: true, + }, +}); +assert.notEqual( + truncatedMigrationHistory.status, + 0, + 'migration history shorter than the repository migration set must fail readiness', +); + +const dynamicTenantCorsDisabled = runReadiness(productionEnv('CORS_TENANT_DOMAINS_ENABLED=false')); +assert.notEqual(dynamicTenantCorsDisabled.status, 0, 'disabled tenant-domain CORS must fail production readiness'); +assert.ok( + dynamicTenantCorsDisabled.payload.checks?.some(item => ( + item.id === 'env.cors_tenant_domains_enabled' && item.status === 'blocker' + )), + 'readiness must require dynamic tenant-domain CORS in production', +); + +for (const [override, checkId] of [ + ['WORKER_IMPORT_LEASE_SECONDS=9', 'env.worker_import_lease_seconds'], + [ + 'WORKER_IMPORT_LEASE_SECONDS=60\nWORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000', + 'env.worker_import_heartbeat_interval_ms', + ], +]) { + const result = runReadiness(productionEnv(override)); + assert.notEqual(result.status, 0, `${checkId} should fail production readiness`); + assert.ok( + result.payload.checks?.some(item => item.id === checkId && item.status === 'blocker'), + `${checkId} should be reported as a blocker`, + ); +} + +for (const [key, unsafeValue] of [ + ['CORS_TENANT_DOMAIN_CACHE_TTL_MS', '999'], + ['CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS', '300001'], + ['CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES', '99'], +]) { + const result = runReadiness(productionEnv(`${key}=${unsafeValue}`)); + assert.notEqual(result.status, 0, `${key} outside the production range must fail readiness`); + assert.ok( + result.payload.checks?.some(item => ( + item.id === `env.${key.toLowerCase()}` && item.status === 'blocker' + )), + `readiness must block unsafe ${key}`, + ); +} + +const missingExpectedRole = runReadiness(productionEnv('DB_EXPECTED_RUNTIME_ROLE=')); +assert.equal( + missingExpectedRole.status, + 0, + 'env-only readiness should defer the authoritative runtime-role identity check to --check-db', +); +assert.ok( + missingExpectedRole.payload.checks?.some(item => ( + item.id === 'env.db_expected_runtime_role' && item.status === 'warn' + )), + 'env-only readiness should warn when DB_EXPECTED_RUNTIME_ROLE is absent', +); + +const mismatchedExpectedRole = runReadiness(productionEnv('DB_EXPECTED_RUNTIME_ROLE=tiku_worker')); +assert.notEqual(mismatchedExpectedRole.status, 0, 'DATABASE_URL role mismatch must fail readiness'); +assert.ok( + mismatchedExpectedRole.payload.checks?.some(item => ( + item.id === 'env.database_runtime_role' && item.status === 'blocker' + )), + 'readiness must block a DATABASE_URL username that differs from DB_EXPECTED_RUNTIME_ROLE', +); + +const invalidWorkerPollInterval = 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 +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 +AUTH_SMS_PROVIDER=aliyun-pnvs +AUTH_CODE_PEPPER=${strongSecretA} +AUTH_SESSION_SECRET=${strongSecretB} +AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json +AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1 +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +PLATFORM_ADMIN_API_KEY=${strongSecretC} +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_READINESS_TEST_ONLY +ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-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-29-stuvwx +WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false +WORKER_CRM_POLL_INTERVAL_MS=0 +`); + +assert.notEqual(invalidWorkerPollInterval.status, 0, 'invalid production worker poll interval should fail readiness'); +assert.ok( + invalidWorkerPollInterval.payload.checks?.some(item => ( + item.id === 'env.worker_crm_poll_interval_ms' && item.status === 'blocker' + )), + 'readiness should block invalid worker poll intervals', +); + const safeAliyunPnvs = 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 +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -200,6 +444,10 @@ const safeAliyunPnvsUnderscoreAlias = 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 +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun_pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -247,6 +495,10 @@ const pnvsTemplateParamWarning = 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 +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -299,6 +551,10 @@ const mismatchedSmsProviderFixture = 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 +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -350,6 +606,10 @@ const legacyTencentSmsProviderFixture = 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 +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -408,6 +668,10 @@ const unsafeProviderFixture = 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 +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -485,6 +749,10 @@ const missingJwksIssuer = runReadiness(` NODE_ENV=production DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku CORS_ORIGIN=https://student.gongxue100.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -517,6 +785,10 @@ const unsafePlatformAuditNotificationLocalhost = runReadiness(` NODE_ENV=production DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku CORS_ORIGIN=https://student.gongxue100.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -549,6 +821,10 @@ const unsafePlatformDunningNotificationLocalhost = runReadiness(` NODE_ENV=production DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku CORS_ORIGIN=https://student.gongxue100.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 AUTH_SMS_PROVIDER=aliyun-pnvs AUTH_CODE_PEPPER=${strongSecretA} AUTH_SESSION_SECRET=${strongSecretB} @@ -578,4 +854,157 @@ assert.ok( 'readiness should block platform dunning notification localhost mode in production', ); +const tenantConfigBaseEnv = ` +NODE_ENV=production +DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku +CORS_ORIGIN=https://student.gongxue100.com +CORS_TENANT_DOMAINS_ENABLED=true +CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000 +CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000 +CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000 +AUTH_SMS_PROVIDER=aliyun-pnvs +AUTH_CODE_PEPPER=${strongSecretA} +AUTH_SESSION_SECRET=${strongSecretB} +AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json +AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1 +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +PLATFORM_ADMIN_API_KEY=${strongSecretC} +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_READINESS_TEST_ONLY +ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-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-29-stuvwx +WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false +`; + +const unsafeTenantPublicUrl = runReadiness(tenantConfigBaseEnv, { + tenantRows: [{ + tenantId: 'tenant-local-url', + slug: 'local-url', + name: 'Local URL tenant', + publicConfig: { frontend: { appUrl: 'http://127.0.0.1:5173' } }, + publishedTheme: { primaryColor: '#2563eb' }, + themeStatus: 'published', + publishedAt: '2026-07-11T00:00:00.000Z', + }], +}); + +assert.notEqual(unsafeTenantPublicUrl.status, 0, 'active tenant localhost public URLs should fail readiness'); +assert.ok( + unsafeTenantPublicUrl.payload.checks?.some(item => item.id === 'db.tenant_public_urls' && item.status === 'blocker'), + 'readiness should block active tenant public URLs that are not production HTTPS', +); + +const tenantWithoutPublishedTheme = runReadiness(tenantConfigBaseEnv, { + tenantRows: [{ + tenantId: 'tenant-default-theme', + slug: 'default-theme', + name: 'Default theme tenant', + publicConfig: { appUrl: 'https://student.gongxue100.com' }, + }], +}); + +assert.equal(tenantWithoutPublishedTheme.status, 0, 'missing tenant theme should use platform defaults without blocking readiness'); +assert.ok( + tenantWithoutPublishedTheme.payload.checks?.some(item => item.id === 'db.tenant_theme_published' && item.status === 'warn'), + 'readiness should warn when an active tenant has no published or branding fallback theme', +); + +const tenantWithBrandingFallback = runReadiness(tenantConfigBaseEnv, { + tenantRows: [{ + tenantId: 'tenant-branding-theme', + slug: 'branding-theme', + name: 'Branding theme tenant', + publicConfig: { appUrl: 'https://student.gongxue100.com' }, + brandingTheme: { primaryColor: '#0f766e' }, + }], +}); + +assert.equal(tenantWithBrandingFallback.status, 0, 'branding theme fallback should pass readiness'); +assert.ok( + tenantWithBrandingFallback.payload.checks?.some(item => item.id === 'db.tenant_theme_published' && item.status === 'pass'), + 'readiness should accept a non-empty tenant branding fallback theme', +); + +for (const environmentSafetyRow of [ + null, + { environment: 'production', allowDestructiveTests: false }, + { environment: 'staging', allow_destructive_tests: false }, +]) { + const result = runReadiness(tenantConfigBaseEnv, { environmentSafetyRow }); + assert.equal( + result.status, + 0, + `production readiness should accept a missing or disabled production/staging marker: ${result.stdout} ${result.stderr}`, + ); + assert.ok( + result.payload.checks?.some(item => ( + item.id === 'db.environment.destructive_tests_disabled' && item.status === 'pass' + )), + 'production readiness should record the safe destructive-test marker state', + ); +} + +for (const environmentSafetyRow of [ + { environment: 'local', allowDestructiveTests: false }, + { environment: 'test', allowDestructiveTests: false }, + { environment: 'ci', allowDestructiveTests: false }, + { environment: 'production', allowDestructiveTests: true }, + { environment: 'staging', allowDestructiveTests: true }, + { environment: 'unknown', allowDestructiveTests: false }, + { environment: 'production', allowDestructiveTests: 'false' }, +]) { + const result = runReadiness(tenantConfigBaseEnv, { environmentSafetyRow }); + assert.notEqual( + result.status, + 0, + `production readiness must reject unsafe destructive-test marker ${JSON.stringify(environmentSafetyRow)}`, + ); + assert.ok( + result.payload.checks?.some(item => ( + item.id === 'db.environment.destructive_tests_disabled' && item.status === 'blocker' + )), + 'production readiness should block unsafe destructive-test marker state', + ); +} + +const readinessSource = fs.readFileSync(scriptPath, 'utf8'); +assert.match( + readinessSource, + /to_regclass\('app_private\.environment_safety'\)/, + 'database readiness must verify that the environment safety migration exists', +); +assert.match( + readinessSource, + /from app_private\.environment_safety[\s\S]*where id = true/i, + 'database readiness must read the authoritative destructive-test marker', +); +for (const gateId of [ + 'db.runtime_role.identity', + 'db.runtime_role.attributes', + 'db.runtime_role.schema_acl', + 'db.runtime_role.table_acl', + 'db.runtime_role.function_acl', + 'db.runtime_role.ownership', + 'db.runtime_role.ddl_denied', +]) { + assert.ok(readinessSource.includes(gateId), `database readiness must include ${gateId}`); +} +assert.match( + readinessSource, + /current_user[\s\S]*session_user[\s\S]*DB_EXPECTED_RUNTIME_ROLE/i, + 'database readiness must verify current_user and session_user against DB_EXPECTED_RUNTIME_ROLE', +); +assert.match( + readinessSource, + /has_database_privilege[\s\S]*has_schema_privilege[\s\S]*db\.runtime_role\.ddl_denied/i, + 'database readiness must verify effective persistent DDL privileges without writing to production', +); + console.log('[PASS] production readiness check script'); diff --git a/scripts/production-readiness-check.js b/scripts/production-readiness-check.js index a64a9830..0eb38a13 100644 --- a/scripts/production-readiness-check.js +++ b/scripts/production-readiness-check.js @@ -3,6 +3,10 @@ import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import pg from 'pg'; +import { + loadTenantForeignKeyRelations, + summarizeTenantForeignKeySchema, +} from './lib/tenant-foreign-key-audit.js'; const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me'; @@ -46,8 +50,113 @@ 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 providerConfigFixture = argValues.get('--provider-config-fixture') || ''; +const tenantConfigFixture = argValues.get('--tenant-config-fixture') || ''; +const environmentSafetyFixture = argValues.get('--environment-safety-fixture') || ''; +const migrationHistoryFixture = argValues.get('--migration-history-fixture') || ''; const checks = []; +function loadRepositoryMigrationState() { + const migrationsDir = path.resolve(process.cwd(), 'supabase', 'migrations'); + if (!fs.existsSync(migrationsDir)) { + return { error: `Migration directory is missing: ${migrationsDir}` }; + } + + const files = fs.readdirSync(migrationsDir) + .filter(file => file.endsWith('.sql')) + .sort(); + const invalidFiles = []; + const versions = []; + for (const file of files) { + const match = /^(\d+)_.*\.sql$/.exec(file); + if (!match) invalidFiles.push(file); + else versions.push(match[1]); + } + const duplicateVersions = [...new Set( + versions.filter((version, index) => versions.indexOf(version) !== index), + )]; + const latestVersion = versions.reduce((latest, version) => { + if (!latest) return version; + if (version.length !== latest.length) return version.length > latest.length ? version : latest; + return version > latest ? version : latest; + }, ''); + + return { + migrationsDir, + files, + versions, + invalidFiles, + duplicateVersions, + latestVersion, + }; +} + +function compareMigrationVersions(left, right) { + const normalizedLeft = String(left || '').replace(/^0+(?=\d)/, ''); + const normalizedRight = String(right || '').replace(/^0+(?=\d)/, ''); + if (normalizedLeft.length !== normalizedRight.length) { + return normalizedLeft.length > normalizedRight.length ? 1 : -1; + } + return normalizedLeft === normalizedRight ? 0 : normalizedLeft > normalizedRight ? 1 : -1; +} + +function validateMigrationHistory(row, source = 'database') { + const repository = loadRepositoryMigrationState(); + if (repository.error + || repository.files?.length === 0 + || repository.invalidFiles?.length > 0 + || repository.duplicateVersions?.length > 0 + || !repository.latestVersion) { + block( + 'db.migrations.repository', + 'Repository migrations must use unique numeric Supabase versions', + { + source, + error: repository.error || '', + migrationCount: repository.files?.length || 0, + invalidFiles: repository.invalidFiles || [], + duplicateVersions: repository.duplicateVersions || [], + }, + ); + return; + } + + const latestVersion = String(row?.latest_version ?? row?.latestVersion ?? '').trim(); + const appliedCount = Number(row?.applied_count ?? row?.appliedCount); + const distinctVersionCount = Number(row?.distinct_version_count ?? row?.distinctVersionCount); + const expectedVersionApplied = row?.expected_version_applied ?? row?.expectedVersionApplied; + const details = { + source, + expectedVersion: repository.latestVersion, + repositoryMigrationCount: repository.files.length, + latestAppliedVersion: latestVersion || '(missing)', + appliedCount, + distinctVersionCount, + expectedVersionApplied: expectedVersionApplied === true, + }; + const validCounts = Number.isInteger(appliedCount) + && appliedCount >= repository.files.length + && Number.isInteger(distinctVersionCount) + && distinctVersionCount === appliedCount; + const current = /^\d+$/.test(latestVersion) + && compareMigrationVersions(latestVersion, repository.latestVersion) >= 0 + && expectedVersionApplied === true; + + if (!validCounts || !current) { + block( + 'db.migrations.current', + 'Database migration history must include the repository latest migration with no duplicate versions', + details, + ); + return; + } + + pass( + 'db.migrations.current', + 'Database migration history includes the repository latest migration', + details, + ); +} + function loadEnvFile(filePath) { if (!filePath || !fs.existsSync(filePath)) return; const content = fs.readFileSync(filePath, 'utf8'); @@ -480,6 +589,128 @@ function validateProviderConfigRows(inputRows) { } } +function tenantPublicUrlEntries(value, prefix = 'publicConfig') { + if (!value || typeof value !== 'object') return []; + if (Array.isArray(value)) { + return value.flatMap((item, index) => tenantPublicUrlEntries(item, `${prefix}[${index}]`)); + } + return Object.entries(value).flatMap(([key, child]) => { + const path = `${prefix}.${key}`; + const current = /(?:url|uri)$/i.test(key) && typeof child === 'string' && child.trim() + ? [[path, child.trim()]] + : []; + return [...current, ...tenantPublicUrlEntries(child, path)]; + }); +} + +function validateTenantConfigRows(inputRows, sourceLabel = 'fixture') { + const rows = inputRows.map(row => ({ + tenantId: row.tenant_id || row.tenantId || 'fixture-tenant', + slug: row.slug || '', + name: row.name || '', + publicConfig: row.public_config || row.publicConfig || {}, + brandingTheme: row.branding_theme || row.brandingTheme || {}, + publishedTheme: row.published_theme || row.publishedTheme || {}, + themeStatus: row.theme_status || row.themeStatus || null, + publishedAt: row.published_at || row.publishedAt || null, + })); + + if (rows.length === 0) { + warn('db.tenant_config.empty', 'No active tenant configuration rows were found', { source: sourceLabel }); + return; + } + + const unsafeUrls = rows.flatMap(row => tenantPublicUrlEntries(row.publicConfig) + .filter(([, value]) => !isProductionHttpsUrl(value)) + .map(([path, value]) => ({ + tenantId: row.tenantId, + slug: row.slug, + path, + protocol: urlFromValue(value)?.protocol || 'invalid', + host: hostFromUrl(value) || 'invalid', + }))); + + if (unsafeUrls.length > 0) { + block('db.tenant_public_urls', 'Active tenant public URL values must use production HTTPS URLs', { + count: unsafeUrls.length, + source: sourceLabel, + samples: unsafeUrls.slice(0, 10), + }); + } else { + pass('db.tenant_public_urls', 'Active tenant public URL values use production HTTPS URLs or are empty', { + source: sourceLabel, + }); + } + + const unpublishedThemeRows = rows.filter(row => { + const hasPublishedTheme = row.themeStatus === 'published' + && Boolean(row.publishedAt) + && Object.keys(row.publishedTheme).length > 0; + const hasBrandingFallback = Object.keys(row.brandingTheme).length > 0; + return !hasPublishedTheme && !hasBrandingFallback; + }); + + if (unpublishedThemeRows.length > 0) { + warn('db.tenant_theme_published', 'Some active tenants have no published theme or tenant branding theme; platform defaults will be used', { + count: unpublishedThemeRows.length, + source: sourceLabel, + samples: unpublishedThemeRows.slice(0, 10).map(row => ({ + tenantId: row.tenantId, + slug: row.slug, + name: row.name, + })), + }); + } else { + pass('db.tenant_theme_published', 'Active tenants have a published or branding fallback theme', { + source: sourceLabel, + }); + } +} + +function validateEnvironmentSafetyMarker(inputRow, sourceLabel = 'database') { + if (!inputRow) { + pass( + 'db.environment.destructive_tests_disabled', + 'No destructive-test authorization marker is present', + { source: sourceLabel, markerPresent: false }, + ); + return; + } + + const environment = String(inputRow.environment || '').trim().toLowerCase(); + const allowValue = inputRow.allow_destructive_tests ?? inputRow.allowDestructiveTests; + const details = { + source: sourceLabel, + markerPresent: true, + environment: environment || 'invalid', + allowDestructiveTests: allowValue === true, + }; + + if (typeof allowValue !== 'boolean') { + block( + 'db.environment.destructive_tests_disabled', + 'Database destructive-test marker must contain a boolean allow_destructive_tests value', + details, + ); + return; + } + + if (allowValue || !['production', 'staging'].includes(environment)) { + block( + 'db.environment.destructive_tests_disabled', + 'Production readiness requires a production/staging marker with destructive tests disabled', + details, + ); + return; + } + + pass( + 'db.environment.destructive_tests_disabled', + 'Database is classified as production/staging with destructive tests disabled', + details, + ); +} + function isSecretLikeKey(key) { const normalized = key.toLowerCase().replace(/[-_\s]/g, ''); const allowedSecretRef = normalized === 'secretref' || normalized.endsWith('secretref'); @@ -520,6 +751,53 @@ function formatTableName(row) { return `${row.table_schema}.${row.table_name}`; } +function validateRuntimeRoleEnvironment({ required = false } = {}) { + const expectedRole = env('DB_EXPECTED_RUNTIME_ROLE', '').trim(); + if (!expectedRole) { + if (required) { + block( + 'env.db_expected_runtime_role', + 'DB_EXPECTED_RUNTIME_ROLE must be tiku_api or tiku_worker for database readiness', + ); + } else { + warn( + 'env.db_expected_runtime_role', + 'DB_EXPECTED_RUNTIME_ROLE is not checked until readiness runs with --check-db', + ); + } + return ''; + } + if (!['tiku_api', 'tiku_worker'].includes(expectedRole)) { + block( + 'env.db_expected_runtime_role', + 'DB_EXPECTED_RUNTIME_ROLE must be tiku_api or tiku_worker', + { expectedRole }, + ); + return expectedRole; + } + + let databaseUser = ''; + try { + databaseUser = decodeURIComponent(new URL(env('DATABASE_URL', DEFAULT_DATABASE_URL)).username || ''); + } catch { + // DATABASE_URL has its own blocker; keep this check fail-closed as well. + } + if (databaseUser !== expectedRole) { + block( + 'env.database_runtime_role', + 'DATABASE_URL username must match DB_EXPECTED_RUNTIME_ROLE', + { expectedRole, databaseUser: databaseUser || '(missing)' }, + ); + } else { + pass( + 'env.database_runtime_role', + 'DATABASE_URL username matches the expected runtime role', + { expectedRole }, + ); + } + return expectedRole; +} + function validateEnv() { const nodeEnv = env('NODE_ENV', 'development'); if (nodeEnv !== 'production') block('env.node_env', 'NODE_ENV must be production for production readiness checks'); @@ -539,7 +817,7 @@ function validateEnv() { 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'); + block('env.cors_origin.empty', 'CORS_ORIGIN must include central platform or operations HTTPS origins'); } else { const unsafeOrigins = corsOrigins.filter(origin => { const host = hostFromUrl(origin); @@ -552,6 +830,31 @@ function validateEnv() { } } + if (!envBool('CORS_TENANT_DOMAINS_ENABLED', false)) { + block( + 'env.cors_tenant_domains_enabled', + 'CORS_TENANT_DOMAINS_ENABLED must be true for production custom tenant domains', + ); + } else { + pass('env.cors_tenant_domains_enabled', 'Dynamic active tenant-domain CORS validation is enabled'); + } + + const corsCacheNumbers = [ + ['CORS_TENANT_DOMAIN_CACHE_TTL_MS', 60_000, 1_000, 600_000], + ['CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS', 10_000, 1_000, 300_000], + ['CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES', 10_000, 100, 100_000], + ]; + for (const [key, fallback, min, max] of corsCacheNumbers) { + const value = envNumber(key, fallback); + if (!Number.isFinite(value) || value < min || value > max) { + block(`env.${key.toLowerCase()}`, `${key} must be between ${min} and ${max}`); + } else { + pass(`env.${key.toLowerCase()}`, `${key} is within the production safety range`, { value }); + } + } + + validateRuntimeRoleEnvironment({ required: !skipDb }); + const authSmsProvider = normalizeSmsProvider(env('AUTH_SMS_PROVIDER', 'mock')); if (!PRODUCTION_SMS_PROVIDERS.has(authSmsProvider)) { block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER must be aliyun-pnvs in production', { @@ -752,6 +1055,40 @@ function validateEnv() { for (const key of requiredPositiveNumbers) { if (envNumber(key, 1) <= 0) block(`env.${key.toLowerCase()}`, `${key} must be greater than 0`); } + + const pollIntervals = { + WORKER_CRM_POLL_INTERVAL_MS: 10_000, + WORKER_COMMERCE_POLL_INTERVAL_MS: 30_000, + WORKER_PROVIDER_BILL_POLL_INTERVAL_MS: 60_000, + WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS: 30_000, + WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS: 30_000, + WORKER_ASSET_POLL_INTERVAL_MS: 30_000, + WORKER_IMPORT_POLL_INTERVAL_MS: 10_000, + WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS: 60_000, + WORKER_EXPORT_POLL_INTERVAL_MS: 10_000, + }; + for (const [key, fallback] of Object.entries(pollIntervals)) { + const value = envNumber(key, fallback); + if (!Number.isFinite(value) || value < 1_000 || value > 3_600_000) { + block(`env.${key.toLowerCase()}`, `${key} must be between 1000 and 3600000`); + } + } + + const importLeaseSeconds = envNumber('WORKER_IMPORT_LEASE_SECONDS', 120); + const importHeartbeatIntervalMs = envNumber('WORKER_IMPORT_HEARTBEAT_INTERVAL_MS', 30_000); + if (!Number.isFinite(importLeaseSeconds) || importLeaseSeconds < 10 || importLeaseSeconds > 86_400) { + block('env.worker_import_lease_seconds', 'WORKER_IMPORT_LEASE_SECONDS must be between 10 and 86400'); + } + if ( + !Number.isFinite(importHeartbeatIntervalMs) + || importHeartbeatIntervalMs < 1_000 + || importHeartbeatIntervalMs >= importLeaseSeconds * 500 + ) { + block( + 'env.worker_import_heartbeat_interval_ms', + 'WORKER_IMPORT_HEARTBEAT_INTERVAL_MS must be at least 1000 and less than half the import lease duration', + ); + } } async function validateDatabase() { @@ -762,6 +1099,475 @@ async function validateDatabase() { const pool = new pg.Pool({ connectionString: env('DATABASE_URL', DEFAULT_DATABASE_URL), max: 2 }); try { + const expectedRuntimeRole = env('DB_EXPECTED_RUNTIME_ROLE', '').trim(); + const runtimeIdentityRows = await pool.query(` + select current_user, + session_user, + current_database() as database_name + `); + const runtimeIdentity = runtimeIdentityRows.rows[0] || {}; + if (!['tiku_api', 'tiku_worker'].includes(expectedRuntimeRole) + || runtimeIdentity.current_user !== expectedRuntimeRole + || runtimeIdentity.session_user !== expectedRuntimeRole) { + block( + 'db.runtime_role.identity', + 'Database readiness must connect directly as DB_EXPECTED_RUNTIME_ROLE without SET ROLE indirection', + { + expectedRole: expectedRuntimeRole || '(missing)', + currentUser: runtimeIdentity.current_user || '(missing)', + sessionUser: runtimeIdentity.session_user || '(missing)', + database: runtimeIdentity.database_name || '(missing)', + }, + ); + } else { + pass( + 'db.runtime_role.identity', + 'Database connection uses the expected dedicated runtime role', + { role: expectedRuntimeRole, database: runtimeIdentity.database_name }, + ); + } + + const runtimeRoleRows = await pool.query(` + select r.rolname, + r.rolsuper, + r.rolinherit, + r.rolcreaterole, + r.rolcreatedb, + r.rolcanlogin, + r.rolreplication, + r.rolbypassrls, + r.rolconfig, + exists ( + select 1 + from pg_auth_members membership + where membership.member = r.oid + ) as has_parent_roles + from pg_roles r + where r.rolname = any(array['tiku_api', 'tiku_worker']::name[]) + order by r.rolname + `); + const runtimeRoleByName = new Map(runtimeRoleRows.rows.map(row => [row.rolname, row])); + const unsafeRuntimeRoles = ['tiku_api', 'tiku_worker'].flatMap(roleName => { + const row = runtimeRoleByName.get(roleName); + if (!row) return [{ role: roleName, issue: 'missing' }]; + const issues = []; + if (row.rolsuper) issues.push('SUPERUSER'); + if (row.rolinherit) issues.push('INHERIT'); + if (row.rolcreaterole) issues.push('CREATEROLE'); + if (row.rolcreatedb) issues.push('CREATEDB'); + if (!row.rolcanlogin) issues.push('NOLOGIN'); + if (row.rolreplication) issues.push('REPLICATION'); + if (!row.rolbypassrls) issues.push('missing intentional BYPASSRLS'); + if (row.has_parent_roles) issues.push('role membership'); + const roleConfig = Array.isArray(row.rolconfig) ? row.rolconfig.map(String) : []; + if (!roleConfig.includes('search_path=pg_catalog, public, extensions')) issues.push('unsafe search_path'); + return issues.map(issue => ({ role: roleName, issue })); + }); + if (unsafeRuntimeRoles.length > 0) { + block( + 'db.runtime_role.attributes', + 'Runtime roles must be LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS with no memberships', + { issues: unsafeRuntimeRoles }, + ); + } else { + pass( + 'db.runtime_role.attributes', + 'Dedicated API and worker roles have the required constrained attributes', + ); + } + + const runtimeSchemaPrivilegeRows = await pool.query(` + select r.rolname, + has_schema_privilege(r.oid, 'public', 'USAGE') as public_usage, + has_schema_privilege(r.oid, 'public', 'CREATE') as public_create, + has_schema_privilege(r.oid, 'app', 'USAGE') as app_usage, + has_schema_privilege(r.oid, 'app', 'CREATE') as app_create, + has_schema_privilege(r.oid, 'app_private', 'USAGE') as private_usage, + has_schema_privilege(r.oid, 'app_private', 'CREATE') as private_create, + has_schema_privilege(r.oid, 'extensions', 'USAGE') as extensions_usage, + has_schema_privilege(r.oid, 'extensions', 'CREATE') as extensions_create + from pg_roles r + where r.rolname = any(array['tiku_api', 'tiku_worker']::name[]) + order by r.rolname + `); + const unsafeSchemaPrivileges = runtimeSchemaPrivilegeRows.rows.filter(row => ( + !row.public_usage + || !row.private_usage + || !row.extensions_usage + || row.public_create + || row.app_create + || row.private_create + || row.extensions_create + || (row.rolname === 'tiku_api' && !row.app_usage) + || (row.rolname === 'tiku_worker' && row.app_usage) + )); + if (runtimeSchemaPrivilegeRows.rowCount !== 2 || unsafeSchemaPrivileges.length > 0) { + block( + 'db.runtime_role.schema_acl', + 'Runtime roles must have only the reviewed schema USAGE privileges and no effective CREATE privilege', + { roles: runtimeSchemaPrivilegeRows.rows }, + ); + } else { + pass( + 'db.runtime_role.schema_acl', + 'Runtime roles cannot create persistent objects in application schemas', + ); + } + + const requiredExtensionRows = await pool.query(` + select extension.extname, + namespace.nspname as schema_name, + count(procedure_row.oid)::integer as function_count, + count(procedure_row.oid) filter ( + where has_function_privilege('anon', procedure_row.oid, 'EXECUTE') + )::integer as anon_execute_count, + count(procedure_row.oid) filter ( + where has_function_privilege('authenticated', procedure_row.oid, 'EXECUTE') + )::integer as authenticated_execute_count, + count(procedure_row.oid) filter ( + where has_function_privilege('tiku_api', procedure_row.oid, 'EXECUTE') + )::integer as api_execute_count, + count(procedure_row.oid) filter ( + where has_function_privilege('tiku_worker', procedure_row.oid, 'EXECUTE') + )::integer as worker_execute_count + from pg_extension extension + join pg_namespace namespace on namespace.oid = extension.extnamespace + left join pg_depend dependency + on dependency.refclassid = 'pg_extension'::regclass + and dependency.refobjid = extension.oid + and dependency.classid = 'pg_proc'::regclass + and dependency.deptype = 'e' + left join pg_proc procedure_row on procedure_row.oid = dependency.objid + where extension.extname = any(array['pgcrypto', 'citext', 'ltree', 'pg_trgm']::name[]) + group by extension.extname, namespace.nspname + order by extension.extname + `); + const requiredExtensionNames = new Set(['pgcrypto', 'citext', 'ltree', 'pg_trgm']); + const unsafeExtensions = requiredExtensionRows.rows.flatMap(row => { + const issues = []; + if (row.schema_name !== 'extensions') issues.push(`schema=${row.schema_name}`); + const functionCount = Number(row.function_count); + const backendExecuteCount = row.extname === 'pgcrypto' ? 0 : functionCount; + if (functionCount <= 0) issues.push('no extension functions found'); + if (Number(row.anon_execute_count) !== 0) issues.push(`anon EXECUTE=${row.anon_execute_count}`); + if (Number(row.authenticated_execute_count) !== 0) issues.push(`authenticated EXECUTE=${row.authenticated_execute_count}`); + if (Number(row.api_execute_count) !== backendExecuteCount) issues.push(`tiku_api EXECUTE=${row.api_execute_count}/${backendExecuteCount}`); + if (Number(row.worker_execute_count) !== backendExecuteCount) issues.push(`tiku_worker EXECUTE=${row.worker_execute_count}/${backendExecuteCount}`); + return issues.map(issue => ({ extension: row.extname, issue })); + }); + const observedExtensionNames = new Set(requiredExtensionRows.rows.map(row => row.extname)); + const missingExtensions = [...requiredExtensionNames].filter(name => !observedExtensionNames.has(name)); + if (missingExtensions.length > 0 || unsafeExtensions.length > 0) { + block( + 'db.extensions.isolation', + 'Required extensions must live outside public with reviewed client/backend function ACLs', + { missingExtensions, issues: unsafeExtensions }, + ); + } else { + pass( + 'db.extensions.isolation', + 'Required extensions are isolated in extensions with reviewed function ACLs', + ); + } + + const runtimeTablePrivilegeRows = await pool.query(` + select r.rolname, + n.nspname as table_schema, + c.relname as table_name, + has_table_privilege(r.oid, c.oid, 'SELECT') as can_select, + has_table_privilege(r.oid, c.oid, 'INSERT') as can_insert, + has_table_privilege(r.oid, c.oid, 'UPDATE') as can_update, + has_table_privilege(r.oid, c.oid, 'DELETE') as can_delete, + has_table_privilege(r.oid, c.oid, 'TRUNCATE') as can_truncate, + has_table_privilege(r.oid, c.oid, 'REFERENCES') as can_reference, + has_table_privilege(r.oid, c.oid, 'TRIGGER') as can_trigger + from pg_roles r + cross join pg_class c + join pg_namespace n on n.oid = c.relnamespace + where r.rolname = any(array['tiku_api', 'tiku_worker']::name[]) + and n.nspname in ('public', 'app_private') + and c.relkind in ('r', 'p', 'v', 'm', 'f') + order by r.rolname, n.nspname, c.relname + `); + const apiPrivateWriteMatrix = new Map([ + ['auth_sessions', { insert: true, update: true, delete: false }], + ['tenant_secrets', { insert: true, update: true, delete: false }], + ['platform_secrets', { insert: true, update: true, delete: false }], + ['sms_send_rate_limits', { insert: true, update: true, delete: true }], + ]); + const requiredPrivateTables = new Set([ + 'auth_sessions', + 'environment_safety', + 'platform_secrets', + 'sms_send_rate_limits', + 'tenant_secrets', + ]); + const observedPrivateTables = new Set( + runtimeTablePrivilegeRows.rows + .filter(row => row.rolname === 'tiku_api' && row.table_schema === 'app_private') + .map(row => row.table_name), + ); + const missingPrivateTables = [...requiredPrivateTables] + .filter(tableName => !observedPrivateTables.has(tableName)); + const unsafeTablePrivileges = runtimeTablePrivilegeRows.rows.flatMap(row => { + const issues = []; + if (!row.can_select) issues.push('missing SELECT'); + if (row.can_truncate) issues.push('TRUNCATE'); + if (row.can_reference) issues.push('REFERENCES'); + if (row.can_trigger) issues.push('TRIGGER'); + + if (row.table_schema === 'public') { + if (!row.can_insert) issues.push('missing INSERT'); + if (!row.can_update) issues.push('missing UPDATE'); + if (!row.can_delete) issues.push('missing DELETE'); + } else { + const expectedWrites = row.rolname === 'tiku_api' + ? apiPrivateWriteMatrix.get(row.table_name) || { insert: false, update: false, delete: false } + : { insert: false, update: false, delete: false }; + if (row.can_insert !== expectedWrites.insert) issues.push(`INSERT=${row.can_insert}`); + if (row.can_update !== expectedWrites.update) issues.push(`UPDATE=${row.can_update}`); + if (row.can_delete !== expectedWrites.delete) issues.push(`DELETE=${row.can_delete}`); + } + + return issues.map(issue => ({ + role: row.rolname, + object: `${row.table_schema}.${row.table_name}`, + issue, + })); + }); + if (missingPrivateTables.length > 0 || unsafeTablePrivileges.length > 0) { + block( + 'db.runtime_role.table_acl', + 'Runtime table privileges must match the API/worker least-privilege matrix', + { + checkedObjects: runtimeTablePrivilegeRows.rowCount, + missingPrivateTables, + issues: unsafeTablePrivileges.slice(0, 50), + }, + ); + } else { + pass( + 'db.runtime_role.table_acl', + 'Runtime table privileges match the API/worker least-privilege matrix', + { checkedObjects: runtimeTablePrivilegeRows.rowCount }, + ); + } + + const runtimeFunctionPrivilegeRows = await pool.query(` + select r.rolname, + n.nspname as function_schema, + p.proname as function_name, + oidvectortypes(p.proargtypes) as argument_types + from pg_roles r + cross join pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where r.rolname = any(array['tiku_api', 'tiku_worker']::name[]) + and n.nspname in ('public', 'app', 'app_private') + and has_function_privilege(r.oid, p.oid, 'EXECUTE') + order by r.rolname, n.nspname, p.proname, argument_types + `); + const allowedRuntimeFunctions = new Map([ + ['tiku_api', new Set([ + 'app.auth_user_exists(uuid)', + 'app.production_migration_history(text)', + 'app.uuid_array_from_jsonb(jsonb)', + 'app.public_question_bank_grant_allows(uuid[], uuid[], uuid, uuid[])', + 'app.public_question_bank_subscription_allows(jsonb, jsonb, uuid, uuid, uuid[])', + ])], + ['tiku_worker', new Set()], + ]); + const observedRuntimeFunctions = new Map([ + ['tiku_api', new Set()], + ['tiku_worker', new Set()], + ]); + const unexpectedRuntimeFunctions = []; + for (const row of runtimeFunctionPrivilegeRows.rows) { + const signature = `${row.function_schema}.${row.function_name}(${row.argument_types})`; + observedRuntimeFunctions.get(row.rolname)?.add(signature); + if (!allowedRuntimeFunctions.get(row.rolname)?.has(signature)) { + unexpectedRuntimeFunctions.push({ role: row.rolname, function: signature }); + } + } + const missingRuntimeFunctions = []; + for (const [roleName, expectedFunctions] of allowedRuntimeFunctions) { + for (const signature of expectedFunctions) { + if (!observedRuntimeFunctions.get(roleName)?.has(signature)) { + missingRuntimeFunctions.push({ role: roleName, function: signature }); + } + } + } + if (unexpectedRuntimeFunctions.length > 0 || missingRuntimeFunctions.length > 0) { + block( + 'db.runtime_role.function_acl', + 'Runtime function EXECUTE privileges must match the reviewed allowlist', + { unexpectedRuntimeFunctions, missingRuntimeFunctions }, + ); + } else { + pass( + 'db.runtime_role.function_acl', + 'Runtime function EXECUTE privileges match the reviewed allowlist', + { checkedGrants: runtimeFunctionPrivilegeRows.rowCount }, + ); + } + + const runtimeAuthPrivilegeRows = await pool.query(` + select r.rolname, + has_schema_privilege(r.oid, 'auth', 'USAGE') as auth_schema_usage, + has_table_privilege(r.oid, 'auth.users', 'SELECT') as auth_users_select, + has_function_privilege(r.oid, 'app.auth_user_exists(uuid)', 'EXECUTE') as auth_user_exists_execute + from pg_roles r + where r.rolname = any(array['tiku_api', 'tiku_worker']::name[]) + order by r.rolname + `); + const unsafeAuthPrivileges = runtimeAuthPrivilegeRows.rows.filter(row => ( + row.auth_schema_usage + || row.auth_users_select + || (row.rolname === 'tiku_api' && !row.auth_user_exists_execute) + || (row.rolname === 'tiku_worker' && row.auth_user_exists_execute) + )); + if (runtimeAuthPrivilegeRows.rowCount !== 2 || unsafeAuthPrivileges.length > 0) { + block( + 'db.runtime_role.auth_acl', + 'Runtime roles must not access auth.users directly; only tiku_api may execute the boolean existence boundary', + { roles: runtimeAuthPrivilegeRows.rows }, + ); + } else { + pass( + 'db.runtime_role.auth_acl', + 'Auth data remains private while tiku_api can validate an Auth UUID through the reviewed boolean boundary', + ); + } + + const runtimeOwnerRows = await pool.query(` + with owned_objects as ( + select c.relowner as owner_oid + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname in ('public', 'app', 'app_private') + union all + select p.proowner + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname in ('public', 'app', 'app_private') + union all + select t.typowner + from pg_type t + join pg_namespace n on n.oid = t.typnamespace + where n.nspname in ('public', 'app', 'app_private') + union all + select n.nspowner + from pg_namespace n + where n.nspname in ('public', 'app', 'app_private') + union all + select d.datdba + from pg_database d + where d.datname = current_database() + ) + select owner_role.rolname, count(*)::int as object_count + from owned_objects objects + join pg_roles owner_role on owner_role.oid = objects.owner_oid + where owner_role.rolname = any(array['tiku_api', 'tiku_worker']::name[]) + group by owner_role.rolname + `); + if (runtimeOwnerRows.rowCount > 0) { + block( + 'db.runtime_role.ownership', + 'Runtime roles must not own the database, schemas, relations, functions or types', + { owners: runtimeOwnerRows.rows }, + ); + } else { + pass( + 'db.runtime_role.ownership', + 'Runtime roles own no database or application schema objects', + ); + } + + const runtimeDdlRows = await pool.query(` + select r.rolname, + has_database_privilege(r.oid, current_database(), 'CREATE') as database_create, + has_schema_privilege(r.oid, 'public', 'CREATE') as public_create, + has_schema_privilege(r.oid, 'app', 'CREATE') as app_create, + has_schema_privilege(r.oid, 'app_private', 'CREATE') as private_create, + has_schema_privilege(r.oid, 'extensions', 'CREATE') as extensions_create + from pg_roles r + where r.rolname = any(array['tiku_api', 'tiku_worker']::name[]) + order by r.rolname + `); + const runtimeDdlAllowed = runtimeDdlRows.rows.filter(row => ( + row.database_create || row.public_create || row.app_create || row.private_create || row.extensions_create + )); + if (runtimeDdlRows.rowCount !== 2 || runtimeDdlAllowed.length > 0 || runtimeOwnerRows.rowCount > 0) { + block( + 'db.runtime_role.ddl_denied', + 'Runtime roles must have no effective persistent database/schema CREATE privilege or object ownership', + { roles: runtimeDdlRows.rows, owners: runtimeOwnerRows.rows }, + ); + } else { + pass( + 'db.runtime_role.ddl_denied', + 'Runtime roles are denied persistent DDL by effective privilege and ownership checks', + ); + } + + const environmentSafetyTable = await pool.query(` + select to_regclass('app_private.environment_safety')::text as table_name + `); + if (!environmentSafetyTable.rows[0]?.table_name) { + block( + 'db.environment.destructive_tests_disabled', + 'Database safety marker table is missing; apply all production migrations', + { source: 'database', markerPresent: false, tablePresent: false }, + ); + } else { + const environmentSafetyRows = await pool.query(` + select environment, + allow_destructive_tests + from app_private.environment_safety + where id = true + limit 1 + `); + validateEnvironmentSafetyMarker(environmentSafetyRows.rows[0] || null, 'database'); + } + + const repositoryMigrationState = loadRepositoryMigrationState(); + const migrationHistoryBoundary = await pool.query(` + select to_regprocedure('app.production_migration_history(text)')::text as function_name + `); + if (!repositoryMigrationState.latestVersion) { + validateMigrationHistory(null, 'database'); + } else if (!migrationHistoryBoundary.rows[0]?.function_name) { + block( + 'db.migrations.current', + 'Production migration history boundary is missing; apply the repository migrations', + { + source: 'database', + expectedVersion: repositoryMigrationState.latestVersion, + helperPresent: false, + }, + ); + } else { + try { + const migrationHistoryRows = await pool.query(` + select latest_version, + applied_count, + distinct_version_count, + expected_version_applied + from app.production_migration_history($1::text) + `, [repositoryMigrationState.latestVersion]); + validateMigrationHistory(migrationHistoryRows.rows[0] || null, 'database'); + } catch (error) { + block( + 'db.migrations.current', + 'Production migration history boundary could not be executed', + { + source: 'database', + expectedVersion: repositoryMigrationState.latestVersion, + errorCode: error && typeof error === 'object' && 'code' in error ? error.code : '', + }, + ); + } + } + const tenantRows = await pool.query(` select id, name, status from public.tenants @@ -771,6 +1577,22 @@ async function validateDatabase() { 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 tenantConfigRows = await pool.query(` + select t.id as tenant_id, t.slug::text, t.name, + coalesce(s.public_config, '{}'::jsonb) as public_config, + coalesce(b.theme, '{}'::jsonb) as branding_theme, + coalesce(tc.active_theme, '{}'::jsonb) as published_theme, + tc.status as theme_status, + tc.published_at + from public.tenants t + left join public.tenant_settings s on s.tenant_id = t.id + left join public.tenant_branding b on b.tenant_id = t.id + left join public.tenant_theme_configs tc on tc.tenant_id = t.id + where t.status = 'active' + order by t.created_at asc + `); + validateTenantConfigRows(tenantConfigRows.rows, 'database'); + const publicSecretRows = await pool.query(` select source, tenant_id, provider, config_public from ( @@ -1020,6 +1842,240 @@ async function validateDatabase() { 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'); + const clientTablePrivilegeRows = await pool.query(` + select r.rolname as role_name, + n.nspname as table_schema, + c.relname as table_name, + privilege.privilege_type + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + cross join (values ('anon'), ('authenticated')) as requested_roles(role_name) + join pg_roles r on r.rolname = requested_roles.role_name + cross join lateral ( + values + ('SELECT', has_table_privilege(r.oid, c.oid, 'SELECT')), + ('INSERT', has_table_privilege(r.oid, c.oid, 'INSERT')), + ('UPDATE', has_table_privilege(r.oid, c.oid, 'UPDATE')), + ('DELETE', has_table_privilege(r.oid, c.oid, 'DELETE')), + ('TRUNCATE', has_table_privilege(r.oid, c.oid, 'TRUNCATE')), + ('REFERENCES', has_table_privilege(r.oid, c.oid, 'REFERENCES')), + ('TRIGGER', has_table_privilege(r.oid, c.oid, 'TRIGGER')) + ) as privilege(privilege_type, allowed) + where n.nspname = 'public' + and c.relkind in ('r', 'p', 'v', 'm', 'f') + and privilege.allowed + order by r.rolname, c.relname, privilege.privilege_type + `); + if (clientTablePrivilegeRows.rowCount > 0) { + block('db.data_api.public_table_acl', 'anon/authenticated must not have direct privileges on public business tables or views', { + count: clientTablePrivilegeRows.rowCount, + samples: clientTablePrivilegeRows.rows.slice(0, 20).map(row => ({ + role: row.role_name, + object: `${row.table_schema}.${row.table_name}`, + privilege: row.privilege_type, + })), + }); + } else { + pass('db.data_api.public_table_acl', 'anon/authenticated have no direct privileges on public business tables or views'); + } + + const clientSequencePrivilegeRows = await pool.query(` + select r.rolname as role_name, + n.nspname as sequence_schema, + c.relname as sequence_name, + privilege.privilege_type + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + cross join (values ('anon'), ('authenticated')) as requested_roles(role_name) + join pg_roles r on r.rolname = requested_roles.role_name + cross join lateral ( + values + ('USAGE', has_sequence_privilege(r.oid, c.oid, 'USAGE')), + ('SELECT', has_sequence_privilege(r.oid, c.oid, 'SELECT')), + ('UPDATE', has_sequence_privilege(r.oid, c.oid, 'UPDATE')) + ) as privilege(privilege_type, allowed) + where n.nspname = 'public' + and c.relkind = 'S' + and privilege.allowed + order by r.rolname, c.relname, privilege.privilege_type + `); + if (clientSequencePrivilegeRows.rowCount > 0) { + block('db.data_api.public_sequence_acl', 'anon/authenticated must not have direct privileges on public sequences', { + count: clientSequencePrivilegeRows.rowCount, + samples: clientSequencePrivilegeRows.rows.slice(0, 20).map(row => ({ + role: row.role_name, + object: `${row.sequence_schema}.${row.sequence_name}`, + privilege: row.privilege_type, + })), + }); + } else { + pass('db.data_api.public_sequence_acl', 'anon/authenticated have no direct privileges on public sequences'); + } + + const clientFunctionPrivilegeRows = await pool.query(` + select r.rolname as role_name, + n.nspname as function_schema, + p.proname as function_name, + pg_get_function_identity_arguments(p.oid) as arguments + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + cross join (values ('anon'), ('authenticated')) as requested_roles(role_name) + join pg_roles r on r.rolname = requested_roles.role_name + where n.nspname = 'public' + and has_function_privilege(r.oid, p.oid, 'EXECUTE') + order by r.rolname, p.proname, arguments + `); + if (clientFunctionPrivilegeRows.rowCount > 0) { + block('db.data_api.public_function_acl', 'anon/authenticated must not execute public RPC functions without an explicit reviewed exception', { + count: clientFunctionPrivilegeRows.rowCount, + samples: clientFunctionPrivilegeRows.rows.slice(0, 20).map(row => ({ + role: row.role_name, + function: `${row.function_schema}.${row.function_name}(${row.arguments})`, + })), + }); + } else { + pass('db.data_api.public_function_acl', 'anon/authenticated cannot execute public RPC functions'); + } + + const unsafeDefaultAclRows = await pool.query(` + with public_object_owners as ( + select distinct c.relowner as owner_oid + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' + and c.relkind in ('r', 'p', 'S', 'v', 'm', 'f') + union + select distinct p.proowner as owner_oid + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' + ), object_types as ( + select 'r'::"char" as object_type, 'TABLES'::text as object_label + union all select 'S'::"char", 'SEQUENCES'::text + union all select 'f'::"char", 'FUNCTIONS'::text + ), default_sources as ( + select owners.owner_oid, + object_types.object_type, + object_types.object_label, + 'GLOBAL'::text as default_scope, + coalesce( + ( + select d.defaclacl + from pg_default_acl d + where d.defaclrole = owners.owner_oid + and d.defaclnamespace = 0 + and d.defaclobjtype = object_types.object_type + ), + acldefault(object_types.object_type, owners.owner_oid) + ) as acl + from public_object_owners owners + cross join object_types + union all + select owners.owner_oid, + object_types.object_type, + object_types.object_label, + 'PUBLIC_SCHEMA'::text as default_scope, + d.defaclacl as acl + from public_object_owners owners + cross join object_types + join pg_default_acl d + on d.defaclrole = owners.owner_oid + and d.defaclobjtype = object_types.object_type + join pg_namespace n + on n.oid = d.defaclnamespace + and n.nspname = 'public' + ) + select owner.rolname as owner_name, + defaults.object_label as object_type, + defaults.default_scope, + coalesce(grantee.rolname, 'PUBLIC') as grantee_name, + acl.privilege_type + from default_sources defaults + join pg_roles owner on owner.oid = defaults.owner_oid + cross join lateral aclexplode(defaults.acl) acl + left join pg_roles grantee on grantee.oid = acl.grantee + where acl.grantee = 0 + or grantee.rolname in ('anon', 'authenticated') + order by owner.rolname, defaults.object_label, grantee_name, acl.privilege_type + `); + if (unsafeDefaultAclRows.rowCount > 0) { + block('db.data_api.public_default_acl', 'public schema default privileges must not expose future tables, sequences or functions to client roles', { + count: unsafeDefaultAclRows.rowCount, + samples: unsafeDefaultAclRows.rows.slice(0, 20).map(row => ({ + owner: row.owner_name, + objectType: row.object_type, + defaultScope: row.default_scope, + grantee: row.grantee_name || 'PUBLIC', + privilege: row.privilege_type, + })), + }); + } else { + pass('db.data_api.public_default_acl', 'public schema default privileges are deny-by-default for client roles'); + } + + const platformUserWritePolicies = await pool.query(` + select p.polname as policy_name, + p.polcmd as policy_command, + array( + select coalesce(r.rolname, 'PUBLIC') + from unnest(p.polroles) policy_role(role_oid) + left join pg_roles r on r.oid = policy_role.role_oid + ) as policy_roles + from pg_policy p + where p.polrelid = 'public.platform_users'::regclass + and p.polcmd in ('*', 'a', 'w', 'd') + and ( + p.polroles = '{0}'::oid[] + or exists ( + select 1 + from unnest(p.polroles) policy_role(role_oid) + join pg_roles r on r.oid = policy_role.role_oid + where r.rolname in ('anon', 'authenticated') + ) + ) + order by p.polname + `); + if (platformUserWritePolicies.rowCount > 0) { + block('db.data_api.platform_users_write_policy', 'platform_users must not expose INSERT/UPDATE/DELETE/FOR ALL policies to client roles', { + count: platformUserWritePolicies.rowCount, + samples: platformUserWritePolicies.rows, + }); + } else { + pass('db.data_api.platform_users_write_policy', 'platform_users exposes no client write policy'); + } + + const platformAdminAuthorityRows = await pool.query(` + select p.oid, + p.prosecdef as security_definer, + p.proconfig, + pg_get_functiondef(p.oid) as definition + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'app' + and p.proname = 'is_platform_admin' + and pg_get_function_identity_arguments(p.oid) = '' + `); + const platformAdminAuthority = platformAdminAuthorityRows.rows[0]; + const platformAdminDefinition = String(platformAdminAuthority?.definition || '').toLowerCase(); + const platformAdminSearchPath = Array.isArray(platformAdminAuthority?.proconfig) + ? platformAdminAuthority.proconfig.map(value => String(value).toLowerCase()) + : []; + const platformAdminAuthoritySafe = platformAdminAuthorityRows.rowCount === 1 + && platformAdminAuthority.security_definer === true + && platformAdminSearchPath.includes('search_path=""') + && platformAdminDefinition.includes('from public.platform_users') + && platformAdminDefinition.includes("status = 'active'") + && !/current_role\(\)\s+in\s*\([^)]*platform_admin/.test(platformAdminDefinition); + if (!platformAdminAuthoritySafe) { + block('db.rls.platform_admin_authority', 'app.is_platform_admin() must resolve an active database identity and must not trust a platform_admin JWT claim', { + functionCount: platformAdminAuthorityRows.rowCount, + securityDefiner: platformAdminAuthority?.security_definer || false, + searchPath: platformAdminSearchPath, + }); + } else { + pass('db.rls.platform_admin_authority', 'RLS platform authority is backed by an active database identity'); + } + const tenantTablesWithoutRls = await pool.query(` select c.table_schema, c.table_name from information_schema.columns c @@ -1081,6 +2137,32 @@ async function validateDatabase() { or lower(pg_get_expr(p.polwithcheck, p.polrelid)) like '%app.current_tenant_id()%' ) ) + and not ( + exists ( + select 1 + from pg_policy platform_policy + where platform_policy.polrelid = cls.oid + and ( + lower(coalesce(pg_get_expr(platform_policy.polqual, platform_policy.polrelid), '')) like '%app.is_platform_admin()%' + or lower(coalesce(pg_get_expr(platform_policy.polwithcheck, platform_policy.polrelid), '')) like '%app.is_platform_admin()%' + ) + ) + and not exists ( + select 1 + from pg_policy non_platform_policy + where non_platform_policy.polrelid = cls.oid + and ( + ( + non_platform_policy.polqual is not null + and lower(pg_get_expr(non_platform_policy.polqual, non_platform_policy.polrelid)) not like '%app.is_platform_admin()%' + ) + or ( + non_platform_policy.polwithcheck is not null + and lower(pg_get_expr(non_platform_policy.polwithcheck, non_platform_policy.polrelid)) not like '%app.is_platform_admin()%' + ) + ) + ) + ) order by c.table_schema, c.table_name `); if (publicTenantTablesWithoutTenantContextPolicy.rowCount > 0) { @@ -1095,6 +2177,23 @@ async function validateDatabase() { } else { pass('db.rls.public_tenant_context', 'Public tenant-scoped tables include tenant context in RLS policies'); } + + const tenantForeignKeySchema = summarizeTenantForeignKeySchema( + await loadTenantForeignKeyRelations(pool), + ); + if (!tenantForeignKeySchema.schemaMatches) { + block( + 'db.tenant_foreign_keys.schema', + 'Tenant-scoped single-key foreign key schema changed or contains unvalidated constraints; review the relation and update the audited contract', + tenantForeignKeySchema, + ); + } else { + pass( + 'db.tenant_foreign_keys.schema', + 'Tenant-scoped single-key foreign key schema matches the reviewed contract', + tenantForeignKeySchema, + ); + } } finally { await pool.end(); } @@ -1133,6 +2232,28 @@ async function main() { const rows = Array.isArray(fixture) ? fixture : Array.isArray(fixture.rows) ? fixture.rows : []; validateProviderConfigRows(rows); } + if (tenantConfigFixture) { + const fixturePath = path.resolve(process.cwd(), tenantConfigFixture); + const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + const rows = Array.isArray(fixture) ? fixture : Array.isArray(fixture.rows) ? fixture.rows : []; + validateTenantConfigRows(rows); + } + if (environmentSafetyFixture) { + const fixturePath = path.resolve(process.cwd(), environmentSafetyFixture); + const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + const row = fixture && typeof fixture === 'object' && !Array.isArray(fixture) && 'row' in fixture + ? fixture.row + : fixture; + validateEnvironmentSafetyMarker(row || null, 'fixture'); + } + if (migrationHistoryFixture) { + const fixturePath = path.resolve(process.cwd(), migrationHistoryFixture); + const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + const row = fixture && typeof fixture === 'object' && !Array.isArray(fixture) && 'row' in fixture + ? fixture.row + : fixture; + validateMigrationHistory(row || null, 'fixture'); + } await validateDatabase(); printSummary(); diff --git a/scripts/release-artifact-hash.js b/scripts/release-artifact-hash.js new file mode 100644 index 00000000..ec933b58 --- /dev/null +++ b/scripts/release-artifact-hash.js @@ -0,0 +1,46 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +function normalizeSlashes(value) { + return value.replace(/\\/g, '/'); +} + +function walkFiles(dir) { + if (!fs.existsSync(dir)) return []; + const files = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...walkFiles(entryPath)); + else files.push(entryPath); + } + return files; +} + +export function hashArtifactDirectory(dir) { + const files = walkFiles(dir) + .map(filePath => ({ + filePath, + relativePath: normalizeSlashes(path.relative(dir, filePath)), + })) + .sort((left, right) => left.relativePath.localeCompare(right.relativePath, 'en')); + + const hash = crypto.createHash('sha256'); + let totalBytes = 0; + for (const file of files) { + const content = fs.readFileSync(file.filePath); + totalBytes += content.length; + hash.update(file.relativePath, 'utf8'); + hash.update('\0'); + hash.update(String(content.length), 'utf8'); + hash.update('\0'); + hash.update(content); + hash.update('\0'); + } + + return { + sha256: hash.digest('hex'), + files: files.length, + totalBytes, + }; +} diff --git a/scripts/remote-tenant-cors-smoke-test.js b/scripts/remote-tenant-cors-smoke-test.js new file mode 100644 index 00000000..9e28e222 --- /dev/null +++ b/scripts/remote-tenant-cors-smoke-test.js @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { runRemoteTenantCorsSmoke } from './remote-tenant-cors-smoke.js'; + +const origins = { + active: 'https://active.gongxue100.test', + disabled: 'https://disabled.gongxue100.test', + unknown: 'https://unknown.gongxue100.test', +}; + +function json(res, status, payload, headers = {}) { + res.writeHead(status, { 'content-type': 'application/json', ...headers }); + res.end(JSON.stringify(payload)); +} + +const server = http.createServer((req, res) => { + const origin = req.headers.origin || ''; + if (req.url === '/health' && req.method === 'GET' && !origin) { + json(res, 200, { ok: true }); + return; + } + if (req.method === 'OPTIONS' && req.url === '/api/tenant/resolve') { + if (origin === origins.active) { + res.writeHead(204, { + 'access-control-allow-origin': origin, + 'access-control-allow-methods': 'GET,POST,OPTIONS', + vary: 'Origin', + }); + res.end(); + return; + } + json(res, 403, { code: 'CORS_ORIGIN_DENIED' }); + return; + } + json(res, 404, { code: 'NOT_FOUND' }); +}); + +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +try { + const address = server.address(); + const summary = await runRemoteTenantCorsSmoke( + { + apiBaseUrl: `http://127.0.0.1:${address.port}`, + activeOrigin: origins.active, + disabledOrigin: origins.disabled, + unknownOrigin: origins.unknown, + timeoutMs: 5_000, + }, + { quiet: true }, + ); + assert.deepEqual(summary, { + failed: 0, + activeTenantOriginAllowed: true, + unknownOriginDenied: true, + disabledOriginDenied: true, + noOriginHealthAllowed: true, + statuses: { active: 204, disabled: 403, unknown: 403, health: 200 }, + }); + console.log('[PASS] remote dynamic tenant CORS smoke script'); +} finally { + await new Promise(resolve => server.close(resolve)); +} diff --git a/scripts/remote-tenant-cors-smoke.js b/scripts/remote-tenant-cors-smoke.js new file mode 100644 index 00000000..c618edd0 --- /dev/null +++ b/scripts/remote-tenant-cors-smoke.js @@ -0,0 +1,168 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const DEFAULT_TIMEOUT_MS = 10_000; + +function envString(env, key, fallback = '') { + return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback; +} + +function envNumber(env, key, fallback) { + const value = Number(envString(env, key)); + return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback; +} + +function normalizeBaseUrl(value) { + return value.replace(/\/+$/, ''); +} + +function normalizeOrigin(value, key) { + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${key} must be a valid URL origin`); + } + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) { + throw new Error(`${key} must contain only scheme, host and optional port`); + } + return parsed.origin; +} + +function parseArgs(argv) { + const options = { json: argv.includes('--json'), quiet: argv.includes('--quiet'), writePath: '' }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--write') { + options.writePath = argv[index + 1] || ''; + index += 1; + } else if (arg.startsWith('--write=')) { + options.writePath = arg.slice('--write='.length); + } + } + return options; +} + +function buildConfig(env = process.env) { + const apiBaseUrl = envString(env, 'TENANT_CORS_API_BASE_URL', envString(env, 'API_BASE', '')); + const activeOrigin = envString(env, 'TENANT_CORS_ACTIVE_ORIGIN'); + const disabledOrigin = envString(env, 'TENANT_CORS_DISABLED_ORIGIN'); + const unknownOrigin = envString(env, 'TENANT_CORS_UNKNOWN_ORIGIN'); + const missing = []; + if (!apiBaseUrl) missing.push('TENANT_CORS_API_BASE_URL'); + if (!activeOrigin) missing.push('TENANT_CORS_ACTIVE_ORIGIN'); + if (!disabledOrigin) missing.push('TENANT_CORS_DISABLED_ORIGIN'); + if (!unknownOrigin) missing.push('TENANT_CORS_UNKNOWN_ORIGIN'); + if (missing.length > 0) throw new Error(`Missing required remote tenant CORS smoke env: ${missing.join(', ')}`); + + return { + apiBaseUrl: normalizeBaseUrl(apiBaseUrl), + activeOrigin: normalizeOrigin(activeOrigin, 'TENANT_CORS_ACTIVE_ORIGIN'), + disabledOrigin: normalizeOrigin(disabledOrigin, 'TENANT_CORS_DISABLED_ORIGIN'), + unknownOrigin: normalizeOrigin(unknownOrigin, 'TENANT_CORS_UNKNOWN_ORIGIN'), + timeoutMs: envNumber(env, 'TENANT_CORS_TIMEOUT_MS', DEFAULT_TIMEOUT_MS), + }; +} + +async function request(config, { origin = '', method = 'OPTIONS', pathName = '/api/tenant/resolve' } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeoutMs); + try { + const response = await fetch(new URL(pathName, config.apiBaseUrl), { + method, + headers: origin + ? { + origin, + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'content-type,x-tenant-code', + } + : {}, + signal: controller.signal, + }); + const text = await response.text(); + let payload = {}; + if (text.trim()) { + try { payload = JSON.parse(text); } catch { payload = { raw: text.slice(0, 300) }; } + } + return { + status: response.status, + allowOrigin: response.headers.get('access-control-allow-origin') || '', + vary: response.headers.get('vary') || '', + payload, + }; + } finally { + clearTimeout(timeout); + } +} + +function assert(condition, message, detail = {}) { + if (condition) return; + const error = new Error(message); + error.detail = detail; + throw error; +} + +async function runRemoteTenantCorsSmoke(inputConfig, options = {}) { + const config = inputConfig?.apiBaseUrl ? inputConfig : buildConfig(options.env || process.env); + const active = await request(config, { origin: config.activeOrigin }); + assert(active.status === 204, 'active tenant Origin preflight must return HTTP 204', active); + assert(active.allowOrigin === config.activeOrigin, 'active tenant Origin must be echoed in access-control-allow-origin', active); + assert(/(?:^|,)\s*origin\s*(?:,|$)/i.test(active.vary), 'active tenant Origin response must vary by Origin', active); + + const disabled = await request(config, { origin: config.disabledOrigin }); + assert(disabled.status === 403, 'disabled tenant Origin preflight must return HTTP 403', disabled); + assert(!disabled.allowOrigin, 'disabled tenant Origin must not receive access-control-allow-origin', disabled); + assert(disabled.payload?.code === 'CORS_ORIGIN_DENIED', 'disabled tenant Origin must fail with CORS_ORIGIN_DENIED', disabled); + + const unknown = await request(config, { origin: config.unknownOrigin }); + assert(unknown.status === 403, 'unknown tenant Origin preflight must return HTTP 403', unknown); + assert(!unknown.allowOrigin, 'unknown tenant Origin must not receive access-control-allow-origin', unknown); + assert(unknown.payload?.code === 'CORS_ORIGIN_DENIED', 'unknown tenant Origin must fail with CORS_ORIGIN_DENIED', unknown); + + const health = await request(config, { method: 'GET', pathName: '/health' }); + assert(health.status === 200, 'health request without Origin must remain available', health); + assert(health.payload?.ok === true, 'health request without Origin must return ok=true', health); + + const summary = { + failed: 0, + activeTenantOriginAllowed: true, + unknownOriginDenied: true, + disabledOriginDenied: true, + noOriginHealthAllowed: true, + statuses: { + active: active.status, + disabled: disabled.status, + unknown: unknown.status, + health: health.status, + }, + }; + if (!options.quiet) console.log('[PASS] remote dynamic tenant CORS smoke'); + return summary; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + try { + const summary = await runRemoteTenantCorsSmoke(buildConfig(), { quiet: options.quiet || options.json }); + if (options.writePath) { + const resolved = path.resolve(process.cwd(), options.writePath); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + fs.writeFileSync(resolved, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + } + if (options.json) console.log(JSON.stringify(summary, null, 2)); + } catch (error) { + const failure = { failed: 1, error: error.message, detail: error.detail || undefined }; + if (options.json) console.log(JSON.stringify(failure, null, 2)); + else { + console.error(error.message); + if (error.detail) console.error(JSON.stringify(error.detail, null, 2)); + } + process.exitCode = 1; + } +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main(); + +export { buildConfig, runRemoteTenantCorsSmoke }; diff --git a/scripts/repo-security-scan-test.js b/scripts/repo-security-scan-test.js index 4dbdaa2a..60acb209 100644 --- a/scripts/repo-security-scan-test.js +++ b/scripts/repo-security-scan-test.js @@ -41,6 +41,7 @@ try { [ 'DATABASE_URL=postgresql://postgres:real-password@db.example.com:5432/postgres', 'SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake-service-role-token-that-should-not-ship', + 'GITEA_TOKEN=0123456789abcdef0123456789abcdef01234567', '', ].join('\n'), 'utf8', @@ -52,6 +53,7 @@ try { assert.ok(payload.findings.some(item => item.id === 'frontend-legacy-user-header'), 'x-user-id should be detected'); assert.ok(payload.findings.some(item => item.id === 'postgres-url'), 'database URL should be detected'); assert.ok(payload.findings.some(item => item.id === 'supabase-service-role'), 'service role key should be detected'); + assert.ok(payload.findings.some(item => item.id === 'git-access-token'), 'Git access token should be detected'); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } diff --git a/scripts/repo-security-scan.js b/scripts/repo-security-scan.js index 5c0bad34..e2eeaeff 100644 --- a/scripts/repo-security-scan.js +++ b/scripts/repo-security-scan.js @@ -74,6 +74,9 @@ const ruleAllowlistedFiles = { 'frontend-legacy-user-header': new Set([ 'scripts/repo-security-scan-test.js', ]), + 'git-access-token': new Set([ + 'scripts/repo-security-scan-test.js', + ]), }; const rules = [ @@ -113,6 +116,13 @@ const rules = [ pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/, message: 'Provider secret keys must not be committed.', }, + { + id: 'git-access-token', + severity: 'critical', + pattern: /\b(?:GITEA_TOKEN|GIT_TOKEN)\s*[:=]\s*["']?([A-Za-z0-9_-]{32,})/i, + message: 'Git access tokens must stay in server-side secret storage.', + validate: (match) => !/^(?:replace|example|your|rotated|placeholder)/i.test(String(match[1] || '')), + }, { id: 'cloud-access-key', severity: 'critical', diff --git a/scripts/rls-tenant-isolation-test.js b/scripts/rls-tenant-isolation-test.js index 98fdfdd9..0233fdc9 100644 --- a/scripts/rls-tenant-isolation-test.js +++ b/scripts/rls-tenant-isolation-test.js @@ -1,13 +1,19 @@ import pg from 'pg'; +import { + assertDestructiveTestDatabase, + resolveDestructiveTestConfirmation, +} from './lib/destructive-test-database-guard.js'; const { Pool } = pg; const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +const destructiveTestConfirmation = resolveDestructiveTestConfirmation(); const ids = { mainTenant: '00000000-0000-0000-0000-000000000001', partnerTenant: '00000000-0000-0000-0000-000000000901', - mainUser: '00000000-0000-0000-0000-000000000101', + normalAuthUser: '00000000-0000-0000-0000-00000000a101', + platformAdminAuthUser: '00000000-0000-0000-0000-00000000a999', partnerAdminUser: '00000000-0000-0000-0000-000000000907', }; @@ -15,74 +21,62 @@ const readChecks = [ { name: 'tenant_branding', sql: 'select tenant_id::text as tenant_id, brand_name as label from public.tenant_branding order by tenant_id', - expectMain: true, - expectPartner: true, + requiredTenantIds: [ids.mainTenant, ids.partnerTenant], }, { name: 'tenant_settings', sql: 'select tenant_id::text as tenant_id, public_config::text as label from public.tenant_settings order by tenant_id', - expectMain: true, - expectPartner: true, + requiredTenantIds: [ids.mainTenant, ids.partnerTenant], }, { name: 'tenant_domains', sql: 'select tenant_id::text as tenant_id, host as label from public.tenant_domains order by tenant_id', - expectMain: true, - expectPartner: true, + requiredTenantIds: [ids.mainTenant, ids.partnerTenant], }, { name: 'tenant_memberships', sql: 'select tenant_id::text as tenant_id, role as label from public.tenant_memberships order by tenant_id, role', - expectMain: true, - expectPartner: true, + requiredTenantIds: [ids.mainTenant, ids.partnerTenant], }, { name: 'regions', sql: 'select tenant_id::text as tenant_id, name as label from public.regions order by tenant_id', - expectMain: true, - expectPartner: true, + requiredTenantIds: [ids.mainTenant], }, { name: 'questions', sql: 'select tenant_id::text as tenant_id, legacy_id as label from public.questions order by tenant_id, id', - expectMain: true, - expectPartner: false, + requiredTenantIds: [ids.mainTenant], }, { name: 'student_profiles', sql: 'select tenant_id::text as tenant_id, user_id::text as label from public.student_profiles order by tenant_id, user_id', - expectMain: true, - expectPartner: false, + requiredTenantIds: [ids.mainTenant], }, { name: 'orders', sql: 'select tenant_id::text as tenant_id, order_no as label from public.orders order by tenant_id', - expectMain: true, - expectPartner: false, + requiredTenantIds: [ids.mainTenant], }, { name: 'content_assets', sql: 'select tenant_id::text as tenant_id, asset_key as label from public.content_assets order by tenant_id', - expectMain: true, - expectPartner: false, + requiredTenantIds: [ids.mainTenant], }, { name: 'tenant_subscriptions', sql: 'select tenant_id::text as tenant_id, plan_code as label from public.tenant_subscriptions order by tenant_id', - expectMain: false, - expectPartner: true, + requiredTenantIds: [ids.partnerTenant], }, { name: 'tenant_invoices', sql: 'select tenant_id::text as tenant_id, invoice_no as label from public.tenant_invoices order by tenant_id', - expectMain: false, - expectPartner: true, + requiredTenantIds: [ids.partnerTenant], }, { name: 'tenant_usage_records', sql: 'select tenant_id::text as tenant_id, metric_key as label from public.tenant_usage_records order by tenant_id, metric_key', - expectMain: false, - expectPartner: true, + requiredTenantIds: [ids.partnerTenant], }, ]; @@ -159,14 +153,27 @@ function onlyTenantRows(rows, tenantId) { return rows.every(row => row.tenant_id === tenantId); } -function hasTenantRows(rows, tenantId) { - return rows.some(row => row.tenant_id === tenantId); +function tenantRows(rows, tenantId) { + return rows.filter(row => row.tenant_id === tenantId); } -async function withRlsContext(client, { dbRole = 'authenticated', tenantId = '', roleClaim = 'authenticated', sub = '' }, action) { +function canonicalRows(rows) { + return rows.map(row => JSON.stringify(row)).sort(); +} + +function sameRows(actual, expected) { + return JSON.stringify(canonicalRows(actual)) === JSON.stringify(canonicalRows(expected)); +} + +async function withRlsContext( + client, + { dbRole = 'authenticated', tenantId = '', roleClaim = 'authenticated', sub = '' }, + action, + extraGrantStatements = [], +) { await client.query('begin'); try { - for (const statement of probeGrantStatements) await client.query(statement); + for (const statement of [...probeGrantStatements, ...extraGrantStatements]) await client.query(statement); await client.query(`set local role ${dbRole}`); if (tenantId) { await client.query("select set_config('request.jwt.claim.tenant_id', $1, true)", [tenantId]); @@ -193,8 +200,19 @@ async function queryAs(client, context, sql, params = []) { }); } -async function runReadIsolationChecks(client) { +async function captureReadBaselines(client) { + const baselines = new Map(); for (const check of readChecks) { + const result = await client.query(check.sql); + baselines.set(check.name, result.rows); + } + return baselines; +} + +async function runReadIsolationChecks(client, baselines) { + for (const check of readChecks) { + const baselineRows = baselines.get(check.name) || []; + const expectedMainRows = tenantRows(baselineRows, ids.mainTenant); const mainRows = await queryAs(client, { tenantId: ids.mainTenant }, check.sql); assert( onlyTenantRows(mainRows, ids.mainTenant), @@ -203,12 +221,13 @@ async function runReadIsolationChecks(client) { { rows: mainRows }, ); assert( - hasTenantRows(mainRows, ids.mainTenant) === check.expectMain, + sameRows(mainRows, expectedMainRows), `rls.read.${check.name}.main_expected_seed`, - '主租户 seed 数据存在性不符合预期', - { expected: check.expectMain, rows: mainRows }, + '主租户上下文应返回基线快照中该租户的完整数据子集', + { expectedRows: expectedMainRows, rows: mainRows }, ); + const expectedPartnerRows = tenantRows(baselineRows, ids.partnerTenant); const partnerRows = await queryAs(client, { tenantId: ids.partnerTenant }, check.sql); assert( onlyTenantRows(partnerRows, ids.partnerTenant), @@ -217,10 +236,10 @@ async function runReadIsolationChecks(client) { { rows: partnerRows }, ); assert( - hasTenantRows(partnerRows, ids.partnerTenant) === check.expectPartner, + sameRows(partnerRows, expectedPartnerRows), `rls.read.${check.name}.partner_expected_seed`, - '伙伴租户 seed 数据存在性不符合预期', - { expected: check.expectPartner, rows: partnerRows }, + '伙伴租户上下文应返回基线快照中该租户的完整数据子集', + { expectedRows: expectedPartnerRows, rows: partnerRows }, ); const anonymousRows = await queryAs(client, { dbRole: 'anon', tenantId: '', roleClaim: 'anon' }, check.sql); @@ -233,20 +252,114 @@ async function runReadIsolationChecks(client) { } } -async function runPlatformAdminChecks(client) { +async function runPlatformAdminChecks(client, baselines) { for (const check of readChecks) { - const rows = await queryAs(client, { tenantId: '', roleClaim: 'platform_admin', sub: ids.mainUser }, check.sql); - const hasMain = hasTenantRows(rows, ids.mainTenant); - const hasPartner = hasTenantRows(rows, ids.partnerTenant); + const rows = await queryAs( + client, + { tenantId: '', roleClaim: 'platform_admin', sub: ids.platformAdminAuthUser }, + check.sql, + ); + const baselineRows = baselines.get(check.name) || []; assert( - hasMain === check.expectMain && hasPartner === check.expectPartner, + sameRows(rows, baselineRows), `rls.platform_admin.${check.name}.cross_tenant_visibility`, - '平台管理员 RLS 旁路应只暴露当前表已有的多租户 seed 数据', - { expectedMain: check.expectMain, expectedPartner: check.expectPartner, rows }, + '平台管理员 RLS 旁路应返回当前表的完整基线快照', + { expectedRows: baselineRows, rows }, ); } } +async function runForgedPlatformAdminChecks(client, baselines) { + for (const check of readChecks) { + const rows = await queryAs( + client, + { + tenantId: ids.mainTenant, + roleClaim: 'platform_admin', + sub: ids.normalAuthUser, + }, + check.sql, + ); + + assert( + onlyTenantRows(rows, ids.mainTenant), + `rls.forged_platform_admin.${check.name}.no_cross_tenant_leak`, + '伪造 platform_admin claim 的普通用户不应获得跨租户可见性', + { rows }, + ); + const expectedMainRows = tenantRows(baselines.get(check.name) || [], ids.mainTenant); + assert( + sameRows(rows, expectedMainRows), + `rls.forged_platform_admin.${check.name}.tenant_scope_preserved`, + '伪造 platform_admin claim 后仍应按普通租户上下文执行 RLS', + { expectedRows: expectedMainRows, rows }, + ); + } +} + +async function runPlatformPrivilegeEscalationCheck(client) { + const result = await withRlsContext( + client, + { + tenantId: ids.mainTenant, + roleClaim: 'platform_admin', + sub: ids.normalAuthUser, + }, + async () => { + const before = await client.query( + ` + select auth_user_id::text, primary_role, status, platform_permissions + from public.platform_users + where auth_user_id = $1::uuid + `, + [ids.normalAuthUser], + ); + const update = await client.query( + ` + update public.platform_users + set primary_role = 'platform_admin', + status = 'disabled', + platform_permissions = '{"*":true}'::jsonb + where auth_user_id = $1::uuid + `, + [ids.normalAuthUser], + ); + const after = await client.query( + ` + select auth_user_id::text, primary_role, status, platform_permissions + from public.platform_users + where auth_user_id = $1::uuid + `, + [ids.normalAuthUser], + ); + return { before: before.rows, updateRowCount: update.rowCount, after: after.rows }; + }, + ['grant select, update on public.platform_users to authenticated'], + ); + + assert( + result.before.length === 1, + 'rls.platform_users.normal_user_self_read_probe', + '权限提升探针需要能读取普通用户自身记录', + result, + ); + assert( + result.updateRowCount === 0, + 'rls.platform_users.self_privilege_escalation_blocked', + '即使误授 authenticated UPDATE,RLS 也不应允许用户将自身提升为平台超管', + result, + ); + assert( + result.after.length === 1 + && result.after[0].primary_role === result.before[0].primary_role + && result.after[0].status === result.before[0].status + && JSON.stringify(result.after[0].platform_permissions) === JSON.stringify(result.before[0].platform_permissions), + 'rls.platform_users.sensitive_fields_unchanged', + '普通用户的角色、状态和平台权限字段不应被客户端修改', + result, + ); +} + async function runWriteIsolationChecks(client) { for (const check of writeChecks) { try { @@ -273,7 +386,364 @@ async function runWriteIsolationChecks(client) { } } -async function verifySeed(client) { +async function runQuestionVersionIntegrityChecks(client) { + const constraints = await client.query( + ` + select conname, convalidated + from pg_constraint + where conrelid in ('public.questions'::regclass, 'public.question_versions'::regclass) + and conname = any($1::text[]) + `, + [[ + 'questions_tenant_id_id_key', + 'question_versions_tenant_question_id_id_key', + 'question_versions_tenant_question_fkey', + 'questions_tenant_current_version_fkey', + ]], + ); + const constraintState = new Map( + constraints.rows.map(row => [row.conname, row.convalidated === true]), + ); + for (const name of [ + 'questions_tenant_id_id_key', + 'question_versions_tenant_question_id_id_key', + 'question_versions_tenant_question_fkey', + 'questions_tenant_current_version_fkey', + ]) { + assert( + constraintState.get(name) === true, + `schema.question_versions.${name}.validated`, + 'Question version tenant integrity constraints must exist and be validated', + { constraints: constraints.rows }, + ); + } + + await client.query('begin'); + try { + const questionA = await client.query( + ` + insert into public.questions (tenant_id, legacy_id, type, status) + values ($1, $2, 'choice', 'draft') + returning id + `, + [ids.mainTenant, `question-integrity-a-${Date.now()}`], + ); + const questionB = await client.query( + ` + insert into public.questions (tenant_id, legacy_id, type, status) + values ($1, $2, 'choice', 'draft') + returning id + `, + [ids.mainTenant, `question-integrity-b-${Date.now()}`], + ); + const versionA = await client.query( + ` + insert into public.question_versions (tenant_id, question_id, version_no, content) + values ($1, $2, 1, 'question integrity probe') + returning id + `, + [ids.mainTenant, questionA.rows[0].id], + ); + + await client.query('savepoint cross_tenant_version'); + try { + await client.query( + ` + insert into public.question_versions (tenant_id, question_id, version_no, content) + values ($1, $2, 2, 'must be rejected') + `, + [ids.partnerTenant, questionA.rows[0].id], + ); + fail( + 'schema.question_versions.cross_tenant_parent_rejected', + 'A question version must not reference a question from another tenant', + ); + } catch (error) { + assert( + error.code === '23503', + 'schema.question_versions.cross_tenant_parent_rejected', + 'Cross-tenant question version insertion must fail with a foreign key violation', + { code: error.code, message: error.message }, + ); + } finally { + await client.query('rollback to savepoint cross_tenant_version'); + } + + await client.query('savepoint cross_question_pointer'); + try { + await client.query( + 'update public.questions set current_version_id = $1 where id = $2', + [versionA.rows[0].id, questionB.rows[0].id], + ); + fail( + 'schema.questions.cross_question_current_version_rejected', + 'A question must not point at another question\'s current version', + ); + } catch (error) { + assert( + error.code === '23503', + 'schema.questions.cross_question_current_version_rejected', + 'Cross-question current version assignment must fail with a foreign key violation', + { code: error.code, message: error.message }, + ); + } finally { + await client.query('rollback to savepoint cross_question_pointer'); + } + + const validPointer = await client.query( + 'update public.questions set current_version_id = $1 where id = $2 returning id', + [versionA.rows[0].id, questionA.rows[0].id], + ); + assert( + validPointer.rowCount === 1, + 'schema.questions.same_question_current_version_allowed', + 'A question must be able to reference its own version in the same tenant', + ); + } finally { + await client.query('rollback').catch(() => {}); + } +} + +async function runCoreTenantForeignKeyIntegrityChecks(client) { + const constraintNames = [ + 'question_versions_tenant_id_id_key', + 'practice_sessions_tenant_id_id_key', + 'orders_tenant_id_id_key', + 'content_assets_tenant_id_id_key', + 'practice_sessions_tenant_user_id_key', + 'answer_records_question_version_requires_question_check', + 'answer_records_tenant_question_fkey', + 'answer_records_tenant_question_version_pair_fkey', + 'answer_records_tenant_user_session_fkey', + 'favorite_questions_tenant_question_fkey', + 'wrong_questions_tenant_question_fkey', + 'payments_tenant_order_fkey', + 'content_export_jobs_tenant_asset_fkey', + ]; + const constraints = await client.query( + ` + select conname, convalidated + from pg_constraint + where conname = any($1::text[]) + `, + [constraintNames], + ); + const constraintState = new Map( + constraints.rows.map(row => [row.conname, row.convalidated === true]), + ); + for (const name of constraintNames) { + assert( + constraintState.get(name) === true, + `schema.core_tenant_foreign_keys.${name}.validated`, + 'Core tenant foreign key constraints must exist and be validated', + { constraints: constraints.rows }, + ); + } + + await client.query('begin'); + try { + const parent = await client.query( + ` + select + version.question_id as "questionId", + version.id as "versionId", + session.id as "sessionId", + session.user_id as "sessionUserId", + (select o.id from public.orders o where o.tenant_id = $1 order by o.id limit 1) as "orderId", + (select a.id from public.content_assets a where a.tenant_id = $1 order by a.id limit 1) as "assetId" + from lateral ( + select v.id, v.question_id + from public.question_versions v + where v.tenant_id = $1 + order by v.id + limit 1 + ) version + cross join lateral ( + select s.id, s.user_id + from public.practice_sessions s + where s.tenant_id = $1 + order by s.id + limit 1 + ) session + `, + [ids.mainTenant], + ); + const references = parent.rows[0]; + assert( + Object.values(references || {}).every(Boolean), + 'schema.core_tenant_foreign_keys.parent_fixtures_available', + 'Core tenant foreign key probes require main-tenant parent fixtures', + { references }, + ); + + const probes = [ + { + name: 'answer_question', + sql: `insert into public.answer_records (tenant_id, user_id, question_id) + values ($1, $2, $3)`, + params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId], + }, + { + name: 'answer_question_version', + sql: `insert into public.answer_records (tenant_id, user_id, question_id, question_version_id) + values ($1, $2, $3, $4)`, + params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId, references.versionId], + }, + { + name: 'answer_practice_session', + sql: `insert into public.answer_records (tenant_id, user_id, practice_session_id) + values ($1, $2, $3)`, + params: [ids.partnerTenant, ids.partnerAdminUser, references.sessionId], + }, + { + name: 'favorite_question', + sql: `insert into public.favorite_questions (tenant_id, user_id, question_id) + values ($1, $2, $3)`, + params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId], + }, + { + name: 'wrong_question', + sql: `insert into public.wrong_questions (tenant_id, user_id, question_id) + values ($1, $2, $3)`, + params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId], + }, + { + name: 'payment_order', + sql: `insert into public.payments (tenant_id, order_id, provider, amount_cents) + values ($1, $2, 'rls-integrity-probe', 1)`, + params: [ids.partnerTenant, references.orderId], + }, + { + name: 'export_asset', + sql: `insert into public.content_export_jobs ( + tenant_id, export_type, format, scope_type, scope_id, asset_id + ) values ($1, 'questions', 'json', 'entry', gen_random_uuid(), $2)`, + params: [ids.partnerTenant, references.assetId], + }, + ]; + + for (const probe of probes) { + const savepoint = `core_tenant_fk_${probe.name}`; + await client.query(`savepoint ${savepoint}`); + try { + await client.query(probe.sql, probe.params); + fail( + `schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`, + 'Cross-tenant parent references must be rejected', + ); + } catch (error) { + assert( + error.code === '23503', + `schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`, + 'Cross-tenant parent references must fail with a foreign key violation', + { code: error.code, message: error.message }, + ); + } finally { + await client.query(`rollback to savepoint ${savepoint}`); + } + } + + const validAnswer = await client.query( + ` + insert into public.answer_records ( + tenant_id, user_id, question_id, question_version_id, practice_session_id + ) + values ($1, $2, $3, $4, $5) + returning id + `, + [ids.mainTenant, references.sessionUserId, references.questionId, references.versionId, references.sessionId], + ); + assert( + validAnswer.rowCount === 1, + 'schema.core_tenant_foreign_keys.same_tenant_answer_allowed', + 'A same-tenant answer record must remain writable after composite foreign keys', + ); + + const differentQuestion = await client.query( + ` + select id + from public.questions + where tenant_id = $1 and id <> $2 + order by id + limit 1 + `, + [ids.mainTenant, references.questionId], + ); + assert( + Boolean(differentQuestion.rows[0]?.id), + 'schema.core_tenant_foreign_keys.different_question_fixture_available', + 'Answer version-question integrity probe requires a second question', + ); + await client.query('savepoint answer_version_question_pair'); + try { + await client.query( + ` + insert into public.answer_records ( + tenant_id, user_id, question_id, question_version_id + ) values ($1, $2, $3, $4) + `, + [ids.mainTenant, '00000000-0000-0000-0000-000000000101', differentQuestion.rows[0].id, references.versionId], + ); + fail( + 'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected', + 'An answer must not combine a question with another question\'s version', + ); + } catch (error) { + assert( + error.code === '23503', + 'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected', + 'Mismatched answer question/version pairs must fail with a foreign key violation', + { code: error.code, message: error.message }, + ); + } finally { + await client.query('rollback to savepoint answer_version_question_pair'); + } + + const alternateUser = await client.query( + ` + select user_id as "userId" + from public.tenant_memberships + where tenant_id = $1 and user_id <> $2 + order by user_id + limit 1 + `, + [ids.mainTenant, references.sessionUserId], + ); + assert( + Boolean(alternateUser.rows[0]?.userId), + 'schema.core_tenant_foreign_keys.alternate_session_user_fixture_available', + 'Answer session-user integrity probe requires another tenant user', + ); + await client.query('savepoint answer_session_user_pair'); + try { + await client.query( + ` + insert into public.answer_records ( + tenant_id, user_id, practice_session_id + ) values ($1, $2, $3) + `, + [ids.mainTenant, alternateUser.rows[0].userId, references.sessionId], + ); + fail( + 'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected', + 'An answer must not reference another user\'s practice session', + ); + } catch (error) { + assert( + error.code === '23503', + 'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected', + 'Mismatched answer user/session pairs must fail with a foreign key violation', + { code: error.code, message: error.message }, + ); + } finally { + await client.query('rollback to savepoint answer_session_user_pair'); + } + } finally { + await client.query('rollback').catch(() => {}); + } +} + +async function verifySeed(client, baselines) { const result = await client.query( ` select tenant_id::text, count(*)::int as count @@ -290,15 +760,38 @@ async function verifySeed(client) { '需要先运行 npm run db:smoke-seed,确保主租户和伙伴租户 seed 都存在', { rows: result.rows }, ); + for (const check of readChecks) { + const rows = baselines.get(check.name) || []; + for (const tenantId of check.requiredTenantIds) { + const fixtures = tenantRows(rows, tenantId); + assert( + fixtures.length > 0, + `rls.seed.${check.name}.${tenantId === ids.mainTenant ? 'main' : 'partner'}_fixture`, + 'RLS 深测表必须有明确的非空租户夹具,避免空快照假通过', + { tenantId, rows }, + ); + } + } } async function main() { const client = await pool.connect(); try { - await verifySeed(client); - await runReadIsolationChecks(client); - await runPlatformAdminChecks(client); + await assertDestructiveTestDatabase({ + client, + databaseUrl, + confirmation: destructiveTestConfirmation, + operation: 'RLS tenant isolation test', + }); + const baselines = await captureReadBaselines(client); + await verifySeed(client, baselines); + await runReadIsolationChecks(client, baselines); + await runPlatformAdminChecks(client, baselines); + await runForgedPlatformAdminChecks(client, baselines); + await runPlatformPrivilegeEscalationCheck(client); await runWriteIsolationChecks(client); + await runQuestionVersionIntegrityChecks(client); + await runCoreTenantForeignKeyIntegrityChecks(client); } finally { client.release(); await pool.end(); diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 37a82892..63a553cd 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -1,9 +1,14 @@ import pg from 'pg'; +import { + assertDestructiveTestDatabase, + resolveDestructiveTestConfirmation, +} from './lib/destructive-test-database-guard.js'; const { Pool } = pg; const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; +const confirmation = resolveDestructiveTestConfirmation(); const ids = { authUser: '00000000-0000-0000-0000-00000000a101', @@ -98,8 +103,16 @@ const pool = new Pool({ connectionString: databaseUrl }); async function main() { const client = await pool.connect(); + let transactionStarted = false; try { + await assertDestructiveTestDatabase({ + client, + databaseUrl, + confirmation, + operation: 'smoke seed', + }); await client.query('begin'); + transactionStarted = true; await client.query( ` @@ -109,6 +122,40 @@ async function main() { `, ); + // A previous integration run may leave a tenant SMS provider active. + // Reset the smoke tenant so local tests never call an external SMS API. + await client.query( + ` + update public.tenant_auth_providers + set status = 'disabled', + updated_at = now() + where tenant_id = $1 + and provider in ( + 'aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth', + 'aliyun', 'aliyun-sms', 'aliyun_sms', + 'tencent', 'tencent-sms', 'tencent_sms' + ) + `, + [tenantId], + ); + + await client.query( + ` + insert into public.tenant_auth_providers ( + tenant_id, provider, status, display_name, config_public + ) + values ( + $1, 'mock', 'testing', '本地模拟短信', '{"channel":"local-dev"}'::jsonb + ) + on conflict (tenant_id, provider) + do update set status = 'testing', + display_name = excluded.display_name, + config_public = excluded.config_public, + updated_at = now() + `, + [tenantId], + ); + await client.query( ` insert into smoke_seed_transient_questions (id) @@ -2358,9 +2405,12 @@ async function main() { ); await client.query('commit'); + transactionStarted = false; console.log(`Smoke seed complete for tenant ${tenantId}`); } catch (error) { - await client.query('rollback'); + if (transactionStarted) { + await client.query('rollback').catch(() => undefined); + } throw error; } finally { client.release(); diff --git a/scripts/sms-rate-limit-contract-test.js b/scripts/sms-rate-limit-contract-test.js new file mode 100644 index 00000000..dc7240ef --- /dev/null +++ b/scripts/sms-rate-limit-contract-test.js @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const migration = fs.readFileSync('supabase/migrations/202607120012_sms_send_reservation_limits.sql', 'utf8'); +const routes = fs.readFileSync('apps/api/src/features/auth/routes.ts', 'utf8'); +const limits = fs.readFileSync('apps/api/src/features/auth/sms-limits.ts', 'utf8'); +const nginx = fs.readFileSync('scripts/deploy/nginx/tjszsb.com.conf.example', 'utf8'); +const taroAuth = fs.readFileSync('apps/taro/src/services/auth.ts', 'utf8'); + +assert.match(migration, /unique index[^;]+idx_sms_codes_active_phone_reservation[\s\S]+status in \('pending', 'sent'\)/i); +assert.match(migration, /app_private\.sms_send_rate_limits/); +assert.match(migration, /primary key \(tenant_id, dimension, scope_hash, bucket_start\)/); +assert.match(limits, /pg_advisory_xact_lock/); +assert.match(limits, /SMS_TENANT_DAILY_LIMIT/); +assert.match(limits, /SMS_PHONE_DAILY_LIMIT/); +assert.match(limits, /SMS_IP_HOURLY_LIMIT/); +assert.match(limits, /SMS_DEVICE_HOURLY_LIMIT/); +assert.match(limits, /createHmac\('sha256', config\.authCodePepper\)/); +assert.match(routes, /reserveSmsSend/); +assert.ok(routes.indexOf('reserveSmsSend') < routes.indexOf('provider.send'), 'SMS quota must be reserved before provider cost is incurred'); +assert.match(nginx, /limit_req_zone \$binary_remote_addr zone=tiku_sms_send/); +assert.match(nginx, /location = \/api\/auth\/sms\/send/); +assert.match(nginx, /limit_req zone=tiku_sms_send/); +assert.match(nginx, /proxy_set_header X-Forwarded-For \$remote_addr;/); +assert.match(taroAuth, /deviceId: smsDeviceId\(\)/); + +console.log('[PASS] SMS atomic quota and proxy rate-limit contract'); diff --git a/scripts/taro-api-auth-mode-test.js b/scripts/taro-api-auth-mode-test.js index edfab9b9..9b067992 100644 --- a/scripts/taro-api-auth-mode-test.js +++ b/scripts/taro-api-auth-mode-test.js @@ -2,6 +2,9 @@ import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; const repoRoot = process.cwd(); +process.env.TARO_ENV = 'h5'; +process.env.TARO_APP_SUPABASE_URL = 'https://auth.example.test'; +process.env.TARO_APP_SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_test'; const authModule = await import(pathToFileURL(`${repoRoot}/apps/taro/src/services/api-auth.ts`).href); authModule.setSupabaseAccessTokenProviderForTest(async () => 'supabase_access_token'); @@ -14,6 +17,16 @@ assert.equal( 'supabase_access_token', ); +assert.equal( + await authModule.resolveApiAuthorization({ + hasTokenOverride: false, + legacyToken: 'tk_legacy_token', + legacySource: 'app_session', + }), + 'tk_legacy_token', + 'An explicitly active app session must remain usable for H5 SMS login', +); + assert.equal( await authModule.resolveApiAuthorization({ authMode: 'none', @@ -47,10 +60,21 @@ assert.equal( await authModule.resolveApiAuthorization({ hasTokenOverride: false, legacyToken: 'tk_legacy_token', + legacySource: 'app_session', }), 'tk_legacy_token', ); +assert.equal( + await authModule.resolveApiAuthorization({ + hasTokenOverride: false, + legacyToken: 'tk_stale_token', + legacySource: 'supabase_jwt', + }), + null, + 'H5 with Supabase configured must not silently fall back to a stale non-active legacy session', +); + assert.equal( await authModule.resolveApiAuthorization({ authMode: 'supabase', diff --git a/scripts/taro-api-compatibility-contract-test.js b/scripts/taro-api-compatibility-contract-test.js new file mode 100644 index 00000000..2608d03c --- /dev/null +++ b/scripts/taro-api-compatibility-contract-test.js @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const read = file => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); + +const httpSource = read('apps/api/src/core/http.ts'); +const serverSource = read('apps/api/src/server.ts'); +const apiSource = read('apps/taro/src/services/api.ts'); +const typesSource = read('apps/taro/src/types.ts'); +const tenantLocatorSource = read('apps/api/src/features/tenant/locator.ts'); +const tenantResolutionSource = read('apps/taro/src/app/tenant-resolution.ts'); +const studentRouteSource = read('apps/api/src/features/tenant-admin/classes.ts'); +const studentCursorSource = read('apps/api/src/features/tenant-admin/student-cursor.ts'); + +assert.match(httpSource, /meta:\s*\{ \.\.\.existingMeta, requestId \}/, 'API responses must expose requestId without discarding endpoint metadata'); +assert.match(serverSource, /sendJson\(res, 200, withResponseMeta\(result, requestId\)\)/, 'Successful API responses must carry requestId metadata'); +assert.match(serverSource, /withResponseMeta\(\{ \.\.\.body, requestId \}, requestId\)/, 'Error API responses must carry the same requestId in the legacy field and metadata envelope'); +assert.match(apiSource, /responseHeaderValue\(response\.header, 'x-request-id'\)/, 'The Taro API client must fall back to the response header requestId'); +assert.match(apiSource, /this\.requestId = payload\.requestId/, 'ApiError must retain requestId for support and observability'); +assert.doesNotMatch(typesSource, /\[key:\s*string\]:\s*unknown/, 'The shared API envelope must not silently accept arbitrary response fields'); +assert.match(typesSource, /interface ApiResponseMeta[\s\S]*requestId:\s*string/, 'The Taro response envelope must type requestId metadata'); + +assert.match(tenantLocatorSource, /TENANT_HOST_CONFLICT/, 'Tenant host conflicts must fail closed'); +assert.match(tenantLocatorSource, /TENANT_LOCATOR_REQUIRED/, 'Tenant resolution must reject a missing locator'); +assert.match(tenantResolutionSource, /tenantCode:\s*!host \|\| isLocalRuntimeHost\(host\)/, 'Production H5 host resolution must suppress tenantCode overrides'); + +assert.ok( + studentRouteSource.includes('(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)'), + 'Deep student pages must use a composite keyset cursor', +); +assert.ok( + studentRouteSource.includes('order by tm.created_at desc, tm.id desc') + && studentRouteSource.includes('limit $${params.length}'), + 'Student pagination must keep stable ordering and a bounded limit', +); +assert.match(studentRouteSource, /const hasMore = rows\.length > limit/, 'List responses must derive hasMore from a limit+1 query'); +assert.match(studentRouteSource, /nextCursor = hasMore && lastItem/, 'List responses must only issue a next cursor when another page exists'); +assert.match(studentCursorSource, /parsed\.version !== 1/, 'Opaque cursors must be versioned and fail closed'); + +const statusContracts = [ + ['order', "('pending', 'paid', 'failed', 'closed', 'refunded')", read('supabase/migrations/202606210001_core_multitenant_schema.sql')], + ['refund', "('requested', 'approved', 'processing', 'succeeded', 'failed', 'rejected', 'cancelled')", read('supabase/migrations/202606290011_commerce_refunds.sql')], + ['content import', "('preview', 'pending', 'importing', 'completed', 'completed_with_errors', 'failed', 'rejected')", read('supabase/migrations/202606210007_content_import_assets.sql')], + ['CRM queue', "('pending', 'processing', 'retrying', 'sent', 'failed', 'discarded')", read('supabase/migrations/202606290010_crm_worker_hardening.sql')], + ['commission settlement', "('draft', 'pending_review', 'approved', 'paid', 'rejected', 'cancelled')", read('supabase/migrations/202606290009_commission_settlements.sql')], +]; + +for (const [name, values, source] of statusContracts) { + assert.ok(source.includes(values), `${name} state values are part of the frontend compatibility boundary`); +} + +console.log('[PASS] Taro API response, tenant resolution, pagination and state-machine compatibility contract'); diff --git a/scripts/taro-app-foundation-test.js b/scripts/taro-app-foundation-test.js new file mode 100644 index 00000000..eea35048 --- /dev/null +++ b/scripts/taro-app-foundation-test.js @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const repoRoot = process.cwd(); +const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src'); + +function moduleUrl(relativePath) { + return pathToFileURL(path.join(taroSrc, relativePath)).href; +} + +function readSource(relativePath) { + return fs.readFileSync(path.join(taroSrc, relativePath), 'utf8').replace(/\r\n/g, '\n'); +} + +const storageScope = await import(moduleUrl('app/storage-scope.ts')); +const tenantA = { portal: 'student', host: 'a.example.com' }; +const tenantB = { portal: 'student', host: 'b.example.com' }; +assert.notEqual(storageScope.tenantContextStorageKey(tenantA), storageScope.tenantContextStorageKey(tenantB)); +assert.notEqual( + storageScope.tenantContextStorageKey(tenantA), + storageScope.tenantContextStorageKey({ ...tenantA, portal: 'tenant-admin' }), +); +assert.notEqual(storageScope.sessionStorageKey(tenantA, 'tenant-a'), storageScope.sessionStorageKey(tenantA, 'tenant-b')); +assert.notEqual( + storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice'), + storageScope.tenantDataStorageKey(tenantA, 'tenant-b', 'user-a', 'practice'), +); +assert.notEqual( + storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice'), + storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-b', 'practice'), +); +assert.ok( + storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice') + .startsWith(storageScope.userDataStoragePrefix(tenantA, 'tenant-a', 'user-a')), +); +assert.match(storageScope.sessionStorageKey(tenantA, 'tenant-a'), /^tiku:v2:/); + +const tenantLaunch = await import(moduleUrl('app/tenant-launch.ts')); +assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { tenantCode: 'school-a' } }), 'school-a'); +assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { scene: 'tenantCode%3Dschool-b' } }), 'school-b'); +assert.equal(tenantLaunch.tenantCodeFromLaunch({ referrerExtraData: { tenant: 'school-c' } }), 'school-c'); +assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { tenantCode: '../unsafe' } }), ''); + +const tenantResolution = await import(moduleUrl('app/tenant-resolution.ts')); +assert.deepEqual( + tenantResolution.tenantResolveQuery({ host: 'school.example.com:443', tenantCode: 'compiled-tenant' }), + { host: 'school.example.com:443', tenantCode: undefined }, + 'A browser host must suppress a compiled tenantCode override', +); +assert.deepEqual( + tenantResolution.tenantResolveQuery({ host: 'localhost:5173', tenantCode: 'school-a' }), + { host: 'localhost:5173', tenantCode: 'school-a' }, + 'Local H5 development may select an explicit tenant code', +); +assert.deepEqual( + tenantResolution.tenantResolveQuery({ host: '', tenantCode: 'school-a' }), + { host: undefined, tenantCode: 'school-a' }, + 'Hostless WeApp resolution must retain its tenant code', +); + +const routePath = await import(moduleUrl('app/route-path.ts')); +assert.equal(routePath.normalizePagePath('/pages/student/login/index'), '/pages/student/login/index'); +assert.equal(routePath.normalizePagePath('/pages/student/login'), '/pages/student/login/index'); +assert.equal(routePath.normalizePagePath('/pages/student/login/'), '/pages/student/login/index'); +assert.equal(routePath.normalizePagePath('#!/pages/bootstrap'), '/pages/bootstrap/index'); +assert.equal(routePath.normalizePagePath('/health'), '/health'); +assert.equal( + routePath.safePageRedirectPath('/pages/student/home', 'student', '/pages/student/home/index'), + '/pages/student/home/index', + 'clean student redirects must resolve to the registered Taro page', +); +assert.equal( + routePath.safePageRedirectPath('/pages/student/practice?mode=mock', 'student', '/pages/student/home/index'), + '/pages/student/practice/index?mode=mock', + 'safe redirect normalization must preserve local query parameters', +); +assert.equal( + routePath.safePageRedirectPath('/pages/student/login', 'student', '/pages/student/home/index'), + '/pages/student/home/index', + 'clean login redirects must not loop back to the login page', +); +assert.equal( + routePath.safePageRedirectPath('/pages/student/home', 'tenant-admin', '/pages/tenant-admin/workbench/index'), + '/pages/tenant-admin/workbench/index', + 'redirects must stay inside the compiled portal', +); + +const permissions = await import(moduleUrl('app/permissions.ts')); +const tenantAccess = { + role: 'tenant_operator', + permissions: { 'students:read': false, '*': true }, + templatePermissions: {}, + effectivePermissions: {}, + menuPermissions: { students: false, content: true }, + modulePermissions: {}, + fieldPermissions: {}, + dataScope: {}, + roleDefaults: { tenant_operator: ['content:*'] }, +}; +assert.equal(permissions.hasTenantPermission(tenantAccess, 'students:read'), false); +assert.equal(permissions.hasTenantPermission(tenantAccess, 'content:write'), true); +assert.equal(permissions.hasTenantMenuAccess(tenantAccess, { menuKey: 'students', permission: 'students:read' }), false); +assert.equal(permissions.hasTenantMenuAccess(tenantAccess, { menuKey: 'content', permission: 'content:read' }), true); +assert.equal(permissions.hasPlatformPermission({ permissions: { '*': true }, effectivePermissions: {} }, 'platform:tenant:read'), true); + +const theme = await import(moduleUrl('theme/tokens.ts')); +const resolvedTheme = theme.resolveTheme({ + logoUrl: 'https://cdn.example.com/fallback.png', + theme: { + primaryColor: '#123456', + accentColor: 'url(javascript:alert(1))', + borderRadius: 99, + buttonRadius: 7, + customCssVars: { + '--tiku-focus-ring': '#123abc', + '--tiku-unsafe': 'url(https://tracker.example.com/pixel.png)', + '--other-product': '#ffffff', + }, + }, + publicAssets: { + logoUrl: 'https://cdn.example.com/logo.png', + shareImageUrl: 'https://cdn.example.com/share.png', + }, +}); +assert.equal(resolvedTheme.tokens.primary, '#123456'); +assert.equal(resolvedTheme.tokens.accent, theme.defaultThemeTokens.accent); +assert.equal(resolvedTheme.tokens.radius, '32px'); +assert.equal(resolvedTheme.tokens.radiusSmall, '7px'); +assert.deepEqual(resolvedTheme.customCssVars, { '--tiku-focus-ring': '#123abc' }); +assert.equal(resolvedTheme.assets.logoUrl, 'https://cdn.example.com/logo.png'); +assert.equal(theme.themeCssVariables(resolvedTheme.tokens)['--tiku-primary'], '#123456'); + +const sessionEvents = await import(moduleUrl('app/session-events.ts')); +const reasons = []; +const unsubscribe = sessionEvents.subscribeSessionChanges(reason => reasons.push(reason)); +sessionEvents.emitSessionChange('cleared'); +sessionEvents.emitSessionChange('cleared'); +sessionEvents.emitSessionChange('saved'); +sessionEvents.emitSessionChange('cleared'); +unsubscribe(); +assert.deepEqual(reasons, ['cleared', 'saved', 'cleared']); +assert.match(readSource('app/session-events.ts'), /addEventListener\('storage'/); +assert.match(readSource('app/session-events.ts'), /h5SessionEventKey/); + +const appSource = readSource('app.tsx'); +assert.match(appSource, //); +assert.match(appSource, //); +assert.match(appSource, /className=\{routeReady \? '' : 'route-guard-hidden'\}/); +assert.match(appSource, /\{content\}/, 'Taro page content must stay mounted while the route guard overlay is visible'); +assert.doesNotMatch(appSource, /routeReady \? content : null/, 'Route readiness must not remove the Taro page instance'); +assert.match(appSource, /useRouter\(true\)/, 'App must react to WeApp and subpackage route changes'); +assert.match(appSource, /normalizePagePath\(router\.path/); +assert.match(appSource, /applyWeappLaunchTenant\(\)/); +assert.match(appSource, /identityKey/); +assert.match(appSource, /key=\{identityKey\}/); +assert.doesNotMatch(appSource, /katex\/dist\/katex\.min\.css/, 'KaTeX CSS must not stay in the global app entry'); +assert.match(readSource('components/RichContent.tsx'), /katex-platform\.css/); +assert.match(readSource('components/katex-platform.h5.css'), /katex\/dist\/katex\.min\.css/); +assert.doesNotMatch(readSource('components/katex-platform.css'), /katex\/dist\/katex\.min\.css/); + +const navigationSource = readSource('capabilities/navigation.ts'); +assert.match(navigationSource, /taroWeappTenantMode\(\) !== 'launch'/, 'Fixed WeApp builds must ignore launch tenant overrides'); +assert.match(navigationSource, /appEnv\.tenantCode = ''/, 'Launch mode must clear any compiled tenant fallback before parsing launch data'); +assert.match(navigationSource, /tenantCodeFromLaunch/, 'Launch mode must parse query, scene, and referrer tenant data'); + +const themeProviderSource = readSource('theme/ThemeProvider.tsx'); +assert.match(themeProviderSource, /data-tiku-theme-managed/); +assert.match(themeProviderSource, /updateManagedMeta\([^\n]+assets\.shareImageUrl\)/); +assert.match(themeProviderSource, /updateManagedFavicon\(assets\.faviconUrl\)/); +assert.match(themeProviderSource, /originalHrefAttribute/); +assert.match(themeProviderSource, /managedCustomCssVars/); +assert.match(themeProviderSource, /removeProperty\(key\)/); +assert.match(themeProviderSource, /\.\.\.resolved\.customCssVars/); + +const loginSource = readSource('pages/student/login/index.tsx'); +assert.match(loginSource, /bootstrapStatus === 'forbidden' \? bootstrapError : ''/); +assert.doesNotMatch(loginSource, /bootstrapStatus === 'unauthenticated' \? bootstrapError/, '401 must not be presented as a permission failure'); + +const tenantSettingsSource = readSource('pages/tenant-admin/settings/index.tsx'); +assert.match(tenantSettingsSource, /await publishTenantTheme/); +assert.match(tenantSettingsSource, /await refreshTenant\(\)/, 'published branding must refresh the active ThemeProvider'); + +for (const shellPath of ['components/AdminLegacyShell.tsx', 'components/StudentLegacyShell.tsx']) { + const source = readSource(shellPath); + assert.match(source, /useApp\(\)/, `${shellPath} must consume AppProvider state`); + assert.doesNotMatch(source, /Taro\.(?:navigateTo|redirectTo|reLaunch)/, `${shellPath} must use navigation capability`); +} + +const apiSource = readSource('services/api.ts'); +assert.match(apiSource, /currentTenantContextStorageKey/); +assert.match(apiSource, /currentSessionStorageKey/); +assert.match(apiSource, /expiresAt <= Date\.now\(\)/); +assert.match(apiSource, /emitSessionChange\('expired'\)/); +assert.match(apiSource, /signOut\(\{ scope: 'local' \}\)/); +assert.match(apiSource, /clearActiveStorageUserData\(tenant\.tenantId\)/); +assert.match(apiSource, /tenantResolveQuery/, 'Tenant resolution requests must use the host-authority contract'); +assert.match(apiSource, /TENANT_DOMAIN_NOT_BOUND/, 'An unbound domain must clear stale tenant context'); + +const appProviderSource = readSource('app/AppProvider.tsx'); +assert.match(appProviderSource, /appEnv\.portal === 'platform-admin'\s*\? null/, 'Platform portal must not require a business tenant at bootstrap'); +assert.match(appProviderSource, /appEnv\.portal !== 'platform-admin' && !tenant/, 'Only tenant-scoped portals may resolve a business tenant'); +assert.match(appProviderSource, /H5 租户由当前域名确定/, 'H5 tenant switching must not override the authoritative host'); +assert.match(apiSource, /clearSession\(options: \{ emit\?: boolean \} = \{\}\)/); +assert.match(apiSource, /rejectedToken.*currentToken/s); + +const storageCapabilitySource = readSource('capabilities/storage.ts'); +assert.match(storageCapabilitySource, /getActiveStorageUserId\(tenantId\)/); +assert.match(storageCapabilitySource, /getActiveStorageUserId\(tenantId\) \|\| 'anonymous'/); +assert.match(storageCapabilitySource, /removeStorageByPrefix\(userDataStoragePrefix/); +assert.match(storageCapabilitySource, /previousUserId !== userId/); +assert.match(storageCapabilitySource, /legacyTenantDataStoragePrefix/); +assert.match(storageCapabilitySource, /tiku:practice:/); +assert.match(readSource('services/storage.ts'), /getActiveStorageUserId\(scope\.tenantId\) === scope\.userId/); +assert.match(readSource('app/AppProvider.tsx'), /activateStorageUser\(tenant\.tenantId, currentUser\.id\)/); +assert.match(readSource('app/AppProvider.tsx'), /event === 'SIGNED_IN'\) clearSession\(\{ emit: false \}\)/); + +const authSource = readSource('services/auth.ts'); +assert.match(authSource, /clearSession\(\{ emit: false \}\)/); +assert.match(authSource, /emitSessionChange\('cleared'\)/); +assert.match(authSource, /source: 'app_session'/); +assert.match(readSource('app/AppProvider.tsx'), /payload\.session\.source !== 'app_session'/); + +for (const pagePath of ['pages/student/practice/index.tsx', 'pages/student/vocabulary/index.tsx']) { + const source = readSource(pagePath); + assert.match(source, /createUserStorage/); + assert.match(source, /currentUser\?\.id/); +} + +const routeGuardSource = readSource('services/routeGuard.ts'); +assert.match(routeGuardSource, /normalizePagePath.*@\/app\/route-path/); +assert.match(routeGuardSource, /export \{ normalizePagePath \}/); +assert.match(routeGuardSource, /safePageRedirectPath\(path, appEnv\.portal, landingPath\(\)\)/); +assert.match(readSource('app/route-path.ts'), /path\.indexOf\('pages\/'\)/, 'route normalization must preserve student subpackage paths'); + +for (const pagePath of [ + 'pages/platform-admin/workbench/index.tsx', + 'pages/tenant-admin/content/index.tsx', + 'pages/tenant-admin/marketing/index.tsx', + 'pages/student/ai-school/index.tsx', + 'pages/student/checkout/index.tsx', + 'pages/student/order-detail/index.tsx', +]) { + const source = readSource(pagePath); + assert.doesNotMatch(source, /document\.createElement|window\.location/, `${pagePath} must use a cross-platform capability`); +} + +const paymentAdapter = readSource('capabilities/payment.ts'); +assert.match(paymentAdapter, /isWeappRuntime\(\)/); +assert.match(paymentAdapter, /Taro\.requestPayment/); + +console.log('[PASS] Taro app foundation contracts'); diff --git a/scripts/taro-build-matrix-contract-test.js b/scripts/taro-build-matrix-contract-test.js new file mode 100644 index 00000000..8bf2f78a --- /dev/null +++ b/scripts/taro-build-matrix-contract-test.js @@ -0,0 +1,275 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const repoRoot = process.cwd(); +const taroRoot = path.join(repoRoot, 'apps', 'taro'); +const configPath = path.join(taroRoot, 'config', 'index.ts'); +const appConfigPath = path.join(taroRoot, 'src', 'app.config.ts'); +const projectConfigPath = path.join(taroRoot, 'project.config.json'); +const rootPackage = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); +const taroPackage = JSON.parse(fs.readFileSync(path.join(taroRoot, 'package.json'), 'utf8')); +const bootstrapSource = fs.readFileSync(path.join(taroRoot, 'src', 'pages', 'bootstrap', 'index.tsx'), 'utf8'); +const h5InteractionSmokeSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'taro-h5-interaction-smoke.js'), 'utf8'); +const h5RuntimePatchCheck = 'node ../../scripts/taro-components-h5-runtime-patch.js --check'; +const weappBuildModule = await import(pathToFileURL(path.join(repoRoot, 'scripts', 'build-weapp-student.js')).href); +const weappGuardModule = await import(pathToFileURL(path.join(repoRoot, 'scripts', 'taro-weapp-release-guardrails.js')).href); + +const matrix = [ + { id: 'h5.student', taroEnv: 'h5', portal: 'student', releaseMode: 'production', outputRoot: 'dist/h5-student', appScript: 'build:h5:student', rootScript: 'build:taro:h5:student' }, + { id: 'h5.tenant', taroEnv: 'h5', portal: 'tenant-admin', releaseMode: 'production', outputRoot: 'dist/h5-tenant-admin', appScript: 'build:h5:tenant', rootScript: 'build:taro:h5:tenant' }, + { id: 'h5.platform', taroEnv: 'h5', portal: 'platform-admin', releaseMode: 'production', outputRoot: 'dist/h5-platform-admin', appScript: 'build:h5:platform', rootScript: 'build:taro:h5:platform' }, + { id: 'weapp.student', taroEnv: 'weapp', portal: 'student', releaseMode: 'preview', outputRoot: 'dist/weapp-student', appScript: 'build:weapp:student', rootScript: 'build:taro:weapp:student' }, +]; + +async function importForBuild(filePath, build) { + process.env.TARO_ENV = build.taroEnv; + process.env.TARO_APP_PORTAL = build.portal; + process.env.TARO_APP_RELEASE_MODE = build.releaseMode || 'preview'; + if (build.apiBaseUrl === undefined) delete process.env.TARO_APP_API_BASE_URL; + else process.env.TARO_APP_API_BASE_URL = build.apiBaseUrl; + if (build.tenantCode === undefined) delete process.env.TARO_APP_TENANT_CODE; + else process.env.TARO_APP_TENANT_CODE = build.tenantCode; + if (build.weappTenantMode === undefined) delete process.env.TARO_APP_WEAPP_TENANT_MODE; + else process.env.TARO_APP_WEAPP_TENANT_MODE = build.weappTenantMode; + globalThis.defineAppConfig = value => value; + const moduleUrl = pathToFileURL(filePath); + moduleUrl.searchParams.set('matrix', build.id); + return (await import(moduleUrl.href)).default; +} + +const originalPortal = process.env.TARO_APP_PORTAL; +const originalTaroEnv = process.env.TARO_ENV; +const originalReleaseMode = process.env.TARO_APP_RELEASE_MODE; +const originalApiBaseUrl = process.env.TARO_APP_API_BASE_URL; +const originalTenantCode = process.env.TARO_APP_TENANT_CODE; +const originalWeappTenantMode = process.env.TARO_APP_WEAPP_TENANT_MODE; +const observedOutputRoots = []; + +for (const build of matrix) { + const config = await importForBuild(configPath, build); + const appConfig = await importForBuild(appConfigPath, build); + assert.equal(config.outputRoot, build.outputRoot, `${build.id} must write to ${build.outputRoot}`); + if (build.taroEnv === 'h5') { + assert.notEqual(config.h5?.useDeprecatedAdapterComponent, true, `${build.id} must keep the reviewed modern Taro component adapter`); + assert.equal(config.h5?.devServer?.host, '127.0.0.1', `${build.id} development server must bind to loopback`); + assert.deepEqual( + config.h5?.devServer?.allowedHosts, + ['localhost', '127.0.0.1'], + `${build.id} development server must reject untrusted Host headers`, + ); + } + const publicBuildConfig = JSON.parse(config.defineConstants?.__TARO_PUBLIC_BUILD_CONFIG__ || '{}'); + assert.equal(publicBuildConfig.portal, build.portal, `${build.id} must compile its portal into the public build config`); + assert.equal(publicBuildConfig.target, build.taroEnv, `${build.id} must compile its target into the public build config`); + assert.equal(publicBuildConfig.releaseMode, build.releaseMode, `${build.id} must compile its release mode into the public build config`); + assert.equal(publicBuildConfig.weappTenantMode, build.taroEnv === 'weapp' ? 'launch' : '', `${build.id} must compile its WeApp tenant mode`); + if (build.releaseMode === 'production') { + assert.doesNotMatch(JSON.stringify(publicBuildConfig), /(?:127\.0\.0\.1|localhost)/i, `${build.id} must not compile a local API fallback`); + } + observedOutputRoots.push(config.outputRoot); + + const appScript = taroPackage.scripts?.[build.appScript] || ''; + const rootScript = rootPackage.scripts?.[build.rootScript] || ''; + if (build.taroEnv === 'weapp') { + assert.ok(appScript.includes('build-weapp-student.js'), `${build.appScript} must use the guarded WeApp build wrapper`); + } else { + assert.ok(appScript.startsWith(`${h5RuntimePatchCheck} && `), `${build.appScript} must verify the reviewed Taro H5 runtime patches`); + assert.ok(appScript.includes(`TARO_ENV=${build.taroEnv}`), `${build.appScript} must pin TARO_ENV=${build.taroEnv}`); + assert.ok(appScript.includes(`TARO_APP_PORTAL=${build.portal}`), `${build.appScript} must pin TARO_APP_PORTAL=${build.portal}`); + assert.ok(appScript.includes('TARO_APP_RELEASE_MODE=production'), `${build.appScript} must fail closed on missing production runtime config`); + assert.ok(appScript.includes(`--type ${build.taroEnv}`), `${build.appScript} must build the ${build.taroEnv} target`); + } + assert.ok(rootScript.includes('@tiku-saas/taro') && rootScript.includes(build.appScript), `${build.rootScript} must delegate to the Taro workspace script`); + + if (build.taroEnv === 'h5') { + assert.equal(appConfig.subPackages, undefined, `${build.id} must not emit mini-program subpackages`); + } else { + assert.deepEqual(appConfig.pages, ['pages/bootstrap/index'], 'Student WeApp main package must stay minimal'); + assert.equal(appConfig.subPackages?.[0]?.root, 'pages/student', 'Student WeApp must keep all business pages in the student subpackage'); + assert.equal(appConfig.lazyCodeLoading, 'requiredComponents', 'Student WeApp must enable required-component lazy loading'); + } +} + +assert.equal(new Set(observedOutputRoots).size, matrix.length, 'Every platform/portal build must have an isolated output directory'); +assert.ok(!taroPackage.scripts?.['build:weapp:tenant'], 'Tenant admin is H5-only and must not expose a WeApp build'); +assert.ok(!taroPackage.scripts?.['build:weapp:platform'], 'Platform admin is H5-only and must not expose a WeApp build'); +assert.ok(rootPackage.scripts?.['build:taro:h5:preview'], 'Root package must expose the three-portal H5 preview build'); +assert.equal( + taroPackage.scripts?.postinstall, + 'node ../../scripts/taro-components-h5-runtime-patch.js --apply', + 'Taro workspace installation must apply the reviewed H5 runtime patches', +); +assert.ok(taroPackage.scripts?.['dev:h5']?.startsWith(`${h5RuntimePatchCheck} && `), 'H5 development must verify the runtime patches'); +for (const item of [ + ['student', 'student'], + ['tenant', 'tenant-admin'], + ['platform', 'platform-admin'], +]) { + const [scriptSuffix, portal] = item; + const appScriptName = `build:h5:${scriptSuffix}:preview`; + const rootScriptName = `build:taro:h5:${scriptSuffix}:preview`; + const appScript = taroPackage.scripts?.[appScriptName] || ''; + const rootScript = rootPackage.scripts?.[rootScriptName] || ''; + assert.ok(appScript.startsWith(`${h5RuntimePatchCheck} && `), `${appScriptName} must verify the reviewed Taro H5 runtime patches`); + assert.ok(appScript.includes(`TARO_APP_PORTAL=${portal}`), `${appScriptName} must pin TARO_APP_PORTAL=${portal}`); + assert.ok(appScript.includes('TARO_APP_RELEASE_MODE=preview'), `${appScriptName} must compile preview mode`); + assert.ok(rootScript.includes(appScriptName), `${rootScriptName} must delegate to ${appScriptName}`); +} +assert.match(h5InteractionSmokeSource, /assertServeOnlyPreviewBuild/); +assert.match(h5InteractionSmokeSource, /not a preview build/); +assert.match(h5InteractionSmokeSource, /assertRuntimeHealthy/); +assert.match(h5InteractionSmokeSource, /Runtime\.exceptionThrown/); +assert.match(h5InteractionSmokeSource, /runCrossPortalRuntimeProbe/); +assert.match(h5InteractionSmokeSource, /runTaroInputWatcherRaceProbe/); +assert.match(h5InteractionSmokeSource, /runTaroButtonLoadingRaceProbe/); + +for (const portal of ['tenant-admin', 'platform-admin']) { + await assert.rejects( + () => importForBuild(appConfigPath, { id: `unsupported.weapp.${portal}`, taroEnv: 'weapp', portal }), + /H5-only/, + `${portal} WeApp configuration must fail fast instead of producing an unsupported admin mini-program`, + ); +} + +const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, 'utf8')); +assert.equal(projectConfig.miniprogramRoot, 'dist/weapp-student/', 'WeChat developer tools must open the isolated student WeApp output'); +assert.ok( + bootstrapSource.includes('redirectToLogin') && bootstrapSource.includes('if (!authorized)'), + 'Student WeApp bootstrap page must route unauthenticated users into the login page inside the student subpackage', +); +assert.ok(taroPackage.scripts?.['build:weapp:student:production']?.includes('--production'), 'Production WeApp build must use strict public config validation'); +assert.ok(rootPackage.scripts?.['build:taro:weapp:student:production'], 'Root package must expose the production WeApp build'); + +const productionWeappConfig = await importForBuild(configPath, { + id: 'weapp.student.production', + taroEnv: 'weapp', + portal: 'student', + releaseMode: 'production', + apiBaseUrl: 'https://api.gongxue100.com', + tenantCode: 'campus-north', + weappTenantMode: 'fixed', +}); +const productionPublicConfig = JSON.parse(productionWeappConfig.defineConstants?.__TARO_PUBLIC_BUILD_CONFIG__ || '{}'); +assert.deepEqual( + productionPublicConfig, + { + portal: 'student', + target: 'weapp', + releaseMode: 'production', + weappTenantMode: 'fixed', + apiBaseUrl: 'https://api.gongxue100.com', + supabaseUrl: '', + supabasePublishableKey: '', + tenantCode: 'campus-north', + }, + 'Production WeApp public config must be fully resolved at compile time', +); +assert.doesNotMatch(JSON.stringify(productionPublicConfig), /(?:127\.0\.0\.1|localhost)/i, 'Production WeApp compile constants must not contain local fallbacks'); + +const launchWeappConfig = await importForBuild(configPath, { + id: 'weapp.student.launch', + taroEnv: 'weapp', + portal: 'student', + releaseMode: 'production', + apiBaseUrl: 'https://api.gongxue100.com', + tenantCode: 'inherited-tenant', + weappTenantMode: 'launch', +}); +const launchPublicConfig = JSON.parse(launchWeappConfig.defineConstants?.__TARO_PUBLIC_BUILD_CONFIG__ || '{}'); +assert.equal(launchPublicConfig.weappTenantMode, 'launch'); +assert.equal(launchPublicConfig.tenantCode, '', 'Launch mode must clear an inherited compile-time tenant code'); + +assert.deepEqual( + weappBuildModule.resolveWeappBuildConfig({ + TARO_APP_API_BASE_URL: 'https://api.gongxue100.com/', + WECHAT_MINIAPP_APP_ID: 'wx6f3a9c2d4e8b1a70', + TARO_APP_WEAPP_TENANT_MODE: 'fixed', + TARO_APP_TENANT_CODE: 'campus-north', + }, { production: true }), + { + production: true, + tenantMode: 'fixed', + tenantCode: 'campus-north', + apiBaseUrl: 'https://api.gongxue100.com', + appId: 'wx6f3a9c2d4e8b1a70', + }, +); +assert.equal(weappBuildModule.resolveWeappTenantMode({}, false), 'launch'); +assert.equal(weappBuildModule.resolveWeappTenantMode({ TARO_APP_TENANT_CODE: 'campus-north' }, false), 'fixed'); +assert.throws( + () => weappBuildModule.resolveWeappBuildConfig({ + TARO_APP_API_BASE_URL: 'https://api.gongxue100.com', + WECHAT_MINIAPP_APP_ID: 'wx6f3a9c2d4e8b1a70', + TARO_APP_WEAPP_TENANT_MODE: 'launch', + TARO_APP_TENANT_CODE: 'inherited-tenant', + }, { production: true }), + /must be empty.*launch/, +); +for (const apiBaseUrl of [ + 'http://api.gongxue100.com', + 'https://localhost:8787', + 'https://127.0.0.1:8787', + 'https://[::1]:8787', + 'https://api.example', + 'https://api.example.test', + 'https://api.internal.local', +]) { + assert.throws(() => weappBuildModule.validateProductionApiBaseUrl(apiBaseUrl), /TARO_APP_API_BASE_URL/); +} +for (const appId of ['touristappid', 'wx0000000000000000', 'wx0123456789abcdef']) { + assert.throws(() => weappBuildModule.validateProductionWechatAppId(appId), /WECHAT_MINIAPP_APP_ID/); +} +for (const tenantCode of ['tenant-production', 'replace-with-tenant-code', 'example', 'test', 'demo', 'smoke', 'placeholder', 'changeme']) { + assert.throws(() => weappBuildModule.validateProductionTenantCode(tenantCode), /placeholder tenant code/); +} + +const guardFixture = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-weapp-guard-')); +try { + fs.mkdirSync(path.join(guardFixture, 'pages', 'student'), { recursive: true }); + fs.writeFileSync(path.join(guardFixture, 'project.config.json'), JSON.stringify({ + appid: 'wx6f3a9c2d4e8b1a70', + setting: { urlCheck: true }, + })); + fs.writeFileSync(path.join(guardFixture, 'app.json'), JSON.stringify({ + pages: ['pages/bootstrap/index'], + subPackages: [{ root: 'pages/student', pages: ['home/index'] }], + })); + fs.writeFileSync(path.join(guardFixture, 'common.js'), `const config=${JSON.stringify(productionPublicConfig)};`); + fs.writeFileSync(path.join(guardFixture, 'pages', 'student', 'home.js'), 'module.exports = {};'); + const releaseChecks = weappGuardModule.inspectWeappRelease({ distRoot: guardFixture, requireProduction: true }); + assert.equal(releaseChecks.some(check => check.status === 'fail'), false, JSON.stringify(releaseChecks)); + + fs.writeFileSync(path.join(guardFixture, 'common.js'), `const config=${JSON.stringify({ + ...productionPublicConfig, + weappTenantMode: 'launch', + tenantCode: 'compiled-fallback', + })};`); + const unsafeLaunchChecks = weappGuardModule.inspectWeappRelease({ distRoot: guardFixture, requireProduction: true }); + assert.ok(unsafeLaunchChecks.some(check => check.id === 'weapp.public_config.tenant_code' && check.status === 'fail')); +} finally { + fs.rmSync(guardFixture, { recursive: true, force: true }); +} + +for (const invalid of [ + { id: 'invalid.portal', taroEnv: 'h5', portal: 'studnet' }, + { id: 'invalid.target', taroEnv: '../h5', portal: 'student' }, +]) { + await assert.rejects(() => importForBuild(configPath, invalid), /Unsupported Taro/, `${invalid.id} must fail fast`); +} + +for (const [key, value] of [ + ['TARO_APP_PORTAL', originalPortal], + ['TARO_ENV', originalTaroEnv], + ['TARO_APP_RELEASE_MODE', originalReleaseMode], + ['TARO_APP_API_BASE_URL', originalApiBaseUrl], + ['TARO_APP_TENANT_CODE', originalTenantCode], + ['TARO_APP_WEAPP_TENANT_MODE', originalWeappTenantMode], +]) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; +} + +console.log(`[PASS] Taro build matrix contract (${matrix.map(item => `${item.id}=${item.outputRoot}`).join(', ')})`); diff --git a/scripts/taro-components-h5-runtime-patch-test.js b/scripts/taro-components-h5-runtime-patch-test.js new file mode 100644 index 00000000..17c4db3e --- /dev/null +++ b/scripts/taro-components-h5-runtime-patch-test.js @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + enforceTaroH5RuntimePatches, + replaceExactlyOnce, + taroButtonLoadingPatch, + taroH5RuntimePatchDefinition, + taroInputWatcherPatch, +} from './taro-components-h5-runtime-patch.js'; + +const repoRoot = process.cwd(); + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function installedPristineSource(patch) { + const target = path.join(repoRoot, 'node_modules', '@tarojs', 'components', patch.targetRelativePath); + const installed = fs.readFileSync(target, 'utf8'); + const pristine = installed.includes(patch.after) ? installed.replace(patch.after, patch.before) : installed; + return pristine; +} + +const pristineSources = new Map([ + [taroInputWatcherPatch.id, installedPristineSource(taroInputWatcherPatch)], + [taroButtonLoadingPatch.id, installedPristineSource(taroButtonLoadingPatch)], +]); + +function createFixture(options = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-taro-h5-runtime-patch-')); + const taroManifest = { + name: '@tiku-saas/taro', + version: '0.1.0', + scripts: { postinstall: taroH5RuntimePatchDefinition.postinstallCommand }, + devDependencies: { + '@tarojs/components': options.declaredVersion || taroH5RuntimePatchDefinition.packageVersion, + }, + }; + writeJson(path.join(root, 'apps', 'taro', 'package.json'), taroManifest); + writeJson(path.join(root, 'package-lock.json'), { + lockfileVersion: 3, + packages: { + 'apps/taro': { + hasInstallScript: true, + devDependencies: { + '@tarojs/components': options.lockWorkspaceVersion || taroH5RuntimePatchDefinition.packageVersion, + }, + }, + 'node_modules/@tarojs/components': { + version: options.lockVersion || taroH5RuntimePatchDefinition.packageVersion, + integrity: options.integrity || taroH5RuntimePatchDefinition.lockIntegrity, + }, + }, + }); + writeJson(path.join(root, 'node_modules', '@tarojs', 'components', 'package.json'), { + name: '@tarojs/components', + version: options.installedVersion || taroH5RuntimePatchDefinition.packageVersion, + }); + + const targets = new Map(); + for (const patch of taroH5RuntimePatchDefinition.patches) { + const target = path.join(root, 'node_modules', '@tarojs', 'components', patch.targetRelativePath); + targets.set(patch.id, target); + if (options.missingTarget !== patch.id) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync( + target, + options.sources?.[patch.id] ?? pristineSources.get(patch.id), + 'utf8', + ); + } + } + return { root, targets }; +} + +function targetContents(fixture) { + return Object.fromEntries([...fixture.targets].map(([id, target]) => [ + id, + fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null, + ])); +} + +const workingState = enforceTaroH5RuntimePatches({ root: repoRoot, mode: 'check' }); +assert.equal(workingState.status, 'pass'); +assert.equal(workingState.patches.inputWatcher.installedSha256, taroInputWatcherPatch.patchedSha256); +assert.equal(workingState.patches.buttonLoading.installedSha256, taroButtonLoadingPatch.patchedSha256); + +const applied = createFixture(); +try { + const first = enforceTaroH5RuntimePatches({ root: applied.root, mode: 'apply' }); + assert.equal(first.patches.inputWatcher.state, 'patched-now'); + assert.equal(first.patches.buttonLoading.state, 'patched-now'); + const second = enforceTaroH5RuntimePatches({ root: applied.root, mode: 'apply' }); + assert.equal(second.patches.inputWatcher.state, 'patched'); + assert.equal(second.patches.buttonLoading.state, 'patched'); + assert.equal(enforceTaroH5RuntimePatches({ root: applied.root, mode: 'check' }).status, 'pass'); +} finally { + fs.rmSync(applied.root, { recursive: true, force: true }); +} + +const pristineCheck = createFixture(); +try { + const before = targetContents(pristineCheck); + assert.throws( + () => enforceTaroH5RuntimePatches({ root: pristineCheck.root, mode: 'check' }), + /patch is not applied/, + ); + assert.deepEqual(targetContents(pristineCheck), before, '--check must never modify a pristine install'); +} finally { + fs.rmSync(pristineCheck.root, { recursive: true, force: true }); +} + +for (const [name, options, pattern] of [ + ['declared version', { declaredVersion: '4.2.1' }, /must pin/], + ['lock integrity', { integrity: 'sha512-unreviewed' }, /unexpected.*integrity/], + ['installed version', { installedVersion: '4.2.1-beta.2' }, /does not match/], + ['missing Input target', { missingTarget: taroInputWatcherPatch.id }, /target is missing/], + ['missing Button target', { missingTarget: taroButtonLoadingPatch.id }, /target is missing/], + ['unknown Input content', { sources: { [taroInputWatcherPatch.id]: `${pristineSources.get(taroInputWatcherPatch.id)}\n// tampered\n` } }, /unreviewed.*hash/], + ['unknown Button content', { sources: { [taroButtonLoadingPatch.id]: `${pristineSources.get(taroButtonLoadingPatch.id)}\n// tampered\n` } }, /unreviewed.*hash/], +]) { + const fixture = createFixture(options); + try { + const before = targetContents(fixture); + assert.throws(() => enforceTaroH5RuntimePatches({ root: fixture.root, mode: 'apply' }), pattern, name); + assert.deepEqual(targetContents(fixture), before, `${name} failure must not modify either target`); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +} + +assert.throws(() => replaceExactlyOnce('safe', 'unsafe', 'guarded'), /found 0/); +assert.throws(() => replaceExactlyOnce('unsafe unsafe', 'unsafe', 'guarded'), /found 2/); +assert.equal(replaceExactlyOnce('before unsafe after', 'unsafe', 'guarded'), 'before guarded after'); + +console.log('[PASS] Taro H5 runtime patch contract'); diff --git a/scripts/taro-components-h5-runtime-patch.js b/scripts/taro-components-h5-runtime-patch.js new file mode 100644 index 00000000..770ad0c1 --- /dev/null +++ b/scripts/taro-components-h5-runtime-patch.js @@ -0,0 +1,198 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const postinstallCommand = 'node ../../scripts/taro-components-h5-runtime-patch.js --apply'; + +const packageContract = Object.freeze({ + packageName: '@tarojs/components', + packageVersion: '4.2.0', + lockIntegrity: 'sha512-SQIK5UxKfmkhV0MdhmC0KV6duSkBtF9C8sX3dF6afKX8DvULXP5dirH8Y4bJEL+Mt+dkhlXiBmuSk/C2KB7sVg==', +}); + +export const taroInputWatcherPatch = Object.freeze({ + id: 'inputWatcher', + label: 'Input watcher', + targetRelativePath: 'dist/components/taro-input-core.js', + pristineSha256: '2483ffc5727959174e988c7171d7f7bb0a6300851cae1a13699d62a7d4769f95', + patchedSha256: '260bb8a07d66eaf3398904acb94a7c2cacabe4411b70a01fb0d03931fe95c499', + before: 'if (this.inputRef.value !== value) {', + after: 'if (this.inputRef && this.inputRef.value !== value) {', +}); + +export const taroButtonLoadingPatch = Object.freeze({ + id: 'buttonLoading', + label: 'Button loading node', + targetRelativePath: 'dist/components/taro-button-core.js', + pristineSha256: 'de5dfab0fc4c68a388b996b059255c57cc1ec52891238e7aacea588e7a9dd63e', + patchedSha256: '428db74e51382c68bc10211ff7815d494b086de465fdef97ca09f5b7ab8368ea', + before: 'loading && h("i", { class: \'weui-loading\' })', + after: 'h("i", { class: \'weui-loading\', style: { display: loading ? \'inline-block\' : \'none\' } })', +}); + +export const taroH5RuntimePatchDefinition = Object.freeze({ + ...packageContract, + postinstallCommand, + patches: Object.freeze([taroInputWatcherPatch, taroButtonLoadingPatch]), +}); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function sha256(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +function occurrences(source, needle) { + return source.split(needle).length - 1; +} + +export function replaceExactlyOnce(source, before, after, label = 'Taro H5 runtime patch') { + const count = occurrences(source, before); + assert(count === 1, `expected the ${label} target exactly once, found ${count}`); + return source.replace(before, after); +} + +function validateRepositoryContract(root, definition) { + const taroManifestPath = path.join(root, 'apps', 'taro', 'package.json'); + const lockPath = path.join(root, 'package-lock.json'); + const taroManifest = readJson(taroManifestPath); + const lock = readJson(lockPath); + + assert( + taroManifest.devDependencies?.[definition.packageName] === definition.packageVersion, + `apps/taro/package.json must pin ${definition.packageName}@${definition.packageVersion}`, + ); + assert( + taroManifest.scripts?.postinstall === definition.postinstallCommand, + 'apps/taro postinstall must apply the reviewed Taro H5 runtime patches', + ); + assert(lock.packages?.['apps/taro']?.hasInstallScript === true, 'package-lock.json must record the Taro workspace install hook'); + assert( + lock.packages?.['apps/taro']?.devDependencies?.[definition.packageName] === definition.packageVersion, + `package-lock.json must pin the Taro workspace to ${definition.packageName}@${definition.packageVersion}`, + ); + + const lockEntry = lock.packages?.[`node_modules/${definition.packageName}`]; + assert(lockEntry?.version === definition.packageVersion, `package-lock.json must resolve ${definition.packageName}@${definition.packageVersion}`); + assert(lockEntry?.integrity === definition.lockIntegrity, `package-lock.json has an unexpected ${definition.packageName} integrity`); + return taroManifestPath; +} + +function resolveInstalledPackage(root, definition, taroManifestPath) { + const requireFromTaro = createRequire(taroManifestPath); + const installedManifestPath = requireFromTaro.resolve(`${definition.packageName}/package.json`); + const installedManifest = readJson(installedManifestPath); + assert( + installedManifest.version === definition.packageVersion, + `installed ${definition.packageName}@${installedManifest.version} does not match the reviewed ${definition.packageVersion}`, + ); + return path.dirname(installedManifestPath); +} + +function validatePatchedText(source, patch) { + assert(occurrences(source, patch.before) === 0, `patched Taro ${patch.label} still contains the unsafe expression`); + assert(occurrences(source, patch.after) === 1, `patched Taro ${patch.label} guard is missing or duplicated`); +} + +function planPatch(root, installedPackageRoot, patch, mode) { + const targetPath = path.join(installedPackageRoot, patch.targetRelativePath); + assert(fs.existsSync(targetPath), `Taro ${patch.label} target is missing: ${targetPath}`); + + const source = fs.readFileSync(targetPath, 'utf8'); + const installedSha256 = sha256(source); + let state = 'patched'; + let finalSource = source; + + if (installedSha256 === patch.pristineSha256) { + state = 'pristine'; + assert(mode === 'apply', `reviewed Taro ${patch.label} patch is not applied; run npm install or the patch command`); + finalSource = replaceExactlyOnce(source, patch.before, patch.after, patch.label); + assert(sha256(finalSource) === patch.patchedSha256, `Taro ${patch.label} patch output hash is unexpected`); + validatePatchedText(finalSource, patch); + state = 'patched-now'; + } else if (installedSha256 === patch.patchedSha256) { + validatePatchedText(source, patch); + } else { + throw new Error( + `unreviewed ${packageContract.packageName} ${patch.label} target hash ${installedSha256}; do not apply the patch to unknown package contents`, + ); + } + + const finalSha256 = sha256(finalSource); + assert(finalSha256 === patch.patchedSha256, `installed Taro ${patch.label} patch hash does not match the reviewed result`); + return { + id: patch.id, + state, + targetPath, + target: path.relative(root, targetPath).replace(/\\/g, '/'), + source, + finalSource, + pristineSha256: patch.pristineSha256, + patchedSha256: patch.patchedSha256, + installedSha256: finalSha256, + }; +} + +export function enforceTaroH5RuntimePatches({ + root = scriptRoot, + mode = 'check', + definition = taroH5RuntimePatchDefinition, +} = {}) { + assert(mode === 'apply' || mode === 'check', 'mode must be apply or check'); + const taroManifestPath = validateRepositoryContract(root, definition); + const installedPackageRoot = resolveInstalledPackage(root, definition, taroManifestPath); + + // Validate every target before writing either file so an unknown package state fails atomically. + const plans = definition.patches.map(patch => planPatch(root, installedPackageRoot, patch, mode)); + if (mode === 'apply') { + for (const plan of plans) { + if (plan.finalSource !== plan.source) fs.writeFileSync(plan.targetPath, plan.finalSource, 'utf8'); + } + } + + return { + schemaVersion: 1, + status: 'pass', + package: definition.packageName, + version: definition.packageVersion, + patches: Object.fromEntries(plans.map(({ id, state, target, pristineSha256, patchedSha256, installedSha256 }) => [ + id, + { state, target, pristineSha256, patchedSha256, installedSha256 }, + ])), + }; +} + +function main() { + const argv = process.argv.slice(2); + const apply = argv.includes('--apply'); + const check = argv.includes('--check'); + assert(apply !== check, 'pass exactly one of --apply or --check'); + const result = enforceTaroH5RuntimePatches({ mode: apply ? 'apply' : 'check' }); + if (argv.includes('--json')) console.log(JSON.stringify(result, null, 2)); + else { + const summary = Object.entries(result.patches) + .map(([id, patch]) => `${id}=${patch.state}:${patch.installedSha256}`) + .join(', '); + console.log(`[PASS] ${result.package}@${result.version} H5 runtime patches (${summary})`); + } +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + try { + main(); + } catch (error) { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/scripts/taro-h5-interaction-smoke.js b/scripts/taro-h5-interaction-smoke.js index 546b6a7e..71ef98d9 100644 --- a/scripts/taro-h5-interaction-smoke.js +++ b/scripts/taro-h5-interaction-smoke.js @@ -1,14 +1,26 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; +import https from 'node:https'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; +import selfsigned from 'selfsigned'; const repoRoot = process.cwd(); const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist'); const outputDir = process.env.TARO_H5_INTERACTION_OUTPUT_DIR || 'docs/refactor/launch-artifacts'; +const smokeApiHostname = 'api-smoke.gongxue100.com'; +const smokeTls = selfsigned.generate([{ name: 'commonName', value: smokeApiHostname }], { + days: 1, + keySize: 2048, + algorithm: 'sha256', + extensions: [{ + name: 'subjectAltName', + altNames: [{ type: 2, value: smokeApiHostname }], + }], +}); const ids = { tenant: '00000000-0000-4000-8000-000000000001', @@ -63,9 +75,18 @@ function parseArgs(argv) { return { json: argv.includes('--json'), keepBrowser: argv.includes('--keep-browser'), + serveOnly: argv.includes('--serve-only'), }; } +function waitForShutdownSignal() { + return new Promise(resolve => { + const shutdown = signal => resolve(signal); + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + }); +} + function shanghaiTimestampForFile(date = new Date()) { const parts = Object.fromEntries( new Intl.DateTimeFormat('en-CA', { @@ -866,31 +887,48 @@ function mockApiPayload(pathname, method, query, body) { return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'smoke' } }; } -async function createMockApiServer() { +async function createMockApiServer(options = {}) { const requests = []; - const server = http.createServer(async (req, res) => { - const url = new URL(req.url || '/', 'http://127.0.0.1'); + let authenticated = true; + const serveOverHttp = options.serveOverHttp === true; + const requestHandler = async (req, res) => { + const url = new URL(req.url || '/', serveOverHttp ? 'http://127.0.0.1' : `https://${smokeApiHostname}`); if (req.method === 'OPTIONS') { jsonResponse(res, 204, {}); return; } const body = await requestBody(req); - requests.push({ + const requestRecord = { method: req.method || 'GET', path: url.pathname, query: Object.fromEntries(url.searchParams.entries()), body, - }); + status: 0, + }; + requests.push(requestRecord); try { + if (url.pathname === '/api/auth/me' && !authenticated) { + requestRecord.status = 401; + jsonResponse(res, 401, { error: 'Invalid or expired session', code: 'AUTH_SESSION_INVALID' }); + return; + } + requestRecord.status = 200; jsonResponse(res, 200, mockApiPayload(url.pathname, req.method || 'GET', url.searchParams, body)); } catch (error) { + requestRecord.status = 500; jsonResponse(res, 500, { code: 'MOCK_API_ERROR', message: error instanceof Error ? error.message : String(error) }); } - }); + }; + const server = serveOverHttp + ? http.createServer(requestHandler) + : https.createServer({ key: smokeTls.private, cert: smokeTls.cert }, requestHandler); const port = await listen(server); return { - baseUrl: `http://127.0.0.1:${port}`, + baseUrl: serveOverHttp ? `http://127.0.0.1:${port}` : `https://${smokeApiHostname}:${port}`, requests, + setAuthenticated(value) { + authenticated = Boolean(value); + }, close: () => closeServer(server), }; } @@ -902,7 +940,7 @@ async function createStaticServer(portal, apiBaseUrl) { apiBaseUrl, supabaseUrl: 'https://auth.example.test', supabasePublishableKey: 'sb_publishable_mock_key_for_h5_interaction_smoke', - tenantCode: 'master', + tenantCode: '', }; assertDistExists(portal); @@ -913,6 +951,11 @@ async function createStaticServer(portal, apiBaseUrl) { jsonResponse(res, 200, runtimeConfig); return; } + if (requestPath === '/favicon.ico') { + res.writeHead(204, { 'cache-control': 'public, max-age=86400' }); + res.end(); + return; + } const filePath = resolveStaticPath(distDir, requestPath); if (!filePath) { @@ -956,6 +999,18 @@ function assertDistExists(portal) { if (!fs.existsSync(indexPath)) throw new Error(`${relative(indexPath)} does not exist. Run npm run build:taro:h5 before interaction smoke.`); } +function assertServeOnlyPreviewBuild(portal) { + const distDir = path.join(distRoot, portal.dist); + const scripts = fs.readdirSync(path.join(distDir, 'js')) + .filter(fileName => fileName.endsWith('.js')) + .map(fileName => fs.readFileSync(path.join(distDir, 'js', fileName), 'utf8')); + if (!scripts.some(source => /["']releaseMode["']\s*:\s*["']preview["']/.test(source))) { + throw new Error( + `${relative(distDir)} is not a preview build. Run npm run build:taro:h5:preview before npm run serve:taro:h5:qa; rebuild all three production H5 artifacts after visual QA.`, + ); + } +} + function listen(server) { return new Promise((resolve, reject) => { server.once('error', reject); @@ -1018,6 +1073,8 @@ async function startBrowser(options) { const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-h5-interaction-')); const args = [ '--headless=new', + '--ignore-certificate-errors', + `--host-resolver-rules=MAP ${smokeApiHostname} 127.0.0.1`, '--disable-gpu', '--disable-dev-shm-usage', '--no-first-run', @@ -1044,8 +1101,10 @@ async function startBrowser(options) { } class CdpPage { - constructor(wsUrl) { + constructor(wsUrl, debugPort, targetId) { this.wsUrl = wsUrl; + this.debugPort = debugPort; + this.targetId = targetId; this.id = 1; this.pending = new Map(); this.events = []; @@ -1113,8 +1172,23 @@ class CdpPage { await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }); } - close() { - this.ws.close(); + async setViewport(width, height) { + await this.send('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: 1, + mobile: false, + }); + } + + async close() { + try { + if (this.targetId) { + await fetch(`http://127.0.0.1:${this.debugPort}/json/close/${encodeURIComponent(this.targetId)}`); + } + } finally { + this.ws.close(); + } } diagnosticEvents() { @@ -1128,10 +1202,16 @@ class CdpPage { ].includes(event.method)) .map(event => { if (event.method === 'Runtime.exceptionThrown') { + const details = event.params?.exceptionDetails || {}; return { method: event.method, - text: event.params?.exceptionDetails?.text, - description: event.params?.exceptionDetails?.exception?.description, + timestamp: event.params?.timestamp, + text: details.text, + description: details.exception?.description, + url: details.url, + lineNumber: details.lineNumber, + columnNumber: details.columnNumber, + stackTrace: details.stackTrace, }; } if (event.method === 'Runtime.consoleAPICalled') { @@ -1170,6 +1250,67 @@ class CdpPage { .slice(-20); } + runtimeFailures({ allowedHttp = [] } = {}) { + const allowedResponse = (status, url) => allowedHttp.some(rule => { + const statusMatches = rule.status === undefined || status === undefined || Number(rule.status) === Number(status); + const urlMatches = !rule.path || String(url || '').includes(rule.path); + return statusMatches && urlMatches; + }); + const failures = []; + const requestUrls = new Map( + this.events + .filter(event => event.method === 'Network.requestWillBeSent') + .map(event => [event.params?.requestId, event.params?.request?.url]), + ); + + for (const event of this.events) { + if (event.method === 'Runtime.exceptionThrown') { + const details = event.params?.exceptionDetails || {}; + failures.push({ + method: event.method, + timestamp: event.params?.timestamp, + text: details.text, + description: details.exception?.description, + url: details.url, + lineNumber: details.lineNumber, + columnNumber: details.columnNumber, + stackTrace: details.stackTrace, + }); + continue; + } + if (event.method === 'Runtime.consoleAPICalled' && ['error', 'assert'].includes(event.params?.type)) { + failures.push({ + method: event.method, + type: event.params?.type, + args: (event.params?.args || []).map(arg => arg.value || arg.description).filter(Boolean).slice(0, 8), + }); + continue; + } + if (event.method === 'Log.entryAdded' && event.params?.entry?.level === 'error') { + const entry = event.params.entry; + if (!allowedResponse(undefined, entry.url)) { + failures.push({ method: event.method, level: entry.level, text: entry.text, url: entry.url }); + } + continue; + } + if (event.method === 'Network.loadingFailed') { + const failure = event.params || {}; + const url = requestUrls.get(failure.requestId) || ''; + if (failure.canceled || failure.errorText === 'net::ERR_ABORTED') continue; + if (allowedResponse(undefined, url)) continue; + failures.push({ method: event.method, errorText: failure.errorText, type: failure.type, url }); + continue; + } + if (event.method === 'Network.responseReceived') { + const response = event.params?.response || {}; + if (response.status >= 400 && !allowedResponse(response.status, response.url)) { + failures.push({ method: event.method, status: response.status, url: response.url }); + } + } + } + return failures.slice(-30); + } + async acceptDialogs() { const events = this.events.filter(event => event.method === 'Page.javascriptDialogOpening'); this.events = this.events.filter(event => event.method !== 'Page.javascriptDialogOpening'); @@ -1180,21 +1321,38 @@ class CdpPage { } async function newPage(browser, url) { - const response = await fetch(`http://127.0.0.1:${browser.debugPort}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' }); + const response = await fetch(`http://127.0.0.1:${browser.debugPort}/json/new?${encodeURIComponent('about:blank')}`, { method: 'PUT' }); if (!response.ok) throw new Error(`Failed to create browser tab: ${response.status}`); const target = await response.json(); - return new CdpPage(target.webSocketDebuggerUrl).connect(); + const page = await new CdpPage(target.webSocketDebuggerUrl, browser.debugPort, target.id).connect(); + if (url) await page.navigate(url); + return page; +} + +function assertRuntimeHealthy(page, label, options = {}) { + const failures = page.runtimeFailures(options); + if (failures.length) { + throw new Error(`${label} emitted browser runtime errors:\n${JSON.stringify(failures, null, 2)}`); + } } async function waitUntil(label, fn, timeoutMs = 10_000) { const started = Date.now(); let lastValue; + let lastError = ''; while (Date.now() - started < timeoutMs) { - lastValue = await fn().catch(error => ({ error: error.message })); + try { + lastValue = await fn(); + lastError = ''; + } catch (error) { + lastValue = undefined; + lastError = error instanceof Error ? error.message : String(error); + } if (lastValue) return lastValue; await delay(200); } - throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}`); + const errorDetail = lastError ? ` Last error: ${lastError}` : ''; + throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}.${errorDetail}`); } async function bodyText(page) { @@ -1226,10 +1384,18 @@ async function assertNoText(page, text) { } async function waitForPath(page, pathPart, timeoutMs = 10_000) { - await waitUntil(`path "${pathPart}"`, async () => { - const pathValue = await currentPath(page); - return pathValue.includes(pathPart); - }, timeoutMs); + try { + await waitUntil(`path "${pathPart}"`, async () => { + const pathValue = await currentPath(page); + return pathValue.includes(pathPart); + }, timeoutMs); + } catch (error) { + const [pathValue, textContent] = await Promise.all([ + currentPath(page).catch(() => ''), + bodyText(page).catch(() => ''), + ]); + throw new Error(`${error.message}\nCurrent path: ${pathValue}\nBody excerpt: ${textContent.slice(0, 1200)}\nBrowser events: ${JSON.stringify(page.diagnosticEvents(), null, 2)}`); + } } async function clickText(page, text) { @@ -1283,16 +1449,12 @@ async function clickText(page, text) { const rect = target.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; - for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { - target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y })); - } target.click(); return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y }; })() `); await page.acceptDialogs(); if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`); - if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {}); await delay(350); await page.acceptDialogs(); return result; @@ -1351,16 +1513,12 @@ async function clickTextInSection(page, sectionTitle, text) { const rect = target.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; - for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { - target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y })); - } target.click(); return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y }; })() `); await page.acceptDialogs(); if (!result?.ok) throw new Error(`Clickable text not found in section "${sectionTitle}": ${text}\n${result?.body || ''}`); - if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {}); await delay(350); await page.acceptDialogs(); return result; @@ -1439,15 +1597,11 @@ async function clickVisibleTextCandidate(page, texts) { const rect = target.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; - for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { - target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y })); - } target.click(); return { ok: true, text: textOf(target).slice(0, 120), x, y }; })() `); if (result?.ok) { - if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {}); await delay(250); await page.acceptDialogs(); } @@ -1701,6 +1855,35 @@ async function navigateAndExpect(page, baseUrl, path, text, timeoutMs = 10_000) async function runStudentJourney(browser, portal, api) { const checks = []; + const assertStudentRuntimeHealthy = label => assertRuntimeHealthy(page, label, { + allowedHttp: [ + { path: 'https://assets.example.test/' }, + { path: 'https://pay.example.test/' }, + ], + }); + api.setAuthenticated(false); + const authRequestStart = api.requests.length; + const loginPage = await newPage(browser, `${portal.baseUrl}/pages/student/login/index`); + try { + await waitForText(loginPage, '欢迎回来'); + await waitForText(loginPage, '手机号登录'); + await waitForText(loginPage, '发送验证码'); + await waitUntil('unauthenticated /api/auth/me response', async () => { + return api.requests + .slice(authRequestStart) + .some(item => item.path === '/api/auth/me' && item.method === 'GET' && item.status === 401); + }); + assertRuntimeHealthy(loginPage, 'Student login flow', { + allowedHttp: [{ status: 401, path: '/api/auth/me' }], + }); + checks.push({ id: 'student.login.unauthenticated_401', status: 'pass', detail: '登录页已真实收到 /api/auth/me 401' }); + } finally { + try { + await loginPage.close(); + } finally { + api.setAuthenticated(true); + } + } const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`); try { await waitForText(page, '今日学习'); @@ -1743,6 +1926,7 @@ async function runStudentJourney(browser, portal, api) { await clickText(page, '刷新状态'); await waitForApiRequest(api, '/api/commerce/orders/status', 'GET'); checks.push({ id: 'student.checkout.order_payment', status: 'pass', detail: '收银台下单、支付参数生成和状态刷新 API 已触发' }); + assertStudentRuntimeHealthy('Student H5 journey through checkout'); await navigateAndExpect(page, portal.baseUrl, '/pages/student/review/index?type=wrong', '错题本'); await waitForText(page, '开始复习'); @@ -1759,6 +1943,7 @@ async function runStudentJourney(browser, portal, api) { await clickText(page, '认识'); await waitForApiRequest(api, '/api/learning/vocabulary/review', 'POST'); checks.push({ id: 'student.vocabulary.review', status: 'pass', detail: '背单词计划和复习提交 API 已触发' }); + assertStudentRuntimeHealthy('Student H5 journey through vocabulary'); await navigateAndExpect(page, portal.baseUrl, '/pages/student/handbook/index', '知识手册'); await waitForText(page, '高等数学手册'); @@ -1775,6 +1960,7 @@ async function runStudentJourney(browser, portal, api) { await waitForText(page, '确认下载'); await waitForApiRequest(api, '/api/catalog/assets/download', 'GET'); checks.push({ id: 'student.assets.signed_watermark', status: 'pass', detail: '资料预览/下载短签名和水印面板可用' }); + assertStudentRuntimeHealthy('Student H5 journey through signed assets'); await navigateAndExpect(page, portal.baseUrl, `/pages/student/video/index?questionId=${ids.question}`, '视频解析'); await waitForText(page, '本题视频解析'); @@ -1791,6 +1977,7 @@ async function runStudentJourney(browser, portal, api) { await waitForText(page, '推荐结果'); await waitForText(page, '天津职业大学'); checks.push({ id: 'student.ai_school.rendered', status: 'pass', detail: 'AI 择校报告列表和推荐结果可渲染' }); + assertStudentRuntimeHealthy('Student H5 journey through scoreline and AI school'); await navigateAndExpect(page, portal.baseUrl, '/pages/student/notifications/index', '消息中心'); await waitForText(page, '入门勋章已发放'); @@ -1798,9 +1985,10 @@ async function runStudentJourney(browser, portal, api) { await waitForApiRequest(api, '/api/profile/notifications/status', 'POST'); checks.push({ id: 'student.notifications.status', status: 'pass', detail: '消息筛选和批量已读 API 已触发' }); + assertStudentRuntimeHealthy('Student H5 journey'); return checks; } finally { - page.close(); + await page.close(); } } @@ -1928,9 +2116,10 @@ async function runTenantJourney(browser, portal, api) { throw new Error(`${error.message}\nMember diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`); } checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布、角色模板和成员绑定 API 已触发' }); + assertRuntimeHealthy(page, 'Tenant admin H5 journey'); return checks; } finally { - page.close(); + await page.close(); } } @@ -2016,9 +2205,109 @@ async function runPlatformJourney(browser, portal, api) { await clickTextAndConfirmForApi(page, api, '保存员工', '/api/platform-admin/staff', 'PUT'); await clickTextAndConfirmForApi(page, api, '禁用', '/api/platform-admin/staff/status', 'PATCH'); checks.push({ id: 'platform.staff.operations', status: 'pass', detail: '平台员工保存和禁用 API 已触发' }); + assertRuntimeHealthy(page, 'Platform admin H5 journey'); return checks; } finally { - page.close(); + await page.close(); + } +} + +async function runCrossPortalRuntimeProbe(browser, staticServers) { + const student = staticServers.find(item => item.portal === 'student'); + const tenant = staticServers.find(item => item.portal === 'tenant-admin'); + const platform = staticServers.find(item => item.portal === 'platform-admin'); + const page = await newPage(browser, `${student.baseUrl}${student.landingPath}`); + try { + await waitForText(page, '今日学习'); + await navigateAndExpect(page, tenant.baseUrl, tenant.landingPath, '工学题库商户后台'); + await page.setViewport(390, 844); + await navigateAndExpect(page, platform.baseUrl, platform.landingPath, 'SaaS 平台后台'); + assertRuntimeHealthy(page, 'Cross-portal desktop-to-mobile H5 probe'); + return { status: 'pass', portals: ['student', 'tenant-admin', 'platform-admin'], mobileViewport: '390x844' }; + } finally { + await page.close(); + } +} + +async function runTaroInputWatcherRaceProbe(browser, portal) { + const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`); + try { + await waitForText(page, 'SaaS 平台后台'); + const result = await page.evaluate(`(async () => { + await customElements.whenDefined('taro-input-core'); + const element = document.createElement('taro-input-core'); + element.className = 'runtime-input-watcher-race-probe'; + element.value = 'before-mount'; + document.body.appendChild(element); + if (typeof element.componentOnReady === 'function') await element.componentOnReady(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const input = element.querySelector('input'); + const beforeMountValue = input?.value || ''; + element.value = 'after-mount'; + await new Promise(resolve => requestAnimationFrame(resolve)); + const afterMountValue = input?.value || ''; + element.remove(); + return { beforeMountValue, afterMountValue }; + })()`); + if (result?.beforeMountValue !== 'before-mount' || result?.afterMountValue !== 'after-mount') { + throw new Error(`Taro Input watcher race probe did not synchronize values: ${JSON.stringify(result)}`); + } + assertRuntimeHealthy(page, 'Taro Input watcher pre-mount race probe'); + return { status: 'pass', ...result }; + } finally { + await page.close(); + } +} + +async function runTaroButtonLoadingRaceProbe(browser, portal) { + const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`); + try { + await waitForText(page, 'SaaS 平台后台'); + const result = await page.evaluate(`(async () => { + await customElements.whenDefined('taro-button-core'); + const element = document.createElement('taro-button-core'); + element.className = 'runtime-button-loading-race-probe'; + element.textContent = '运行时按钮探针'; + document.body.appendChild(element); + if (typeof element.componentOnReady === 'function') await element.componentOnReady(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + const loadingNode = element.querySelector('.weui-loading'); + const initialChildCount = element.children.length; + const initialDisplay = loadingNode ? getComputedStyle(loadingNode).display : ''; + for (let index = 0; index < 200; index += 1) { + element.loading = index % 2 === 0; + } + element.loading = true; + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const activeNode = element.querySelector('.weui-loading'); + const activeDisplay = activeNode ? getComputedStyle(activeNode).display : ''; + const activeChildCount = element.children.length; + + element.loading = false; + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const inactiveNode = element.querySelector('.weui-loading'); + const inactiveDisplay = inactiveNode ? getComputedStyle(inactiveNode).display : ''; + const inactiveChildCount = element.children.length; + element.remove(); + return { + loadingNodeStable: Boolean(loadingNode && loadingNode === activeNode && activeNode === inactiveNode), + childCounts: [initialChildCount, activeChildCount, inactiveChildCount], + displays: { initial: initialDisplay, active: activeDisplay, inactive: inactiveDisplay }, + }; + })()`); + if ( + !result?.loadingNodeStable + || new Set(result.childCounts || []).size !== 1 + || result?.displays?.active === 'none' + || result?.displays?.inactive !== 'none' + ) { + throw new Error(`Taro Button loading race probe did not keep a stable loading node: ${JSON.stringify(result)}`); + } + assertRuntimeHealthy(page, 'Taro Button loading node race probe'); + return { status: 'pass', toggles: 200, ...result }; + } finally { + await page.close(); } } @@ -2055,16 +2344,40 @@ function writeReport(payload) { async function main() { const options = parseArgs(process.argv.slice(2)); - const api = await createMockApiServer(); + const api = await createMockApiServer({ serveOverHttp: options.serveOnly }); const staticServers = []; let browser = null; try { + if (options.serveOnly) portals.forEach(assertServeOnlyPreviewBuild); for (const portal of portals) staticServers.push(await createStaticServer(portal, api.baseUrl)); + if (options.serveOnly) { + const payload = { + mockApi: api.baseUrl, + portals: staticServers.map(item => ({ + portal: item.portal, + url: `${item.baseUrl}${item.landingPath}`, + })), + }; + console.log(JSON.stringify(payload, null, 2)); + console.log('[smoke] browser QA servers are ready; press Ctrl+C to stop'); + await waitForShutdownSignal(); + return; + } browser = await startBrowser(options); const checks = []; checks.push(...await runStudentJourney(browser, staticServers.find(item => item.portal === 'student'), api)); checks.push(...await runTenantJourney(browser, staticServers.find(item => item.portal === 'tenant-admin'), api)); checks.push(...await runPlatformJourney(browser, staticServers.find(item => item.portal === 'platform-admin'), api)); + const crossPortal = await runCrossPortalRuntimeProbe(browser, staticServers); + const inputWatcherRace = await runTaroInputWatcherRaceProbe( + browser, + staticServers.find(item => item.portal === 'platform-admin'), + ); + const buttonLoadingRace = await runTaroButtonLoadingRaceProbe( + browser, + staticServers.find(item => item.portal === 'platform-admin'), + ); + const runtimeHealth = { status: 'pass', crossPortal, inputWatcherRace, buttonLoadingRace }; const payload = { generatedAt: new Date().toISOString(), @@ -2074,6 +2387,7 @@ async function main() { }, browser: { executable: browser.executable, + runtimeHealth, }, staticServers: staticServers.map(item => ({ portal: item.portal, baseUrl: item.baseUrl, landingPath: item.landingPath })), mockApi: { diff --git a/scripts/taro-h5-release-guardrails-test.js b/scripts/taro-h5-release-guardrails-test.js index b1cc6739..f2f299fb 100644 --- a/scripts/taro-h5-release-guardrails-test.js +++ b/scripts/taro-h5-release-guardrails-test.js @@ -237,8 +237,6 @@ function validateDistArtifact(portal, distName, options, collector) { 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 }); diff --git a/scripts/taro-h5-release-manifest-test.js b/scripts/taro-h5-release-manifest-test.js index 404fdd66..ae993a21 100644 --- a/scripts/taro-h5-release-manifest-test.js +++ b/scripts/taro-h5-release-manifest-test.js @@ -48,6 +48,7 @@ assert.equal(loosePayload.schemaVersion, 1); assert.equal(loosePayload.summary.portals, 3); assert.equal(loosePayload.portals.length, 3); assert.ok(loosePayload.portals.every(item => item.buildCommand.startsWith('npm run build:taro:h5:'))); +assert.ok(loosePayload.portals.filter(item => item.dist.exists).every(item => /^[0-9a-f]{64}$/.test(item.dist.treeSha256))); assert.ok(loosePayload.checks.some(item => item.id === 'build.student.portal_env' && item.status === 'pass')); const strictMissingRuntime = run(['--require-runtime-config']); diff --git a/scripts/taro-h5-release-manifest.js b/scripts/taro-h5-release-manifest.js index 4560d86f..f2aa8b62 100644 --- a/scripts/taro-h5-release-manifest.js +++ b/scripts/taro-h5-release-manifest.js @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { hashArtifactDirectory } from './release-artifact-hash.js'; const repoRoot = process.cwd(); const taroRoot = path.join(repoRoot, 'apps', 'taro'); @@ -163,6 +164,7 @@ function summarize(checks, portalResults) { portals: portals.length, distReady: portalResults.filter(item => item.dist?.exists && item.dist?.indexHtml).length, runtimeConfigs: portalResults.filter(item => item.runtimeConfig?.exists).length, + treeHashes: portalResults.filter(item => /^[0-9a-f]{64}$/.test(item.dist?.treeSha256 || '')).length, }; } @@ -291,6 +293,7 @@ function inspectDist(portal, collector, options) { dir: relative(dir), indexHtml: fs.existsSync(indexPath), indexSha256: '', + treeSha256: '', files: 0, totalBytes: 0, assetReferences: 0, @@ -303,9 +306,10 @@ function inspectDist(portal, collector, options) { } collector.pass(`dist.${portal.portal}.exists`, 'H5 dist directory exists', { dir: result.dir }); - const files = walkFiles(dir); - result.files = files.length; - result.totalBytes = files.reduce((sum, filePath) => sum + fs.statSync(filePath).size, 0); + const tree = hashArtifactDirectory(dir); + result.files = tree.files; + result.totalBytes = tree.totalBytes; + result.treeSha256 = tree.sha256; if (!result.indexHtml) { if (options.requireDist) collector.fail(`dist.${portal.portal}.index`, 'H5 index.html is missing', { file: relative(indexPath) }); @@ -322,6 +326,7 @@ function inspectDist(portal, collector, options) { collector.pass(`dist.${portal.portal}.index`, 'H5 index.html is deployable', { file: relative(indexPath), sha256: result.indexSha256, + treeSha256: result.treeSha256, assetReferences: result.assetReferences, }); } @@ -375,7 +380,7 @@ function printHuman(manifest) { console.log(`Taro H5 release manifest: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`); for (const portal of manifest.portals) { console.log(`[${portal.portal}] ${portal.buildCommand}`); - console.log(` dist=${portal.dist.dir} files=${portal.dist.files} bytes=${portal.dist.totalBytes} runtime=${portal.runtimeConfig.exists ? 'present' : 'missing'}`); + console.log(` dist=${portal.dist.dir} files=${portal.dist.files} bytes=${portal.dist.totalBytes} tree=${portal.dist.treeSha256 || 'missing'} runtime=${portal.runtimeConfig.exists ? 'present' : 'missing'}`); console.log(` landing=${portal.landingPath}`); } for (const item of manifest.checks.filter(check => check.status !== 'pass')) { diff --git a/scripts/taro-h5-static-smoke.js b/scripts/taro-h5-static-smoke.js index c206cb9d..92ec5e55 100644 --- a/scripts/taro-h5-static-smoke.js +++ b/scripts/taro-h5-static-smoke.js @@ -1,10 +1,22 @@ import fs from 'node:fs'; import http from 'node:http'; +import https from 'node:https'; import path from 'node:path'; import process from 'node:process'; +import selfsigned from 'selfsigned'; const repoRoot = process.cwd(); const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist'); +const smokeApiHostname = 'api-smoke.gongxue100.com'; +const smokeTls = selfsigned.generate([{ name: 'commonName', value: smokeApiHostname }], { + days: 1, + keySize: 2048, + algorithm: 'sha256', + extensions: [{ + name: 'subjectAltName', + altNames: [{ type: 2, value: smokeApiHostname }], + }], +}); const portals = [ { @@ -98,6 +110,35 @@ function textResponse(response, statusCode, body, headers = {}) { } async function request(input, options = {}) { + const target = new URL(input); + if (target.protocol === 'https:' && target.hostname === smokeApiHostname) { + return new Promise((resolve, reject) => { + const request = https.request({ + protocol: 'https:', + hostname: '127.0.0.1', + port: target.port, + path: `${target.pathname}${target.search}`, + method: options.method || 'GET', + headers: { host: target.host, ...(options.headers || {}) }, + servername: smokeApiHostname, + rejectUnauthorized: false, + }, response => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { + text += chunk; + }); + response.on('end', () => resolve({ + ok: response.statusCode >= 200 && response.statusCode < 300, + status: response.statusCode || 0, + headers: response.headers, + text, + })); + }); + request.on('error', reject); + request.end(); + }); + } const response = await fetch(input, { method: options.method || 'GET', headers: options.headers || {}, @@ -113,8 +154,8 @@ async function request(input, options = {}) { async function createMockApiServer() { const requests = []; - const server = http.createServer((req, res) => { - const url = new URL(req.url || '/', 'http://127.0.0.1'); + const server = https.createServer({ key: smokeTls.private, cert: smokeTls.cert }, (req, res) => { + const url = new URL(req.url || '/', `https://${smokeApiHostname}`); requests.push({ method: req.method, path: url.pathname, @@ -159,7 +200,7 @@ async function createMockApiServer() { const port = await listen(server); return { - baseUrl: `http://127.0.0.1:${port}`, + baseUrl: `https://${smokeApiHostname}:${port}`, requests, close: () => closeServer(server), }; @@ -172,7 +213,7 @@ async function createStaticServer(portal, apiBaseUrl) { apiBaseUrl, supabaseUrl: 'https://auth.example.test', supabasePublishableKey: 'sb_publishable_mock_key_for_static_smoke', - tenantCode: 'master', + tenantCode: '', }; const server = http.createServer((req, res) => { @@ -259,9 +300,10 @@ function validateRuntimeConfig(config, portal) { if (unknownKeys.length) errors.push(`unknown runtime config keys: ${unknownKeys.join(', ')}`); if (forbiddenKeys.length) errors.push(`forbidden runtime config keys: ${forbiddenKeys.join(', ')}`); if (config.portal !== portal.portal) errors.push(`portal mismatch: expected ${portal.portal}, got ${config.portal}`); - if (!String(config.apiBaseUrl || '').startsWith('http://127.0.0.1:')) errors.push('apiBaseUrl must point to the local mock API in smoke'); + if (!String(config.apiBaseUrl || '').startsWith(`https://${smokeApiHostname}:`)) errors.push('apiBaseUrl must point to the HTTPS mock API host in smoke'); if (!String(config.supabaseUrl || '').startsWith('https://')) errors.push('supabaseUrl must be HTTPS even in smoke runtime config'); if (!config.supabasePublishableKey) errors.push('supabasePublishableKey is required'); + if (String(config.tenantCode || '').trim()) errors.push('production H5 tenantCode must be empty and resolved from the browser origin'); for (const [key, value] of Object.entries(config)) { if (forbiddenValuePatterns.some(pattern => pattern.test(String(value)))) errors.push(`secret-looking value in ${key}`); } @@ -326,8 +368,8 @@ async function smokePortal(portal, api) { if (runtimeConfig) { const resolveUrl = new URL('/api/tenant/resolve', runtimeConfig.apiBaseUrl); - resolveUrl.searchParams.set('tenantCode', runtimeConfig.tenantCode); resolveUrl.searchParams.set('host', new URL(staticServer.baseUrl).host); + if (runtimeConfig.tenantCode) resolveUrl.searchParams.set('tenantCode', runtimeConfig.tenantCode); const tenantResolve = await request(resolveUrl, { headers: { 'x-smoke-portal': portal.portal } }); let tenantPayload = null; try { @@ -337,8 +379,12 @@ async function smokePortal(portal, api) { } checks.push({ id: `${portal.portal}.tenant_resolve_contract`, - ok: tenantResolve.ok && Boolean(tenantPayload?.item?.tenantId) && tenantPayload.item.features?.enableLeaderboard === false, - detail: `status=${tenantResolve.status}`, + ok: tenantResolve.ok + && Boolean(tenantPayload?.item?.tenantId) + && tenantPayload.item.features?.enableLeaderboard === false + && Boolean(api.requests.at(-1)?.query?.host) + && !Object.prototype.hasOwnProperty.call(api.requests.at(-1)?.query || {}, 'tenantCode'), + detail: `status=${tenantResolve.status} query=${JSON.stringify(api.requests.at(-1)?.query || {})}`, }); } else { checks.push({ diff --git a/scripts/taro-persona-contract-test.js b/scripts/taro-persona-contract-test.js index 485db9db..1a55a4ae 100644 --- a/scripts/taro-persona-contract-test.js +++ b/scripts/taro-persona-contract-test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; const repoRoot = process.cwd(); const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src'); @@ -11,7 +12,7 @@ const contracts = [ file: 'pages/student/home/index.tsx', mustContain: [ 'loadStudentDashboard', - 'loadCurrentUser', + 'useApp', '/pages/student/catalog/index', '/pages/student/review/index', '/pages/student/profile/index', @@ -75,10 +76,20 @@ const contracts = [ 'createOrder', 'createPayment', 'loadOrderStatus', - 'Taro.requestPayment', + 'launchPayment', '/pages/student/order-detail/index?orderNo=', ], }, + { + id: 'cross-platform.payment-adapter', + file: 'capabilities/payment.ts', + mustContain: [ + 'isWeappRuntime', + 'Taro.requestPayment', + 'openExternalUrl', + 'copyText', + ], + }, { id: 'student.profile-center', file: 'pages/student/profile/index.tsx', @@ -103,8 +114,8 @@ const contracts = [ id: 'tenant.workbench-permission-modules', file: 'pages/tenant-admin/workbench/index.tsx', mustContain: [ - 'loadTenantPermissions', - 'canOpenModule', + 'useApp', + 'canTenantMenu', '/pages/tenant-admin/dashboard/index', '/pages/tenant-admin/students/index', '/pages/tenant-admin/content/index', @@ -299,6 +310,7 @@ const contracts = [ const routeContracts = [ { id: 'student-first-web-launch', + portal: 'student', routes: [ 'pages/student/home/index', 'pages/student/catalog/index', @@ -312,6 +324,7 @@ const routeContracts = [ }, { id: 'tenant-admin-launch', + portal: 'tenant-admin', routes: [ 'pages/tenant-admin/workbench/index', 'pages/tenant-admin/dashboard/index', @@ -324,6 +337,7 @@ const routeContracts = [ }, { id: 'platform-admin-launch', + portal: 'platform-admin', routes: [ 'pages/platform-admin/workbench/index', 'pages/platform-admin/tenants/index', @@ -346,15 +360,29 @@ function assertNotContains(text, token, label) { assert.ok(!text.includes(token), `${label} must not contain ${token}`); } -function parseAppRoutes() { - const text = readText('app.config.ts'); - const match = text.match(/pages\s*:\s*\[([\s\S]*?)\]/m); - assert.ok(match, 'apps/taro/src/app.config.ts must define pages'); - return new Set([...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map(item => item[1])); +async function loadPortalRoutes(portal) { + process.env.TARO_APP_PORTAL = portal; + process.env.TARO_ENV = 'h5'; + globalThis.defineAppConfig = value => value; + const moduleUrl = pathToFileURL(path.join(taroSrc, 'app.config.ts')); + moduleUrl.searchParams.set('persona', portal); + return new Set(((await import(moduleUrl.href)).default.pages || [])); } -const appRoutes = parseAppRoutes(); +const originalPortal = process.env.TARO_APP_PORTAL; +const originalTaroEnv = process.env.TARO_ENV; +const routesByPortal = { + student: await loadPortalRoutes('student'), + 'tenant-admin': await loadPortalRoutes('tenant-admin'), + 'platform-admin': await loadPortalRoutes('platform-admin'), +}; +if (originalPortal === undefined) delete process.env.TARO_APP_PORTAL; +else process.env.TARO_APP_PORTAL = originalPortal; +if (originalTaroEnv === undefined) delete process.env.TARO_ENV; +else process.env.TARO_ENV = originalTaroEnv; + for (const contract of routeContracts) { + const appRoutes = routesByPortal[contract.portal]; for (const route of contract.routes) { assert.ok(appRoutes.has(route), `${contract.id} route must be registered: ${route}`); assert.ok(fs.existsSync(path.join(taroSrc, `${route}.tsx`)), `${contract.id} route file must exist: ${route}.tsx`); diff --git a/scripts/taro-route-contract-test.js b/scripts/taro-route-contract-test.js index aa6d117d..e23c9d06 100644 --- a/scripts/taro-route-contract-test.js +++ b/scripts/taro-route-contract-test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; const repoRoot = process.cwd(); const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src'); @@ -33,15 +34,6 @@ function walkIndexPages(dir) { return files; } -function parseAppRoutes() { - const text = readText(appConfigPath); - const match = text.match(/const\s+allPageRoutes\s*=\s*\[([\s\S]*?)\]/m); - assert.ok(match, 'apps/taro/src/app.config.ts must define const allPageRoutes = [...]'); - return [...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)] - .map(item => item[1].trim()) - .filter(route => route.startsWith('pages/')); -} - function parsePortalLandingRoutes() { const text = readText(appConfigPath); const match = text.match(/const\s+portalLandingRoutes\s*:\s*Record\s*=\s*\{([\s\S]*?)\}/m); @@ -66,11 +58,69 @@ function uniqueSorted(items) { return [...new Set(items)].sort(); } -const appRoutes = parseAppRoutes(); -const appRouteSet = new Set(appRoutes); +async function loadAppConfig(portal, taroEnv) { + process.env.TARO_APP_PORTAL = portal; + process.env.TARO_ENV = taroEnv; + globalThis.defineAppConfig = value => value; + const moduleUrl = pathToFileURL(appConfigPath); + moduleUrl.searchParams.set('portal', portal); + moduleUrl.searchParams.set('env', taroEnv); + return (await import(moduleUrl.href)).default; +} + +function expandedRoutes(config) { + const mainRoutes = config.pages || []; + const packageRoutes = (config.subPackages || config.subpackages || []).flatMap(item => ( + (item.pages || []).map(route => `${String(item.root || '').replace(/\/$/, '')}/${String(route).replace(/^\//, '')}`) + )); + return [...mainRoutes, ...packageRoutes]; +} + const actualRoutes = walkIndexPages(pagesRoot).map(routeFromIndexFile); const actualRouteSet = new Set(actualRoutes); const portalLandingRouteMap = parsePortalLandingRoutes(); +const originalPortal = process.env.TARO_APP_PORTAL; +const originalTaroEnv = process.env.TARO_ENV; +const portalConfigs = { + student: await loadAppConfig('student', 'h5'), + 'tenant-admin': await loadAppConfig('tenant-admin', 'h5'), + 'platform-admin': await loadAppConfig('platform-admin', 'h5'), +}; +const studentWeappConfig = await loadAppConfig('student', 'weapp'); +if (originalPortal === undefined) delete process.env.TARO_APP_PORTAL; +else process.env.TARO_APP_PORTAL = originalPortal; +if (originalTaroEnv === undefined) delete process.env.TARO_ENV; +else process.env.TARO_ENV = originalTaroEnv; + +const sharedH5Routes = ['pages/bootstrap/index', 'pages/student/login/index']; +const expectedRoutesByPortal = { + student: actualRoutes.filter(route => route === 'pages/bootstrap/index' || route.startsWith('pages/student/')), + 'tenant-admin': actualRoutes.filter(route => sharedH5Routes.includes(route) || route.startsWith('pages/tenant-admin/')), + 'platform-admin': actualRoutes.filter(route => sharedH5Routes.includes(route) || route.startsWith('pages/platform-admin/')), +}; + +for (const [portal, config] of Object.entries(portalConfigs)) { + const routes = config.pages || []; + assert.equal(routes.length, new Set(routes).size, `${portal} H5 config must not contain duplicate page routes`); + assert.equal(routes[0], portalLandingRouteMap[portal], `${portal} H5 landing route must be the first page`); + assert.deepEqual( + uniqueSorted(routes), + uniqueSorted(expectedRoutesByPortal[portal]), + `${portal} H5 build must package only its own portal pages plus shared bootstrap/login pages`, + ); +} + +assert.deepEqual(studentWeappConfig.pages, ['pages/bootstrap/index'], 'Student WeApp main package must contain only the bootstrap page'); +assert.equal(studentWeappConfig.subPackages?.length, 1, 'Student WeApp must use one stable student subpackage'); +assert.equal(studentWeappConfig.subPackages?.[0]?.root, 'pages/student', 'Student WeApp subpackage root must preserve existing student routes'); +assert.deepEqual( + uniqueSorted(expandedRoutes(studentWeappConfig)), + uniqueSorted(expectedRoutesByPortal.student), + 'Student WeApp main package and subpackage must cover every student route without admin pages', +); + +const appRoutes = uniqueSorted(Object.values(portalConfigs).flatMap(config => config.pages || [])); +const appRouteSet = new Set(appRoutes); assert.equal(appRoutes.length, appRouteSet.size, 'app.config.ts must not contain duplicate page routes'); @@ -127,4 +177,4 @@ for (const route of uniqueSorted(handoffRoutes)) { assert.ok(actualRouteSet.has(route), `Frontend handoff doc references a page file that is missing: ${route}`); } -console.log(`[PASS] Taro route contract (${appRoutes.length} registered pages)`); +console.log(`[PASS] Taro route contract (${appRoutes.length} registered pages; H5 portals cropped; WeApp student subpackage verified)`); diff --git a/scripts/taro-runtime-config-test.js b/scripts/taro-runtime-config-test.js index d044fd20..05e929ff 100644 --- a/scripts/taro-runtime-config-test.js +++ b/scripts/taro-runtime-config-test.js @@ -2,7 +2,27 @@ import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; const repoRoot = process.cwd(); +globalThis.__TARO_PUBLIC_BUILD_CONFIG__ = { + portal: 'student', + target: 'weapp', + releaseMode: 'production', + weappTenantMode: 'fixed', + apiBaseUrl: 'https://compiled-api.gongxue100.com', + supabaseUrl: 'https://compiled-auth.gongxue100.com', + supabasePublishableKey: 'sb_publishable_compiled_key', + tenantCode: 'compiled-tenant', +}; const envModule = await import(pathToFileURL(`${repoRoot}/apps/taro/src/env.ts`).href); +delete globalThis.__TARO_PUBLIC_BUILD_CONFIG__; + +assert.equal(envModule.appEnv.portal, 'student'); +assert.equal(envModule.appEnv.apiBaseUrl, 'https://compiled-api.gongxue100.com'); +assert.equal(envModule.appEnv.supabaseUrl, 'https://compiled-auth.gongxue100.com'); +assert.equal(envModule.appEnv.supabasePublishableKey, 'sb_publishable_compiled_key'); +assert.equal(envModule.appEnv.tenantCode, 'compiled-tenant'); +assert.equal(envModule.taroRuntimeEnv(), 'weapp'); +assert.equal(envModule.isWeappRuntime(), true); +assert.equal(envModule.taroWeappTenantMode(), 'fixed'); envModule.applyRuntimeConfig({ portal: 'tenant-admin', @@ -18,6 +38,9 @@ assert.equal(envModule.appEnv.supabaseUrl, 'https://auth.gongxue100.com'); assert.equal(envModule.appEnv.supabasePublishableKey, 'sb_publishable_public_key'); assert.equal(envModule.appEnv.tenantCode, 'tenant-a'); +envModule.applyRuntimeConfig({ tenantCode: '' }); +assert.equal(envModule.appEnv.tenantCode, '', 'An empty runtime tenantCode must clear a compiled tenant fallback'); + envModule.applyRuntimeConfig({ TARO_APP_PORTAL: 'platform-admin', TARO_APP_API_BASE_URL: 'https://api2.gongxue100.com', @@ -50,4 +73,100 @@ assert.throws( /Unknown key in runtime config: unexpectedFeatureFlag/, ); +globalThis.__TARO_PUBLIC_BUILD_CONFIG__ = { + portal: 'student', + target: 'h5', + releaseMode: 'production', + weappTenantMode: '', + apiBaseUrl: '', + supabaseUrl: '', + supabasePublishableKey: '', + tenantCode: '', +}; +globalThis.window = { + location: { origin: 'https://student.example.test' }, + fetch: async () => { + throw new Error('network unavailable'); + }, +}; +const productionH5Url = pathToFileURL(`${repoRoot}/apps/taro/src/env.ts`); +productionH5Url.searchParams.set('runtime-config-test', 'production-h5'); +const productionH5Env = await import(productionH5Url.href); +assert.equal(productionH5Env.appEnv.apiBaseUrl, '', 'Production H5 must not compile a local API fallback'); +assert.equal(productionH5Env.taroReleaseMode(), 'production'); +await assert.rejects( + () => productionH5Env.loadRuntimeConfig(), + /Production H5 runtime-config\.json request failed: network unavailable/, + 'Production H5 must fail closed when runtime config cannot be fetched', +); +globalThis.window.fetch = async () => ({ ok: false, status: 404 }); +await assert.rejects( + () => productionH5Env.loadRuntimeConfig(), + /Production H5 runtime-config\.json request failed with status 404/, + 'Production H5 must fail closed when runtime config is missing', +); +globalThis.window.fetch = async () => ({ ok: true, status: 200, text: async () => '' }); +await assert.rejects( + () => productionH5Env.loadRuntimeConfig(), + /Production H5 runtime-config\.json is empty or unreadable/, + 'Production H5 must fail closed when runtime config is unreadable', +); +globalThis.window.fetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ portal: 'student', apiBaseUrl: '' }), +}); +await assert.rejects( + () => productionH5Env.loadRuntimeConfig(), + /Production H5 runtime-config\.json apiBaseUrl must be an absolute HTTPS URL and must not use localhost or loopback/, + 'Production H5 must fail closed when runtime config omits its API endpoint', +); +for (const apiBaseUrl of [ + 'http://api.gongxue100.com', + 'https://localhost:8787', + 'https://127.0.0.1:8787', + 'https://[::1]:8787', +]) { + globalThis.window.fetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ portal: 'student', apiBaseUrl }), + }); + await assert.rejects( + () => productionH5Env.loadRuntimeConfig(), + /must be an absolute HTTPS URL and must not use localhost or loopback/, + `Production H5 must reject unsafe API endpoint ${apiBaseUrl}`, + ); +} +globalThis.window.fetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + portal: 'student', + apiBaseUrl: 'https://api.gongxue100.com/', + supabaseUrl: 'https://auth.gongxue100.com/', + supabasePublishableKey: 'sb_publishable_runtime_test', + tenantCode: '', + }), +}); +await productionH5Env.loadRuntimeConfig(); +assert.equal(productionH5Env.appEnv.apiBaseUrl, 'https://api.gongxue100.com'); +assert.equal(productionH5Env.appEnv.tenantCode, '', 'Production H5 must remain domain resolved'); +globalThis.window.fetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + portal: 'student', + apiBaseUrl: 'https://api.gongxue100.com', + tenantCode: 'campus-north', + }), +}); +await assert.rejects( + () => productionH5Env.loadRuntimeConfig(), + /tenantCode must be empty; tenant is resolved from the browser origin/, + 'Production H5 must reject a fixed tenant override', +); +delete globalThis.window; +delete globalThis.__TARO_PUBLIC_BUILD_CONFIG__; + console.log('[PASS] Taro runtime config guardrails'); diff --git a/scripts/taro-supply-chain-audit-test.js b/scripts/taro-supply-chain-audit-test.js new file mode 100644 index 00000000..d7816968 --- /dev/null +++ b/scripts/taro-supply-chain-audit-test.js @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + allowedInvalidEdges, + npmAuditArgs, + securedBundleDependencies, + validateAuditPayload, + validateNpmLsPayload, +} from './taro-supply-chain-audit.js'; +import { + enforceTaroH5RuntimePatches, + taroButtonLoadingPatch, + taroH5RuntimePatchDefinition, + taroInputWatcherPatch, +} from './taro-components-h5-runtime-patch.js'; + +const repoRoot = process.cwd(); +const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); +const packageLock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8')); +const taroPackage = JSON.parse(fs.readFileSync(path.join(repoRoot, 'apps', 'taro', 'package.json'), 'utf8')); + +assert.equal(taroPackage.devDependencies?.['@tarojs/components'], taroH5RuntimePatchDefinition.packageVersion); +assert.equal(taroPackage.scripts?.postinstall, taroH5RuntimePatchDefinition.postinstallCommand); +assert.equal(packageLock.packages?.['apps/taro']?.hasInstallScript, true); +const patchState = enforceTaroH5RuntimePatches({ root: repoRoot, mode: 'check' }); +assert.equal(patchState.status, 'pass'); +assert.equal(patchState.patches.inputWatcher.installedSha256, taroInputWatcherPatch.patchedSha256); +assert.equal(patchState.patches.buttonLoading.installedSha256, taroButtonLoadingPatch.patchedSha256); + +for (const [name, expected] of Object.entries(securedBundleDependencies)) { + assert.equal(packageJson.overrides?.[name], expected.version, `${name} override must stay exact`); + assert.equal(packageLock.packages?.[`node_modules/${name}`]?.version, expected.version, `${name} lock version must stay exact`); + assert.equal(packageLock.packages?.[`node_modules/${name}`]?.integrity, expected.integrity, `${name} lock integrity must stay exact`); +} + +const dependencyNode = (name, parents) => ({ + version: securedBundleDependencies[name].version, + invalid: parents.map(parent => `"${parent.declared}" from node_modules/${parent.parent}`).join(', '), + problems: [`invalid: ${name}@${securedBundleDependencies[name].version} /repo/node_modules/${name}`], +}); + +const edgesFor = dependency => allowedInvalidEdges.filter(edge => edge.dependency === dependency); +const validLs = { + error: { code: 'ELSPROBLEMS' }, + problems: Object.entries(securedBundleDependencies).map(([name, expected]) => `invalid: ${name}@${expected.version} /repo/node_modules/${name}`), + dependencies: { + '@tiku-saas/taro': { + dependencies: { + '@tarojs/components': { + dependencies: { + swiper: dependencyNode('swiper', edgesFor('swiper').filter(edge => edge.parent === '@tarojs/components')), + }, + }, + '@tarojs/plugin-platform-h5': { + dependencies: { + '@tarojs/components-react': { + dependencies: { + swiper: dependencyNode('swiper', edgesFor('swiper').filter(edge => edge.parent === '@tarojs/components-react')), + }, + }, + '@tarojs/taro-h5': { + dependencies: { + 'lodash-es': dependencyNode('lodash-es', edgesFor('lodash-es').filter(edge => edge.parent === '@tarojs/taro-h5')), + }, + }, + 'lodash-es': dependencyNode('lodash-es', edgesFor('lodash-es').filter(edge => edge.parent === '@tarojs/plugin-platform-h5')), + }, + }, + }, + }, + }, +}; + +const lsSummary = validateNpmLsPayload(validLs); +assert.equal(lsSummary.edges.length, 4); + +assert.throws( + () => validateNpmLsPayload({ ...validLs, problems: [...validLs.problems, 'extraneous: unsafe@1.0.0 /repo/node_modules/unsafe'] }), + /unexpected problem|unapproved problem/, + 'new npm ls problems must fail closed', +); + +const missingEdgeLs = structuredClone(validLs); +delete missingEdgeLs.dependencies['@tiku-saas/taro'].dependencies['@tarojs/plugin-platform-h5'].dependencies['@tarojs/components-react']; +assert.throws(() => validateNpmLsPayload(missingEdgeLs), /once per reviewed invalid edge|invalid-edge set changed/, 'the exception set must not silently shrink or change'); + +const auditFixture = { + vulnerabilities: { + '@tarojs/cli': { severity: 'high', isDirect: true }, + download: { severity: 'critical', isDirect: false, via: [] }, + 'git-clone': { severity: 'high', isDirect: false, via: [{ source: 1093404, severity: 'high' }] }, + esbuild: { severity: 'moderate', isDirect: false }, + }, + metadata: { + vulnerabilities: { info: 0, low: 0, moderate: 1, high: 2, critical: 1, total: 4 }, + }, +}; + +const auditSummary = validateAuditPayload(auditFixture); +assert.equal(auditSummary.reviewedHighCritical.length, 3); +assert.deepEqual(auditSummary.reviewedAdvisories, [1093404]); + +assert.throws( + () => validateAuditPayload({ + ...auditFixture, + vulnerabilities: { ...auditFixture.vulnerabilities, swiper: { severity: 'critical', isDirect: false } }, + }), + /still reports swiper/, + 'bundle dependency advisories must fail the gate', +); + +assert.throws( + () => validateAuditPayload({ + ...auditFixture, + vulnerabilities: { ...auditFixture.vulnerabilities, 'new-build-risk': { severity: 'high', isDirect: false } }, + }), + /new unreviewed high/, + 'new high or critical toolchain findings must require review', +); + +assert.throws( + () => validateAuditPayload({ + ...auditFixture, + vulnerabilities: { + ...auditFixture.vulnerabilities, + 'git-clone': { severity: 'high', isDirect: false, via: [{ source: 9999999, severity: 'high' }] }, + }, + }), + /new unreviewed high Taro advisory/, + 'new advisories on an already allowlisted package must require review', +); + +const auditArgs = npmAuditArgs('https://registry.npmjs.org/'); +assert.ok(auditArgs.includes('--workspace')); +assert.ok(auditArgs.includes('@tiku-saas/taro')); +assert.equal(auditArgs.some(arg => arg === '--omit=dev' || arg.startsWith('--omit=')), false, 'Taro audit must include dependencies that are marked dev but bundled into H5'); + +console.log('[PASS] Taro supply-chain audit contract'); diff --git a/scripts/taro-supply-chain-audit.js b/scripts/taro-supply-chain-audit.js new file mode 100644 index 00000000..0de5f73c --- /dev/null +++ b/scripts/taro-supply-chain-audit.js @@ -0,0 +1,316 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { enforceTaroH5RuntimePatches } from './taro-components-h5-runtime-patch.js'; + +const repoRoot = process.cwd(); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +export const securedBundleDependencies = Object.freeze({ + 'lodash-es': Object.freeze({ + version: '4.18.1', + integrity: 'sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==', + }), + swiper: Object.freeze({ + version: '12.1.2', + integrity: 'sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ==', + }), +}); + +export const allowedInvalidEdges = Object.freeze([ + Object.freeze({ parent: '@tarojs/components', dependency: 'swiper', declared: '11.1.15' }), + Object.freeze({ parent: '@tarojs/components-react', dependency: 'swiper', declared: '11.1.15' }), + Object.freeze({ parent: '@tarojs/plugin-platform-h5', dependency: 'lodash-es', declared: '4.17.21' }), + Object.freeze({ parent: '@tarojs/taro-h5', dependency: 'lodash-es', declared: '4.17.21' }), +]); + +export const reviewedHighCriticalToolchainPackages = new Set([ + '@tarojs/cli', + '@tarojs/plugin-doctor', + '@tarojs/webpack5-runner', + 'cacheable-request', + 'decompress', + 'download', + 'download-git-repo', + 'git-clone', + 'glob', + 'got', + 'html-minifier', + 'http-cache-semantics', + 'serialize-javascript', +]); + +export const reviewedHighCriticalAdvisories = new Set([ + 1093404, + 1102456, + 1105440, + 1109842, + 1113686, + 1122670, +]); + +export const npmLsArgs = Object.freeze([ + 'ls', + ...Object.keys(securedBundleDependencies), + '--workspace', + '@tiku-saas/taro', + '--all', + '--json', +]); + +export function npmAuditArgs(registry = process.env.NPM_AUDIT_REGISTRY || 'https://registry.npmjs.org/') { + return [ + 'audit', + `--registry=${registry}`, + '--workspace', + '@tiku-saas/taro', + '--audit-level=high', + '--json', + ]; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function dependencyProblem(problem) { + const match = /^invalid: ((?:@[^/\s]+\/)?[^@\s]+)@([^\s]+)\s+(.+)$/.exec(String(problem || '')); + return match ? { name: match[1], version: match[2], location: match[3] } : null; +} + +function expectedProblemKey(name) { + return `${name}@${securedBundleDependencies[name].version}`; +} + +function collectTreeProblems(node, dependencyName = '', state = { problems: [], invalidEdges: [] }) { + if (node?.error?.code === 'ELSPROBLEMS' && !state.errorCode) state.errorCode = node.error.code; + for (const problem of node?.problems || []) state.problems.push(String(problem)); + + if (node?.invalid) { + assert(securedBundleDependencies[dependencyName], `npm ls reported an unexpected invalid dependency: ${dependencyName}`); + const edgePattern = /"([^"]+)" from node_modules\/((?:@[^/,]+\/)?[^,\s]+)/g; + let match; + let matched = false; + while ((match = edgePattern.exec(String(node.invalid)))) { + matched = true; + state.invalidEdges.push({ parent: match[2], dependency: dependencyName, declared: match[1] }); + } + assert(matched, `npm ls returned an unparseable invalid edge for ${dependencyName}: ${node.invalid}`); + } + + assert(!node?.extraneous, `npm ls reported an extraneous dependency: ${dependencyName}`); + assert(!node?.missing, `npm ls reported a missing dependency: ${dependencyName}`); + + for (const [name, dependency] of Object.entries(node?.dependencies || {})) { + collectTreeProblems(dependency, name, state); + } + return state; +} + +export function validateNpmLsPayload(payload) { + assert(payload && typeof payload === 'object', 'npm ls did not return a JSON object'); + assert(payload.error?.code === 'ELSPROBLEMS', 'npm ls must fail only with the reviewed Taro exact-dependency ELSPROBLEMS result'); + + const state = collectTreeProblems(payload); + const problemCounts = new Map(); + const actualProblemKeys = new Set(); + for (const problem of state.problems) { + const parsed = dependencyProblem(problem); + assert(parsed, `npm ls returned an unexpected problem: ${problem}`); + assert(securedBundleDependencies[parsed.name], `npm ls returned an unapproved problem: ${problem}`); + assert(parsed.version === securedBundleDependencies[parsed.name].version, `npm ls problem uses an unexpected ${parsed.name} version: ${problem}`); + assert(parsed.location.replace(/\\/g, '/').endsWith(`/node_modules/${parsed.name}`), `npm ls problem has an unexpected location: ${problem}`); + const key = `${parsed.name}@${parsed.version}`; + actualProblemKeys.add(key); + problemCounts.set(key, (problemCounts.get(key) || 0) + 1); + } + + const expectedProblemKeys = new Set(Object.keys(securedBundleDependencies).map(expectedProblemKey)); + assert(actualProblemKeys.size === expectedProblemKeys.size, 'npm ls must report exactly the two reviewed invalid packages'); + for (const key of expectedProblemKeys) { + assert(actualProblemKeys.has(key), `npm ls did not report the reviewed invalid package ${key}`); + const dependency = key.slice(0, key.lastIndexOf('@')); + const expectedCount = 1 + allowedInvalidEdges.filter(edge => edge.dependency === dependency).length; + assert(problemCounts.get(key) === expectedCount, `npm ls must report ${key} once at the root and once per reviewed invalid edge`); + } + + const actualEdges = new Set(state.invalidEdges.map(edge => `${edge.parent}>${edge.dependency}@${edge.declared}`)); + const expectedEdges = new Set(allowedInvalidEdges.map(edge => `${edge.parent}>${edge.dependency}@${edge.declared}`)); + assert(actualEdges.size === expectedEdges.size, `npm ls invalid-edge set changed: expected ${expectedEdges.size}, received ${actualEdges.size}`); + for (const edge of expectedEdges) assert(actualEdges.has(edge), `npm ls did not report the reviewed invalid edge ${edge}`); + + return { + packages: [...expectedProblemKeys].sort(), + edges: allowedInvalidEdges.map(edge => ({ ...edge })), + }; +} + +export function validateAuditPayload(payload) { + assert(payload && typeof payload === 'object', 'npm audit did not return a JSON object'); + assert(payload.metadata?.vulnerabilities, 'npm audit payload is missing vulnerability metadata'); + + const vulnerabilities = payload.vulnerabilities || {}; + for (const dependency of Object.keys(securedBundleDependencies)) { + assert(!vulnerabilities[dependency], `npm audit still reports ${dependency}; the bundle mitigation is not effective`); + } + + const reviewed = []; + const moderate = []; + const observedAdvisories = new Set(); + for (const [name, vulnerability] of Object.entries(vulnerabilities)) { + const severity = String(vulnerability.severity || '').toLowerCase(); + if (severity === 'high' || severity === 'critical') { + assert(reviewedHighCriticalToolchainPackages.has(name), `new unreviewed ${severity} Taro vulnerability: ${name}`); + for (const via of vulnerability.via || []) { + if (!via || typeof via !== 'object') continue; + const viaSeverity = String(via.severity || '').toLowerCase(); + if (viaSeverity !== 'high' && viaSeverity !== 'critical') continue; + assert(reviewedHighCriticalAdvisories.has(via.source), `new unreviewed ${viaSeverity} Taro advisory ${via.source} in ${name}`); + observedAdvisories.add(via.source); + } + reviewed.push({ name, severity, direct: Boolean(vulnerability.isDirect) }); + } else { + moderate.push({ name, severity, direct: Boolean(vulnerability.isDirect) }); + } + } + + return { + counts: { ...payload.metadata.vulnerabilities }, + reviewedAdvisories: [...observedAdvisories].sort((left, right) => left - right), + reviewedHighCritical: reviewed.sort((left, right) => left.name.localeCompare(right.name)), + other: moderate.sort((left, right) => left.name.localeCompare(right.name)), + }; +} + +function findPackageJsonFromEntry(entryPath, expectedName) { + let current = path.dirname(entryPath); + while (current !== path.dirname(current)) { + const candidate = path.join(current, 'package.json'); + if (fs.existsSync(candidate)) { + const manifest = readJson(candidate); + if (manifest.name === expectedName) return candidate; + } + current = path.dirname(current); + } + throw new Error(`cannot locate package.json for ${expectedName}`); +} + +function resolveDependencyPackage(parentPackagePath, dependency) { + const requireFromParent = createRequire(parentPackagePath); + try { + return requireFromParent.resolve(`${dependency}/package.json`); + } catch { + return findPackageJsonFromEntry(requireFromParent.resolve(dependency), dependency); + } +} + +export function validateManifestLockAndInstall(root = repoRoot) { + const rootManifest = readJson(path.join(root, 'package.json')); + const lock = readJson(path.join(root, 'package-lock.json')); + const installed = {}; + + for (const [dependency, expected] of Object.entries(securedBundleDependencies)) { + assert(rootManifest.overrides?.[dependency] === expected.version, `package.json must override ${dependency} to ${expected.version}`); + const lockEntry = lock.packages?.[`node_modules/${dependency}`]; + assert(lockEntry?.version === expected.version, `package-lock.json must resolve ${dependency} to ${expected.version}`); + assert(lockEntry?.integrity === expected.integrity, `package-lock.json has an unexpected integrity for ${dependency}`); + + const installedManifest = readJson(path.join(root, 'node_modules', dependency, 'package.json')); + assert(installedManifest.version === expected.version, `node_modules has ${dependency}@${installedManifest.version}; run npm ci to install the secured lock`); + installed[dependency] = installedManifest.version; + } + + for (const edge of allowedInvalidEdges) { + const parentPackagePath = path.join(root, 'node_modules', ...edge.parent.split('/'), 'package.json'); + assert(fs.existsSync(parentPackagePath), `reviewed Taro parent package is not installed: ${edge.parent}`); + const parentManifest = readJson(parentPackagePath); + assert(parentManifest.dependencies?.[edge.dependency] === edge.declared, `${edge.parent} no longer declares the reviewed ${edge.dependency}@${edge.declared} edge; remove or update the exception`); + const resolvedPackagePath = resolveDependencyPackage(parentPackagePath, edge.dependency); + const resolvedManifest = readJson(resolvedPackagePath); + assert(resolvedManifest.version === securedBundleDependencies[edge.dependency].version, `${edge.parent} resolves ${edge.dependency}@${resolvedManifest.version} instead of the secured override`); + } + + return installed; +} + +function runNpm(args) { + return spawnSync(npmCommand, args, { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + env: process.env, + }); +} + +function parseCommandJson(result, label) { + try { + return JSON.parse(result.stdout || '{}'); + } catch (error) { + throw new Error(`${label} did not return valid JSON: ${error.message}`); + } +} + +export function runSupplyChainAudit() { + const versions = validateManifestLockAndInstall(); + const h5RuntimePatches = enforceTaroH5RuntimePatches({ root: repoRoot, mode: 'check' }); + + const lsResult = runNpm(npmLsArgs); + assert(lsResult.status === 1, `npm ls returned ${lsResult.status}; expected the reviewed ELSPROBLEMS status`); + const invalid = validateNpmLsPayload(parseCommandJson(lsResult, 'npm ls')); + + const auditResult = runNpm(npmAuditArgs()); + assert(auditResult.status === 0 || auditResult.status === 1, `npm audit failed operationally with status ${auditResult.status}: ${auditResult.stderr}`); + const audit = validateAuditPayload(parseCommandJson(auditResult, 'npm audit')); + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + status: 'pass-with-reviewed-toolchain-risk', + securedBundleDependencies: versions, + h5RuntimePatches, + npmLs: invalid, + audit, + riskBoundary: { + appliesTo: 'Taro 4.2.0 CLI and build toolchain; swiper and lodash-es bundle advisories are mitigated', + controls: [ + 'build only on isolated trusted runners', + 'do not expose Taro development servers to public networks', + 'do not process untrusted templates, archives, repositories, or CLI arguments', + 'publish only reviewed apps/taro/dist static artifacts', + ], + }, + }; +} + +function printHuman(result) { + console.log('[PASS] Taro supply-chain override and install state'); + console.log(`[PASS] bundle dependencies: ${Object.entries(result.securedBundleDependencies).map(([name, version]) => `${name}@${version}`).join(', ')}`); + const patchSummary = Object.entries(result.h5RuntimePatches.patches) + .map(([id, patch]) => `${id}=${patch.installedSha256}`) + .join(', '); + console.log(`[PASS] Taro H5 runtime patches: ${result.h5RuntimePatches.version} ${patchSummary}`); + console.log(`[PASS] npm ls exceptions restricted to ${result.npmLs.edges.length} reviewed Taro exact-dependency edges`); + console.log(`[WARN] full Taro workspace audit remains ${result.audit.counts.total} findings (${result.audit.counts.critical} critical, ${result.audit.counts.high} high, ${result.audit.counts.moderate} moderate)`); + console.log('[WARN] remaining high/critical findings are confined to the reviewed Taro CLI/build-toolchain package allowlist'); +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + const json = process.argv.includes('--json'); + try { + const result = runSupplyChainAudit(); + if (json) console.log(JSON.stringify(result, null, 2)); + else printHuman(result); + } catch (error) { + if (json) console.log(JSON.stringify({ schemaVersion: 1, status: 'fail', error: error.message }, null, 2)); + else console.error(`[FAIL] ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/taro-weapp-release-guardrails.js b/scripts/taro-weapp-release-guardrails.js new file mode 100644 index 00000000..23433765 --- /dev/null +++ b/scripts/taro-weapp-release-guardrails.js @@ -0,0 +1,221 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { + validateProductionApiBaseUrl, + validateProductionTenantCode, + validateProductionWechatAppId, + validateTenantCodeFormat, +} from './build-weapp-student.js'; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(scriptPath), '..'); +const defaultDistRoot = path.join(repoRoot, 'apps', 'taro', 'dist', 'weapp-student'); + +function walkFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const filePath = path.join(dir, entry.name); + return entry.isDirectory() ? walkFiles(filePath) : [filePath]; + }); +} + +function directoryBytes(dir) { + return walkFiles(dir).reduce((total, filePath) => total + fs.statSync(filePath).size, 0); +} + +function parsedJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +export function extractCompiledPublicBuildConfig(source) { + const normalizedSource = source.replace(/"([A-Za-z][A-Za-z0-9]*)":/g, '$1:'); + const marker = 'weappTenantMode:'; + const configs = []; + let markerIndex = normalizedSource.indexOf(marker); + while (markerIndex >= 0) { + const start = normalizedSource.lastIndexOf('{portal:', markerIndex); + if (start >= 0) { + let inString = false; + let escaped = false; + let depth = 0; + for (let index = start; index < normalizedSource.length; index += 1) { + const char = normalizedSource[index]; + if (inString) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"') inString = true; + else if (char === '{') depth += 1; + else if (char === '}') { + depth -= 1; + if (depth === 0) { + const candidate = normalizedSource.slice(start, index + 1); + const keys = ['portal', 'target', 'releaseMode', 'weappTenantMode', 'apiBaseUrl', 'supabaseUrl', 'supabasePublishableKey', 'tenantCode']; + const config = Object.fromEntries(keys.map(key => { + const match = candidate.match(new RegExp(`(?:^|[,\\{])${key}:"((?:\\\\.|[^"\\\\])*)"`)); + if (!match) return [key, '']; + return [key, JSON.parse(`"${match[1]}"`)]; + })); + if (config.portal && config.target && config.weappTenantMode) configs.push(config); + break; + } + } + } + } + markerIndex = normalizedSource.indexOf(marker, markerIndex + marker.length); + } + + const unique = [...new Map(configs.map(config => [JSON.stringify(config), config])).values()]; + if (!unique.length) throw new Error('Compiled __TARO_PUBLIC_BUILD_CONFIG__ was not found in common.js'); + if (unique.length > 1) throw new Error('Multiple conflicting public build configs were found in common.js'); + return unique[0]; +} + +function validationError(validate, value) { + try { + validate(value); + return ''; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +export function inspectWeappRelease({ distRoot = defaultDistRoot, requireProduction = false } = {}) { + const checks = []; + const push = (status, id, message, details = {}) => checks.push({ status, id, message, details }); + + if (!fs.existsSync(distRoot)) { + push('fail', 'weapp.dist.exists', 'Student WeApp output is missing', { dist: path.relative(repoRoot, distRoot) }); + return checks; + } + + push('pass', 'weapp.dist.exists', 'Student WeApp output exists'); + const projectConfigPath = path.join(distRoot, 'project.config.json'); + const appConfigPath = path.join(distRoot, 'app.json'); + const commonPath = path.join(distRoot, 'common.js'); + if (![projectConfigPath, appConfigPath, commonPath].every(fs.existsSync)) { + push('fail', 'weapp.artifacts.required', 'WeApp output is missing project.config.json, app.json, or common.js'); + return checks; + } + + const projectConfig = parsedJson(projectConfigPath); + const appConfig = parsedJson(appConfigPath); + const appId = String(projectConfig.appid || ''); + const appIdError = validationError(validateProductionWechatAppId, appId); + if (requireProduction && appIdError) push('fail', 'weapp.appid', appIdError); + else push(appIdError ? 'warn' : 'pass', 'weapp.appid', appIdError || 'WeApp AppID is production-ready'); + + if (requireProduction && projectConfig.setting?.urlCheck !== true) push('fail', 'weapp.url_check', 'Production WeApp must enable legal-domain URL checks'); + else push(projectConfig.setting?.urlCheck === true ? 'pass' : 'warn', 'weapp.url_check', projectConfig.setting?.urlCheck === true ? 'WeApp legal-domain URL checks are enabled' : 'WeApp URL checks are disabled for preview'); + + let compiledConfig = null; + try { + compiledConfig = extractCompiledPublicBuildConfig(fs.readFileSync(commonPath, 'utf8')); + push('pass', 'weapp.public_config.found', 'Compiled public build config was found in common.js'); + } catch (error) { + push('fail', 'weapp.public_config.found', error instanceof Error ? error.message : String(error)); + } + if (compiledConfig) { + if (compiledConfig.portal === 'student' && compiledConfig.target === 'weapp') { + push('pass', 'weapp.public_config.identity', 'Compiled public build config targets the student WeApp'); + } else { + push('fail', 'weapp.public_config.identity', 'Compiled public build config must target portal=student and target=weapp', { compiledConfig }); + } + + if (requireProduction && compiledConfig.releaseMode !== 'production') { + push('fail', 'weapp.public_config.release_mode', 'Production guard requires releaseMode=production', { releaseMode: compiledConfig.releaseMode }); + } else { + push(compiledConfig.releaseMode === 'production' ? 'pass' : 'warn', 'weapp.public_config.release_mode', `Compiled release mode is ${compiledConfig.releaseMode || 'missing'}`); + } + + const tenantMode = String(compiledConfig.weappTenantMode || ''); + if (tenantMode !== 'fixed' && tenantMode !== 'launch') { + push('fail', 'weapp.public_config.tenant_mode', 'Compiled weappTenantMode must be fixed or launch'); + } else { + push('pass', 'weapp.public_config.tenant_mode', `Compiled WeApp tenant mode is ${tenantMode}`); + const tenantCode = String(compiledConfig.tenantCode || ''); + if (tenantMode === 'launch') { + if (tenantCode) push('fail', 'weapp.public_config.tenant_code', 'Launch mode must not retain a compiled tenant fallback', { tenantCode }); + else push('pass', 'weapp.public_config.tenant_code', 'Launch mode contains no compiled tenant fallback'); + } else { + const tenantError = validationError(requireProduction ? validateProductionTenantCode : validateTenantCodeFormat, tenantCode); + if (tenantError) push('fail', 'weapp.public_config.tenant_code', tenantError); + else push('pass', 'weapp.public_config.tenant_code', 'Fixed mode contains a valid compiled tenant code'); + } + } + + const apiBaseUrl = String(compiledConfig.apiBaseUrl || ''); + const apiError = validationError(validateProductionApiBaseUrl, apiBaseUrl); + if (requireProduction && apiError) push('fail', 'weapp.public_config.api', apiError); + else push(apiError ? 'warn' : 'pass', 'weapp.public_config.api', apiError || 'Compiled API endpoint is production-ready'); + } + + const mainPages = Array.isArray(appConfig.pages) ? appConfig.pages : []; + const subpackages = appConfig.subPackages || appConfig.subpackages || []; + if (mainPages.length === 1 && mainPages[0] === 'pages/bootstrap/index') push('pass', 'weapp.main_pages', 'WeApp main package contains only bootstrap'); + else push('fail', 'weapp.main_pages', 'WeApp main package must contain only bootstrap', { mainPages }); + if (subpackages.length === 1 && subpackages[0]?.root === 'pages/student') push('pass', 'weapp.subpackage', 'Student pages use one subpackage'); + else push('fail', 'weapp.subpackage', 'Student WeApp subpackage contract is invalid'); + + const totalBytes = directoryBytes(distRoot); + const subpackageBytes = directoryBytes(path.join(distRoot, 'pages', 'student')); + const mainBytes = totalBytes - subpackageBytes; + if (totalBytes > 4 * 1024 * 1024) push('fail', 'weapp.size.total', 'WeApp output exceeds the 4 MiB release budget', { totalBytes }); + else push('pass', 'weapp.size.total', 'WeApp output is within the 4 MiB release budget', { totalBytes }); + if (mainBytes > 2 * 1024 * 1024) push('fail', 'weapp.size.main', 'WeApp main package exceeds the 2 MiB release budget', { mainBytes }); + else push('pass', 'weapp.size.main', 'WeApp main package is within the 2 MiB release budget', { mainBytes }); + + const forbidden = [ + ['database-url', /postgres(?:ql)?:\/\//i], + ['service-role', /\b(?:service_role|sb_secret_)\b/i], + ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/i], + ]; + const violations = []; + const localEndpointFiles = []; + const allowedDependencyLocalEndpoints = []; + for (const filePath of walkFiles(distRoot).filter(item => /\.(?:js|json|wxml|wxss|txt)$/.test(item))) { + const source = fs.readFileSync(filePath, 'utf8'); + const relativeFile = path.relative(repoRoot, filePath); + const localEndpoints = source.match(/https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?/gi) || []; + for (const endpoint of localEndpoints) { + if (relativeFile === 'apps/taro/dist/weapp-student/vendors.js' && endpoint.toLowerCase() === 'http://localhost:9999') { + allowedDependencyLocalEndpoints.push({ file: relativeFile, endpoint, dependency: '@supabase/auth-js' }); + } else { + localEndpointFiles.push(relativeFile); + } + } + for (const [id, pattern] of forbidden) { + if (pattern.test(source)) violations.push({ id, file: relativeFile }); + } + } + if (allowedDependencyLocalEndpoints.length) { + push('pass', 'weapp.dependency_local_placeholder', 'Known inert Supabase SDK local placeholder is explicitly allowlisted', { matches: allowedDependencyLocalEndpoints }); + } + if (localEndpointFiles.length) push(requireProduction ? 'fail' : 'warn', 'weapp.local_endpoints', 'WeApp output contains local API endpoints', { files: [...new Set(localEndpointFiles)].slice(0, 20) }); + else push('pass', 'weapp.local_endpoints', 'WeApp output contains no local API endpoints'); + if (violations.length) push('fail', 'weapp.forbidden_patterns', 'WeApp output contains secret-looking values', { violations: violations.slice(0, 20) }); + else push('pass', 'weapp.forbidden_patterns', 'WeApp output contains no secret-looking values'); + return checks; +} + +export function runGuardrails({ argv = process.argv.slice(2), distRoot = defaultDistRoot } = {}) { + const requireProduction = argv.includes('--production'); + const json = argv.includes('--json'); + const checks = inspectWeappRelease({ distRoot, requireProduction }); + const summary = checks.reduce((result, item) => ({ ...result, [item.status]: result[item.status] + 1 }), { fail: 0, warn: 0, pass: 0 }); + const payload = { summary, checks }; + if (json) console.log(JSON.stringify(payload, null, 2)); + else { + console.log(`Taro WeApp release guardrails: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`); + checks.filter(item => item.status !== 'pass').forEach(item => console.log(`[${item.status.toUpperCase()}] ${item.id}: ${item.message}`)); + } + return summary.fail > 0 ? 1 : 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + process.exitCode = runGuardrails(); +} diff --git a/scripts/tenant-foreign-key-audit-test.js b/scripts/tenant-foreign-key-audit-test.js new file mode 100644 index 00000000..3cfee3e1 --- /dev/null +++ b/scripts/tenant-foreign-key-audit-test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { + EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT, + EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256, + buildTenantForeignKeyViolationQuery, + tenantForeignKeyExceptions, + tenantForeignKeySchemaSha256, +} from './lib/tenant-foreign-key-audit.js'; +import { + assertTenantForeignKeyAuditTarget, + parseTenantForeignKeyAuditOptions, +} from './tenant-foreign-key-audit.js'; + +const relation = overrides => ({ + childTable: 'child_rows', + constraintName: 'child_rows_parent_id_fkey', + childColumns: ['parent_id'], + parentTable: 'parent_rows', + parentColumns: ['id'], + validated: true, + updateAction: 'a', + deleteAction: 'a', + ...overrides, +}); + +assert.equal(EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT, 189); +assert.match(EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256, /^[0-9a-f]{64}$/); +assert.equal(tenantForeignKeyExceptions().length, 3); + +const digestA = tenantForeignKeySchemaSha256([relation({ childTable: 'b' }), relation({ childTable: 'a' })]); +const digestB = tenantForeignKeySchemaSha256([relation({ childTable: 'a' }), relation({ childTable: 'b' })]); +assert.equal(digestA, digestB, 'schema fingerprint must not depend on catalog row ordering'); + +const defaultQuery = buildTenantForeignKeyViolationQuery([relation({})]); +assert.match(defaultQuery, /child\.tenant_id is distinct from parent\.tenant_id/i); + +const globalRuleQuery = buildTenantForeignKeyViolationQuery([ + relation({ + childTable: 'platform_audit_alerts', + constraintName: 'platform_audit_alerts_rule_id_fkey', + childColumns: ['rule_id'], + parentTable: 'platform_audit_alert_rules', + }), +]); +assert.match(globalRuleQuery, /parent\.tenant_id is not null and child\.tenant_id is distinct from parent\.tenant_id/i); + +const platformBankQuery = buildTenantForeignKeyViolationQuery([ + relation({ + childTable: 'tenant_question_bank_adoptions', + constraintName: 'tenant_question_bank_adoptions_source_question_bank_id_fkey', + childColumns: ['source_question_bank_id'], + parentTable: 'question_banks', + }), +]); +assert.match(platformBankQuery, /parent\.source_scope is distinct from 'platform'/i); + +assert.throws( + () => parseTenantForeignKeyAuditOptions([], {}), + /DATABASE_URL is required/, +); +assert.throws( + () => assertTenantForeignKeyAuditTarget('postgresql://postgres:postgres@127.0.0.1:5432/postgres'), + /reserved for tikupro-pg/, +); +assert.throws( + () => assertTenantForeignKeyAuditTarget('postgresql://postgres:postgres@tikupro-pg:55432/postgres'), + /tikupro-pg targets are forbidden/, +); +assert.deepEqual( + assertTenantForeignKeyAuditTarget('postgresql://postgres:secret@127.0.0.1:55432/postgres'), + { host: '127.0.0.1', port: '55432', database: 'postgres', user: 'postgres' }, +); + +const readiness = fs.readFileSync('scripts/production-readiness-check.js', 'utf8'); +assert.match(readiness, /db\.tenant_foreign_keys\.schema/); +const launchGate = fs.readFileSync('scripts/production-launch-gate.js', 'utf8'); +assert.match(launchGate, /db\.tenant-foreign-key-audit/); + +console.log('[PASS] tenant foreign key audit contract'); diff --git a/scripts/tenant-foreign-key-audit.js b/scripts/tenant-foreign-key-audit.js new file mode 100644 index 00000000..38daef87 --- /dev/null +++ b/scripts/tenant-foreign-key-audit.js @@ -0,0 +1,128 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import pg from 'pg'; +import { + assertDestructiveTestDatabase, + describeDatabaseTarget, + resolveDestructiveTestConfirmation, +} from './lib/destructive-test-database-guard.js'; +import { + TENANT_FOREIGN_KEY_AUDIT_KIND, + auditTenantForeignKeyData, + loadTenantForeignKeyRelations, + summarizeTenantForeignKeySchema, + tenantForeignKeyExceptions, +} from './lib/tenant-foreign-key-audit.js'; + +const { Client } = pg; +const BLOCKED_TARGET_PATTERN = /(?:^|[-_.])tikupro(?:-pg)?(?:$|[-_.])/i; + +function argumentValue(argv, name) { + const index = argv.indexOf(name); + if (index >= 0) return String(argv[index + 1] || '').trim(); + const prefix = `${name}=`; + const item = argv.find(value => value.startsWith(prefix)); + return item ? item.slice(prefix.length).trim() : ''; +} + +export function parseTenantForeignKeyAuditOptions(argv = process.argv.slice(2), env = process.env) { + const databaseUrl = String(env.DATABASE_URL || '').trim(); + if (!databaseUrl) throw new Error('DATABASE_URL is required'); + const timeoutValue = argumentValue(argv, '--statement-timeout-ms') || env.TENANT_FK_AUDIT_STATEMENT_TIMEOUT_MS || '120000'; + const statementTimeoutMs = Number(timeoutValue); + if (!Number.isInteger(statementTimeoutMs) || statementTimeoutMs < 1_000 || statementTimeoutMs > 900_000) { + throw new Error('statement timeout must be an integer between 1000 and 900000 milliseconds'); + } + return { + databaseUrl, + statementTimeoutMs, + confirmation: resolveDestructiveTestConfirmation(env, argv), + json: argv.includes('--json'), + quiet: argv.includes('--quiet'), + writePath: argumentValue(argv, '--write'), + }; +} + +export function assertTenantForeignKeyAuditTarget(databaseUrl) { + const target = describeDatabaseTarget(databaseUrl); + const host = target.host.replace(/^\[(.*)\]$/, '$1').toLowerCase(); + if (['127.0.0.1', 'localhost', '::1'].includes(host) && target.port === '5432') { + throw new Error('Refusing tenant foreign key audit: local port 5432 is reserved for tikupro-pg'); + } + if ([target.host, target.database, target.user].some(value => BLOCKED_TARGET_PATTERN.test(value))) { + throw new Error('Refusing tenant foreign key audit: tikupro-pg targets are forbidden'); + } + return target; +} + +export async function runTenantForeignKeyAudit(options) { + const startedAt = new Date(); + const target = assertTenantForeignKeyAuditTarget(options.databaseUrl); + const client = new Client({ + connectionString: options.databaseUrl, + application_name: 'tiku-tenant-foreign-key-audit', + }); + await client.connect(); + try { + const safety = await assertDestructiveTestDatabase({ + client, + databaseUrl: options.databaseUrl, + confirmation: options.confirmation, + operation: 'tenant foreign key full-data audit on an isolated clone', + }); + const relations = await loadTenantForeignKeyRelations(client); + const schema = summarizeTenantForeignKeySchema(relations); + const violations = schema.schemaMatches + ? await auditTenantForeignKeyData(client, relations, options.statementTimeoutMs) + : []; + const completedAt = new Date(); + return { + schemaVersion: 1, + kind: TENANT_FOREIGN_KEY_AUDIT_KIND, + startedAt: startedAt.toISOString(), + completedAt: completedAt.toISOString(), + durationMs: completedAt.getTime() - startedAt.getTime(), + target, + safety: { databaseEnvironment: safety.environment }, + schema, + exceptions: tenantForeignKeyExceptions(), + data: { + auditedRelations: schema.schemaMatches ? relations.length : 0, + invalidRelations: violations.length, + violations, + }, + status: schema.schemaMatches && violations.length === 0 ? 'pass' : 'fail', + }; + } finally { + await client.end(); + } +} + +async function main() { + let options; + try { + options = parseTenantForeignKeyAuditOptions(); + const artifact = await runTenantForeignKeyAudit(options); + if (options.writePath) { + const outputPath = path.resolve(process.cwd(), options.writePath); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + } + if (options.json) console.log(JSON.stringify(artifact, null, 2)); + else if (!options.quiet) { + console.log(`Tenant foreign key audit: ${artifact.status.toUpperCase()}`); + console.log(`Relations: ${artifact.schema.relationCount}; exceptions: ${artifact.schema.exceptionCount}; invalid: ${artifact.data.invalidRelations}`); + if (options.writePath) console.log(`Artifact: ${path.resolve(process.cwd(), options.writePath)}`); + } + if (artifact.status !== 'pass') process.exitCode = 1; + } catch (error) { + const failure = { status: 'fail', error: error instanceof Error ? error.message : String(error) }; + if (options?.json || process.argv.includes('--json')) console.log(JSON.stringify(failure, null, 2)); + else console.error(failure.error); + process.exitCode = 1; + } +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main(); diff --git a/scripts/tenant-permission-resolution-test.js b/scripts/tenant-permission-resolution-test.js new file mode 100644 index 00000000..0877bc68 --- /dev/null +++ b/scripts/tenant-permission-resolution-test.js @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; + +const { + hasResolvedTenantPermission, + hasTenantPermission, +} = await import('../apps/api/src/features/tenant-admin/auth.ts'); + +function auth(role, permissions = {}, templatePermissions = {}) { + return { role, permissions, templatePermissions }; +} + +assert.equal( + hasTenantPermission(auth('teacher'), 'content:questions:write'), + true, + 'teacher role defaults should grant content permissions', +); +assert.equal( + hasTenantPermission(auth('teacher', { 'content:*': false }), 'content:questions:write'), + false, + 'member wildcard deny must override role defaults', +); +assert.equal( + hasTenantPermission(auth('tenant_operator', { 'content:questions:write': false }), 'content:questions:write'), + false, + 'member exact deny must override role defaults', +); +assert.equal( + hasTenantPermission(auth('teacher', { 'content:questions:write': true, 'content:*': false }), 'content:questions:write'), + true, + 'member exact allow must take precedence over a broader member deny', +); +assert.equal( + hasTenantPermission(auth('teacher', {}, { 'content:*': false }), 'content:questions:write'), + false, + 'template wildcard deny must override role defaults', +); +assert.equal( + hasTenantPermission(auth('teacher', { 'content:*': false }, { 'content:questions:write': true }), 'content:questions:write'), + false, + 'member decisions must take precedence over template decisions', +); +assert.equal( + hasResolvedTenantPermission(auth('teacher'), 'content:analytics:read', false), + false, + 'a caller may narrow role defaults for sensitive content permissions', +); +assert.equal( + hasResolvedTenantPermission(auth('teacher', { 'content:analytics:read': true }), 'content:analytics:read', false), + true, + 'an explicit member grant should work when the caller role default is denied', +); +assert.equal( + hasResolvedTenantPermission(auth('tenant_owner', { 'content:*': false }), 'content:write', true), + false, + 'explicit deny must apply even to a role that is allowed by default', +); + +console.log('[PASS] tenant permission resolution precedence'); diff --git a/scripts/tenant-resolve-contract-test.js b/scripts/tenant-resolve-contract-test.js new file mode 100644 index 00000000..b40f89a4 --- /dev/null +++ b/scripts/tenant-resolve-contract-test.js @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { HttpError } from '../apps/api/src/core/errors.ts'; +import { normalizeTenantHost, selectTenantLocator } from '../apps/api/src/features/tenant/locator.ts'; +import { resolveTenantRoute } from '../apps/api/src/features/tenant/routes.ts'; + +const TENANT_ID = '00000000-0000-4000-8000-000000000001'; + +function tenantRow(overrides = {}) { + return { + id: TENANT_ID, + slug: 'campus-a', + name: 'Campus A', + status: 'active', + mode: 'saas', + host: 'campus-a.example.com', + brand_name: 'Campus A', + short_name: 'Campus A', + slogan: null, + logo_url: null, + favicon_url: null, + service_wechat: null, + service_account_name: null, + theme: {}, + public_assets: {}, + feature_flags: {}, + admin_feature_flags: {}, + public_config: {}, + ...overrides, + }; +} + +function requestContext(url, headers = {}) { + return { + url: new URL(url), + req: { headers }, + res: {}, + }; +} + +async function expectHttpError(run, statusCode, code) { + await assert.rejects(run, error => { + assert.ok(error instanceof HttpError); + assert.equal(error.statusCode, statusCode); + assert.equal(error.code, code); + return true; + }); +} + +assert.equal(normalizeTenantHost('Example.COM.:443'), 'example.com'); +assert.equal(normalizeTenantHost('[::1]:8787'), '::1'); +assert.equal(normalizeTenantHost('::1'), '::1'); +assert.equal(normalizeTenantHost('bad,forwarded.example.com'), ''); + +assert.deepEqual(selectTenantLocator({ + origin: 'https://Campus-A.Example.com:443', + requestedHost: 'campus-a.example.com.', + requestHost: 'api.example.com', + isProduction: true, +}), { + ok: true, + locator: { + kind: 'host', + host: 'campus-a.example.com', + expectedTenantCode: null, + source: 'browser', + }, +}); + +assert.deepEqual(selectTenantLocator({ + origin: 'null', + referer: 'https://campus-a.example.com/pages/student/home', + requestHost: 'api.example.com', + isProduction: true, +}), { + ok: true, + locator: { + kind: 'host', + host: 'campus-a.example.com', + expectedTenantCode: null, + source: 'browser', + }, +}); + +assert.equal(selectTenantLocator({ + origin: 'https://campus-a.example.com', + requestedHost: 'campus-b.example.com', + isProduction: true, +}).code, 'TENANT_HOST_CONFLICT'); + +assert.deepEqual(selectTenantLocator({ + origin: 'http://localhost:5173', + requestedHost: 'localhost:5173', + tenantCode: 'campus-a', + requestHost: 'localhost:8787', + isProduction: false, +}), { + ok: true, + locator: { kind: 'tenantCode', tenantCode: 'campus-a', source: 'local-development' }, +}); + +assert.deepEqual(selectTenantLocator({ + tenantCode: 'campus-a', + requestHost: 'api.example.com', + isProduction: true, +}), { + ok: true, + locator: { kind: 'tenantCode', tenantCode: 'campus-a', source: 'headless-client' }, +}); + +assert.equal(selectTenantLocator({ + requestedHost: 'campus-a.example.com', + requestHost: 'api.example.com', + isProduction: true, +}).code, 'TENANT_HOST_UNTRUSTED'); + +{ + const calls = []; + const result = await resolveTenantRoute( + requestContext('https://api.example.com/api/tenant/resolve?host=campus-a.example.com', { + host: 'api.example.com', + origin: 'https://campus-a.example.com', + 'x-forwarded-host': 'attacker.example.com', + }), + async (sql, params) => { + calls.push({ sql, params }); + return tenantRow(); + }, + ); + assert.equal(result.tenant.id, TENANT_ID); + assert.deepEqual(calls[0].params, ['campus-a.example.com']); + assert.match(calls[0].sql, /d\.status = 'active' and t\.status = 'active'/); +} + +await expectHttpError( + () => resolveTenantRoute( + requestContext('https://api.example.com/api/tenant/resolve?host=unknown.example.com', { + host: 'api.example.com', + origin: 'https://unknown.example.com', + }), + async () => null, + ), + 404, + 'TENANT_DOMAIN_NOT_BOUND', +); + +await expectHttpError( + () => resolveTenantRoute( + requestContext('https://api.example.com/api/tenant/resolve?host=campus-a.example.com&tenantCode=campus-b', { + host: 'api.example.com', + origin: 'https://campus-a.example.com', + }), + async () => tenantRow(), + ), + 409, + 'TENANT_LOCATOR_CONFLICT', +); + +await expectHttpError( + () => resolveTenantRoute( + requestContext('https://api.example.com/api/tenant/resolve?host=campus-a.example.com', { + host: 'api.example.com', + origin: 'https://campus-b.example.com', + }), + async () => tenantRow(), + ), + 409, + 'TENANT_HOST_CONFLICT', +); + +console.log('[PASS] Tenant resolve fail-closed contract'); diff --git a/scripts/tenant-student-capacity-contract-test.js b/scripts/tenant-student-capacity-contract-test.js new file mode 100644 index 00000000..c0bd9f57 --- /dev/null +++ b/scripts/tenant-student-capacity-contract-test.js @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + CAPACITY_NAMESPACE, + DEFAULT_STUDENT_COUNT, + MAX_STUDENT_COUNT, + buildStudentListQuery, + containsSearchPattern, + parseCapacityOptions, + safeTarget, +} from './tenant-student-capacity.js'; + +const repoRoot = process.cwd(); +const harnessPath = path.join(repoRoot, 'scripts', 'tenant-student-capacity.js'); +const apiPath = path.join(repoRoot, 'apps', 'api', 'src', 'features', 'tenant-admin', 'classes.ts'); +const migrationPath = path.join( + repoRoot, + 'supabase', + 'migrations', + '202607120011_tenant_student_search_pagination.sql', +); +const runbookPath = path.join(repoRoot, 'docs', 'refactor', 'tenant-student-capacity-runbook.md'); + +const harness = fs.readFileSync(harnessPath, 'utf8'); +const api = fs.readFileSync(apiPath, 'utf8'); +const migration = fs.readFileSync(migrationPath, 'utf8'); +const runbook = fs.readFileSync(runbookPath, 'utf8'); +const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + +const defaults = parseCapacityOptions([], {}); +assert.equal(defaults.mode, 'plan'); +assert.equal(defaults.count, DEFAULT_STUDENT_COUNT); +assert.equal(DEFAULT_STUDENT_COUNT, MAX_STUDENT_COUNT); +assert.equal(defaults.databaseUrl, ''); +assert.throws(() => parseCapacityOptions(['--mode=seed'], {}), /DATABASE_URL is required/); +assert.throws( + () => parseCapacityOptions(['--count=100001'], {}), + /count must be between 10 and 100000/, +); +assert.throws( + () => parseCapacityOptions(['--tenant-slug=master'], {}), + /tenant slug must match capacity-test-/, +); +assert.equal(containsSearchPattern('a_b%c\\d'), '%a\\_b\\%c\\\\d%'); +assert.throws( + () => safeTarget('postgresql://postgres:secret@127.0.0.1:5432/postgres'), + /local port 5432 is reserved for tikupro-pg/, +); +assert.throws( + () => safeTarget('postgresql://postgres:secret@tikupro-pg:5432/postgres'), + /tikupro-pg targets are forbidden/, +); +assert.deepEqual(safeTarget('postgresql://postgres:secret@127.0.0.1:55432/postgres'), { + host: '127.0.0.1', + port: '55432', + database: 'postgres', + user: 'postgres', +}); + +assert.match(harness, /assertDestructiveTestDatabase\s*\(/); +assert.match(harness, /pg_try_advisory_lock/); +assert.match(harness, /generate_series\(\$4::integer, \$5::integer\)/); +assert.match(harness, /on conflict \(legacy_id\)/i); +assert.match(harness, /on conflict \(tenant_id, user_id, role\)/i); +assert.match(harness, /on conflict \(tenant_id, user_id\)/i); +assert.match(harness, /raw_profile #>> '\{capacityHarness,namespace\}'/); +assert.match(harness, /metadata #>> '\{capacityHarness,namespace\}'/); +assert.match(harness, /auth_user_id is not null/); +assert.match(harness, /tm\.tenant_id <> \$3/); +assert.match(harness, /dedicated tenant contains unmanaged memberships/); +assert.match(harness, /EXPLAIN \(ANALYZE, BUFFERS, FORMAT JSON\)/i); +assert.match(harness, /MAX_STUDENT_COUNT = 100_000/); +assert.match(harness, /timingsMs/); +const databaseModeStart = harness.indexOf('async function runDatabaseMode(options)'); +const databaseModeEnd = harness.indexOf('\nexport async function main', databaseModeStart); +const databaseMode = harness.slice(databaseModeStart, databaseModeEnd); +assert.ok(databaseModeStart >= 0 && databaseModeEnd > databaseModeStart); +assert.ok( + databaseMode.indexOf('assertDestructiveTestDatabase({') < databaseMode.indexOf('seedFixture(client, options)'), + 'the destructive database guard must execute before fixture writes', +); + +for (const shape of [{}, { cursor: true }, { keyword: true }]) { + const benchmarkSql = buildStudentListQuery(shape).sql; + for (const fragment of [ + 'with student_page as materialized', + 'join public.platform_users u on u.id = tm.user_id', + 'left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id', + 'join student_page page on page.tenant_id = tcm.tenant_id and page.user_id = tcm.user_id', + 'order by tm.created_at desc, tm.id desc', + ]) { + assert.ok(benchmarkSql.includes(fragment), `benchmark SQL must contain ${fragment}`); + assert.ok(api.includes(fragment), `API SQL must contain ${fragment}`); + } +} + +const identityExpressionFragments = [ + "coalesce(u.username, '') || ' '", + "coalesce(u.name, '') || ' '", + "coalesce(u.phone, '') || ' '", + "coalesce(u.email::text, '')", +]; +for (const fragment of identityExpressionFragments) { + assert.ok(buildStudentListQuery({ keyword: true }).sql.includes(fragment)); + assert.ok(api.includes(fragment)); + assert.ok(migration.includes(fragment.replaceAll('u.', ''))); +} + +assert.match(migration, /create extension if not exists pg_trgm with schema extensions/i); +assert.match( + migration, + /idx_memberships_student_keyset_page[\s\S]*\(tenant_id, status, created_at desc, id desc\)[\s\S]*where role = 'student'/i, +); +assert.match( + migration, + /idx_platform_users_identity_search_trgm[\s\S]*using gin[\s\S]*gin_trgm_ops/i, +); + +assert.equal(CAPACITY_NAMESPACE, 'tiku.student-capacity.v1'); +for (const scriptName of [ + 'perf:tenant-students:plan', + 'perf:tenant-students:run', + 'perf:tenant-students:evidence', + 'perf:tenant-students:benchmark', + 'perf:tenant-students:cleanup', + 'test:tenant-students:capacity:contract', + 'test:tenant-students:capacity:smoke', +]) { + assert.ok(packageJson.scripts?.[scriptName], `missing package script ${scriptName}`); +} +assert.match(packageJson.scripts['test:tenant-students:capacity:smoke'], /--count=250/); +assert.match( + packageJson.scripts['test:tenant-students:capacity:smoke'], + /--tenant-slug=capacity-test-students-smoke/, +); +assert.match(packageJson.scripts['test:tenant-students:capacity:smoke'], /SMOKE_SEED_LOCAL_OR_CI_ONLY/); +assert.ok(packageJson.scripts['test:readiness'].includes('tenant-student-capacity-contract-test.js')); + +assert.match(runbook, /capacity-test-/); +assert.match(runbook, /tikupro-pg/i); +assert.match(runbook, /SMOKE_SEED_LOCAL_OR_CI_ONLY/); +assert.match(runbook, /100000/); +assert.match(runbook, /perf:tenant-students:evidence/); +assert.match(runbook, /cleanupVerified=true/); +assert.match(runbook, /cleanup/i); +assert.match(runbook, /not a production SLA/i); + +console.log('[PASS] tenant student capacity harness contract'); diff --git a/scripts/tenant-student-capacity.js b/scripts/tenant-student-capacity.js new file mode 100644 index 00000000..ad8d785c --- /dev/null +++ b/scripts/tenant-student-capacity.js @@ -0,0 +1,1114 @@ +import { performance } from 'node:perf_hooks'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import pg from 'pg'; +import { + DESTRUCTIVE_TEST_CONFIRMATION, + assertDestructiveTestDatabase, + describeDatabaseTarget, + resolveDestructiveTestConfirmation, +} from './lib/destructive-test-database-guard.js'; + +const { Client } = pg; + +export const CAPACITY_NAMESPACE = 'tiku.student-capacity.v1'; +export const DEFAULT_TENANT_SLUG = 'capacity-test-students-100k'; +export const MAX_STUDENT_COUNT = 100_000; +export const DEFAULT_STUDENT_COUNT = 100_000; +export const DEFAULT_BATCH_SIZE = 10_000; +export const DEFAULT_ITERATIONS = 10; +export const DEFAULT_WARMUP_ITERATIONS = 3; +export const DEFAULT_PAGE_LIMIT = 100; +export const DEFAULT_OUTPUT_DIR = 'docs/refactor/performance-reports'; + +const MODES = new Set(['plan', 'seed', 'benchmark', 'run', 'evidence', 'cleanup', 'smoke']); +const TENANT_SLUG_PATTERN = /^capacity-test-[a-z0-9](?:[a-z0-9-]{0,47}[a-z0-9])?$/; +const BLOCKED_TARGET_PATTERN = /(?:^|[-_.])tikupro(?:-pg)?(?:$|[-_.])/i; + +function argumentValue(argv, name) { + const directIndex = argv.indexOf(name); + if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim(); + const prefix = `${name}=`; + const direct = argv.find(value => value.startsWith(prefix)); + return direct ? String(direct).slice(prefix.length).trim() : ''; +} + +function integerOption(value, fallback, { name, min, max }) { + if (value === '') return fallback; + if (!/^\d+$/.test(value)) throw new Error(`${name} must be an integer`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`${name} must be between ${min} and ${max}`); + } + return parsed; +} + +function validateTenantSlug(value) { + const slug = String(value || '').toLowerCase(); + if (!TENANT_SLUG_PATTERN.test(slug)) { + throw new Error('tenant slug must match capacity-test-* and contain only lowercase letters, numbers, and hyphens'); + } + return slug; +} + +export function parseCapacityOptions(argv = process.argv.slice(2), env = process.env) { + const mode = argumentValue(argv, '--mode') || 'plan'; + if (!MODES.has(mode)) throw new Error(`unsupported mode: ${mode}`); + + const options = { + mode, + databaseUrl: String(env.DATABASE_URL || '').trim(), + tenantSlug: validateTenantSlug( + argumentValue(argv, '--tenant-slug') || env.CAPACITY_TENANT_SLUG || DEFAULT_TENANT_SLUG, + ), + count: integerOption(argumentValue(argv, '--count'), DEFAULT_STUDENT_COUNT, { + name: 'count', min: 10, max: MAX_STUDENT_COUNT, + }), + batchSize: integerOption(argumentValue(argv, '--batch-size'), DEFAULT_BATCH_SIZE, { + name: 'batch-size', min: 100, max: 20_000, + }), + iterations: integerOption(argumentValue(argv, '--iterations'), DEFAULT_ITERATIONS, { + name: 'iterations', min: 1, max: 100, + }), + warmupIterations: integerOption( + argumentValue(argv, '--warmup-iterations'), + DEFAULT_WARMUP_ITERATIONS, + { name: 'warmup-iterations', min: 0, max: 20 }, + ), + pageLimit: integerOption(argumentValue(argv, '--limit'), DEFAULT_PAGE_LIMIT, { + name: 'limit', min: 1, max: 500, + }), + outputDir: argumentValue(argv, '--output-dir') || env.CAPACITY_OUTPUT_DIR || DEFAULT_OUTPUT_DIR, + confirmation: resolveDestructiveTestConfirmation(env, argv), + }; + + if (mode !== 'plan' && !options.databaseUrl) { + throw new Error('DATABASE_URL is required outside plan mode'); + } + return options; +} + +function safeTarget(databaseUrl) { + if (!databaseUrl) return null; + const target = describeDatabaseTarget(databaseUrl); + const normalizedHost = target.host.replace(/^\[(.*)\]$/, '$1').toLowerCase(); + if ( + ['127.0.0.1', 'localhost', '::1'].includes(normalizedHost) && + target.port === '5432' + ) { + throw new Error('Refusing tenant student capacity operation: local port 5432 is reserved for tikupro-pg'); + } + for (const value of [target.host, target.database, target.user]) { + if (BLOCKED_TARGET_PATTERN.test(value)) { + throw new Error('Refusing tenant student capacity operation: tikupro-pg targets are forbidden'); + } + } + return target; +} + +function normalizePlanText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function percentile(values, target) { + if (!values.length) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.ceil((target / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(sorted.length - 1, index))]; +} + +function round(value) { + return Math.round(Number(value || 0) * 1000) / 1000; +} + +function containsSearchPattern(value) { + return `%${String(value).replace(/[\\%_]/g, match => `\\${match}`)}%`; +} + +function studentLegacyId(tenantSlug, ordinal) { + return `${CAPACITY_NAMESPACE}:${tenantSlug}:${String(ordinal).padStart(6, '0')}`; +} + +function capacityPlan(options) { + return { + schemaVersion: 1, + mode: options.mode, + dryRun: options.mode === 'plan', + target: safeTarget(options.databaseUrl), + fixture: { + namespace: CAPACITY_NAMESPACE, + tenantSlug: options.tenantSlug, + requestedStudents: options.count, + batchSize: options.batchSize, + tables: ['public.platform_users', 'public.tenant_memberships', 'public.student_profiles'], + }, + benchmark: { + cases: ['first-page', 'deep-cursor', 'name-substring', 'phone-substring', 'email-substring'], + iterations: options.iterations, + warmupIterations: options.warmupIterations, + pageLimit: options.pageLimit, + explain: 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)', + }, + applyRequirement: `--confirm=${DESTRUCTIVE_TEST_CONFIRMATION}`, + }; +} + +async function acquireHarnessLock(client, tenantSlug) { + const result = await client.query( + 'select pg_try_advisory_lock(hashtext($1)) as acquired', + [`${CAPACITY_NAMESPACE}:${tenantSlug}`], + ); + if (result.rows[0]?.acquired !== true) { + throw new Error(`capacity tenant ${tenantSlug} is already being modified or benchmarked`); + } +} + +async function releaseHarnessLock(client, tenantSlug) { + await client.query('select pg_advisory_unlock(hashtext($1))', [`${CAPACITY_NAMESPACE}:${tenantSlug}`]); +} + +async function loadManagedTenant(client, tenantSlug) { + const result = await client.query( + ` + select id, slug::text as slug, name, owner_user_id as "ownerUserId", metadata + from public.tenants + where slug = $1::citext + limit 1 + `, + [tenantSlug], + ); + return result.rows[0] || null; +} + +function assertManagedTenant(tenant, tenantSlug) { + const marker = tenant?.metadata?.capacityHarness; + if ( + !tenant || + marker?.namespace !== CAPACITY_NAMESPACE || + marker?.managed !== true || + marker?.tenantSlug !== tenantSlug || + tenant.ownerUserId !== null + ) { + throw new Error(`Refusing to use tenant ${tenantSlug}: capacity harness metadata marker does not match`); + } +} + +async function ensureManagedTenant(client, tenantSlug) { + await client.query('begin'); + try { + let tenant = await loadManagedTenant(client, tenantSlug); + if (tenant) { + assertManagedTenant(tenant, tenantSlug); + } else { + const inserted = await client.query( + ` + insert into public.tenants ( + slug, name, legal_name, status, mode, billing_status, metadata + ) + values ( + $1::citext, + 'Capacity Test Students', + 'Capacity Test Students - Non Production Only', + 'active', + 'saas', + 'trial', + jsonb_build_object( + 'capacityHarness', + jsonb_build_object( + 'namespace', $2::text, + 'managed', true, + 'tenantSlug', $1::text + ) + ) + ) + returning id, slug::text as slug, name, owner_user_id as "ownerUserId", metadata + `, + [tenantSlug, CAPACITY_NAMESPACE], + ); + tenant = inserted.rows[0]; + } + await client.query('commit'); + return tenant; + } catch (error) { + await client.query('rollback').catch(() => undefined); + throw error; + } +} + +async function managedFixtureCounts(client, tenantId, tenantSlug) { + const result = await client.query( + ` + with managed_users as ( + select id + from public.platform_users + where raw_profile #>> '{capacityHarness,namespace}' = $1 + and raw_profile #>> '{capacityHarness,tenantSlug}' = $2 + and raw_profile #>> '{capacityHarness,tenantId}' = $3::text + ) + select + (select count(*)::integer from managed_users) as "platformUsers", + ( + select count(*)::integer + from public.tenant_memberships tm + join managed_users u on u.id = tm.user_id + where tm.tenant_id = $3::uuid and tm.role = 'student' + ) as "tenantMemberships", + ( + select count(*)::integer + from public.student_profiles sp + join managed_users u on u.id = sp.user_id + where sp.tenant_id = $3::uuid + ) as "studentProfiles" + `, + [CAPACITY_NAMESPACE, tenantSlug, tenantId], + ); + return result.rows[0]; +} + +async function assertManagedUsersAreIsolated(client, tenantId, tenantSlug) { + const result = await client.query( + ` + select + count(*) filter ( + where u.auth_user_id is not null + )::integer as "linkedAuthUsers", + count(*) filter ( + where coalesce(u.raw_profile #>> '{capacityHarness,tenantId}', '') <> $3::text + )::integer as "wrongTenantMarkers", + count(*) filter ( + where exists ( + select 1 + from public.tenant_memberships tm + where tm.user_id = u.id and tm.tenant_id <> $3::uuid + ) + )::integer as "crossTenantMemberships" + from public.platform_users u + where u.raw_profile #>> '{capacityHarness,namespace}' = $1 + and u.raw_profile #>> '{capacityHarness,tenantSlug}' = $2 + `, + [CAPACITY_NAMESPACE, tenantSlug, tenantId], + ); + const isolation = result.rows[0]; + if ( + Number(isolation.linkedAuthUsers) > 0 || + Number(isolation.wrongTenantMarkers) > 0 || + Number(isolation.crossTenantMemberships) > 0 + ) { + throw new Error(`Refusing capacity fixture mutation: managed users are not isolated (${JSON.stringify(isolation)})`); + } +} + +async function assertManagedTenantMemberships(client, tenantId, tenantSlug) { + const result = await client.query( + ` + select count(*)::integer as "unmanagedMemberships" + from public.tenant_memberships tm + join public.platform_users u on u.id = tm.user_id + where tm.tenant_id = $3::uuid + and ( + tm.role <> 'student' + or u.raw_profile #>> '{capacityHarness,namespace}' is distinct from $1 + or u.raw_profile #>> '{capacityHarness,tenantSlug}' is distinct from $2 + or u.raw_profile #>> '{capacityHarness,tenantId}' is distinct from $3::text + ) + `, + [CAPACITY_NAMESPACE, tenantSlug, tenantId], + ); + if (Number(result.rows[0]?.unmanagedMemberships || 0) > 0) { + throw new Error('Refusing capacity tenant operation: dedicated tenant contains unmanaged memberships'); + } +} + +async function assertBatchHasNoIdentityCollisions(client, tenantId, tenantSlug, start, end) { + const result = await client.query( + ` + with source as ( + select + ordinal, + $1::text || ':' || $2::text || ':' || lpad(ordinal::text, 6, '0') as legacy_id + from generate_series($4::integer, $5::integer) ordinal + ) + select count(*)::integer as collisions + from source + join public.platform_users u on u.legacy_id = source.legacy_id + where u.raw_profile #>> '{capacityHarness,namespace}' is distinct from $1 + or u.raw_profile #>> '{capacityHarness,tenantSlug}' is distinct from $2 + or u.raw_profile #>> '{capacityHarness,tenantId}' is distinct from $3::text + `, + [CAPACITY_NAMESPACE, tenantSlug, tenantId, start, end], + ); + if (Number(result.rows[0]?.collisions || 0) > 0) { + throw new Error(`Refusing capacity fixture seed: legacy identity collision in ordinals ${start}-${end}`); + } +} + +async function seedBatch(client, tenantId, tenantSlug, start, end) { + await client.query('begin'); + try { + await assertBatchHasNoIdentityCollisions(client, tenantId, tenantSlug, start, end); + const result = await client.query( + ` + with source as materialized ( + select + ordinal, + md5($1::text || ':' || $2::text || ':user:' || ordinal::text)::uuid as user_id, + $1::text || ':' || $2::text || ':' || lpad(ordinal::text, 6, '0') as legacy_id, + 'capacity_' || replace($2::text, '-', '_') || '_' || lpad(ordinal::text, 6, '0') as username, + 'student' || lpad(ordinal::text, 6, '0') || '@' || $2::text || '.capacity.invalid' as email, + '188' || lpad(ordinal::text, 8, '0') as phone, + 'Capacity Student ' || lpad(ordinal::text, 6, '0') as name, + timestamptz '2024-01-01 00:00:00+00' + (ordinal * interval '1 second') as created_at + from generate_series($4::integer, $5::integer) ordinal + ), + upsert_users as ( + insert into public.platform_users ( + id, legacy_id, username, email, phone, name, primary_role, status, + raw_profile, created_at, updated_at + ) + select + source.user_id, + source.legacy_id, + source.username, + source.email::citext, + source.phone, + source.name, + 'student', + 'active', + jsonb_build_object( + 'capacityHarness', + jsonb_build_object( + 'namespace', $1::text, + 'managed', true, + 'tenantSlug', $2::text, + 'tenantId', $3::text, + 'ordinal', source.ordinal + ) + ), + source.created_at, + now() + from source + on conflict (legacy_id) + do update set + username = excluded.username, + email = excluded.email, + phone = excluded.phone, + name = excluded.name, + primary_role = 'student', + status = 'active', + raw_profile = excluded.raw_profile, + created_at = excluded.created_at, + updated_at = now() + where public.platform_users.raw_profile #>> '{capacityHarness,namespace}' = $1 + and public.platform_users.raw_profile #>> '{capacityHarness,tenantSlug}' = $2 + and public.platform_users.raw_profile #>> '{capacityHarness,tenantId}' = $3::text + returning id + ), + upsert_memberships as ( + insert into public.tenant_memberships ( + tenant_id, user_id, role, status, permissions, created_at, updated_at + ) + select $3::uuid, source.user_id, 'student', 'active', '{}'::jsonb, source.created_at, now() + from source + join upsert_users users on users.id = source.user_id + on conflict (tenant_id, user_id, role) + do update set + status = 'active', + permissions = '{}'::jsonb, + created_at = excluded.created_at, + updated_at = now() + returning id + ), + upsert_profiles as ( + insert into public.student_profiles ( + tenant_id, user_id, questions_answered_today, mastered_words_count, + stats, progress, module_selections, recent_activities, created_at, updated_at + ) + select + $3::uuid, + source.user_id, + source.ordinal % 80, + source.ordinal % 5000, + jsonb_build_object('capacityHarnessOrdinal', source.ordinal), + jsonb_build_object('completionPercent', source.ordinal % 101), + '{}'::jsonb, + '[]'::jsonb, + source.created_at, + now() + from source + join upsert_users users on users.id = source.user_id + on conflict (tenant_id, user_id) + do update set + questions_answered_today = excluded.questions_answered_today, + mastered_words_count = excluded.mastered_words_count, + stats = excluded.stats, + progress = excluded.progress, + module_selections = excluded.module_selections, + recent_activities = excluded.recent_activities, + created_at = excluded.created_at, + updated_at = now() + returning id + ) + select + (select count(*)::integer from upsert_users) as users, + (select count(*)::integer from upsert_memberships) as memberships, + (select count(*)::integer from upsert_profiles) as profiles + `, + [CAPACITY_NAMESPACE, tenantSlug, tenantId, start, end], + ); + const expected = end - start + 1; + const counts = result.rows[0]; + for (const key of ['users', 'memberships', 'profiles']) { + if (Number(counts?.[key] || 0) !== expected) { + throw new Error(`capacity batch ${start}-${end} wrote ${counts?.[key] || 0} ${key}; expected ${expected}`); + } + } + await client.query('commit'); + return counts; + } catch (error) { + await client.query('rollback').catch(() => undefined); + throw error; + } +} + +async function seedFixture(client, options) { + const tenant = await ensureManagedTenant(client, options.tenantSlug); + assertManagedTenant(tenant, options.tenantSlug); + await assertManagedTenantMemberships(client, tenant.id, options.tenantSlug); + await assertManagedUsersAreIsolated(client, tenant.id, options.tenantSlug); + + const before = await managedFixtureCounts(client, tenant.id, options.tenantSlug); + if (Number(before.platformUsers) > options.count) { + throw new Error( + `managed fixture already has ${before.platformUsers} users; cleanup is required before reducing it to ${options.count}`, + ); + } + + const batches = []; + for (let start = 1; start <= options.count; start += options.batchSize) { + const end = Math.min(options.count, start + options.batchSize - 1); + const counts = await seedBatch(client, tenant.id, options.tenantSlug, start, end); + batches.push({ start, end, ...counts }); + console.error(`[capacity] seeded ${end}/${options.count} students`); + } + + await client.query('analyze public.platform_users'); + await client.query('analyze public.tenant_memberships'); + await client.query('analyze public.student_profiles'); + + const after = await managedFixtureCounts(client, tenant.id, options.tenantSlug); + for (const key of ['platformUsers', 'tenantMemberships', 'studentProfiles']) { + if (Number(after[key]) !== options.count) { + throw new Error(`capacity fixture validation failed: ${key}=${after[key]}, expected ${options.count}`); + } + } + + return { tenant, before, after, batches }; +} + +function buildStudentListQuery({ keyword = false, cursor = false, explain = false } = {}) { + const params = { tenantId: 1, status: 2 }; + let nextParam = 3; + const filters = ['tm.tenant_id = $1', `tm.role = 'student'`, 'tm.status = $2']; + if (keyword) { + params.keyword = nextParam++; + filters.push(`( + coalesce(u.username, '') || ' ' || + coalesce(u.name, '') || ' ' || + coalesce(u.phone, '') || ' ' || + coalesce(u.email::text, '') + ) ilike $${params.keyword} escape '\\'`); + } + if (cursor) { + params.cursorCreatedAt = nextParam++; + params.cursorMembershipId = nextParam++; + filters.push( + `(tm.created_at, tm.id) < ($${params.cursorCreatedAt}::timestamptz, $${params.cursorMembershipId}::uuid)`, + ); + } + params.limit = nextParam; + + const sql = ` + with student_page as materialized ( + select tm.id, tm.tenant_id, tm.user_id, tm.status, tm.created_at, tm.updated_at + from public.tenant_memberships tm + join public.platform_users u on u.id = tm.user_id + left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id + where ${filters.join(' and ')} + order by tm.created_at desc, tm.id desc + limit $${params.limit} + ), + class_agg as ( + select tcm.tenant_id, tcm.user_id, + jsonb_agg( + jsonb_build_object( + 'classId', tc.id, + 'className', tc.name, + 'classCode', tc.code, + 'memberType', tcm.member_type, + 'joinedAt', tcm.joined_at + ) + order by tc.sort_order asc, tc.created_at desc + ) filter (where tcm.status = 'active') as classes + from public.tenant_class_members tcm + join student_page page on page.tenant_id = tcm.tenant_id and page.user_id = tcm.user_id + join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id + where tcm.tenant_id = $1 and tcm.member_type = 'student' + group by tcm.tenant_id, tcm.user_id + ) + select tm.id as "membershipId", tm.user_id as "userId", tm.status, + tm.created_at as "memberCreatedAt", tm.created_at::text as "cursorCreatedAt", + tm.updated_at as "memberUpdatedAt", + u.username, u.email::text as email, u.phone, u.name, + null::text as "avatarUrl", u.primary_role as "primaryRole", + u.last_seen_at as "lastSeenAt", + sp.id as "profileId", sp.region_id as "regionId", r.name as "regionName", + sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName", + sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName", + sp.questions_answered_today as "questionsAnsweredToday", + sp.mastered_words_count as "masteredWordsCount", + sp.last_check_in_date as "lastCheckInDate", + sp.stats, sp.progress, sp.module_selections as "moduleSelections", + coalesce(ca.classes, '[]'::jsonb) as classes + from student_page tm + join public.platform_users u on u.id = tm.user_id + left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id + left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id + left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id + left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id + left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id + order by tm.created_at desc, tm.id desc + `; + return { + sql: explain ? `explain (analyze, buffers, format json) ${sql}` : sql, + params, + }; +} + +async function assertBenchmarkIndexes(client) { + const names = [ + 'idx_memberships_student_keyset_page', + 'idx_platform_users_identity_search_trgm', + ]; + const result = await client.query( + ` + select + index_name, + to_regclass('public.' || index_name)::text as relation, + pg_get_indexdef(to_regclass('public.' || index_name)) as definition + from unnest($1::text[]) index_name + order by index_name + `, + [names], + ); + const missing = result.rows.filter(row => !row.relation || !row.definition); + if (missing.length) { + throw new Error(`required student capacity indexes are missing: ${missing.map(row => row.index_name).join(', ')}`); + } + return result.rows.map(row => ({ name: row.index_name, definition: normalizePlanText(row.definition) })); +} + +async function loadBenchmarkFixture(client, tenantSlug) { + const tenant = await loadManagedTenant(client, tenantSlug); + assertManagedTenant(tenant, tenantSlug); + await assertManagedTenantMemberships(client, tenant.id, tenantSlug); + await assertManagedUsersAreIsolated(client, tenant.id, tenantSlug); + const counts = await managedFixtureCounts(client, tenant.id, tenantSlug); + if ( + Number(counts.platformUsers) < 10 || + counts.platformUsers !== counts.tenantMemberships || + counts.platformUsers !== counts.studentProfiles + ) { + throw new Error(`capacity fixture is incomplete: ${JSON.stringify(counts)}`); + } + return { tenant, counts, count: Number(counts.platformUsers) }; +} + +async function loadBenchmarkTargets(client, tenantId, tenantSlug, count) { + const searchOrdinal = Math.max(1, Math.floor(count / 2)); + const deepOrdinal = Math.max(2, Math.floor(count * 0.1)); + const result = await client.query( + ` + select + u.legacy_id as "legacyId", + u.name, + u.phone, + u.email::text as email, + tm.id as "membershipId", + tm.created_at as "createdAt" + from public.platform_users u + join public.tenant_memberships tm + on tm.tenant_id = $1 and tm.user_id = u.id and tm.role = 'student' + where u.legacy_id = any($2::text[]) + `, + [tenantId, [studentLegacyId(tenantSlug, searchOrdinal), studentLegacyId(tenantSlug, deepOrdinal)]], + ); + const byLegacyId = new Map(result.rows.map(row => [row.legacyId, row])); + const search = byLegacyId.get(studentLegacyId(tenantSlug, searchOrdinal)); + const deep = byLegacyId.get(studentLegacyId(tenantSlug, deepOrdinal)); + if (!search || !deep) throw new Error('capacity benchmark target rows are missing'); + return { + searchOrdinal, + deepOrdinal, + approximateDeepCursorOffset: count - deepOrdinal, + search, + deep, + }; +} + +function explainSummary(document) { + const root = Array.isArray(document) ? document[0] : document; + const plan = root?.Plan || {}; + const nodeTypes = new Set(); + const indexes = new Set(); + function visit(node) { + if (!node || typeof node !== 'object') return; + if (node['Node Type']) nodeTypes.add(node['Node Type']); + if (node['Index Name']) indexes.add(node['Index Name']); + for (const child of node.Plans || []) visit(child); + } + visit(plan); + return { + planningTimeMs: round(root?.['Planning Time']), + executionTimeMs: round(root?.['Execution Time']), + actualRows: Number(plan['Actual Rows'] || 0), + nodeTypes: [...nodeTypes], + indexes: [...indexes], + buffers: { + sharedHit: Number(plan['Shared Hit Blocks'] || 0), + sharedRead: Number(plan['Shared Read Blocks'] || 0), + sharedDirtied: Number(plan['Shared Dirtied Blocks'] || 0), + sharedWritten: Number(plan['Shared Written Blocks'] || 0), + localHit: Number(plan['Local Hit Blocks'] || 0), + localRead: Number(plan['Local Read Blocks'] || 0), + tempRead: Number(plan['Temp Read Blocks'] || 0), + tempWritten: Number(plan['Temp Written Blocks'] || 0), + }, + }; +} + +async function benchmarkCase(client, definition, options) { + const query = buildStudentListQuery(definition.shape); + for (let index = 0; index < options.warmupIterations; index += 1) { + await client.query(query.sql, definition.values); + } + + const latencies = []; + const rowCounts = []; + for (let index = 0; index < options.iterations; index += 1) { + const startedAt = performance.now(); + const result = await client.query(query.sql, definition.values); + latencies.push(performance.now() - startedAt); + rowCounts.push(result.rowCount); + } + + const explainQuery = buildStudentListQuery({ ...definition.shape, explain: true }); + const explainResult = await client.query(explainQuery.sql, definition.values); + const explain = explainResult.rows[0]?.['QUERY PLAN']; + return { + id: definition.id, + label: definition.label, + searchField: definition.searchField || null, + keyword: definition.keyword || null, + cursor: definition.cursor || null, + iterations: options.iterations, + rows: { + min: Math.min(...rowCounts), + max: Math.max(...rowCounts), + }, + latencyMs: { + min: round(Math.min(...latencies)), + p50: round(percentile(latencies, 50)), + p95: round(percentile(latencies, 95)), + max: round(Math.max(...latencies)), + }, + explain: { + summary: explainSummary(explain), + document: explain, + }, + }; +} + +async function benchmarkFixture(client, options, safety) { + const fixture = await loadBenchmarkFixture(client, options.tenantSlug); + const indexes = await assertBenchmarkIndexes(client); + const targets = await loadBenchmarkTargets( + client, + fixture.tenant.id, + options.tenantSlug, + fixture.count, + ); + const limitValue = options.pageLimit + 1; + const nameKeyword = String(targets.search.name || '').replace(/^Capacity /, ''); + const phoneKeyword = String(targets.search.phone || '').slice(-7); + const emailKeyword = String(targets.search.email || '').match(/\d{6}@/)?.[0] || String(targets.search.email || ''); + + const definitions = [ + { + id: 'first-page', + label: 'Tenant student first page', + shape: {}, + values: [fixture.tenant.id, 'active', limitValue], + }, + { + id: 'deep-cursor', + label: 'Tenant student deep cursor page', + shape: { cursor: true }, + values: [fixture.tenant.id, 'active', targets.deep.createdAt, targets.deep.membershipId, limitValue], + cursor: { + approximateOffset: targets.approximateDeepCursorOffset, + anchorOrdinal: targets.deepOrdinal, + }, + }, + { + id: 'name-substring', + label: 'Student name substring search', + searchField: 'name', + keyword: nameKeyword, + shape: { keyword: true }, + values: [fixture.tenant.id, 'active', containsSearchPattern(nameKeyword), limitValue], + }, + { + id: 'phone-substring', + label: 'Student phone substring search', + searchField: 'phone', + keyword: phoneKeyword, + shape: { keyword: true }, + values: [fixture.tenant.id, 'active', containsSearchPattern(phoneKeyword), limitValue], + }, + { + id: 'email-substring', + label: 'Student email substring search', + searchField: 'email', + keyword: emailKeyword, + shape: { keyword: true }, + values: [fixture.tenant.id, 'active', containsSearchPattern(emailKeyword), limitValue], + }, + ]; + + const cases = []; + for (const definition of definitions) { + console.error(`[capacity] benchmarking ${definition.id}`); + cases.push(await benchmarkCase(client, definition, options)); + } + + return { + schemaVersion: 1, + kind: 'tenant-student-capacity', + generatedAt: new Date().toISOString(), + safety: { + databaseEnvironment: safety.environment, + databaseTarget: safety.target, + namespace: CAPACITY_NAMESPACE, + tenantSlug: options.tenantSlug, + productionAllowed: false, + }, + fixture: { + platformUsers: Number(fixture.counts.platformUsers), + tenantMemberships: Number(fixture.counts.tenantMemberships), + studentProfiles: Number(fixture.counts.studentProfiles), + }, + config: { + iterations: options.iterations, + warmupIterations: options.warmupIterations, + pageLimit: options.pageLimit, + rawQueryLimit: limitValue, + deepCursorApproximateOffset: targets.approximateDeepCursorOffset, + }, + indexes, + cases, + limitations: [ + 'Synthetic non-production data only.', + 'Latency samples are warm-cache observations from the current test host, not a production SLA.', + 'The harness validates the unscoped tenant-admin student list shape; class/region filters require separate workload evidence.', + ], + }; +} + +function reportMarkdown(report) { + const lines = [ + '# Tenant Student Capacity Evidence', + '', + `Generated: ${report.generatedAt}`, + '', + `Database environment: \`${report.safety.databaseEnvironment}\``, + '', + `Dedicated tenant: \`${report.safety.tenantSlug}\``, + '', + `Managed rows: ${report.fixture.platformUsers} platform users / ${report.fixture.tenantMemberships} memberships / ${report.fixture.studentProfiles} profiles`, + '', + ...(report.timingsMs + ? [ + `Harness timings: ${Object.entries(report.timingsMs).map(([key, value]) => `${key}=${value}ms`).join(', ')}`, + '', + ] + : []), + '| Case | Rows | P50 ms | P95 ms | EXPLAIN ms | Shared hit/read | Plan indexes |', + '| --- | ---: | ---: | ---: | ---: | ---: | --- |', + ]; + for (const item of report.cases) { + const summary = item.explain.summary; + lines.push( + `| ${item.id} | ${item.rows.min}-${item.rows.max} | ${item.latencyMs.p50} | ${item.latencyMs.p95} | ${summary.executionTimeMs} | ${summary.buffers.sharedHit}/${summary.buffers.sharedRead} | ${summary.indexes.join(', ') || 'none'} |`, + ); + } + lines.push('', '## Index Definitions', ''); + for (const index of report.indexes) { + lines.push(`### ${index.name}`, '', '```sql', index.definition, '```', ''); + } + lines.push( + '## Interpretation Limits', + '', + ...report.limitations.map(item => `- ${item}`), + '', + 'The full JSON report contains PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` documents for every case.', + '', + ); + return `${lines.join('\n')}\n`; +} + +function fileTimestamp(date = new Date()) { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} + +async function writeReport(report, outputDir) { + const absoluteOutputDir = path.resolve(outputDir); + await fs.mkdir(absoluteOutputDir, { recursive: true }); + const stamp = fileTimestamp(new Date(report.generatedAt)); + const jsonPath = path.join(absoluteOutputDir, `tenant-student-capacity-${stamp}.json`); + const markdownPath = path.join(absoluteOutputDir, `tenant-student-capacity-${stamp}.md`); + await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await fs.writeFile(markdownPath, reportMarkdown(report), 'utf8'); + return { jsonPath, markdownPath }; +} + +async function cleanupManagedFixture(client, tenantSlug) { + const tenant = await loadManagedTenant(client, tenantSlug); + let tenantId = null; + if (tenant) { + assertManagedTenant(tenant, tenantSlug); + tenantId = tenant.id; + await assertManagedTenantMemberships(client, tenant.id, tenantSlug); + await assertManagedUsersAreIsolated(client, tenant.id, tenantSlug); + } else { + const orphanSafety = await client.query( + ` + select count(*)::integer as unsafe + from public.platform_users u + where u.raw_profile #>> '{capacityHarness,namespace}' = $1 + and u.raw_profile #>> '{capacityHarness,tenantSlug}' = $2 + and ( + u.auth_user_id is not null + or exists (select 1 from public.tenant_memberships tm where tm.user_id = u.id) + ) + `, + [CAPACITY_NAMESPACE, tenantSlug], + ); + if (Number(orphanSafety.rows[0]?.unsafe || 0) > 0) { + throw new Error('Refusing orphan capacity cleanup: managed users still have auth or tenant membership references'); + } + } + + let deletedTenant = 0; + if (tenantId) { + const deleted = await client.query( + ` + delete from public.tenants + where id = $1 + and metadata #>> '{capacityHarness,namespace}' = $2 + and metadata #>> '{capacityHarness,tenantSlug}' = $3 + `, + [tenantId, CAPACITY_NAMESPACE, tenantSlug], + ); + deletedTenant = deleted.rowCount; + } + + let deletedUsers = 0; + while (true) { + const deleted = await client.query( + ` + with doomed as ( + select id + from public.platform_users + where raw_profile #>> '{capacityHarness,namespace}' = $1 + and raw_profile #>> '{capacityHarness,tenantSlug}' = $2 + and auth_user_id is null + and not exists ( + select 1 from public.tenant_memberships tm where tm.user_id = public.platform_users.id + ) + order by id + limit 5000 + ) + delete from public.platform_users users + using doomed + where users.id = doomed.id + `, + [CAPACITY_NAMESPACE, tenantSlug], + ); + deletedUsers += deleted.rowCount; + if (deleted.rowCount === 0) break; + } + + const remaining = await client.query( + ` + with managed_users as ( + select id + from public.platform_users + where raw_profile #>> '{capacityHarness,namespace}' = $1 + and raw_profile #>> '{capacityHarness,tenantSlug}' = $2 + ) + select + ( + select count(*)::integer + from public.tenants + where slug = $2::citext + and metadata #>> '{capacityHarness,namespace}' = $1 + and metadata #>> '{capacityHarness,tenantSlug}' = $2 + ) as tenants, + (select count(*)::integer from managed_users) as "platformUsers", + ( + select count(*)::integer + from public.tenant_memberships membership + join managed_users users on users.id = membership.user_id + ) as memberships, + ( + select count(*)::integer + from public.student_profiles profile + join managed_users users on users.id = profile.user_id + ) as profiles + `, + [CAPACITY_NAMESPACE, tenantSlug], + ); + const remainingCounts = remaining.rows[0] || {}; + const cleanupVerified = Object.values(remainingCounts).every(value => Number(value || 0) === 0); + if (!cleanupVerified) { + throw new Error(`capacity cleanup left managed rows behind: ${JSON.stringify(remainingCounts)}`); + } + return { deletedTenant, deletedUsers, cleanupVerified, remaining: remainingCounts }; +} + +async function runDatabaseMode(options) { + const totalStartedAt = performance.now(); + safeTarget(options.databaseUrl); + const client = new Client({ + connectionString: options.databaseUrl, + application_name: 'tiku-tenant-student-capacity', + }); + await client.connect(); + let locked = false; + try { + const safety = await assertDestructiveTestDatabase({ + client, + databaseUrl: options.databaseUrl, + confirmation: options.confirmation, + operation: `tenant student capacity ${options.mode}`, + }); + await client.query(`select set_config('lock_timeout', '5s', false)`); + await client.query(`select set_config('statement_timeout', '300s', false)`); + await acquireHarnessLock(client, options.tenantSlug); + locked = true; + + if (options.mode === 'cleanup') { + const cleanupStartedAt = performance.now(); + const cleanup = await cleanupManagedFixture(client, options.tenantSlug); + return { + mode: options.mode, + cleanup, + safety, + timingsMs: { + cleanup: round(performance.now() - cleanupStartedAt), + total: round(performance.now() - totalStartedAt), + }, + }; + } + + let seed = null; + let report = null; + let reportPaths = null; + let primaryError = null; + const timingsMs = {}; + try { + if (['seed', 'run', 'evidence', 'smoke'].includes(options.mode)) { + const seedStartedAt = performance.now(); + seed = await seedFixture(client, options); + timingsMs.seed = round(performance.now() - seedStartedAt); + } + if (['benchmark', 'run', 'evidence', 'smoke'].includes(options.mode)) { + const benchmarkStartedAt = performance.now(); + report = await benchmarkFixture(client, options, safety); + timingsMs.benchmark = round(performance.now() - benchmarkStartedAt); + report.timingsMs = { ...timingsMs }; + if (options.mode !== 'evidence') reportPaths = await writeReport(report, options.outputDir); + } + } catch (error) { + primaryError = error; + } + + let cleanup = null; + if (['smoke', 'evidence'].includes(options.mode)) { + try { + const cleanupStartedAt = performance.now(); + cleanup = await cleanupManagedFixture(client, options.tenantSlug); + timingsMs.cleanup = round(performance.now() - cleanupStartedAt); + } catch (cleanupError) { + if (primaryError) { + primaryError.message += `; cleanup also failed: ${cleanupError.message}`; + } else { + primaryError = cleanupError; + } + } + } + if (primaryError) throw primaryError; + + if (report && options.mode === 'evidence') { + report.cleanup = cleanup; + report.timingsMs = { + ...timingsMs, + total: round(performance.now() - totalStartedAt), + }; + reportPaths = await writeReport(report, options.outputDir); + } + + return { + mode: options.mode, + safety, + seed: seed ? { tenantId: seed.tenant.id, counts: seed.after, batches: seed.batches.length } : null, + report: report ? { fixture: report.fixture, paths: reportPaths } : null, + cleanup, + timingsMs: { + ...timingsMs, + total: round(performance.now() - totalStartedAt), + }, + }; + } finally { + if (locked) await releaseHarnessLock(client, options.tenantSlug).catch(() => undefined); + await client.end().catch(() => undefined); + } +} + +export async function main(argv = process.argv.slice(2), env = process.env) { + const options = parseCapacityOptions(argv, env); + if (options.mode === 'plan') { + console.log(JSON.stringify(capacityPlan(options), null, 2)); + return; + } + const result = await runDatabaseMode(options); + console.log(JSON.stringify(result, null, 2)); +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ''; +if (import.meta.url === invokedPath) { + main().catch(error => { + console.error(`[capacity] ${error.message}`); + process.exitCode = 1; + }); +} + +export { + buildStudentListQuery, + capacityPlan, + containsSearchPattern, + reportMarkdown, + safeTarget, + studentLegacyId, +}; diff --git a/scripts/tenant-student-cursor-test.js b/scripts/tenant-student-cursor-test.js new file mode 100644 index 00000000..95acad24 --- /dev/null +++ b/scripts/tenant-student-cursor-test.js @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; + +const { + containsSearchPattern, + decodeTenantStudentsCursor, + encodeTenantStudentsCursor, +} = await import('../apps/api/src/features/tenant-admin/student-cursor.ts'); + +const cursor = { + createdAt: '2026-07-12T03:04:05.678Z', + membershipId: '11111111-1111-4111-8111-111111111111', +}; +const encoded = encodeTenantStudentsCursor(cursor); +assert.deepEqual(decodeTenantStudentsCursor(encoded), cursor, 'student cursor should round-trip'); +assert.equal(decodeTenantStudentsCursor(''), null, 'empty cursor should start from the first page'); +assert.equal(containsSearchPattern('100%_ready\\now'), '%100\\%\\_ready\\\\now%', 'student search should escape LIKE metacharacters'); + +for (const invalid of [ + 'not-a-valid-cursor', + Buffer.from(JSON.stringify({ version: 2, ...cursor })).toString('base64url'), + Buffer.from(JSON.stringify({ version: 1, createdAt: 'not-a-date', membershipId: cursor.membershipId })).toString('base64url'), + Buffer.from(JSON.stringify({ version: 1, createdAt: cursor.createdAt, membershipId: 'not-a-uuid' })).toString('base64url'), +]) { + assert.throws( + () => decodeTenantStudentsCursor(invalid), + error => error?.code === 'INVALID_STUDENT_CURSOR' && error?.statusCode === 400, + 'malformed student cursors must fail closed', + ); +} + +console.log('[PASS] tenant student cursor contract'); diff --git a/scripts/worker-scheduling-contract-test.js b/scripts/worker-scheduling-contract-test.js new file mode 100644 index 00000000..05d2d378 --- /dev/null +++ b/scripts/worker-scheduling-contract-test.js @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { parseWorkerCli, resolveWorkerMonth } from '../apps/worker/src/cli.ts'; + +const root = process.cwd(); + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8').replace(/\r\n/g, '\n'); +} + +assert.throws( + () => parseWorkerCli(['--loop']), + /--job is required exactly once/, + 'a production loop without an explicit job must fail closed', +); +assert.deepEqual(parseWorkerCli(['--loop', '--job', 'crm']), { job: 'crm', loop: true, month: undefined }); +assert.throws( + () => parseWorkerCli(['--loop', '--job', 'platform-billing']), + /periodic and must be scheduled with --once/, +); +assert.deepEqual( + parseWorkerCli(['--once', '--job', 'platform-usage', '--month', 'previous']), + { job: 'platform-usage', loop: false, month: resolveWorkerMonth('previous') }, +); +assert.throws(() => parseWorkerCli(['--once', '--job', 'assets', '--month', 'previous']), /only supported/); +assert.throws(() => parseWorkerCli(['--once', '--job', 'crm', '--unknown']), /Unknown worker option/); +assert.throws(() => parseWorkerCli(['--once', '--job', 'crm', 'stray']), /Unexpected worker argument/); + +const workerService = read('scripts/deploy/systemd/tiku-worker@.service'); +const periodicService = read('scripts/deploy/systemd/tiku-worker-job@.service'); +const monthlyService = read('scripts/deploy/systemd/tiku-worker-monthly-usage.service'); +const target = read('scripts/deploy/systemd/tiku-workers.target'); +const workerEnv = read('scripts/deploy/env/worker.env.example'); +const workerConfig = read('apps/worker/src/config.ts'); +const sharedDbConfig = read('packages/db/src/index.ts'); + +assert.match(workerService, /ExecStart=.*--loop --job %i/); +assert.match(periodicService, /Type=oneshot/); +assert.match(periodicService, /ExecStart=.*--once --job %i/); +assert.match(monthlyService, /--job platform-usage --month previous/); +assert.match(monthlyService, /--job platform-usage-overage --month previous/); + +const continuousJobs = [ + 'crm', + 'commerce', + 'provider-bills', + 'platform-dunning-notifications', + 'platform-audit-notifications', + 'assets', + 'imports', + 'public-banks', + 'exports', +]; +for (const job of continuousJobs) { + assert.match(target, new RegExp(`Requires=tiku-worker@${job}\\.service`), `missing continuous worker ${job}`); +} + +const timerNames = [ + 'platform-billing', + 'platform-usage', + 'platform-dunning', + 'platform-audit-alerts', + 'student-supervision', + 'monthly-usage', +]; +for (const name of timerNames) { + const timerPath = `scripts/deploy/systemd/tiku-worker-${name}.timer`; + assert.ok(fs.existsSync(path.join(root, timerPath)), `missing timer ${timerPath}`); + assert.match(target, new RegExp(`Wants=tiku-worker-${name}\\.timer`), `target must want ${name} timer`); + const timer = read(timerPath); + if (timer.includes('OnCalendar=')) { + assert.match(timer, /\nPersistent=true\n/, `${timerPath} must catch up after downtime`); + } else { + assert.match(timer, /\nOnBootSec=/, `${timerPath} must resume its monotonic cadence after boot`); + } + assert.match(timer, /\nUnit=tiku-worker-(?:job@[^\n]+|monthly-usage\.service)\n/, `${timerPath} must name an explicit service`); +} + +const envKeys = new Set( + [ + ...workerConfig.matchAll(/env(?:String|Number|Boolean|List)\(\s*'([A-Z0-9_]+)'/g), + ...sharedDbConfig.matchAll(/positiveEnvNumber\(\s*'([A-Z0-9_]+)'/g), + ...sharedDbConfig.matchAll(/process\.env\.([A-Z0-9_]+)/g), + ].map(match => match[1]), +); +const workerEnvKeys = [...workerEnv.matchAll(/^([A-Z][A-Z0-9_]*)=/gm)].map(match => match[1]); +for (const key of workerEnvKeys) { + if (['NODE_ENV', 'DATABASE_URL', 'DB_EXPECTED_RUNTIME_ROLE'].includes(key)) continue; + assert.ok(envKeys.has(key), `worker.env.example contains a key not read by worker config: ${key}`); +} + +for (const key of [ + 'STORAGE_DEFAULT_BUCKET', + 'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT', + 'WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN', + 'WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS', + 'WORKER_CRM_POLL_INTERVAL_MS', + 'WORKER_COMMERCE_POLL_INTERVAL_MS', + 'WORKER_PROVIDER_BILL_POLL_INTERVAL_MS', + 'WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS', + 'WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS', + 'WORKER_ASSET_POLL_INTERVAL_MS', + 'WORKER_IMPORT_POLL_INTERVAL_MS', + 'WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS', + 'WORKER_EXPORT_POLL_INTERVAL_MS', +]) { + assert.match(workerEnv, new RegExp(`^${key}=`, 'm'), `worker env must document ${key}`); +} + +const schedulerEnvKeys = new Set([ + 'WORKER_PLATFORM_USAGE_MONTH', + 'WORKER_PLATFORM_USAGE_OVERAGE_MONTH', + 'ALIYUN_OSS_STS_TOKEN', + 'EXPORT_PDF_FONT_PATH', +]); +const codeDefaults = new Map( + [...workerConfig.matchAll(/env(?:String|Number|Boolean|List)\(\s*'([A-Z0-9_]+)'\s*,\s*([^\n,)]+)/g)] + .map(match => [match[1], match[2].trim()]), +); +for (const line of workerEnv.split('\n')) { + const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/); + if (!match || schedulerEnvKeys.has(match[1])) continue; + if (codeDefaults.get(match[1]) === "''") { + assert.notEqual(match[2], '', `worker env must not leave required ${match[1]} empty`); + } +} + +for (const legacyKey of [ + 'ALIYUN_OSS_BUCKET', + 'ASSET_SECURITY_SCAN_ENDPOINT', + 'ASSET_SECURITY_SCAN_TOKEN', + 'WORKER_POLL_INTERVAL_MS', +]) { + assert.doesNotMatch(workerEnv, new RegExp(`^${legacyKey}=`, 'm'), `worker env must not expose unused ${legacyKey}`); +} + +console.log('[PASS] worker CLI and production scheduling contract'); diff --git a/supabase/migrations/202606210001_core_multitenant_schema.sql b/supabase/migrations/202606210001_core_multitenant_schema.sql index c6660bc0..6738ed9c 100644 --- a/supabase/migrations/202606210001_core_multitenant_schema.sql +++ b/supabase/migrations/202606210001_core_multitenant_schema.sql @@ -1,5 +1,5 @@ -create extension if not exists pgcrypto; -create extension if not exists citext; +create extension if not exists pgcrypto with schema extensions; +create extension if not exists citext with schema extensions; create schema if not exists app; create schema if not exists app_private; @@ -52,7 +52,7 @@ $$; create table if not exists public.tenants ( id uuid primary key default gen_random_uuid(), - slug citext not null unique, + slug extensions.citext not null unique, name text not null, legal_name text, status text not null default 'active' check (status in ('draft', 'active', 'suspended', 'archived')), @@ -70,7 +70,7 @@ create table if not exists public.platform_users ( auth_user_id uuid unique references auth.users(id) on delete set null, legacy_id text unique, username text, - email citext, + email extensions.citext, phone text, name text, avatar_url text, @@ -97,7 +97,7 @@ create table if not exists public.user_identities ( union_id text, open_id text, phone text, - email citext, + email extensions.citext, secret_payload jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), @@ -120,7 +120,7 @@ create table if not exists public.tenant_memberships ( create table if not exists public.tenant_domains ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, - host citext not null unique, + host extensions.citext not null unique, domain_type text not null default 'custom' check (domain_type in ('system', 'custom', 'miniapp')), status text not null default 'pending' check (status in ('pending', 'active', 'failed', 'disabled')), is_primary boolean not null default false, @@ -609,7 +609,7 @@ create table if not exists public.activation_codes ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, - code citext not null, + code extensions.citext not null, days integer not null default 0, is_used boolean not null default false, used_by uuid references public.platform_users(id) on delete set null, @@ -633,7 +633,7 @@ create table if not exists public.coupons ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, - code citext not null, + code extensions.citext not null, plan_id uuid references public.svip_plans(id) on delete set null, discount_type text check (discount_type in ('percent', 'fixed')), discount_value numeric, diff --git a/supabase/migrations/202606210005_platform_admin_billing.sql b/supabase/migrations/202606210005_platform_admin_billing.sql index 74803154..8f4b7a63 100644 --- a/supabase/migrations/202606210005_platform_admin_billing.sql +++ b/supabase/migrations/202606210005_platform_admin_billing.sql @@ -21,7 +21,7 @@ create table if not exists public.tenant_billing_profiles ( tax_id text, contact_name text, contact_phone text, - contact_email citext, + contact_email extensions.citext, billing_address text, invoice_title text, invoice_type text check (invoice_type in ('none', 'normal_vat', 'special_vat')), diff --git a/supabase/migrations/202606210006_growth_referral_crm.sql b/supabase/migrations/202606210006_growth_referral_crm.sql index 63147697..2b718c63 100644 --- a/supabase/migrations/202606210006_growth_referral_crm.sql +++ b/supabase/migrations/202606210006_growth_referral_crm.sql @@ -2,7 +2,7 @@ create table if not exists public.referral_codes ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid not null references public.platform_users(id) on delete cascade, - code citext not null, + code extensions.citext not null, status text not null default 'active' check (status in ('active', 'disabled')), channel text, landing_path text, @@ -21,7 +21,7 @@ create table if not exists public.referral_leads ( tenant_id uuid not null references public.tenants(id) on delete cascade, student_user_id uuid not null references public.platform_users(id) on delete cascade, referrer_user_id uuid references public.platform_users(id) on delete set null, - ref_code citext, + ref_code extensions.citext, source text, first_track_id uuid references public.referral_tracks(id) on delete set null, bind_type text not null default 'first_touch' check (bind_type in ('first_touch', 'manual', 'imported')), @@ -54,7 +54,7 @@ create table if not exists public.referral_qrcodes ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, user_id uuid references public.platform_users(id) on delete set null, - ref_code citext not null, + ref_code extensions.citext not null, scene text not null, page text not null default 'pages/index/index', provider text not null default 'wechat-miniapp', diff --git a/supabase/migrations/202606210008_content_navigation_practice.sql b/supabase/migrations/202606210008_content_navigation_practice.sql index 8bd26bc6..dce13761 100644 --- a/supabase/migrations/202606210008_content_navigation_practice.sql +++ b/supabase/migrations/202606210008_content_navigation_practice.sql @@ -1,4 +1,4 @@ -create extension if not exists ltree; +create extension if not exists ltree with schema extensions; create table if not exists public.content_entries ( id uuid primary key default gen_random_uuid(), @@ -40,7 +40,7 @@ create table if not exists public.content_nodes ( marker_type text check (marker_type is null or marker_type in ('school', 'major', 'subject', 'exam_track', 'course_package', 'sales_intent', 'custom')), marker_config jsonb not null default '{}'::jsonb, - path ltree, + path extensions.ltree, depth integer not null default 0 check (depth >= 0), sort_order integer not null default 0, is_active boolean not null default true, diff --git a/supabase/migrations/202606290032_points_activities_exchange.sql b/supabase/migrations/202606290032_points_activities_exchange.sql index 3e23b8bf..542e6070 100644 --- a/supabase/migrations/202606290032_points_activities_exchange.sql +++ b/supabase/migrations/202606290032_points_activities_exchange.sql @@ -2,7 +2,7 @@ create table if not exists public.point_activity_tasks ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, - code citext not null, + code extensions.citext not null, title text not null, description text, task_type text not null default 'manual' @@ -60,7 +60,7 @@ create table if not exists public.point_exchange_items ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, legacy_id text, - code citext not null, + code extensions.citext not null, title text not null, description text, cost_points integer not null check (cost_points > 0 and cost_points <= 10000000), diff --git a/supabase/migrations/202607110001_data_api_acl_rls_hardening.sql b/supabase/migrations/202607110001_data_api_acl_rls_hardening.sql new file mode 100644 index 00000000..93d9ba2d --- /dev/null +++ b/supabase/migrations/202607110001_data_api_acl_rls_hardening.sql @@ -0,0 +1,102 @@ +-- Taro uses Supabase for Auth only. Business data is served by apps/api, so +-- public-schema Data API access must stay closed until a table/view/RPC has a +-- dedicated authorization model and an explicit grant migration. +revoke all privileges on all tables in schema public from public, anon, authenticated; +revoke all privileges on all sequences in schema public from public, anon, authenticated; +revoke all privileges on all functions in schema public from public, anon, authenticated; +revoke create on schema public from public, anon, authenticated; + +-- A standard Supabase migration user cannot change ACLs on public extension +-- functions owned by supabase_admin. The privileged runtime-role bootstrap must +-- seal those base-image functions before migrations; never accept warning-only +-- REVOKE output as proof that the Data API RPC surface is closed. +do $$ +begin + if exists ( + select 1 + from pg_proc function_row + join pg_namespace namespace on namespace.oid = function_row.pronamespace + cross join (values ('anon'), ('authenticated')) requested_role(role_name) + join pg_roles client_role on client_role.rolname = requested_role.role_name + where namespace.nspname = 'public' + and has_function_privilege(client_role.oid, function_row.oid, 'EXECUTE') + ) then + raise exception + 'public functions remain executable by anon/authenticated; run the privileged backend runtime role bootstrap before migrations'; + end if; +end +$$; + +do $$ +declare + owner_name name; +begin + for owner_name in + select distinct owner_role.rolname + from ( + select c.relowner as owner_oid + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' + and c.relkind in ('r', 'p', 'S', 'v', 'm', 'f') + union + select p.proowner as owner_oid + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' + ) owners + join pg_roles owner_role on owner_role.oid = owners.owner_oid + where owner_role.rolname = current_user + or pg_has_role(current_user, owner_role.oid, 'MEMBER') + loop + execute format( + 'alter default privileges for role %I in schema public revoke all privileges on tables from public, anon, authenticated', + owner_name + ); + execute format( + 'alter default privileges for role %I in schema public revoke all privileges on sequences from public, anon, authenticated', + owner_name + ); + -- PostgreSQL grants PUBLIC EXECUTE to new functions through the global + -- default ACL. A schema-local revoke cannot subtract that global default. + execute format( + 'alter default privileges for role %I revoke execute on functions from public, anon, authenticated', + owner_name + ); + end loop; +end $$; + +-- A platform role claim can outlive a staff-status change. Resolve platform +-- authority from the active database identity on every RLS evaluation instead. +create or replace function app.is_platform_admin() +returns boolean +language sql +stable +security definer +set search_path = '' +as $$ + select app.current_role() = 'service_role' + or exists ( + select 1 + from public.platform_users u + where u.auth_user_id = (select auth.uid()) + and u.primary_role = 'platform_admin' + and u.status = 'active' + ) +$$; + +revoke all on function app.is_platform_admin() from public, anon, authenticated; +grant execute on function app.is_platform_admin() to anon, authenticated, service_role; + +-- The original FOR ALL owner policy allowed a signed-in user to update their +-- own role, status and platform_permissions. Keep only a future-safe self-read +-- policy; no client table grant is provided by this migration. +drop policy if exists platform_admin_platform_users on public.platform_users; +drop policy if exists platform_users_self_read on public.platform_users; +create policy platform_users_self_read on public.platform_users + for select + to authenticated + using (auth_user_id = (select auth.uid())); + +comment on policy platform_users_self_read on public.platform_users is + 'Self-read policy only. anon/authenticated have no table grant; privileged updates must use apps/api.'; diff --git a/supabase/migrations/202607120001_destructive_test_environment_safety.sql b/supabase/migrations/202607120001_destructive_test_environment_safety.sql new file mode 100644 index 00000000..e317556e --- /dev/null +++ b/supabase/migrations/202607120001_destructive_test_environment_safety.sql @@ -0,0 +1,15 @@ +create table if not exists app_private.environment_safety ( + id boolean primary key default true check (id), + environment text not null check (environment in ('local', 'test', 'ci', 'staging', 'production')), + allow_destructive_tests boolean not null default false, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +comment on table app_private.environment_safety is + 'Fail-closed database marker for destructive integration fixtures. Production and staging must never set allow_destructive_tests=true.'; + +revoke all on app_private.environment_safety from public, anon, authenticated; + +-- Runtime access is granted centrally by +-- 202607120013_backend_runtime_roles.sql after the roles exist. diff --git a/supabase/migrations/202607120010_question_version_tenant_integrity.sql b/supabase/migrations/202607120010_question_version_tenant_integrity.sql new file mode 100644 index 00000000..e90e206c --- /dev/null +++ b/supabase/migrations/202607120010_question_version_tenant_integrity.sql @@ -0,0 +1,106 @@ +do $$ +declare + mismatched_version_count bigint; + mismatched_current_version_count bigint; +begin + select count(*) + into mismatched_version_count + from public.question_versions v + join public.questions q on q.id = v.question_id + where v.tenant_id <> q.tenant_id; + + if mismatched_version_count > 0 then + raise exception + 'Cannot enforce question version tenant integrity: % question_versions rows reference a question in another tenant', + mismatched_version_count; + end if; + + select count(*) + into mismatched_current_version_count + from public.questions q + join public.question_versions v on v.id = q.current_version_id + where q.current_version_id is not null + and (v.tenant_id <> q.tenant_id or v.question_id <> q.id); + + if mismatched_current_version_count > 0 then + raise exception + 'Cannot enforce current question version integrity: % questions point to a version from another tenant or question', + mismatched_current_version_count; + end if; +end +$$; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conrelid = 'public.questions'::regclass + and conname = 'questions_tenant_id_id_key' + ) then + alter table public.questions + add constraint questions_tenant_id_id_key unique (tenant_id, id); + end if; + + if not exists ( + select 1 + from pg_constraint + where conrelid = 'public.question_versions'::regclass + and conname = 'question_versions_tenant_question_id_id_key' + ) then + alter table public.question_versions + add constraint question_versions_tenant_question_id_id_key + unique (tenant_id, question_id, id); + end if; +end +$$; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conrelid = 'public.question_versions'::regclass + and conname = 'question_versions_tenant_question_fkey' + ) then + alter table public.question_versions + add constraint question_versions_tenant_question_fkey + foreign key (tenant_id, question_id) + references public.questions (tenant_id, id) + on delete cascade + not valid; + end if; + + if not exists ( + select 1 + from pg_constraint + where conrelid = 'public.questions'::regclass + and conname = 'questions_tenant_current_version_fkey' + ) then + alter table public.questions + add constraint questions_tenant_current_version_fkey + foreign key (tenant_id, id, current_version_id) + references public.question_versions (tenant_id, question_id, id) + on delete set null (current_version_id) + not valid; + end if; +end +$$; + +alter table public.question_versions + validate constraint question_versions_tenant_question_fkey; + +alter table public.questions + validate constraint questions_tenant_current_version_fkey; + +alter table public.question_versions + drop constraint if exists question_versions_question_id_fkey; + +alter table public.questions + drop constraint if exists questions_current_version_id_fkey; + +comment on constraint question_versions_tenant_question_fkey on public.question_versions is + 'A question version must belong to the same tenant as its parent question.'; + +comment on constraint questions_tenant_current_version_fkey on public.questions is + 'The current version pointer must reference a version of the same question in the same tenant.'; diff --git a/supabase/migrations/202607120011_tenant_student_search_pagination.sql b/supabase/migrations/202607120011_tenant_student_search_pagination.sql new file mode 100644 index 00000000..e0b52b03 --- /dev/null +++ b/supabase/migrations/202607120011_tenant_student_search_pagination.sql @@ -0,0 +1,27 @@ +create schema if not exists extensions; +create extension if not exists pg_trgm with schema extensions; + +create index if not exists idx_memberships_student_keyset_page + on public.tenant_memberships (tenant_id, status, created_at desc, id desc) + include (user_id) + where role = 'student'; + +set search_path = public, extensions; + +create index if not exists idx_platform_users_identity_search_trgm + on public.platform_users using gin ( + ( + coalesce(username, '') || ' ' || + coalesce(name, '') || ' ' || + coalesce(phone, '') || ' ' || + coalesce(email::text, '') + ) gin_trgm_ops + ); + +reset search_path; + +comment on index public.idx_memberships_student_keyset_page is + 'Supports tenant student keyset pagination without deep OFFSET scans.'; + +comment on index public.idx_platform_users_identity_search_trgm is + 'Supports tenant student substring search across username, name, phone and email.'; diff --git a/supabase/migrations/202607120012_sms_send_reservation_limits.sql b/supabase/migrations/202607120012_sms_send_reservation_limits.sql new file mode 100644 index 00000000..826b4e25 --- /dev/null +++ b/supabase/migrations/202607120012_sms_send_reservation_limits.sql @@ -0,0 +1,52 @@ +with ranked_active_codes as ( + select id, + row_number() over ( + partition by tenant_id, phone, purpose + order by created_at desc, id desc + ) as row_no + from public.sms_verification_codes + where consumed_at is null and status in ('pending', 'sent') +) +update public.sms_verification_codes code +set status = 'expired', + metadata = code.metadata || '{"expiredBy":"sms-active-reservation-migration"}'::jsonb +from ranked_active_codes ranked +where code.id = ranked.id and ranked.row_no > 1; + +create unique index if not exists idx_sms_codes_active_phone_reservation + on public.sms_verification_codes (tenant_id, phone, purpose) + where consumed_at is null and status in ('pending', 'sent'); + +create table if not exists app_private.sms_send_rate_limits ( + tenant_id uuid not null references public.tenants(id) on delete cascade, + dimension text not null check (dimension in ('tenant', 'phone', 'ip', 'device')), + scope_hash text not null, + bucket_start timestamptz not null, + request_count integer not null default 0 check (request_count >= 0), + updated_at timestamptz not null default now(), + primary key (tenant_id, dimension, scope_hash, bucket_start) +); + +revoke all on table app_private.sms_send_rate_limits from public, anon, authenticated; + +alter table app_private.sms_send_rate_limits enable row level security; + +drop policy if exists platform_admin_sms_send_rate_limits on app_private.sms_send_rate_limits; +create policy platform_admin_sms_send_rate_limits on app_private.sms_send_rate_limits + for all + using (app.is_platform_admin()) + with check (app.is_platform_admin()); + +create index if not exists idx_sms_send_rate_limits_updated + on app_private.sms_send_rate_limits (updated_at); + +comment on index public.idx_sms_codes_active_phone_reservation is + 'Atomically permits one active SMS send reservation per tenant, phone and purpose.'; + +comment on table app_private.sms_send_rate_limits is + 'Atomic hour/day SMS quota buckets. Dimension values are stored only as HMAC hashes.'; + +drop trigger if exists set_updated_at on app_private.sms_send_rate_limits; +create trigger set_updated_at + before update on app_private.sms_send_rate_limits + for each row execute function app.touch_updated_at(); diff --git a/supabase/migrations/202607120013_backend_runtime_roles.sql b/supabase/migrations/202607120013_backend_runtime_roles.sql new file mode 100644 index 00000000..b7d6aeb5 --- /dev/null +++ b/supabase/migrations/202607120013_backend_runtime_roles.sql @@ -0,0 +1,277 @@ +do $$ +declare + runtime_role name; + role_state record; +begin + foreach runtime_role in array array['tiku_api'::name, 'tiku_worker'::name] + loop + select role_row.*, + exists ( + select 1 from pg_auth_members membership + where membership.member = role_row.oid + ) as has_parent_roles + into role_state + from pg_roles role_row + where role_row.rolname = runtime_role; + + if not found then + raise exception + 'Runtime role % is missing. A PostgreSQL superuser must run scripts/bootstrap-backend-runtime-roles.js before migrations.', + runtime_role; + end if; + + if role_state.rolsuper + or role_state.rolinherit + or role_state.rolcreatedb + or role_state.rolcreaterole + or not role_state.rolcanlogin + or role_state.rolreplication + or not role_state.rolbypassrls + or role_state.has_parent_roles + or not coalesce(role_state.rolconfig, '{}'::text[]) + @> array['search_path=pg_catalog, public, extensions']::text[] then + raise exception + 'Runtime role % attributes are unsafe or incomplete. Re-run the privileged backend runtime role bootstrap.', + runtime_role; + end if; + end loop; +end +$$; + +-- Persistent DDL stays with the migration owner. Revoke PUBLIC first so an +-- effective CREATE privilege cannot leak back through the implicit role. +revoke create on schema public, app, app_private, extensions from public; +revoke all privileges on schema public, app, app_private, extensions from tiku_api, tiku_worker; +grant usage on schema public, app_private, extensions to tiku_api, tiku_worker; +grant usage on schema app to tiku_api; + +revoke all privileges + on all tables in schema public, app_private + from tiku_api, tiku_worker; + +grant select, insert, update, delete + on all tables in schema public + to tiku_api, tiku_worker; + +grant select + on all tables in schema app_private + to tiku_api, tiku_worker; + +grant insert, update + on app_private.auth_sessions, + app_private.tenant_secrets, + app_private.platform_secrets + to tiku_api; + +grant insert, update, delete + on app_private.sms_send_rate_limits + to tiku_api; + +revoke all privileges + on all sequences in schema public, app_private + from tiku_api, tiku_worker; + +grant usage, select + on all sequences in schema public + to tiku_api, tiku_worker; + +-- Normalize UUID defaults to the PostgreSQL 13+ core function. Supabase may +-- install pgcrypto in extensions while older self-hosted databases may have a +-- public wrapper, so runtime correctness must not depend on either layout. +do $$ +declare + default_column record; +begin + if to_regprocedure('pg_catalog.gen_random_uuid()') is null then + raise exception 'PostgreSQL 13 or newer is required: pg_catalog.gen_random_uuid() is unavailable'; + end if; + + for default_column in + select namespace.nspname as schema_name, + relation.relname as table_name, + attribute.attname as column_name + from pg_attrdef default_value + join pg_class relation on relation.oid = default_value.adrelid + join pg_namespace namespace on namespace.oid = relation.relnamespace + join pg_attribute attribute + on attribute.attrelid = relation.oid + and attribute.attnum = default_value.adnum + where namespace.nspname in ('public', 'app_private') + and relation.relkind in ('r', 'p') + and attribute.atttypid = 'uuid'::regtype + and pg_get_expr(default_value.adbin, default_value.adrelid) + ~ '(^|[.])gen_random_uuid[(][)]$' + loop + execute format( + 'alter table %I.%I alter column %I set default pg_catalog.gen_random_uuid()', + default_column.schema_name, + default_column.table_name, + default_column.column_name + ); + end loop; +end +$$; + +-- Function execution is reviewed explicitly. The API directly calls only the +-- public-bank helpers; pg_catalog functions retain PostgreSQL's built-in ACL. +revoke execute on all functions in schema app from public; +revoke all privileges + on all functions in schema public, app, app_private + from tiku_api, tiku_worker; + +-- RLS policies resolve these functions by OID. Client roles still need +-- EXECUTE even though the app schema is not exposed for direct RPC access. +grant execute on function app.jwt_text(text) + to anon, authenticated, service_role; + +grant execute on function app.current_tenant_id() + to anon, authenticated, service_role; + +grant execute on function app.current_role() + to anon, authenticated, service_role; + +grant execute on function app.is_platform_admin() + to anon, authenticated, service_role; + +grant execute on function app.uuid_array_from_jsonb(jsonb) + to anon, authenticated, service_role; + +grant execute on function app.public_question_bank_grant_allows(uuid[], uuid[], uuid, uuid[]) + to anon, authenticated, service_role; + +grant execute on function app.public_question_bank_subscription_allows(jsonb, jsonb, uuid, uuid, uuid[]) + to anon, authenticated, service_role; + +grant execute on function app.uuid_array_from_jsonb(jsonb) + to tiku_api; + +grant execute on function app.public_question_bank_grant_allows(uuid[], uuid[], uuid, uuid[]) + to tiku_api; + +grant execute on function app.public_question_bank_subscription_allows(jsonb, jsonb, uuid, uuid, uuid[]) + to tiku_api; + +-- Keep future migration-owned objects on the same privilege matrix. New +-- functions intentionally receive no runtime grant until reviewed. +do $$ +declare + schema_name text; + owner_name name; +begin + foreach schema_name in array array['public', 'app_private'] + loop + for owner_name in + select distinct owner_role.rolname + from ( + select c.relowner as owner_oid + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = schema_name + union + select p.proowner as owner_oid + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = schema_name + ) owners + join pg_roles owner_role on owner_role.oid = owners.owner_oid + where owner_role.rolname = current_user + or pg_has_role(current_user, owner_role.oid, 'MEMBER') + loop + execute format( + 'alter default privileges for role %I in schema %I revoke all privileges on tables from tiku_api, tiku_worker', + owner_name, + schema_name + ); + execute format( + 'alter default privileges for role %I in schema %I revoke all privileges on sequences from tiku_api, tiku_worker', + owner_name, + schema_name + ); + + if schema_name = 'public' then + execute format( + 'alter default privileges for role %I in schema public grant select, insert, update, delete on tables to tiku_api, tiku_worker', + owner_name + ); + execute format( + 'alter default privileges for role %I in schema public grant usage, select on sequences to tiku_api, tiku_worker', + owner_name + ); + else + execute format( + 'alter default privileges for role %I in schema app_private grant select on tables to tiku_api, tiku_worker', + owner_name + ); + end if; + end loop; + end loop; +end +$$; + +-- PostgreSQL's built-in function default is PUBLIC EXECUTE. Revoke it at the +-- owner-global level because a schema-local default ACL cannot subtract that +-- global default for future app functions. +do $$ +declare + owner_name name; +begin + for owner_name in + select distinct owner_role.rolname + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + join pg_roles owner_role on owner_role.oid = p.proowner + where n.nspname = 'app' + and ( + owner_role.rolname = current_user + or pg_has_role(current_user, owner_role.oid, 'MEMBER') + ) + loop + execute format( + 'alter default privileges for role %I revoke execute on functions from public', + owner_name + ); + end loop; +end +$$; + +-- Fail closed if an earlier manual setup made a runtime role an owner. Owners +-- can always ALTER/DROP their objects regardless of grants. +do $$ +declare + owned_object_count integer; +begin + select count(*) + into owned_object_count + from ( + select c.relowner as owner_oid + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where n.nspname in ('public', 'app', 'app_private') + union all + select p.proowner + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname in ('public', 'app', 'app_private') + union all + select t.typowner + from pg_type t + join pg_namespace n on n.oid = t.typnamespace + where n.nspname in ('public', 'app', 'app_private') + union all + select n.nspowner + from pg_namespace n + where n.nspname in ('public', 'app', 'app_private') + union all + select d.datdba + from pg_database d + where d.datname = current_database() + ) owners + join pg_roles owner_role on owner_role.oid = owners.owner_oid + where owner_role.rolname in ('tiku_api', 'tiku_worker'); + + if owned_object_count > 0 then + raise exception + 'tiku_api/tiku_worker must not own database objects; reassign ownership to the migration role before applying this migration'; + end if; +end +$$; diff --git a/supabase/migrations/202607120014_audit_log_capacity_indexes.sql b/supabase/migrations/202607120014_audit_log_capacity_indexes.sql new file mode 100644 index 00000000..f1ec7af9 --- /dev/null +++ b/supabase/migrations/202607120014_audit_log_capacity_indexes.sql @@ -0,0 +1,33 @@ +create index if not exists idx_audit_logs_created + on public.audit_logs (created_at desc, id desc); + +create index if not exists idx_audit_logs_tenant_created + on public.audit_logs (tenant_id, created_at desc, id desc) + where tenant_id is not null; + +create index if not exists idx_audit_logs_tenant_actor_created + on public.audit_logs (tenant_id, actor_user_id, created_at desc, id desc) + where tenant_id is not null and actor_user_id is not null; + +create index if not exists idx_audit_logs_tenant_target_created + on public.audit_logs (tenant_id, target_type, created_at desc, id desc) + where tenant_id is not null and target_type is not null; + +create index if not exists idx_audit_logs_platform_created + on public.audit_logs (created_at, id) + where action like 'platform.%'; + +comment on index public.idx_audit_logs_created is + 'Supports the bounded newest-first platform audit list and exports.'; + +comment on index public.idx_audit_logs_tenant_created is + 'Supports newest-first tenant audit history without scanning other tenants.'; + +comment on index public.idx_audit_logs_tenant_actor_created is + 'Supports tenant audit history filtered by actor.'; + +comment on index public.idx_audit_logs_tenant_target_created is + 'Supports tenant audit history filtered by target type.'; + +comment on index public.idx_audit_logs_platform_created is + 'Supports incremental platform audit alert scans over recent platform events.'; diff --git a/supabase/migrations/202607120015_core_tenant_foreign_key_integrity.sql b/supabase/migrations/202607120015_core_tenant_foreign_key_integrity.sql new file mode 100644 index 00000000..0b920476 --- /dev/null +++ b/supabase/migrations/202607120015_core_tenant_foreign_key_integrity.sql @@ -0,0 +1,237 @@ +do $$ +declare + violation record; +begin + for violation in + select relation_name, cross_tenant_count + from ( + select 'answer_records.question_id'::text as relation_name, count(*)::bigint as cross_tenant_count + from public.answer_records child + join public.questions parent on parent.id = child.question_id + where child.question_id is not null and child.tenant_id <> parent.tenant_id + + union all + select 'answer_records.question_version_id', count(*)::bigint + from public.answer_records child + join public.question_versions parent on parent.id = child.question_version_id + where child.question_version_id is not null and child.tenant_id <> parent.tenant_id + + union all + select 'answer_records.practice_session_id', count(*)::bigint + from public.answer_records child + join public.practice_sessions parent on parent.id = child.practice_session_id + where child.practice_session_id is not null and child.tenant_id <> parent.tenant_id + + union all + select 'favorite_questions.question_id', count(*)::bigint + from public.favorite_questions child + join public.questions parent on parent.id = child.question_id + where child.tenant_id <> parent.tenant_id + + union all + select 'wrong_questions.question_id', count(*)::bigint + from public.wrong_questions child + join public.questions parent on parent.id = child.question_id + where child.tenant_id <> parent.tenant_id + + union all + select 'payments.order_id', count(*)::bigint + from public.payments child + join public.orders parent on parent.id = child.order_id + where child.tenant_id <> parent.tenant_id + + union all + select 'content_export_jobs.asset_id', count(*)::bigint + from public.content_export_jobs child + join public.content_assets parent on parent.id = child.asset_id + where child.asset_id is not null and child.tenant_id <> parent.tenant_id + ) checks + where cross_tenant_count > 0 + loop + raise exception + 'Cannot enforce core tenant foreign key integrity: % has % cross-tenant rows', + violation.relation_name, + violation.cross_tenant_count; + end loop; +end +$$; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.question_versions'::regclass + and conname = 'question_versions_tenant_id_id_key' + ) then + alter table public.question_versions + add constraint question_versions_tenant_id_id_key unique (tenant_id, id); + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.practice_sessions'::regclass + and conname = 'practice_sessions_tenant_id_id_key' + ) then + alter table public.practice_sessions + add constraint practice_sessions_tenant_id_id_key unique (tenant_id, id); + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.orders'::regclass + and conname = 'orders_tenant_id_id_key' + ) then + alter table public.orders + add constraint orders_tenant_id_id_key unique (tenant_id, id); + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.content_assets'::regclass + and conname = 'content_assets_tenant_id_id_key' + ) then + alter table public.content_assets + add constraint content_assets_tenant_id_id_key unique (tenant_id, id); + end if; +end +$$; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.answer_records'::regclass + and conname = 'answer_records_tenant_question_fkey' + ) then + alter table public.answer_records + add constraint answer_records_tenant_question_fkey + foreign key (tenant_id, question_id) + references public.questions (tenant_id, id) + on delete set null (question_id) + not valid; + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.answer_records'::regclass + and conname = 'answer_records_tenant_question_version_fkey' + ) then + alter table public.answer_records + add constraint answer_records_tenant_question_version_fkey + foreign key (tenant_id, question_version_id) + references public.question_versions (tenant_id, id) + on delete set null (question_version_id) + not valid; + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.answer_records'::regclass + and conname = 'answer_records_tenant_practice_session_fkey' + ) then + alter table public.answer_records + add constraint answer_records_tenant_practice_session_fkey + foreign key (tenant_id, practice_session_id) + references public.practice_sessions (tenant_id, id) + on delete set null (practice_session_id) + not valid; + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.favorite_questions'::regclass + and conname = 'favorite_questions_tenant_question_fkey' + ) then + alter table public.favorite_questions + add constraint favorite_questions_tenant_question_fkey + foreign key (tenant_id, question_id) + references public.questions (tenant_id, id) + on delete cascade + not valid; + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.wrong_questions'::regclass + and conname = 'wrong_questions_tenant_question_fkey' + ) then + alter table public.wrong_questions + add constraint wrong_questions_tenant_question_fkey + foreign key (tenant_id, question_id) + references public.questions (tenant_id, id) + on delete cascade + not valid; + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.payments'::regclass + and conname = 'payments_tenant_order_fkey' + ) then + alter table public.payments + add constraint payments_tenant_order_fkey + foreign key (tenant_id, order_id) + references public.orders (tenant_id, id) + on delete cascade + not valid; + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.content_export_jobs'::regclass + and conname = 'content_export_jobs_tenant_asset_fkey' + ) then + alter table public.content_export_jobs + add constraint content_export_jobs_tenant_asset_fkey + foreign key (tenant_id, asset_id) + references public.content_assets (tenant_id, id) + on delete set null (asset_id) + not valid; + end if; +end +$$; + +alter table public.answer_records + validate constraint answer_records_tenant_question_fkey; +alter table public.answer_records + validate constraint answer_records_tenant_question_version_fkey; +alter table public.answer_records + validate constraint answer_records_tenant_practice_session_fkey; +alter table public.favorite_questions + validate constraint favorite_questions_tenant_question_fkey; +alter table public.wrong_questions + validate constraint wrong_questions_tenant_question_fkey; +alter table public.payments + validate constraint payments_tenant_order_fkey; +alter table public.content_export_jobs + validate constraint content_export_jobs_tenant_asset_fkey; + +alter table public.answer_records + drop constraint if exists answer_records_question_id_fkey; +alter table public.answer_records + drop constraint if exists answer_records_question_version_id_fkey; +alter table public.answer_records + drop constraint if exists answer_records_practice_session_id_fkey; +alter table public.favorite_questions + drop constraint if exists favorite_questions_question_id_fkey; +alter table public.wrong_questions + drop constraint if exists wrong_questions_question_id_fkey; +alter table public.payments + drop constraint if exists payments_order_id_fkey; +alter table public.content_export_jobs + drop constraint if exists content_export_jobs_asset_id_fkey; + +comment on constraint answer_records_tenant_question_fkey on public.answer_records is + 'An answer record may only reference a question in the same tenant.'; +comment on constraint answer_records_tenant_question_version_fkey on public.answer_records is + 'An answer record may only reference a question version in the same tenant.'; +comment on constraint answer_records_tenant_practice_session_fkey on public.answer_records is + 'An answer record may only reference a practice session in the same tenant.'; +comment on constraint favorite_questions_tenant_question_fkey on public.favorite_questions is + 'A favorite may only reference a question in the same tenant.'; +comment on constraint wrong_questions_tenant_question_fkey on public.wrong_questions is + 'A wrong-question record may only reference a question in the same tenant.'; +comment on constraint payments_tenant_order_fkey on public.payments is + 'A payment may only reference an order in the same tenant.'; +comment on constraint content_export_jobs_tenant_asset_fkey on public.content_export_jobs is + 'An export job may only reference an output asset in the same tenant.'; diff --git a/supabase/migrations/202607120016_content_import_job_leases.sql b/supabase/migrations/202607120016_content_import_job_leases.sql new file mode 100644 index 00000000..1afc3978 --- /dev/null +++ b/supabase/migrations/202607120016_content_import_job_leases.sql @@ -0,0 +1,97 @@ +alter table public.content_import_jobs + add column if not exists lease_token uuid, + add column if not exists lease_expires_at timestamptz, + add column if not exists last_heartbeat_at timestamptz; + +-- Jobs left in importing by the pre-lease worker cannot prove ownership. Put +-- them back in the queue so the first lease-aware worker can claim them. +update public.content_import_jobs +set status = 'pending', + locked_at = null, + locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, + next_attempt_at = coalesce(next_attempt_at, now()), + summary = coalesce(summary, '{}'::jsonb) || jsonb_build_object( + 'leaseMigrationRecovery', jsonb_build_object( + 'requeuedAt', now(), + 'reason', 'legacy_importing_job_without_persistent_lease' + ) + ), + updated_at = now() +where execution_mode = 'async' + and status = 'importing' + and (lease_token is null or lease_expires_at is null); + +update public.content_import_jobs +set locked_at = null, + locked_by = null, + lease_token = null, + lease_expires_at = null, + last_heartbeat_at = null, + updated_at = now() +where execution_mode = 'async' + and status <> 'importing' + and ( + locked_at is not null + or locked_by is not null + or lease_token is not null + or lease_expires_at is not null + or last_heartbeat_at is not null + ); + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'content_import_jobs_async_lease_state_check' + and conrelid = 'public.content_import_jobs'::regclass + ) then + alter table public.content_import_jobs + add constraint content_import_jobs_async_lease_state_check + check ( + execution_mode <> 'async' + or ( + status = 'importing' + and locked_at is not null + and locked_by is not null + and lease_token is not null + and lease_expires_at is not null + and last_heartbeat_at is not null + and lease_expires_at > locked_at + ) + or ( + status <> 'importing' + and locked_at is null + and locked_by is null + and lease_token is null + and lease_expires_at is null + and last_heartbeat_at is null + ) + ); + end if; +end $$; + +create index if not exists idx_content_import_jobs_async_pending_ready + on public.content_import_jobs(next_attempt_at, created_at, id) + where execution_mode = 'async' + and status = 'pending' + and attempt_count < max_attempts; + +create index if not exists idx_content_import_jobs_async_expired_lease + on public.content_import_jobs(lease_expires_at, created_at, id) + where execution_mode = 'async' + and status = 'importing' + and attempt_count < max_attempts; + +do $$ +begin + if to_regrole('tiku_api') is not null then + grant select, insert, update, delete on table public.content_import_jobs to tiku_api; + end if; + if to_regrole('tiku_worker') is not null then + grant select, insert, update, delete on table public.content_import_jobs to tiku_worker; + end if; +end $$; diff --git a/supabase/migrations/202607120017_answer_semantics_and_fk_indexes.sql b/supabase/migrations/202607120017_answer_semantics_and_fk_indexes.sql new file mode 100644 index 00000000..c1dba51d --- /dev/null +++ b/supabase/migrations/202607120017_answer_semantics_and_fk_indexes.sql @@ -0,0 +1,137 @@ +do $$ +declare + version_question_mismatches bigint; + versions_without_questions bigint; + session_user_mismatches bigint; +begin + select count(*) + into versions_without_questions + from public.answer_records + where question_version_id is not null + and question_id is null; + + if versions_without_questions > 0 then + raise exception + 'Cannot enforce answer question-version integrity: % answers reference a version without its question', + versions_without_questions; + end if; + + select count(*) + into version_question_mismatches + from public.answer_records answer + join public.question_versions version on version.id = answer.question_version_id + where answer.question_id is not null + and answer.question_version_id is not null + and answer.question_id <> version.question_id; + + if version_question_mismatches > 0 then + raise exception + 'Cannot enforce answer question-version integrity: % answers reference a version from another question', + version_question_mismatches; + end if; + + select count(*) + into session_user_mismatches + from public.answer_records answer + join public.practice_sessions session on session.id = answer.practice_session_id + where answer.practice_session_id is not null + and answer.user_id <> session.user_id; + + if session_user_mismatches > 0 then + raise exception + 'Cannot enforce answer session-user integrity: % answers reference another user''s practice session', + session_user_mismatches; + end if; +end +$$; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.answer_records'::regclass + and conname = 'answer_records_question_version_requires_question_check' + ) then + alter table public.answer_records + add constraint answer_records_question_version_requires_question_check + check (question_version_id is null or question_id is not null) + not valid; + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.practice_sessions'::regclass + and conname = 'practice_sessions_tenant_user_id_key' + ) then + alter table public.practice_sessions + add constraint practice_sessions_tenant_user_id_key + unique (tenant_id, user_id, id); + end if; +end +$$; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.answer_records'::regclass + and conname = 'answer_records_tenant_question_version_pair_fkey' + ) then + alter table public.answer_records + add constraint answer_records_tenant_question_version_pair_fkey + foreign key (tenant_id, question_id, question_version_id) + references public.question_versions (tenant_id, question_id, id) + on delete set null (question_version_id) + not valid; + end if; + + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.answer_records'::regclass + and conname = 'answer_records_tenant_user_session_fkey' + ) then + alter table public.answer_records + add constraint answer_records_tenant_user_session_fkey + foreign key (tenant_id, user_id, practice_session_id) + references public.practice_sessions (tenant_id, user_id, id) + on delete set null (practice_session_id) + not valid; + end if; +end +$$; + +alter table public.answer_records + validate constraint answer_records_question_version_requires_question_check; +alter table public.answer_records + validate constraint answer_records_tenant_question_version_pair_fkey; +alter table public.answer_records + validate constraint answer_records_tenant_user_session_fkey; + +alter table public.answer_records + drop constraint if exists answer_records_tenant_question_version_fkey; +alter table public.answer_records + drop constraint if exists answer_records_tenant_practice_session_fkey; + +create index if not exists idx_answer_records_tenant_question + on public.answer_records (tenant_id, question_id) + where question_id is not null; + +create index if not exists idx_answer_records_tenant_question_version + on public.answer_records (tenant_id, question_id, question_version_id) + where question_version_id is not null; + +create index if not exists idx_answer_records_tenant_user_session + on public.answer_records (tenant_id, user_id, practice_session_id) + where practice_session_id is not null; + +create index if not exists idx_favorite_questions_tenant_question + on public.favorite_questions (tenant_id, question_id); + +create index if not exists idx_wrong_questions_tenant_question + on public.wrong_questions (tenant_id, question_id); + +comment on constraint answer_records_tenant_question_version_pair_fkey on public.answer_records is + 'When both pointers are present, an answer version must belong to the referenced question and tenant.'; + +comment on constraint answer_records_tenant_user_session_fkey on public.answer_records is + 'An answer may only reference a practice session owned by the same user and tenant.'; diff --git a/supabase/migrations/202607120018_auth_user_reference_boundary.sql b/supabase/migrations/202607120018_auth_user_reference_boundary.sql new file mode 100644 index 00000000..e21c6b8c --- /dev/null +++ b/supabase/migrations/202607120018_auth_user_reference_boundary.sql @@ -0,0 +1,22 @@ +-- Backend platform-admin flows only need to validate a supplied Auth UUID. Do +-- not grant the API role direct access to auth.users or its profile metadata. +create or replace function app.auth_user_exists(target_user_id uuid) +returns boolean +language sql +stable +security definer +set search_path = '' +as $$ + select exists ( + select 1 + from auth.users auth_user + where auth_user.id = target_user_id + ) +$$; + +revoke all on function app.auth_user_exists(uuid) + from public, anon, authenticated, service_role, tiku_api, tiku_worker; +grant execute on function app.auth_user_exists(uuid) to tiku_api; + +comment on function app.auth_user_exists(uuid) is + 'Minimal Auth boundary for the trusted API role; returns existence only and exposes no auth.users profile fields.'; diff --git a/supabase/migrations/202607120019_production_migration_history_boundary.sql b/supabase/migrations/202607120019_production_migration_history_boundary.sql new file mode 100644 index 00000000..a8470493 --- /dev/null +++ b/supabase/migrations/202607120019_production_migration_history_boundary.sql @@ -0,0 +1,38 @@ +-- Runtime roles must not read Supabase's migration ledger directly. Expose only +-- the aggregate state needed by the production readiness gate. +create or replace function app.production_migration_history(expected_version text) +returns table ( + latest_version text, + applied_count bigint, + distinct_version_count bigint, + expected_version_applied boolean +) +language plpgsql +security definer +stable +set search_path = '' +as $$ +begin + if pg_catalog.to_regclass('supabase_migrations.schema_migrations') is null then + return query + select null::text, 0::bigint, 0::bigint, false; + return; + end if; + + return query execute + 'select max(version::text), + count(*)::bigint, + count(distinct version::text)::bigint, + bool_or(version::text = $1) + from supabase_migrations.schema_migrations' + using expected_version; +end +$$; + +revoke all on function app.production_migration_history(text) + from public, anon, authenticated, service_role, tiku_api, tiku_worker; + +grant execute on function app.production_migration_history(text) to tiku_api; + +comment on function app.production_migration_history(text) is + 'Returns aggregate Supabase migration state for the production readiness gate without exposing migration SQL.'; diff --git a/supabase/seed.sql b/supabase/seed.sql index 66357af3..9e4a2f6f 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,3 +1,10 @@ +insert into app_private.environment_safety (id, environment, allow_destructive_tests) +values (true, 'local', true) +on conflict (id) +do update set environment = excluded.environment, + allow_destructive_tests = excluded.allow_destructive_tests, + updated_at = now(); + insert into public.tenants (id, slug, name, status, mode) values ( '00000000-0000-0000-0000-000000000001',