diff --git a/AGENTS.md b/AGENTS.md index f288a23..2f2aeef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,5 +226,5 @@ The Cloud VM is Linux; the repo primarily targets Windows, but the Node services - **Pre-existing bugs (not environment issues), fixed as of this note — kept here for history:** - `tools/check-catalog.js` used top-level ESM `import` syntax while the repo root `package.json` has no `"type": "module"` → `SyntaxError: Cannot use import statement outside a module` under plain `node`. Converted to CommonJS `require()` to match every other `tools/*.js` script. - `tools/check-ootb.js`, `tools/sim-new-user.js`, and `tools/test-db-api.js` spawned `db-server/index.js`, which does not exist — the real entry is `db-server/dist/index.js`. This made the `db-server 启动` and `sim-new-user` checks in `tools/check-ootb.js` time out, so a healthy env used to show **check-ootb 5/6 pass**; both are now fixed and should pass 6/6 given a built workspace + `--experimental-sqlite`-capable Node. - - The SQLite-backed gameplay routes (`economy`, `lands`, `coops`, `scoreboards`, …) return `near "?": syntax error` because table names are interpolated through `sql-template-strings` (`FROM ${TABLE}` → `FROM ?`). The JSON-backed module/config API and `/api/health` work. This one is still open. + - The SQLite-backed gameplay/`economy` path previously returned `near "?": syntax error` because table names were interpolated through `sql-template-strings` (`FROM ${TABLE}` → `FROM ?`). **Fixed** via `sql-helpers.sql()` embedding trusted identifiers (#19/#21); keep using `sql()` / `.append(raw(...))`, never `SQL\`…${table}…\`` or bare `SQL\`…${raw(t)}…\``. - **`npm run lint` is broken out-of-the-box** — ESLint v10 needs a flat `eslint.config.js` and none exists in the repo. Use per-workspace `npm run typecheck` for static checking instead. diff --git a/db-server/src/index.ts b/db-server/src/index.ts index bf54a1f..b7d40fc 100644 --- a/db-server/src/index.ts +++ b/db-server/src/index.ts @@ -254,7 +254,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom // ── 预读 body(所有路由共享) ─────────────────────────────── await body(req); - // ── v2 模块身份校验:写 req.moduleAuth ──────────────────── + // ── v2 模块身份校验 → 写入路由 ctx.moduleAuth(LoD:不挂 req 私有字段) ── // 注意:`/api/sfmc/configs/all` 是旧的一次性配置快照端点(SAPI ConfigManager.init // 启动必用),不属于 v2 模块配置命名空间(configs/<模块 configKey>),必须豁免, // 否则会被模块鉴权网关拦成 401,导致插件端起不来。 @@ -263,6 +263,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom path.startsWith("/api/sfmc/db/") || path.startsWith("/api/sfmc/services") || (/^\/api\/sfmc\/configs\/[A-Za-z0-9_-]+(?:\/(?:set|notify))?$/.test(path) && !isLegacyConfigAll); + let moduleAuthCtx: { id: string; permissions: string[] } | null = null; if (needsModuleAuth) { const id = verifyModuleAuth({ headers: req.headers, @@ -275,7 +276,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom return; } const manifest = enabledManifests.get(id); - (req as http.IncomingMessage & { moduleAuth: { id: string; permissions: string[] } }).moduleAuth = { + moduleAuthCtx = { id, permissions: manifest?.permissions ?? [], }; @@ -298,14 +299,14 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom try { // ── v2 路由(优先匹配) ───────────────────────────── - const reqWithAuth = req as http.IncomingMessage & { moduleAuth?: { id: string; permissions: string[] } }; - if (reqWithAuth.moduleAuth && (path.startsWith("/api/sfmc/db/") || path.startsWith("/api/sfmc/services") || (/^\/api\/sfmc\/configs\/[A-Za-z0-9_-]+/.test(path) && !isLegacyConfigAll))) { + if (moduleAuthCtx && (path.startsWith("/api/sfmc/db/") || path.startsWith("/api/sfmc/services") || (/^\/api\/sfmc\/configs\/[A-Za-z0-9_-]+/.test(path) && !isLegacyConfigAll))) { const ctx: Record = { path, method, params, req, res, + moduleAuth: moduleAuthCtx, }; // body 已在上面 `await body(req)` 预读并缓存到 req._bodyPromise; // 这里复用缓存(原实现读的 req._body 从未被赋值,导致所有 v2 路由 body 恒为空)。 diff --git a/db-server/src/routes/_shared.ts b/db-server/src/routes/_shared.ts index 06469a9..056445e 100644 --- a/db-server/src/routes/_shared.ts +++ b/db-server/src/routes/_shared.ts @@ -36,12 +36,17 @@ export type RouteDeps = { land?: Record unknown>; }; +/** v2 模块身份 — 由 handle 校验后写入路由 ctx(勿挂 req 私有字段,LoD)。 */ +export type ModuleAuth = { id: string; permissions: string[] }; + export type RouteCtx = { path: string; method: Method | string; params: URLSearchParams; req: IncomingMessage; res: ServerResponse; + /** v2 路由专用:模块鉴权结果 */ + moduleAuth?: ModuleAuth; }; export type RouteHandler = (ctx: RouteCtx) => Promise | boolean; diff --git a/db-server/src/routes/db-routes.ts b/db-server/src/routes/db-routes.ts index 5b55ae8..dd9f38f 100644 --- a/db-server/src/routes/db-routes.ts +++ b/db-server/src/routes/db-routes.ts @@ -13,12 +13,12 @@ * POST /api/sfmc/db/idempotent/probe body {action, key} → {replayed, cached?} * POST /api/sfmc/db/idempotent/commit body {action, key, value?} → {ok} * - * 鉴权:handle() 层做完模块身份校验后,把 {id, permissions} 写到 - * `req.moduleAuth`,本路由直接读。 + * 鉴权:handle() 校验后把 {id, permissions} 写到 ctx.moduleAuth(不挂 req)。 */ import type { IncomingMessage, ServerResponse } from "node:http"; import { json as defaultJson, type Method } from "../lib/http.js"; +import type { ModuleAuth } from "./_shared.js"; import type { DefineTableRequest, SchemaRegistry, @@ -38,13 +38,6 @@ export interface DbRoutesDeps { json?: typeof defaultJson; } -type ModuleAuth = { id: string; permissions: string[] }; - -function getModuleAuth(req: IncomingMessage): ModuleAuth | null { - const m = (req as IncomingMessage & { moduleAuth?: ModuleAuth }).moduleAuth; - return m ?? null; -} - export function createDbRoutes(depsIn: Partial) { const deps = depsIn as Partial; if (!deps.schemaRegistry || !deps.txRunner || !deps.idempotent) { @@ -58,12 +51,13 @@ export function createDbRoutes(depsIn: Partial) { req: IncomingMessage; res: ServerResponse; body?: Promise> | Record; + moduleAuth?: ModuleAuth; }): Promise => { - const { path, method, req, res } = ctx; + const { path, method, res } = ctx; if (!path.startsWith("/api/sfmc/db/")) return false; if (method !== "POST" && method !== "DELETE") return false; - const auth = getModuleAuth(req); + const auth = ctx.moduleAuth ?? null; if (!auth) { json(res, { success: false, error: "unauthorized: module identity missing" }, 401); return true; diff --git a/db-server/src/routes/module-config-routes.ts b/db-server/src/routes/module-config-routes.ts index e1c270c..7bcb367 100644 --- a/db-server/src/routes/module-config-routes.ts +++ b/db-server/src/routes/module-config-routes.ts @@ -6,7 +6,7 @@ * POST /api/sfmc/configs/:configKey/set → 写一个 key * GET /api/sfmc/configs/:configKey/notify (SSE:onChange 推送) * - * 鉴权:模块身份来自 moduleAuth;Permission = config:read:configKey / config:write:configKey。 + * 鉴权:模块身份来自 ctx.moduleAuth;Permission = config:read:configKey / config:write:configKey。 * * 设计:文件 = configs/.json;整文件 read-modify-write(lock by * 简单 mutex),不是细粒度 lock — 模块 config 文件本来就小;并发冲突概率极低。 @@ -18,6 +18,7 @@ import { readJson, writeJson } from "@sfmc-bds/sdk/node/config"; import { json as defaultJson, type Method } from "../lib/http.js"; import { assertModulePermission, Perm } from "../permission-gate.js"; import type { ModuleManifestV2 } from "../manifest-loader.js"; +import type { ModuleAuth } from "./_shared.js"; export interface ModuleConfigRoutesDeps { projectRoot: string; @@ -25,8 +26,6 @@ export interface ModuleConfigRoutesDeps { json?: typeof defaultJson; } -type ModuleAuth = { id: string; permissions: string[] }; - function configPath(projectRoot: string, key: string): string { if (!/^[A-Za-z0-9_-]+$/.test(key)) { throw new Error(`invalid configKey "${key}"`); @@ -71,13 +70,14 @@ export function createModuleConfigRoutes(depsIn: Partial req: IncomingMessage; res: ServerResponse; body?: Promise>; + moduleAuth?: ModuleAuth; }): Promise => { const { path, method, req, res } = ctx; const m = path.match(/^\/api\/sfmc\/configs\/([A-Za-z0-9_-]+)(?:\/(set|notify))?$/); if (!m) return false; const configKey = m[1]; const tail = m[2]; - const auth = (req as IncomingMessage & { moduleAuth?: ModuleAuth }).moduleAuth; + const auth = ctx.moduleAuth; if (!auth) { json(res, { success: false, error: "unauthorized" }, 401); return true; @@ -114,8 +114,9 @@ export function createModuleConfigRoutes(depsIn: Partial notify(configKey, key, value); json(res, { ok: true }); } catch (e) { + // LSP: /set 成功用 ok:true,失败统一 ok:false(勿混用 success) const code = (e as { name?: string }).name === "PermissionDeniedError" ? 403 : 500; - json(res, { success: false, error: (e as Error).message }, code); + json(res, { ok: false, error: (e as Error).message }, code); } return true; } diff --git a/db-server/src/routes/service-routes.ts b/db-server/src/routes/service-routes.ts index 373826d..3f0226c 100644 --- a/db-server/src/routes/service-routes.ts +++ b/db-server/src/routes/service-routes.ts @@ -4,13 +4,14 @@ * 端点: * GET /api/sfmc/services → { services: [{name, moduleId}] } * GET /api/sfmc/services/:name?input= - * → { ok: true, result } | { ok: false, error, code, status } + * → { ok: true, result } | { ok: false, error, code } (+ HTTP status) */ import type { IncomingMessage, ServerResponse } from "node:http"; import { json as defaultJson, type Method } from "../lib/http.js"; import { assertModulePermission, Perm, PermissionDeniedError } from "../permission-gate.js"; import type { ServiceRegistry } from "../service-registry.js"; +import type { ModuleAuth } from "./_shared.js"; export interface ServiceRoutesDeps { serviceRegistry: ServiceRegistry; @@ -18,12 +19,6 @@ export interface ServiceRoutesDeps { json?: typeof defaultJson; } -type ModuleAuth = { id: string; permissions: string[] }; - -function getModuleAuth(req: IncomingMessage): ModuleAuth | null { - return (req as IncomingMessage & { moduleAuth?: ModuleAuth }).moduleAuth ?? null; -} - export function createServiceRoutes(depsIn: Partial) { const deps = depsIn as Partial; if (!deps.serviceRegistry || !deps.enabled) { @@ -37,14 +32,16 @@ export function createServiceRoutes(depsIn: Partial) { params: URLSearchParams; req: IncomingMessage; res: ServerResponse; + moduleAuth?: ModuleAuth; }): Promise => { - const { path, method, params, req, res } = ctx; + const { path, method, params, res } = ctx; if (!path.startsWith("/api/sfmc/services")) return false; if (method !== "GET") return false; - const auth = getModuleAuth(req); + const auth = ctx.moduleAuth ?? null; if (!auth) { - json(res, { success: false, error: "unauthorized: module identity missing" }, 401); + // LSP: 与成功路径同一 ok 方言(文档约定 { ok:false, error, code }) + json(res, { ok: false, error: "unauthorized: module identity missing", code: "unauthorized" }, 401); return true; } @@ -62,7 +59,11 @@ export function createServiceRoutes(depsIn: Partial) { try { payload = JSON.parse(rawInput); } catch (e) { - json(res, { success: false, error: `invalid input json: ${(e as Error).message}` }, 400); + json( + res, + { ok: false, error: `invalid input json: ${(e as Error).message}`, code: "bad_request" }, + 400 + ); return true; } } @@ -79,13 +80,13 @@ export function createServiceRoutes(depsIn: Partial) { json(res, { ok: true, result: out.result }); } catch (e) { if (e instanceof PermissionDeniedError) { - json(res, { success: false, error: e.message, code: "permission_denied" }, 403); + json(res, { ok: false, error: e.message, code: "permission_denied" }, 403); return true; } const err = e as { status?: number; code?: string; message: string }; json( res, - { success: false, error: err.message, code: err.code ?? "internal" }, + { ok: false, error: err.message, code: err.code ?? "internal" }, err.status ?? 500 ); } diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/http-envelope.test.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/http-envelope.test.ts new file mode 100644 index 0000000..bc669a3 --- /dev/null +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/http-envelope.test.ts @@ -0,0 +1,28 @@ +import { equal } from "node:assert/strict"; +import test from "node:test"; +import { isSuccessfulHttpEnvelope } from "./http-envelope.ts"; + +test("信封:200 + 无 ok/success 字段 → 成功(列表/配置读取)", () => { + equal(isSuccessfulHttpEnvelope(200, {}), true); +}); + +test("信封:200 + ok:true → 成功", () => { + equal(isSuccessfulHttpEnvelope(200, { ok: true }), true); +}); + +test("信封:200 + success:true → 成功", () => { + equal(isSuccessfulHttpEnvelope(200, { success: true }), true); +}); + +test("信封:200 + ok:false → 失败(LSP)", () => { + equal(isSuccessfulHttpEnvelope(200, { ok: false, error: "x" }), false); +}); + +test("信封:200 + success:false → 失败(LSP,防误判)", () => { + equal(isSuccessfulHttpEnvelope(200, { success: false, error: "x" }), false); +}); + +test("信封:非 200 → 失败", () => { + equal(isSuccessfulHttpEnvelope(400, { ok: true }), false); + equal(isSuccessfulHttpEnvelope(403, { success: false, error: "denied" }), false); +}); diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/http-envelope.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/http-envelope.ts new file mode 100644 index 0000000..4b172b7 --- /dev/null +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/http-envelope.ts @@ -0,0 +1,20 @@ +/** + * HTTP 业务信封判定(无 Minecraft 依赖,可单测)。 + * + * 服务端历史混用 `ok` / `success` 方言;客户端必须以同一契约认失败, + * 否则 HTTP 200 + `{ success: false }` 会被误判为成功(LSP)。 + */ + +export type HttpEnvelopeFields = { + ok?: unknown; + success?: unknown; + error?: unknown; +}; + +/** status===200 且未显式声明失败时视为成功。 */ +export function isSuccessfulHttpEnvelope(status: number, parsed: HttpEnvelopeFields): boolean { + if (status !== 200) return false; + if (parsed.ok === false) return false; + if (parsed.success === false) return false; + return true; +} diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts index 6a3930d..30bbf4d 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts @@ -9,6 +9,7 @@ import { system } from "@minecraft/server"; import { http, HttpRequest, HttpRequestMethod } from "@minecraft/server-net"; +import { isSuccessfulHttpEnvelope } from "./http-envelope.js"; const HOST = "127.0.0.1"; const PORT = 3001; @@ -127,7 +128,10 @@ export class HttpDB { if (!body) return { ok: false, error: "network_error", status }; try { const parsed = JSON.parse(body); - if (status === 200 && parsed.ok !== false) return { ok: true, data: parsed as T, status }; + // LSP: 同时认 ok / success 方言 — 仅看 ok!==false 会把 HTTP 200+{success:false} 当成功 + if (isSuccessfulHttpEnvelope(status, parsed)) { + return { ok: true, data: parsed as T, status }; + } return { ok: false, error: parsed.error || "request_failed", status, data: parsed as T }; } catch { return { ok: false, error: "invalid_response", status }; diff --git a/modules/sdk/@sfmc-sdk/tsconfig.types.json b/modules/sdk/@sfmc-sdk/tsconfig.types.json index cf05242..18ebd95 100644 --- a/modules/sdk/@sfmc-sdk/tsconfig.types.json +++ b/modules/sdk/@sfmc-sdk/tsconfig.types.json @@ -18,5 +18,5 @@ "noUnusedParameters": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] }