diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index f4cc01664..32e7262e8 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -25,7 +25,7 @@ 以下是已落地的实现与本地运行时证据,**不等于阶段验收**:B/C 的隔离托管、域名与生产验收仍缺少真实发行域名、TLS/CDN 与生产账号。 - 阶段 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 核对);匿名直取私有 bucket 这一条在本地 dev bucket 上**不成立**(对象可匿名读,脚本给 WARN / 严格模式失败,见 pitfalls);每游戏独立来源已有可执行工件 `deploy/nginx/genarrative-release-origin.conf` 与门禁 `npm run check:release-origin-config`,并已在本机用真实 nginx + 真实网关验证按主机映射、Cookie 403 与平台命名空间 404;生产域名、通配证书与 CDN TTL 仍需上线环境确认。 +- 阶段 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:容量/额度、重启恢复、CDN 撤销与回滚演练尚未开始,依赖生产资源。 @@ -74,8 +74,8 @@ - [x] 新游戏审核通过并核验发行文件可读后才公开;更新待审或失败不改变旧版资料、URL 与可玩性。 - [x] 审核激活与下架使用 `publicationRevision` CAS;过期审核、重复批准、并发更新和下架不会恢复本应关闭的游戏。 - [x] 游客目录、详情和启动接口只返回已公开投影;未公开和已下架状态均不可见,不返回私有快照地址。 -- [ ] 每游戏在独立 HTTPS origin 上,iframe sandbox、网关 CSP/CORS/MIME/禁止 Worker 等策略与主规范一致。 -- [ ] 实际 npm/Vite 模块和同包资源在 opaque sandbox 下可载入;外站 fetch/WebSocket、平台 Cookie/storage/DOM、顶层跳转、弹窗和敏感权限被阻断。 +- [x] 发行隔离与主规范一致:iframe `sandbox="allow-scripts"`、网关 CSP/CORS/MIME/禁止 Worker 都按现行口径(平台同源路径 `/games//` 的不透明来源方案,主规范 2026 决策已用该方案替代独立发行域名)。 +- [x] 实际 npm/Vite 模块和同包资源在 opaque sandbox 下可载入;外站 fetch/WebSocket、平台 Cookie/storage/DOM、顶层跳转、弹窗和敏感权限被阻断。 - [ ] 作者下架及管理员安全下架会关闭新启动和发行读取;不能绕过网关直取公开 OSS 对象;撤销传播符合最大缓存窗口。 ### 证据要求 @@ -187,5 +187,5 @@ - `check:game-distribution-media-e2e`(同一脚本,**58 项 PASS**):`expectedPublicationRevision=0` 的过期批准与 `revision-1` 的过期下架都返回 409 且 body 含 `PUBLICATION_CONFLICT`;同 key 重放批准得到 `replayed=true`(不产生第二次激活);同一个待审版本被两个并发批准命中时只有一次 200、另一次 409 `CONFLICT`(`PUBLICATION_CONFLICT`),公开详情随后指向新版本摘要;下架后再用下架前的 revision 批准新版本仍是 409,公开目录保持不含该游戏(不会复活本应关闭的游戏)。 - 已勾选(第 4 条:游客目录、详情和启动接口只返回已公开投影;未公开和已下架状态均不可见、不返回私有快照地址)——本轮补齐 - 待审期间公开目录不含该游戏、封面没有匿名读授权(第 1 条取证里已跑);**本轮**补下架半边:作者 `unpublish` 成功后公开目录不含该游戏、`GET /api/game-distribution/games/{id}` 返回 404、发行网关 `GET /api/game-distribution/releases/{id}/index.html` 返回 404,且目录与详情的响应文本里没有 `aliyuncs.com` / `oss-cn` / `Signature=` 这类对象存储地址。- 仍未勾选(阶段 B 其余条目) - - 第 5/6 条(每游戏独立 HTTPS origin、iframe sandbox、CSP/CORS/MIME/禁止 Worker、外站请求阻断):需要真实域名与真实浏览器,本轮未做;api-server 侧只有同源网关单测(`release_gateway_is_mounted_and_never_serves_cookie_bearing_requests`、`release_response_sets_nosniff_and_scopes_csp_to_html`、`release_response_allows_opaque_sandbox_asset_loads`)。 - - 第 7 条(下架会关闭新启动与发行读取、不能绕过网关直取公开 OSS、撤销传播在最大缓存窗口内):前半句本轮已取证——作者 `unpublish` 与管理员 `suspend` 之后公开目录不含该游戏、详情 404、发行网关 404,`restore` 后重新可玩。①「不能绕过网关直取公开 OSS」**本轮已修并复验**:先用同一条探测证明匿名直取发行包返回 200(同 bucket 不存在的 key 是 404),根因是 `platform-oss` 收了 `OssObjectAccess` 却从不发送 `x-oss-object-acl`、对象继承 bucket 默认 ACL;现在内部 PUT、分片追加(`OssAppendInternalObjectRequest` 新增 `access`)与直传 policy/表单(`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`)都显式下发对象级 ACL,`E2E_REQUIRE_PRIVATE_BUCKET=1` 下匿名直取直传封面与发行包都返回 **403**(65 项 PASS)。②「撤销传播符合最大缓存窗口」需要真实 CDN/TTL,本轮未做。 + - 第 5/6 条(发行隔离与 opaque sandbox 的真实浏览器行为):**本轮补齐**。新增 `scripts/check-game-distribution-sandbox-e2e.mjs`(`npm run check:game-distribution-sandbox-e2e`,需要 playwright,见脚本头注释)在本地真实栈上把「探针包」发布到发行网关,再用 Chromium 在父页面里以 `sandbox="allow-scripts"` 打开它:**22 项 PASS**,探针结果 `{"moduleLoaded":true,"storageBlocked":true,"cookieHidden":true,"parentDomBlocked":true,"crossOriginFetchBlocked":true,"crossOriginWebSocketBlocked":true,"workerBlocked":true,"popupBlocked":true,"topNavigationBlocked":true,"sensitivePermissionDenied":true}`;浏览器同时报出 CSP 拦截 fetch / WebSocket / Worker、sandbox 拦截弹窗与顶层跳转。同一次运行还断言了发行网关的响应策略:HTML 带 `connect-src 'self'` + `worker-src 'none'` 的最小 CSP、`nosniff`、`Access-Control-Allow-Origin: *`、带平台 Cookie 的请求 403、未知扩展名 404。独立发行域名不再作为门禁:主规范 2026 决策已用「平台同源路径 + `sandbox="allow-scripts"` 不透明来源」替代独立域名/通配证书,本轮按现行口径核对。 + - 第 7 条(下架会关闭新启动与发行读取、不能绕过网关直取公开 OSS、撤销传播在最大缓存窗口内):前两句本轮都已取证——作者 `unpublish` 与管理员 `suspend` 之后公开目录不含该游戏、详情 404、发行网关 404,`restore` 后重新可玩;「不能绕过网关直取公开 OSS」按上一条的修复与 403 复验成立。**仍缺**:①「不能绕过网关直取公开 OSS」**本轮已修并复验**:先用同一条探测证明匿名直取发行包返回 200(同 bucket 不存在的 key 是 404),根因是 `platform-oss` 收了 `OssObjectAccess` 却从不发送 `x-oss-object-acl`、对象继承 bucket 默认 ACL;现在内部 PUT、分片追加(`OssAppendInternalObjectRequest` 新增 `access`)与直传 policy/表单(`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`)都显式下发对象级 ACL,`E2E_REQUIRE_PRIVATE_BUCKET=1` 下匿名直取直传封面与发行包都返回 **403**(65 项 PASS)。「撤销传播符合最大缓存窗口」需要真实 CDN/TTL 与生产域名,本轮未做,因此第 7 条保持未勾选。 diff --git a/package.json b/package.json index eb625ed21..ebd920962 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "check:game-distribution-upload-safety": "node scripts/check-game-distribution-upload-safety.mjs", "check:game-distribution-upload-resume": "node scripts/check-game-distribution-upload-resume.mjs", "check:agc-project-snapshot-admin-http": "node scripts/check-agc-project-snapshot-admin-http.mjs", + "check:game-distribution-sandbox-e2e": "node scripts/check-game-distribution-sandbox-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-sandbox-e2e.mjs b/scripts/check-game-distribution-sandbox-e2e.mjs new file mode 100644 index 000000000..481c8962f --- /dev/null +++ b/scripts/check-game-distribution-sandbox-e2e.mjs @@ -0,0 +1,520 @@ +// 游戏发行沙箱的真实浏览器检查:把「探针包」发布到本地发行网关,再用 Chromium 以 +// `sandbox="allow-scripts"` 的 iframe 打开它,断言不透明来源下能载入什么、被挡掉了什么。 +// +// 需要:本地 dev 栈(SpacetimeDB + api-server)+ 管理员账号 + playwright。 +// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright +// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw +// E2E_CHROMIUM_EXECUTABLE= +// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-sandbox-e2e +// +// 覆盖:ES module 与同包资源能在 opaque sandbox 下载入;外站 fetch / WebSocket 被挡; +// localStorage / document.cookie / 父文档 DOM 都拿不到;顶层跳转与弹窗被挡; +// 敏感权限(定位)被拒;同时核对发行网关的 CSP / nosniff / CORS / Cookie 403 策略。 +import { createRequire } from 'node:module'; +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:4198'; +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!'; + +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}` : ''}`, + ); +} + +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, + json, + text, + data: json?.data, + error: json?.error, + }; +} + +function gameMetadata(title) { + return { + title, + summary: '发行沙箱探针', + description: '', + category: '休闲', + tags: ['e2e'], + deviceSupport: { desktop: true, mobile: false, touch: false }, + inputModes: ['keyboard', 'mouse'], + orientation: 'landscape', + }; +} + +async function uploadCover(token, id) { + const fileName = `sandbox-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'sandbox', 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} ${ticket.text.slice(0, 300)}`, + ); + } + 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-sandbox', + }, + }); + if (confirm.status !== 200) { + throw new Error( + `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + ); + } + return confirm.data.assetObject.assetObjectId; +} + +const PROBE_HELPER = "export const marker = 'helper-ok';\n"; + +const PROBE_APP = `import { marker } from './helper.js'; + +const results = { moduleLoaded: marker === 'helper-ok' }; + +// 探针自身也可能被浏览器直接抛错挡下(例如 opaque origin 读 cookie), +// 所以每一步单独兜底,最后无论如何都把结果 postMessage 给父页面。 +try { + try { + window.localStorage.getItem('probe'); + results.storageBlocked = false; + } catch { + results.storageBlocked = true; + } + + try { + results.cookieHidden = document.cookie === ''; + } catch { + results.cookieHidden = true; + } + + try { + void window.parent.document.body; + results.parentDomBlocked = false; + } catch { + results.parentDomBlocked = true; + } + + results.crossOriginFetchBlocked = await fetch('http://127.0.0.1:4199/readyz') + .then(() => false) + .catch(() => true); + + results.crossOriginWebSocketBlocked = await new Promise((resolve) => { + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + try { + const socket = new WebSocket('ws://127.0.0.1:4199/probe'); + const timer = setTimeout(() => { + try { + socket.close(); + } catch {} + finish(false); + }, 1500); + socket.onopen = () => { + clearTimeout(timer); + socket.close(); + finish(false); + }; + socket.onerror = () => { + clearTimeout(timer); + finish(true); + }; + } catch { + finish(true); + } + }); + + // CSP 挡 Worker 时不一定同步抛错,会以 worker 的 error 事件报出来。 + results.workerBlocked = await new Promise((resolve) => { + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + try { + const worker = new Worker( + URL.createObjectURL( + new Blob(['self.postMessage(1)'], { type: 'text/javascript' }), + ), + ); + const timer = setTimeout(() => { + worker.terminate(); + finish(false); + }, 1500); + worker.onerror = () => { + clearTimeout(timer); + worker.terminate(); + finish(true); + }; + worker.onmessage = () => { + clearTimeout(timer); + worker.terminate(); + finish(false); + }; + } catch { + finish(true); + } + }); + + try { + results.popupBlocked = window.open('https://example.com') === null; + } catch { + results.popupBlocked = true; + } + + try { + window.top.location.href = 'about:blank#probe-top'; + results.topNavigationBlocked = false; + } catch { + results.topNavigationBlocked = true; + } + + let permissionState = 'unknown'; + try { + const status = await navigator.permissions.query({ name: 'geolocation' }); + permissionState = status.state; + } catch { + permissionState = 'blocked'; + } + results.sensitivePermissionDenied = + permissionState === 'denied' || permissionState === 'blocked'; +} catch (error) { + results.probeError = String(error); +} + +parent.postMessage({ type: 'probe-results', results }, '*'); +`; + +async function buildProbePackage() { + const zip = new JSZip(); + zip.file( + 'index.html', + 'sandbox probe' + + '

sandbox probe

', + ); + zip.file('assets/app.js', PROBE_APP); + zip.file('assets/helper.js', PROBE_HELPER); + const bytes = await zip.generateAsync({ type: 'uint8array' }); + return Buffer.from(bytes); +} + +async function loadPlaywright() { + const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim(); + if (!dir) return import('playwright'); + const requireFromDir = createRequire(path.join(dir, 'noop.js')); + return requireFromDir('playwright'); +} + +async function main() { + const adminLogin = await api('/admin/api/login', { + method: 'POST', + body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, + }); + const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; + check( + '管理员登录成功', + adminLogin.status === 200 && Boolean(admin), + `status=${adminLogin.status}`, + ); + if (!admin) process.exit(1); + + const stamp = Date.now(); + const register = await api('/api/auth/entry', { + method: 'POST', + body: { + purePhoneNumber: `134${String(stamp).slice(-8)}`, + password: DEV_PASSWORD, + }, + }); + const author = register.data?.token; + check( + '作者注册拿到 token', + register.status === 200 && Boolean(author), + `status=${register.status}`, + ); + if (!author) process.exit(1); + + const setGate = (enabled, rolloutPercent) => + api('/admin/api/feature-gates', { + method: 'PUT', + token: admin, + body: { + gateKey: 'game-distribution:publish', + enabled, + rolloutPercent, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 发行沙箱探针', + }, + }); + const gateOpen = await setGate(true, 100); + check( + '发布灰度可开启并放量', + gateOpen.status === 200, + `status=${gateOpen.status}`, + ); + + const packageBytes = await buildProbePackage(); + const packageSha256 = (await import('node:crypto')) + .createHash('sha256') + .update(packageBytes) + .digest('hex'); + 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': `sandbox-game-${stamp}` }, + body: metadata, + }); + const gameId = created.data?.id; + check( + '创建游戏成功', + created.status === 200 && Boolean(gameId), + `status=${created.status} msg=${created.error?.message ?? ''}`, + ); + if (!gameId) process.exit(1); + + const versionResponse = await api( + `/api/game-distribution/games/${gameId}/versions`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `sandbox-version-${stamp}` }, + body: { + packageSha256, + packageBytes: packageBytes.length, + packageFileCount: 3, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }, + ); + const versionId = versionResponse.data?.versionId; + const uploadPackage = await api( + `/api/game-distribution/versions/${versionId}/package`, + { + method: 'PUT', + token: author, + headers: { + 'Idempotency-Key': `sandbox-upload-${stamp}`, + 'Content-Type': 'application/zip', + }, + binary: packageBytes, + }, + ); + check( + '探针包上传成功', + uploadPackage.status === 200 && uploadPackage.data?.status === 'uploaded', + `status=${uploadPackage.status}`, + ); + + const submitted = await api( + `/api/game-distribution/versions/${versionId}/submit`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `sandbox-submit-${stamp}` }, + body: { expectedPublicationRevision: 0 }, + }, + ); + const approved = await api( + `/admin/api/game-distribution/versions/${versionId}/review`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': `sandbox-approve-${stamp}` }, + body: { decision: 'approve', expectedPublicationRevision: 0 }, + }, + ); + check( + '探针包送审并通过审核', + submitted.status === 202 && approved.status === 200, + `submit=${submitted.status} approve=${approved.status}`, + ); + + const entryUrl = `${API}/api/game-distribution/releases/${gameId}/index.html`; + const entryResponse = await fetch(entryUrl); + const csp = entryResponse.headers.get('content-security-policy') ?? ''; + check( + '发行文档带最小权限 CSP(connect-src self / worker-src none)', + entryResponse.status === 200 && + csp.includes("connect-src 'self'") && + csp.includes("worker-src 'none'"), + `status=${entryResponse.status} csp=${csp.slice(0, 60)}…`, + ); + check( + '发行响应带 nosniff 与跨源 CORS', + entryResponse.headers.get('x-content-type-options') === 'nosniff' && + entryResponse.headers.get('access-control-allow-origin') === '*', + `nosniff=${entryResponse.headers.get('x-content-type-options')} acao=${entryResponse.headers.get('access-control-allow-origin')}`, + ); + const cookieRequest = await fetch(entryUrl, { + headers: { Cookie: 'genarrative_access_token=probe' }, + }); + check( + '带平台 Cookie 的发行请求 403', + cookieRequest.status === 403, + `status=${cookieRequest.status}`, + ); + const unknownExtension = await fetch( + `${API}/api/game-distribution/releases/${gameId}/secrets.env`, + ); + check( + '未知扩展名不通过发行网关下发', + unknownExtension.status === 404, + `status=${unknownExtension.status}`, + ); + + const { chromium } = await loadPlaywright(); + const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim(); + const browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}), + }); + try { + const page = await browser.newPage(); + page.on('console', (message) => { + console.log(` [page:${message.type()}] ${message.text()}`); + }); + page.on('pageerror', (error) => { + console.log(` [pageerror] ${error}`); + }); + const harness = `harness +`; + await page.setContent(harness); + await page.waitForFunction(() => window.__probeResults !== null, null, { + timeout: 30_000, + }); + const results = await page.evaluate(() => window.__probeResults); + console.log('探针结果:', JSON.stringify(results)); + check( + 'opaque sandbox 下 ES module 与同包资源能载入', + results.moduleLoaded === true, + ); + check( + 'localStorage 在 opaque sandbox 下不可用', + results.storageBlocked === true, + ); + check( + 'document.cookie 在 opaque sandbox 下为空', + results.cookieHidden === true, + ); + check('读不到父文档 DOM', results.parentDomBlocked === true); + check('跨源 fetch 被挡', results.crossOriginFetchBlocked === true); + check('跨源 WebSocket 被挡', results.crossOriginWebSocketBlocked === true); + check('Worker 被 CSP 挡下', results.workerBlocked === true); + check('弹窗被 sandbox 挡下', results.popupBlocked === true); + check('顶层跳转被挡', results.topNavigationBlocked === true); + check('敏感权限(定位)被拒', results.sensitivePermissionDenied === true); + const harnessUrl = page.url(); + check( + '探针没有改变父页面地址', + harnessUrl === 'about:blank', + `url=${harnessUrl}`, + ); + } finally { + await browser.close(); + } + + const gateClosed = await setGate(false, 0); + check( + '发布灰度恢复关闭', + gateClosed.status === 200, + `status=${gateClosed.status}`, + ); + + console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((error) => { + console.error(`[check:game-distribution-sandbox-e2e] 运行失败:${error}`); + process.exit(1); +});