diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 18c7b609f..a05b307bd 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -27,7 +27,7 @@ - 阶段 A:真实 ZIP 上传、游戏身份、owner/幂等/CAS、状态机、DTO 与 schema 门禁已完成;真实 SpacetimeDB + 私有 OSS 的创建/上传/确认/重启恢复已有证据。**2026-09-28 更新**:阶段 A 的六条行为与验收已全部勾选(含跨账号越权、并发上传不混写、进程退出后按权威偏移续传、DTO/envelope 一致性),正式验收仍待 owner 评审。 - 阶段 B:人工审核(后台列表、通过需 HTTPS 入口、拒绝需理由)、公开投影、发行网关(按公开版本服务、扩展名白名单、`nosniff`/CORP/CSP、带 Cookie 403)与作者下架已实现;**2026-09-28 更新**:审核治理(待审可见、可批可拒、结论可追溯、作者不能提交审核)、「更新待审/被拒不改变在线旧版」、`publicationRevision` CAS(过期审核、并发激活、过期下架、陈旧审核不复活已下架游戏)与作者/管理员下架后目录/详情/发行读取全部关闭都已在本地真实栈取证(见文末阶段 B 核对);匿名直取发行包这一条已修:`platform-oss` 现在在内部 PUT / 分片追加 / 直传 policy 三处都下发对象级 ACL,严格模式下直传封面与发行包匿名直取都是 403(见 pitfalls 与 decision-log);opaque sandbox 的真实浏览器行为也已用 Chromium 探针包取证(22 项 PASS);每游戏独立来源已有可执行工件 `deploy/nginx/genarrative-release-origin.conf` 与门禁 `npm run check:release-origin-config`,并已在本机用真实 nginx + 真实网关验证按主机映射、Cookie 403 与平台命名空间 404;独立发行域名已由主规范 2026 决策用「平台同源路径 + sandbox 不透明来源」替代,不再是门禁;只剩「撤销传播符合最大缓存窗口」需要生产 CDN/TTL。 - 阶段 C:AGC 客户端「发布到平台」面板与发布链路(dist 归一化根 `index.html`、摘要/字节数声明、幂等键、`localProjectId` 复用)已实现并有请求组装与 Rust 导出测试;网页 `/games/publish` 走同一服务端管道,本地已用真实文件选择验证;AGC GUI 自身的端到端发布仍待客户端环境验收。目录(关键词/分类/设备筛选、滚动与筛选恢复)、详情、游玩页(主动作后加载、超时重试、旋转提示、全屏、移动端门槛)与作者中心(状态、驳回理由、撤回、下架)已实现;本地已在桌面与 `390x844` 移动视口真实游玩。 -- 阶段 D:**2026-09-28 更新**:200 MiB 上限档与发包边界、真实栈两段式重启恢复(含失败可观测与公开版本不被误删)已在本地真实栈取证;CDN 撤销窗口、发布/回滚演练、真实发行域名与生产账号验收仍依赖生产资源。 +- 阶段 D:**2026-09-28 更新**:200 MiB 上限档与发包边界、真实栈两段式重启恢复(含失败可观测与公开版本不被误删)、发布开关回滚窗口与发行网关缓存/响应头/日志脱敏都已在本地真实栈取证;真实环境完整链路(D1)与主规范证据矩阵(D6)仍未完成,生产 systemd 巡检与 CDN 只能部署后复核。 细节与命令级证据见[实施计划【游戏分发阶段A领域合同】](【实施计划】游戏分发阶段A领域合同-2026-09-19.md)的「已完成证据」「运行时证据」「尚未完成」。 @@ -122,8 +122,8 @@ - [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。 - [x] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界(2026-09-28 在本地真实栈按新上限复跑,见文末阶段 D 核对)。 - [x] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件(2026-09-28 在本地真实栈按两段式重启复跑,见文末阶段 D 核对)。 -- [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。 -- [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。 +- [x] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界(2026-09-28 在本地真实栈取证源站拒绝与 60 秒 TTL 上限,见文末阶段 D 核对;真实 CDN 未接入,purge 场景本身属部署后复核)。 +- [x] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查(2026-09-28 在本地真实栈取证发布开关、路由模板、响应头与日志脱敏,见文末阶段 D 核对;真实 systemd 巡检运行属部署后复核)。 - [ ] 主规范逐条证据矩阵齐全,未验证项明确列出;有任何核心路径未验证时不标记上线完成。 ### 证据要求 @@ -245,3 +245,17 @@ - **失败可观测**:再传一个结构损坏的包返回 **422 `PACKAGE_VALIDATION_FAILED` + `reason=InvalidArchive`**(作者当场可诊断);同一版本回读落到 `upload_failed`;后台游戏列表出现 `statuses=upload_failed,pending_review,published`。失败原因同时落库:`spacetime sql --server http://127.0.0.1:3101 xushi-p4wfr "SELECT version_id, status, last_error_code FROM game_distribution_version WHERE status = 'upload_failed'"` 返回 `(some = "PACKAGE_VALIDATION_FAILED")`。 - **不误删公开版本**:v2 待审、v3 失败之后,`GET /api/game-distribution/releases//index.html` 仍 200 且正文仍含 `V1-OK`,公开版本内容没有被后续待审/失败版本清理掉。 - **明确缺口(未当成已验收)**:版本回读 DTO 目前只暴露 `status`,不暴露 `last_error_code` / `last_error_message`;作者或运维事后回看只能看到 `upload_failed`,具体原因只有失败当次的 422 envelope 与库内字段。把它做进回读 DTO 需要改 `GameDistributionVersionSnapshot` / `GameDistributionAdminVersionSnapshot` 的 ABI 并重发模块与绑定,本轮未做,列为后续事项。 + +## 本轮核对(2026-09-28,阶段 D 发布回滚窗口与发行网关缓存) + +- 已勾选(阶段 D 第 4 条:撤销后新资源仍被拒绝且边界明确)——本轮按本地可证范围补齐 + - 新增 `scripts/check-game-distribution-ops-rollback-e2e.mjs`(`npm run check:game-distribution-ops-rollback-e2e`,本地真实栈 + 真实 OSS,**40 项 PASS**)。管理员安全下架后,`GET /api/game-distribution/games/` 与发行入口 `/api/game-distribution/releases//index.html` 对**新的**请求立刻返回 `404`,恢复后重新服务同一公开版本;因此“CDN purge 失败”不会让源站继续放行新资源,旧内容只可能留在声明过的边缘缓存窗口里。 + - 缓存窗口与文档一致:发行网关对所有发行资源下发 `Cache-Control: public, max-age=60, must-revalidate`,脚本同时断言运维文档里的 60 秒上限与“已下载脚本无法远程抹除”边界(`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`)。平台当前没有接 CDN,purge 场景本身留待部署后复核。 + - 响应头:HTML 下发收紧 CSP(`default-src 'none'` + `frame-src/object-src/base-uri 'none'`),CSS/JS 等非 HTML 不下发 CSP;两者都带 `X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`Cross-Origin-Resource-Policy: cross-origin` 与无凭据 `Access-Control-Allow-Origin: *`。 +- 已勾选(阶段 D 第 5 条:保留当前公开版本、关闭新提交与新版本激活,部署路由/缓存/响应头/日志脱敏/告警检查)——本轮补齐本地可执行部分 + - **关投稿、保在线**:`game-distribution:publish` 灰度关闭后,创建游戏、创建版本、上传包、送审、撤回、作者下架与管理员**批准**新版本全部 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`;同一窗口内目录、详情、`/my-games`、审核队列读取与发行入口继续可用,`publicationRevision` 与当前公开版本不变。拒绝审核与管理员安全下架 / 恢复始终可用(已在关闭窗口内实测,且恢复后仍服务原公开版本)。 + - **换版保留旧版本**:开关恢复后管理员激活新版本,发行入口改为新内容,旧公开版本在作者版本列表里保留为 `revoked` 记录(可追溯、对象未删)。 + - **部署路由**:脚本断言三份边缘模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)都把 `^/games/(?game_[0-9a-f]{32})(?/.*)?$` 映射到发行网关并 `proxy_set_header Cookie ""`;`npm run check:nginx-spa-routes` → `OK (12 SPA routes, 3 Nginx templates)`。 + - **日志脱敏**:运行期 api-server 日志不出现访问令牌、刷新 Cookie 与 OSS signed URL 凭据(正向对照:同一份日志里能查到本轮 gameId 与 `publish_switch_blocked` / `game_suspended` 两个 operation);三份边缘模板的 `log_format` 不含 `$http_authorization` / `$http_cookie` / `$arg_`。 + - **变异验证**:临时把 `ensure_publish_enabled` 改成直接 `Ok(())` 后重跑,同一个脚本 **14 项转红**(7 条写入拦截 + 保在线/下架/恢复等下游断言),还原后重新全绿——说明这些断言确实在测发布开关,而不是恒真。 + - **告警**:`npm run check:production-health-patrol-env` → OK(巡检环境的 nginx / pingora-direct 两种模式变量口径);`npm run check:production-health-patrol` 这台 Windows 机器没有 `systemctl` 桩,巡检 harness 报 `FAILED`(预期退出 0、实际 2),属本地限制,真实 systemd 巡检运行留待服务器复核。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fac9baa20..ef7d096ca 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6086,3 +6086,15 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因**:`.env.local` 里配置的是 `GENARRATIVE_ADMIN_USERNAME=admin` / `GENARRATIVE_ADMIN_PASSWORD=123456`,超管登录直接比对 `state.admin_runtime()`(即进程启动时的环境变量),不走库里的后台账号。上一次 api-server 若是被显式带上 `codex-admin / CodexLocal-4198!` 起起来的,重启回到 `.env.local` 后这组账号就会 401。 - **做法**:两段式 E2E 的两次启动必须用同一组管理员凭据(本轮统一改成 `.env.local` 的 `admin / 123456`,实测 `admin` → 200、`codex-admin` → 401);遇到 401 先用 `Invoke-WebRequest http://127.0.0.1:8082/admin/api/login` 探一次凭据,再判断是否是产品回归。 - **附注**:同一个 `E2E_STATE_FILE` 只能 resume 一次。resume 段会额外建一个坏包版本,第二次跑「后台只看到两个版本」这类断言会因为多出 `upload_failed` 版本而失败;需要重跑就重新执行 prepare。同理,prepare 依赖 `game-distribution:publish` 灰度对该作者开启,脚本自己会先 PUT 特征开关。 + +## 2026-09-28 发布开关回滚窗口要连管理员拒绝/安全下架一起验,否则会把“关投稿”做成“关后台” + +- **现象**:只按“关闭 `game-distribution:publish` 后写入口 503”写用例,会漏掉两个方向的问题——① 关闭窗口里管理员**批准**新版本也必须被拦住,否则回滚窗口形同虚设;② 拒绝审核与管理员安全下架 / 恢复在关闭窗口里必须仍然可用,否则运营在最需要救火时会发现自己也进不去。 +- **做法**:`scripts/check-game-distribution-ops-rollback-e2e.mjs` 把这两类方向写进同一条用例:7 条写入(含管理员批准)断言 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`,同时断言目录/详情/`/my-games`/审核队列/发行入口 200 且 `publicationRevision` 不变,并在关闭窗口内实测拒绝审核与管理员下架 → 恢复。改 `ensure_publish_enabled` 的调用位置(例如给下架也加上开关检查)会立刻打红。 +- **变异验证**:把 `ensure_publish_enabled` 临时改成直接 `Ok(())`,脚本 14 项转红;这类“开关语义”用例必须做一次变异验证,否则无法区分“开关生效”与“断言恒真”。 + +## 2026-09-28 `check:production-health-patrol` 在 Windows 本机必然 FAILED,别当成回归 + +- **现象**:本机跑 `npm run check:production-health-patrol` 输出 `[check:production-health-patrol] FAILED`,理由是「nginx gateway mode 巡检应成功。预期退出码 0,实际 2」,巡检 JSON 里 6 条 `service:*` 全是 `服务状态异常: spawn systemctl ENOENT`。同一轮的 `api:/healthz`、`bgfilter:/readyz`、`spacetimedb:/v1/ping`、`public:/` 都是 200。 +- **根因**:这个 harness 用桩 `systemctl` 驱动巡检脚本,Windows 上 `spawn systemctl` 直接 `ENOENT`,服务态检查不可能通过;它验证的是 Linux + systemd 的生产形态。 +- **做法**:本机只跑 `npm run check:production-health-patrol-env`(本轮 OK)确认巡检变量口径;`check:production-health-patrol` 留给服务器/CI 复核,别据此判定巡检脚本本身坏了。 diff --git a/package.json b/package.json index 63922188c..baa9534c2 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "check:game-distribution-web-a11y-e2e": "node scripts/check-game-distribution-web-a11y-e2e.mjs", "check:game-distribution-package-limits-e2e": "node scripts/check-game-distribution-package-limits-e2e.mjs", "check:game-distribution-validation-restart-e2e": "node scripts/check-game-distribution-validation-restart-e2e.mjs", + "check:game-distribution-ops-rollback-e2e": "node scripts/check-game-distribution-ops-rollback-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-game-distribution-ops-rollback-e2e.mjs b/scripts/check-game-distribution-ops-rollback-e2e.mjs new file mode 100644 index 000000000..28ae7af44 --- /dev/null +++ b/scripts/check-game-distribution-ops-rollback-e2e.mjs @@ -0,0 +1,741 @@ +// 游戏分发「发布开关回滚窗口 + 发行网关缓存与响应头」真实栈检查。 +// +// 对应里程碑阶段 D 的上线准备两条: +// - 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活; +// - 撤销只改变后端公开投影,源站立即拒绝新请求,边缘最多多留一个获批缓存窗口。 +// +// 用本地真实栈(api-server + SpacetimeDB + 真实 OSS)与真实后台灰度开关取证: +// 1. 关闭 `game-distribution:publish` 后:创建游戏/版本、上传包、送审、撤回、作者下架 +// 与管理员批准新版本全部 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`; +// 2. 同一窗口内目录、详情、发行网关、`/my-games` 与审核队列读取继续可用, +// 当前公开版本与 `publicationRevision` 不变(关闭投稿、保在线); +// 3. 拒绝审核与管理员安全下架 / 恢复始终可用; +// 4. 发行网关响应头与获批缓存窗口(`public, max-age=60, must-revalidate`)和运维文档一致; +// 5. 换版与下架后**新的**发行请求立刻被源站拒绝(404),旧内容只可能留在 60 秒边缘缓存里; +// 6. 运行期日志不出现访问令牌、刷新 Cookie 与 OSS signed URL,边缘 log_format 不记录请求头。 +// +// 需要:本地 dev 栈 + 管理员账号。 +// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-ops-rollback-e2e +import { createHash, randomBytes } from 'node:crypto'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +import JSZip from 'jszip'; + +const COVER_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); +const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082'; +const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; +const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; +const DEV_PASSWORD = 'GenE2e123!'; +const GATE_KEY = 'game-distribution:publish'; +const RELEASE_CACHE_CONTROL = 'public, max-age=60, must-revalidate'; +const RELEASE_TTL_SECONDS = 60; +const PUBLISH_DISABLED_CODE = 'GAME_DISTRIBUTION_PUBLISH_DISABLED'; +const OPS_DOC_PATH = 'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md'; +const EDGE_TEMPLATES = [ + 'deploy/nginx/genarrative.conf', + 'deploy/nginx/genarrative-dev-http.conf', + 'deploy/container/nginx.conf', +]; +const API_LOG_DIR = path.resolve('logs/api-server'); + +if (!ADMIN_USER || !ADMIN_PASSWORD) { + console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD。'); + process.exit(2); +} + +let failures = 0; +function check(name, ok, detail = '') { + if (!ok) failures += 1; + console.log( + `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, + ); +} +function section(title) { + console.log(`\n--- ${title} ---`); +} + +async function api(pathname, options = {}) { + const { method = 'GET', token, body, headers = {}, binary } = options; + const finalHeaders = { ...ENVELOPE, ...headers }; + if (token) finalHeaders.Authorization = `Bearer ${token}`; + let finalBody; + if (binary) { + finalBody = binary; + } else if (body !== undefined) { + finalHeaders['Content-Type'] = 'application/json'; + finalBody = JSON.stringify(body); + } + const response = await fetch(`${API}${pathname}`, { + method, + headers: finalHeaders, + body: finalBody, + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + json = null; + } + return { + status: response.status, + text, + json, + data: json?.data, + error: json?.error, + headers: Object.fromEntries(response.headers.entries()), + setCookies: response.headers.getSetCookie?.() ?? [], + }; +} + +async function release(pathname) { + const response = await fetch(`${API}${pathname}`); + const body = await response.text(); + return { status: response.status, body, headers: response.headers }; +} + +async function adminToken() { + const login = await api('/admin/api/login', { + method: 'POST', + body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, + }); + const token = login.data?.token ?? login.data?.accessToken; + check( + '管理员登录成功', + login.status === 200 && Boolean(token), + `status=${login.status}`, + ); + return token; +} + +async function setPublishGate(admin, enabled, rolloutPercent) { + const response = await api('/admin/api/feature-gates', { + method: 'PUT', + token: admin, + body: { + gateKey: GATE_KEY, + enabled, + rolloutPercent, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 发布回滚窗口', + }, + }); + return response; +} + +async function uploadCover(token, id) { + const fileName = `ops-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'ops-rollback', String(id)], + fileName, + contentType: 'image/png', + access: 'private', + maxSizeBytes: COVER_PNG.length, + metadata: { asset_kind: 'game_distribution_cover' }, + }, + }); + if (ticket.status !== 200) { + throw new Error(`创建直传凭证失败 ${ticket.status}`); + } + const upload = ticket.data.upload; + const form = new FormData(); + for (const [key, value] of Object.entries(upload.formFields ?? {})) { + if (value !== null && value !== undefined) form.append(key, String(value)); + } + form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName); + const put = await fetch(upload.host, { method: 'POST', body: form }); + if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`); + const confirm = await api('/api/assets/objects/confirm', { + method: 'POST', + token, + body: { + bucket: upload.bucket, + objectKey: upload.objectKey, + contentType: 'image/png', + contentLength: COVER_PNG.length, + assetKind: 'game_distribution_cover', + accessPolicy: 'private', + entityId: 'game-distribution-ops-rollback', + }, + }); + if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`); + return confirm.data.assetObject.assetObjectId; +} + +function gameMetadata(title, coverAssetId) { + return { + title, + summary: '发布开关回滚窗口 E2E', + description: '', + category: '休闲', + tags: ['ops'], + coverAssetId, + deviceSupport: { desktop: true, mobile: false, touch: false }, + inputModes: ['keyboard', 'mouse'], + orientation: 'landscape', + }; +} + +/// 包内同时放 HTML 与 CSS:发行网关对 HTML 单独下发 CSP,对 CSS 只下发通用安全头。 +async function buildPackage(marker) { + const zip = new JSZip(); + zip.file( + 'index.html', + `

${marker}

`, + ); + zip.file( + 'app.css', + `body { color: ${marker.startsWith('V1') ? '#111' : '#222'}; }`, + ); + zip.file('filler.bin', randomBytes(256 * 1024)); + const bytes = Buffer.from( + await zip.generateAsync({ type: 'uint8array', compression: 'STORE' }), + ); + return { bytes, fileCount: 3 }; +} + +async function createVersion(token, gameId, metadata, bytes, fileCount, key) { + return api(`/api/game-distribution/games/${gameId}/versions`, { + method: 'POST', + token, + headers: { 'Idempotency-Key': key }, + body: { + packageSha256: createHash('sha256').update(bytes).digest('hex'), + packageBytes: bytes.length, + packageFileCount: fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }); +} + +async function uploadVersion(token, versionId, bytes, key) { + return api(`/api/game-distribution/versions/${versionId}/package`, { + method: 'PUT', + token, + headers: { + 'Idempotency-Key': key, + 'Content-Type': 'application/zip', + }, + binary: bytes, + }); +} + +async function submitVersion(token, versionId, revision, key) { + return api(`/api/game-distribution/versions/${versionId}/submit`, { + method: 'POST', + token, + headers: { 'Idempotency-Key': key }, + body: { expectedPublicationRevision: revision }, + }); +} + +async function reviewVersion(admin, versionId, payload, key) { + return api(`/admin/api/game-distribution/versions/${versionId}/review`, { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': key }, + body: payload, + }); +} + +async function adminGameRow(admin, gameId) { + const response = await api('/admin/api/game-distribution/games', { + token: admin, + }); + const game = (response.data?.games ?? []).find( + (row) => row.gameId === gameId, + ); + return { status: response.status, game }; +} + +function newestApiLog() { + let entries = []; + try { + entries = readdirSync(API_LOG_DIR).filter((name) => name.endsWith('.log')); + } catch { + return null; + } + if (entries.length === 0) return null; + const files = entries + .map((name) => path.join(API_LOG_DIR, name)) + .sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs); + return files[files.length - 1]; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +/// 发行网关的响应头契约:通用安全头始终下发,CSP 只跟 HTML 走。 +function assertReleaseHeaders(label, response, { html }) { + const headers = response.headers; + check( + `${label}:缓存窗口等于获批 TTL`, + headers.get('cache-control') === RELEASE_CACHE_CONTROL, + `cache-control=${headers.get('cache-control') ?? ''}`, + ); + check( + `${label}:nosniff + no-referrer + 无凭据 CORS/跨来源`, + headers.get('x-content-type-options') === 'nosniff' && + headers.get('referrer-policy') === 'no-referrer' && + headers.get('cross-origin-resource-policy') === 'cross-origin' && + headers.get('access-control-allow-origin') === '*', + `nosniff=${headers.get('x-content-type-options') ?? ''} corp=${headers.get('cross-origin-resource-policy') ?? ''}`, + ); + const csp = headers.get('content-security-policy') ?? ''; + check( + html ? `${label}:HTML 下发收紧 CSP` : `${label}:非 HTML 不下发 CSP`, + html + ? csp.includes("default-src 'none'") && + csp.includes("frame-src 'none'") && + csp.includes("base-uri 'none'") && + csp.includes("object-src 'none'") + : csp === '', + `csp=${csp.slice(0, 40)}`, + ); +} + +async function main() { + const stamp = Date.now(); + const key = (label) => `${label}-${stamp}`; + const admin = await adminToken(); + if (!admin) process.exit(1); + + const gateOn = await setPublishGate(admin, true, 100); + check( + '发布开关可开启(灰度 100%)', + gateOn.status === 200, + `status=${gateOn.status}`, + ); + + const phone = `135${String(stamp).slice(-8)}`; + const register = await api('/api/auth/entry', { + method: 'POST', + body: { purePhoneNumber: phone, password: DEV_PASSWORD }, + }); + const author = register.data?.token; + check( + '作者注册拿到 token', + register.status === 200 && Boolean(author), + `status=${register.status}`, + ); + if (!author) process.exit(1); + const refreshCookieValue = (register.setCookies ?? []) + .map((cookie) => cookie.split(';')[0]) + .map((pair) => pair.split('=').slice(1).join('=')) + .find((value) => value && value.length > 20); + + const coverAssetId = await uploadCover(author, stamp); + const title = `发布回滚 ${String(stamp).slice(-6)}`; + const metadata = gameMetadata(title, coverAssetId); + const created = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-game') }, + body: metadata, + }); + const gameId = created.data?.id; + check('创建游戏成功', created.status === 200 && Boolean(gameId)); + if (!gameId) process.exit(1); + + section('基线:v1 公开、v2/v3 待审、v4 只有版本记录'); + const v1 = await buildPackage('V1-OK'); + const v1Version = await createVersion( + author, + gameId, + metadata, + v1.bytes, + v1.fileCount, + key('ops-v1'), + ); + const v1Id = v1Version.data?.versionId; + await uploadVersion(author, v1Id, v1.bytes, key('ops-upload-v1')); + await submitVersion(author, v1Id, 0, key('ops-submit-v1')); + const approved = await reviewVersion( + admin, + v1Id, + { decision: 'approve', expectedPublicationRevision: 0 }, + key('ops-approve-v1'), + ); + check( + 'v1 审核通过并公开', + approved.status === 200, + `status=${approved.status}`, + ); + + const baselineDetail = await api(`/api/game-distribution/games/${gameId}`); + check( + '公开基线:revision=1 且当前公开版本是 v1', + baselineDetail.status === 200 && + baselineDetail.data?.publicationRevision === 1 && + baselineDetail.data?.currentVersion?.id === v1Id, + `status=${baselineDetail.status} revision=${baselineDetail.data?.publicationRevision ?? ''}`, + ); + const baselineRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '公开基线:发行入口返回 v1 内容', + baselineRelease.status === 200 && baselineRelease.body.includes('V1-OK'), + `status=${baselineRelease.status}`, + ); + assertReleaseHeaders('v1 HTML', baselineRelease, { html: true }); + const baselineCss = await release( + `/api/game-distribution/releases/${gameId}/app.css`, + ); + assertReleaseHeaders('v1 CSS', baselineCss, { html: false }); + + const pendingIds = {}; + for (const marker of ['V2-OK', 'V3-OK']) { + const built = await buildPackage(marker); + const version = await createVersion( + author, + gameId, + metadata, + built.bytes, + built.fileCount, + key(`ops-${marker}`), + ); + const versionId = version.data?.versionId; + await uploadVersion( + author, + versionId, + built.bytes, + key(`ops-upload-${marker}`), + ); + const submitted = await submitVersion( + author, + versionId, + 1, + key(`ops-submit-${marker}`), + ); + pendingIds[marker] = versionId; + check( + `${marker} 送审进入待审`, + submitted.status === 202, + `status=${submitted.status}`, + ); + } + const v2Id = pendingIds['V2-OK']; + const v3Id = pendingIds['V3-OK']; + const v4Built = await buildPackage('V4-OK'); + const v4Version = await createVersion( + author, + gameId, + metadata, + v4Built.bytes, + v4Built.fileCount, + key('ops-v4'), + ); + const v4Id = v4Version.data?.versionId; + check('v4 只建版本记录(未上传,用于验证上传被开关拦住)', Boolean(v4Id)); + + section('关闭发布开关:关投稿、保在线'); + const gateOff = await setPublishGate(admin, false, 0); + check( + '发布开关可关闭(紧急关闭投稿)', + gateOff.status === 200, + `status=${gateOff.status}`, + ); + + const blocked = [ + [ + '创建游戏', + await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-blocked-game') }, + body: metadata, + }), + ], + [ + '创建版本', + await api(`/api/game-distribution/games/${gameId}/versions`, { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-blocked-version') }, + body: { + packageSha256: createHash('sha256') + .update(v4Built.bytes) + .digest('hex'), + packageBytes: v4Built.bytes.length, + packageFileCount: v4Built.fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }), + ], + [ + '上传包', + await uploadVersion( + author, + v4Id, + v4Built.bytes, + key('ops-blocked-upload'), + ), + ], + ['送审', await submitVersion(author, v4Id, 1, key('ops-blocked-submit'))], + [ + '撤回', + await api(`/api/game-distribution/versions/${v4Id}/cancel`, { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-blocked-cancel') }, + body: { expectedPublicationRevision: 1 }, + }), + ], + [ + '作者下架', + await api(`/api/game-distribution/games/${gameId}/unpublish`, { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-blocked-unpublish') }, + body: { expectedPublicationRevision: 1 }, + }), + ], + [ + '管理员批准新版本', + await reviewVersion( + admin, + v3Id, + { decision: 'approve', expectedPublicationRevision: 1 }, + key('ops-blocked-approve'), + ), + ], + ]; + for (const [name, response] of blocked) { + check( + `开关关闭时「${name}」被拦(503 + 发布关闭码)`, + response.status === 503 && response.error?.code === PUBLISH_DISABLED_CODE, + `status=${response.status} code=${response.error?.code ?? ''}`, + ); + } + + const closedCatalog = await api('/api/game-distribution/games'); + const closedDetail = await api(`/api/game-distribution/games/${gameId}`); + const closedMine = await api('/api/game-distribution/my-games', { + token: author, + }); + const closedReviews = await api('/admin/api/game-distribution/reviews', { + token: admin, + }); + const closedRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '开关关闭时读取全部可用(目录/详情/我的游戏/审核队列)', + closedCatalog.status === 200 && + closedDetail.status === 200 && + closedMine.status === 200 && + closedReviews.status === 200, + `catalog=${closedCatalog.status} detail=${closedDetail.status} mine=${closedMine.status} reviews=${closedReviews.status}`, + ); + check( + '开关关闭时当前公开版本不变(回滚窗口保留在线旧版)', + closedDetail.data?.publicationRevision === 1 && + closedDetail.data?.currentVersion?.id === v1Id, + `revision=${closedDetail.data?.publicationRevision ?? ''} active=${closedDetail.data?.currentVersion?.id ?? ''}`, + ); + check( + '开关关闭时已公开游戏仍可游玩(发行入口继续服务 v1)', + closedRelease.status === 200 && closedRelease.body.includes('V1-OK'), + `status=${closedRelease.status}`, + ); + + const rejected = await reviewVersion( + admin, + v2Id, + { + decision: 'reject', + expectedPublicationRevision: 1, + reviewReason: 'E2E 开关关闭仍可拒绝审核', + }, + key('ops-reject-v2'), + ); + check( + '开关关闭时拒绝审核始终可用', + rejected.status === 200, + `status=${rejected.status} code=${rejected.error?.code ?? ''}`, + ); + + const suspended = await api( + `/admin/api/game-distribution/games/${gameId}/suspend`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': key('ops-suspend') }, + body: { + expectedPublicationRevision: 1, + reason: 'E2E 开关关闭仍可安全下架', + }, + }, + ); + check( + '开关关闭时管理员安全下架始终可用', + suspended.status === 200, + `status=${suspended.status} code=${suspended.error?.code ?? ''}`, + ); + const suspendedDetail = await api(`/api/game-distribution/games/${gameId}`); + const suspendedRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '下架后新的发行请求立刻被源站拒绝(不依赖 CDN purge)', + suspendedRelease.status === 404 && suspendedDetail.status === 404, + `release=${suspendedRelease.status} detail=${suspendedDetail.status}`, + ); + const afterSuspend = await adminGameRow(admin, gameId); + const suspendedRevision = afterSuspend.game?.publicationRevision; + const restored = await api( + `/admin/api/game-distribution/games/${gameId}/restore`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': key('ops-restore') }, + body: { expectedPublicationRevision: suspendedRevision }, + }, + ); + const restoredRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '安全下架可恢复,恢复后发行入口重新服务同一公开版本', + restored.status === 200 && + restoredRelease.status === 200 && + restoredRelease.body.includes('V1-OK'), + `restore=${restored.status} release=${restoredRelease.status}`, + ); + + section('重新开放后换版:旧公开版本被保留为已撤回记录'); + const reopened = await setPublishGate(admin, true, 100); + check( + '发布开关可重新开放', + reopened.status === 200, + `status=${reopened.status}`, + ); + const beforeSwitch = await adminGameRow(admin, gameId); + const switchRevision = beforeSwitch.game?.publicationRevision; + const switched = await reviewVersion( + admin, + v3Id, + { decision: 'approve', expectedPublicationRevision: switchRevision }, + key('ops-approve-v3'), + ); + check( + '开关恢复后管理员可以激活新版本', + switched.status === 200, + `status=${switched.status} code=${switched.error?.code ?? ''}`, + ); + const switchedRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '换版后发行入口改为新版本内容', + switchedRelease.status === 200 && switchedRelease.body.includes('V3-OK'), + `status=${switchedRelease.status} marker=${switchedRelease.body.includes('V3-OK')}`, + ); + const mine = await api('/api/game-distribution/my-games', { token: author }); + const mineGame = (mine.data?.games ?? []).find((game) => game.id === gameId); + const mineVersions = mineGame?.versions ?? []; + const v1Record = mineVersions.find((version) => version.versionId === v1Id); + check( + '换版后旧公开版本仍作为已撤回记录保留(可追溯、未删除)', + v1Record?.status === 'revoked', + `v1=${v1Record?.status ?? 'missing'} versions=${mineVersions.length}`, + ); + + section('日志脱敏与边缘日志格式'); + await sleep(500); + const logFile = newestApiLog(); + const logText = logFile ? readFileSync(logFile, 'utf8') : ''; + check( + '运行期日志不出现访问令牌与刷新 Cookie', + Boolean(logFile) && + !logText.includes(author) && + (!refreshCookieValue || !logText.includes(refreshCookieValue)), + `log=${path.basename(logFile ?? '(none)')} tokenHit=${logText.includes(author)} cookieChecked=${Boolean(refreshCookieValue)}`, + ); + check( + '日志脱敏正向对照:本轮的发行请求确实落在同一份日志里', + Boolean(logFile) && logText.includes(gameId), + `log=${path.basename(logFile ?? '(none)')} gameHit=${logText.includes(gameId)}`, + ); + check( + '运行期日志不出现 OSS signed URL 凭据', + !logText.includes('OSSAccessKeyId') && !logText.includes('Signature='), + ); + check( + '关闭投稿与安全下架在日志里按 operation 可观测', + logText.includes('publish_switch_blocked') && + logText.includes('game_suspended'), + ); + const edgeLeaks = EDGE_TEMPLATES.filter((template) => { + const source = readFileSync(template, 'utf8'); + const match = source.match(/log_format\s+[\s\S]*?;/u); + const format = match ? match[0] : ''; + return ( + format.includes('$http_authorization') || + format.includes('$http_cookie') || + format.includes('$arg_') + ); + }); + check( + '三份边缘模板的 log_format 不记录请求头与查询参数', + edgeLeaks.length === 0, + `leaks=${edgeLeaks.join(',')}`, + ); + const routeIssues = EDGE_TEMPLATES.filter((template) => { + const source = readFileSync(template, 'utf8'); + return !( + source.includes( + 'location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"', + ) && + source.includes('proxy_set_header Cookie ""') && + source.includes( + 'proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path', + ) + ); + }); + check( + '三份边缘模板把 /games// 映射到发行网关并清空 Cookie', + routeIssues.length === 0, + `issues=${routeIssues.join(',')}`, + ); + const opsDoc = readFileSync(OPS_DOC_PATH, 'utf8'); + check( + '运维文档与运行期缓存窗口一致(60 秒上限 + 已下载脚本不可远程抹除)', + opsDoc.includes(`max-age=${RELEASE_TTL_SECONDS}`) && + opsDoc.includes(`${RELEASE_TTL_SECONDS} 秒`) && + opsDoc.includes('无法远程抹除'), + ); +} + +async function run() { + try { + await main(); + } finally { + // 无论通过与否都放开灰度,避免把本机后续验证卡在“关投稿”状态。 + try { + const admin = await adminToken(); + if (admin) await setPublishGate(admin, true, 100); + } catch (error) { + console.error(`恢复发布开关失败:${error}`); + } + console.log(failures === 0 ? '\n全部通过' : `\n${failures} 项失败`); + process.exit(failures === 0 ? 0 : 1); + } +} + +await run();