From e1bd3debb8ee94679a73622403b3e2f3d9cfe8ac Mon Sep 17 00:00:00 2001 From: greenthree <1395214327@qq.com> Date: Sun, 9 Aug 2026 12:26:52 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E5=AE=8C=E5=96=84=20Agent=20=E5=86=B7?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E4=BA=A4=E6=8E=A5=E4=B8=8E=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=B0=B1=E7=BB=AA=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 + LICENSE | 201 +++++++++++ README.md | 21 +- ROADMAP.md | 10 +- agent.md | 213 +++++++++++ docs/backup-and-recovery.md | 7 +- ...ent-cold-start-handoff-audit-2026-08-09.md | 88 +++++ docs/maintainer-handoff.md | 340 +++++++++++------- docs/operations-runbook.md | 18 +- docs/release-checklist.md | 18 +- scripts/check-repository-readiness.mjs | 69 +++- scripts/check-repository-readiness.test.ts | 30 +- 12 files changed, 847 insertions(+), 175 deletions(-) create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 agent.md create mode 100644 docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c396795 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Agent Instructions + +Before taking any action in this repository, read [`agent.md`](./agent.md) completely and follow it as the root operational contract. + +All development, testing, browser work, GitHub operations, deployment, monitoring, synchronization maintenance, backup orchestration, and incident response are performed by Agents. The project owner only supplies credentials, MFA/CAPTCHA, legal or product decisions, and explicit approval for irreversible or high-risk production changes. + +More specific `AGENTS.md` files may add directory-level constraints but must not weaken the root contract. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b33c3f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 USTSACMLand contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 103121c..9befdf6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 苏州科技大学 ACM 集训队官网。项目使用 GitHub Pages 托管 React SPA,介绍算法竞赛、主要赛事、线上公开赛、学习资源和入队方式,并通过 Supabase 提供认证、Postgres、RLS 和 Edge Functions,展示队员在多个竞赛平台的 Rating 与刷题数据。 -> 当前状态:集训队官网、生产 Supabase、首管理员、十个 Edge Function 和 71 个 production migration 均已部署,前端由 GitHub Pages 发布并连接真实认证、榜单与管理接口。每日一题、讨论、训练目标、刷题增量榜和个人数据导出已上线;六个平台的可恢复同步失败统一最多自动重试一次。Firecrawl 多 Key、QOJ 独立 Browser Sandbox、真实邮箱找回密码、加密备份与隔离恢复、注销恢复下限围栏和细粒度 GitHub 凭据均已完成生产验收。WebChat / AI 学习助手与推荐计划已经退出产品范围并停止开发,生产入口、请求和奖励流程保持关闭;既有前端、Edge Function、数据库 migration、测试及安全实现仅作为遗留实现保留,不删除、不回滚,也不作为发布待办。主分支 CI、secret scan、Pages build/deploy、生产榜单审计和可复跑生产安全检查持续作为发布门禁。 +> 当前状态:集训队官网、生产 Supabase、首管理员、12 个 Edge Function 和 75 个 production migration 均已部署,前端由 GitHub Pages 发布并连接真实认证、榜单与管理接口。每日一题、讨论、训练目标、刷题增量榜和个人数据导出已上线;六个平台的可恢复同步失败统一最多自动重试一次。Firecrawl 多 Key、QOJ 独立 Browser Sandbox、真实邮箱找回密码、加密备份与隔离恢复、注销恢复下限围栏和细粒度 GitHub 凭据均已完成生产验收。WebChat / AI 学习助手与推荐计划已经退出产品范围并停止开发,生产入口、请求和奖励流程保持关闭;既有前端、Edge Function、数据库 migration、测试及安全实现仅作为遗留实现保留,不删除、不回滚,也不作为发布待办。主分支 CI、secret scan、Pages build/deploy、生产榜单审计和可复跑生产安全检查持续作为发布门禁。 ## 已实现 @@ -223,16 +223,21 @@ Turnstile 私有 Secret 只配置在 Supabase Auth,真实邮箱确认、Auth 直连拒绝必须一起验收。完整启用与回滚顺序见 [注册滥用防护](./docs/registration-abuse-controls.md)。 -GitHub Actions Secrets: +GitHub 仓库级 Actions Secrets: -- `SUPABASE_PROJECT_REF` -- `SUPABASE_SERVICE_ROLE_KEY` - `VITE_SUPABASE_URL` - `VITE_SUPABASE_ANON_KEY` - `SUPABASE_ACCESS_TOKEN`(仅供加密数据库备份;CLI 每次运行动态取得短期数据库登录,不保存长期数据库密码) - `BACKUP_ENCRYPTION_PASSPHRASE`(独立随机口令,至少 32 个字符) - 遗留 WebChat 兼容性工作流仍识别 `CHAT_RELAY_BASE_URL`、`CHAT_RELAY_API_KEY`、`CHAT_RELAY_MODEL`,但模块停止后不再运行付费协议、Abort 或缓存验收,也不再向 GitHub 新增或更新这三项 Secret。仍保留在 Supabase Vault 的生产配置不得复制到 `VITE_*`、前端、日志或发布记录。 +`production-operations` Environment Secrets: + +- `SUPABASE_PROJECT_REF` +- `SUPABASE_SERVICE_ROLE_KEY` + +该 Environment 只允许默认分支 `main`,生产同步、备份、恢复和只读探针只能通过绑定该 Environment 的工作流取得上述名称对应的值;不得在仓库级或组织级创建同名副本。 + Supabase Function Secrets/配置: - `FIRECRAWL_API_KEY`(数据库多 Key 池尚未建立时的兼容 Key;只允许配置在 Supabase Function Secrets) @@ -290,7 +295,7 @@ npx --yes deno run \ ## 部署 -生产 Supabase 项目已关联;截至 2026-07-25,仓库与生产均为 71 个 migration、0 项 pending,十个 Edge Function 均为 ACTIVE、启用 JWT 验证并使用仓库 import map。`webchat-attachment` 与 `webchat-image-cleanup` 已以默认关闭方式部署,推荐计划仍全局暂停。仓库 migration 必须按时间顺序应用;部署前先使用 `supabase migration list --linked` 和 `db push --dry-run --include-all` 核对远端状态。函数部署需要显式传入 Deno import map: +生产 Supabase 项目已关联;截至 2026-08-09,仓库与生产均为 75 个 migration、0 项 pending,12 个 Edge Function 均为 ACTIVE 并使用仓库 import map;除公开头像代理 `member-avatar` 按设计关闭 JWT 验证外,其余函数均启用 JWT 验证。`webchat-attachment` 与 `webchat-image-cleanup` 已以默认关闭方式部署,推荐计划仍全局暂停。仓库 migration 必须按时间顺序应用;部署前先使用 `supabase migration list --linked` 和 `db push --dry-run --include-all` 核对远端状态。函数部署需要显式传入 Deno import map: `202607140010_platform_account_canonicalization.sql` 会在修改数据前检查历史牛客/洛谷绑定:如果两个成员的 UID 只差前导零,或存在超过 20 位的旧 UID,migration 会带修复提示安全终止。管理员应先在成员管理中确认归属并改正或解绑冲突记录,再重新应用 migration;脚本不会自动选择账号所有者或删除成员数据。 @@ -315,11 +320,11 @@ Vite 生产 `base` 使用域名根路径 `/`,构建脚本会复制 `dist/index ## 当前限制与下一步 1. 保持 WebChat 与推荐计划关闭,防止遗留入口、请求、图片上传或奖励流程被误开启;除关闭态安全修复外不再投入开发。 -2. 由项目负责人确定源码许可证和学校、集训队、赛事标识授权范围,并由第二名维护者按 [维护者交接操作卡](./docs/maintainer-handoff.md) 完成独立演练。 +2. 源代码采用 Apache License 2.0;学校、集训队和赛事标识的授权范围仍需单独确认。项目采用 Agent 全程维护模式,后续 Agent 先按根目录 [`agent.md`](./agent.md) 和 [Agent 维护交接手册](./docs/maintainer-handoff.md) 冷启动。项目负责人只提供 Agent 无法代替的凭据输入、账号持有人操作、法律/产品决定和高风险批准,并接受没有第二名真人维护者的单点风险。 3. 按 [正式发布检查单](./docs/release-checklist.md) 创建 `v1.0.0`,补齐 Cloudflare Purge、证书和 DNS 回滚验收。人工屏幕阅读器验收不纳入本项目发布范围;现有键盘操作、语义结构和自动化可访问性回归继续保留。 -视觉规范见 [docs/DESIGN.md](./docs/DESIGN.md),架构取舍见 [docs/adr/](./docs/adr/README.md),部署与故障处理见 [生产运维手册](./docs/operations-runbook.md),维护权交接见 [维护者交接与独立操作卡](./docs/maintainer-handoff.md),数据恢复见 [数据库备份与恢复方案](./docs/backup-and-recovery.md),遗留 WebChat 图片边界保留在 [图片输入 v1 历史契约](./docs/webchat-image-input-v1.md),发布门禁见 [正式发布检查单](./docs/release-checklist.md),详细进度见 [ROADMAP.md](./ROADMAP.md)。 +Agent 的根级操作契约见 [`agent.md`](./agent.md),视觉规范见 [docs/DESIGN.md](./docs/DESIGN.md),架构取舍见 [docs/adr/](./docs/adr/README.md),部署与故障处理见 [生产运维手册](./docs/operations-runbook.md),具体维护操作卡见 [Agent 维护交接手册](./docs/maintainer-handoff.md),数据恢复见 [数据库备份与恢复方案](./docs/backup-and-recovery.md),遗留 WebChat 图片边界保留在 [图片输入 v1 历史契约](./docs/webchat-image-input-v1.md),发布门禁见 [正式发布检查单](./docs/release-checklist.md),详细进度见 [ROADMAP.md](./ROADMAP.md)。 ## 许可证与归属 -许可证尚未确定;在补充 `LICENSE` 前,源码默认不授予复制、修改或再分发许可。数据处理说明见 [PRIVACY.md](./PRIVACY.md) 与 [第三方数据来源说明](./docs/third-party-data-sources.md),漏洞报告方式见 [SECURITY.md](./SECURITY.md)。正式使用学校及 ACM 集训队相关标识前仍需确认授权范围。 +源代码按 Apache License 2.0(SPDX 标识符:`Apache-2.0`)发布,详见 [`LICENSE`](./LICENSE)。该许可证仅覆盖项目原创源代码,不自动授予学校、集训队或赛事名称与图形标识的使用权,也不覆盖第三方字体、图片、成员数据或平台数据;这些内容分别遵循其独立授权和数据处理边界。数据处理说明见 [PRIVACY.md](./PRIVACY.md) 与 [第三方数据来源说明](./docs/third-party-data-sources.md),漏洞报告方式见 [SECURITY.md](./SECURITY.md)。 diff --git a/ROADMAP.md b/ROADMAP.md index a061f86..26d6565 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # USTSACMLand 开发路线图 -最后更新:2026-07-29(Asia/Shanghai) +最后更新:2026-08-09(Asia/Shanghai) 本路线图只保留仍需执行的工作和当前生产基线,不再把已经完成的每次迁移、测试数量和历史烟测逐条堆在主文档中。详细实现记录放在 `README.md`、`docs/evidence/`、`docs/operations-runbook.md` 和 GitHub Pull Request 中。 @@ -40,7 +40,7 @@ USTSACMLand 的定位是苏州科技大学 ACM 集训队官网,当前产品范 ## 2. 当前生产基线 - [x] GitHub Pages 已发布 React SPA,支持子路径资源、深链刷新和生产榜单审计。 -- [x] Supabase Auth、Postgres、RLS、十个 Edge Function 和 71 个生产 migration 已部署;十函数 JWT、import map、按用途区分的浏览器 CORS/后台拒绝边界及同步调度已通过生产只读检查。推荐计划因生产邮箱自动确认无法证明邮箱控制权而安全暂停,重开安全闸门已部署;WebChat 图片数据库与函数安全基础已部署,但前端入口、视觉模型和定时清理开关继续关闭。证据见 [`docs/evidence/supabase-ten-function-readiness-2026-07-26.md`](./docs/evidence/supabase-ten-function-readiness-2026-07-26.md)。 +- [x] Supabase Auth、Postgres、RLS、12 个 Edge Function 和 75 个生产 migration 已部署;除公开头像代理 `member-avatar` 按设计关闭 JWT 验证外,其余函数均启用 JWT 验证,12 个函数的 import map、浏览器 CORS/后台拒绝边界及同步调度已通过生产只读检查。推荐计划与 WebChat 成员入口继续关闭,遗留图片数据库与函数安全基础保留。证据见 [`docs/evidence/supabase-ten-function-readiness-2026-07-26.md`](./docs/evidence/supabase-ten-function-readiness-2026-07-26.md) 与 [`docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md`](./docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md)。 - [x] 邮箱注册、密码登录、真实邮箱找回密码、修改密码和会话恢复流程可用。 - [x] 成员资料、年级、专业联想、QQ、六个平台绑定和 XCPC ELO 姓名自动匹配已上线。 - [x] Rating 榜、刷题榜、周榜、月榜和自定义时间范围增量榜已上线。 @@ -84,9 +84,9 @@ USTSACMLand 的定位是苏州科技大学 ACM 集训队官网,当前产品范 ### P2:发布治理 -- [ ] 由项目负责人确定源码许可证并添加 `LICENSE`;确定前不把项目描述为开源。 -- [ ] 确认同步状态巡检、数据库备份、凭据轮换、回滚和管理员交接文档均可由其他维护者执行。2026-07-26 已新增单一入口 [`docs/maintainer-handoff.md`](./docs/maintainer-handoff.md),补齐权限登记、固定巡检阈值、GitHub CLI 备份/恢复、逐类凭据消费者与回滚、十函数回滚和脱敏交接模板;仓库就绪检查也开始强制要求备份大小与 Storage 对象上限变量。生产变量已按 50 名成员理论上限配置,手动备份运行 `30192826527` 成功并完成密文 Artifact 白名单核对。仍需一名非原维护者实际完成六项独立演练并签署复核,不能用原维护者本轮执行替代,因此保持未完成。证据见 [`docs/evidence/database-backup-capacity-guard-2026-07-26.md`](./docs/evidence/database-backup-capacity-guard-2026-07-26.md)。 -- [ ] 按 `docs/release-checklist.md` 完成最终检查并创建 `v1.0.0` 标签。2026-07-29 已通过仓库就绪检查、全仓库 ESLint、七项工作流结构门禁、93 个 Vitest 文件共 596 项测试、10 个函数入口 Deno 类型检查、136 个文件 Deno Lint、462 项 Edge Function 测试、生产构建和 Cloudflare 指纹资源长期缓存门禁;Supabase 已关闭邮箱自动确认并启用服务端 Turnstile CAPTCHA,preflight 与严格就绪检查均通过。全新真实邮箱的有效 token 注册、确认邮件、确认前登录拒绝、重复确认安全性、确认后密码登录和带独立 Turnstile 的自助注销已经完成;注销后的 Auth、Profile 及 19 类关联数据均为 0,再次登录返回标准凭据拒绝。两组各不超过 30 次的生产限流检查均安全停止但未触发 `429`。2026-08-04 又将登录/注册限额临时从 `30` 降至 `2`,等待 30 秒并严格限制为三次请求后仍只得到 `400 / invalid_credentials`;这表明动态降阈值没有立即收缩当前 IP 的旧令牌桶余额,配置已恢复为 `30`,不能把结果记作恢复通过。仍缺维护窗口内可复现的受控 `429` 窗口恢复、许可证和非原维护者交接,因此暂不创建标签。证据见 [`docs/evidence/release-gates-2026-07-28.md`](./docs/evidence/release-gates-2026-07-28.md)、[`docs/evidence/registration-abuse-production-gap-2026-07-28.md`](./docs/evidence/registration-abuse-production-gap-2026-07-28.md) 与 [`docs/evidence/auth-rate-limit-recovery-production-2026-08-04.md`](./docs/evidence/auth-rate-limit-recovery-production-2026-08-04.md)。 +- [x] 项目负责人已选择 Apache License 2.0(SPDX 标识符:`Apache-2.0`),维护 Agent 已加入根目录 `LICENSE`。该许可证仅覆盖项目原创源代码;学校、集训队、赛事标识、第三方素材、成员数据和平台数据仍保持独立授权边界。 +- [x] 确认同步巡检、数据库备份、凭据轮换、回滚和管理员操作可以由全新上下文 Agent 仅根据仓库文档冷启动执行。2026-08-09 全新上下文 Agent 完整阅读根契约与专项手册,在不触发生产写操作的前提下完成 GitHub/Supabase 冷启动核对、同步队列巡检、备份连续性检查及前端、Edge Function、数据库和 Cloudflare 回滚桌面推演;同时识别并修复仓库级 Secret 与 `production-operations` Environment Secret 的检查口径偏差。证据见 [`docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md`](./docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md)。 +- [ ] 按 `docs/release-checklist.md` 完成最终检查并创建 `v1.0.0` 标签。2026-07-29 已通过仓库就绪检查、全仓库 ESLint、七项工作流结构门禁、93 个 Vitest 文件共 596 项测试、10 个函数入口 Deno 类型检查、136 个文件 Deno Lint、462 项 Edge Function 测试、生产构建和 Cloudflare 指纹资源长期缓存门禁;Supabase 已关闭邮箱自动确认并启用服务端 Turnstile CAPTCHA,preflight 与严格就绪检查均通过。全新真实邮箱的有效 token 注册、确认邮件、确认前登录拒绝、重复确认安全性、确认后密码登录和带独立 Turnstile 的自助注销已经完成;注销后的 Auth、Profile 及 19 类关联数据均为 0,再次登录返回标准凭据拒绝。两组各不超过 30 次的生产限流检查均安全停止但未触发 `429`。2026-08-04 又将登录/注册限额临时从 `30` 降至 `2`,等待 30 秒并严格限制为三次请求后仍只得到 `400 / invalid_credentials`;这表明动态降阈值没有立即收缩当前 IP 的旧令牌桶余额,配置已恢复为 `30`,不能把结果记作恢复通过。冷启动交接验收已于 2026-08-09 完成;仍缺维护窗口内可复现的受控 `429` 窗口恢复、当前 `main` Schema v2 备份恢复演练,以及 Supabase 日志与 Firecrawl 保留窗口核验,因此暂不创建标签。证据见 [`docs/evidence/release-gates-2026-07-28.md`](./docs/evidence/release-gates-2026-07-28.md)、[`docs/evidence/registration-abuse-production-gap-2026-07-28.md`](./docs/evidence/registration-abuse-production-gap-2026-07-28.md)、[`docs/evidence/auth-rate-limit-recovery-production-2026-08-04.md`](./docs/evidence/auth-rate-limit-recovery-production-2026-08-04.md) 与 [`docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md`](./docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md)。 ## 4. v1.0.0 发布后接入 Cloudflare diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..6523cfe --- /dev/null +++ b/agent.md @@ -0,0 +1,213 @@ +# USTSACMLand Agent 操作契约 + +本文是所有接手 USTSACMLand 的 Agent 的根级入口。无论任务来自新会话、自动化任务还是其他 Agent 的交接,都必须先完整阅读本文,再阅读与任务直接相关的专项文档。 + +## 1. 项目定位 + +USTSACMLand 是苏州科技大学 ACM 集训队官网。当前正式产品范围包括: + +- 集训队与算法竞赛介绍、校内赛事入口和新手学习引导; +- 成员、平台账号、Rating 与刷题榜单; +- 注册、登录、邮箱确认、找回密码、账号资料和训练目标; +- 管理员成员管理、平台数据维护、同步状态和生产配置; +- Codeforces、牛客、AtCoder、洛谷、QOJ 与 XCPC ELO 数据同步; +- GitHub Pages、Cloudflare 自定义域名、Supabase 数据库与 Edge Functions。 + +正式站点为 。当前事实必须从代码、`ROADMAP.md`、GitHub、Supabase 和生产只读检查中重新确认,不得把旧会话、旧截图或本文中的概述当作实时状态。 + +WebChat / AI 学习助手、图片输入和推荐计划已经暂停并退出当前产品范围。保留其代码、Schema、migration、函数、测试、后台配置和历史数据边界,不删除、不重新开发、不向普通成员显示,也不打开生产开关。只允许处理关闭态安全、Schema 兼容、备份、注销和数据保护回归。 + +## 2. Agent 是实际维护者 + +所有可由仓库工具、CLI、浏览器、供应商控制台或自动化完成的开发和维护工作都由 Agent 执行,包括: + +- 需求分析、代码实现、测试、视觉检查和文档更新; +- issue / PR、CI、合并、发布、部署、回滚和发布后观察; +- 数据同步巡检、失败诊断、队列维护和受控重跑; +- migration、Edge Function、备份、恢复演练和凭据轮换编排; +- 管理员操作、生产烟测、故障处置、证据记录和下一次交接。 + +不要把 Agent 能完成的步骤转交给项目负责人。需要网页操作时由 Agent 使用已授权的 Chrome 会话;需要命令时由 Agent 执行命令;需要等待 CI 或部署时由 Agent 持续监控到终态。 + +项目负责人只负责 Agent 无法代替的事项: + +- 输入或更新密码、API Key、MFA、验证码、恢复材料和付款信息; +- 完成实名认证、供应商申诉、域名续费等必须由账号持有人进行的行为; +- 决定许可证、品牌授权、产品范围、费用和风险接受; +- 对不可逆、删除性、高费用或高风险生产变更给出明确批准。 + +负责人提供输入或批准后,Agent 应立即接回后续操作、验证和记录,不要求负责人继续完成可自动执行的步骤。若已有登录态或所需授权,Agent 应直接工作,不重复索取凭据。 + +项目接受只有一名真人持有账号恢复能力的风险。Agent 能接管全部日常工作,但不能在负责人失联时取代其 MFA、实名域名、付款方式或法律责任。 + +## 3. 每个新任务的冷启动 + +任何写操作前依次完成: + +1. 完整阅读本文件、`ROADMAP.md` 和任务涉及的专项文档。 +2. 检查 `git status -sb`、当前分支、远端、默认分支、最近提交和未跟踪文件。 +3. 区分用户已有修改、本任务修改和生成物;不得覆盖或清理来源不明的改动。 +4. 检查任务涉及的 GitHub PR / Actions / Pages、Supabase migration / Functions / cron 或 Cloudflare 状态。 +5. 明确当前事实、任务范围、验证方式、回滚方式及需要负责人输入或批准的唯一事项。 +6. 向用户简短报告所见状态和执行计划,然后持续工作到任务终态。 + +冷启动时优先使用只读检查。诊断结论必须来自代码、日志、状态接口或可复现结果,不能仅依赖历史对话。 + +## 4. 权限与执行边界 + +### 可直接执行 + +- 阅读仓库、文档、Git 历史、PR、Actions 和公开生产页面。 +- 运行本地 lint、类型检查、测试、构建、格式检查和只读健康检查。 +- 在用户要求修改或开发时编辑任务范围内的代码、测试和文档。 +- 在用户要求诊断时完成只读排查并给出证据;除非请求同时包含修复,不擅自扩大为生产写操作。 +- 在用户明确要求提交、推送、合并、部署、发布或回滚后,执行完整流程并监控到终态。 + +### 每次需要明确批准 + +- 应用生产 migration 或修改生产 RLS、Auth、Vault、cron、Function Secret; +- 部署会改变生产数据行为的 Edge Function; +- 修改 Cloudflare DNS、TLS、缓存规则、自定义域名或执行大范围 Purge; +- 触发恢复、改变恢复下限、删除生产数据、注销账号或批量改写成员数据; +- 轮换密码、Cookie、CSRF、API Key、队列 Token、备份口令或恢复 Token; +- 提升或降级管理员、创建正式版本标签、执行产生明显费用的操作。 + +批准只覆盖已说明的目标、范围和本次执行。得到批准后由 Agent 完成操作,不再把执行步骤退回给负责人。操作结果不确定时先只读对账,禁止重复点击或盲目重放写请求。 + +### 禁止事项 + +- 读取、回显、复制到聊天、写入命令历史、日志、截图或 Git 的密码、Token、Cookie、JWT、恢复码、浏览器存储或成员私有数据; +- 绕过 MFA、验证码、分支保护、Environment 审批、RLS、速率限制或第三方反自动化措施; +- 发起网络安全探测、爆破、请求洪泛、漏洞利用或故意触发第三方封禁; +- 使用 `git reset --hard`、强推默认分支、重写已部署 migration、删除未知文件或覆盖他人改动; +- 未经重新立项开启 WebChat、图片输入或推荐计划; +- 用演示数据、缓存页面或旧截图冒充生产验证结果。 + +## 5. 仓库工作规则 + +- 默认 ASCII;已有中文文档和界面可继续使用中文。 +- 手工编辑使用 `apply_patch`;格式化和明确的机械批量改写可使用项目工具。 +- 搜索优先使用 `rg`;可并行的只读检查并行执行。 +- 始终先查看工作树。来源不明的改动视为用户修改并保留。 +- 只暂存本任务文件,工作树混杂时逐文件 `git add`,不使用无差别 `git add -A`。 +- 不提交 `.claude/`、`.agents/`、`.cli-home/`、`.codex-tmp/`、`.firecrawl/`、`.playwright/`、`artifacts/`、`dist/`、`test-results/`、日志、截图、导出、备份和本地环境文件。 +- 不读取或提交 `.env*` 中的真实值。配置契约只通过 `.env.example` 和文档维护。 +- 不进行与当前任务无关的重构、格式 churn、依赖升级或元数据改写。 +- 除非用户明确要求,不提交、不推送、不合并、不部署。 + +如果使用子 Agent,必须给出边界清晰、可独立完成的子任务和文件所有权,并提醒其共享工作树、保留其他人的改动。主 Agent 负责整合、复核和最终验证,不能把子 Agent 的结论未经检查直接作为完成证据。 + +## 6. 实现与验证闭环 + +每项开发按以下顺序完成: + +1. 阅读现有实现、测试、数据契约和设计规范。 +2. 找到根因或明确产品行为,再做最小范围修改。 +3. 补充与风险相称的单元、集成、数据库或 E2E 测试。 +4. 运行最接近改动面的验证,再运行必要的共享门禁。 +5. 前端变更使用 Chrome 检查桌面、390px 移动端和宽屏;检查交互、控制台、溢出、文字遮挡和截图。 +6. 数据库或生产变更先写兼容顺序与回滚计划,按“数据库 -> Edge Functions -> Pages”执行。 +7. 更新 `ROADMAP.md`、运行手册或证据文档中的真实状态;只有确认完成的条目才从 `- [ ]` 改为 `- [x]`。 +8. 复查 diff,确认没有 Secret、私有数据、生成物和无关修改。 + +常用本地门禁以 `package.json` 和 `docs/release-checklist.md` 为准。最小文档验证为: + +```powershell +npx prettier --check agent.md AGENTS.md README.md ROADMAP.md docs/maintainer-handoff.md docs/operations-runbook.md docs/release-checklist.md +git diff --check +``` + +代码改动通常还应按影响范围运行: + +```powershell +npm run lint +npm test +npm run build +``` + +不要为了得到绿色结果关闭安全检查、放宽生产配置或修改无关测试。 + +## 7. 前端和浏览器约定 + +- 需要浏览器操作或生产烟测时使用 Chrome;不要静默改用内置浏览器、Firefox 或其他会话。 +- 优先复用用户已经登录的 Chrome 会话,不读取 Cookie、Local Storage、Session Storage、密码或 Token。 +- UI 图标统一使用 Lucide,界面不使用表情符号。 +- 延续 `docs/DESIGN.md` 和现有页面的字体、配色、间距与响应式规则。 +- 页面必须在桌面、移动和宽屏下无无意义横向滚动、遮挡、跳动和不可读小字。 +- 视觉改动不能只看源码;必须以实际渲染、交互和截图为准。 + +## 8. GitHub、发布与部署 + +默认发布链路: + +1. 从任务分支形成范围清晰的提交和 PR; +2. 等待并检查全部必需 CI、数据库安全和 Secret Scan; +3. 获得用户明确的合并/部署授权后合入 `main`; +4. 等待 `main` CI 和 GitHub Pages 部署完成; +5. 检查 、深链刷新、指纹资源和 Cloudflare 缓存; +6. 等待 `production-ranking-audit` 等发布后门禁进入终态; +7. 记录提交、PR、运行编号、生产结果、遗留风险和回滚点。 + +检查未通过时由 Agent 读取日志、修复、重新验证并继续监控。不要让用户代替 Agent 盯 CI。除非专项运行手册明确要求,不直接覆盖 Pages 产物,不通过手工上传绕过受保护部署链路。 + +详细步骤见 `docs/operations-runbook.md`、`docs/release-checklist.md`、`docs/repository-settings.md` 和 `docs/custom-domain-cloudflare.md`。 + +## 9. Supabase、同步与备份 + +- 操作前确认 CLI 只链接预期项目,migration 与远端状态一致。 +- 生产数据只能通过受控 RPC、Edge Function、migration 或明确记录的管理员流程修改,不直接绕过业务约束改表。 +- 同步维护须检查调度、队列、最近成功、平台级错误、数据新鲜度和审计;自动重试规则以当前代码和 `docs/sync-alerting.md` 为准。 +- 单平台烟测只使用明确授权的受控成员,不抓取或输出其他成员私有资料。 +- 第三方登录、Cookie、CSRF、QOJ / 洛谷账号和 Firecrawl Key 只通过 Secret 使用,Agent 不读取原值。 +- 备份和恢复必须遵循 `docs/backup-and-recovery.md`,验证密文、清单、引用对象、恢复下限和明文清理。 +- 故障恢复优先 Git revert、兼容函数回滚或数据库前向修复,不回退已发布 migration 历史。 + +## 10. 安全与隐私 + +- 最小化读取和输出;只记录状态、Secret 名称、消费者、时间和脱敏结果。 +- 屏幕、终端或工具输出意外出现敏感值时,不在回复中复述,并立即停止可能扩大暴露的操作。 +- 不使用真实成员账号进行破坏性测试;临时测试账号和测试数据必须在任务结束时清理并对账。 +- 任何新增第三方数据流都必须同步检查 `PRIVACY.md` 和 `docs/third-party-data-sources.md`。 +- 账号注销、管理员权限、RLS、备份恢复下限和私有 Storage 属于高风险边界,修改前必须阅读对应 ADR、migration 和生产证据。 +- 用户已明确禁止网络安全请求;安全工作限于代码审计、本地测试、配置核对和正常业务烟测。 + +## 11. 完成定义 + +只有同时满足以下条件才可报告完成: + +- 请求的行为已经实现或诊断结论已有可复现证据; +- 相关测试、构建、视觉或生产验证已完成,未完成项已明确说明; +- 工作树只新增预期改动,未覆盖用户文件,未泄露 Secret 或成员数据; +- 需要的文档、ROADMAP 状态和脱敏证据已同步; +- 获授权的提交、推送、合并、部署与观察均已到达终态; +- 已给出简洁的变更摘要、验证结果、生产状态和真正需要负责人处理的事项。 + +任务结束时留下以下交接信息: + +```text +任务: +当前分支 / 提交: +已完成: +验证: +生产状态: +未提交或用户已有改动: +遗留风险: +下一步: +需要项目负责人输入或批准:无 / <具体事项> +``` + +## 12. 文档索引 + +- 产品与本地开发:`README.md` +- 当前计划与发布阻塞:`ROADMAP.md` +- Agent 生产操作卡:`docs/maintainer-handoff.md` +- 生产部署与故障处理:`docs/operations-runbook.md` +- 正式发布门禁:`docs/release-checklist.md` +- 数据库备份与恢复:`docs/backup-and-recovery.md` +- 同步监控:`docs/sync-alerting.md` +- Cloudflare 与自定义域名:`docs/custom-domain-cloudflare.md` +- 设计规范:`docs/DESIGN.md` +- 架构决策:`docs/adr/README.md` +- 隐私、安全与第三方来源:`PRIVACY.md`、`SECURITY.md`、`docs/third-party-data-sources.md` + +专项文档比本文更具体时按专项文档执行;专项文档与当前代码或生产事实冲突时,先停止写操作,通过只读证据确认真实状态并更新文档。 diff --git a/docs/backup-and-recovery.md b/docs/backup-and-recovery.md index 5064f86..47edd0c 100644 --- a/docs/backup-and-recovery.md +++ b/docs/backup-and-recovery.md @@ -53,14 +53,15 @@ ## GitHub Actions Secrets -在仓库 `Settings > Secrets and variables > Actions` 配置: +在仓库 `Settings > Secrets and variables > Actions` 配置仓库级 Secret: | Secret | 要求 | | ------------------------------ | ------------------------------------------------------------------------------ | | `SUPABASE_ACCESS_TOKEN` | Supabase 个人访问令牌;固定版本 CLI 仅用它链接目标项目并动态取得短期数据库登录 | -| `SUPABASE_PROJECT_REF` | 生产项目引用,用于把备份任务严格绑定到目标项目 | | `BACKUP_ENCRYPTION_PASSPHRASE` | 独立随机口令,至少 32 个字符;不得与数据库、GitHub、邮箱或平台账号密码复用 | +`SUPABASE_PROJECT_REF` 与 `SUPABASE_SERVICE_ROLE_KEY` 只配置在受保护的 `production-operations` Environment;该 Environment 只允许默认分支 `main`。备份工作流从该 Environment 取得项目引用,生产同步与只读探针同时使用项目引用和 service role key;仓库级和组织级不得保留同名副本。 + 同一页面的 `Variables` 必须配置: | Variable | 要求 | @@ -79,7 +80,7 @@ | `DELETION_RECOVERY_REPOSITORY` | 固定为目标 GitHub 仓库,如 `greenthree/USTSACMLand` | | `DELETION_RECOVERY_GITHUB_TOKEN` | Fine-grained PAT;只授权该仓库的 Variables write,不授予 Contents 等其他权限 | -推荐使用密码管理器生成并保存备份口令,至少由两名授权负责人分别确认可访问。口令丢失等同于全部仓库备份不可恢复;口令泄露时必须立即轮换,并删除旧 Artifact。 +使用项目负责人控制的密码管理器生成并保存备份口令。项目已明确接受只有一名真人持有账号恢复能力的风险;口令丢失等同于全部仓库备份不可恢复,口令泄露时必须立即轮换,并删除旧 Artifact。 ## 下载与本地完整性检查 diff --git a/docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md b/docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md new file mode 100644 index 0000000..f279b4a --- /dev/null +++ b/docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md @@ -0,0 +1,88 @@ +# Agent 冷启动接管验收记录(2026-08-09) + +## 范围与授权 + +- 日期与时区:2026-08-09,Asia/Shanghai。 +- 任务:仅依靠仓库文档完成冷启动检查、同步巡检、备份连续性核对和发布/回滚桌面推演。 +- 起始提交:`c6ed95e721eb4ab58f796a4c8ca13694fef10e8e`,默认分支 `main`。 +- 本轮允许:仓库、GitHub、Supabase 和公开生产站点只读检查;本地检查器、测试与文档修复。 +- 本轮禁止且未执行:触发同步、备份或恢复工作流,修改生产 migration、Function、Secret、Auth、cron、Cloudflare、成员数据,创建标签、合并或部署。 +- 冷启动时已有的 README、ROADMAP 和运维文档修改及未跟踪 Agent 契约均被保留,没有清理或覆盖。 + +## GitHub 与发布基线 + +- 默认分支为 `main`,当前提交 `c6ed95e`。 +- 启用的仓库 ruleset 要求 Pull Request、禁止删除与 non-fast-forward,并要求 `verify`、`database-security`、`gitleaks`;无默认绕过主体。 +- `production-operations` Environment 只允许 `main`,只核对到两个 Supabase Secret 名称,没有读取值。 +- GitHub Actions 默认日志与 Artifact 保留期为 30 天;数据库加密备份 Artifact 单独保留 14 天。 +- 当前提交的 CI `31011279491`、Secret Scan `31011279143`、Pages build/deploy 与生产榜单审计 `31012265942` 均成功。 +- `npm run check:cloudflare-domain` 通过裸域、重定向、SPA 回退、HTML 短缓存、指纹资源一年缓存和二次缓存命中检查。 + +## Supabase 与同步巡检 + +- 唯一 linked 项目为 `ACTIVE_HEALTHY`。 +- 严格就绪检查通过:75 个 migration、0 项 pending、12 个 ACTIVE Edge Function、23 个 Function Secret 名称且 0 缺失、0 个 schema lint finding;Auth 邮件、匿名 REST、函数边界和队列调度均通过。 +- 12 个函数均使用仓库 import map;除公开头像代理 `member-avatar` 按设计关闭 JWT 验证外,其余函数均启用 JWT 验证。 +- 五分钟队列 cron 为 active,Vault 配置完整;检查时近 15 分钟运行 3 次且 3 次成功,最近请求 HTTP 为 `200`,无超时或传输错误。 +- 队列聚合:到期排队 0、未来排队 0、运行中 0、超过 15 分钟运行 0、等待重试 0、已耗尽但仍活动 0;过去 24 小时任务成功 54、失败 0。 +- 六个平台最近终态均为成功。过去 24 小时 Codeforces、牛客、AtCoder 与洛谷没有失败;XCPC ELO 与 QOJ 最近一次周二批次成功。 +- 最近 12 次 `Sync platform statistics` 计划运行全部成功;最新运行 `31284111001` 成功完成配置检查和 Supabase 同步触发。 +- `npm run check:sync-workflow` 通过默认分支 Environment 保护、分页、持久队列重试所有权和日志脱敏结构门禁。 + +## 备份与恢复边界 + +- 2026-08-02 至 2026-08-08 的七个每日计划备份中六个成功,达到手册要求的“最近七天至少六次成功”。 +- 2026-08-06 运行 `31125234967` 在没有执行步骤的情况下被取消;后续 2026-08-07 和 2026-08-08 均成功。最新成功运行 `31268403658` 完成加密逻辑备份、即时验证和 Artifact 上传。 +- `npm run check:backup-workflow` 与 `npm run check:restore-drill-workflow` 通过:备份包含 7 个逻辑 dump、1 个私有 Storage 快照和 14 天保留;恢复演练保持手动触发、核对 8 项聚合计数、私有 Storage 和脱敏证据。 +- Supabase PITR 未启用且供应商物理备份为 0,仍依赖仓库加密逻辑备份。 +- 本轮没有触发新备份或恢复。正式发布检查单要求的“当前 `main` 新生成 Schema v2 Artifact 恢复演练”仍未完成,不能用旧成功运行替代。 + +## 发布与回滚桌面推演 + +### 当前发布 + +- 当前 Pages 发布提交为 `c6ed95e`,上一成功发布提交为 `63655a3`。 +- 两个提交之间只有前端、字体、文档和构建预算变化,没有 Supabase migration 或 Edge Function 变化。 + +### 前端回滚 + +1. 从当前 `origin/main` 创建 `codex/*` 修复分支。 +2. 使用 `git revert c6ed95e` 形成可审计回退提交,不重写历史。 +3. 提交 PR,等待 `verify`、`database-security`、`gitleaks`。 +4. 获得项目负责人明确部署批准后合并,等待精确 `head_sha` 的 Pages build、deploy 和 `production-ranking-audit`。 +5. 验证正式首页、深链、登录、账号页、后台和遗留模块关闭状态。 + +### Edge Function 回滚 + +- 只有旧函数与当前 75 个 migration 兼容时,才在临时 worktree 检出最后正常提交并只部署受影响函数。 +- 部署 Function 属于生产写操作,必须逐次批准;完成后严格运行 `npm run check:supabase-readiness`。 +- 若 Schema 不兼容旧函数,不回滚函数,改为从 `main` 发布最小兼容修复。 + +### 数据库回滚 + +- 未应用 migration:停止并修正后重新跑空库 CI。 +- 已应用 migration:不修改历史文件,新增 corrective migration 前向修复。 +- 只有整库灾难恢复才考虑备份;必须先验证 `BACKUP_RECOVERY_NOT_BEFORE`,并在隔离项目完成恢复演练。任何生产恢复都需要单独批准。 + +### Cloudflare 回滚 + +- DNS only、移除 Pages Custom domain、缓存 Purge 和流量路径切换都会改变生产行为,必须在维护窗口取得单独批准。 +- 指纹静态资源不做无差别 Purge;仅在 Pages 已成功但 HTML 未更新时清理必要 HTML 路径。 + +## 发现与修复 + +- `check:repository-readiness` 原先把 `SUPABASE_PROJECT_REF` 和 `SUPABASE_SERVICE_ROLE_KEY` 当作仓库级 Secret,和实际最小权限配置冲突。 +- 检查器现分别验证四个仓库级 Secret 名称、两个 `production-operations` Environment Secret 名称,并验证 Environment 只允许默认分支。 +- README、ROADMAP、运维与发布文档同步为 75 个 migration、12 个函数、30 天仓库 Actions 保留期和正确 Secret 存放位置。 +- 项目负责人在本次交接后选择 Apache License 2.0(SPDX 标识符:`Apache-2.0`);维护 Agent 已加入根目录 `LICENSE`,并在 README、ROADMAP 与发布检查单中明确源码与学校/集训队/赛事标识、第三方素材、成员数据和平台数据的授权边界。 + +## 结论与遗留阻塞 + +本次冷启动验收满足 `docs/maintainer-handoff.md` 第 13 节:Agent 能仅根据仓库文档区分授权等级,完成同步巡检和发布/回滚桌面推演,并保持 Secret、成员资料和生产状态不被修改。 + +`v1.0.0` 仍有以下发布阻塞: + +- 维护窗口内完成受控 Auth `429` 窗口恢复验证。 +- 使用当前 `main` 新生成的 Schema v2 Artifact 完成手动隔离恢复演练。 +- 核验 Supabase Auth/Edge 日志与 Firecrawl 作业、会话的实际保留窗口。 +- Cloudflare 真实流量回滚演练需要单独维护窗口和明确批准。 diff --git a/docs/maintainer-handoff.md b/docs/maintainer-handoff.md index 30b027f..6f22b9f 100644 --- a/docs/maintainer-handoff.md +++ b/docs/maintainer-handoff.md @@ -1,82 +1,134 @@ -# 维护者交接与独立操作卡 +# Agent 维护交接手册 -本文是新维护者接手 USTS ACM Land 的单一入口。专项原理仍以 -[生产运维手册](./operations-runbook.md)、[备份与恢复](./backup-and-recovery.md)、 -[同步巡检](./sync-alerting.md) 和 [正式发布检查单](./release-checklist.md) 为准。 +本文是任何全新上下文 Agent 接手 USTS ACM Land 具体维护任务时的操作手册;根级责任、边界和完成定义以 [`agent.md`](../agent.md) 为准。项目采用 Agent 全程维护模式:Agent 负责日常巡检、代码修复、测试、PR、发布、部署、生产操作、监控和事故处理的完整闭环。项目负责人不执行 Agent 能完成的日常步骤,只提供必要的凭据/MFA/验证码输入、账号持有人操作、法律与产品决定及不可逆高风险批准。项目负责人已明确接受没有第二名真人维护者带来的单点风险;Agent 不取得长期账号、MFA 或 Secret 所有权。 -本文不记录账号、邮箱、Token、Cookie、恢复码、项目数据库 URI 或成员资料。所有权限 -登记只写服务、角色、负责人和验证日期;敏感值只保存在组织批准的密码管理器或对应服务 -Secret 中。 +专项原理仍以 [生产运维手册](./operations-runbook.md)、[备份与恢复](./backup-and-recovery.md)、[同步巡检](./sync-alerting.md)、[Cloudflare 自定义域名手册](./custom-domain-cloudflare.md) 和 [正式发布检查单](./release-checklist.md) 为准。发生冲突时,以更具体的专项文档、当前代码和生产只读证据为准,不凭记忆操作。 -WebChat / AI 学习助手与推荐计划已退出产品范围。现有代码、migration、函数和私有数据结构保留,但生产必须保持关闭;本操作卡不再要求中转站、图片、邀请码、奖励或重开演练。维护者只需防止入口或服务被误开启,并维持遗留私有数据的 RLS、注销和备份边界。 +本文不记录账号、邮箱、Token、Cookie、恢复码、项目数据库 URI、密码或成员资料。所有权限登记只写服务、责任人、Secret 名称、消费者和验证日期;敏感值只保存在供应商 Secret、Vault、GitHub Environment 或项目负责人控制的密码管理器中。 -## 完成标准 +WebChat / AI 学习助手与推荐计划已经退出产品范围。现有代码、migration、函数和私有数据结构保留,生产必须保持关闭。维护 Agent 只负责防止入口或服务被误开启,并维持遗留私有数据的 RLS、注销和备份边界,不进行重开、计奖、视觉模型或付费中转站验收。 -ROADMAP 的“其他维护者可执行”条目只有在一名非原维护者独立完成并记录以下六项后才可 -勾选: +## 1. 责任模型与已接受风险 -1. 一次同步状态巡检; -2. 一次手动加密备份; -3. 一次使用该备份的隔离恢复演练; -4. 一次不更换真实 Secret 的凭据轮换桌面演练,或一次明确批准的低风险真实轮换; -5. 一次前端与 Edge Function 回滚桌面演练; -6. 一次站内管理员和外部维护权限交接。 +| 角色 | 责任 | 不承担的责任 | +| ---------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| 项目负责人 | 持有供应商账号、MFA、恢复材料和密码管理器;提供必要输入;批准不可逆高风险操作;决定许可证、产品范围和风险接受 | 不承担 Agent 可以完成的开发、测试、发布或日常运维执行 | +| 维护 Agent | 读取仓库与运行手册;检查状态;修改代码;运行测试;形成 PR;监控 CI;部署、运维并留下脱敏记录 | 不持有长期凭据,不自行扩大任务范围,不代表独立真人复核 | +| 冷启动复核 Agent | 在不继承旧会话推理的情况下,仅依靠仓库文档复核计划、命令、证据和清理结果 | 不替代项目负责人批准,不通过共享上下文“假装独立” | -自动化测试只能证明工具链,不替代供应商权限、MFA、密码管理器和人员交接。 +项目负责人接受以下风险: -## 1. 权限登记 +- 账号恢复、域名续费、供应商申诉和法律责任仍集中在一人;Agent 无法在负责人完全失联时恢复这些能力。 +- Agent 的独立性只用于验证文档是否足够清晰,不能证明第二名真人能够接管 MFA、付款方式或实名域名。 +- 单人批准降低了双人复核强度,因此所有生产写操作必须更依赖最小权限、可回滚方案、数据库约束、CI 门禁和脱敏证据。 -在私有维护记录中填写下表。不要把账号标识或恢复方式提交到 Git。 +若未来增加真人维护者,可在不改变操作卡的前提下恢复双人复核;在此之前,ROADMAP 不再要求第二名真人签署。 -| 服务 | 最小权限 | 主要负责人 | 替补负责人 | MFA/恢复责任人 | 最近验证 | 到期/复核日 | -| ------------------------------ | ---------------------------------------------------------- | ---------- | ---------- | -------------- | -------- | ------------ | -| GitHub 仓库 | 读取代码与 Actions;发布者另需写入分支/PR 和手动运行工作流 | 待登记 | 待登记 | 待登记 | 待验证 | 每季度 | -| GitHub `production-operations` | 审批备份、恢复和生产运维任务 | 待登记 | 待登记 | 待登记 | 待验证 | 每季度 | -| Supabase | 目标项目开发者;数据库/Secret 变更仅限授权维护者 | 待登记 | 待登记 | 待登记 | 待验证 | 每季度 | -| Cloudflare | DNS、TLS、缓存规则和 Purge 权限 | 待登记 | 待登记 | 待登记 | 待验证 | 每季度 | -| 阿里云域名 | 域名续费、实名信息和 DNS 应急切回权限 | 待登记 | 待登记 | 待登记 | 待验证 | 到期前 60 天 | -| Firecrawl | Key 池额度与轮换权限 | 待登记 | 待登记 | 待登记 | 待验证 | 每季度 | -| QOJ/洛谷服务账号 | 登录与凭据轮换 | 待登记 | 待登记 | 待登记 | 待验证 | 每季度 | -| 密码管理器 | 备份口令及恢复材料条目访问 | 待登记 | 待登记 | 待登记 | 待验证 | 每季度 | +## 2. 给新 Agent 的启动提示 -新维护者先执行只读准入检查: +新会话可直接使用以下提示,后接具体任务: + +```text +你正在维护 USTSACMLand。先完整阅读 docs/maintainer-handoff.md,再按其中的冷启动检查执行。 +项目采用 Agent 全程维护模式:Agent 完成所有可通过工具执行的工作;项目负责人仅保留账号、MFA、Secret 输入及法律/产品和不可逆高风险决定。 +不要读取或回显密码、Token、Cookie、浏览器存储或成员私有数据;不要提交 .claude、临时截图、导出文件和备份 Artifact。 +默认先做只读诊断。代码修改、提交、推送、合并、部署和生产操作分别以用户本轮明确授权为边界,不能从“维护”一词推导全部权限。 +WebChat 和推荐计划保持关闭,不进行重开开发或生产验收。 +开始时报告:当前分支、工作树状态、远端默认分支、最近 CI/Pages 状态、生产基线、计划和需要项目负责人批准的操作。 + +本次任务:<填写任务> +``` + +## 3. 授权等级 + +### A. 可直接执行的只读操作 + +- 阅读仓库代码、文档、migration 和测试;检查 `git status`、diff、提交历史和分支关系。 +- 查看 GitHub PR、Actions、Pages、Supabase migration/function 列表和后台健康状态。 +- 运行不修改生产数据的本地测试、构建、lint、格式检查和只读就绪检查。 +- 检查公开页面、公开 API 和当前页面 UI;不得检查浏览器 Cookie、Local Storage、密码或会话存储。 +- 形成诊断、修复建议、回滚计划和脱敏证据草稿。 + +### B. 需要任务本身明确授权的仓库写操作 + +- 修改代码、测试和文档。 +- 创建 migration 或 Edge Function 变更,但不自动部署到生产。 +- 提交和推送;只有用户明确要求“部署、发布或合并”时才可合入 `main` 并等待 Pages。 +- 只暂存任务范围内的文件;工作树混杂时逐文件暂存,不使用无差别 `git add -A`。 + +### C. 每次都要项目负责人明确批准的生产操作 + +- 应用生产 migration、部署 Edge Function、修改 Function Secret、Vault、Auth、RLS 或 cron。 +- 修改 Cloudflare DNS/TLS/缓存规则、执行大范围 Purge、切换 Pages 自定义域名或做流量回滚。 +- 触发或下载加密备份、运行隔离恢复、修改恢复下限。 +- 轮换 Cookie、CSRF、API Key、密码、队列 Token、备份口令或注销恢复 Token。 +- 提升/降级管理员、手工修改成员生产数据、批量同步、注销账号或删除 Storage 对象。 +- 创建正式版本标签、回滚生产版本或执行可能产生费用的第三方操作。 + +批准只覆盖说明过的目标、范围和一次执行,不自动扩展到其他服务或后续批次。若操作结果不确定,先只读对账,不重复点击或重放写请求。 + +### D. 禁止操作 + +- 回显、复制到聊天、写入 Git 或日志的任何 Secret、Cookie、JWT、密码、恢复码或完整第三方响应。 +- 绕过分支保护、Environment 审批、RLS、速率限制、MFA、验证码或供应商反自动化保护。 +- 为了“验证安全”进行爆破、请求洪泛、刻意触发第三方封禁或未被项目负责人单独批准的网络安全测试。 +- 强推默认分支、删除 migration、重写已部署历史、执行 `git reset --hard` 或用整库恢复替代可行的前向修复。 +- 把 WebChat、图片输入或推荐计划重新开启,除非项目负责人未来明确重新立项。 + +## 4. 冷启动检查 + +Agent 在任何写操作前完成以下步骤: + +1. 完整阅读本文和与任务直接相关的专项手册。 +2. 检查 `git status -sb`、当前分支、远端、最近提交和未跟踪文件;用户已有修改不得覆盖。 +3. 检查 GitHub 登录、仓库默认分支、PR、CI、Secret Scan 和 Pages 最近状态。 +4. 检查任务是否涉及生产 Supabase、Cloudflare、Firecrawl、QOJ/洛谷账号或成员隐私;涉及则列出需要批准的操作。 +5. 先运行只读或本地验证,形成明确计划;不要以旧会话摘要替代当前代码和生产状态。 +6. 任务结束时报告提交、PR、部署 ID、验证结果、遗留风险、临时文件和夹具清理情况。 + +基础命令: ```powershell +git status -sb +git remote -v gh auth status gh repo view greenthree/USTSACMLand --json nameWithOwner,defaultBranchRef -gh workflow list --repo greenthree/USTSACMLand +gh run list --repo greenthree/USTSACMLand --branch main --limit 8 npx --yes supabase@2.109.1 projects list npm run check:repository-readiness -- greenthree/USTSACMLand -npm run check:supabase-readiness +npm run check:supabase-preflight ``` -Supabase 命令必须只显示一个 `linked` 项目,且项目引用必须是仓库当前生产配置。严格 -Supabase 检查可能因 ROADMAP 已知的 Auth/CAPTCHA 配置返回阻塞;新维护者仍须确认 -migration、十个函数、Function Secret 名称、匿名 REST、函数边界和同步调度结果没有新增 -故障。 +Supabase CLI 必须只显示一个 linked 项目,且项目引用与仓库生产配置一致。严格检查出现已知阻塞时,Agent 仍须区分“已有风险”与“本次新增回归”,不能为了得到绿色结果修改生产安全配置。 + +## 5. 权限与 Secret 登记 + +项目负责人保留下表中的长期权限。Agent 只使用当前任务已经提供的 CLI、浏览器登录态或临时授权,不建立自己的永久账号,不读取 Secret 原值。 -以下能力必须在服务控制台中人工验证并记录,仓库脚本不能代替: +| 服务 | 权威位置与最小权限 | Agent 使用方式 | 高风险批准 | 复核周期 | +| ----------------------- | -------------------------------- | --------------------------------------- | ----------------------------- | ------------ | +| GitHub 仓库 | 项目负责人账号;PR/Actions/Pages | 已登录 `gh` 或受控浏览器 | 合并、发布、标签 | 每季度 | +| `production-operations` | GitHub Environment;仅默认分支 | Actions 中引用,不下载 Secret | 所有生产工作流 | 每季度 | +| Supabase | 目标项目;数据库与 Function 管理 | linked CLI、Dashboard 或受控脚本 | migration、函数、Secret、Auth | 每季度 | +| Cloudflare | DNS、TLS、缓存和 Purge | 受控浏览器或最小权限 API | DNS、TLS、规则、Purge | 每季度 | +| 阿里云域名 | 续费、实名与应急控制 | Agent 操作控制台;负责人仅输入实名/付款 | 全部写操作 | 到期前 60 天 | +| Firecrawl | Key 池与额度 | 管理后台或供应商只读用量 | Key 变更与付费操作 | 每季度 | +| QOJ/洛谷账号 | 供应商账号与 Supabase Secret | 只通过现有函数或受控登录烟测 | 密码、Cookie、CSRF 轮换 | 每季度 | +| 密码管理器 | 备份口令与恢复材料 | Agent 不读取;由负责人输入对应控制台 | 全部访问 | 每季度 | -- 谁能批准 `production-operations`; -- `production-operations` 仅允许默认分支部署,`SUPABASE_PROJECT_REF` 与 - `SUPABASE_SERVICE_ROLE_KEY` 只保存为该 Environment 的 Secret,仓库级或组织级同名 - Secret 副本已经删除; -- 谁能手动运行 Actions 和下载 Artifact; -- 谁能读取备份解密口令; -- 谁能修改 Supabase Auth、Function Secrets 和数据库; -- 谁能执行 Cloudflare Purge、DNS 回滚和域名续费; -- GitHub、Supabase、Cloudflare、阿里云和密码管理器均启用 MFA,且至少一名替补人可恢复。 +必须确认: -## 2. 同步巡检操作卡 +- `SUPABASE_PROJECT_REF` 和 `SUPABASE_SERVICE_ROLE_KEY` 只存在于 `production-operations` Environment,不创建仓库级或组织级副本。 +- GitHub、Supabase、Cloudflare、阿里云和密码管理器启用 MFA;恢复责任由项目负责人承担。 +- Secret 名称、消费者和最近轮换日期可以记录,值、摘要和截图不能记录。 -### 频率与责任 +## 6. 同步巡检操作卡 + +### 频率 - 每个工作日检查后台“同步中心”和“数据源健康”。 -- 每周执行一次仓库和 Supabase 只读检查。 +- 每周检查仓库 Actions、Supabase 队列和 cron。 - 计划同步、队列或凭据错误持续两个调度周期时建立事件记录;不要等到榜单显示为 0。 -- 值班人保存观察时间、脱敏 Actions URL、平台、聚合状态、处置和关闭人。 - -### 命令 ```powershell npm run check:repository-readiness -- greenthree/USTSACMLand @@ -84,34 +136,29 @@ npm run check:supabase-readiness gh run list --repo greenthree/USTSACMLand --workflow sync-stats.yml --branch main --limit 10 ``` -只有数据库队列 cron 故障且已确认没有正常 worker 正在处理时,才使用手动应急入口: +只有数据库队列 cron 故障、已确认没有正常 worker 正在处理且项目负责人批准时,才运行: ```powershell gh workflow run sync-stats.yml --repo greenthree/USTSACMLand --ref main -f scope=queue ``` -`sync-stats.yml` 的计划任务和手动任务都只能在正式仓库默认分支运行,并通过 -`production-operations` 读取生产 Supabase Secret。不要为了让分支任务通过而重新创建 -仓库级或组织级 `SUPABASE_SERVICE_ROLE_KEY`;否则分支工作流可绕过 Environment 边界。 +通过条件: -### 通过条件 - -- 计划同步最近一次成功距今小于 14 小时; -- 数据库队列最近调度和最近完成响应距今均小于 12 分钟; +- 日更计划最近一次成功距今小于 14 小时; +- 数据库队列最近调度和完成响应距今均小于 12 分钟; - 最近队列 HTTP 为 2xx,近 15 分钟至少一次 cron 成功; -- 没有长期停留的 `queued` / `running` 任务; -- 平台数据是否过期以各行 `stale_after` 为准,不以页面访问时间猜测; -- 最终失败保留最后成功值,不能被写成 0; -- 凭据、结构变化和权限错误不进入自动重试;可恢复错误最多一次重试。 +- 没有长期停留的 `queued` / `running`; +- 失败平台保留最后成功值,不能写成 0; +- 可恢复错误最多重试一次,凭据、结构、权限和校验错误不重试; +- 事件只有在后续批次成功、freshness 恢复且无残留任务后才能关闭。 -事件只有在任务进入终态、后续计划批次成功、数据 freshness 恢复且没有残留队列后才能关闭。 +平台定位顺序和增量规则见 [同步巡检](./sync-alerting.md) 与 [生产运维手册](./operations-runbook.md)。只修复并重试受影响平台,不批量重复同步其他平台。 -## 3. 手动备份与恢复演练 +## 7. 加密备份与隔离恢复操作卡 -需要仓库 Actions 运行权限、`production-operations` 审批、Artifact 读取权限,以及密码 -管理器中备份口令的受控访问。口令不得进入 PowerShell 历史或下载目录名称。 +触发备份和恢复前必须取得项目负责人批准。Artifact 只允许保存在仓库忽略的 `.artifacts/`,不得在共享目录解密或提交到 Git。 -### 触发并取得备份 +### 手动加密备份 ```powershell gh workflow run database-backup.yml --repo greenthree/USTSACMLand --ref main @@ -121,10 +168,9 @@ New-Item -ItemType Directory -Force ".artifacts\backup-$backupRunId" | Out-Null gh run download $backupRunId --repo greenthree/USTSACMLand --dir ".artifacts\backup-$backupRunId" ``` -下载目录只能包含工作流发布的加密文件和校验文件。不要在共享目录解密,不要把 Artifact -提交到 Git。 +下载目录只能包含工作流发布的加密文件和校验文件。Agent 不索取解密口令;恢复工作流通过 Environment Secret 使用口令。 -### 触发隔离恢复演练 +### 隔离恢复演练 ```powershell gh workflow run database-restore-drill.yml --repo greenthree/USTSACMLand --ref main -f backup_run_id=$backupRunId @@ -134,44 +180,46 @@ New-Item -ItemType Directory -Force ".artifacts\restore-$restoreRunId" | Out-Nul gh run download $restoreRunId --repo greenthree/USTSACMLand --dir ".artifacts\restore-$restoreRunId" ``` -验收记录只保存:backup/restore run ID、源 SHA、Schema 版本、8 项聚合行数、孤儿关系 -计数、Storage 对象数量/字节/集合哈希、Auth/RLS/注销烟测、RPO、RTO 和明文清理确认。 -不得保存 SQL、成员数据、对象内容、口令或 Secret。 +记录只包含 backup/restore run ID、源 SHA、Schema 版本、聚合行数、孤儿计数、Storage 数量/字节/集合哈希、RPO、RTO 和清理确认。不得保存 SQL、成员数据、对象内容、口令或 Secret。 + +## 8. 凭据轮换操作卡 -图片正式开放前还必须完成一次 Schema v2 **非空图片对象**恢复演练;零对象恢复不能替代。 +固定顺序:创建新值 → 更新全部消费者 → 受控烟测 → 确认回滚值仍可用 → 撤销旧值。不得先撤销旧值。 -## 4. 凭据轮换操作卡 +| 凭据 | 权威存储与消费者 | 烟测 | 回滚边界 | +| --------------------- | ------------------------------------------- | -------------------------------------------------- | --------------------------------------- | +| Firecrawl Key | 管理后台 Vault Key 池;环境变量只作兼容回退 | 逐 Key 健康/额度、牛客回退、QOJ 会话清理 | 新 Key 健康后禁用旧 Key,再由供应商撤销 | +| QOJ 密码 | QOJ 与 Supabase Function Secret | 一次目标主页匹配与临时会话关闭 | 新密码失败立即恢复旧 Secret | +| 洛谷 Cookie/CSRF | Supabase Function Secrets,成对更新 | UID、P/B Accepted 增量同步 | 任一失败时成对恢复 | +| `SYNC_QUEUE_TOKEN` | Supabase Function Secret 与 Vault | 更新两处后手动调用及下一次 cron 2xx | 两处未一致前不恢复 cron | +| Supabase service role | GitHub Environment 和必要服务端消费者 | 同步、生产安全检查、前端分块无泄露 | 所有消费者通过后撤销旧值 | +| 备份加密口令 | GitHub Environment Secret 与密码管理器 | 新备份和隔离恢复 | 旧 Artifact 到期前保留对应旧口令 | +| 注销恢复 GitHub Token | Supabase Function Secret | `check:recovery-token`、受控注销、恢复下限单调前移 | 新 Token 验收后撤销旧 Token | -通用顺序固定为:创建新值 → 更新所有消费者 → 受控烟测 → 确认回滚值仍可用 → 撤销旧值。 -不得先撤销旧值。 +Agent 只记录项目负责人批准时间、消费者清单、烟测、撤销时间和回滚判断,不记录值或摘要。若控制台要求输入新值,由项目负责人输入;Agent 可以继续完成其余页面操作和验证。 -| 凭据 | 权威存储与消费者 | 烟测 | 回滚/撤销边界 | -| ----------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------- | -| Firecrawl Key | 管理后台私有 Vault Key 池;`FIRECRAWL_API_KEY` 仅是无数据库 Key 时的兼容回退 | 逐 Key 健康/额度、牛客回退、QOJ 临时会话清理 | 新 Key 健康后禁用旧 Key,再由供应商撤销 | -| QOJ 密码 | QOJ 与 Supabase Function Secret | 一次目标主页匹配;确认临时会话关闭 | 新密码失败立即恢复旧 Secret,未验证前不撤销旧密码 | -| 洛谷 Cookie/CSRF | Supabase Function Secrets,成对更新 | UID 校验、P/B Accepted 增量同步 | 任一项失败时成对恢复 | -| 同步队列 Token | Supabase Function Secret 与 Vault | 暂停 cron、更新两处、手动调用和下一次 cron 2xx | 两处未一致前不恢复 cron | -| Supabase service role | GitHub/Supabase 受控 Secret 消费者 | 同步、生产安全检查;浏览器分块无泄露 | 所有消费者切换并通过后撤销旧值 | -| 遗留 WebChat 中转站 Key | Supabase Vault;不再复制到 GitHub 或前端 | 确认数据库、函数和前端入口均关闭 | 无业务依赖后按批准流程删除;保留期间继续防止读取和回显 | -| 备份加密口令 | GitHub Environment Secret 与密码管理器 | 新备份 + 恢复演练 | 14 天内旧 Artifact 的旧口令必须保留到对应 Artifact 到期 | -| 注销恢复 GitHub Token | Supabase Function Secret | `npm run check:recovery-token`、受控注销和变量单调前移 | 新 Token 验收完成后撤销旧 Token;不得降低恢复下限 | +## 9. 发布与回滚操作卡 -真实轮换必须记录变更人、第二复核人、消费者清单、烟测结果、旧值撤销时间和回滚判断, -但不记录任何值或摘要。 +### 常规发布 -## 5. 回滚桌面演练 +1. 明确发布范围,逐文件暂存,排除用户本地配置和临时产物。 +2. 运行相关测试、全仓库 lint/format、生产构建和 `git diff --check`。 +3. 推送 `codex/*` 分支,创建 PR;等待 `verify`、`database-security` 和 `gitleaks`。 +4. 只有项目负责人明确要求部署时才合并到 `main`。 +5. 等待 `main` CI 成功后触发 `Deploy GitHub Pages`,确认 build、deploy 和 `production-ranking-audit` 全部成功。 +6. 记录 PR、合并 SHA、CI run、Pages run 和正式地址,不把日志中的敏感数据复制进记录。 -### 前端 +### 前端回滚 -1. 记录故障提交、最后正常 SHA 和当前 Pages deployment URL。 -2. 使用 Git revert 创建 PR,不强推、不重写历史。 -3. 等待 `verify`、`database-security`、`gitleaks` 和 Pages 部署通过。 -4. 验证首页、深链、登录、账号页和后台入口,并确认遗留 AI 助手与推荐计划入口不可见。 -5. Cloudflare 只 Purge HTML/必要路径,不清除指纹静态资源的长期缓存;保存脱敏 Purge 记录。 +1. 记录故障 SHA、最后正常 SHA 和当前 Pages deployment URL。 +2. 使用 `git revert` 创建 PR,不强推、不重写历史。 +3. 等待全部 CI 与 Pages 部署通过。 +4. 验证首页、深链、登录、账号页和后台;确认遗留 WebChat 与推荐计划入口仍关闭。 +5. 只有项目负责人批准时才 Purge 必要 HTML/路径,不清除指纹静态资源的长期缓存。 -### Edge Functions +### Edge Function 回滚 -只有旧函数与当前 Schema 兼容时才重新部署。临时 worktree 中只部署受影响函数: +旧函数必须与当前 Schema 兼容。使用临时 worktree,只部署受影响函数;数据库 migration 不回写、不删除,使用 corrective migration。遗留 WebChat 函数不随常规回滚部署,除非是关闭态安全或 Schema 兼容修复。 ```powershell git worktree add ..\ustsacmland-rollback @@ -180,49 +228,69 @@ npx --yes supabase@2.109.1 functions deploy sync-member sync-stats delete-accoun npm run check:supabase-readiness ``` -记录回滚前后业务函数版本。遗留 WebChat 函数不随常规回滚重新部署;只有关闭态安全修复或 Schema 兼容确有需要时才从当前主分支部署,并在部署后复核全部开关仍关闭。若旧函数不兼容当前 Schema,只能从主分支发布前向修复。 +## 10. 管理员、账号与外部权限 -数据库 migration 不回写、不删除;已部署问题使用 corrective migration。整库恢复必须先通过 -`BACKUP_RECOVERY_NOT_BEFORE`,并在隔离环境验证 Auth、RLS、8 项行数、孤儿关系和非空 -Storage 对象。 +Agent 不创建专用站内管理员账号,也不保存项目负责人的登录信息。需要后台操作时,使用项目负责人已登录的受控浏览器会话;不得读取 Cookie、Local Storage、密码或恢复码。 -## 6. 管理员与维护权交接 +角色变更遵循:目标成员正常注册并验证 → Agent 核对目标且项目负责人批准角色变更 → Agent 在后台填写原因并二次确认 → 目标管理员完成必须由本人进行的登录 → Agent 继续只读验证并执行一次低风险审计操作 → 确认可用后再由 Agent 降级旧管理员。数据库必须始终保留至少一名启用管理员。 -### 站内管理员 +外部权限仍由项目负责人长期持有,包括 GitHub、Supabase、Cloudflare、阿里云域名、Firecrawl、密码管理器、QOJ/洛谷服务账号和备份口令。Agent 只通过当前已授权会话执行任务,不拥有这些权限。 -1. 新管理员正常注册,验证密码找回和资料。 -2. 现任管理员在成员管理中提升角色,填写原因并二次确认。 -3. 新管理员只读打开成员、同步、审计、Firecrawl 和遗留 WebChat 配置页,确认 WebChat 与推荐计划保持关闭且这些入口只对管理员可见。 -4. 执行一项可撤销、低风险且有审计的操作,例如修改后立即恢复一条测试公告草稿。 -5. 运行 `npm run check:production-security` 验证角色变化、旧 JWT 和跨成员边界。 -6. 确认新管理员可用后再降级离任管理员;数据库必须拒绝移除最后一名启用管理员。 +首管理员 bootstrap 只适用于数据库完全没有管理员的首次部署,不能用于失联恢复。单人维护模式下的 break-glass 操作必须由项目负责人在本次会话明确批准,并记录原因、时间、临时权限、验证和撤销结果。 -### 外部维护权 +## 11. 事件优先级与停止条件 -逐项转交或撤销 GitHub、`production-operations`、Supabase、Cloudflare、阿里云域名、 -Firecrawl、密码管理器、QOJ/洛谷服务账号和备份口令访问。站内角色交接不能替代这些权限。 +优先级:账号/数据泄露风险 → Auth/RLS/注销不一致 → 数据库或 Pages 不可用 → 同步队列停滞 → 单平台失败 → UI 缺陷。 -首管理员 bootstrap 只适用于数据库中完全没有管理员的首次部署,不能作为管理员失联恢复 -方案。break-glass 恢复必须由两名维护者批准,在私有事件记录中写明原因、时间、临时权限 -和撤销确认。 +出现以下情况时 Agent 必须停止写操作并请求项目负责人决定: -## 7. 脱敏交接记录模板 +- 生产目标、账号或分支与文档不一致; +- 工作树中存在无法判定归属的用户修改; +- 需要新的供应商权限、付款、MFA、实名操作或 Secret 原值; +- 回滚会导致数据丢失、Schema 不兼容或流量路径变化; +- 写请求结果不确定,无法通过只读状态精确对账; +- 同一阻塞连续三轮仍无法消除。 + +失败时优先保持服务关闭、保留最后成功数据、停止扩大变更范围。不要为了让检查变绿而削弱 RLS、关闭验证码、放宽 CORS、增加无限重试或把失败值写成 0。 + +## 12. Agent 交接记录模板 ```text -日期: -接任维护者:内部登记,不提交账号标识 -复核人: -目标提交: - -[ ] 权限准入与 MFA/恢复责任人已核对 -[ ] 同步巡检已独立完成,事件关闭条件可解释 -[ ] 手动备份成功,run ID: -[ ] 隔离恢复演练成功,run ID: -[ ] 凭据轮换桌面/真实演练完成 -[ ] 前端和 Edge 回滚桌面演练完成 -[ ] 站内管理员与外部维护权限交接完成 -[ ] 所有临时权限、Artifact 明文和测试夹具已清理 - -发现的问题与修复: -ROADMAP 是否可勾选:否 / 是(必须有第二复核人) +日期与时区: +执行 Agent / 会话:不记录账号标识 +项目负责人批准范围: +任务目标: +起始分支与提交: +目标生产提交: + +[ ] 已完整阅读 maintainer-handoff 和任务专项文档 +[ ] 已核对工作树、远端、CI、Pages 和 linked Supabase 项目 +[ ] 未读取或回显 Secret、Cookie、JWT、密码和成员私有数据 +[ ] 已列出并取得本次生产写操作的明确批准 +[ ] 本地测试、CI、部署或只读检查已记录 +[ ] 写请求不确定时先完成只读对账,没有盲目重试 +[ ] 临时分支、worktree、Artifact、截图和测试夹具已清理或明确保留原因 +[ ] WebChat 与推荐计划继续保持关闭 + +代码提交 / PR: +CI run: +Pages / Supabase run: +生产验证: +遗留风险与下一步: +需要项目负责人决定的事项: ``` + +## 13. 冷启动可执行性验收 + +交接文档的完成标准不再要求第二名真人。满足以下条件即可认为 Agent 交接可用: + +1. 一个不继承维护会话推理的 Agent 仅根据仓库文档完成冷启动检查; +2. 它能正确区分只读、本地写、发布和生产高风险授权; +3. 它至少完成一次同步巡检和一次发布/回滚桌面推演; +4. 备份、恢复和凭据轮换可以只读核对命令与消费者,不要求无业务需要地真实轮换; +5. 输出使用本节模板,且不含 Secret、成员资料或浏览器存储; +6. 项目负责人确认文档足以让后续 Agent 继续执行。 + +这项验收只证明操作文档可被 Agent 使用。账号恢复、MFA、域名续费和供应商申诉仍是项目负责人承担的单人风险。 + +2026-08-09 已由全新上下文 Agent 完成首次冷启动可执行性验收:GitHub、Supabase、同步队列和备份连续性只读核对通过,发布与四类回滚完成桌面推演,没有触发生产写操作。验收同时发现并修复仓库级 Secret 与 `production-operations` Environment Secret 的检查口径偏差;完整脱敏记录见 [`docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md`](./evidence/agent-cold-start-handoff-audit-2026-08-09.md)。 diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index 98e6992..6cfb42c 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -1,10 +1,10 @@ # 生产运维手册 -本文供 USTS ACM Land 的项目维护者使用,覆盖部署、验证、回滚、凭据轮换、数据源修复和管理员交接。新维护者应先按 [维护者交接与独立操作卡](./maintainer-handoff.md) 完成权限准入、同步巡检、备份恢复、轮换和回滚演练。所有命令均在仓库根目录执行;示例中的项目引用、邮箱和 URL 必须替换为当前生产配置,禁止把真实 Secret 写入命令历史、工单、截图或 Git。 +本文供 USTS ACM Land 的维护 Agent 使用,覆盖部署、验证、回滚、凭据轮换、数据源修复和管理员操作。任何全新上下文 Agent 都应先完整阅读根目录 [`agent.md`](../agent.md),再按 [Agent 维护交接手册](./maintainer-handoff.md) 完成冷启动检查、授权分级和任务范围确认。所有命令均在仓库根目录执行;示例中的项目引用、邮箱和 URL 必须替换为当前生产配置,禁止把真实 Secret 写入命令历史、工单、截图或 Git。 ## 1. 权限与职责 -至少保留两名维护者,但日常只使用完成任务所需的最小权限: +项目采用 Agent 全程维护、一名项目负责人持有长期账号恢复能力的模式。开发、测试、发布、运维和事故处理均由 Agent 执行;项目负责人只提供必要凭据输入、账号持有人操作、法律/产品决定和高风险批准。项目负责人已接受没有第二名真人维护者的单点风险;Agent 日常只使用完成任务所需的最小权限: | 角色 | 必需权限 | 禁止事项 | | ------------ | ------------------------------------------------ | --------------------------------------- | @@ -13,17 +13,17 @@ | 同步维护者 | Edge Function 部署、Function Secrets、同步健康页 | 不下载或传播成员私有资料 | | 集训队管理员 | 网站后台 | 不直接操作数据库表,不共享个人登录会话 | -生产变更至少记录:变更人、关联提交、开始/结束时间、受影响组件、验证结果和回滚判断。高风险数据库与凭据变更建议由第二名维护者复核。 +生产变更至少记录:项目负责人批准范围、执行 Agent/会话、关联提交、开始/结束时间、受影响组件、验证结果和回滚判断。高风险数据库、凭据、域名和权限变更必须由项目负责人逐次明确批准;Agent 不得把一次批准扩展到后续批次。 ### 生产保留窗口登记 -正式发布前必须由对应维护者在供应商控制台核验并填写下表。任何“待核验”项都属于发布阻塞,不得用供应商默认值或推测值代替。 +正式发布前必须由维护 Agent 在供应商控制台核验并填写下表;遇到必须由账号持有人完成的步骤时,只由项目负责人提供输入,后续核验仍由 Agent 完成。任何“待核验”项都属于发布阻塞,不得用供应商默认值或推测值代替。 | 服务与数据 | 实际保留窗口 | 核验日期与负责人 | 删除/恢复能力与限制 | | ----------------------------------- | -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------- | | Supabase 数据库备份 / PITR | PITR 未启用,可用物理备份 0 份 | 2026-07-15 / 自动只读检查 | `walg_enabled=true` 不等于存在可恢复备份;正式发布必须依赖并演练加密逻辑备份 | | Supabase Auth / Edge Functions 日志 | 待核验(发布阻塞) | 待填写 | 待填写 | -| GitHub Actions 日志与 artifact | 仓库默认 90 天;完整加密快照单项 14 天 | 2026-07-15 / GitHub API | 数据库和引用的 WebChat 图片共同进入每日完整快照;约占 `14 × 单次密文大小` | +| GitHub Actions 日志与 artifact | 仓库默认 30 天;完整加密快照单项 14 天 | 2026-08-09 / GitHub API | 数据库和引用的 WebChat 图片共同进入每日完整快照;约占 `14 × 单次密文大小` | | Firecrawl 作业与会话记录 | 待核验(发布阻塞) | 待填写 | 待填写 | 数据库备份的文件范围、加密参数、Secret 配置和隔离恢复步骤见 [数据库备份与恢复方案](./backup-and-recovery.md)。当前 Artifact 同时包含数据库快照和该快照精确引用的私有 `webchat-images` 对象,但不包含其他 Storage Bucket、Edge Function Secrets 或第三方凭据。Storage 失败时整个任务失败,不得发布数据库-only 的部分 Artifact。 @@ -85,7 +85,7 @@ npm run check:supabase-preflight 该命令退出码为 `1` 时不得继续发布。先处理缺失 Secret、远端独有 migration、项目健康、权限或 schema lint 等真正的前置阻塞;待部署 migration 与函数本身会保留为 warning。 -2026-07-26 的生产只读核对确认 71 个 migration 已全部应用,十个 Edge Function 均为 ACTIVE、启用 JWT 验证并使用仓库 import map;浏览器函数的正式 Origin 与后台专用函数的拒绝浏览器边界均通过。AI 助手页面必须以管理员当前运行时配置为权威,文档不固定模型名称;当次页面显示 `grok-4.5`,此前 GPT-5.6 的渠道验收记录不能代表当前配置。发布后仍必须重新运行严格就绪检查,不能用部分函数的通过结果替代完整门禁。证据见 [`docs/evidence/supabase-ten-function-readiness-2026-07-26.md`](./evidence/supabase-ten-function-readiness-2026-07-26.md) 与 [`docs/evidence/webchat-current-model-cache-channel-2026-07-25.md`](./evidence/webchat-current-model-cache-channel-2026-07-25.md)。 +2026-08-09 的生产只读核对确认 75 个 migration 已全部应用、0 项 pending,12 个 Edge Function 均为 ACTIVE 并使用仓库 import map;除公开头像代理 `member-avatar` 按设计关闭 JWT 验证外,其余函数均启用 JWT 验证,浏览器函数的正式 Origin与后台专用函数的拒绝浏览器边界均通过。AI 助手已经退出产品范围,运行时配置仍以管理员后台为权威,但生产请求、图片和成员入口必须保持关闭。发布后仍必须重新运行严格就绪检查,不能用部分函数的通过结果替代完整门禁。历史十函数证据见 [`docs/evidence/supabase-ten-function-readiness-2026-07-26.md`](./evidence/supabase-ten-function-readiness-2026-07-26.md),当前基线见 [`docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md`](./evidence/agent-cold-start-handoff-audit-2026-08-09.md)。 先核对本地与远端 migration,不直接在生产 SQL Editor 手工粘贴仓库 migration: @@ -263,12 +263,12 @@ npm run check:supabase-readiness 后台成员管理提供受控角色交接,并遵循“先增加、验证,再移除旧权限”: 1. 新管理员先完成正常注册、邮箱恢复配置和资料填写。 -2. 两名维护者核对目标邮箱与 Profile ID,不在聊天或截图中传播会话信息。 +2. 维护 Agent 核对目标成员,项目负责人只确认角色变更目标并给出高风险批准;Agent 使用已授权的后台会话完成操作,不读取 Cookie、密码或浏览器存储。 3. 在后台成员管理中选择目标成员,点击“设为管理员”,填写交接原因、核对权限影响并二次确认。 4. 新管理员登录并完成只读检查,再执行一次低风险、可审计操作。 5. 确认新管理员可用后,才在同一页面把离任管理员降为普通成员;数据库会串行化角色变化并拒绝移除最后一名启用管理员。 6. 轮换离任人员可访问的 GitHub、Supabase、Firecrawl 和第三方服务凭据。 -7. 保存交接清单,但不保存密码、Token、Cookie 或恢复码。 +7. 使用 `docs/maintainer-handoff.md` 的 Agent 交接模板保存批准范围和结果,但不保存密码、Token、Cookie 或恢复码。 首次部署且数据库中完全没有管理员时,才允许使用一次性的: @@ -286,7 +286,7 @@ select public.bootstrap_first_admin('new-admin@example.edu.cn'); - 同步成功率、耗时、队列长度和后台终态失败记录符合预期。 - 没有新增凭据错误、结构错误、无限重试或重复快照。 - 数据过期规则按最近计划批次计算,没有把失败值写成 0。 -- 变更记录包含验证证据、遗留风险和下一位值班维护者。 +- 变更记录包含验证证据、遗留风险和下一次 Agent 冷启动所需上下文。 ### 推荐计划全线开关 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 0fa1746..5483c24 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -4,13 +4,15 @@ 本检查单用于 USTS ACM Land 的候选版本、正式版本和紧急修复发布。每次发布复制一份到变更记录中填写,不在仓库中记录密码、Token、Cookie、成员私有资料或第三方原始响应。 +发布全过程由维护 Agent 按根目录 `agent.md` 执行并监控到终态。项目负责人只提供必要凭据输入以及发布决定或高风险批准,不负责运行命令、操作普通控制台步骤或等待 CI。 + ## 1. 发布范围与责任人 -- [ ] 已记录版本号、候选提交、变更摘要、发布人和复核人。 +- [ ] 已记录版本号、候选提交、变更摘要、项目负责人批准和执行 Agent/会话。 - [ ] 工作树只包含本次发布内容;临时截图、导出文件和根目录本地素材未被误纳入。 - [ ] 数据库、Edge Functions、前端和配置的兼容顺序已明确。 - [ ] 已记录最后一个可用的 Git 提交、Pages 部署和 Supabase migration 状态。 -- [ ] 发布人与复核人均已按 `docs/maintainer-handoff.md` 验证所需供应商权限;记录不包含账号标识或 Secret。 +- [ ] 项目负责人已按 `docs/maintainer-handoff.md` 确认所需供应商权限与高风险操作范围;Agent 只使用当前授权会话,记录不包含账号标识或 Secret。 ## 2. 本地与 CI 门禁 @@ -35,7 +37,7 @@ npm run test:e2e npm run build npm run check:bundle - npx --yes deno check --config supabase/functions/deno.json supabase/functions/sync-member/index.ts supabase/functions/sync-stats/index.ts supabase/functions/delete-account/index.ts supabase/functions/change-password/index.ts supabase/functions/firecrawl-config/index.ts supabase/functions/webchat/index.ts supabase/functions/webchat-attachment/index.ts supabase/functions/webchat-image-cleanup/index.ts supabase/functions/webchat-config/index.ts supabase/functions/webchat-cache-probe/index.ts + npx --yes deno check --config supabase/functions/deno.json supabase/functions/sync-member/index.ts supabase/functions/sync-stats/index.ts supabase/functions/sync-avatar/index.ts supabase/functions/member-avatar/index.ts supabase/functions/delete-account/index.ts supabase/functions/change-password/index.ts supabase/functions/firecrawl-config/index.ts supabase/functions/webchat/index.ts supabase/functions/webchat-attachment/index.ts supabase/functions/webchat-image-cleanup/index.ts supabase/functions/webchat-config/index.ts supabase/functions/webchat-cache-probe/index.ts npx --yes deno lint --config supabase/functions/deno.json supabase/functions npx --yes deno test --allow-read --allow-env --config supabase/functions/deno.json supabase/functions git diff --check @@ -71,8 +73,8 @@ - [ ] `sync-member`、`sync-stats`、`delete-account`、`change-password` 与 `firecrawl-config` 使用仓库 import map 部署成功。 - 遗留关闭检查:除安全修复或 Schema 兼容需要外不再发布 `webchat-config`、`webchat`、`webchat-attachment`、`webchat-image-cleanup` 或 `webchat-cache-probe`;如必须维护,部署后 `CHAT_ENABLED` 与所有产品入口仍为关闭。 - [ ] 数据库与函数部署后,严格运行 `npm run check:supabase-readiness`,不再允许待部署 migration、缺失函数或 `404` 边界。 -- [ ] 发布记录包含当前 Git SHA 与十个 Edge Function 部署后版本号;黑盒就绪检查不作为源码一致性证明。 -- [ ] `npm run check:supabase-readiness` 确认十个函数均使用预期 JWT/import map 配置,浏览器可调用函数精确允许正式 Pages Origin、不允许恶意 Origin,且匿名请求只返回预期的 `401`、`403`、`405` 或安全关闭状态。 +- [ ] 发布记录包含当前 Git SHA 与 12 个 Edge Function 部署后版本号;黑盒就绪检查不作为源码一致性证明。 +- [ ] `npm run check:supabase-readiness` 确认 12 个函数均使用预期 JWT/import map 配置,浏览器可调用函数精确允许正式 Pages Origin、不允许恶意 Origin,且匿名请求只返回预期的 `401`、`403`、`405` 或安全关闭状态。 - [ ] `npm run check:supabase-readiness` 确认数据库队列 Vault 配置完整、五分钟 cron active、最近 12 分钟有调度、最近 HTTP 为 2xx 且近 15 分钟至少一次 cron 成功。 - [ ] 仅对受控测试成员执行一次单平台同步,快照、运行记录、新鲜度和审计一致。 - [ ] Codeforces、牛客、AtCoder、XCPC ELO、洛谷、QOJ 的固定样本契约测试通过。 @@ -118,15 +120,15 @@ - [ ] 恢复工具拒绝早于当前注销恢复下限的备份,并拒绝仓库变量回退到备份 metadata 之前。 - [ ] 已按 [数据库备份与恢复方案](./backup-and-recovery.md) 使用当前 `main` 新生成的 Schema v2 真实 Artifact 运行手动 `Encrypted database restore drill`;演练完成来源/恢复下限、解密、动态归档白名单、单事务数据库恢复、8 项行数、7 类孤儿、私有 Bucket 重建、匿名访问拒绝、数据库引用与对象字节/哈希比对、3 个 Auth hooks、注册建档、密码登录、RLS、受控注销和明文/对象清理核对。旧 run `29656219433` 只覆盖 database-only 格式,可作为历史基线但不能替代本项。 - [ ] 已确认学校、集训队、ICPC 等名称和图形标识的使用授权范围。 -- [ ] 已由项目负责人选择并加入 `LICENSE`;在此之前不得把源码描述为开源。 +- [x] 项目负责人已选择 Apache License 2.0(SPDX 标识符:`Apache-2.0`),维护 Agent 已加入 `LICENSE`。该许可证仅覆盖项目原创源代码;学校、集训队、赛事标识、第三方素材、成员数据和平台数据仍保持独立授权边界。 - [ ] 真实队员已小范围核对姓名、专业、年级、平台绑定和统计值。 - [ ] 已观察至少一个完整日更批次;涉及 XCPC ELO/QOJ 时观察到下一个周二批次。 - [ ] 所有阻塞问题已关闭,遗留非阻塞风险有负责人和后续日期。 ## 8. 发布与观察 -- [ ] 复核人明确给出发布决定后,才创建带注释的 `v1.0.0`(或对应版本)标签。 +- [ ] 项目负责人明确给出发布决定后,才由执行 Agent 创建带注释的 `v1.0.0`(或对应版本)标签。 - [ ] 标签指向已通过全部门禁并实际部署的提交,不在失败构建上移动或复用标签。 - [ ] Pages、认证、后台、同步队列、同步失败状态和数据库指标在发布后观察窗口内正常。 - [ ] 若出现故障,已按 [生产运维手册](./operations-runbook.md) 执行 Git revert、函数兼容回滚或数据库前向修复。 -- [ ] 发布记录包含验证证据、最终部署 ID、遗留风险和下一位维护者。 +- [ ] 发布记录包含验证证据、最终部署 ID、遗留风险和下一次 Agent 冷启动所需上下文。 diff --git a/scripts/check-repository-readiness.mjs b/scripts/check-repository-readiness.mjs index 21d7075..8ea588d 100644 --- a/scripts/check-repository-readiness.mjs +++ b/scripts/check-repository-readiness.mjs @@ -14,12 +14,15 @@ export const expectedWorkflows = [ export const requiredActionSecrets = [ 'VITE_SUPABASE_URL', 'VITE_SUPABASE_ANON_KEY', - 'SUPABASE_PROJECT_REF', - 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_ACCESS_TOKEN', 'BACKUP_ENCRYPTION_PASSPHRASE', ] +export const requiredProductionEnvironmentSecrets = [ + 'SUPABASE_PROJECT_REF', + 'SUPABASE_SERVICE_ROLE_KEY', +] + export const requiredActionVariables = [ 'BACKUP_RECOVERY_NOT_BEFORE', 'MAX_BACKUP_ARTIFACT_BYTES', @@ -30,6 +33,7 @@ export const requiredActionVariables = [ // "workflow / job" labels. const requiredChecks = ['verify', 'database-security', 'gitleaks'] const productionDomain = 'ustsacm.fun' +const productionEnvironmentName = 'production-operations' const scheduledWorkflowMaxAgeHours = new Map([ // The twice-daily GitHub platform batches have a two-hour execution grace. // The five-minute retry queue is monitored separately in Supabase. @@ -135,7 +139,33 @@ export function evaluateRepositoryReadiness(state) { const actionSecrets = asSet(state.actionSecrets) for (const secret of requiredActionSecrets) { - if (!actionSecrets.has(secret)) errors.push(`Actions Secret 未配置:${secret}。`) + if (!actionSecrets.has(secret)) errors.push(`仓库 Actions Secret 未配置:${secret}。`) + } + + const productionEnvironment = state.productionEnvironment + if (productionEnvironment?.name !== productionEnvironmentName) { + errors.push(`缺少 ${productionEnvironmentName} Environment。`) + } else { + const environmentSecrets = asSet(productionEnvironment.secrets) + for (const secret of requiredProductionEnvironmentSecrets) { + if (!environmentSecrets.has(secret)) { + errors.push(`${productionEnvironmentName} Environment Secret 未配置:${secret}。`) + } + } + + const deploymentPolicy = productionEnvironment.deploymentBranchPolicy + const branchPolicies = productionEnvironment.branchPolicies ?? [] + const onlyDefaultBranch = + deploymentPolicy?.customBranchPolicies === true && + deploymentPolicy?.protectedBranches === false && + branchPolicies.length === 1 && + branchPolicies[0]?.type === 'branch' && + branchPolicies[0]?.name === defaultBranch + if (!onlyDefaultBranch) { + errors.push( + `${productionEnvironmentName} Environment 必须只允许默认分支 ${defaultBranch} 部署。`, + ) + } } const actionVariables = asSet(state.actionVariables) @@ -191,6 +221,7 @@ export function evaluateRepositoryReadiness(state) { repository: state.repository.nameWithOwner, workflows: state.workflows.length, actionSecrets: state.actionSecrets.length, + productionEnvironmentSecrets: state.productionEnvironment?.secrets?.length ?? 0, actionVariables: state.actionVariables.length, pagesUrl: state.pages.htmlUrl ?? null, actionsRetentionDays: state.actionsRetention?.days ?? null, @@ -280,6 +311,21 @@ export function collectRepositoryReadinessState(repositoryName) { '私密漏洞报告设置', ) const pages = runGhJson(['api', `${apiRoot}/pages`], 'GitHub Pages 设置') + const productionEnvironment = runGhJson( + ['api', `${apiRoot}/environments/${productionEnvironmentName}`], + `${productionEnvironmentName} Environment`, + ) + const environmentSecretsResponse = runGhJson( + ['api', `${apiRoot}/environments/${productionEnvironmentName}/secrets?per_page=100`], + `${productionEnvironmentName} Environment Secret 名称`, + ) + const environmentBranchPoliciesResponse = runGhJson( + [ + 'api', + `${apiRoot}/environments/${productionEnvironmentName}/deployment-branch-policies?per_page=100`, + ], + `${productionEnvironmentName} Environment 分支策略`, + ) const workflows = (workflowResponse.workflows ?? []).map((workflow) => { const expected = expectedWorkflows.find((item) => item.name === workflow.name) let contentMatches = false @@ -327,6 +373,21 @@ export function collectRepositoryReadinessState(repositoryName) { ['variable', 'list', '--repo', nameWithOwner, '--json', 'name'], 'Actions 变量名称', ), + productionEnvironment: { + name: productionEnvironment.name, + secrets: (environmentSecretsResponse.secrets ?? []) + .map((secret) => secret.name) + .filter(Boolean), + deploymentBranchPolicy: { + customBranchPolicies: + productionEnvironment.deployment_branch_policy?.custom_branch_policies, + protectedBranches: productionEnvironment.deployment_branch_policy?.protected_branches, + }, + branchPolicies: (environmentBranchPoliciesResponse.branch_policies ?? []).map((policy) => ({ + name: policy.name, + type: policy.type, + })), + }, actionsPermissions: { defaultWorkflowPermissions: actionsPermissions.default_workflow_permissions, canApprovePullRequestReviews: actionsPermissions.can_approve_pull_request_reviews, @@ -360,7 +421,7 @@ async function main() { console.log(`Repository readiness: ${report.summary.repository}`) console.log( - `Observed ${report.summary.workflows} workflows, ${report.summary.actionSecrets} Actions Secret names and ${report.summary.actionVariables} Actions variable names.`, + `Observed ${report.summary.workflows} workflows, ${report.summary.actionSecrets} repository Actions Secret names, ${report.summary.productionEnvironmentSecrets} ${productionEnvironmentName} Environment Secret names and ${report.summary.actionVariables} Actions variable names.`, ) console.log(`Actions default retention: ${report.summary.actionsRetentionDays} days.`) if (report.summary.pagesUrl) console.log(`Pages: ${report.summary.pagesUrl}`) diff --git a/scripts/check-repository-readiness.test.ts b/scripts/check-repository-readiness.test.ts index d9cb1cb..781a324 100644 --- a/scripts/check-repository-readiness.test.ts +++ b/scripts/check-repository-readiness.test.ts @@ -4,6 +4,7 @@ import { expectedWorkflows, requiredActionSecrets, requiredActionVariables, + requiredProductionEnvironmentSecrets, } from './check-repository-readiness.mjs' function createReadyState() { @@ -42,6 +43,15 @@ function createReadyState() { url: `https://github.test/actions/${workflow.name}`, })), actionSecrets: [...requiredActionSecrets], + productionEnvironment: { + name: 'production-operations', + secrets: [...requiredProductionEnvironmentSecrets], + deploymentBranchPolicy: { + customBranchPolicies: true, + protectedBranches: false, + }, + branchPolicies: [{ name: 'main', type: 'branch' }], + }, actionVariables: [...requiredActionVariables], actionsPermissions: { defaultWorkflowPermissions: 'read', @@ -113,7 +123,8 @@ describe('repository readiness checker', () => { summary: { repository: 'greenthree/USTSACMLand', workflows: 5, - actionSecrets: 6, + actionSecrets: 4, + productionEnvironmentSecrets: 2, actionVariables: 3, actionsRetentionDays: 14, defaultBranchSha: '0123456789abcdef', @@ -145,7 +156,7 @@ describe('repository readiness checker', () => { expect(evaluateRepositoryReadiness(state).errors).toEqual( expect.arrayContaining([ - 'Actions Secret 未配置:BACKUP_ENCRYPTION_PASSPHRASE。', + '仓库 Actions Secret 未配置:BACKUP_ENCRYPTION_PASSPHRASE。', 'Actions 变量未配置:BACKUP_RECOVERY_NOT_BEFORE。', 'Actions 变量未配置:MAX_BACKUP_ARTIFACT_BYTES。', 'Actions 变量未配置:MAX_STORAGE_OBJECTS。', @@ -153,6 +164,21 @@ describe('repository readiness checker', () => { ) }) + it('requires production credentials only in the default-branch environment', () => { + const state = createReadyState() + state.productionEnvironment.secrets = state.productionEnvironment.secrets.filter( + (name) => name !== 'SUPABASE_SERVICE_ROLE_KEY', + ) + state.productionEnvironment.branchPolicies = [{ name: 'release/*', type: 'branch' }] + + expect(evaluateRepositoryReadiness(state).errors).toEqual( + expect.arrayContaining([ + 'production-operations Environment Secret 未配置:SUPABASE_SERVICE_ROLE_KEY。', + 'production-operations Environment 必须只允许默认分支 main 部署。', + ]), + ) + }) + it('rejects incomplete default-branch protection', () => { const state = createReadyState() state.rulesets[0].rules = state.rulesets[0].rules.filter( From ad889fb7e050e6b83a76d05e2ee929948e50efe9 Mon Sep 17 00:00:00 2001 From: greenthree <1395214327@qq.com> Date: Sun, 9 Aug 2026 13:01:42 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E4=B8=8E=E7=AC=AC=E4=B8=89=E6=96=B9=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 2 +- ROADMAP.md | 4 +-- THIRD_PARTY_NOTICES.md | 20 ++++++++++++++ docs/backup-and-recovery.md | 2 +- docs/maintainer-handoff.md | 2 +- docs/operations-runbook.md | 32 +++++++++++----------- docs/release-checklist.md | 2 +- scripts/check-repository-readiness.mjs | 32 ++++++++++++++++++++-- scripts/check-repository-readiness.test.ts | 15 ++++++++++ 10 files changed, 88 insertions(+), 24 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/.gitignore b/.gitignore index cd63096..384eef6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ coverage/ .supabase/ supabase/.temp/ supabase/.branches/ +.claude/ # Editor and operating system files .vscode/ diff --git a/README.md b/README.md index 9befdf6..6a21ef9 100644 --- a/README.md +++ b/README.md @@ -327,4 +327,4 @@ Agent 的根级操作契约见 [`agent.md`](./agent.md),视觉规范见 [docs/ ## 许可证与归属 -源代码按 Apache License 2.0(SPDX 标识符:`Apache-2.0`)发布,详见 [`LICENSE`](./LICENSE)。该许可证仅覆盖项目原创源代码,不自动授予学校、集训队或赛事名称与图形标识的使用权,也不覆盖第三方字体、图片、成员数据或平台数据;这些内容分别遵循其独立授权和数据处理边界。数据处理说明见 [PRIVACY.md](./PRIVACY.md) 与 [第三方数据来源说明](./docs/third-party-data-sources.md),漏洞报告方式见 [SECURITY.md](./SECURITY.md)。 +项目原创源代码,以及未附带其他授权声明的原创文档和配置,按 Apache License 2.0(SPDX 标识符:`Apache-2.0`)发布,详见 [`LICENSE`](./LICENSE)。该许可证不自动授予学校、集训队或赛事名称与图形标识的使用权,也不覆盖第三方字体、图片、成员数据或平台数据;完整资产清单和独立授权边界见 [第三方素材与商标说明](./THIRD_PARTY_NOTICES.md)。数据处理说明见 [PRIVACY.md](./PRIVACY.md) 与 [第三方数据来源说明](./docs/third-party-data-sources.md),漏洞报告方式见 [SECURITY.md](./SECURITY.md)。 diff --git a/ROADMAP.md b/ROADMAP.md index 26d6565..3c014fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,8 +141,8 @@ USTSACMLand 的定位是苏州科技大学 ACM 集训队官网,当前产品范 以下记录仅用于解释已保留的数据库和前端实现,不再作为待办、发布门禁或下一步工作。生产全局开关与重开安全闸门保持关闭;已有邀请码、绑定和奖励数据不删除,也不继续发放。 -- 已停止:在管理后台提供推荐计划全局开关,管理员可一键开启或关闭,并清晰显示当前状态、最后修改时间和修改人;关闭和重新开启都需要二次确认,操作结果即时反馈。2026-07-26 已在正式后台只读确认关闭状态、配置版本、最后修改时间、修改人和原因,并打开开启方向的二次确认:变更原因与全站影响确认未完成前,确认按钮保持禁用;随后取消且未修改生产配置。仍缺关闭方向正式页面复验、完整焦点顺序、390px 移动端及安全闸门解除后的受控重开结果反馈,因此保持未完成。证据见 [`docs/evidence/production-referral-global-switch-2026-07-23.md`](./docs/evidence/production-referral-global-switch-2026-07-23.md)。 -- 已停止:全局开关必须由私有数据库配置和仅管理员可调用的 RPC 强制执行,并包含原因、乐观锁、原子限流和审计记录;注册触发器、邀请码校验、账号页摘要和奖励事务都读取同一状态,不能只隐藏前端入口。生产关闭/重开烟测和重开安全闸门均已完成,当前共 71 个 production migration;仓库当前 48 个 pgTAP 文件、1206 项断言已在干净数据库通过。2026-07-26 新增的本地双连接验证器已真实观察配置行锁等待、全字段 CAS、响应丢失精确对账和零残留,仍待受控生产双连接烟测。证据见 [`docs/evidence/referral-concurrency-local-2026-07-26.md`](./docs/evidence/referral-concurrency-local-2026-07-26.md)。 +- 已停止:在管理后台提供推荐计划全局开关,管理员可一键开启或关闭,并清晰显示当前状态、最后修改时间和修改人;关闭和重新开启都需要二次确认,操作结果即时反馈。2026-07-26 已在正式后台只读确认关闭状态、配置版本、最后修改时间、修改人和原因,并打开开启方向的二次确认:变更原因与全站影响确认未完成前,确认按钮保持禁用;随后取消且未修改生产配置。关闭态页面、焦点和失败关闭边界仅作为历史证据保留,不再安排重开。证据见 [`docs/evidence/production-referral-global-switch-2026-07-23.md`](./docs/evidence/production-referral-global-switch-2026-07-23.md)。 +- 已停止:全局开关必须由私有数据库配置和仅管理员可调用的 RPC 强制执行,并包含原因、乐观锁、原子限流和审计记录;注册触发器、邀请码校验、账号页摘要和奖励事务都读取同一状态,不能只隐藏前端入口。生产关闭/重开烟测和重开安全闸门均已完成;2026-07-26 的历史快照为 71 个 production migration,当前生产基线已更新为 75 个 migration。仓库当前 48 个 pgTAP 文件、1206 项断言已在干净数据库通过。证据见 [`docs/evidence/referral-concurrency-local-2026-07-26.md`](./docs/evidence/referral-concurrency-local-2026-07-26.md)。 - 已停止:关闭后立即停止邀请码展示、公开校验、新绑定和新奖励,但不删除已有邀请码、绑定或撤回已发 Token;新用户仍可正常注册。关闭态、初始检查和状态查询失败时,注册页与成员账号页均不渲染推荐计划名称、邀请码或历史奖励摘要;重新开启后沿用原邀请码,不追补关闭期间完成的注册,也不改变已有奖励次数。成员端单元测试与本地桌面/移动浏览器烟测已通过,待生产页面验收。 - 已停止:开关切换与并发注册使用数据库行锁或等价事务围栏:关闭事务提交后启动的注册不得绑定或计奖,已成功提交的绑定不能被回滚;重复点击和网络响应丢失必须可安全对账。数据库已统一 `profile -> config -> code -> access` 锁序并收紧同管理员/同原因的丢失响应识别;本地真实双连接已分别验证“确认先提交、关停后提交”“关停先提交、确认后提交”和丢失响应重放,并已纳入 GitHub `database-security` 必跑门禁,仍待受控生产双连接烟测。证据见 [`docs/evidence/referral-concurrency-local-2026-07-26.md`](./docs/evidence/referral-concurrency-local-2026-07-26.md)。 - 已停止:补齐普通成员越权、停用管理员、最后管理员保护、并发开关/注册、审计脱敏、关闭状态页面、重新开启和生产回滚烟测后再上线;本地权限矩阵、审计白名单、关闭/重开和前端失败状态已覆盖。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..1776df3 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,20 @@ +# 第三方素材与商标说明 + +根目录 [`LICENSE`](./LICENSE) 只授予项目原创内容的 Apache License 2.0 权利。下列文件包含第三方字体、品牌标识或需要独立确认的视觉素材,不因根目录许可证获得额外授权。 + +## Noto Serif SC + +`public/fonts/noto-serif-sc/` 中的 Noto Serif SC 字体分片及其 CSS 派生文件依据 SIL Open Font License 1.1 使用。 + +- 上游项目:[Noto CJK](https://github.com/notofonts/noto-cjk) +- 字体许可证:[SIL Open Font License 1.1](https://scripts.sil.org/OFL) +- 字体名称和保留名称限制以其上游许可证为准。 + +## 品牌标识与图片 + +- `public/icpc-foundation.png`:ICPC Foundation 标识。商标、品牌指南和再使用许可由 ICPC Foundation 独立决定。 +- `public/ccpc-logo.png`:CCPC 标识。商标、赛事名称和图形使用范围由其权利人独立决定。 +- `public/ustsacm.png`:学校 ACM 集训队标识。学校、集训队和校内赛事相关名称与图形授权需由项目负责人另行确认。 +- `public/og-image.png`、`public/favicon-192.png`、`public/favicon-512.png`:站点分享图和图标;其中嵌入的品牌标识继续遵循上列独立边界。 + +除明确写入本文件或文件邻接许可证的内容外,第三方素材的权利人、商标和品牌授权不因项目源代码采用 Apache-2.0 而转移或扩展。分发或改用这些素材前,应保留本说明并核对权利人的当前条款。 diff --git a/docs/backup-and-recovery.md b/docs/backup-and-recovery.md index 47edd0c..5bb45ec 100644 --- a/docs/backup-and-recovery.md +++ b/docs/backup-and-recovery.md @@ -60,7 +60,7 @@ | `SUPABASE_ACCESS_TOKEN` | Supabase 个人访问令牌;固定版本 CLI 仅用它链接目标项目并动态取得短期数据库登录 | | `BACKUP_ENCRYPTION_PASSPHRASE` | 独立随机口令,至少 32 个字符;不得与数据库、GitHub、邮箱或平台账号密码复用 | -`SUPABASE_PROJECT_REF` 与 `SUPABASE_SERVICE_ROLE_KEY` 只配置在受保护的 `production-operations` Environment;该 Environment 只允许默认分支 `main`。备份工作流从该 Environment 取得项目引用,生产同步与只读探针同时使用项目引用和 service role key;仓库级和组织级不得保留同名副本。 +`SUPABASE_PROJECT_REF` 与 `SUPABASE_SERVICE_ROLE_KEY` 只配置在受保护的 `production-operations` Environment;该 Environment 只允许默认分支 `main`。备份工作流从该 Environment 取得项目引用,生产同步与只读探针同时使用项目引用和 service role key;仓库级和组织级不得保留同名副本。当前 `greenthree/USTSACMLand` 属于个人账户,因此没有组织级 Secret 作用域;若仓库转移到 Organization,必须重新运行仓库就绪检查并核对组织级 Secret 名称。 同一页面的 `Variables` 必须配置: diff --git a/docs/maintainer-handoff.md b/docs/maintainer-handoff.md index 6f22b9f..910810a 100644 --- a/docs/maintainer-handoff.md +++ b/docs/maintainer-handoff.md @@ -224,7 +224,7 @@ Agent 只记录项目负责人批准时间、消费者清单、烟测、撤销 ```powershell git worktree add ..\ustsacmland-rollback Set-Location ..\ustsacmland-rollback -npx --yes supabase@2.109.1 functions deploy sync-member sync-stats delete-account change-password firecrawl-config --use-api --import-map supabase/functions/deno.json +npx --yes supabase@2.109.1 functions deploy sync-member sync-stats sync-avatar member-avatar delete-account change-password firecrawl-config --use-api --import-map supabase/functions/deno.json npm run check:supabase-readiness ``` diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index 6cfb42c..a554eb5 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -129,15 +129,15 @@ npx --yes supabase@2.109.1 functions deploy sync-member sync-stats sync-avatar m --use-api --import-map supabase/functions/deno.json ``` -WebChat 不随其他函数直接启用。可以先在 `VITE_WEBCHAT_UI_ENABLED=false`、`CHAT_ENABLED=false` 和数据库 `requests_enabled=false` 的三层关闭态下应用配额、账号授权与配置 migration,并使用仓库 import map 部署 `webchat-config`、`webchat` 与拒绝浏览器调用的 `webchat-cache-probe`;这一步只提供管理员配置入口和失败关闭边界,不允许账号调用中转站。首次启用或更换中转站前,在 GitHub Actions Secrets 中配置 `CHAT_RELAY_BASE_URL`、`CHAT_RELAY_API_KEY`、`CHAT_RELAY_MODEL`,手动运行 `WebChat relay compatibility`,并下载检查 14 天保留的脱敏报告。非流式、typed SSE、Usage 和 Abort 四项均通过后,再由管理员在 `/admin/webchat` 写入同一组 Base URL、模型和 Key,设置全站北京时间每日请求/Token 预算,并继续保持“允许账号发起 AI 请求”关闭。Key 只进入 Supabase Vault;保存后页面必须清空密钥输入框,刷新时只能看到配置状态、开关、预算、版本和更新时间。生产配置写入后可手动运行 `WebChat production cache probe`:它只使用仓库现有的 `SUPABASE_PROJECT_REF` 与 `SUPABASE_SERVICE_ROLE_KEY`,由 Supabase 内部读取 Vault,不要在 GitHub 再保存一份中转站配置;探针固定占用全站 2 次请求,并按两次请求 JSON 的 UTF-8 字节、最大输出与协议余量动态保守预留 Token,不扣成员额度、30 分钟冷却且不自动重试,第二次必须报告 `cached_tokens > 0`。随后在账号详情中按实际需要打开“允许使用 AI 学习助手”,逐人设置累计请求与 Token 总限额并填写原因;无授权行默认拒绝,停用账号始终无效。已授权账号的 `/assistant` 应只显示服务端当前模型名、自己的累计已用、预留、总限额和剩余,不得返回 Base URL、Key、全站预算或他人额度;成员累计额度不会每日重置,管理员提高总限额可追加可用额度,历史用量不得清零。同一服务端解析模型必须进入请求系统提示词与额度指纹。此时保持 `CHAT_ENABLED=false`,先验证环境禁用态 `503`、数据库暂停态 `503`、CORS、匿名拒绝、管理员配置边界、账号默认拒绝、撤权/降额竞态、逐人累计额度及全站日预算边界。完成真实中转站、额度和隐私验收后,先受控设置 `CHAT_ENABLED=true`,再由管理员打开数据库请求开关,最后在下一次前端 Pages 构建中设置 `VITE_WEBCHAT_UI_ENABLED=true`。`CHAT_RELAY_*` 与 `CHAT_GLOBAL_*` Supabase Function Secrets 只在数据库 RPC 没有返回中转站配置行时作为部署引导/应急回退;账号累计额度没有环境变量回退。数据库配置行一旦存在,后台暂停开关和预算始终优先,启用状态下缺少地址、模型或 Vault Key 会失败关闭。任一阶段失败时立即关闭数据库请求开关,并按需要恢复 `CHAT_ENABLED=false` 与 `VITE_WEBCHAT_UI_ENABLED=false`;不能只隐藏导航而继续让后端消费模型额度。正式试运行观察名单与连续观察机制已取消,后台只保留账号授权、累计额度、用量和缓存监控。完整步骤见 [WebChat 中转站兼容性验收](./webchat-relay-compatibility.md)。 +WebChat / AI 学习助手已退出正式产品范围。遗留代码、Schema、函数和后台配置只允许在关闭态维护:`VITE_WEBCHAT_UI_ENABLED=false`、`CHAT_ENABLED=false`、数据库 `requests_enabled=false`,且不向 GitHub 或 Supabase 新增、轮换或读取中转站运行凭据。只有安全修复或 Schema 兼容需要时,才可使用仓库 import map 部署遗留 WebChat 函数;不得运行付费兼容性验收、缓存探针、模型请求、成员授权或额度烟测,也不得设置任何开关为 `true`。每次关闭态维护后必须运行严格 Supabase readiness 和生产安全检查,确认匿名、普通成员和管理员请求均失败关闭、入口仍不可见且没有新请求/额度账目。历史协议说明保留在 [WebChat 中转站兼容性验收](./webchat-relay-compatibility.md),不构成启用步骤。 -Pages 的客户端开关使用 GitHub 仓库变量 `VITE_WEBCHAT_UI_ENABLED`,只接受小写 `true` 或 `false`;未配置时 workflow 固定回退为 `false`。生产客户端从同一次构建的 `VITE_SUPABASE_URL` 推导当前项目的 `/functions/v1/webchat`,不得用覆盖 URL 把成员 Supabase 登录 Token 发送到其他域名。 +Pages 的遗留 WebChat 客户端变量 `VITE_WEBCHAT_UI_ENABLED` 必须固定为小写 `false`;未配置时 workflow 也固定回退为 `false`,不得在生产构建中设置为 `true`。生产客户端从同一次构建的 `VITE_SUPABASE_URL` 推导当前项目的 `/functions/v1/webchat`,不得用覆盖 URL 把成员 Supabase 登录 Token 发送到其他域名。 注册防滥用使用 Cloudflare Turnstile 的公开 Site Key 和 Supabase Auth 私有 Secret 双端 验证。跨系统启用时先暂时禁止注册,避免 Pages 与 Auth 配置切换窗口;真实邮箱确认、 Auth 限流、直连拒绝和回滚步骤见 [注册滥用防护](./registration-abuse-controls.md)。 -WebChat 部署后还要核对 `/admin/webchat` 的当天请求数、已结算 Token、正在预留 Token、剩余额度和下一次北京时间 00:00 重置时间。使用隔离数据分别触发请求/Token 阻断,确认后台聚合状态准确、并发请求不能越过预算,原请求返回额度 `503` 且不得自动重试或放行。 +遗留 WebChat 关闭态维护后,只读核对 `/admin/webchat` 显示关闭状态、请求数和额度账目没有新增;不得使用真实成员或模型请求触发预算阻断。若关闭态查询失败,成员端和服务端必须继续失败关闭。 部署后执行受控烟测: @@ -215,19 +215,19 @@ npm run check:supabase-readiness 生产环境按产品决定不配置外部告警 Webhook;同步最终状态改由后台同步中心、数据源健康页和审计记录巡检。当前 `DELETION_RECOVERY_REPOSITORY` 与 `DELETION_RECOVERY_GITHUB_TOKEN` 已配置,Token 已收敛为只授权目标仓库 Variables 读写的 fine-grained Token;维护端单调前推并回读恢复下限、Edge 自主写入、真实注销与残留清理烟测均已通过。Secret 审计工具只读取名称,不输出真实值或摘要。 -| 凭据 | 存放位置 | 轮换后验证 | -| ------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| Firecrawl API Key | 管理后台私有 Vault Key 池;Function Secret 仅为兼容回退 | 逐 Key 健康/额度、受控冷却轮换、牛客回退和 QOJ 登录会话清理 | -| QOJ 服务账号密码 | QOJ + Supabase Function Secrets | 每个 attempt 单次登录、目标主页匹配、会话最终关闭;可恢复错误最多一次队列重试 | -| 洛谷 Cookie + CSRF | Supabase Function Secrets,必须成对更新 | 公开 UID 校验、Accepted 分页、仅 P/B 题去重 | -| 同步队列调度 Token | Supabase Function Secret + Vault | 暂停 cron 后同步轮换两处,手动调用与下一次 cron 均为 2xx | -| Supabase access token | GitHub Actions/维护者本机安全存储 | migration list 与函数部署只访问目标项目 | -| Supabase service role key | Edge/GitHub 受控 Secret | 计划同步、队列领取和管理员函数正常;浏览器包中不存在该值 | -| WebChat 中转站 Key | GitHub Actions(验收)+ Supabase Vault(运行) | 手动兼容性烟测、后台轮换、禁用态函数边界和成员生产验证;前端包、配置读取与审计中均不存在该值 | -| Supabase 数据库密码/URI | GitHub Actions Secret、密码管理器 | 手动备份 dry run 与一次受控加密备份成功;日志不含 URI | -| 数据库备份加密口令 | GitHub Actions Secret、密码管理器 | 下载最新 Artifact,在隔离目录完成解密和 SHA256 校验 | -| 注销恢复下限 GitHub Token | Supabase Function Secret | 仅 Variables write;受控注销前后变量单调前移且不含身份信息 | -| 管理员密码 | Supabase Auth | 新密码登录、旧会话按策略失效、恢复邮箱可用 | +| 凭据 | 存放位置 | 轮换后验证 | +| ------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Firecrawl API Key | 管理后台私有 Vault Key 池;Function Secret 仅为兼容回退 | 逐 Key 健康/额度、受控冷却轮换、牛客回退和 QOJ 登录会话清理 | +| QOJ 服务账号密码 | QOJ + Supabase Function Secrets | 每个 attempt 单次登录、目标主页匹配、会话最终关闭;可恢复错误最多一次队列重试 | +| 洛谷 Cookie + CSRF | Supabase Function Secrets,必须成对更新 | 公开 UID 校验、Accepted 分页、仅 P/B 题去重 | +| 同步队列调度 Token | Supabase Function Secret + Vault | 暂停 cron 后同步轮换两处,手动调用与下一次 cron 均为 2xx | +| Supabase access token | GitHub Actions/维护者本机安全存储 | migration list 与函数部署只访问目标项目 | +| Supabase service role key | Edge/GitHub 受控 Secret | 计划同步、队列领取和管理员函数正常;浏览器包中不存在该值 | +| 遗留 WebChat 配置 | Supabase Vault(如历史残留) | 只读确认三层关闭,按维护策略删除或轮换;不运行兼容性烟测,不向前端、日志或审计暴露 | +| Supabase 数据库密码/URI | GitHub Actions Secret、密码管理器 | 手动备份 dry run 与一次受控加密备份成功;日志不含 URI | +| 数据库备份加密口令 | GitHub Actions Secret、密码管理器 | 下载最新 Artifact,在隔离目录完成解密和 SHA256 校验 | +| 注销恢复下限 GitHub Token | Supabase Function Secret | 仅 Variables write;受控注销前后变量单调前移且不含身份信息 | +| 管理员密码 | Supabase Auth | 新密码登录、旧会话按策略失效、恢复邮箱可用 | 轮换后搜索近期日志只能检查字段名或错误码,禁止搜索并输出 Secret 本身。若凭据疑似泄露,先撤销、再调查影响范围;不要继续使用旧值“观察是否被滥用”。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 5483c24..e13e513 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -70,7 +70,7 @@ ## 4. Edge Functions 与同步 - [ ] 按“数据库 → Edge Functions → Pages”的顺序部署。 -- [ ] `sync-member`、`sync-stats`、`delete-account`、`change-password` 与 `firecrawl-config` 使用仓库 import map 部署成功。 +- [ ] `sync-member`、`sync-stats`、`sync-avatar`、`member-avatar`、`delete-account`、`change-password` 与 `firecrawl-config` 使用仓库 import map 部署成功。 - 遗留关闭检查:除安全修复或 Schema 兼容需要外不再发布 `webchat-config`、`webchat`、`webchat-attachment`、`webchat-image-cleanup` 或 `webchat-cache-probe`;如必须维护,部署后 `CHAT_ENABLED` 与所有产品入口仍为关闭。 - [ ] 数据库与函数部署后,严格运行 `npm run check:supabase-readiness`,不再允许待部署 migration、缺失函数或 `404` 边界。 - [ ] 发布记录包含当前 Git SHA 与 12 个 Edge Function 部署后版本号;黑盒就绪检查不作为源码一致性证明。 diff --git a/scripts/check-repository-readiness.mjs b/scripts/check-repository-readiness.mjs index 8ea588d..6d26c18 100644 --- a/scripts/check-repository-readiness.mjs +++ b/scripts/check-repository-readiness.mjs @@ -42,7 +42,7 @@ const scheduledWorkflowMaxAgeHours = new Map([ ]) function asSet(values) { - return new Set(values.filter(Boolean)) + return new Set((Array.isArray(values) ? values : []).filter(Boolean)) } function targetsDefaultBranch(ruleset, defaultBranch) { @@ -141,6 +141,22 @@ export function evaluateRepositoryReadiness(state) { for (const secret of requiredActionSecrets) { if (!actionSecrets.has(secret)) errors.push(`仓库 Actions Secret 未配置:${secret}。`) } + for (const secret of requiredProductionEnvironmentSecrets) { + if (actionSecrets.has(secret)) { + errors.push( + `${secret} 不得存在仓库 Actions Secret;请只配置在 ${productionEnvironmentName} Environment。`, + ) + } + } + + const organizationActionSecrets = asSet(state.organizationActionSecrets) + for (const secret of requiredProductionEnvironmentSecrets) { + if (organizationActionSecrets.has(secret)) { + errors.push( + `${secret} 不得存在组织级 Actions Secret;请只配置在 ${productionEnvironmentName} Environment。`, + ) + } + } const productionEnvironment = state.productionEnvironment if (productionEnvironment?.name !== productionEnvironmentName) { @@ -222,6 +238,7 @@ export function evaluateRepositoryReadiness(state) { workflows: state.workflows.length, actionSecrets: state.actionSecrets.length, productionEnvironmentSecrets: state.productionEnvironment?.secrets?.length ?? 0, + organizationActionSecrets: state.organizationActionSecrets?.length ?? 0, actionVariables: state.actionVariables.length, pagesUrl: state.pages.htmlUrl ?? null, actionsRetentionDays: state.actionsRetention?.days ?? null, @@ -292,6 +309,8 @@ export function collectRepositoryReadinessState(repositoryName) { ) const runResponse = runGhJson(['api', `${apiRoot}/actions/runs?per_page=100`], 'Actions 运行记录') const repositorySettings = runGhJson(['api', apiRoot], '仓库安全设置') + const organizationOwner = + repositorySettings.owner?.type === 'Organization' ? repositorySettings.owner.login : null const actionsPermissions = runGhJson( ['api', `${apiRoot}/actions/permissions/workflow`], 'Actions 默认权限', @@ -326,6 +345,12 @@ export function collectRepositoryReadinessState(repositoryName) { ], `${productionEnvironmentName} Environment 分支策略`, ) + const organizationSecretsResponse = organizationOwner + ? runGhJson( + ['api', `orgs/${organizationOwner}/actions/secrets?per_page=100`], + '组织级 Actions Secret 名称', + ) + : null const workflows = (workflowResponse.workflows ?? []).map((workflow) => { const expected = expectedWorkflows.find((item) => item.name === workflow.name) let contentMatches = false @@ -388,6 +413,9 @@ export function collectRepositoryReadinessState(repositoryName) { type: policy.type, })), }, + organizationActionSecrets: (organizationSecretsResponse?.secrets ?? []) + .map((secret) => secret.name) + .filter(Boolean), actionsPermissions: { defaultWorkflowPermissions: actionsPermissions.default_workflow_permissions, canApprovePullRequestReviews: actionsPermissions.can_approve_pull_request_reviews, @@ -421,7 +449,7 @@ async function main() { console.log(`Repository readiness: ${report.summary.repository}`) console.log( - `Observed ${report.summary.workflows} workflows, ${report.summary.actionSecrets} repository Actions Secret names, ${report.summary.productionEnvironmentSecrets} ${productionEnvironmentName} Environment Secret names and ${report.summary.actionVariables} Actions variable names.`, + `Observed ${report.summary.workflows} workflows, ${report.summary.actionSecrets} repository Actions Secret names, ${report.summary.productionEnvironmentSecrets} ${productionEnvironmentName} Environment Secret names, ${report.summary.organizationActionSecrets} organization Actions Secret names and ${report.summary.actionVariables} Actions variable names.`, ) console.log(`Actions default retention: ${report.summary.actionsRetentionDays} days.`) if (report.summary.pagesUrl) console.log(`Pages: ${report.summary.pagesUrl}`) diff --git a/scripts/check-repository-readiness.test.ts b/scripts/check-repository-readiness.test.ts index 781a324..2af8b98 100644 --- a/scripts/check-repository-readiness.test.ts +++ b/scripts/check-repository-readiness.test.ts @@ -43,6 +43,7 @@ function createReadyState() { url: `https://github.test/actions/${workflow.name}`, })), actionSecrets: [...requiredActionSecrets], + organizationActionSecrets: [], productionEnvironment: { name: 'production-operations', secrets: [...requiredProductionEnvironmentSecrets], @@ -125,6 +126,7 @@ describe('repository readiness checker', () => { workflows: 5, actionSecrets: 4, productionEnvironmentSecrets: 2, + organizationActionSecrets: 0, actionVariables: 3, actionsRetentionDays: 14, defaultBranchSha: '0123456789abcdef', @@ -179,6 +181,19 @@ describe('repository readiness checker', () => { ) }) + it('rejects production credentials copied to repository or organization secrets', () => { + const state = createReadyState() + state.actionSecrets.push('SUPABASE_PROJECT_REF') + state.organizationActionSecrets.push('SUPABASE_SERVICE_ROLE_KEY') + + expect(evaluateRepositoryReadiness(state).errors).toEqual( + expect.arrayContaining([ + 'SUPABASE_PROJECT_REF 不得存在仓库 Actions Secret;请只配置在 production-operations Environment。', + 'SUPABASE_SERVICE_ROLE_KEY 不得存在组织级 Actions Secret;请只配置在 production-operations Environment。', + ]), + ) + }) + it('rejects incomplete default-branch protection', () => { const state = createReadyState() state.rulesets[0].rules = state.rulesets[0].rules.filter( From 1d89a17f79d8faebe7a2a0d428a688038044d82c Mon Sep 17 00:00:00 2001 From: greenthree <1395214327@qq.com> Date: Sun, 9 Aug 2026 16:47:54 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=81=97=E7=95=99?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=85=B3=E9=97=AD=E6=80=81=E4=B8=8E=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=96=B9=E8=AE=B8=E5=8F=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PRIVACY.md | 10 +-- ROADMAP.md | 22 ++--- THIRD_PARTY_NOTICES.md | 4 +- docs/operations-runbook.md | 10 +-- docs/release-checklist.md | 2 +- docs/third-party-data-sources.md | 2 +- docs/webchat-relay-compatibility.md | 130 ++++++---------------------- public/fonts/noto-serif-sc/OFL.txt | 92 ++++++++++++++++++++ src/pages/PrivacyPage.test.tsx | 16 ++-- src/pages/PrivacyPage.tsx | 17 ++-- 10 files changed, 161 insertions(+), 144 deletions(-) create mode 100644 public/fonts/noto-serif-sc/OFL.txt diff --git a/PRIVACY.md b/PRIVACY.md index b256369..355d290 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # USTS ACM Land 隐私说明 -更新日期:2026 年 8 月 2 日 +更新日期:2026 年 8 月 9 日 ## 收集的数据 @@ -25,13 +25,13 @@ GitHub Pages 托管静态前端,Supabase 提供认证、数据库和服务端函数。同步时会使用成员填写的平台账号查询 Codeforces、牛客、AtCoder、AtCoder Problems、XCPC ELO、洛谷和 QOJ;牛客反自动化回退查询和 QOJ 临时登录使用 Firecrawl。成员填写或修改 QQ 后,只有服务端会把 QQ 号发送给腾讯 QQ 头像接口以获取头像;浏览器不会取得该请求地址,本站只在私有存储中保留规范化 WebP 头像和不可逆来源摘要。QOJ 登录会把专用服务账号和目标用户名发送给 Firecrawl,但不会发送成员邮箱、QQ 或本站密码;本站创建临时浏览器时显式关闭会话录制和直播,不使用持久 profile,并在每次 attempt 后主动删除会话。完整字段、认证方式和保留边界见[第三方数据来源说明](./docs/third-party-data-sources.md)。各服务对请求数据的处理同时受其自身政策和生产配置约束。 -## AI 学习助手 +## 遗留 AI 学习助手数据 -AI 学习助手仅向管理员单独授权的成员开放。成员提交的问题、当前会话中一并发送的可见上下文,以及本站固定的学习助手指令,会由服务端转发给管理员配置的中转站及其上游模型。浏览器不会获得中转站 Base URL、API Key 或服务端指令,也不能自行选择模型或覆盖这些配置。 +AI 学习助手已经退出当前产品范围。生产入口、成员授权、消息请求和图片输入均保持关闭,本站不再把新的成员问题、会话上下文或固定学习助手指令发送给中转站及其上游模型。历史运行期间,该功能只向管理员单独授权的成员开放;浏览器不会获得当时配置的中转站 Base URL、API Key 或服务端指令,也不能自行选择模型或覆盖这些配置。 -为支持刷新恢复和历史会话,本站会在 Supabase 私有数据库中保存会话标题、成员问题和模型可见回复。每个账号最多保留 100 个会话,单个会话最多 120 条消息且序列化内容不超过 1 MiB;会话最后活动超过 180 天后自动删除,成员也可以随时删除单个会话。历史读取、修改和删除接口只绑定当前登录账号,不接受目标成员 ID;管理员默认也不能读取其他成员的对话正文。永久注销账号时,全部会话和消息随账号级联删除。 +历史运行期间形成的会话标题、成员问题和模型可见回复仍保存在 Supabase 私有数据库中,并继续受原有保留和删除规则约束。每个账号最多保留 100 个会话,单个会话最多 120 条消息且序列化内容不超过 1 MiB;会话最后活动超过 180 天后自动删除,成员也可以随时删除单个会话。历史读取、修改和删除接口只绑定当前登录账号,不接受目标成员 ID;管理员默认也不能读取其他成员的对话正文。永久注销账号时,全部会话和消息随账号级联删除。 -额度、并发控制和故障诊断使用另一套私有账本,保存成员 UUID、随机请求标识、不可逆内容指纹、请求状态、时间及聚合 Token 数。额度账本本身不保存可还原的对话正文。中转站和上游模型对请求内容的留存、训练使用、删除与跨境处理,仍受管理员最终选择的服务和该服务政策约束。请勿向学习助手发送密码、Cookie、Token、API Key、个人隐私、未公开比赛题目或其他不应交给第三方处理的内容。 +遗留额度、并发控制和故障诊断账本保存成员 UUID、随机请求标识、不可逆内容指纹、请求状态、时间及聚合 Token 数,不保存可还原的对话正文。历史请求内容在中转站和上游模型处的留存、训练使用、删除与跨境处理,受当时所选服务及其政策约束;功能关闭后不再产生新的模型请求。 ## 导出个人数据 diff --git a/ROADMAP.md b/ROADMAP.md index 3c014fc..fb89e03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,7 +84,7 @@ USTSACMLand 的定位是苏州科技大学 ACM 集训队官网,当前产品范 ### P2:发布治理 -- [x] 项目负责人已选择 Apache License 2.0(SPDX 标识符:`Apache-2.0`),维护 Agent 已加入根目录 `LICENSE`。该许可证仅覆盖项目原创源代码;学校、集训队、赛事标识、第三方素材、成员数据和平台数据仍保持独立授权边界。 +- [x] 项目负责人已选择 Apache License 2.0(SPDX 标识符:`Apache-2.0`),维护 Agent 已加入根目录 `LICENSE`。该许可证覆盖项目原创源代码,以及未附带其他授权声明的原创文档和配置;学校、集训队、赛事标识、第三方素材、成员数据和平台数据仍保持独立授权边界。 - [x] 确认同步巡检、数据库备份、凭据轮换、回滚和管理员操作可以由全新上下文 Agent 仅根据仓库文档冷启动执行。2026-08-09 全新上下文 Agent 完整阅读根契约与专项手册,在不触发生产写操作的前提下完成 GitHub/Supabase 冷启动核对、同步队列巡检、备份连续性检查及前端、Edge Function、数据库和 Cloudflare 回滚桌面推演;同时识别并修复仓库级 Secret 与 `production-operations` Environment Secret 的检查口径偏差。证据见 [`docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md`](./docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md)。 - [ ] 按 `docs/release-checklist.md` 完成最终检查并创建 `v1.0.0` 标签。2026-07-29 已通过仓库就绪检查、全仓库 ESLint、七项工作流结构门禁、93 个 Vitest 文件共 596 项测试、10 个函数入口 Deno 类型检查、136 个文件 Deno Lint、462 项 Edge Function 测试、生产构建和 Cloudflare 指纹资源长期缓存门禁;Supabase 已关闭邮箱自动确认并启用服务端 Turnstile CAPTCHA,preflight 与严格就绪检查均通过。全新真实邮箱的有效 token 注册、确认邮件、确认前登录拒绝、重复确认安全性、确认后密码登录和带独立 Turnstile 的自助注销已经完成;注销后的 Auth、Profile 及 19 类关联数据均为 0,再次登录返回标准凭据拒绝。两组各不超过 30 次的生产限流检查均安全停止但未触发 `429`。2026-08-04 又将登录/注册限额临时从 `30` 降至 `2`,等待 30 秒并严格限制为三次请求后仍只得到 `400 / invalid_credentials`;这表明动态降阈值没有立即收缩当前 IP 的旧令牌桶余额,配置已恢复为 `30`,不能把结果记作恢复通过。冷启动交接验收已于 2026-08-09 完成;仍缺维护窗口内可复现的受控 `429` 窗口恢复、当前 `main` Schema v2 备份恢复演练,以及 Supabase 日志与 Firecrawl 保留窗口核验,因此暂不创建标签。证据见 [`docs/evidence/release-gates-2026-07-28.md`](./docs/evidence/release-gates-2026-07-28.md)、[`docs/evidence/registration-abuse-production-gap-2026-07-28.md`](./docs/evidence/registration-abuse-production-gap-2026-07-28.md)、[`docs/evidence/auth-rate-limit-recovery-production-2026-08-04.md`](./docs/evidence/auth-rate-limit-recovery-production-2026-08-04.md) 与 [`docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md`](./docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md)。 @@ -143,18 +143,18 @@ USTSACMLand 的定位是苏州科技大学 ACM 集训队官网,当前产品范 - 已停止:在管理后台提供推荐计划全局开关,管理员可一键开启或关闭,并清晰显示当前状态、最后修改时间和修改人;关闭和重新开启都需要二次确认,操作结果即时反馈。2026-07-26 已在正式后台只读确认关闭状态、配置版本、最后修改时间、修改人和原因,并打开开启方向的二次确认:变更原因与全站影响确认未完成前,确认按钮保持禁用;随后取消且未修改生产配置。关闭态页面、焦点和失败关闭边界仅作为历史证据保留,不再安排重开。证据见 [`docs/evidence/production-referral-global-switch-2026-07-23.md`](./docs/evidence/production-referral-global-switch-2026-07-23.md)。 - 已停止:全局开关必须由私有数据库配置和仅管理员可调用的 RPC 强制执行,并包含原因、乐观锁、原子限流和审计记录;注册触发器、邀请码校验、账号页摘要和奖励事务都读取同一状态,不能只隐藏前端入口。生产关闭/重开烟测和重开安全闸门均已完成;2026-07-26 的历史快照为 71 个 production migration,当前生产基线已更新为 75 个 migration。仓库当前 48 个 pgTAP 文件、1206 项断言已在干净数据库通过。证据见 [`docs/evidence/referral-concurrency-local-2026-07-26.md`](./docs/evidence/referral-concurrency-local-2026-07-26.md)。 -- 已停止:关闭后立即停止邀请码展示、公开校验、新绑定和新奖励,但不删除已有邀请码、绑定或撤回已发 Token;新用户仍可正常注册。关闭态、初始检查和状态查询失败时,注册页与成员账号页均不渲染推荐计划名称、邀请码或历史奖励摘要;重新开启后沿用原邀请码,不追补关闭期间完成的注册,也不改变已有奖励次数。成员端单元测试与本地桌面/移动浏览器烟测已通过,待生产页面验收。 -- 已停止:开关切换与并发注册使用数据库行锁或等价事务围栏:关闭事务提交后启动的注册不得绑定或计奖,已成功提交的绑定不能被回滚;重复点击和网络响应丢失必须可安全对账。数据库已统一 `profile -> config -> code -> access` 锁序并收紧同管理员/同原因的丢失响应识别;本地真实双连接已分别验证“确认先提交、关停后提交”“关停先提交、确认后提交”和丢失响应重放,并已纳入 GitHub `database-security` 必跑门禁,仍待受控生产双连接烟测。证据见 [`docs/evidence/referral-concurrency-local-2026-07-26.md`](./docs/evidence/referral-concurrency-local-2026-07-26.md)。 +- 已停止:关闭后立即停止邀请码展示、公开校验、新绑定和新奖励,但不删除已有邀请码、绑定或撤回已发 Token;新用户仍可正常注册。关闭态、初始检查和状态查询失败时,注册页与成员账号页均不渲染推荐计划名称、邀请码或历史奖励摘要;重新开启后沿用原邀请码,不追补关闭期间完成的注册,也不改变已有奖励次数。成员端单元测试与本地桌面/移动浏览器烟测已通过;原计划的生产页面验收已随项目停止而取消。 +- 已停止:开关切换与并发注册使用数据库行锁或等价事务围栏:关闭事务提交后启动的注册不得绑定或计奖,已成功提交的绑定不能被回滚;重复点击和网络响应丢失必须可安全对账。数据库已统一 `profile -> config -> code -> access` 锁序并收紧同管理员/同原因的丢失响应识别;本地真实双连接已分别验证“确认先提交、关停后提交”“关停先提交、确认后提交”和丢失响应重放,并已纳入 GitHub `database-security` 必跑门禁。原计划的受控生产双连接烟测已随项目停止而取消。证据见 [`docs/evidence/referral-concurrency-local-2026-07-26.md`](./docs/evidence/referral-concurrency-local-2026-07-26.md)。 - 已停止:补齐普通成员越权、停用管理员、最后管理员保护、并发开关/注册、审计脱敏、关闭状态页面、重新开启和生产回滚烟测后再上线;本地权限矩阵、审计白名单、关闭/重开和前端失败状态已覆盖。 -- 已停止:为每名已注册成员生成可分享的邀请码,并在账号页提供查看和复制入口;生产 migration 与真实邀请码读取已通过,仍待前端发布和正式页面烟测。邀请码不得暴露成员邮箱、QQ、数据库主键或其他私有标识。 -- 已停止:注册页增加可选邀请码字段;真实生产注册与邀请码绑定已通过,仍待前端发布后验证页面输入、分享链接预填和无邀请码注册。新用户可不填写邀请码正常注册,填写时必须验证邀请码存在且可用,并在注册成功后绑定到邀请码所属成员。 -- 已停止:每个新成员最多绑定一个邀请人,绑定成功后不可由普通用户改绑;唯一约束与行锁已实现,待生产并发验收。 -- 已停止:每次有效绑定为邀请人增加 `1,000,000` WebChat 累计 Token 额度上限,不修改其已使用 Token,也不绕过全站预算和其他服务端限额;旧流程的生产计奖烟测已完成,本地第 10 次奖励并发竞态确认额度只增加一次,邮箱确认后计奖的新流程仍待真实生产注册复测。 -- 已停止:只有邀请人的邮箱首次确认后才建立绑定并发放奖励;未验证邮箱不得占用十次名额或增加 Token 上限,重复确认必须幂等,确认时邀请码失效不得阻断账号确认。安全 migration 已部署并撤销仍未验证账号的历史奖励,本地 18 项专项断言通过,仍待真实邮件确认烟测和批量注册防护。 +- 已停止:为每名已注册成员生成可分享的邀请码,并在账号页提供查看和复制入口;生产 migration 与真实邀请码读取已通过。前端发布和正式页面烟测未执行,并已随项目停止而取消。邀请码不得暴露成员邮箱、QQ、数据库主键或其他私有标识。 +- 已停止:注册页增加可选邀请码字段;真实生产注册与邀请码绑定已通过。页面输入、分享链接预填和无邀请码注册的发布后验证未执行,并已随项目停止而取消。新用户可不填写邀请码正常注册,填写时必须验证邀请码存在且可用,并在注册成功后绑定到邀请码所属成员。 +- 已停止:每个新成员最多绑定一个邀请人,绑定成功后不可由普通用户改绑;唯一约束与行锁已实现,原计划的生产并发验收已取消。 +- 已停止:每次有效绑定为邀请人增加 `1,000,000` WebChat 累计 Token 额度上限,不修改其已使用 Token,也不绕过全站预算和其他服务端限额;旧流程的生产计奖烟测已完成,本地第 10 次奖励并发竞态确认额度只增加一次。邮箱确认后计奖的新流程未进行真实生产注册复测,并已随项目停止而取消。 +- 已停止:只有邀请人的邮箱首次确认后才建立绑定并发放奖励;未验证邮箱不得占用十次名额或增加 Token 上限,重复确认必须幂等,确认时邀请码失效不得阻断账号确认。安全 migration 已部署并撤销仍未验证账号的历史奖励,本地 18 项专项断言通过。真实邮件确认烟测和批量注册防护验收未执行,并已随项目停止而取消。 - 已停止:推荐计划当前已在生产全局暂停并保留已有邀请码、绑定和奖励。`202607230003_referral_reopen_safety_gate.sql` 已单独部署并通过迁移内真实拒绝自检,默认锁死未经受控运维解锁的重新开启;证据见 [`docs/evidence/production-referral-reopen-safety-gate-2026-07-23.md`](./docs/evidence/production-referral-reopen-safety-gate-2026-07-23.md)。项目已取消重开计划,不再补做邮箱计奖、注册风控或并发奖励验收。 -- 已停止:每名邀请人最多接受 10 次有效邀请绑定,即累计最多增加 `10,000,000` WebChat Token 额度上限;达到上限后邀请码不可再用于新用户注册绑定,也不得继续发放额度。本地从第 9 次开始的两个邮箱确认真实并发只产生第 10 次绑定、一次奖励和一次审计,仍待受控生产上限烟测。 -- 已停止:邀请绑定、奖励次数和额度变更必须在同一数据库事务内完成,使用唯一约束和幂等键防止重复计奖,并写入管理员可查询的审计记录。本地双连接关停围栏、第 10 次并发和响应丢失重放均保持精确账目,仍待生产复验。 -- 已停止:已明确停用、注销和重新注册时的奖励规则,并补齐滥用防护、RLS、并发、额度边界、注册回滚和个人导出测试;自动化测试、生产部署、真实绑定、被邀请人注销后奖励保留以及邀请人注销清理已通过;本地双连接与第 10 次上限已补齐,仍待受控生产并发与十次上限验收。 +- 已停止:每名邀请人最多接受 10 次有效邀请绑定,即累计最多增加 `10,000,000` WebChat Token 额度上限;达到上限后邀请码不可再用于新用户注册绑定,也不得继续发放额度。本地从第 9 次开始的两个邮箱确认真实并发只产生第 10 次绑定、一次奖励和一次审计;原计划的受控生产上限烟测已取消。 +- 已停止:邀请绑定、奖励次数和额度变更必须在同一数据库事务内完成,使用唯一约束和幂等键防止重复计奖,并写入管理员可查询的审计记录。本地双连接关停围栏、第 10 次并发和响应丢失重放均保持精确账目;原计划的生产复验已取消。 +- 已停止:已明确停用、注销和重新注册时的奖励规则,并补齐滥用防护、RLS、并发、额度边界、注册回滚和个人导出测试;自动化测试、生产部署、真实绑定、被邀请人注销后奖励保留以及邀请人注销清理已通过,本地双连接与第 10 次上限已补齐。原计划的受控生产并发与十次上限验收已取消。 ## 8. 完成定义 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1776df3..fafcdb2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,13 +1,13 @@ # 第三方素材与商标说明 -根目录 [`LICENSE`](./LICENSE) 只授予项目原创内容的 Apache License 2.0 权利。下列文件包含第三方字体、品牌标识或需要独立确认的视觉素材,不因根目录许可证获得额外授权。 +根目录 [`LICENSE`](./LICENSE) 授予项目原创源代码,以及未附带其他授权声明的原创文档和配置的 Apache License 2.0 权利。下列文件包含第三方字体、品牌标识或需要独立确认的视觉素材,不因根目录许可证获得额外授权。 ## Noto Serif SC `public/fonts/noto-serif-sc/` 中的 Noto Serif SC 字体分片及其 CSS 派生文件依据 SIL Open Font License 1.1 使用。 - 上游项目:[Noto CJK](https://github.com/notofonts/noto-cjk) -- 字体许可证:[SIL Open Font License 1.1](https://scripts.sil.org/OFL) +- 随字体分发的许可证:[SIL Open Font License 1.1](./public/fonts/noto-serif-sc/OFL.txt) - 字体名称和保留名称限制以其上游许可证为准。 ## 品牌标识与图片 diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index a554eb5..1bd9ab0 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -28,7 +28,7 @@ 数据库备份的文件范围、加密参数、Secret 配置和隔离恢复步骤见 [数据库备份与恢复方案](./backup-and-recovery.md)。当前 Artifact 同时包含数据库快照和该快照精确引用的私有 `webchat-images` 对象,但不包含其他 Storage Bucket、Edge Function Secrets 或第三方凭据。Storage 失败时整个任务失败,不得发布数据库-only 的部分 Artifact。 -WebChat 图片数据库 migration、`webchat-attachment` 与 `webchat-image-cleanup` 已在默认关闭状态部署。2026-07-25 的受控空队列与真实私有对象清理烟测均通过,确认本人历史恢复、跨成员/直接 Storage 拒绝、短时签名预览、零重试、零死信、对象删除和 Storage 对账一致。仓库变量 `WEBCHAT_IMAGE_CLEANUP_ENABLED` 仍必须保持缺失或为 `false`;只有图片入口正式开放并完成失败、过期和孤儿对象验收后,才可将该变量设为 `true`。回滚图片功能时先关闭前端、视觉模型和定时清理开关,再处理函数或数据库兼容变更;不得直接回滚已被备份与注销流程引用的表结构。 +WebChat 图片数据库 migration、`webchat-attachment` 与 `webchat-image-cleanup` 已在默认关闭状态部署。2026-07-25 的受控空队列与真实私有对象清理烟测均通过,确认本人历史恢复、跨成员/直接 Storage 拒绝、短时签名预览、零重试、零死信、对象删除和 Storage 对账一致。WebChat 已退出产品范围,仓库变量 `WEBCHAT_IMAGE_CLEANUP_ENABLED` 必须保持缺失或为 `false`,不得依据历史图片验收将其设为 `true`。关闭态维护时先确认前端、视觉模型和定时清理开关均关闭,再处理函数或数据库兼容变更;不得直接回滚已被备份与注销流程引用的表结构。未来只有项目负责人重新立项并重新完成产品、隐私、安全和运维授权后,才能另行制定启用流程。 视觉能力使用 `CHAT_VISION_ENABLED` 与 `CHAT_VISION_MODEL` 双重服务端门禁。后者必须与 管理员后台当前运行时模型完全一致;更换模型会让图片请求立即返回 @@ -288,10 +288,10 @@ select public.bootstrap_first_admin('new-admin@example.edu.cn'); - 数据过期规则按最近计划批次计算,没有把失败值写成 0。 - 变更记录包含验证证据、遗留风险和下一次 Agent 冷启动所需上下文。 -### 推荐计划全线开关 +### 推荐计划关闭态维护 -推荐计划异常时,从后台概览的“推荐计划”区域发起关闭,填写可审计原因并二次确认。关闭只停止邀请码展示、校验、新绑定和新奖励,不删除邀请码、绑定或已发 Token;关闭期间新账号仍可注册。操作后同时核对面板版本、修改人和原因,以及审计页的“关闭推荐计划”记录。恢复前确认异常已处理,再通过同一区域重新开启;不得为关闭期间注册的账号手工追补绑定或奖励。 +推荐计划已退出产品范围,生产全局开关与重开安全闸门必须保持关闭。管理员后台只用于查看关闭状态、版本、最后修改人和原因;正常维护不得重新开启、发放奖励或运行生产并发计奖烟测。关闭状态下不得展示邀请码、接受新绑定或发放新奖励,已有邀请码、绑定和奖励数据继续保留。 -若提交结果因网络中断不确定,先刷新面板对账,不要连续点击。只有状态、版本和原因均符合本次操作时才视为成功;否则按最新版本重新确认。数据库以 `profile -> config -> code -> access` 行锁顺序围栏并发注册,生产发布仍需用受控双连接烟测确认关闭提交后的新注册不计奖。 +若只读检查意外发现功能已开启,将其作为生产事件处理:从后台概览填写可审计原因并二次确认关闭,随后核对面板状态、版本、修改人、原因和审计记录。若提交结果因网络中断不确定,先刷新面板只读对账,不要连续点击;关闭后继续验证注册和成员页面没有推荐入口、邀请码校验或奖励行为。 -若无法证明恢复正常,保持事件开放并停止扩大变更范围。 +不得依据本手册重新开启推荐计划。未来只有项目负责人明确重新立项,并重新完成产品、滥用防护、隐私、安全、生产验收和变更授权后,才能另行制定启用流程。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index e13e513..81a737e 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -120,7 +120,7 @@ - [ ] 恢复工具拒绝早于当前注销恢复下限的备份,并拒绝仓库变量回退到备份 metadata 之前。 - [ ] 已按 [数据库备份与恢复方案](./backup-and-recovery.md) 使用当前 `main` 新生成的 Schema v2 真实 Artifact 运行手动 `Encrypted database restore drill`;演练完成来源/恢复下限、解密、动态归档白名单、单事务数据库恢复、8 项行数、7 类孤儿、私有 Bucket 重建、匿名访问拒绝、数据库引用与对象字节/哈希比对、3 个 Auth hooks、注册建档、密码登录、RLS、受控注销和明文/对象清理核对。旧 run `29656219433` 只覆盖 database-only 格式,可作为历史基线但不能替代本项。 - [ ] 已确认学校、集训队、ICPC 等名称和图形标识的使用授权范围。 -- [x] 项目负责人已选择 Apache License 2.0(SPDX 标识符:`Apache-2.0`),维护 Agent 已加入 `LICENSE`。该许可证仅覆盖项目原创源代码;学校、集训队、赛事标识、第三方素材、成员数据和平台数据仍保持独立授权边界。 +- [x] 项目负责人已选择 Apache License 2.0(SPDX 标识符:`Apache-2.0`),维护 Agent 已加入 `LICENSE`。该许可证覆盖项目原创源代码,以及未附带其他授权声明的原创文档和配置;学校、集训队、赛事标识、第三方素材、成员数据和平台数据仍保持独立授权边界。 - [ ] 真实队员已小范围核对姓名、专业、年级、平台绑定和统计值。 - [ ] 已观察至少一个完整日更批次;涉及 XCPC ELO/QOJ 时观察到下一个周二批次。 - [ ] 所有阻塞问题已关闭,遗留非阻塞风险有负责人和后续日期。 diff --git a/docs/third-party-data-sources.md b/docs/third-party-data-sources.md index 462bc4f..c954e66 100644 --- a/docs/third-party-data-sources.md +++ b/docs/third-party-data-sources.md @@ -38,7 +38,7 @@ USTS ACM Land 使用成员提供的平台标识查询公开竞赛数据,并通 普通成员永久注销后,本站删除其平台绑定、当前统计、历史快照和同步记录,并按 [账号与数据生命周期](./data-lifecycle.md) 匿名化相关审计信息。该操作不会删除成员在第三方平台上的公开账号或比赛记录,也不会要求 XCPC ELO 从其公开数据集中删除同名选手。 -Supabase 备份与日志、GitHub Actions 日志和 Firecrawl 作业记录可能按各自生产配置或服务政策继续保留。本站不能改写已经生成的供应商备份;实际发布前必须记录生产保留窗口,并选择“最小加密注销台账”或“禁止恢复到注销前”的可执行恢复策略。策略未选择和演练前不得恢复旧备份或正式发布。 +Supabase 备份与日志、GitHub Actions 日志和 Firecrawl 作业记录可能按各自生产配置或服务政策继续保留。本站不能改写已经生成的供应商备份;项目已选择“禁止恢复到注销前”的策略,并在每次受控注销前更新仓库恢复下限。当前正式发布仍受生产保留窗口核验和 Schema v2 隔离恢复演练阻塞;完成前不得正式发布,任何恢复都必须拒绝早于当前恢复下限的备份。 ## 来源变更 diff --git a/docs/webchat-relay-compatibility.md b/docs/webchat-relay-compatibility.md index b2d609a..f442c61 100644 --- a/docs/webchat-relay-compatibility.md +++ b/docs/webchat-relay-compatibility.md @@ -1,127 +1,47 @@ -# WebChat 中转站兼容性验收 +# WebChat 中转站兼容性历史记录 -本文定义 USTS ACM Land 在配置或更换 AI 中转站时必须执行的真实协议验收。该验收会产生少量模型费用,但不会部署 Edge Function、开启 WebChat 或把验收 Prompt/回复写入本站历史会话表。 +> 归档边界:WebChat / AI 学习助手已退出当前产品范围。本文件只保存既有协议设计与历史验收结论,不是启用、换站或生产烟测手册。正常维护不得运行付费中转站工作流或缓存探针,不得新增、读取或轮换中转站凭据,也不得把任何 WebChat 开关设为 `true`。未来只有项目负责人重新立项后,才能另行编写新的产品、隐私、安全与启用方案。 -## 协议基线 +## 历史协议基线 -WebChat Edge Function 使用 OpenAI Responses HTTP API。官方文档要求流式响应使用 typed server-sent events;文字流至少关注: +遗留 WebChat Edge Function 使用 OpenAI Responses HTTP API。历史流式消费者按 typed server-sent events 处理: - `response.created` - `response.output_text.delta` - `response.completed` - `error` -最终 Token Usage 位于 `response.completed.response.usage`。生产请求显式发送 `tools: []` 与 `tool_choice: none`;本项目同时接受可见的 `response.refusal.delta`,但拒绝任何工具事件、伪装成正文的 `TOOLCALL` 协议、缺少终态 Usage、畸形 JSON、非 SSE Content-Type、半途断流或未知终态。模型正文先在 Edge Function 内完整缓冲,只有通过协议检查且成功结算后才一次性下发,最多保留 `CHAT_MAX_OUTPUT_CHARS` 个 Unicode 字符,因此异常中转站的半截搜索过程不会进入浏览器或历史会话。 +最终 Token Usage 位于 `response.completed.response.usage`。生产实现固定发送 `tools: []` 与 `tool_choice: none`,接受可见的 `response.refusal.delta`,并拒绝工具事件、伪装成正文的 `TOOLCALL`、缺少终态 Usage、畸形 JSON、非 SSE Content-Type、半途断流和未知终态。模型正文在 Edge Function 内完整缓冲,只有协议检查和额度结算成功后才一次性返回,因此异常上游的半截输出不会进入浏览器或历史会话。 -每次生产请求还携带由“模型 + 系统提示词版本”派生的 64 位 SHA-256 `prompt_cache_key`,同一配置使用稳定键,不包含成员身份或对话正文。OpenAI Prompt Caching 只会对至少 1024 个输入 Token 的精确重复前缀产生命中;因此短对话显示 `cached_tokens = 0` 属于正常行为。稳定键用于提高同一前缀被路由到同一缓存的概率,不会绕过 1024 Token 门槛,也不能保证第三方中转站真实透传或计费。更换模型或 `CHAT_SYSTEM_PROMPT_VERSION` 会自动更换键,避免不同提示版本混用。 +历史请求使用由“模型 + 系统提示词版本”派生的 64 位 SHA-256 `prompt_cache_key`。该键不包含成员身份或对话正文,更换模型或 `CHAT_SYSTEM_PROMPT_VERSION` 会改变键。Prompt Caching 仍受至少 1,024 个输入 Token 的精确重复前缀门槛约束,短对话的 `cached_tokens = 0` 不代表故障。 -2026-07-19 的两次生产兼容性试验分别验证了“typed `input_text` + `prompt_cache_breakpoint` + 默认模式”和同一 typed 形状加 `prompt_cache_options.mode=explicit`,当前中转站均在首个请求返回 HTTP 400。失败发生在追加轮之前,因此不能把问题归因于第二轮历史结构;当前可确认的共同不兼容边界是 typed 内容块级显式断点形状。真实成员会话和探针因此使用普通 `role/content` 消息且不发送 `prompt_cache_breakpoint`。流式默认策略两轮缓存均为 0,显式声明请求级 `mode=implicit`、`ttl=30m` 后,独立探针键和生产键的追加轮均命中 1,792 Token。真实 WebChat 只为直接 GPT-5.6+ 模型 ID 启用该策略,旧模型与自定义代理模型保持原请求形状;同一真实成员长会话的第二次追加请求命中 6,912 Token。追加下一轮消息时,系统指令和早期消息对象保持字节不变。 +## 历史安全边界 -参考: +完整协议验收器曾从受控环境读取 `CHAT_RELAY_BASE_URL`、`CHAT_RELAY_API_KEY` 和 `CHAT_RELAY_MODEL`。真实值不得写入 `.env.example`、命令参数、PR、Issue、截图、聊天、日志或 Artifact。历史报告只保留事件类型、时延、聚合 Usage、缓存命中和 Abort 结果,不保存 Prompt、模型回复、请求 ID、API Key、明文主机、Supabase 身份或响应原文。 -- [Streaming API responses](https://developers.openai.com/api/docs/guides/streaming-responses?api-mode=responses) -- [Migrate to Responses:更新流式消费者](https://developers.openai.com/api/docs/guides/migrate-to-responses#7-update-streaming-consumers) -- [Create a model response](https://developers.openai.com/api/reference/resources/responses/methods/create) -- [Prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching) +`webchat-cache-probe` 使用 `production-operations` Environment 中的 `SUPABASE_PROJECT_REF` 与 `SUPABASE_SERVICE_ROLE_KEY` 调用服务端函数,再由 Supabase 内部读取 Vault。中转站 Base URL、模型和 Key 不复制到 GitHub 仓库级或组织级 Secret。探针拒绝浏览器 Origin、要求 service role 身份、固定计入两次全站请求、30 分钟内最多执行一次且不自动重试。 -## 安全边界 +这些边界继续作为遗留代码的安全回归约束,但不授权再次连接模型或产生费用。 -完整中转站协议验收器只从环境变量读取: +## 历史验收结论 -- `CHAT_RELAY_BASE_URL`:不含凭据、查询参数或 fragment 的 HTTPS 基地址;脚本自动追加 `/responses`。 -- `CHAT_RELAY_API_KEY`:专用中转站 Key。 -- `CHAT_RELAY_MODEL`:中转站实际接受的 Responses 模型 ID。 +- 2026-07-18 的生产 run [`29650242439`](https://github.com/greenthree/USTSACMLand/actions/runs/29650242439) 曾完成一次无重试缓存复核:首个请求输入 2,335 Token、缓存命中 0,第二个相同请求命中 1,792 个输入 Token。脱敏记录见[生产命中证据](./evidence/webchat-input-cache-production-smoke-2026-07-18.md)。 +- 2026-07-19 的两次兼容性试验确认 typed `input_text` 内容块级 `prompt_cache_breakpoint` 形状会在首个请求收到 HTTP 400。后续历史实现改用普通 `role/content` 消息,并只为直接 GPT-5.6+ 模型 ID 使用已验证的请求级隐式缓存策略。 +- 历史真实成员长会话的追加请求曾命中 6,912 个缓存输入 Token;渠道差异诊断见 `docs/evidence/webchat-cache-route-diagnostics-2026-07-19.md`。 -禁止把三项真实值写入 `.env.example`、命令参数、PR、Issue、截图或聊天。完整协议工作流只允许 `workflow_dispatch` 手动触发,不监听 `push`、`pull_request`、`schedule` 或 `workflow_run`,避免无意产生费用。默认执行 Abort 和 Prompt Caching 检查;只有排查供应商故障时才可在手动表单中临时关闭,关闭后的结果不能作为完整上线证据。 +这些结果只描述当时的中转站、模型和实现,不能证明当前或未来供应商兼容,也不得作为重新启用的依据。 -生产缓存复核使用独立的 `webchat-cache-probe` Edge Function。GitHub 只提供现有 `SUPABASE_PROJECT_REF` 和 `SUPABASE_SERVICE_ROLE_KEY`,函数在 Supabase 内部通过 service-role-only RPC 读取 Vault 中已经保存的 Base URL、模型和 API Key;三项中转站配置不再复制到 GitHub。探针使用与真实成员请求相同的“模型 + `CHAT_SYSTEM_PROMPT_VERSION`”缓存路由键,但 Prompt 始终是固定合成文本,因此不会读取、复用或暴露成员对话。函数拒绝浏览器 `Origin`,Gateway 先验证 Bearer JWT,函数内再要求它与当前运行时 service role Key 一致,或其已验证 Payload 明确包含 `role=service_role`,从而兼容项目轮换后仍有效的 service-role JWT。一次探针固定计入全站两次请求,Token 则按两次请求 JSON 的 UTF-8 字节、最大输出与协议余量动态保守预留;它不扣任何成员额度,不自动重试,30 分钟内只允许一次。开始前失败会回滚请求数和 Token 预留,开始后 Usage 缺失或异常超过预留则按预留上限计入未知用量并让探针失败。 +## 当前关闭状态 -完整协议验收报告只包含: +截至 2026-08-09,生产必须同时保持: -- 中转站主机的 SHA-256 和 `/responses` 路径。 -- 请求模型与实际返回模型。 -- 事件类型、增量次数、可见字符数、首增量/总时延。 -- 输入、输出和总 Token 数。 -- 缓存探针首、次请求的 `cached_tokens`、可用时的 `cache_write_tokens`,以及第二次实际复用的输入 Token 数。 -- Abort 后流的结束方式和耗时。 +- 数据库成员请求开关关闭; +- `CHAT_ENABLED=false`; +- `VITE_WEBCHAT_UI_ENABLED=false`; +- `VITE_WEBCHAT_IMAGE_INPUT_ENABLED=false`; +- `CHAT_VISION_ENABLED=false`; +- `WEBCHAT_IMAGE_CLEANUP_ENABLED` 缺失或为 `false`。 -生产缓存报告进一步只保留模型、两次请求的时延与聚合 Usage、`cached_tokens`、可用时的 `cache_write_tokens`、全站剩余额度和结算 Token;不保存中转站主机哈希或路径。两类报告都不包含 Prompt、模型回复、请求 ID、API Key、明文中转站主机、Supabase 身份或响应原文,Artifact 均在 14 天后删除。 +成员端不得显示入口、发起模型请求、上传图片或新增额度账目。管理员后台只保留关闭状态和历史账目的维护入口。既有私有会话、额度账本、图片元数据和 Storage 对象继续受 RLS、注销、备份与恢复下限约束。 -## 本地运行 - -在不进入 shell 历史的受控环境中注入三项变量,然后执行: - -```powershell -npm run check:webchat-relay-workflow -npm run check:webchat-relay -``` - -可选运行参数: - -```text -WEBCHAT_RELAY_TIMEOUT_MS=120000 -WEBCHAT_RELAY_ABORT_SETTLE_MS=2000 -WEBCHAT_RELAY_ABORT_CHECK=true -WEBCHAT_RELAY_CACHE_CHECK=true -WEBCHAT_RELAY_REPORT_PATH=artifacts/webchat-relay-compatibility.json -``` - -成功条件: - -1. 非流式 `/responses` 返回 JSON、可见文字、实际模型和内部一致的 Usage。 -2. Prompt Caching 探针先发送带长首轮用户消息的流式普通 Responses 请求,再发送保留同一首轮并追加助手/用户消息的第二轮请求;GPT-5.6 及以后官方模型使用已验证的请求级隐式缓存策略。首个请求的 `input_tokens >= 1024`,第二个请求的 `input_tokens_details.cached_tokens > 0`,否则任务以 `cache_probe_*` 错误失败。该形状验证真实流式追加会话,而不再只证明两个字节完全相同的非流式请求能够命中。 -3. 流式请求返回 `text/event-stream`,先出现 `response.created`,随后出现至少一个文字/拒绝增量,最终以带 Usage 的 `response.completed` 结束。 -4. Abort 检查在首个可见增量后主动中止长输出,客户端读取在 2 秒内以 Abort 或连接关闭结束。 -5. 脱敏 JSON 的 `status` 为 `passed`,且人工抽查不存在上述禁止字段。 - -普通短问答不能作为缓存失败证据。如果专用探针仍为 `cache_probe_miss`,说明中转站没有把同一 `prompt_cache_key` 与精确前缀稳定路由至支持缓存的上游,或没有回传真实 `cached_tokens`;应先在中转站确认 `/responses` 请求体字段透传和 Usage 映射,再考虑应用层改动。 - -真实成员验收不能由固定长前缀探针替代。部署真实请求缓存 migration 和 `webchat` 后,使用一个已授权测试账号完成至少三轮追加式对话,使早期稳定前缀超过 1024 Token;随后同时核对中转站日志与 `/admin/webchat` 的脱敏缓存摘要。至少一次后续请求应出现 `cached_tokens > 0`,后台的命中请求数和缓存输入 Token 应同步增长。摘要只统计请求数和 Token,不记录成员、会话、请求 ID、Prompt、回复、Base URL 或 Key;成员与全站额度仍按完整 `total_tokens` 结算。 - -“客户端连接关闭”只能证明中转站接受 HTTP Abort,不能单独证明供应商停止计费。首次生产启用前还应在中转站控制台对照该次测试的 Usage/请求状态;若供应商不能提供可核验状态,按保守额度结算并记录该限制。 - -## GitHub 手动工作流 - -### 完整协议与 Abort 验收 - -1. 在仓库 Actions Secrets 中添加 `CHAT_RELAY_BASE_URL`、`CHAT_RELAY_API_KEY`、`CHAT_RELAY_MODEL`。 -2. 打开 Actions → `WebChat relay compatibility` → Run workflow。 -3. 保持 `include_abort=true`、`include_cache_probe=true`,运行当前候选分支。 -4. 确认任务通过,下载 `webchat-relay-compatibility-` Artifact。 -5. 抽查报告结构和禁止字段;Artifact 14 天后自动删除。 - -该工作流不得加入 required status checks,也不得改为自动运行。它是有费用、需要当前生产中转站 Secret 的受控发布验收。 - -### 已配置生产环境的缓存复核 - -部署 `202607180006_webchat_cache_probe_accounting.sql` 和 `webchat-cache-probe` 后: - -1. 确认管理员后台保存的中转站配置完整、数据库请求开关开启,且全站当日还允许至少 2 次请求,并有足够 Token 余额容纳函数根据两次请求 JSON、最大输出与协议余量动态计算的保守预留。 -2. 打开 Actions → `WebChat production cache probe` → Run workflow。 -3. 工作流使用仓库中已经存在的 `SUPABASE_PROJECT_REF` 与 `SUPABASE_SERVICE_ROLE_KEY`;不要新增或复制 `CHAT_RELAY_*` Secret。 -4. 确认首个请求 `inputTokens >= 1024`,第二个请求 `cachedInputTokens > 0`,下载 `webchat-production-cache-probe-` Artifact。 -5. 若返回 `cooldown`,按 `retry-after` 等待;若返回 `cache_probe_miss`,到中转站核对 `prompt_cache_key`、精确请求前缀和 `input_tokens_details.cached_tokens` 的透传。工作流和函数都不会自动重试。 - -该探针只验证当前 Supabase Vault 配置实际走通后的缓存读写,不替代首次接入或更换中转站时的完整非流式、流式和 Abort 兼容性验收。 - -2026-07-18 的生产 run [`29650242439`](https://github.com/greenthree/USTSACMLand/actions/runs/29650242439) 已完成一次无重试复核:首个请求输入 2335 Token、缓存命中 0,第二个相同请求命中 1792 个输入 Token。当前中转站能够透传缓存键和重复前缀,并回传可验证的 `cached_tokens`;脱敏记录见[生产命中证据](./evidence/webchat-input-cache-production-smoke-2026-07-18.md)。 - -## 通过后的部署顺序 - -1. 保持 `VITE_WEBCHAT_UI_ENABLED=false`、Supabase `CHAT_ENABLED=false` 与数据库成员请求开关关闭。 -2. 应用 WebChat 配额与中转站配置 migration,并确认空库 pgTAP 与生产 migration 列表一致。 -3. 将精确 Origin、输出/请求上限、额度和租约写入 Supabase Function Secrets,部署 `webchat-config`、`webchat` 与 service-role-only `webchat-cache-probe`;保持 `CHAT_ENABLED=false`。 -4. 管理员在 `/admin/webchat` 写入已通过验收的 Base URL、模型和 API Key,设置全站北京时间每日请求/Token 预算,并继续保持成员请求关闭。确认页面只显示 Key 是否已配置、请求开关、预算、版本和更新时间,配置读取、审计与日志均不含 Key。 -5. 验证环境禁用态与数据库暂停态、CORS、匿名拒绝、无逐人授权拒绝、停用/非普通成员拒绝、非管理员配置拒绝、撤权/降额竞态、成员/全站原子额度、乐观锁冲突和有效成员认证。 -6. 在成员详情中按实际需要开启权限并设置逐人请求/Token 上限;核对成员端剩余额度后验证请求数、Token、Abort、超时、429 和重复请求结算。 -7. 负责人确认预算、隐私披露和关闭方式后,先开启后端 `CHAT_ENABLED=true`,再由管理员打开数据库成员请求开关;确认健康后,最后在下一次前端构建开启 `VITE_WEBCHAT_UI_ENABLED=true`。 - -出现协议变化、Usage 缺失、Abort 超时、重复计费、额度绕过或隐私问题时,先关闭数据库成员请求开关,并按需要恢复另外两层为 `false`。只隐藏前端入口不能替代关闭后端模型调用。 - -## 当前生产状态(2026-07-19) - -- 真实中转站的非流式、Responses typed SSE、Usage、Abort、模型标识和系统提示词均已完成受控生产验收。 -- 中转站 Base URL、模型和全站预算由私有数据库配置保存,Key 位于 Supabase Vault;浏览器与配置读取接口均不能读取明文 Key。 -- `webchat` v18、`webchat-cache-probe` v11、`CHAT_ENABLED=true` 与数据库请求开关已对显式授权账号启用;真实成员追加请求曾命中 6,912 个缓存输入 Token,后续中转站渠道差异诊断见 `docs/evidence/webchat-cache-route-diagnostics-2026-07-19.md`。 -- GitHub 仓库变量 `VITE_WEBCHAT_UI_ENABLED=true` 已随 Pages run `29594758865` attempt 2 发布;未登录直达 `/assistant` 仍进入登录页,只有登录且经后台授权的账号能获得模型调用。 -- 正式试运行观察名单与连续观察机制已取消;出现协议、额度、隐私或稳定性问题时按上文顺序关闭三层开关,并通过管理员用量面板和脱敏请求 ID 排查。 +关闭态维护只允许修复安全问题、Schema 兼容和数据保护回归。完成此类修改后,运行不访问中转站的本地/CI 测试、严格 Supabase readiness 与生产安全检查,并确认没有新请求或额度记录;不得运行历史付费工作流、缓存探针或真实模型调用。 diff --git a/public/fonts/noto-serif-sc/OFL.txt b/public/fonts/noto-serif-sc/OFL.txt new file mode 100644 index 0000000..d952d62 --- /dev/null +++ b/public/fonts/noto-serif-sc/OFL.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/pages/PrivacyPage.test.tsx b/src/pages/PrivacyPage.test.tsx index 0afee0a..f1ffe85 100644 --- a/src/pages/PrivacyPage.test.tsx +++ b/src/pages/PrivacyPage.test.tsx @@ -18,19 +18,25 @@ describe('PrivacyPage', () => { }) }) - it('discloses WebChat relay processing, private history retention, and quota metadata', async () => { + it('discloses the closed WebChat boundary, private history retention, and quota metadata', async () => { render() await screen.findByRole('heading', { name: '推荐计划' }) - const section = screen.getByRole('heading', { name: 'AI 学习助手' }).closest('section') + const section = screen.getByRole('heading', { name: '遗留 AI 学习助手数据' }).closest('section') expect(section).not.toBeNull() + expect(section).toHaveTextContent('已经退出当前产品范围') + expect(section).toHaveTextContent( + '不再把新的成员问题、会话上下文或固定学习助手指令发送给中转站及其上游模型', + ) expect(section).toHaveTextContent('中转站及其上游模型') - expect(section).toHaveTextContent('Supabase 私有数据库中保存会话标题、成员问题和模型可见回复') + expect(section).toHaveTextContent( + '会话标题、成员问题和模型可见回复仍保存在 Supabase 私有数据库中', + ) expect(section).toHaveTextContent('管理员默认也不能读取其他成员的对话正文') expect(section).toHaveTextContent('180 天后自动删除') expect(section).toHaveTextContent('不可逆内容指纹') - expect(section).toHaveTextContent('受管理员最终选择的服务和该服务政策约束') - expect(section).toHaveTextContent('持续核对真实中转站的数据政策') + expect(section).toHaveTextContent('受当时所选服务及其政策约束') + expect(section).toHaveTextContent('功能关闭后不再产生新的模型请求') }) it('keeps credentials and private member fields outside the public ranking disclosure', async () => { diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx index b41bd57..6ad7dde 100644 --- a/src/pages/PrivacyPage.tsx +++ b/src/pages/PrivacyPage.tsx @@ -42,7 +42,7 @@ export function PrivacyPage() {

USTS ACM Land

隐私说明

说明成员账号、公开榜单和第三方数据同步涉及的数据范围。

- 更新日期:2026 年 8 月 2 日 + 更新日期:2026 年 8 月 9 日
@@ -104,28 +104,27 @@ export function PrivacyPage() {
-

AI 学习助手

+

遗留 AI 学习助手数据

AI - 学习助手仅向管理员单独授权的成员开放。成员提交的问题、当前会话中一并发送的可见上下文,以及本站固定的学习助手指令,会由服务端转发给管理员配置的中转站及其上游模型。浏览器不会获得中转站 + 学习助手已经退出当前产品范围。生产入口、成员授权、消息请求和图片输入均保持关闭,本站不再把新的成员问题、会话上下文或固定学习助手指令发送给中转站及其上游模型。历史运行期间,该功能只向管理员单独授权的成员开放;浏览器不会获得当时配置的中转站 Base URL、API Key 或服务端指令,也不能自行选择模型或覆盖这些配置。

- 为支持刷新恢复和历史会话,本站会在 Supabase - 私有数据库中保存会话标题、成员问题和模型可见回复。每个账号最多保留 100 - 个会话,单个会话最多 120 条消息且序列化内容不超过 1 MiB;会话最后活动超过 180 + 历史运行期间形成的会话标题、成员问题和模型可见回复仍保存在 Supabase + 私有数据库中,并继续受原有保留和删除规则约束。每个账号最多保留 100 个会话,单个会话最多 + 120 条消息且序列化内容不超过 1 MiB;会话最后活动超过 180 天后自动删除,成员也可以随时删除单个会话。历史读取、修改和删除接口只绑定当前登录账号,不接受目标成员 ID;管理员默认也不能读取其他成员的对话正文。永久注销账号时,全部会话和消息随账号级联删除。

- 为执行限额、并发控制和故障诊断,另一套私有账本会保存成员 + 遗留额度、并发控制和故障诊断账本保存成员 UUID、随机请求标识、不可逆内容指纹、请求状态、时间及聚合 Token 数。额度账本本身不保存可还原的对话正文。普通成员只能读取自己的累计请求数、已结算与预留 Token、累计总限额和剩余额度;成员额度不会每日重置。

- 中转站和上游模型对请求内容的留存、训练使用、删除与跨境处理,仍受管理员最终选择的服务和该服务政策约束。维护者必须持续核对真实中转站的数据政策并在配置变化时更新本说明。请勿向学习助手发送密码、Cookie、Token、API - Key、个人隐私、未公开比赛题目或其他不应交给第三方处理的内容。 + 历史请求内容在中转站和上游模型处的留存、训练使用、删除与跨境处理,受当时所选服务及其政策约束;功能关闭后不再产生新的模型请求。

From 395959f104cbe0e9bacc3d1b45779a59ec22bd92 Mon Sep 17 00:00:00 2001 From: greenthree <1395214327@qq.com> Date: Mon, 10 Aug 2026 14:17:58 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E9=81=97=E7=95=99?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=85=B3=E9=97=AD=E6=80=81=E4=B8=8E=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PRIVACY.md | 2 +- README.md | 20 +- ROADMAP.md | 34 +- THIRD_PARTY_NOTICES.md | 14 +- docs/backup-and-recovery.md | 6 +- docs/data-lifecycle.md | 4 +- docs/operations-runbook.md | 49 +- docs/release-checklist.md | 6 +- docs/webchat-conversation-history.md | 38 +- docs/webchat-image-input-v1.md | 76 +-- package.json | 4 +- scripts/check-ci-workflow.test.ts | 4 +- scripts/check-repository-readiness.mjs | 43 +- scripts/check-repository-readiness.test.ts | 30 + scripts/generate-third-party-licenses.mjs | 529 ++++++++++++++++++ scripts/generate-third-party-licenses.test.ts | 392 +++++++++++++ .../third-party-license-materials/README.md | 6 + .../ai-sdk-provider-utils-5.0.10-LICENSE.txt | 13 + .../use-composed-ref-1.4.0-LICENSE.txt | 23 + .../admin/AdminWebChatPilotPanel.test.tsx | 8 +- .../admin/AdminWebChatPilotPanel.tsx | 8 +- src/components/admin/admin.css | 17 + src/pages/PrivacyPage.test.tsx | 4 + src/pages/PrivacyPage.tsx | 4 +- .../admin/AdminMemberDetailPage.test.tsx | 81 +-- src/pages/admin/AdminMemberDetailPage.tsx | 195 ++----- src/pages/admin/AdminWebChatPage.test.tsx | 194 +------ src/pages/admin/AdminWebChatPage.tsx | 378 ++----------- src/styles.css | 12 + supabase/functions/webchat-config/handler.ts | 31 +- .../functions/webchat-config/handler_test.ts | 102 ++-- .../202608090001_retire_webchat_mutations.sql | 30 + ...15_admin_rpc_authorization_matrix.test.sql | 14 +- .../20_webchat_relay_admin_config.test.sql | 9 +- .../tests/22_webchat_member_access.test.sql | 9 +- .../tests/23_webchat_admin_access.test.sql | 9 +- .../28_webchat_total_member_quotas.test.sql | 5 + .../30_webchat_conversation_history.test.sql | 30 +- .../35_webchat_observation_retired.test.sql | 4 +- .../42_webchat_image_attachments.test.sql | 4 + .../51_webchat_retired_mutations.test.sql | 60 ++ 41 files changed, 1557 insertions(+), 944 deletions(-) create mode 100644 scripts/generate-third-party-licenses.mjs create mode 100644 scripts/generate-third-party-licenses.test.ts create mode 100644 scripts/third-party-license-materials/README.md create mode 100644 scripts/third-party-license-materials/ai-sdk-provider-utils-5.0.10-LICENSE.txt create mode 100644 scripts/third-party-license-materials/use-composed-ref-1.4.0-LICENSE.txt create mode 100644 supabase/migrations/202608090001_retire_webchat_mutations.sql create mode 100644 supabase/tests/51_webchat_retired_mutations.test.sql diff --git a/PRIVACY.md b/PRIVACY.md index 355d290..6b35797 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -29,7 +29,7 @@ GitHub Pages 托管静态前端,Supabase 提供认证、数据库和服务端 AI 学习助手已经退出当前产品范围。生产入口、成员授权、消息请求和图片输入均保持关闭,本站不再把新的成员问题、会话上下文或固定学习助手指令发送给中转站及其上游模型。历史运行期间,该功能只向管理员单独授权的成员开放;浏览器不会获得当时配置的中转站 Base URL、API Key 或服务端指令,也不能自行选择模型或覆盖这些配置。 -历史运行期间形成的会话标题、成员问题和模型可见回复仍保存在 Supabase 私有数据库中,并继续受原有保留和删除规则约束。每个账号最多保留 100 个会话,单个会话最多 120 条消息且序列化内容不超过 1 MiB;会话最后活动超过 180 天后自动删除,成员也可以随时删除单个会话。历史读取、修改和删除接口只绑定当前登录账号,不接受目标成员 ID;管理员默认也不能读取其他成员的对话正文。永久注销账号时,全部会话和消息随账号级联删除。 +历史运行期间形成的会话标题、成员问题和模型可见回复仍保存在 Supabase 私有数据库中,并继续受原有保留和删除规则约束。每个账号最多保留 100 个会话,单个会话最多 120 条消息且序列化内容不超过 1 MiB;会话最后活动超过 180 天后自动删除。当前关闭态不提供会话浏览或单会话删除入口;登录成员仍可在“我的资料”导出本人历史,永久注销账号时全部会话和消息随账号级联删除。遗留历史接口只绑定当前登录账号,不接受目标成员 ID;管理员默认也不能读取其他成员的对话正文。 遗留额度、并发控制和故障诊断账本保存成员 UUID、随机请求标识、不可逆内容指纹、请求状态、时间及聚合 Token 数,不保存可还原的对话正文。历史请求内容在中转站和上游模型处的留存、训练使用、删除与跨境处理,受当时所选服务及其政策约束;功能关闭后不再产生新的模型请求。 diff --git a/README.md b/README.md index 6a21ef9..204b8b0 100644 --- a/README.md +++ b/README.md @@ -205,8 +205,8 @@ npm run test:db - `VITE_SUPABASE_ANON_KEY` - `VITE_REGISTRATION_TURNSTILE_ENABLED`(注册 Turnstile 开关;默认关闭) - `VITE_TURNSTILE_SITE_KEY`(公开站点 Key;Turnstile 开启时必填) -- `VITE_WEBCHAT_UI_ENABLED`(仅接受 `true`/`false`;默认关闭) -- `VITE_WEBCHAT_IMAGE_INPUT_ENABLED`(图片粘贴/选择入口;默认关闭,必须在 Storage 备份恢复与视觉模型烟测后才可开启) +- `VITE_WEBCHAT_UI_ENABLED`(遗留界面开关;生产固定为 `false`,不得开启) +- `VITE_WEBCHAT_IMAGE_INPUT_ENABLED`(遗留图片入口开关;生产固定为 `false`,不得开启) - `VITE_WEBCHAT_API_URL`(仅供本地回环测试;生产固定使用当前 Supabase 项目的 `webchat` Function) 只读生产验收变量: @@ -217,7 +217,7 @@ GitHub Actions Variables: - `VITE_REGISTRATION_TURNSTILE_ENABLED`(Pages 注册安全验证开关) - `VITE_TURNSTILE_SITE_KEY`(Cloudflare Turnstile 公开站点 Key,不是 Secret) -- `VITE_WEBCHAT_UI_ENABLED`(Pages 构建开关;未配置时 workflow 明确回退为 `false`) +- `VITE_WEBCHAT_UI_ENABLED`(遗留 Pages 构建开关;必须为 `false`,未配置时 workflow 也固定回退为 `false`) Turnstile 私有 Secret 只配置在 Supabase Auth,真实邮箱确认、Auth 限流和无 token 直连拒绝必须一起验收。完整启用与回滚顺序见 @@ -229,7 +229,7 @@ GitHub 仓库级 Actions Secrets: - `VITE_SUPABASE_ANON_KEY` - `SUPABASE_ACCESS_TOKEN`(仅供加密数据库备份;CLI 每次运行动态取得短期数据库登录,不保存长期数据库密码) - `BACKUP_ENCRYPTION_PASSPHRASE`(独立随机口令,至少 32 个字符) -- 遗留 WebChat 兼容性工作流仍识别 `CHAT_RELAY_BASE_URL`、`CHAT_RELAY_API_KEY`、`CHAT_RELAY_MODEL`,但模块停止后不再运行付费协议、Abort 或缓存验收,也不再向 GitHub 新增或更新这三项 Secret。仍保留在 Supabase Vault 的生产配置不得复制到 `VITE_*`、前端、日志或发布记录。 +- 遗留 WebChat 兼容性工作流仍识别 `CHAT_RELAY_BASE_URL`、`CHAT_RELAY_API_KEY`、`CHAT_RELAY_MODEL`,但模块停止后不再运行付费协议、Abort 或缓存验收,也不再向 GitHub 新增或更新这三项 Secret。仍保留在 Supabase Vault 的生产配置只允许核对名称和关闭状态,不读取值、不轮换,也不得复制到 `VITE_*`、前端、日志或发布记录;未来若删除,必须单独取得生产 Secret 变更批准。 `production-operations` Environment Secrets: @@ -246,11 +246,11 @@ Supabase Function Secrets/配置: - `LUOGU_COOKIE`(专用洛谷会话 Cookie) - `LUOGU_CSRF_TOKEN`(与 Cookie 对应的 CSRF Token) - `SYNC_QUEUE_TOKEN`(独立随机值,32-256 个可打印 ASCII 字符;只授权 `scope=queue`) -- 可选:`WEBCHAT_CACHE_PROBE_TIMEOUT_MS`(单次上游缓存探针超时,默认 120000ms;两次请求共享的数据库租约会据此保持足够余量) +- 遗留:`WEBCHAT_CACHE_PROBE_TIMEOUT_MS`(只解释已停止缓存探针的兼容实现;关闭态不新增或调整该配置) - `DELETION_RECOVERY_REPOSITORY`、`DELETION_RECOVERY_GITHUB_TOKEN`(注销前更新 GitHub 恢复下限;Token 仅授予目标仓库 Variables write) -- `CHAT_IMAGE_INPUT_ENABLED`(Edge 图片上传入口;默认关闭) -- `CHAT_VISION_ENABLED`(Edge 向中转站发送图片的视觉能力;默认关闭,必须与前端/上传开关分别审核) -- `CHAT_VISION_MODEL`(唯一允许接收图片的精确运行时模型;管理员更换模型后图片自动失败关闭) +- `CHAT_IMAGE_INPUT_ENABLED`(遗留 Edge 图片上传入口;生产固定关闭) +- `CHAT_VISION_ENABLED`(遗留视觉能力开关;生产固定为 `false`) +- `CHAT_VISION_MODEL`(只保留历史精确模型绑定的数据契约;关闭态不配置、更换或验收模型) - 遗留 WebChat 仍保留浏览器、服务端和数据库三层开关、Vault 配置、预算账本与成员授权结构;生产三层均保持关闭,后台不再录入或修改中转站、模型、Key、预算和成员 AI 权限。浏览器不得配置任何 `VITE_CHAT_RELAY_*` 密钥变量。 - `ALLOWED_ORIGIN` - 可选:`FIRECRAWL_API_URL`、`CODEFORCES_MAX_PAGES`、`LUOGU_MAX_PAGES`、`XCPC_ELO_DATA_URL` @@ -260,11 +260,11 @@ Supabase Function Secrets/配置: WebChat 的 Origin 白名单是浏览器跨域边界,不代替身份认证。没有 `Origin` 的受控 CLI/服务端请求仍必须携带有效 Supabase Bearer Token,并通过 Profile 启用状态检查;浏览器请求只允许 `CHAT_ALLOWED_ORIGINS` 中的精确 Origin。 -WebChat 配额表位于 `private` Schema,浏览器角色和 `service_role` 都没有配额表直表权限,只能由 Edge Function 通过最小权限 `SECURITY DEFINER` RPC 执行 claim、开始、结算、开始前释放、聚合用量读取和预算阻断标记。额度账本不保存消息正文;成员和全站额度按完整 `total_tokens` 结算,不因缓存折扣改变产品额度。生产缓存探针固定计入全站 2 次请求,不占成员额度,30 分钟冷却且无自动重试;会话正文另存于私有历史表,只能通过绑定 `auth.uid()` 的 own-history RPC 访问,普通管理员默认也不能读取成员正文。 +WebChat 配额表位于 `private` Schema,浏览器角色和 `service_role` 都没有配额表直表权限,只能由 Edge Function 通过最小权限 `SECURITY DEFINER` RPC 执行 claim、开始、结算、开始前释放、聚合用量读取和预算阻断标记。额度账本不保存消息正文;成员和全站额度按完整 `total_tokens` 结算,不因缓存折扣改变产品额度。历史缓存探针曾固定计入全站 2 次请求、不占成员额度,并受 30 分钟冷却和零自动重试约束;该探针现已停止,不得运行。会话正文另存于私有历史表,只能通过绑定 `auth.uid()` 的 own-history RPC 访问,普通管理员默认也不能读取成员正文。 WebChat 历史设计曾把成员问题、会话上下文和固定系统指令转发给中转站及其上游模型;生产关闭后不再发送新内容。遗留图片对象只允许私有 Storage 中的规范化 WebP,继续受四重归属、短时签名 URL、账号/会话容量、自动清理和注销级联约束;历史消息最多保留 100 个会话、单会话 120 条消息/1 MiB,最后活动超过 180 天自动清理。历史接口只绑定当前登录账号,额度账本不保存正文。站内披露见 [`/privacy`](https://ustsacm.fun/privacy),工程边界见[WebChat 私有历史会话](./docs/webchat-conversation-history.md)。 -`npm run test:e2e:webchat` 使用本地脱敏流式服务覆盖 Chromium、Firefox、WebKit、390px 移动端和宽屏:登录返回、动态 Token、流式输出、键盘停止、403 权限刷新、429 限流不重试、502/504 手动恢复、会话失效、减少动画和 axe 均进入门禁;Chromium 还会同时驱动 10 个独立页面验证回复不串流,并用 10 路并行 HTTP 流确认服务端传输层可同时完成且无残留活动连接。该测试只证明本地协议与客户端隔离,不能替代真实中转站费用、Usage 和 Abort 验收。 +`npm run test:e2e:webchat` 使用本地脱敏流式服务覆盖 Chromium、Firefox、WebKit、390px 移动端和宽屏:登录返回、动态 Token、流式输出、键盘停止、403 权限刷新、429 限流不重试、502/504 手动恢复、会话失效、减少动画和 axe 均进入门禁;Chromium 还会同时驱动 10 个独立页面验证回复不串流,并用 10 路并行 HTTP 流确认服务端传输层可同时完成且无残留活动连接。该测试只保留为本地协议、客户端隔离和关闭态安全回归,不再安排真实中转站费用、Usage、Abort 或模型验收。 历史上的中转站兼容性验收覆盖非流式、Responses typed SSE、Usage、Abort 和缓存命中,并使用 service-role-only `webchat-cache-probe` 从 Vault 读取配置。该工作流与探针现已停止运行,不再消耗模型额度;协议与安全记录保留在 [WebChat 中转站兼容性验收](./docs/webchat-relay-compatibility.md),仅供理解遗留实现。 diff --git a/ROADMAP.md b/ROADMAP.md index fb89e03..893d6be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -88,6 +88,20 @@ USTSACMLand 的定位是苏州科技大学 ACM 集训队官网,当前产品范 - [x] 确认同步巡检、数据库备份、凭据轮换、回滚和管理员操作可以由全新上下文 Agent 仅根据仓库文档冷启动执行。2026-08-09 全新上下文 Agent 完整阅读根契约与专项手册,在不触发生产写操作的前提下完成 GitHub/Supabase 冷启动核对、同步队列巡检、备份连续性检查及前端、Edge Function、数据库和 Cloudflare 回滚桌面推演;同时识别并修复仓库级 Secret 与 `production-operations` Environment Secret 的检查口径偏差。证据见 [`docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md`](./docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md)。 - [ ] 按 `docs/release-checklist.md` 完成最终检查并创建 `v1.0.0` 标签。2026-07-29 已通过仓库就绪检查、全仓库 ESLint、七项工作流结构门禁、93 个 Vitest 文件共 596 项测试、10 个函数入口 Deno 类型检查、136 个文件 Deno Lint、462 项 Edge Function 测试、生产构建和 Cloudflare 指纹资源长期缓存门禁;Supabase 已关闭邮箱自动确认并启用服务端 Turnstile CAPTCHA,preflight 与严格就绪检查均通过。全新真实邮箱的有效 token 注册、确认邮件、确认前登录拒绝、重复确认安全性、确认后密码登录和带独立 Turnstile 的自助注销已经完成;注销后的 Auth、Profile 及 19 类关联数据均为 0,再次登录返回标准凭据拒绝。两组各不超过 30 次的生产限流检查均安全停止但未触发 `429`。2026-08-04 又将登录/注册限额临时从 `30` 降至 `2`,等待 30 秒并严格限制为三次请求后仍只得到 `400 / invalid_credentials`;这表明动态降阈值没有立即收缩当前 IP 的旧令牌桶余额,配置已恢复为 `30`,不能把结果记作恢复通过。冷启动交接验收已于 2026-08-09 完成;仍缺维护窗口内可复现的受控 `429` 窗口恢复、当前 `main` Schema v2 备份恢复演练,以及 Supabase 日志与 Firecrawl 保留窗口核验,因此暂不创建标签。证据见 [`docs/evidence/release-gates-2026-07-28.md`](./docs/evidence/release-gates-2026-07-28.md)、[`docs/evidence/registration-abuse-production-gap-2026-07-28.md`](./docs/evidence/registration-abuse-production-gap-2026-07-28.md)、[`docs/evidence/auth-rate-limit-recovery-production-2026-08-04.md`](./docs/evidence/auth-rate-limit-recovery-production-2026-08-04.md) 与 [`docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md`](./docs/evidence/agent-cold-start-handoff-audit-2026-08-09.md)。 +### PR #146 合并前审查整改 + +本节记录当前分支的仓库整改状态,不使用生产完成复选框;“仓库已完成”只表示实现与本地验证完成,不能替代上文 `[x]` 所要求的生产部署和验收。 + +- 仓库已完成:清理退役 WebChat / 图片输入文档中的开启、付费烟测、非空对象开放验收和遗留中转站凭据轮换指令;历史契约只记录停止时的实现与安全边界,任何重新立项继续要求项目负责人另行批准。 +- 仓库已完成:Dependabot 报告的 4 条高危开发依赖告警已由 PR #147 修复,包括 `postcss` 的 `GHSA-r28c-9q8g-f849` 和 `brace-expansion` 的 `GHSA-mh99-v99m-4gvg` / `GHSA-rgw5-rvv9-x895`;2026-08-09 通过 GitHub API 复核开放告警、高危告警和严重告警均为 0,当前完整测试与生产构建通过。 +- 仓库已完成:为 Pages 生产 bundle 生成并分发可复现的第三方运行时依赖许可证清单,补齐已跟踪品牌素材的独立授权说明,并在构建门禁中验证许可证文件存在且不为空。构建现按 Vite 实际模块图生成 102 个唯一运行时包的 `THIRD_PARTY_LICENSES.txt`,并逐字分发根 `LICENSE` 与 `THIRD_PARTY_NOTICES.md`;17 项专项测试和完整生产构建通过。品牌与素材的实际授权仍须由项目负责人确认,本项不把软件许可证声明当作品牌授权。 +- 仓库已完成:修复许可证门禁对实际进入 bundle 的 `devDependency` 和部分未映射 `node_modules` 模块的漏报,并让独立 `licenses:check` 重新生成只读 Vite 模块图,对账完整包集合,拒绝被删节或生成后已变化的报告;负向回归测试覆盖 bundled dev、未锁定模块、删节报告和 bundle 变化。 +- 仓库已完成:移除 `use-composed-ref@1.4.0` 许可补充材料中从其他包推断的版权归属;现仅记录该精确 npm 包与源码 tag 的 MIT 声明、标准许可条款,以及上游未提供 LICENSE 文件或版权声明的事实,不再制造未经上游确认的版权行。 +- 仓库已完成:修复仓库就绪检查的组织级 Actions Secret 分页漏检,并让缺少 `production-operations` Environment 时返回可操作的失败信息;已补齐分页、缺失 Environment 和失败信息回归测试,readiness 专项 14 项通过。 +- 仓库已完成:统一公开隐私页与关闭态产品行为,不再承诺当前没有成员入口的单会话删除操作,明确 180 天自动清理、个人数据导出和永久注销边界;隐私页与默认关闭路由共 21 项测试通过。 +- 仓库已完成:阻止普通成员绕过已隐藏的 WebChat 界面直接调用 own-history 写 RPC 创建或修改遗留会话;新增 migration 撤销 `public`、`anon`、`authenticated` 与 `service_role` 对创建、重命名、归档和消息 upsert 的执行权限,同时保留本人历史读取/删除、个人导出、180 天清理和注销级联边界。当前 51 个 pgTAP 文件、1247 项断言通过;生产 migration 尚未应用,仍须项目负责人单独批准后部署。 +- 仓库已完成:将管理员 WebChat 配置与成员 AI 权限界面收紧为只读关闭态,禁止通过站内后台修改中转站、模型、Key、预算、请求开关、成员授权或额度;`webchat-config` 的合法更新请求固定返回 `410 feature_retired`,同时保留关闭状态和历史用量诊断。管理员界面专项 20 项、Edge Function 专项 15 项、全量 Deno 477 项测试通过,并已完成桌面、390px 与宽屏 Chrome 验收;Pages 与 Edge Function 生产部署仍须另行授权。 + ## 4. v1.0.0 发布后接入 Cloudflare 默认方案是继续使用 GitHub Pages 作为静态源站,由 Cloudflare 提供自定义域名、DNS、CDN、TLS 和基础防护;暂不迁移到 Cloudflare Pages。 @@ -110,17 +124,17 @@ USTSACMLand 的定位是苏州科技大学 ACM 集训队官网,当前产品范 - 历史完成:图片安全基础已以默认关闭方式部署到生产:71 项 migration 全部一致,`webchat-attachment` 与 `webchat-image-cleanup` 为 ACTIVE,私有 Bucket、4 MiB/仅 WebP 限制、全站暂停、匿名/普通成员拒绝和夹具零残留已通过 47 项可复跑生产检查;这不代表图片输入已向成员开放。证据见 [`docs/evidence/webchat-image-foundation-production-2026-07-25.md`](./docs/evidence/webchat-image-foundation-production-2026-07-25.md)。 - 历史完成:默认关闭状态下的真实图片对象生命周期已完成生产验收:随机临时成员的规范 WebP 经私有路径保存,本人历史可恢复、跨成员和直接 Storage 读取被拒绝、30 秒签名预览内容与 SHA-256 一致;删除消息后正式清理函数只领取该夹具,零重试/零死信删除对象并恢复全局账目,最终注销后附件、对象和删除队列物理零残留。可复跑生产检查现为 55 项且 `cleanupFallbacks=0`;前端和视觉模型仍未开放。证据见 [`docs/evidence/webchat-image-foundation-production-2026-07-25.md`](./docs/evidence/webchat-image-foundation-production-2026-07-25.md)。 -- 历史完成:私有 Supabase Storage、成员/会话隔离、本人历史引用、跨成员拒绝、浏览器直读拒绝和 30 秒签名预览已用真实生产对象验收;浏览器不接触 service role key。开放态附件界面仍由后续端到端条目验收。 +- 历史完成:私有 Supabase Storage、成员/会话隔离、本人历史引用、跨成员拒绝、浏览器直读拒绝和 30 秒签名预览已用真实生产对象验收;浏览器不接触 service role key。开放态附件界面验收在项目停止时未执行,现已取消。 - 历史完成:图片对象的删除消息队列、幂等领取、零重试/零死信物理清理、注销零残留和全局 Storage 对账已通过真实生产生命周期验收;定时 schedule 仍保持关闭。 -- 历史完成:图片 Schema v2 加密备份与隔离恢复路径已在当前生产零对象状态通过:安装状态、私有 Bucket、匿名拒绝、数据库引用、对象集合哈希、Auth/RLS 和恢复夹具清理均成功。零对象快照不替代正式开放前的非空对象贯通恢复演练。证据见 [`docs/evidence/webchat-image-backup-restore-2026-07-25.md`](./docs/evidence/webchat-image-backup-restore-2026-07-25.md)。 -- 已停止:支持在 WebChat 输入区直接粘贴剪贴板图片,也支持通过图片按钮选择本地文件;发送前展示稳定尺寸的缩略图、文件状态和移除操作,桌面端、移动端与键盘操作均可用。客户端实现与本地开启态验收已完成:Browser 实测选择、预览和移除无控制台错误,粘贴与图片发送/刷新恢复进入 Chromium 门禁,纯键盘添加/移除在 Chromium、Firefox、WebKit、390px 移动端和宽屏五项目通过;生产三层开关仍关闭,因此本项保持未完成。证据见 [`docs/evidence/webchat-image-client-local-2026-07-26.md`](./docs/evidence/webchat-image-client-local-2026-07-26.md)。 -- 已停止:第一版只接受 JPEG、PNG 和 WebP,拒绝 SVG、动图和伪造 MIME;客户端与服务端同时限制单图体积、像素尺寸、单条消息图片数量、会话待上传总量和账号级 Storage 使用;4 MiB、2,048 px / 4,194,304 像素、每条 4 张、每会话待处理 8 张 / 16 MiB、每账号 200 个 / 64 MiB、滚动一小时 30 个新附件等具体上限已写入 [`docs/webchat-image-input-v1.md`](./docs/webchat-image-input-v1.md)。数据库已在每账号行锁内按最近一小时 `reserved_at` 记录计数;从零安装全部 migration 后当前 48 个 pgTAP 文件、1206 项断言通过,真实双连接验证第 30 个预留成功、第 31 个在等待同一全局优先锁后以账号滚动限额拒绝。生产 migration 和默认暂停边界已部署验收,仍缺开放态端到端验收,因此不提前勾选。证据见 [`docs/evidence/webchat-image-safety-foundation-2026-07-23.md`](./docs/evidence/webchat-image-safety-foundation-2026-07-23.md) 与 [`docs/evidence/webchat-image-foundation-production-2026-07-25.md`](./docs/evidence/webchat-image-foundation-production-2026-07-25.md)。 -- 已停止:图片消息使用中转站兼容的多模态消息格式发送,并在请求前确认当前模型支持视觉输入;不支持图片的模型必须给出明确提示,不能静默丢图、降级为 OCR 文本或自动改用其他模型。服务端本地实现与门禁已完成:当前运行模型必须与审核模型完全一致,图片以 Responses `input_image` / `detail: high` 发送,工具固定关闭;仍待真实视觉模型生产烟测,因此不提前勾选。证据见 [`docs/evidence/webchat-image-relay-quota-local-2026-07-26.md`](./docs/evidence/webchat-image-relay-quota-local-2026-07-26.md)。 -- 已停止:图片输入沿用 WebChat 的单次请求 claim、累计请求上限和累计 Token 上限;服务端按可解释的保守值预留图片 Token,成功后以中转站真实 usage 结算,上游启动前失败释放预留,上游启动后无法取得 usage 时把预留转入未知用量,付费请求仍禁止自动重试。本地已完成最坏尺寸图片预留、真实 usage 解析、未知用量结算和单次 fetch 门禁;本轮进一步保证图片请求体校验发生在上游围栏前、超时从围栏后计时,且围栏确认后不再尝试释放 claim。四个核心专项文件 67 项、完整 WebChat Edge Function 目录 79 项均为 0 失败,仍待真实中转站图片 usage 生产烟测,因此不提前勾选。证据见 [`docs/evidence/webchat-image-relay-quota-local-2026-07-26.md`](./docs/evidence/webchat-image-relay-quota-local-2026-07-26.md)。 -- 已停止:在开放态附件流程中完成上传、签名预览、发送和刷新恢复;删除会话时清理全部对象,个人数据导出只包含本人附件清单与必要元数据,不泄露长期可访问 URL。2026-07-26 已在默认关闭的生产安全烟测中使用随机临时成员和真实私有 WebP 验证:本人导出仅含媒体类型、字节、尺寸和生命周期时间,另一成员附件计数为 0,且导出不含签名 URL、对象路径、哈希、附件/会话/消息标识;消息删除、对象清理、附件/队列物理清理和账号注销后零残留均通过。仍缺向成员开放后的完整客户端发送与刷新恢复流程,因此不提前勾选。证据见 [`docs/evidence/webchat-image-foundation-production-2026-07-25.md`](./docs/evidence/webchat-image-foundation-production-2026-07-25.md)。 -- 已停止:在开放态附件流程中复核服务端文件签名、解码、像素上限和 EXIF 移除;确认图片内容、签名 URL 和原始文件名不进入日志、审计或错误消息,并验收上传失败、过期对象和孤儿对象的清理路径。本地生产实现已完成 fail-closed 签名 URL 校验、显式静态 WASM 部署打包和本地预览 origin 安全重写;真实 JPEG/PNG/WebP 规范化、JPEG EXIF 方向与元数据清理、伪造格式拒绝、日志脱敏、上传回滚和删除队列共 129 项专项测试通过。真实 Supabase Edge Runtime PNG→WebP、签名预览、历史恢复、零重试删除、Storage 对账及测试账号零残留烟测也已通过。仍缺生产开放态异常路径验收,因此不提前勾选。证据见 [`docs/evidence/webchat-image-attachment-security-local-2026-07-26.md`](./docs/evidence/webchat-image-attachment-security-local-2026-07-26.md)。 -- 已停止:图片功能全线开启前增加全站每小时上传数量/字节预算、总 Storage 容量上限和并发熔断,并完成匿名注册滥用防护;账号级限额不能作为批量注册攻击下的唯一成本边界。全站预算、Storage 预留、跨实例 validation lease、删除确认释放和漂移自动暂停已实现。2026-07-28 已创建 Managed Turnstile Widget,配置 Supabase 私有 Secret、真实邮箱确认与 Auth 限流,写入 Pages 公开变量并部署;无 token 与伪 token 的生产直连注册均以 HTTP 400 / `captcha_failed` 拒绝,preflight 与严格就绪检查通过。仍缺全新真实邮箱的有效 token 注册、确认前登录拒绝、重复确认幂等和受控 `429` 恢复烟测,因此本项暂不勾选,推荐计划和 WebChat 图片开关继续关闭。证据见 [`docs/evidence/registration-abuse-foundation-2026-07-23.md`](./docs/evidence/registration-abuse-foundation-2026-07-23.md)、[`docs/evidence/supabase-ten-function-readiness-2026-07-26.md`](./docs/evidence/supabase-ten-function-readiness-2026-07-26.md) 与 [`docs/evidence/registration-abuse-production-gap-2026-07-28.md`](./docs/evidence/registration-abuse-production-gap-2026-07-28.md)。 -- 已停止:补齐开放态粘贴、选择、预览、移除、发送、刷新恢复、模型不兼容、超限、上游前失败释放、上游后未知用量保守结算、移动端和无障碍自动化;完成真实视觉模型烟测及受控非空对象备份恢复贯通演练。2026-07-26 本地完整 WebChat 矩阵为 95 项:75 通过、20 项按项目能力设计跳过;已覆盖图片选择、粘贴、私有 URN、刷新预览、四张上限、键盘操作、390px 无溢出和 axe。图片附件、清理、协议、额度与监控专项现为 129 项通过;全量 Edge Function 为 462 项通过、0 失败、1 项按环境忽略,并覆盖真实编解码、真实 Edge Runtime 上传/预览/清理、围栏前校验、真实 usage 结算、单次 fetch、围栏后超时和启动后禁止释放。仍缺生产开放态、真实视觉模型、用量结算故障和非空备份恢复验收。 +- 历史完成:图片 Schema v2 加密备份与隔离恢复路径已在当前生产零对象状态通过:安装状态、私有 Bucket、匿名拒绝、数据库引用、对象集合哈希、Auth/RLS 和恢复夹具清理均成功。项目停止前未执行非空对象贯通恢复;该开放态验收现已取消,零对象结果只作为关闭态恢复安全基线。证据见 [`docs/evidence/webchat-image-backup-restore-2026-07-25.md`](./docs/evidence/webchat-image-backup-restore-2026-07-25.md)。 +- 已停止:支持在 WebChat 输入区直接粘贴剪贴板图片,也支持通过图片按钮选择本地文件;发送前展示稳定尺寸的缩略图、文件状态和移除操作,桌面端、移动端与键盘操作均可用。客户端实现与本地开启态验收已完成:Browser 实测选择、预览和移除无控制台错误,粘贴与图片发送/刷新恢复进入 Chromium 门禁,纯键盘添加/移除在 Chromium、Firefox、WebKit、390px 移动端和宽屏五项目通过;生产三层开关保持关闭,开放计划已经取消。证据见 [`docs/evidence/webchat-image-client-local-2026-07-26.md`](./docs/evidence/webchat-image-client-local-2026-07-26.md)。 +- 已停止:第一版只接受 JPEG、PNG 和 WebP,拒绝 SVG、动图和伪造 MIME;客户端与服务端同时限制单图体积、像素尺寸、单条消息图片数量、会话待上传总量和账号级 Storage 使用;4 MiB、2,048 px / 4,194,304 像素、每条 4 张、每会话待处理 8 张 / 16 MiB、每账号 200 个 / 64 MiB、滚动一小时 30 个新附件等具体上限已写入 [`docs/webchat-image-input-v1.md`](./docs/webchat-image-input-v1.md)。数据库已在每账号行锁内按最近一小时 `reserved_at` 记录计数;从零安装全部 migration 后当前 48 个 pgTAP 文件、1206 项断言通过,真实双连接验证第 30 个预留成功、第 31 个在等待同一全局优先锁后以账号滚动限额拒绝。生产 migration 和默认暂停边界已部署验收;开放态端到端验收在项目停止时未执行,现已取消。证据见 [`docs/evidence/webchat-image-safety-foundation-2026-07-23.md`](./docs/evidence/webchat-image-safety-foundation-2026-07-23.md) 与 [`docs/evidence/webchat-image-foundation-production-2026-07-25.md`](./docs/evidence/webchat-image-foundation-production-2026-07-25.md)。 +- 已停止:图片消息使用中转站兼容的多模态消息格式发送,并在请求前确认当前模型支持视觉输入;不支持图片的模型必须给出明确提示,不能静默丢图、降级为 OCR 文本或自动改用其他模型。服务端本地实现与门禁已完成:当前运行模型必须与审核模型完全一致,图片以 Responses `input_image` / `detail: high` 发送,工具固定关闭;真实视觉模型生产烟测在项目停止时未执行,现已取消。证据见 [`docs/evidence/webchat-image-relay-quota-local-2026-07-26.md`](./docs/evidence/webchat-image-relay-quota-local-2026-07-26.md)。 +- 已停止:图片输入沿用 WebChat 的单次请求 claim、累计请求上限和累计 Token 上限;服务端按可解释的保守值预留图片 Token,成功后以中转站真实 usage 结算,上游启动前失败释放预留,上游启动后无法取得 usage 时把预留转入未知用量,付费请求仍禁止自动重试。本地已完成最坏尺寸图片预留、真实 usage 解析、未知用量结算和单次 fetch 门禁;本轮进一步保证图片请求体校验发生在上游围栏前、超时从围栏后计时,且围栏确认后不再尝试释放 claim。四个核心专项文件 67 项、完整 WebChat Edge Function 目录 79 项均为 0 失败;真实中转站图片 usage 生产烟测在项目停止时未执行,现已取消。证据见 [`docs/evidence/webchat-image-relay-quota-local-2026-07-26.md`](./docs/evidence/webchat-image-relay-quota-local-2026-07-26.md)。 +- 已停止:在开放态附件流程中完成上传、签名预览、发送和刷新恢复;删除会话时清理全部对象,个人数据导出只包含本人附件清单与必要元数据,不泄露长期可访问 URL。2026-07-26 已在默认关闭的生产安全烟测中使用随机临时成员和真实私有 WebP 验证:本人导出仅含媒体类型、字节、尺寸和生命周期时间,另一成员附件计数为 0,且导出不含签名 URL、对象路径、哈希、附件/会话/消息标识;消息删除、对象清理、附件/队列物理清理和账号注销后零残留均通过。向成员开放后的客户端发送与刷新恢复流程在项目停止时未执行,现已取消。证据见 [`docs/evidence/webchat-image-foundation-production-2026-07-25.md`](./docs/evidence/webchat-image-foundation-production-2026-07-25.md)。 +- 已停止:在开放态附件流程中复核服务端文件签名、解码、像素上限和 EXIF 移除;确认图片内容、签名 URL 和原始文件名不进入日志、审计或错误消息,并验收上传失败、过期对象和孤儿对象的清理路径。本地生产实现已完成 fail-closed 签名 URL 校验、显式静态 WASM 部署打包和本地预览 origin 安全重写;真实 JPEG/PNG/WebP 规范化、JPEG EXIF 方向与元数据清理、伪造格式拒绝、日志脱敏、上传回滚和删除队列共 129 项专项测试通过。真实 Supabase Edge Runtime PNG→WebP、签名预览、历史恢复、零重试删除、Storage 对账及测试账号零残留烟测也已通过。生产开放态异常路径验收在项目停止时未执行,现已取消。证据见 [`docs/evidence/webchat-image-attachment-security-local-2026-07-26.md`](./docs/evidence/webchat-image-attachment-security-local-2026-07-26.md)。 +- 已停止:图片功能全线开启前增加全站每小时上传数量/字节预算、总 Storage 容量上限和并发熔断,并完成匿名注册滥用防护;账号级限额不能作为批量注册攻击下的唯一成本边界。全站预算、Storage 预留、跨实例 validation lease、删除确认释放和漂移自动暂停已实现。2026-07-28 已创建 Managed Turnstile Widget,配置 Supabase 私有 Secret、真实邮箱确认与 Auth 限流,写入 Pages 公开变量并部署;无 token 与伪 token 的生产直连注册均以 HTTP 400 / `captcha_failed` 拒绝,preflight 与严格就绪检查通过。图片开放相关的剩余风控验收随项目停止而取消;通用注册限流 `429` 恢复仍作为独立发布门禁保留,推荐计划和 WebChat 图片开关继续关闭。证据见 [`docs/evidence/registration-abuse-foundation-2026-07-23.md`](./docs/evidence/registration-abuse-foundation-2026-07-23.md)、[`docs/evidence/supabase-ten-function-readiness-2026-07-26.md`](./docs/evidence/supabase-ten-function-readiness-2026-07-26.md) 与 [`docs/evidence/registration-abuse-production-gap-2026-07-28.md`](./docs/evidence/registration-abuse-production-gap-2026-07-28.md)。 +- 已停止:补齐开放态粘贴、选择、预览、移除、发送、刷新恢复、模型不兼容、超限、上游前失败释放、上游后未知用量保守结算、移动端和无障碍自动化;完成真实视觉模型烟测及受控非空对象备份恢复贯通演练。2026-07-26 本地完整 WebChat 矩阵为 95 项:75 通过、20 项按项目能力设计跳过;已覆盖图片选择、粘贴、私有 URN、刷新预览、四张上限、键盘操作、390px 无溢出和 axe。图片附件、清理、协议、额度与监控专项现为 129 项通过;全量 Edge Function 为 462 项通过、0 失败、1 项按环境忽略,并覆盖真实编解码、真实 Edge Runtime 上传/预览/清理、围栏前校验、真实 usage 结算、单次 fetch、围栏后超时和启动后禁止释放。项目停止时未完成的生产开放态、真实视觉模型、用量结算故障和非空备份恢复验收均已取消,不再作为后续任务。 ## 6. v1.0.0 发布后用户功能 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fafcdb2..3ea0886 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,14 @@ -# 第三方素材与商标说明 +# 第三方软件、素材与商标说明 -根目录 [`LICENSE`](./LICENSE) 授予项目原创源代码,以及未附带其他授权声明的原创文档和配置的 Apache License 2.0 权利。下列文件包含第三方字体、品牌标识或需要独立确认的视觉素材,不因根目录许可证获得额外授权。 +根目录 [`LICENSE`](./LICENSE) 授予项目原创源代码,以及未附带其他授权声明的原创文档和配置的 Apache License 2.0 权利。项目使用的第三方软件、字体、品牌标识和视觉素材不因根目录许可证获得额外授权。 + +## 运行时软件依赖 + +生产构建根据 Vite 实际纳入 Pages bundle 的模块、`package-lock.json` 和本地安装版本,可复现地生成 `dist/THIRD_PARTY_LICENSES.txt`。该文件逐包记录版本、许可证声明以及 npm 包内随附的 LICENSE、NOTICE、COPYING、COPYRIGHT 或 PATENTS 内容;构建门禁会拒绝缺失包级许可证正文、空白材料、版本不一致或未覆盖关键运行时包的清单。`dist/` 和 `node_modules/` 均为生成物,不提交到仓库。 + +个别 npm 发布包没有随附包级许可证文件时,只允许使用 `scripts/third-party-license-materials/` 中经过来源核对、固定到精确包版本的材料;不存在对应材料时构建失败,不把 `package.json` 中的许可证声明单独视为可分发正文。若上游只声明 SPDX 许可证而没有提供版权声明,补充材料会明确记录这一缺失并附标准许可条款,不从其他包推断版权归属。 + +同一构建流程还会把根 `LICENSE` 和本文件逐字复制为 `dist/LICENSE.txt` 与 `dist/THIRD_PARTY_NOTICES.md`,并在 Pages 门禁中复核内容一致。 ## Noto Serif SC @@ -12,7 +20,7 @@ ## 品牌标识与图片 -- `public/icpc-foundation.png`:ICPC Foundation 标识。商标、品牌指南和再使用许可由 ICPC Foundation 独立决定。 +- `public/icpc-foundation.png`、`ICPCLogo-on-dark-smaller.png`:ICPC Foundation 标识。商标、品牌指南和再使用许可由 ICPC Foundation 独立决定。 - `public/ccpc-logo.png`:CCPC 标识。商标、赛事名称和图形使用范围由其权利人独立决定。 - `public/ustsacm.png`:学校 ACM 集训队标识。学校、集训队和校内赛事相关名称与图形授权需由项目负责人另行确认。 - `public/og-image.png`、`public/favicon-192.png`、`public/favicon-512.png`:站点分享图和图标;其中嵌入的品牌标识继续遵循上列独立边界。 diff --git a/docs/backup-and-recovery.md b/docs/backup-and-recovery.md index 5bb45ec..8d2ce26 100644 --- a/docs/backup-and-recovery.md +++ b/docs/backup-and-recovery.md @@ -43,7 +43,7 @@ - `ustsacmland-database-backup.enc` - `ustsacmland-database-backup.enc.sha256` -密文保留 14 天且不再次压缩。按每日任务计算,仓库备份的目标 RPO 为 24 小时;GitHub Artifact 没有跨快照去重,稳定状态下的存储占用约为 `14 × 单次加密快照大小`。2026-07-19 首次真实 Artifact 隔离演练的 GitHub Actions 端到端耗时为 2 分 7 秒,其中数据库恢复与验证阶段为 3 秒;该结果只覆盖旧的数据库-only 格式。Storage 纳入后必须重新运行一次真实隔离演练,才能建立新的自动化 RTO 基线。完整站点事故恢复仍不包含新建远端项目、Secrets/Auth 回调、Edge Functions、DNS、第三方凭据和业务复核,RTO 须通过远端灾备演练确认。 +密文保留 14 天且不再次压缩。按每日任务计算,仓库备份的目标 RPO 为 24 小时;GitHub Artifact 没有跨快照去重,稳定状态下的存储占用约为 `14 × 单次加密快照大小`。2026-07-19 首次真实 Artifact 隔离演练的 GitHub Actions 端到端耗时为 2 分 7 秒,其中数据库恢复与验证阶段为 3 秒;该结果只覆盖旧的数据库-only 格式。当前 Schema v2 恢复演练必须继续验证 Storage 清单与快照中实际引用的对象,才能建立新的自动化 RTO 基线;这是遗留数据保护要求,不是图片功能开放门禁。完整站点事故恢复仍不包含新建远端项目、Secrets/Auth 回调、Edge Functions、DNS、第三方凭据和业务复核,RTO 须通过远端灾备演练确认。 `.github/workflows/database-restore-drill.yml` 提供手动隔离恢复演练。管理员输入一个来自 `main` 的成功 `Encrypted database backup` run ID;工作流会同时核对来源仓库、工作流路径、分支、结论、run attempt、Artifact 名称和过期状态,不能把其他工作流或 PR 产物冒充为生产备份。该工作流没有 Supabase Access Token、项目引用或远端数据库连接,只把备份恢复到 GitHub Runner 中一次性的本地 Supabase/PostgreSQL 17,完成后无状态销毁。 @@ -133,7 +133,7 @@ gh run download $restoreRunId --repo greenthree/USTSACMLand --dir ".artifacts\re 完整权限登记、证据模板和清理要求见 [维护者交接与独立操作卡](./maintainer-handoff.md)。 -1. 先手动运行当前 `main` 的 `Encrypted database backup`,等待成功并记录 run ID。恢复演练优先使用 Schema v2;历史 Schema v1 Artifact 仍可用于数据库恢复,但只接受固定的数据库-only 文件白名单,归档中出现任何 Storage 路径都会拒绝,报告也会明确标记 Storage 证据不可用。该兼容路径不能替代上线图片功能前的 Schema v2 Storage 恢复演练。 +1. 先手动运行当前 `main` 的 `Encrypted database backup`,等待成功并记录 run ID。恢复演练优先使用 Schema v2;历史 Schema v1 Artifact 仍可用于数据库恢复,但只接受固定的 database-only 文件白名单,归档中出现任何 Storage 路径都会拒绝,报告也会明确标记 Storage 证据不可用。该兼容路径不能替代当前 Schema v2 的遗留 Storage 数据保护验收。 2. 打开 Actions → `Encrypted database restore drill`,输入刚才的 run ID。恢复任务不会自动运行或自动重试。 3. 工作流校验密文 SHA-256、AES-256/PBKDF2 解密、按 Storage 清单生成的归档精确白名单、内部 `SHA256SUMS` 和当前 `BACKUP_RECOVERY_NOT_BEFORE`。 4. 仓库 migration 会先移出目标目录,保证恢复目标只有 Supabase 平台基线;解密文件只复制进一次性数据库容器,由容器内 `supabase_admin` Unix-socket 管理入口把角色、应用 Schema、三个 `auth.users` 应用触发器、业务数据、Auth 数据和 migration 历史在同一 `psql --single-transaction` 中恢复,任一步失败都会整体回滚。工作流不会让普通本地 `postgres` 提升为平台角色,也不持有远端数据库连接。 @@ -182,4 +182,4 @@ psql \ 只有“最近备份存在”不能证明可恢复;必须以隔离项目成功登录和数据核对作为恢复验收证据。 -旧数据库-only 格式的首次真实演练 run、聚合行数、Auth/RLS、受控注销、清理结果和 RTO 边界见 [生产加密数据库隔离恢复演练证据](./evidence/database-restore-drill-2026-07-19.md)。Schema v2 零对象恢复见 [WebChat 图片备份恢复证据](./evidence/webchat-image-backup-restore-2026-07-25.md);图片正式开放前仍必须补充一次非空 `webchat-images` 对象贯通恢复记录。 +旧 database-only 格式的首次真实演练 run、聚合行数、Auth/RLS、受控注销、清理结果和 RTO 边界见 [生产加密数据库隔离恢复演练证据](./evidence/database-restore-drill-2026-07-19.md)。Schema v2 零对象恢复见 [WebChat 图片备份恢复证据](./evidence/webchat-image-backup-restore-2026-07-25.md)。图片功能已经退役,不再为开放制造非空夹具;若真实备份包含遗留 `webchat-images` 对象,恢复演练仍必须逐项验证对象、引用和匿名拒绝。 diff --git a/docs/data-lifecycle.md b/docs/data-lifecycle.md index e0b06ea..a70a47c 100644 --- a/docs/data-lifecycle.md +++ b/docs/data-lifecycle.md @@ -10,7 +10,7 @@ - 认证数据:密码、会话和密码重置信息仅由 Supabase Auth 处理,业务表不保存密码。 - 第三方数据:平台统计来自对应公开接口或认证查询;XCPC ELO 学校缓存来自第三方公开数据集,不是成员资料的副本。 - 训练数据:每日题目只保存第三方题目链接和必要元数据;完成记录属于成员私有活动,讨论内容仅向启用成员和管理员提供。 -- AI 对话数据:会话标题、成员问题和模型可见回复保存在 Supabase 私有 Schema;仅当前登录账号可通过不接受目标用户 ID 的 RPC 读取、修改或删除,管理员默认没有跨成员正文读取接口。额度账本与正文分表保存,只记录请求状态、不可逆指纹和聚合 Token。用户上传的图片对象保存在私有 `webchat-images` Storage Bucket,数据库只保存归属、消息关联、校验摘要和生命周期状态;浏览器不能列举 Bucket 或取得长期公开 URL。 +- AI 对话数据:会话标题、成员问题和模型可见回复保存在 Supabase 私有 Schema;关闭态仅允许当前登录账号通过不接受目标用户 ID 的 RPC 读取或删除本人历史,创建、重命名、归档和消息 upsert 已对应用角色撤权,管理员默认没有跨成员正文读取接口。额度账本与正文分表保存,只记录请求状态、不可逆指纹和聚合 Token。用户上传的图片对象保存在私有 `webchat-images` Storage Bucket,数据库只保存归属、消息关联、校验摘要和生命周期状态;浏览器不能列举 Bucket 或取得长期公开 URL。 - 推荐计划数据:邀请码、绑定关系和奖励次数保存在私有 Schema;成员只能读取自己的邀请码和聚合奖励状态,管理员只能看到脱敏后的奖励审计,不直接读取被邀请人的邮箱、QQ 或其他私有标识。 ## 正常使用 @@ -25,7 +25,7 @@ 推荐计划导出同样只返回本人邀请码、全局计划状态、累计奖励次数、累计奖励 Token、是否曾被绑定和绑定时间,不暴露其他成员身份信息;全局关闭时导出中的邀请码为 `null`,历史奖励与绑定摘要仍保留。 -AI 学习助手会为每个账号保存最多 100 个历史会话;单会话最多 120 条消息,序列化内容上限为 1 MiB,单条消息上限为 64 KiB。浏览器只在本机保存当前会话 ID 以便刷新恢复,生产对话正文由私有数据库保存。成员可以逐个删除会话;数据库每天清理最后活动超过 180 天的会话。会话列表使用账号与最后活动时间游标索引,消息写入锁定所属会话并维护消息数量和体积,避免并发标签页绕过上限。 +AI 学习助手的历史实现为每个账号保存最多 100 个会话;单会话最多 120 条消息,序列化内容上限为 1 MiB,单条消息上限为 64 KiB。浏览器过去只在本机保存当前会话 ID 以便刷新恢复,生产对话正文由私有数据库保存。当前关闭态不提供会话浏览或单会话删除入口,也不再创建或更新会话;底层仍向登录成员保留绑定当前账号的历史读取和删除 RPC,用于数据保护兼容,但不构成成员产品入口。登录成员仍可通过个人数据导出取得本人历史,数据库每天清理最后活动超过 180 天的会话,永久注销会级联删除全部会话。会话列表索引、消息计数和体积约束继续保留,避免历史数据边界回归。 AI 图片附件只有完成服务端格式、大小、尺寸和摘要校验后才能绑定到本人消息。数据库按账号限制最多 200 个未删除附件、合计 64 MiB,并以滚动窗口限制每小时 30 个新附件;这些限制在持有账号状态行锁时执行,不能由多标签页绕过。删除消息、会话或账号时,数据库先把对象加入受控删除队列;Storage worker 确认对象删除后才把附件标记为已删除,已删除元数据再保留 7 天用于故障对账。账号注销会冻结新上传,并在仍有未确认对象时失败关闭,避免删除 Profile 后留下失去归属的私有文件。 diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index 1bd9ab0..2ce1df0 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -30,12 +30,11 @@ WebChat 图片数据库 migration、`webchat-attachment` 与 `webchat-image-cleanup` 已在默认关闭状态部署。2026-07-25 的受控空队列与真实私有对象清理烟测均通过,确认本人历史恢复、跨成员/直接 Storage 拒绝、短时签名预览、零重试、零死信、对象删除和 Storage 对账一致。WebChat 已退出产品范围,仓库变量 `WEBCHAT_IMAGE_CLEANUP_ENABLED` 必须保持缺失或为 `false`,不得依据历史图片验收将其设为 `true`。关闭态维护时先确认前端、视觉模型和定时清理开关均关闭,再处理函数或数据库兼容变更;不得直接回滚已被备份与注销流程引用的表结构。未来只有项目负责人重新立项并重新完成产品、隐私、安全和运维授权后,才能另行制定启用流程。 -视觉能力使用 `CHAT_VISION_ENABLED` 与 `CHAT_VISION_MODEL` 双重服务端门禁。后者必须与 -管理员后台当前运行时模型完全一致;更换模型会让图片请求立即返回 -`vision_not_enabled`,只有新模型完成视觉协议和 Usage 烟测后才能更新精确绑定。不要用 -宽泛前缀、正则或浏览器传入的模型名放行,纯文本请求不依赖该绑定。 +遗留视觉实现保留 `CHAT_VISION_ENABLED` 与 `CHAT_VISION_MODEL` 双重失败关闭门禁;生产 +视觉开关必须为 `false`,关闭态不配置或更换模型,也不运行视觉协议或 Usage 烟测。 +精确模型匹配、拒绝宽泛前缀/正则和拒绝浏览器模型名的代码只作为安全兼容边界保留。 -仓库 Actions Variables 必须配置正整数 `MAX_BACKUP_ARTIFACT_BYTES` 和非负整数 `MAX_STORAGE_OBJECTS`;前者同时限制引用图片总字节与最终密文大小,后者限制单次快照引用对象数。每月按最近一次实际密文估算 14 份完整快照的容量,并检查备份耗时和 Runner 磁盘趋势。备份脚本先从数据库快照生成精确对象计划,再逐个下载被引用对象;未引用对象不会进入下载阶段。清理队列积压、对象计划异常增长或备份耗时异常时,应先停止图片功能发布并处理删除队列与死信。 +仓库 Actions Variables 必须配置正整数 `MAX_BACKUP_ARTIFACT_BYTES` 和非负整数 `MAX_STORAGE_OBJECTS`;前者同时限制引用图片总字节与最终密文大小,后者限制单次快照引用对象数。每月按最近一次实际密文估算 14 份完整快照的容量,并检查备份耗时和 Runner 磁盘趋势。备份脚本先从数据库快照生成精确对象计划,再逐个下载被引用对象;未引用对象不会进入下载阶段。清理队列积压、对象计划异常增长或备份耗时异常时,应阻塞发布并按遗留数据保护事件处理删除队列与死信。 本项目已选择“禁止恢复到最近一次注销事件之前”的策略。`delete-account` 的目标绑定数据库租约必须覆盖完整临界区:取得 owner/target 租约 → 使用仅有目标仓库 Variables write 的 fine-grained PAT 更新 `BACKUP_RECOVERY_NOT_BEFORE` 并回读确认 → 续期并停止外部阶段心跳 → 调用最终删除 RPC。RPC 对租约行和目标 Profile `FOR UPDATE`,重新验证 owner、target、有效期、角色与活动同步,设置事务内 fence 标记,并在同一事务删除 `auth.users` 与消费租约;Auth 触发器拒绝没有匹配标记的旧 HTTP/旁路删除,使 migration 与 Edge Function 的部署切换也保持失败关闭。租约取得、删除前续期或恢复记录失败时不得进入最终 RPC,并返回 `503`;管理员、活动同步、Storage 所有权或其他受控约束拒绝删除时返回 `409`。最终事务由数据库行锁 fencing,不依赖 Edge Runtime 定时器。最终 RPC 抛出传输错误、返回错误或响应契约损坏时,Edge 并行只读核对 Auth 与 Profile;仅两者均明确不存在才确认提交成功,仍存在、状态分裂、模糊 Auth 404 或任一查询失败均重抛原错误。该变量不含成员身份,只保存带一小时并发/时钟安全余量的 UTC 恢复下限。 @@ -129,7 +128,7 @@ npx --yes supabase@2.109.1 functions deploy sync-member sync-stats sync-avatar m --use-api --import-map supabase/functions/deno.json ``` -WebChat / AI 学习助手已退出正式产品范围。遗留代码、Schema、函数和后台配置只允许在关闭态维护:`VITE_WEBCHAT_UI_ENABLED=false`、`CHAT_ENABLED=false`、数据库 `requests_enabled=false`,且不向 GitHub 或 Supabase 新增、轮换或读取中转站运行凭据。只有安全修复或 Schema 兼容需要时,才可使用仓库 import map 部署遗留 WebChat 函数;不得运行付费兼容性验收、缓存探针、模型请求、成员授权或额度烟测,也不得设置任何开关为 `true`。每次关闭态维护后必须运行严格 Supabase readiness 和生产安全检查,确认匿名、普通成员和管理员请求均失败关闭、入口仍不可见且没有新请求/额度账目。历史协议说明保留在 [WebChat 中转站兼容性验收](./webchat-relay-compatibility.md),不构成启用步骤。 +WebChat / AI 学习助手已退出正式产品范围。遗留代码、Schema、函数和后台配置只允许在关闭态维护:`VITE_WEBCHAT_UI_ENABLED=false`、`CHAT_ENABLED=false`、数据库 `requests_enabled=false`,且不向 GitHub 或 Supabase 新增、轮换或读取中转站运行凭据。只有安全修复或 Schema 兼容需要时,才可使用仓库 import map 部署遗留 WebChat 函数;不得运行付费兼容性验收、缓存探针、模型请求、成员授权或额度烟测,也不得设置任何开关为 `true`。每次关闭态维护后必须运行严格 Supabase readiness 和生产安全检查,确认匿名、普通成员和管理员发起的新 AI 消息、配置写入、成员授权和额度变更请求均失败关闭,成员入口仍不可见且没有新增请求或额度账目;本人历史读取/删除和管理员只读诊断继续按保留的数据保护边界工作。历史协议说明保留在 [WebChat 中转站兼容性验收](./webchat-relay-compatibility.md),不构成启用步骤。 Pages 的遗留 WebChat 客户端变量 `VITE_WEBCHAT_UI_ENABLED` 必须固定为小写 `false`;未配置时 workflow 也固定回退为 `false`,不得在生产构建中设置为 `true`。生产客户端从同一次构建的 `VITE_SUPABASE_URL` 推导当前项目的 `/functions/v1/webchat`,不得用覆盖 URL 把成员 Supabase 登录 Token 发送到其他域名。 @@ -191,12 +190,15 @@ git worktree add ..\ustsacmland-rollback Set-Location ..\ustsacmland-rollback npx --yes supabase@2.109.1 functions deploy sync-member sync-stats sync-avatar member-avatar delete-account change-password ` --use-api --import-map supabase/functions/deno.json -npx --yes supabase@2.109.1 functions deploy firecrawl-config webchat webchat-attachment webchat-image-cleanup webchat-config webchat-cache-probe ` +npx --yes supabase@2.109.1 functions deploy firecrawl-config ` --use-api --import-map supabase/functions/deno.json npm run check:supabase-readiness ``` -完成后回到主工作区并删除临时 worktree。若旧函数不兼容新 Schema,不回滚函数,改为从主分支发布最小修复版本。 +遗留 WebChat 函数不随常规回滚部署。只有关闭态安全或 Schema 兼容修复明确涉及某个 +遗留函数时,才在单独批准的范围内只部署该函数,并再次确认全部产品开关为 `false`、 +没有新增请求或额度记录。完成后回到主工作区并删除临时 worktree。若旧函数不兼容新 +Schema,不回滚函数,改为从主分支发布最小修复版本。 ### 4.3 数据库回滚 @@ -207,7 +209,7 @@ npm run check:supabase-readiness - 已发生数据破坏:立即暂停同步和管理写入,记录时间窗口,评估 Supabase PITR/备份恢复。 - 只有整库灾难恢复才考虑使用备份;必须先用 GitHub 当前 `BACKUP_RECOVERY_NOT_BEFORE` 运行恢复下限校验,再核对 Auth、业务表、审计匿名化和私有 `webchat-images` 对象。校验失败时禁止恢复旧备份。 -恢复前不得覆盖唯一可用备份。所有恢复操作先在隔离项目演练,并保存恢复点、8 个聚合行数、7 类孤儿关系、Storage 数量/字节/哈希、匿名访问拒绝和抽样结果。Schema v2 的零图片对象恢复已经通过,但图片正式开放前仍必须使用包含真实非空图片对象的 Artifact 完成贯通演练;零对象结果不能替代。 +恢复前不得覆盖唯一可用备份。所有恢复操作先在隔离项目演练,并保存恢复点、8 个聚合行数、7 类孤儿关系、Storage 数量/字节/哈希、匿名访问拒绝和抽样结果。Schema v2 的零图片对象恢复已经通过;图片功能已经退役,不再为功能开放制造非空夹具或安排开放态演练。若真实备份包含遗留图片对象,恢复演练仍必须逐项验证这些对象,这是数据保护要求。 ## 5. 凭据轮换 @@ -215,19 +217,22 @@ npm run check:supabase-readiness 生产环境按产品决定不配置外部告警 Webhook;同步最终状态改由后台同步中心、数据源健康页和审计记录巡检。当前 `DELETION_RECOVERY_REPOSITORY` 与 `DELETION_RECOVERY_GITHUB_TOKEN` 已配置,Token 已收敛为只授权目标仓库 Variables 读写的 fine-grained Token;维护端单调前推并回读恢复下限、Edge 自主写入、真实注销与残留清理烟测均已通过。Secret 审计工具只读取名称,不输出真实值或摘要。 -| 凭据 | 存放位置 | 轮换后验证 | -| ------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| Firecrawl API Key | 管理后台私有 Vault Key 池;Function Secret 仅为兼容回退 | 逐 Key 健康/额度、受控冷却轮换、牛客回退和 QOJ 登录会话清理 | -| QOJ 服务账号密码 | QOJ + Supabase Function Secrets | 每个 attempt 单次登录、目标主页匹配、会话最终关闭;可恢复错误最多一次队列重试 | -| 洛谷 Cookie + CSRF | Supabase Function Secrets,必须成对更新 | 公开 UID 校验、Accepted 分页、仅 P/B 题去重 | -| 同步队列调度 Token | Supabase Function Secret + Vault | 暂停 cron 后同步轮换两处,手动调用与下一次 cron 均为 2xx | -| Supabase access token | GitHub Actions/维护者本机安全存储 | migration list 与函数部署只访问目标项目 | -| Supabase service role key | Edge/GitHub 受控 Secret | 计划同步、队列领取和管理员函数正常;浏览器包中不存在该值 | -| 遗留 WebChat 配置 | Supabase Vault(如历史残留) | 只读确认三层关闭,按维护策略删除或轮换;不运行兼容性烟测,不向前端、日志或审计暴露 | -| Supabase 数据库密码/URI | GitHub Actions Secret、密码管理器 | 手动备份 dry run 与一次受控加密备份成功;日志不含 URI | -| 数据库备份加密口令 | GitHub Actions Secret、密码管理器 | 下载最新 Artifact,在隔离目录完成解密和 SHA256 校验 | -| 注销恢复下限 GitHub Token | Supabase Function Secret | 仅 Variables write;受控注销前后变量单调前移且不含身份信息 | -| 管理员密码 | Supabase Auth | 新密码登录、旧会话按策略失效、恢复邮箱可用 | +遗留 WebChat 配置不进入凭据轮换流程。若 Supabase Vault 仍有历史残留,只允许通过 +Secret 名称和关闭状态确认三层关闭,不读取值、不轮换、不运行兼容性烟测。未来若要 +删除遗留 Secret,必须把目标名称和影响范围作为单独的生产 Secret 变更取得批准。 + +| 凭据 | 存放位置 | 轮换后验证 | +| ------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Firecrawl API Key | 管理后台私有 Vault Key 池;Function Secret 仅为兼容回退 | 逐 Key 健康/额度、受控冷却轮换、牛客回退和 QOJ 登录会话清理 | +| QOJ 服务账号密码 | QOJ + Supabase Function Secrets | 每个 attempt 单次登录、目标主页匹配、会话最终关闭;可恢复错误最多一次队列重试 | +| 洛谷 Cookie + CSRF | Supabase Function Secrets,必须成对更新 | 公开 UID 校验、Accepted 分页、仅 P/B 题去重 | +| 同步队列调度 Token | Supabase Function Secret + Vault | 暂停 cron 后同步轮换两处,手动调用与下一次 cron 均为 2xx | +| Supabase access token | GitHub Actions/维护者本机安全存储 | migration list 与函数部署只访问目标项目 | +| Supabase service role key | Edge/GitHub 受控 Secret | 计划同步、队列领取和管理员函数正常;浏览器包中不存在该值 | +| Supabase 数据库密码/URI | GitHub Actions Secret、密码管理器 | 手动备份 dry run 与一次受控加密备份成功;日志不含 URI | +| 数据库备份加密口令 | GitHub Actions Secret、密码管理器 | 下载最新 Artifact,在隔离目录完成解密和 SHA256 校验 | +| 注销恢复下限 GitHub Token | Supabase Function Secret | 仅 Variables write;受控注销前后变量单调前移且不含身份信息 | +| 管理员密码 | Supabase Auth | 新密码登录、旧会话按策略失效、恢复邮箱可用 | 轮换后搜索近期日志只能检查字段名或错误码,禁止搜索并输出 Secret 本身。若凭据疑似泄露,先撤销、再调查影响范围;不要继续使用旧值“观察是否被滥用”。 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 81a737e..619204f 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -94,11 +94,11 @@ - [ ] 生产凭据轮换人、存放位置和回滚方式已记录;未把真实值复制到发布记录。 - [ ] Firecrawl 用量、QOJ 登录、洛谷认证和 Supabase 配额均处于可用状态。 - [ ] 使用与生产 `FIRECRAWL_API_KEY` 相同团队的维护者凭据运行 `firecrawl credit-usage --json --pretty`;剩余比例高于 25%,或已记录扩容/降耗措施。不得把 API Key 或完整凭据配置写入发布记录。 -- 遗留关闭检查:不再启用、更换或验收 WebChat 中转站;不运行付费兼容性或缓存探针。若仍保存旧 Key,必须继续只存在 Vault 并按维护策略轮换或删除,不得进入前端和日志。 +- 遗留关闭检查:不再启用、更换或验收 WebChat 中转站;不运行付费兼容性或缓存探针。若仍保存旧 Key,只确认它位于 Vault 且三层开关关闭,不读取值、不轮换,也不得进入前端和日志;未来若删除,必须单独取得生产 Secret 变更批准。 - 历史记录:`npm run test:e2e:webchat` 曾通过五浏览器矩阵与并发、Abort、减少动画和移动端 axe 门禁;测试保留用于关闭态安全回归,不再推动功能开放。 -- 遗留关闭检查:管理员后台继续保留 WebChat 配置页,用于查看关闭状态和维护历史配置;任何管理操作都不得绕过三层关闭边界产生新请求、Token 预留或额度结算。历史账本继续保持私有且不可跨成员读取。 +- 遗留关闭检查:管理员后台继续保留 WebChat 配置页,只用于查看关闭状态、历史配置和历史用量;任何管理操作都不得绕过三层关闭边界产生新请求、修改配置或成员权限、预留 Token 或结算额度。历史账本继续保持私有且不可跨成员读取。 - 历史记录:`/assistant` 的模型、额度和私有账本隔离曾通过本地与 Pages 验证;生产现保持关闭。 -- 关闭顺序:数据库请求开关、`CHAT_ENABLED`、`VITE_WEBCHAT_UI_ENABLED` 和 `VITE_WEBCHAT_IMAGE_INPUT_ENABLED` 全部保持 `false`。不再保留“启用顺序”作为发布操作。 +- 关闭状态:数据库请求开关、`CHAT_ENABLED`、`VITE_WEBCHAT_UI_ENABLED` 和 `VITE_WEBCHAT_IMAGE_INPUT_ENABLED` 全部保持 `false`。不再保留“启用顺序”作为发布操作。 ## 6. 前端与可访问性烟测 diff --git a/docs/webchat-conversation-history.md b/docs/webchat-conversation-history.md index 5c68463..478ddd7 100644 --- a/docs/webchat-conversation-history.md +++ b/docs/webchat-conversation-history.md @@ -1,14 +1,18 @@ -# WebChat 私有历史会话 +# WebChat 私有会话历史契约 -本文定义 AI 学习助手的刷新恢复、历史会话和正文保留边界。实现由 `202607180005_webchat_conversation_history.sql`、前端 assistant-ui 线程适配器和站内隐私说明共同约束。 +> 归档边界:WebChat / AI 学习助手已经退出产品范围。本文只解释保留的私有历史 +> Schema、RPC、前端代码和数据生命周期,不是成员入口、启用或生产对话验收手册。 +> 生产请求和界面必须保持关闭,不得为了验证历史功能发送模型请求或重新开放入口。 + +保留实现由 `202607180005_webchat_conversation_history.sql`、前端 assistant-ui 线程适配器和站内隐私说明共同约束。关闭态维护继续保护已有历史数据的账号隔离、保留期、账号绑定删除 RPC 和注销级联。 ## 数据边界 - `private.webchat_conversations` 保存当前用户的会话标题、状态、消息数、内容体积、版本和活动时间。 - `private.webchat_messages` 保存 `ai-sdk/v6` 消息内容、父消息 ID 和稳定位置。 - 浏览器、`authenticated` 与 `service_role` 都没有两张表的直表读取权限。 -- 浏览器只能调用绑定 `auth.uid()` 的 own-history RPC;接口不接受目标用户 ID,因此普通管理员也不能借助站内后台读取成员正文。 -- 生产浏览器只在 `localStorage` 保存当前会话 UUID,用于刷新后选择同一线程;正文由 Supabase 私有数据库保存。未配置 Supabase 的本地演示环境使用按演示账号隔离的本机存储。 +- 关闭态浏览器只保留绑定 `auth.uid()` 的 own-history 列表、正文读取和删除 RPC;接口不接受目标用户 ID,因此普通管理员也不能借助站内后台读取成员正文。创建、重命名、归档和消息 upsert RPC 已对 `anon`、`authenticated` 与 `service_role` 撤销执行权限。 +- 历史客户端只在 `localStorage` 保存当前会话 UUID,用于刷新后选择同一线程;正文由 Supabase 私有数据库保存。未配置 Supabase 的本地演示实现使用按演示账号隔离的本机存储。 ## 上限与保留 @@ -16,22 +20,22 @@ - 每个会话最多 120 条消息。 - 单条序列化消息不超过 64 KiB,单会话序列化正文不超过 1 MiB。 - 会话最后活动超过 180 天后,由 `webchat-history-retention` 每日任务删除。 -- 用户删除单个会话时,消息随外键级联删除;永久注销 Profile 时,全部会话和消息级联删除。 +- own-history 删除 RPC 删除单个会话时,消息随外键级联删除;永久注销 Profile 时,全部会话和消息级联删除。 -消息 upsert 会锁定所属会话,校验父消息已经存在,并原子更新消息数、总字节、位置和版本。重复写入同一消息 ID 只更新内容,不重复计数。历史列表按 `(user_id, status, last_message_at, id)` 游标分页,每页 30 条。 +遗留消息 upsert 实现会锁定所属会话,校验父消息已经存在,并原子更新消息数、总字节、位置和版本;重复写入同一消息 ID 只更新内容,不重复计数。该实现仅为 Schema 兼容和事务回归测试保留,没有应用角色执行权。历史列表按 `(user_id, status, last_message_at, id)` 游标分页,每页 30 条。 -## 客户端行为 +## 历史客户端行为 -- 一次生成结束后,assistant-ui 历史适配器保存本轮用户消息和模型可见回复。 -- 刷新页面会读取上次活动会话 UUID,再从私有 RPC 恢复消息链。 -- “新建对话”创建新的空线程;侧栏可切换或删除历史线程。 -- 请求已经提交但首个可见正文尚未到达时显示“思考中”;首个正文、失败、停止或超时都会清除该状态。 +- 历史 assistant-ui 适配器曾在生成结束后保存本轮用户消息和模型可见回复。 +- 历史界面曾读取上次活动会话 UUID,并通过私有 RPC 恢复、切换或删除线程。 +- 历史请求在首个可见正文到达前显示“思考中”,并在正文、失败、停止或超时后清除该状态。 - 历史持久化不参与付费请求 claim,不会额外扣减成员累计额度或全站日预算。 -## 发布检查 +上述客户端代码只为兼容和审计保留,不构成当前成员入口,也不要求继续完成交互验收。 + +## 关闭态维护检查 -1. 应用 migration,并运行 `30_webchat_conversation_history.test.sql`,确认表权限、账号隔离、管理员隔离、上限和 180 天清理。 -2. 部署前端后,使用已授权账号发送一轮对话,刷新并继续提问,再新建线程并切回旧线程。 -3. 确认删除会话后 own-history RPC 不再返回元数据和消息。 -4. 确认管理员后台没有正文入口,数据库审计或错误日志不记录消息内容。 -5. 核对 `/privacy`、`PRIVACY.md` 和 `docs/data-lifecycle.md` 与实际保留期一致。 +1. 运行 `30_webchat_conversation_history.test.sql` 与 `51_webchat_retired_mutations.test.sql`,确认写 RPC 撤权、保留的读取/删除权限、表权限、账号隔离、管理员隔离、上限、删除级联和 180 天清理。 +2. 通过本地/CI 测试确认 own-history RPC 删除后不再返回元数据和消息;不得临时恢复生产写权限、发送真实模型请求或使用生产成员验证。 +3. 确认管理员后台没有正文入口,数据库审计或错误日志不记录消息内容。 +4. 核对 `/privacy`、`PRIVACY.md` 和 `docs/data-lifecycle.md` 与实际保留期一致。 diff --git a/docs/webchat-image-input-v1.md b/docs/webchat-image-input-v1.md index 70b4015..0d480d7 100644 --- a/docs/webchat-image-input-v1.md +++ b/docs/webchat-image-input-v1.md @@ -1,7 +1,12 @@ -# WebChat 图片输入 v1 契约 +# WebChat 图片输入 v1 历史契约 -本文锁定第一版图片输入的边界。图片功能在本文、对应 migration、Edge Function -和自动化测试全部完成前保持关闭,不把“前端能选择文件”视为已上线。 +> 归档边界:WebChat 图片输入已经退出产品范围。本文只解释仓库中保留的代码、 +> Schema、migration、Edge Function、测试和历史数据约束,不是开发、启用或生产验收 +> 手册。生产图片入口、视觉模型和定时清理必须保持关闭,不得运行付费模型、Usage 或 +> 开放态烟测,也不得把任何图片开关设为 `true`。历史未完成项已经取消,不是下一步。 + +下列约束继续用于关闭态安全、Schema 兼容、备份、注销和数据保护回归;保留实现不能 +被解释为图片功能已上线或可以恢复开发。 ## 请求与限制 @@ -23,27 +28,27 @@ 字节流,检查 magic bytes、实际解码、尺寸、像素数、动画帧和解压资源上限。文件 名、扩展名和浏览器声明的 MIME 只能作为提示,不能作为安全判断。 -## 上传流程 +## 历史上传流程 -1. 登录成员通过 `webchat-attachment` Edge Function 创建并上传一个临时附件。 - 请求只接受 `multipart/form-data`,身份来自 Supabase Bearer Token;客户端不能 - 提供对象路径、用户 ID 或目标成员 ID。 -2. 服务端验证当前账号已启用 WebChat、会话属于当前账号,并在读取过程中执行 - 体积上限和超时限制。验证通过后完整解码并重新编码,去除 EXIF、原始文件名、 - 尾随数据和动画帧。 -3. 服务端生成随机对象键 +1. 历史客户端通过 `webchat-attachment` Edge Function 创建并上传临时附件;请求只 + 接受 `multipart/form-data`,身份来自 Supabase Bearer Token,且客户端不能提供 + 对象路径、用户 ID 或目标成员 ID。 +2. 历史服务端设计为验证账号 WebChat 权限和会话归属,并在读取过程中执行体积上限 + 与超时限制;验证通过后完整解码并重新编码,去除 EXIF、原始文件名、尾随数据和 + 动画帧。 +3. 历史服务端生成随机对象键 `user//conversation//attachment/.webp`,把规范化文件写入 私有 `webchat-images` Bucket,再以 `ready` 状态写入附件元数据。客户端永远 不直接写 `storage.objects`。规范化完成后、写入 Storage 前必须续租验证 lease; 外部写入最长 120 秒,删除队列至少等待 lease 到期后的 2 分钟安全窗口,避免 迟到写入在清理完成后重新生成固定对象键。 -4. 预览和上游模型读取分别使用不超过 120 秒的短时签名 URL。签名 URL 不写入 +4. 历史预览和上游模型读取使用不超过 120 秒的短时签名 URL。签名 URL 不写入 消息历史、个人导出、审计详情或日志。 -5. 发送消息时只提交附件 UUID。数据库事务验证附件属于当前账号和会话、状态为 - `ready` 且未被其他消息使用,然后把它转为 `attached`。同一附件重复提交必须 - 幂等,不得重复计数或重复计奖。 +5. 历史发送流程只提交附件 UUID;数据库事务验证附件属于当前账号和会话、状态为 + `ready` 且未被其他消息使用,再把它转为 `attached`。同一附件重复提交保持幂等, + 不重复计数或重复计奖。 -## 消息协议 +## 历史消息协议 历史消息沿用 `ai-sdk/v6` 格式,但图片 part 只允许以下不含可访问 URL 的引用: @@ -60,15 +65,13 @@ 包含文本;整条消息至少要有一个非空文本 part 或一个有效图片 part。历史恢复时 前端根据 UUID 请求短时预览,不把二进制写进 localStorage 或数据库 JSON。 -发往视觉模型的 Responses 输入使用 `input_image`,由服务端将规范化对象读取后 -转换为短时 URL 或受控 data URL;客户端不能选择模型、细节级别或上游 URL。当前 -模型不明确支持图片,或运行时配置未显式开启视觉能力时,服务端返回结构化 -`vision_not_enabled`,不能静默丢弃图片、OCR 替代或自动切换模型。 -`CHAT_VISION_ENABLED=true` 还不构成授权:运行时数据库模型必须与服务端 -`CHAT_VISION_MODEL` 完全一致。管理员更换模型后,图片请求自动恢复失败关闭,直到新模型 -完成视觉协议烟测并由维护者更新该服务端绑定;纯文本对话不受影响。 +历史 Responses 设计使用 `input_image`,由服务端将规范化对象读取后转换为短时 URL +或受控 data URL;客户端不能选择模型、细节级别或上游 URL。保留实现继续要求视觉 +开关关闭时返回结构化 `vision_not_enabled`,不能静默丢弃图片、OCR 替代或自动切换 +模型。精确模型绑定只作为失败关闭的兼容约束保留;关闭态不得设置视觉开关、配置或 +更换模型,也不得运行视觉协议或 Usage 烟测。 -## 配额 +## 历史配额设计 图片请求沿用现有单次 claim、累计请求上限、累计 Token 上限和分阶段结算状态机。 预留值包含文本 JSON、固定输出上限和每张图片按 32 px patch 的保守上限;成功后 @@ -94,22 +97,25 @@ 对象均已处理;有活动对象时失败关闭并返回受控 `409`。 - 个人导出只包含本人附件数量、规范 MIME、尺寸、字节数和时间,不包含二进制、 原始文件名、对象键、哈希或签名 URL。 -- 加密数据库备份之外必须增加私有 Bucket 对象的加密清单/对象备份和隔离恢复校验; - 清单必须来自 `storage.objects.metadata` 的真实 MIME、Cache-Control 和字节数,恢复后 - 重新查询对象元数据逐项比对;在该流程上线前不得把图片功能标记为生产完成。 -- 定时清理任务必须由仓库变量 `WEBCHAT_IMAGE_CLEANUP_ENABLED=true` 显式启用;图片 - migration 和清理函数未部署时保持变量缺失或为 `false`,仅允许受控手工执行。 +- 加密数据库备份必须继续包含数据库快照引用的私有 Bucket 对象、加密清单和隔离恢复 + 校验;清单来自 `storage.objects.metadata` 的真实 MIME、Cache-Control 和字节数, + 恢复后重新查询对象元数据逐项比对。这是遗留数据保护边界,不是功能开放门禁。 +- `WEBCHAT_IMAGE_CLEANUP_ENABLED` 必须保持缺失或为 `false`。注销、数据保护或 Schema + 兼容确需处理遗留对象时,只能作为关闭态维护按生产变更审批执行,不得启用定时清理。 -## 关闭条件与验证 +## 关闭态维护验证 -视觉模型能力、Storage 对象备份、解码/重编码运行时和数据保留策略任一未确认时, -服务端图片入口保持失败关闭。至少需要: +生产图片入口始终保持失败关闭。维护只验证保留实现没有破坏安全、备份、注销和数据 +保护边界: - Deno 单测覆盖签名、尺寸、像素、动画、EXIF、解码失败和资源耗尽; - pgTAP 覆盖 RLS、跨成员访问、附件状态机、消息绑定、并发与删除围栏; -- 浏览器覆盖粘贴、选择、预览、移除、发送、刷新恢复、超限、模型不兼容、上游前 - 失败释放、上游后未知用量保守结算、390px 移动端、键盘和无障碍; -- 受控生产烟测确认真实视觉模型响应、真实 Usage、对象清理和备份恢复。 +- 备份恢复验证数据库引用、对象数量、字节、哈希、MIME、Cache-Control 和匿名拒绝; +- 严格 Supabase readiness 与生产安全检查确认上传、请求、模型和定时清理入口均关闭, + 且没有新增请求、额度或对象记录。 + +历史开发阶段尚未完成的开放态浏览器、真实视觉模型、Usage 和非空夹具验收已随功能 +退役而取消,不得为了补齐历史记录重新执行。 参考: diff --git a/package.json b/package.json index 22790ad..bee4cfb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ }, "scripts": { "dev": "vite", - "build": "tsc -b && vite build && node scripts/copy-spa-fallback.mjs && node scripts/check-site-metadata.mjs && node scripts/check-bundle-size.mjs", + "build": "tsc -b && node scripts/generate-third-party-licenses.mjs --build && node scripts/copy-spa-fallback.mjs && node scripts/generate-third-party-licenses.mjs --check && node scripts/check-site-metadata.mjs && node scripts/check-bundle-size.mjs", + "licenses:generate": "node scripts/generate-third-party-licenses.mjs --build", + "licenses:check": "node scripts/generate-third-party-licenses.mjs --check", "check:bundle": "node scripts/check-bundle-size.mjs", "check:cloudflare-domain": "node scripts/check-cloudflare-domain.mjs", "check:backup-workflow": "node scripts/check-database-backup-workflow.mjs", diff --git a/scripts/check-ci-workflow.test.ts b/scripts/check-ci-workflow.test.ts index c39b327..a880cb4 100644 --- a/scripts/check-ci-workflow.test.ts +++ b/scripts/check-ci-workflow.test.ts @@ -38,8 +38,8 @@ describe('CI workflow', () => { imageCleanupWorkflow, ), ).toEqual({ - fileCount: 50, - assertionCount: 1243, + fileCount: 51, + assertionCount: 1247, releaseMigrationCount: 54, }) }) diff --git a/scripts/check-repository-readiness.mjs b/scripts/check-repository-readiness.mjs index 6d26c18..241eb19 100644 --- a/scripts/check-repository-readiness.mjs +++ b/scripts/check-repository-readiness.mjs @@ -273,7 +273,30 @@ export function classifyGhFailure(error) { return 'GitHub CLI 返回未知错误' } -function runGhJson(args, label) { +function isGhNotFoundFailure(error) { + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? Buffer.isBuffer(error.stderr) + ? error.stderr.toString('utf8') + : String(error.stderr ?? '') + : '' + const message = error instanceof Error ? error.message : '' + return /HTTP 404|not found/i.test(`${message}\n${stderr}`) +} + +export function describeProductionEnvironmentReadFailure(error) { + if (isGhNotFoundFailure(error)) { + return `${productionEnvironmentName} Environment 不存在或当前 GitHub Token 无权读取;请创建该 Environment 并授予当前 Token Actions 读取权限。` + } + return `无法读取 ${productionEnvironmentName} Environment:${classifyGhFailure(error)}。` +} + +export function collectSecretNames(response) { + const pages = Array.isArray(response) ? response : [response] + return pages.flatMap((page) => (page?.secrets ?? []).map((secret) => secret.name).filter(Boolean)) +} + +function runGhJson(args, label, options = {}) { try { const output = execFileSync('gh', args, { encoding: 'utf8', @@ -283,6 +306,9 @@ function runGhJson(args, label) { }) return output.trim() ? JSON.parse(output) : null } catch (error) { + if (options.failureMessage) { + throw new Error(options.failureMessage(error)) + } throw new Error(`无法读取 ${label}:${classifyGhFailure(error)}。`) } } @@ -292,6 +318,10 @@ function listNames(args, label) { return Array.isArray(rows) ? rows.map((row) => row.name).filter(Boolean) : [] } +function listPagedSecretNames(args, label) { + return collectSecretNames(runGhJson([...args, '--paginate', '--slurp'], label)) +} + export function collectRepositoryReadinessState(repositoryName) { const repoViewArgs = repositoryName ? ['repo', 'view', repositoryName, '--json', 'nameWithOwner,defaultBranchRef'] @@ -333,6 +363,9 @@ export function collectRepositoryReadinessState(repositoryName) { const productionEnvironment = runGhJson( ['api', `${apiRoot}/environments/${productionEnvironmentName}`], `${productionEnvironmentName} Environment`, + { + failureMessage: describeProductionEnvironmentReadFailure, + }, ) const environmentSecretsResponse = runGhJson( ['api', `${apiRoot}/environments/${productionEnvironmentName}/secrets?per_page=100`], @@ -346,11 +379,11 @@ export function collectRepositoryReadinessState(repositoryName) { `${productionEnvironmentName} Environment 分支策略`, ) const organizationSecretsResponse = organizationOwner - ? runGhJson( + ? listPagedSecretNames( ['api', `orgs/${organizationOwner}/actions/secrets?per_page=100`], '组织级 Actions Secret 名称', ) - : null + : [] const workflows = (workflowResponse.workflows ?? []).map((workflow) => { const expected = expectedWorkflows.find((item) => item.name === workflow.name) let contentMatches = false @@ -413,9 +446,7 @@ export function collectRepositoryReadinessState(repositoryName) { type: policy.type, })), }, - organizationActionSecrets: (organizationSecretsResponse?.secrets ?? []) - .map((secret) => secret.name) - .filter(Boolean), + organizationActionSecrets: organizationSecretsResponse, actionsPermissions: { defaultWorkflowPermissions: actionsPermissions.default_workflow_permissions, canApprovePullRequestReviews: actionsPermissions.can_approve_pull_request_reviews, diff --git a/scripts/check-repository-readiness.test.ts b/scripts/check-repository-readiness.test.ts index 2af8b98..71360cd 100644 --- a/scripts/check-repository-readiness.test.ts +++ b/scripts/check-repository-readiness.test.ts @@ -1,5 +1,7 @@ import { classifyGhFailure, + collectSecretNames, + describeProductionEnvironmentReadFailure, evaluateRepositoryReadiness, expectedWorkflows, requiredActionSecrets, @@ -117,6 +119,25 @@ describe('repository readiness checker', () => { ) }) + it('collects every organization Secret name returned by paginated GitHub API responses', () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ name: `SECRET_${index}` })) + + expect( + collectSecretNames([ + { secrets: firstPage }, + { secrets: [{ name: 'SUPABASE_SERVICE_ROLE_KEY' }] }, + ]), + ).toEqual([...firstPage.map((secret) => secret.name), 'SUPABASE_SERVICE_ROLE_KEY']) + }) + + it('explains an unavailable production environment without exposing the GitHub response', () => { + expect( + describeProductionEnvironmentReadFailure({ stderr: 'HTTP 404: environment not found' }), + ).toBe( + 'production-operations Environment 不存在或当前 GitHub Token 无权读取;请创建该 Environment 并授予当前 Token Actions 读取权限。', + ) + }) + it('accepts a repository that satisfies the release settings contract', () => { expect(evaluateRepositoryReadiness(createReadyState())).toMatchObject({ errors: [], @@ -181,6 +202,15 @@ describe('repository readiness checker', () => { ) }) + it('fails closed with a specific blocker when the production environment is unavailable', () => { + const state = createReadyState() + state.productionEnvironment = null + + expect(evaluateRepositoryReadiness(state).errors).toContain( + '缺少 production-operations Environment。', + ) + }) + it('rejects production credentials copied to repository or organization secrets', () => { const state = createReadyState() state.actionSecrets.push('SUPABASE_PROJECT_REF') diff --git a/scripts/generate-third-party-licenses.mjs b/scripts/generate-third-party-licenses.mjs new file mode 100644 index 0000000..744afe6 --- /dev/null +++ b/scripts/generate-third-party-licenses.mjs @@ -0,0 +1,529 @@ +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const runtimeLicenseFileName = 'THIRD_PARTY_LICENSES.txt' + +export const requiredRuntimeLicensePackages = [ + '@ai-sdk/react', + '@supabase/supabase-js', + 'ai', + 'lucide-react', + 'react', +] + +export const distributionFiles = [ + { source: 'LICENSE', target: 'LICENSE.txt' }, + { source: 'THIRD_PARTY_NOTICES.md', target: 'THIRD_PARTY_NOTICES.md' }, +] + +const CURATED_LICENSE_MATERIALS = new Map([ + [ + '@ai-sdk/provider-utils@5.0.10', + ['scripts/third-party-license-materials/ai-sdk-provider-utils-5.0.10-LICENSE.txt'], + ], + [ + 'use-composed-ref@1.4.0', + ['scripts/third-party-license-materials/use-composed-ref-1.4.0-LICENSE.txt'], + ], +]) + +const LICENSE_FILE_PATTERN = /^(?:licen[cs]e|copying|copyright|notice|patents)(?:[._-]|$)/i +const INVALID_LICENSE_DECLARATION_PATTERN = /^(?:unlicen[cs]ed|proprietary|none)$/i +const REFERENCED_LICENSE_DECLARATION_PATTERN = /^SEE LICEN[CS]E IN (.+)$/i + +function normalizeNewlines(value) { + return value.replace(/\r\n?/g, '\n') +} + +function normalizeLocation(value) { + return value.replaceAll('\\', '/') +} + +function compareText(left, right) { + if (left === right) return 0 + return left < right ? -1 : 1 +} + +function packageLicenseDeclaration(packageJson) { + if (typeof packageJson.license === 'string' && packageJson.license.trim()) { + return packageJson.license.trim() + } + + if (Array.isArray(packageJson.licenses)) { + const declarations = packageJson.licenses + .map((license) => (typeof license === 'string' ? license : license?.type)) + .filter((license) => typeof license === 'string' && license.trim()) + .map((license) => license.trim()) + if (declarations.length > 0) return declarations.join(' OR ') + } + + return null +} + +function assertSafePackageLocation(location, rootDirectory) { + const normalized = normalizeLocation(location) + if ( + isAbsolute(location) || + !normalized.startsWith('node_modules/') || + normalized.split('/').includes('..') + ) { + throw new Error(`Unsafe package location in package-lock.json: ${location}`) + } + + const packageDirectory = resolve(rootDirectory, location) + const relativePath = relative(rootDirectory, packageDirectory) + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new Error(`Package location escapes the repository: ${location}`) + } + return packageDirectory +} + +async function licenseMaterialFiles(packageDirectory, currentDirectory = packageDirectory) { + const entries = await readdir(currentDirectory, { withFileTypes: true }) + const nested = await Promise.all( + entries.map(async (entry) => { + const path = join(currentDirectory, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules') return [] + return licenseMaterialFiles(packageDirectory, path) + } + if (!entry.isFile() || !LICENSE_FILE_PATTERN.test(entry.name)) return [] + + const content = normalizeNewlines(await readFile(path, 'utf8')).trim() + const materialPath = normalizeLocation(relative(packageDirectory, path)) + if (!content) throw new Error(`License material is empty: ${materialPath}`) + return [{ path: materialPath, content, packageLevel: currentDirectory === packageDirectory }] + }), + ) + + return nested.flat().sort((left, right) => compareText(left.path, right.path)) +} + +async function curatedLicenseMaterials(rootDirectory, packageName, packageVersion) { + const packageKey = `${packageName}@${packageVersion}` + const paths = CURATED_LICENSE_MATERIALS.get(packageKey) ?? [] + return Promise.all( + paths.map(async (materialPath) => { + let content + try { + content = normalizeNewlines( + await readFile(join(rootDirectory, materialPath), 'utf8'), + ).trim() + } catch { + throw new Error(`Curated license material is missing for ${packageKey}: ${materialPath}`) + } + if (!content) throw new Error(`Curated license material is empty: ${materialPath}`) + return { path: `curated/${normalizeLocation(materialPath)}`, content, packageLevel: true } + }), + ) +} + +function parseLockfile(contents, lockfilePath) { + let lockfile + try { + lockfile = JSON.parse(contents) + } catch (error) { + throw new Error(`Unable to parse ${lockfilePath}: ${error.message}`) + } + + if (lockfile.lockfileVersion !== 3 || !lockfile.packages || !lockfile.packages['']) { + throw new Error(`${lockfilePath} must use npm lockfileVersion 3 and contain a root package.`) + } + return lockfile +} + +async function readLockfile( + rootDirectory, + lockfilePath = join(rootDirectory, 'package-lock.json'), +) { + return parseLockfile(await readFile(lockfilePath, 'utf8'), lockfilePath) +} + +function normalizedModulePath(moduleId) { + let path = moduleId.startsWith('\0') ? moduleId.slice(1) : moduleId + path = path.split('?')[0] + if (path.startsWith('file:')) path = fileURLToPath(path) + return isAbsolute(path) ? resolve(path) : null +} + +export function runtimePackageLocationsFromViteOutput({ rootDirectory, lockfile, outputs }) { + const buildOutputs = Array.isArray(outputs) ? outputs : [outputs] + const modulePaths = new Set() + for (const buildOutput of buildOutputs) { + for (const output of buildOutput?.output ?? []) { + if (output.type !== 'chunk') continue + for (const moduleId of Object.keys(output.modules ?? {})) { + const modulePath = normalizedModulePath(moduleId) + if (modulePath) modulePaths.add(modulePath) + } + } + } + if (modulePaths.size === 0) throw new Error('Vite produced no runtime module inventory.') + + const candidates = Object.entries(lockfile.packages) + .filter(([location]) => location.startsWith('node_modules/')) + .map(([location]) => ({ + location: normalizeLocation(location), + directory: assertSafePackageLocation(location, rootDirectory), + })) + .sort((left, right) => right.directory.length - left.directory.length) + + const packageLocations = new Set() + const unmappedPackageModules = [] + const nodeModulesDirectory = resolve(rootDirectory, 'node_modules') + for (const modulePath of modulePaths) { + const owner = candidates.find( + (candidate) => + modulePath === candidate.directory || modulePath.startsWith(`${candidate.directory}${sep}`), + ) + if (owner) { + packageLocations.add(owner.location) + } else if ( + modulePath === nodeModulesDirectory || + modulePath.startsWith(`${nodeModulesDirectory}${sep}`) + ) { + unmappedPackageModules.push(normalizeLocation(relative(rootDirectory, modulePath))) + } + } + if (unmappedPackageModules.length > 0) { + throw new Error( + `Vite runtime modules did not map to package-lock.json: ${unmappedPackageModules.join(', ')}`, + ) + } + if (packageLocations.size === 0) { + throw new Error('Vite runtime modules did not map to installed production packages.') + } + return packageLocations +} + +export async function collectRuntimeLicenseEntries({ + rootDirectory = process.cwd(), + lockfilePath = join(rootDirectory, 'package-lock.json'), + includedPackageLocations, +} = {}) { + const lockfile = await readLockfile(rootDirectory, lockfilePath) + const requestedLocations = includedPackageLocations + ? new Set([...includedPackageLocations].map(normalizeLocation)) + : null + const collectedLocations = new Set() + const entries = [] + + for (const [location, lockedPackage] of Object.entries(lockfile.packages)) { + const normalized = normalizeLocation(location) + if ( + !normalized.startsWith('node_modules/') || + (!requestedLocations && lockedPackage.dev === true) || + (requestedLocations && !requestedLocations.has(normalized)) + ) { + continue + } + + const packageDirectory = assertSafePackageLocation(location, rootDirectory) + const packageJsonPath = join(packageDirectory, 'package.json') + let packageJson + try { + packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) + } catch (error) { + if (!requestedLocations && lockedPackage.optional === true && error?.code === 'ENOENT') + continue + throw new Error(`Required production package is not installed correctly: ${location}`) + } + + if (!packageJson.name || !packageJson.version) { + throw new Error(`Installed package metadata is incomplete: ${location}`) + } + if (packageJson.version !== lockedPackage.version) { + throw new Error( + `Installed package version does not match package-lock.json: ${packageJson.name} has ${packageJson.version}, expected ${lockedPackage.version}.`, + ) + } + + const declaredLicense = packageLicenseDeclaration(packageJson) + if (declaredLicense && INVALID_LICENSE_DECLARATION_PATTERN.test(declaredLicense)) { + throw new Error( + `Production package is not distributable: ${packageJson.name}@${packageJson.version} declares ${declaredLicense}.`, + ) + } + + const packageMaterials = await licenseMaterialFiles(packageDirectory) + const curatedMaterials = await curatedLicenseMaterials( + rootDirectory, + packageJson.name, + packageJson.version, + ) + const materials = [...curatedMaterials, ...packageMaterials].sort((left, right) => + compareText(left.path, right.path), + ) + if (!materials.some((material) => material.packageLevel)) { + throw new Error( + `Production package has no package-level license material: ${packageJson.name}@${packageJson.version}`, + ) + } + + const referencedLicense = declaredLicense?.match(REFERENCED_LICENSE_DECLARATION_PATTERN)?.[1] + if ( + referencedLicense && + !packageMaterials.some( + (material) => + material.path.toLowerCase() === normalizeLocation(referencedLicense).toLowerCase(), + ) + ) { + throw new Error( + `Production package references missing license material: ${packageJson.name}@${packageJson.version} expects ${referencedLicense}`, + ) + } + + entries.push({ + name: packageJson.name, + version: packageJson.version, + location: normalized, + declaredLicense, + materials, + }) + collectedLocations.add(normalized) + } + + if (requestedLocations) { + const missingLocations = [...requestedLocations].filter( + (location) => !collectedLocations.has(location), + ) + if (missingLocations.length > 0) { + throw new Error( + `Runtime package locations were not collected: ${missingLocations.join(', ')}`, + ) + } + } + if (entries.length === 0) { + throw new Error('No installed production packages were found for the license report.') + } + + return entries.sort( + (left, right) => + compareText(left.name, right.name) || + compareText(left.version, right.version) || + compareText(left.location, right.location), + ) +} + +export function verifyRuntimeLicenseEntries( + entries, + requiredPackages = requiredRuntimeLicensePackages, +) { + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error('The runtime license inventory is empty.') + } + if (entries.some((entry) => !entry.materials?.some((material) => material.packageLevel))) { + throw new Error('Every runtime package must include non-empty package-level license material.') + } + + const packageNames = new Set(entries.map((entry) => entry.name)) + const missingPackages = requiredPackages.filter((packageName) => !packageNames.has(packageName)) + if (missingPackages.length > 0) { + throw new Error( + `Runtime license inventory is missing required packages: ${missingPackages.join(', ')}`, + ) + } +} + +export function renderRuntimeLicenseReport(entries) { + const sections = entries.map((entry) => { + const header = [ + '================================================================================', + `Package: ${entry.name}@${entry.version}`, + `Installed path: ${entry.location}`, + `Declared license: ${entry.declaredLicense ?? 'not declared'}`, + ] + const materials = entry.materials.map( + (material) => + `\n----- BEGIN ${material.path} -----\n${material.content}\n----- END ${material.path} -----`, + ) + return `${header.join('\n')}\n${materials.join('\n')}` + }) + + return `${[ + 'THIRD-PARTY RUNTIME LICENSES', + '', + 'This file is generated deterministically from package-lock.json, installed package', + 'materials, and the modules included in the Vite production bundle. Do not edit it', + 'or commit dist/.', + '', + `Production package entries: ${entries.length}`, + '', + ...sections, + '', + ].join('\n')}` +} + +export function verifyRuntimeLicenseReport( + report, + requiredPackages = requiredRuntimeLicensePackages, +) { + if (typeof report !== 'string' || !report.trim()) { + throw new Error(`${runtimeLicenseFileName} is missing or empty.`) + } + if (!report.startsWith('THIRD-PARTY RUNTIME LICENSES\n')) { + throw new Error(`${runtimeLicenseFileName} has an invalid header.`) + } + + const missingPackages = requiredPackages.filter( + (packageName) => !report.includes(`Package: ${packageName}@`), + ) + if (missingPackages.length > 0) { + throw new Error(`${runtimeLicenseFileName} is missing packages: ${missingPackages.join(', ')}`) + } +} + +function packageLocationsFromReport(report) { + const locations = [...report.matchAll(/^Installed path: (node_modules\/.+)$/gm)].map( + (match) => match[1], + ) + if (locations.length === 0 || new Set(locations).size !== locations.length) { + throw new Error(`${runtimeLicenseFileName} has missing or duplicate package locations.`) + } + return new Set(locations) +} + +async function writeDistributionFiles(rootDirectory, outputDirectory) { + for (const file of distributionFiles) { + const source = await readFile(join(rootDirectory, file.source)) + if (!source.toString('utf8').trim()) throw new Error(`${file.source} is empty.`) + const targetPath = join(outputDirectory, file.target) + await writeFile(targetPath, source) + const written = await readFile(targetPath) + if (!written.equals(source)) throw new Error(`${file.target} was not copied byte-for-byte.`) + } +} + +async function checkDistributionFiles(rootDirectory, outputDirectory) { + for (const file of distributionFiles) { + const source = await readFile(join(rootDirectory, file.source)) + let distributed + try { + distributed = await readFile(join(outputDirectory, file.target)) + } catch { + throw new Error(`${file.target} is missing from the production build.`) + } + if (!distributed.equals(source)) { + throw new Error(`${file.target} does not match ${file.source} byte-for-byte.`) + } + } +} + +export async function writeRuntimeLicenseReport({ + rootDirectory = process.cwd(), + outputDirectory = join(rootDirectory, 'dist'), + requiredPackages = requiredRuntimeLicensePackages, + includedPackageLocations, +} = {}) { + const entries = await collectRuntimeLicenseEntries({ rootDirectory, includedPackageLocations }) + verifyRuntimeLicenseEntries(entries, requiredPackages) + const report = renderRuntimeLicenseReport(entries) + verifyRuntimeLicenseReport(report, requiredPackages) + + await mkdir(outputDirectory, { recursive: true }) + const outputPath = join(outputDirectory, runtimeLicenseFileName) + await writeFile(outputPath, report, 'utf8') + await writeDistributionFiles(rootDirectory, outputDirectory) + const writtenReport = await readFile(outputPath, 'utf8') + if (writtenReport !== report) { + throw new Error(`${runtimeLicenseFileName} did not round-trip exactly after writing.`) + } + return { outputPath, entries, report } +} + +export async function checkRuntimeLicenseReport({ + rootDirectory = process.cwd(), + outputDirectory = join(rootDirectory, 'dist'), + requiredPackages = requiredRuntimeLicensePackages, + viteBuild, +} = {}) { + const outputPath = join(outputDirectory, runtimeLicenseFileName) + let actualReport + try { + actualReport = await readFile(outputPath, 'utf8') + } catch { + throw new Error(`${runtimeLicenseFileName} is missing from the production build.`) + } + verifyRuntimeLicenseReport(actualReport, requiredPackages) + + const build = viteBuild ?? (await import('vite')).build + const outputs = await build({ root: rootDirectory, build: { write: false } }) + const lockfile = await readLockfile(rootDirectory) + const bundledPackageLocations = runtimePackageLocationsFromViteOutput({ + rootDirectory, + lockfile, + outputs, + }) + const reportedPackageLocations = packageLocationsFromReport(actualReport) + const missingLocations = [...bundledPackageLocations].filter( + (location) => !reportedPackageLocations.has(location), + ) + const extraLocations = [...reportedPackageLocations].filter( + (location) => !bundledPackageLocations.has(location), + ) + if (missingLocations.length > 0 || extraLocations.length > 0) { + const details = [ + missingLocations.length > 0 ? `missing: ${missingLocations.join(', ')}` : null, + extraLocations.length > 0 ? `extra: ${extraLocations.join(', ')}` : null, + ].filter(Boolean) + throw new Error( + `${runtimeLicenseFileName} does not match the Vite runtime package inventory (${details.join('; ')}).`, + ) + } + + const entries = await collectRuntimeLicenseEntries({ + rootDirectory, + includedPackageLocations: bundledPackageLocations, + }) + verifyRuntimeLicenseEntries(entries, requiredPackages) + const expectedReport = renderRuntimeLicenseReport(entries) + if (actualReport !== expectedReport) { + throw new Error(`${runtimeLicenseFileName} is stale or does not match installed dependencies.`) + } + await checkDistributionFiles(rootDirectory, outputDirectory) + return { outputPath, entries, report: actualReport } +} + +export async function buildProductionWithRuntimeLicenses({ + rootDirectory = process.cwd(), + outputDirectory = join(rootDirectory, 'dist'), + requiredPackages = requiredRuntimeLicensePackages, + viteBuild, +} = {}) { + const build = viteBuild ?? (await import('vite')).build + const outputs = await build() + const lockfile = await readLockfile(rootDirectory) + const includedPackageLocations = runtimePackageLocationsFromViteOutput({ + rootDirectory, + lockfile, + outputs, + }) + return writeRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages, + includedPackageLocations, + }) +} + +async function main() { + const mode = process.argv[2] ?? '--build' + if (!['--build', '--check'].includes(mode)) { + throw new Error('Usage: node scripts/generate-third-party-licenses.mjs [--build|--check]') + } + + const result = + mode === '--check' + ? await checkRuntimeLicenseReport() + : await buildProductionWithRuntimeLicenses() + const action = mode === '--check' ? 'Verified' : 'Built and generated' + console.log( + `${action} ${runtimeLicenseFileName} with ${result.entries.length} production package entries.`, + ) +} + +const currentFile = fileURLToPath(import.meta.url) +if (process.argv[1] && resolve(process.argv[1]) === resolve(currentFile)) { + await main() +} diff --git a/scripts/generate-third-party-licenses.test.ts b/scripts/generate-third-party-licenses.test.ts new file mode 100644 index 0000000..d73a518 --- /dev/null +++ b/scripts/generate-third-party-licenses.test.ts @@ -0,0 +1,392 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + checkRuntimeLicenseReport, + collectRuntimeLicenseEntries, + requiredRuntimeLicensePackages, + renderRuntimeLicenseReport, + runtimePackageLocationsFromViteOutput, + writeRuntimeLicenseReport, +} from './generate-third-party-licenses.mjs' + +type PackageFixture = { + name: string + version?: string + license?: string + material?: string | null + dev?: boolean +} + +describe('third-party runtime license report', () => { + let rootDirectory: string + + beforeEach(async () => { + rootDirectory = await mkdtemp(join(tmpdir(), 'usts-runtime-licenses-')) + }) + + afterEach(async () => { + await rm(rootDirectory, { recursive: true, force: true }) + }) + + async function writeFixture(packages: PackageFixture[]) { + const lockPackages: Record = { + '': { + name: 'fixture', + version: '1.0.0', + dependencies: Object.fromEntries( + packages + .filter((entry) => !entry.dev) + .map((entry) => [entry.name, entry.version ?? '1.0.0']), + ), + }, + } + + for (const entry of packages) { + const version = entry.version ?? '1.0.0' + const location = `node_modules/${entry.name}` + lockPackages[location] = { version, ...(entry.dev ? { dev: true } : {}) } + const directory = join(rootDirectory, location) + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, 'package.json'), + JSON.stringify({ + name: entry.name, + version, + ...(entry.license ? { license: entry.license } : {}), + }), + ) + if (entry.material !== null && entry.material !== undefined) { + await writeFile(join(directory, 'LICENSE'), entry.material) + } + } + + await writeFile( + join(rootDirectory, 'package-lock.json'), + JSON.stringify({ + name: 'fixture', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: lockPackages, + }), + ) + await writeFile(join(rootDirectory, 'LICENSE'), 'Root project license\r\n') + await writeFile( + join(rootDirectory, 'THIRD_PARTY_NOTICES.md'), + '# Fixture third-party notices\r\n', + ) + } + + function viteOutputFor(packageNames: string[]) { + return { + output: [ + { + type: 'chunk', + modules: Object.fromEntries( + packageNames.map((name) => [ + join(rootDirectory, 'node_modules', name, 'dist', 'index.js'), + {}, + ]), + ), + }, + ], + } + } + + it('generates deterministic content from installed production packages and excludes dev packages', async () => { + await writeFixture([ + { name: 'react', license: 'MIT', material: 'React fixture license\r\n' }, + { name: 'dev-only', license: 'MIT', material: 'Dev fixture license', dev: true }, + ]) + + const entries = await collectRuntimeLicenseEntries({ rootDirectory }) + const first = renderRuntimeLicenseReport(entries) + const second = renderRuntimeLicenseReport(await collectRuntimeLicenseEntries({ rootDirectory })) + + expect(first).toBe(second) + expect(first).toContain('Package: react@1.0.0') + expect(first).toContain('React fixture license\n----- END LICENSE -----') + expect(first).not.toContain('dev-only') + expect(first).not.toContain(rootDirectory) + }) + + it('rejects a package.json license declaration without distributable license text', async () => { + await writeFixture([{ name: 'react', license: 'MIT', material: null }]) + + await expect(collectRuntimeLicenseEntries({ rootDirectory })).rejects.toThrow( + /no package-level license material/, + ) + }) + + it('does not infer a copyright owner for use-composed-ref 1.4.0', async () => { + const material = await readFile( + join( + process.cwd(), + 'scripts/third-party-license-materials/use-composed-ref-1.4.0-LICENSE.txt', + ), + 'utf8', + ) + + expect(material).toContain( + 'The exact npm tarball and source tag do not provide a copyright notice or a\n' + + 'separate license file. No copyright attribution is inferred here.', + ) + expect(material).not.toContain('Copyright (c) Andarist') + }) + + it('fails closed when an installed production package has no license evidence', async () => { + await writeFixture([{ name: 'react', material: null }]) + + await expect(collectRuntimeLicenseEntries({ rootDirectory })).rejects.toThrow( + /no package-level license material/, + ) + }) + + it('fails closed when a shipped license file is empty', async () => { + await writeFixture([{ name: 'react', license: 'MIT', material: '' }]) + + await expect(collectRuntimeLicenseEntries({ rootDirectory })).rejects.toThrow( + /License material is empty/, + ) + }) + + it('fails closed when package.json points to license material that was not shipped', async () => { + await writeFixture([{ name: 'react', license: 'SEE LICENSE IN LICENSE-MIT', material: null }]) + + await expect(collectRuntimeLicenseEntries({ rootDirectory })).rejects.toThrow( + /no package-level license material/, + ) + }) + + it('maps Vite chunk modules to the most specific production package location', async () => { + await writeFixture([ + { name: 'parent', license: 'MIT', material: 'Parent license' }, + { name: 'parent/node_modules/nested', license: 'MIT', material: 'Nested license' }, + ]) + const lockfile = JSON.parse(await readFile(join(rootDirectory, 'package-lock.json'), 'utf8')) + const nestedModule = join( + rootDirectory, + 'node_modules/parent/node_modules/nested/dist/index.js', + ) + + const locations = runtimePackageLocationsFromViteOutput({ + rootDirectory, + lockfile, + outputs: { output: [{ type: 'chunk', modules: { [nestedModule]: {} } }] }, + }) + + expect([...locations]).toEqual(['node_modules/parent/node_modules/nested']) + }) + + it('includes a devDependency when Vite actually bundles it', async () => { + await writeFixture([ + { name: 'react', license: 'MIT', material: 'React license' }, + { name: 'dev-only', license: 'MIT', material: 'Dev license', dev: true }, + ]) + const lockfile = JSON.parse(await readFile(join(rootDirectory, 'package-lock.json'), 'utf8')) + const locations = runtimePackageLocationsFromViteOutput({ + rootDirectory, + lockfile, + outputs: viteOutputFor(['react', 'dev-only']), + }) + const entries = await collectRuntimeLicenseEntries({ + rootDirectory, + includedPackageLocations: locations, + }) + + expect([...locations].sort()).toEqual(['node_modules/dev-only', 'node_modules/react']) + expect(entries.map((entry) => entry.name).sort()).toEqual(['dev-only', 'react']) + }) + + it('fails closed when any bundled node_modules path is absent from package-lock.json', async () => { + await writeFixture([{ name: 'react', license: 'MIT', material: 'React license' }]) + const lockfile = JSON.parse(await readFile(join(rootDirectory, 'package-lock.json'), 'utf8')) + const outputs = viteOutputFor(['react', 'not-locked']) + + expect(() => + runtimePackageLocationsFromViteOutput({ rootDirectory, lockfile, outputs }), + ).toThrow(/did not map to package-lock.json.*not-locked/) + }) + + it('fails closed when an installed version differs from package-lock.json', async () => { + await writeFixture([{ name: 'react', version: '1.0.0', license: 'MIT', material: 'License' }]) + await writeFile( + join(rootDirectory, 'node_modules/react/package.json'), + JSON.stringify({ name: 'react', version: '2.0.0', license: 'MIT' }), + ) + + await expect(collectRuntimeLicenseEntries({ rootDirectory })).rejects.toThrow( + /does not match package-lock.json/, + ) + }) + + it('writes and then verifies a non-empty report with required package coverage', async () => { + await writeFixture([{ name: 'react', license: 'MIT', material: 'License text' }]) + const outputDirectory = join(rootDirectory, 'dist') + const viteBuild = vi.fn(async () => viteOutputFor(['react'])) + + await writeRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + }) + const result = await checkRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + viteBuild, + }) + + expect(viteBuild).toHaveBeenCalledWith({ + root: rootDirectory, + build: { write: false }, + }) + expect(result.report).toContain('Package: react@1.0.0') + expect(await readFile(join(outputDirectory, 'THIRD_PARTY_LICENSES.txt'), 'utf8')).not.toBe('') + expect(await readFile(join(outputDirectory, 'LICENSE.txt'), 'utf8')).toBe( + 'Root project license\r\n', + ) + expect(await readFile(join(outputDirectory, 'THIRD_PARTY_NOTICES.md'), 'utf8')).toBe( + '# Fixture third-party notices\r\n', + ) + }) + + it('rejects distributed project license or notice files that are missing or changed', async () => { + await writeFixture([{ name: 'react', license: 'MIT', material: 'License text' }]) + const outputDirectory = join(rootDirectory, 'dist') + await writeRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + }) + + await writeFile(join(outputDirectory, 'LICENSE.txt'), 'changed') + await expect( + checkRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + viteBuild: async () => viteOutputFor(['react']), + }), + ).rejects.toThrow(/does not match LICENSE byte-for-byte/) + + await writeFile(join(outputDirectory, 'LICENSE.txt'), 'Root project license\r\n') + await rm(join(outputDirectory, 'THIRD_PARTY_NOTICES.md')) + await expect( + checkRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + viteBuild: async () => viteOutputFor(['react']), + }), + ).rejects.toThrow(/THIRD_PARTY_NOTICES.md is missing/) + }) + + it('rejects a self-consistent report that omits a bundled package', async () => { + await writeFixture([ + { name: 'react', license: 'MIT', material: 'React license' }, + { name: 'bundled-extra', license: 'MIT', material: 'Extra license' }, + ]) + const outputDirectory = join(rootDirectory, 'dist') + await writeRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + }) + const truncatedEntries = await collectRuntimeLicenseEntries({ + rootDirectory, + includedPackageLocations: new Set(['node_modules/react']), + }) + await writeFile( + join(outputDirectory, 'THIRD_PARTY_LICENSES.txt'), + renderRuntimeLicenseReport(truncatedEntries), + ) + + await expect( + checkRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + viteBuild: async () => viteOutputFor(['react', 'bundled-extra']), + }), + ).rejects.toThrow(/does not match the Vite runtime package inventory.*bundled-extra/) + }) + + it('rejects a report after the Vite runtime package inventory changes', async () => { + await writeFixture([ + { name: 'react', license: 'MIT', material: 'React license' }, + { name: 'later-bundled', license: 'MIT', material: 'Later license' }, + ]) + const outputDirectory = join(rootDirectory, 'dist') + await writeRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + includedPackageLocations: new Set(['node_modules/react']), + }) + + await expect( + checkRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + viteBuild: async () => viteOutputFor(['react', 'later-bundled']), + }), + ).rejects.toThrow(/does not match the Vite runtime package inventory.*later-bundled/) + }) + + it('enforces the React, Supabase, AI SDK, and Lucide build coverage set', async () => { + await writeFixture( + requiredRuntimeLicensePackages.map((name) => ({ + name, + license: 'MIT', + material: `${name} license`, + })), + ) + + const result = await writeRuntimeLicenseReport({ + rootDirectory, + outputDirectory: join(rootDirectory, 'dist'), + }) + + for (const packageName of requiredRuntimeLicensePackages) { + expect(result.report).toContain(`Package: ${packageName}@1.0.0`) + } + }) + + it('rejects a missing or stale production report', async () => { + await writeFixture([{ name: 'react', license: 'MIT', material: 'License text' }]) + const outputDirectory = join(rootDirectory, 'dist') + + await expect( + checkRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + }), + ).rejects.toThrow(/missing from the production build/) + + await mkdir(outputDirectory) + await writeFile(join(outputDirectory, 'THIRD_PARTY_LICENSES.txt'), 'stale') + await expect( + checkRuntimeLicenseReport({ + rootDirectory, + outputDirectory, + requiredPackages: ['react'], + }), + ).rejects.toThrow(/invalid header/) + }) + + it('rejects a report that does not cover the required runtime packages', async () => { + await writeFixture([{ name: 'react', license: 'MIT', material: 'License text' }]) + + await expect( + writeRuntimeLicenseReport({ + rootDirectory, + outputDirectory: join(rootDirectory, 'dist'), + requiredPackages: ['react', '@supabase/supabase-js'], + }), + ).rejects.toThrow(/missing required packages: @supabase\/supabase-js/) + }) +}) diff --git a/scripts/third-party-license-materials/README.md b/scripts/third-party-license-materials/README.md new file mode 100644 index 0000000..5fd0d34 --- /dev/null +++ b/scripts/third-party-license-materials/README.md @@ -0,0 +1,6 @@ +# Curated runtime license materials + +These files are used only when an exact production package version is included in the Vite bundle but its npm tarball omits package-level license material. The generator maps each file to an exact `name@version` and fails closed for any other version. + +- `ai-sdk-provider-utils-5.0.10-LICENSE.txt` is the root `LICENSE` at Vercel AI tag `ai@7.0.30` (`2667d274aa5d4ed53ce3fcbaa020302f47a86ec2`), the release containing `@ai-sdk/provider-utils@5.0.10`. GitHub blob SHA: `6c16c29f41168411dbf6e3ba3a063ce789c27334`. +- `use-composed-ref-1.4.0-LICENSE.txt` records the MIT declaration and standard permission terms for the exact npm package and source tag `v1.4.0` (`ea2c9d969f2f90a349f4c99338b806423824cea2`). The upstream tag and npm tarball contain no LICENSE file or copyright notice, so the material states that absence explicitly and does not infer an attribution from another package. This exception must be re-reviewed if the dependency changes. diff --git a/scripts/third-party-license-materials/ai-sdk-provider-utils-5.0.10-LICENSE.txt b/scripts/third-party-license-materials/ai-sdk-provider-utils-5.0.10-LICENSE.txt new file mode 100644 index 0000000..824d33f --- /dev/null +++ b/scripts/third-party-license-materials/ai-sdk-provider-utils-5.0.10-LICENSE.txt @@ -0,0 +1,13 @@ +Copyright 2023 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/scripts/third-party-license-materials/use-composed-ref-1.4.0-LICENSE.txt b/scripts/third-party-license-materials/use-composed-ref-1.4.0-LICENSE.txt new file mode 100644 index 0000000..d980664 --- /dev/null +++ b/scripts/third-party-license-materials/use-composed-ref-1.4.0-LICENSE.txt @@ -0,0 +1,23 @@ +use-composed-ref 1.4.0 declares the MIT license in its published package.json. +The exact npm tarball and source tag do not provide a copyright notice or a +separate license file. No copyright attribution is inferred here. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/components/admin/AdminWebChatPilotPanel.test.tsx b/src/components/admin/AdminWebChatPilotPanel.test.tsx index 40d1706..a8a8185 100644 --- a/src/components/admin/AdminWebChatPilotPanel.test.tsx +++ b/src/components/admin/AdminWebChatPilotPanel.test.tsx @@ -85,7 +85,7 @@ describe('AdminWebChatPilotPanel', () => { const region = await screen.findByRole('region', { name: 'AI 助手账号与用量' }) const summary = await within(region).findByLabelText('AI 助手账号摘要') expect(within(summary).getByText('已配置账号').nextSibling).toHaveTextContent('2') - expect(within(summary).getByText('当前可用').nextSibling).toHaveTextContent('1') + expect(within(summary).getByText('停止前已授权').nextSibling).toHaveTextContent('1') expect(within(summary).getByText('累计占用 Token').nextSibling).toHaveTextContent('15,000') const cacheSummary = await within(region).findByLabelText('输入缓存摘要') expect(within(cacheSummary).getByText('命中请求').nextSibling).toHaveTextContent('7 / 10') @@ -121,11 +121,11 @@ describe('AdminWebChatPilotPanel', () => { expect(screen.getByRole('alert')).toHaveTextContent('缓存摘要暂时不可用') }) - it('explains how to configure the first AI assistant account', async () => { + it('keeps an empty account list closed', async () => { pilotPanelMocks.fetchMembers.mockResolvedValue([]) renderPanel() - expect(await screen.findByText('尚未配置 AI 助手账号')).toBeInTheDocument() - expect(screen.getByText(/开放 AI 助手权限并设置累计额度/)).toBeInTheDocument() + expect(await screen.findByText('没有历史授权记录')).toBeInTheDocument() + expect(screen.getByText(/不再新增成员授权/)).toBeInTheDocument() }) }) diff --git a/src/components/admin/AdminWebChatPilotPanel.tsx b/src/components/admin/AdminWebChatPilotPanel.tsx index 9d617a4..8637a5b 100644 --- a/src/components/admin/AdminWebChatPilotPanel.tsx +++ b/src/components/admin/AdminWebChatPilotPanel.tsx @@ -90,7 +90,7 @@ export function AdminWebChatPilotPanel() {

AI 助手账号与用量

-

集中查看成员权限、累计额度、剩余用量和脱敏活动状态。

+

只读查看停止前的成员授权、累计额度和脱敏活动状态。