dcef9b62a8
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m17s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 7m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 7m48s
Project CI / Repository checks (pull_request) Successful in 5m36s
- admin.rs:钱包流水来源映射补齐第 17 个变体(索引 16 → game_purchase),后台「消耗泥点」白名单新增 game_purchase,并抽出效果分类与单行累加函数
- profile.rs:消耗口径抽出 profile_wallet_ledger_source_counts_as_consumption,消费投影增量、历史花费重建、投影初始化三处一并纳入 GamePurchase
- module-game-distribution/errors.rs:价格上限文案改为插值 MAX_GAME_PRICE_MUD_POINTS,并新增文案断言
- 新增 scripts/check-game-distribution-price-limit-parity.mjs 并挂进 npm run lint(随 Repository checks 在 CI 生效):断言 TS / 服务端 Rust / AGC Rust 常量与网页侧别名四处同源
- api-server 发行网关抽出可注入 loader 的 serve_public_release_asset,付费 404 严格发生在读取包字节之前;播放会话准入抽出 resolve_paid_play_session_entitlement
- 新增 api-server 单测:付费发行路径 404 且零次读包字节、免费放行且读到包字节、未购买 403 与作者 / 已购买 / 管理员放行
- spacetime-module:把「先判定后扣费」从源码扫描改为 resolve_game_purchase_pre_charge_plan 纯函数断言,删除 include_str 源码切片用例
- E2E 脚本:平台同源断言缺配置时记 SKIP 并在报告末尾汇总 SKIP 清单,E2E_REQUIRE_PLATFORM_ORIGIN=1 时缺配置直接 FAIL,汇总行改为输出 PASS/FAIL/WARN/SKIP 计数
- 修正注释里不存在的路由 POST /api/game-distribution/play-sessions(真实为 POST /api/game-distribution/games/{gameId}/play-session)与「管理员令牌 403」口径(403 需带 admin 角色的用户令牌)
445 lines
16 KiB
JavaScript
445 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { pathToFileURL } from 'node:url';
|
||
|
||
const APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts';
|
||
const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx';
|
||
const APP_PREFIX_ROUTE_ENTRIES_PATTERN =
|
||
/const APP_PREFIX_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u;
|
||
const COMPATIBILITY_ROUTES = [];
|
||
const NGINX_PATHS = [
|
||
'deploy/nginx/genarrative.conf',
|
||
'deploy/nginx/genarrative-dev-http.conf',
|
||
'deploy/container/nginx.conf',
|
||
];
|
||
const MAINTENANCE_NGINX_PATHS = [
|
||
'deploy/nginx/genarrative.conf',
|
||
'deploy/nginx/genarrative-dev-http.conf',
|
||
];
|
||
const MAINTENANCE_SNIPPET_PATH =
|
||
'deploy/nginx/snippets/genarrative-maintenance.conf';
|
||
const SPA_BLOCK_START = '# BEGIN GENARRATIVE MAIN SPA ROUTES';
|
||
const SPA_BLOCK_END = '# END GENARRATIVE MAIN SPA ROUTES';
|
||
const UNKNOWN_ROUTE_SAMPLES = [
|
||
'/unknown-root',
|
||
'/creation/not-exist',
|
||
'/runtime/not-exist',
|
||
'/puzzle/not-exist',
|
||
];
|
||
|
||
const failures = [];
|
||
|
||
function fail(message) {
|
||
failures.push(message);
|
||
}
|
||
|
||
function extractSourceBlock(source, pattern, label) {
|
||
const match = source.match(pattern);
|
||
if (!match) {
|
||
fail(`${label} 未找到。`);
|
||
return '';
|
||
}
|
||
return match[1];
|
||
}
|
||
|
||
function collectExpectedMainSpaRoutes() {
|
||
const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8');
|
||
const appRoutes = readFileSync(APP_ROUTES_PATH, 'utf8');
|
||
const stageEntries = extractSourceBlock(
|
||
appPageRoutes,
|
||
/const STAGE_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u,
|
||
`${APP_PAGE_ROUTES_PATH} STAGE_ROUTE_ENTRIES`,
|
||
);
|
||
|
||
const routes = [
|
||
...Array.from(
|
||
stageEntries.matchAll(/\[\s*'[^']+'\s*,\s*'([^']+)'\s*\]/gu),
|
||
(match) => match[1],
|
||
),
|
||
...Array.from(
|
||
appRoutes.matchAll(/normalizedPath === '([^']+)'/gu),
|
||
(match) => match[1],
|
||
),
|
||
...COMPATIBILITY_ROUTES,
|
||
];
|
||
|
||
const uniqueRoutes = [...new Set(routes)].sort();
|
||
if (uniqueRoutes.length === 0) {
|
||
fail('未从前端路由源提取到主站 SPA 路由。');
|
||
}
|
||
for (const route of uniqueRoutes) {
|
||
if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route)) {
|
||
fail(`前端路由源包含门禁暂不支持的路径格式: ${route}`);
|
||
}
|
||
}
|
||
return uniqueRoutes;
|
||
}
|
||
|
||
function compareRouteSets(actualRoutes, expectedRoutes, label) {
|
||
const actual = new Set(actualRoutes);
|
||
const expected = new Set(expectedRoutes);
|
||
const missing = expectedRoutes.filter((route) => !actual.has(route));
|
||
const extra = actualRoutes.filter((route) => !expected.has(route));
|
||
if (missing.length > 0) {
|
||
fail(`${label} 缺少 SPA 路由: ${missing.join(', ')}`);
|
||
}
|
||
if (extra.length > 0) {
|
||
fail(`${label} 包含非当前路由: ${extra.join(', ')}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 前缀路由在 Nginx 里的锚定形状:前缀 + 恰好一个路径段 + 一个可省略的尾部斜杠。
|
||
* 裸前缀自身由 SPA allowlist 的精确 location 负责,这里不重复放行,也不放宽到多段路径。
|
||
*/
|
||
export function buildPrefixRouteNginxPattern(prefix) {
|
||
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||
return `^${escapedPrefix}/[^/]+/?$`;
|
||
}
|
||
|
||
/** 校验前缀路由的放行形状;返回失败原因列表(空数组代表通过)。 */
|
||
export function collectPrefixRoutePatternFailures(pattern, prefix) {
|
||
const failures = [];
|
||
const expected = buildPrefixRouteNginxPattern(prefix);
|
||
if (pattern !== expected) {
|
||
failures.push(
|
||
`前缀路由 ${prefix} 的 Nginx 放行形状必须锚定为 ${expected}(前缀 + 恰好一个路径段 + 可省略的尾部斜杠),实际为 ${pattern}。`,
|
||
);
|
||
return failures;
|
||
}
|
||
const matcher = new RegExp(pattern, 'iu');
|
||
for (const sample of [
|
||
`${prefix}/checkout-token`,
|
||
`${prefix.toUpperCase()}/CheckOutToken/`,
|
||
]) {
|
||
if (!matcher.test(sample)) {
|
||
failures.push(`前缀路由形状 ${pattern} 未匹配深链: ${sample}`);
|
||
}
|
||
}
|
||
for (const sample of [
|
||
prefix,
|
||
`${prefix}/`,
|
||
`${prefix}/two/segments`,
|
||
`${prefix}suffix/segment`,
|
||
]) {
|
||
if (matcher.test(sample)) {
|
||
failures.push(`前缀路由形状 ${pattern} 错误接收非深链路径: ${sample}`);
|
||
}
|
||
}
|
||
return failures;
|
||
}
|
||
|
||
export function collectExpectedPrefixRoutes() {
|
||
const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8');
|
||
const entries = extractSourceBlock(
|
||
appPageRoutes,
|
||
APP_PREFIX_ROUTE_ENTRIES_PATTERN,
|
||
`${APP_PAGE_ROUTES_PATH} APP_PREFIX_ROUTE_ENTRIES`,
|
||
);
|
||
const routes = Array.from(
|
||
entries.matchAll(/\[\s*'([^']+)'\s*,\s*'([^']+)'\s*\]/gu),
|
||
(match) => ({ path: match[1], stage: match[2] }),
|
||
);
|
||
const uniqueRoutes = new Map(routes.map((route) => [route.path, route]));
|
||
for (const route of uniqueRoutes.values()) {
|
||
if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route.path)) {
|
||
fail(`前端前缀路由源包含门禁暂不支持的路径格式: ${route.path}`);
|
||
}
|
||
}
|
||
return [...uniqueRoutes.values()].sort((left, right) =>
|
||
left.path.localeCompare(right.path),
|
||
);
|
||
}
|
||
|
||
function findRegexLocationBody(block, pattern) {
|
||
for (const match of block.matchAll(/location\s+~\*\s+"([^"]+)"\s*\{/gu)) {
|
||
if (match[1] !== pattern) {
|
||
continue;
|
||
}
|
||
const openBrace = match.index + match[0].length - 1;
|
||
let depth = 0;
|
||
for (let index = openBrace; index < block.length; index += 1) {
|
||
if (block[index] === '{') {
|
||
depth += 1;
|
||
} else if (block[index] === '}') {
|
||
depth -= 1;
|
||
if (depth === 0) {
|
||
return block.slice(openBrace + 1, index);
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) {
|
||
const source = readFileSync(nginxPath, 'utf8');
|
||
const blockStart = source.indexOf(SPA_BLOCK_START);
|
||
const blockEnd = source.indexOf(SPA_BLOCK_END);
|
||
if (blockStart < 0 || blockEnd <= blockStart) {
|
||
fail(`${nginxPath} 缺少完整 SPA allowlist 标记。`);
|
||
return;
|
||
}
|
||
|
||
const block = source.slice(blockStart, blockEnd + SPA_BLOCK_END.length);
|
||
if (!/location\s+=\s+\/\s*\{/u.test(block)) {
|
||
fail(`${nginxPath} SPA allowlist 缺少根路径精确 location。`);
|
||
}
|
||
if (!block.includes('try_files /index.html =404;')) {
|
||
fail(`${nginxPath} 根路径没有精确回退 index.html。`);
|
||
}
|
||
if (!block.includes('try_files $uri /index.html =404;')) {
|
||
fail(`${nginxPath} SPA allowlist 没有精确回退 index.html。`);
|
||
}
|
||
|
||
const regexMatch = block.match(/location\s+~\*\s+"([^"]+)"\s*\{/u);
|
||
if (!regexMatch) {
|
||
fail(`${nginxPath} 缺少大小写不敏感的 SPA allowlist regex location。`);
|
||
return;
|
||
}
|
||
|
||
const nginxPattern = regexMatch[1];
|
||
const alternativesMatch = nginxPattern.match(/^\^\/\(\?:(.+)\)\/\?\$$/u);
|
||
if (!alternativesMatch) {
|
||
fail(`${nginxPath} SPA allowlist 必须锚定完整路径并允许一个尾部斜杠。`);
|
||
return;
|
||
}
|
||
|
||
const configuredRoutes = [
|
||
'/',
|
||
...alternativesMatch[1].split('|').map((route) => `/${route}`),
|
||
].sort();
|
||
compareRouteSets(configuredRoutes, expectedRoutes, nginxPath);
|
||
|
||
const matcher = new RegExp(nginxPattern, 'iu');
|
||
for (const route of expectedRoutes.filter((candidate) => candidate !== '/')) {
|
||
if (!matcher.test(route)) {
|
||
fail(`${nginxPath} SPA allowlist 未匹配完整路径: ${route}`);
|
||
}
|
||
if (!matcher.test(`${route.toUpperCase()}/`)) {
|
||
fail(`${nginxPath} SPA allowlist 未允许大小写差异和尾部斜杠: ${route}`);
|
||
}
|
||
}
|
||
for (const route of UNKNOWN_ROUTE_SAMPLES) {
|
||
if (matcher.test(route) || matcher.test(`${route}/`)) {
|
||
fail(`${nginxPath} SPA allowlist 错误接收未知路径: ${route}`);
|
||
}
|
||
}
|
||
|
||
// 前缀路由(带动态段)必须有独立的锚定 location:只放行裸前缀会让真实深链落到默认
|
||
// location 变成 404(例如收银台 `/pay/<checkoutToken>`)。
|
||
const exactLocationBody = findRegexLocationBody(block, nginxPattern);
|
||
const maintenanceGuard = 'if ($genarrative_maintenance) { return 503; }';
|
||
for (const { path: prefix } of prefixRoutes) {
|
||
if (!expectedRoutes.includes(prefix)) {
|
||
fail(
|
||
`${nginxPath} 前缀路由 ${prefix} 必须同时是精确路由:裸前缀自身也要能直达。`,
|
||
);
|
||
continue;
|
||
}
|
||
const expectedPattern = buildPrefixRouteNginxPattern(prefix);
|
||
const prefixLocationBody = findRegexLocationBody(block, expectedPattern);
|
||
if (prefixLocationBody === null) {
|
||
fail(
|
||
`${nginxPath} 缺少前缀路由 ${prefix} 的锚定 location(期望 location ~* "${expectedPattern}")。`,
|
||
);
|
||
continue;
|
||
}
|
||
for (const failure of collectPrefixRoutePatternFailures(
|
||
expectedPattern,
|
||
prefix,
|
||
)) {
|
||
fail(`${nginxPath} ${failure}`);
|
||
}
|
||
if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) {
|
||
fail(
|
||
`${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`,
|
||
);
|
||
}
|
||
if (
|
||
exactLocationBody?.includes(maintenanceGuard) &&
|
||
!prefixLocationBody.includes(maintenanceGuard)
|
||
) {
|
||
fail(
|
||
`${nginxPath} 前缀路由 ${prefix} 的 location 必须与精确 SPA location 一样先判维护状态。`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length);
|
||
if (!defaultLocation.includes('try_files $uri $uri/ =404;')) {
|
||
fail(
|
||
`${nginxPath} 未命中 SPA allowlist 的路径必须只读真实静态文件并返回 404。`,
|
||
);
|
||
}
|
||
if (defaultLocation.includes('try_files $uri $uri/ /index.html;')) {
|
||
fail(`${nginxPath} 默认 location 仍存在全路径 SPA fallback。`);
|
||
}
|
||
}
|
||
|
||
function validateMaintenanceInternalBypass() {
|
||
const snippet = readFileSync(MAINTENANCE_SNIPPET_PATH, 'utf8');
|
||
const internalBypassPattern =
|
||
/set \$genarrative_maintenance 0;\s*if \(-f \/var\/lib\/genarrative\/maintenance\/enabled\) \{\s*set \$genarrative_maintenance 1;\s*\}\s*if \(\$genarrative_internal_client\) \{\s*set \$genarrative_maintenance 0;\s*\}/u;
|
||
if (!internalBypassPattern.test(snippet)) {
|
||
fail(
|
||
`${MAINTENANCE_SNIPPET_PATH} 必须在读取维护 marker 后为真实内网来源清除全站维护状态。`,
|
||
);
|
||
}
|
||
if (snippet.includes('$genarrative_admin_maintenance')) {
|
||
fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`);
|
||
}
|
||
for (const fragment of [
|
||
'location = /branding/taonier-maintenance-page.png {',
|
||
'try_files /branding/taonier-maintenance-page.png =404;',
|
||
'location = /branding/taonier-product-ip.png {',
|
||
'try_files /branding/taonier-product-ip.png =404;',
|
||
'location = /404.html {',
|
||
'if ($http_accept !~* "text/html") {',
|
||
'try_files /404.html =404;',
|
||
'add_header Cache-Control "no-store";',
|
||
'internal;',
|
||
]) {
|
||
if (!snippet.includes(fragment)) {
|
||
fail(`${MAINTENANCE_SNIPPET_PATH} 缺少品牌 404 页面约束: ${fragment}`);
|
||
}
|
||
}
|
||
|
||
for (const nginxPath of MAINTENANCE_NGINX_PATHS) {
|
||
const source = readFileSync(nginxPath, 'utf8');
|
||
for (const fragment of [
|
||
'geo $remote_addr $genarrative_internal_client {',
|
||
'127.0.0.0/8 1;',
|
||
'10.0.0.0/8 1;',
|
||
'172.16.0.0/12 1;',
|
||
'192.168.0.0/16 1;',
|
||
'169.254.0.0/16 1;',
|
||
'::1 1;',
|
||
'fc00::/7 1;',
|
||
'fe80::/10 1;',
|
||
]) {
|
||
if (!source.includes(fragment)) {
|
||
fail(`${nginxPath} 缺少内网来源识别片段: ${fragment}`);
|
||
}
|
||
}
|
||
if (source.includes('$genarrative_admin_maintenance')) {
|
||
fail(`${nginxPath} 的维护入口必须统一使用全站维护变量。`);
|
||
}
|
||
if (!source.includes('error_page 404 /404.html;')) {
|
||
fail(`${nginxPath} 的 Web 未知路由必须返回品牌 404 页面。`);
|
||
}
|
||
const maintenanceChecks =
|
||
source.match(/if \(\$genarrative_[a-z_]*maintenance\)/gu) ?? [];
|
||
if (maintenanceChecks.length === 0) {
|
||
fail(`${nginxPath} 缺少维护状态判断。`);
|
||
}
|
||
for (const maintenanceCheck of maintenanceChecks) {
|
||
if (maintenanceCheck !== 'if ($genarrative_maintenance)') {
|
||
fail(
|
||
`${nginxPath} 存在未统一到全站维护变量的判断: ${maintenanceCheck}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const PLAY_SESSION_LOCATION =
|
||
'location ^~ /api/game-distribution/play-sessions/';
|
||
const GENERIC_API_LOCATION = 'location ~ ^/api(?:/|$)';
|
||
|
||
/** 取某个 location 头之后配对的花括号块内容;找不到返回 null。 */
|
||
function findLocationBody(source, locationHeader) {
|
||
const start = source.indexOf(locationHeader);
|
||
if (start < 0) {
|
||
return null;
|
||
}
|
||
const openBrace = source.indexOf('{', start);
|
||
if (openBrace < 0) {
|
||
return null;
|
||
}
|
||
let depth = 0;
|
||
for (let index = openBrace; index < source.length; index += 1) {
|
||
if (source[index] === '{') {
|
||
depth += 1;
|
||
} else if (source[index] === '}') {
|
||
depth -= 1;
|
||
if (depth === 0) {
|
||
return source.slice(openBrace + 1, index);
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 付费游戏播放会话前缀(sandbox iframe src 的前缀,包内相对资源沿同一前缀解析)必须有自己的
|
||
* `^~` 前缀 location 并清空 Cookie:api-server 播放网关对带平台 refresh Cookie 的请求返回 403,
|
||
* Cookie 一旦被边缘转发,iframe 与包内每个资源都会 403,付费游戏实际不可玩。
|
||
* `^~` 不能省——不加时正则 location `~ ^/api(?:/|$)` 优先级更高,Cookie 又会被转发回去;
|
||
* 前缀末尾的斜杠只影响裸前缀 `/api/game-distribution/play-sessions`(无 token,api-server 未注册
|
||
* 该路径);`/play-sessions/<token>` 与 `/play-sessions/<token>/…` 都落在该前缀内并被清 Cookie。
|
||
*/
|
||
function validatePlaySessionCookieIsolation() {
|
||
for (const nginxPath of NGINX_PATHS) {
|
||
const source = readFileSync(nginxPath, 'utf8');
|
||
const playSessionIndex = source.indexOf(PLAY_SESSION_LOCATION);
|
||
if (playSessionIndex < 0) {
|
||
fail(`${nginxPath} 缺少播放会话前缀 location:${PLAY_SESSION_LOCATION}`);
|
||
continue;
|
||
}
|
||
const body = findLocationBody(source, PLAY_SESSION_LOCATION);
|
||
if (body === null) {
|
||
fail(`${nginxPath} 播放会话前缀 location 没有配对的闭合块。`);
|
||
continue;
|
||
}
|
||
for (const fragment of [
|
||
'proxy_set_header Cookie "";',
|
||
'proxy_pass http://genarrative_api;',
|
||
'proxy_set_header Host $host;',
|
||
'proxy_set_header X-Real-IP $remote_addr;',
|
||
'proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
|
||
'proxy_set_header X-Forwarded-Proto $scheme;',
|
||
]) {
|
||
if (!body.includes(fragment)) {
|
||
fail(`${nginxPath} 播放会话前缀 location 缺少代理片段:${fragment}`);
|
||
}
|
||
}
|
||
const genericApiIndex = source.indexOf(GENERIC_API_LOCATION);
|
||
if (genericApiIndex >= 0 && playSessionIndex > genericApiIndex) {
|
||
fail(
|
||
`${nginxPath} 播放会话前缀 location 必须排在通用 /api location(${GENERIC_API_LOCATION})之前。`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
|
||
export const expectedPrefixRoutes = collectExpectedPrefixRoutes();
|
||
|
||
const isMainModule =
|
||
process.argv[1] &&
|
||
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
|
||
|
||
if (isMainModule) {
|
||
for (const nginxPath of NGINX_PATHS) {
|
||
validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes);
|
||
}
|
||
validateMaintenanceInternalBypass();
|
||
validatePlaySessionCookieIsolation();
|
||
|
||
if (failures.length > 0) {
|
||
console.error('[check:nginx-spa-routes] FAILED');
|
||
for (const failure of failures) {
|
||
console.error(`- ${failure}`);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(
|
||
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${expectedPrefixRoutes.length} prefix routes, ${NGINX_PATHS.length} Nginx templates)`,
|
||
);
|
||
}
|