Files
Genarrative/scripts/check-pingora-route-parity.mjs
T
suzmii eeb101458a
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
Merge remote-tracking branch 'origin/master' into feat/game-fork
# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs
#	apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx
#	apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	package.json
#	packages/shared/src/contracts/gameDistribution.ts
#	scripts/check-game-distribution-dto-parity.mjs
#	server-rs/crates/api-server/src/modules/game_distribution.rs
#	server-rs/crates/module-game-distribution/src/errors.rs
#	server-rs/crates/module-game-distribution/src/lib.rs
#	server-rs/crates/shared-contracts/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/active.rs
#	server-rs/crates/spacetime-client/src/active/mapper.rs
#	server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs
#	server-rs/crates/spacetime-client/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/module_bindings.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs
#	server-rs/crates/spacetime-module/src/game_distribution.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	src/components/game-distribution/GameDetailPage.tsx
#	src/components/game-distribution/GameDistributionPages.test.tsx
#	src/components/game-distribution/GamePlayPage.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
#	src/services/gameDistributionClient.test.ts
#	src/services/gameDistributionClient.ts
2026-10-06 16:10:52 +08:00

467 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import {
expectedMainSpaRoutes,
expectedPrefixRoutes,
} from './check-nginx-spa-routes.mjs';
const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json';
const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf';
const DEVELOPMENT_NGINX_PATH = 'deploy/nginx/genarrative-dev-http.conf';
const PINGORA_DOC_PATH =
'docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md';
const PINGORA_GATEWAY_SOURCE = 'server-rs/crates/pingora-gateway/src/main.rs';
const VALID_KINDS = new Set([
'proxy',
'static',
'release_gateway',
'play_session_gateway',
'redirect_permanent',
'shadow_probe',
'not_found',
]);
const VALID_PROXY_TARGETS = new Set(['api', 'spacetime']);
const VALID_STATIC_ROOTS = new Set(['web', 'acme']);
const VALID_STATIC_MODES = new Set(['exact', 'spa_fallback']);
const VALID_PROTECTION_CLASSES = new Set(['admin_api', 'api', 'spacetime']);
const REQUIRED_ROUTE_IDS = [
'acme_challenge',
'shadow_probe',
'admin_redirect',
'admin_api_proxy',
'admin_assets',
'admin_spa_fallback',
'web_assets',
'generic_api_proxy',
'spacetime_subscribe',
'spacetime_identity',
'v1_forbidden',
'healthz_forbidden',
'readyz_forbidden',
'generated_assets_forbidden',
'web_spa_fallback',
'profile_spa_fallback',
'games_spa_fallback',
'games_release_gateway',
'play_sessions_gateway',
'web_root_spa',
'web_spa_case_trailing_slash',
'web_unknown_path_exact',
'creation_unknown_path_exact',
'runtime_unknown_path_exact',
'puzzle_unknown_path_exact',
];
const files = {
production: readFileSync(PRODUCTION_NGINX_PATH, 'utf8'),
development: readFileSync(DEVELOPMENT_NGINX_PATH, 'utf8'),
};
const docs = readFileSync(PINGORA_DOC_PATH, 'utf8');
const pingoraGatewaySource = readFileSync(PINGORA_GATEWAY_SOURCE, 'utf8');
const matrix = JSON.parse(readFileSync(MATRIX_PATH, 'utf8'));
const failures = [];
function fail(message) {
failures.push(message);
}
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function requireString(value, context) {
if (typeof value !== 'string' || value.trim() === '') {
fail(`${context} 必须是非空字符串。`);
return false;
}
return true;
}
function validateExpectation(route) {
const context = `${MATRIX_PATH} route ${route.id}`;
const expect = route.expect;
if (!expect || typeof expect !== 'object' || Array.isArray(expect)) {
fail(`${context} 缺少 expect 对象。`);
return;
}
if (!VALID_KINDS.has(expect.kind)) {
fail(`${context} expect.kind 不支持: ${expect.kind}`);
return;
}
if (expect.kind === 'proxy') {
if (!VALID_PROXY_TARGETS.has(expect.target)) {
fail(`${context} proxy target 不支持: ${expect.target}`);
}
if (
hasOwn(expect, 'bodyLimit') &&
expect.bodyLimit !== null &&
expect.bodyLimit !== 'default' &&
(!Number.isInteger(expect.bodyLimit) || expect.bodyLimit < 1)
) {
fail(`${context} bodyLimit 必须是 null、default 或正整数。`);
}
if (!VALID_PROTECTION_CLASSES.has(expect.protectionClass)) {
fail(
`${context} proxy protectionClass 不支持: ${expect.protectionClass}`,
);
}
return;
}
if (expect.kind === 'play_session_gateway') {
// 播放会话入口与通用 `/api` 同口径(同样吃 api 限流),但路径不重写、也不引入新的头部语义;
// 显式声明 protectionClass 是为了让下面「必须清空 Cookie」的断言有对比基准。
if (!VALID_PROTECTION_CLASSES.has(expect.protectionClass)) {
fail(
`${context} play_session_gateway protectionClass 不支持: ${expect.protectionClass}`,
);
}
if (hasOwn(expect, 'upstreamPath')) {
fail(
`${context} play_session_gateway 不做路径重写,不能配置 upstreamPath。`,
);
}
return;
}
if (hasOwn(expect, 'protectionClass')) {
fail(`${context} 非 proxy 路由不能配置 protectionClass。`);
}
if (expect.kind !== 'release_gateway' && hasOwn(expect, 'upstreamPath')) {
fail(`${context} 只有 release_gateway 路由才能配置 upstreamPath。`);
}
if (expect.kind === 'release_gateway') {
requireString(expect.upstreamPath, `${context} upstreamPath`);
return;
}
if (expect.kind === 'static') {
if (!VALID_STATIC_ROOTS.has(expect.root)) {
fail(`${context} static root 不支持: ${expect.root}`);
}
if (!VALID_STATIC_MODES.has(expect.mode)) {
fail(`${context} static mode 不支持: ${expect.mode}`);
}
}
if (
expect.kind === 'redirect_permanent' &&
!requireString(expect.location, `${context} redirect location`)
) {
fail(`${context} redirect_permanent 必须配置 location。`);
}
}
function validateNginxFragments(route) {
for (const environment of ['production', 'development']) {
const fragments = route.nginx?.[environment];
if (fragments === undefined) {
if (environment === 'production' && route.id !== 'shadow_probe') {
fail(`${MATRIX_PATH} route ${route.id} 缺少 production Nginx 片段。`);
}
continue;
}
if (!Array.isArray(fragments) || fragments.length === 0) {
fail(
`${MATRIX_PATH} route ${route.id} 的 ${environment} Nginx 片段不能为空。`,
);
continue;
}
for (const fragment of fragments) {
if (!requireString(fragment, `${route.id} ${environment} Nginx 片段`)) {
continue;
}
if (!files[environment].includes(fragment)) {
fail(
`${environment} Nginx 模板缺少 route ${route.id} 片段: ${fragment}`,
);
}
}
}
}
function validateDocFragments(route) {
if (!Array.isArray(route.docs) || route.docs.length === 0) {
fail(`${MATRIX_PATH} route ${route.id} 缺少 docs 片段。`);
return;
}
for (const fragment of route.docs) {
if (!requireString(fragment, `${route.id} docs 片段`)) {
continue;
}
if (!docs.includes(fragment)) {
fail(`Pingora 试点文档缺少 route ${route.id} 片段: ${fragment}`);
}
}
}
function validateMatrixShape() {
if (matrix.version !== 1) {
fail(`${MATRIX_PATH} version 必须为 1。`);
}
if (!Array.isArray(matrix.routes) || matrix.routes.length === 0) {
fail(`${MATRIX_PATH} routes 不能为空。`);
return;
}
const ids = new Set();
const samplePaths = new Set();
for (const route of matrix.routes) {
if (!requireString(route.id, `${MATRIX_PATH} route.id`)) {
continue;
}
if (ids.has(route.id)) {
fail(`${MATRIX_PATH} route id 重复: ${route.id}`);
}
ids.add(route.id);
if (!requireString(route.samplePath, `${route.id} samplePath`)) {
continue;
}
if (!route.samplePath.startsWith('/')) {
fail(`${MATRIX_PATH} route ${route.id} samplePath 必须以 / 开头。`);
}
if (samplePaths.has(route.samplePath)) {
fail(`${MATRIX_PATH} samplePath 重复: ${route.samplePath}`);
}
samplePaths.add(route.samplePath);
validateExpectation(route);
validateNginxFragments(route);
validateDocFragments(route);
}
for (const routeId of REQUIRED_ROUTE_IDS) {
if (!ids.has(routeId)) {
fail(`${MATRIX_PATH} 缺少必需 route id: ${routeId}`);
}
}
}
function validateRustTestUsesMatrix() {
for (const fragment of [
'include_str!("../../../../deploy/pingora/nginx-route-parity.matrix.json")',
'serde_json::from_str(ROUTE_PARITY_MATRIX_JSON)',
'protection_class_for_route(&route, &case.sample_path)',
'fn matches_nginx_route_parity_matrix()',
'fn is_main_spa_path(path: &str)',
"path.strip_suffix('/')",
'normalized.eq_ignore_ascii_case(candidate)',
'("play_session_gateway", RouteDecision::PlaySessionGateway)',
]) {
if (!pingoraGatewaySource.includes(fragment)) {
fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`);
}
}
}
// 播放会话前缀的 Pingora 侧判定:必须独立成 `PlaySessionGateway`,在通用 `/api` 分支之前命中,
// 并通过 `route_clears_cookie` 在上游代理阶段清空 Cookie。把前缀合并回通用 `/api` 分支(或删掉
// 清 Cookie 的处理)都会让这条断言失败。
function validateRustPlaySessionGatewayIsolation() {
for (const fragment of [
'fn is_play_session_proxy_path(path: &str) -> bool',
'"/api/game-distribution/play-sessions/"',
'fn route_clears_cookie(route: &RouteDecision) -> bool',
'upstream_request.remove_header("cookie");',
]) {
if (!pingoraGatewaySource.includes(fragment)) {
fail(`Pingora Rust 缺少播放会话 Cookie 隔离实现: ${fragment}`);
}
}
const classifyBlock = pingoraGatewaySource.match(
/fn classify_path\(path: &str\) -> RouteDecision \{([\s\S]*?)\n\}/u,
);
if (!classifyBlock) {
fail('Pingora Rust 缺少 classify_path 路由判定函数。');
return;
}
const playSessionIndex = classifyBlock[1].indexOf(
'if is_play_session_proxy_path(path) {',
);
const genericApiIndex = classifyBlock[1].indexOf(
'path == "/api" || path.starts_with("/api/")',
);
if (playSessionIndex < 0) {
fail(
'Pingora classify_path 缺少播放会话前缀判定(必须在通用 /api 分支之前命中)。',
);
return;
}
if (genericApiIndex < 0) {
fail('Pingora classify_path 缺少通用 /api 代理分支。');
return;
}
if (playSessionIndex > genericApiIndex) {
fail(
'Pingora classify_path 的播放会话前缀判定必须排在通用 /api 分支之前。',
);
}
if (!pingoraGatewaySource.includes('if route_clears_cookie(&ctx.route) {')) {
fail(
'Pingora 上游代理阶段必须按 route_clears_cookie 清空 Cookie(发行入口 / 播放会话入口同口径)。',
);
}
}
// 平台内容网关(发行入口 / 播放会话入口)必须在边缘清空 Cookie:api-server 侧对带平台
// refresh Cookie 的请求返回 403(纵深防御保留),Cookie 一旦被转发,sandbox iframe 与包内
// 每个相对资源都会 403,付费游戏实际不可玩(2026-10-05 就是这样被发现的)。
// 这条同时挡住「把播放会话前缀合并回通用 `/api` 规则」:通用规则必须继续转发 Cookie
// (`/api/auth/*` 依赖 refresh cookie),合并后要么清空 Cookie 的片段消失、要么落到通用 location。
function validateContentGatewayCookieIsolation() {
const clearCookieFragment = 'proxy_set_header Cookie "";';
const genericApiLocation = 'location ~ ^/api(?:/|$)';
const contentGatewayKinds = new Set([
'release_gateway',
'play_session_gateway',
]);
for (const route of matrix.routes) {
if (!contentGatewayKinds.has(route.expect?.kind)) {
continue;
}
for (const environment of ['production', 'development']) {
const fragments = route.nginx?.[environment] ?? [];
if (!fragments.includes(clearCookieFragment)) {
fail(
`route ${route.id} 的 ${environment} Nginx 片段必须显式清空 Cookie:${clearCookieFragment}`,
);
}
const mergedIntoTemplate = fragments.some((fragment) =>
fragment.includes(genericApiLocation),
);
if (mergedIntoTemplate) {
fail(
`route ${route.id} 的 ${environment} Nginx 片段不能复用通用 ${genericApiLocation}(通用规则会转发 Cookie)。`,
);
}
}
}
// 播放会话前缀必须排在自己的 `^~` 前缀 location 上,并且在模板里排在通用 `/api` location 之前;
// nginx 的 `^~` 前缀优先于正则 location,但顺序仍按任务要求固定,便于人工核对。
const playSessionLocation =
'location ^~ /api/game-distribution/play-sessions/';
for (const environment of ['production', 'development']) {
const source = files[environment];
const playSessionIndex = source.indexOf(playSessionLocation);
if (playSessionIndex < 0) {
fail(
`${environment} Nginx 模板缺少播放会话前缀 location: ${playSessionLocation}`,
);
continue;
}
const genericApiIndex = source.indexOf(genericApiLocation);
if (genericApiIndex >= 0 && playSessionIndex > genericApiIndex) {
fail(
`${environment} Nginx 模板的播放会话前缀 location 必须排在通用 ${genericApiLocation} 之前。`,
);
}
}
}
// 反向覆盖:模板里出现的每条 location 都必须被矩阵某条用例声明过。
// 只做正向检查(矩阵片段存在于模板)会漏掉「Nginx 模板加/改了路由、矩阵与 Pingora 没跟上」——
// 2026-09-29 的发行网关路由就是这样:Nginx 有三份、Pingora 和矩阵都没有,门禁一直是绿的。
function validateNginxLocationsAreCovered() {
for (const environment of ['production', 'development']) {
const source = files[environment];
const locationFragments = matrix.routes
.flatMap((route) => route.nginx?.[environment] ?? [])
.map((fragment) => fragment.trim())
.filter((fragment) => fragment.startsWith('location'));
for (const match of source.matchAll(/^[ \t]*location\b[^\n]*/gmu)) {
const line = match[0].trim().replace(/\s*\{\s*$/u, '');
if (line.startsWith('#')) {
continue;
}
if (!locationFragments.some((fragment) => line.startsWith(fragment))) {
fail(`${environment} 模板的 location 没有被矩阵覆盖: ${line}`);
}
}
}
}
function validateRustMainSpaRoutes() {
const routeBlock = pingoraGatewaySource.match(
/const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
);
if (!routeBlock) {
fail('Pingora Rust 缺少 MAIN_SPA_PATHS allowlist。');
return;
}
const rustRoutes = Array.from(
routeBlock[1].matchAll(/"([^"]+)"/gu),
(match) => match[1],
).sort();
const expected = new Set(expectedMainSpaRoutes);
const actual = new Set(rustRoutes);
const missing = expectedMainSpaRoutes.filter((route) => !actual.has(route));
const extra = rustRoutes.filter((route) => !expected.has(route));
if (missing.length > 0) {
fail(`Pingora MAIN_SPA_PATHS 缺少当前前端路由: ${missing.join(', ')}`);
}
if (extra.length > 0) {
fail(`Pingora MAIN_SPA_PATHS 包含非当前前端路由: ${extra.join(', ')}`);
}
}
function validateRustMainSpaPrefixPaths() {
const prefixBlock = pingoraGatewaySource.match(
/const MAIN_SPA_PREFIX_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
);
if (!prefixBlock) {
fail('Pingora Rust 缺少 MAIN_SPA_PREFIX_PATHS allowlist。');
return;
}
const rustPrefixes = Array.from(
prefixBlock[1].matchAll(/"([^"]+)"/gu),
(match) => match[1],
);
const expected = expectedPrefixRoutes.map((route) => route.path);
const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix));
const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix));
if (missing.length > 0) {
fail(
`Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`,
);
}
if (extra.length > 0) {
fail(
`Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`,
);
}
}
validateMatrixShape();
validateRustTestUsesMatrix();
validateRustPlaySessionGatewayIsolation();
validateContentGatewayCookieIsolation();
validateNginxLocationsAreCovered();
validateRustMainSpaRoutes();
validateRustMainSpaPrefixPaths();
if (failures.length > 0) {
console.error('[check:pingora-route-parity] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(`[check:pingora-route-parity] OK (${matrix.routes.length} routes)`);