Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 5 additions & 4 deletions db-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,导致插件端起不来。
Expand All @@ -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,
Expand All @@ -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 ?? [],
};
Expand All @@ -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<string, unknown> = {
path,
method,
params,
req,
res,
moduleAuth: moduleAuthCtx,
};
// body 已在上面 `await body(req)` 预读并缓存到 req._bodyPromise;
// 这里复用缓存(原实现读的 req._body 从未被赋值,导致所有 v2 路由 body 恒为空)。
Expand Down
5 changes: 5 additions & 0 deletions db-server/src/routes/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@ export type RouteDeps = {
land?: Record<string, (...args: unknown[]) => 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> | boolean;
Expand Down
16 changes: 5 additions & 11 deletions db-server/src/routes/db-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<DbRoutesDeps>) {
const deps = depsIn as Partial<DbRoutesDeps>;
if (!deps.schemaRegistry || !deps.txRunner || !deps.idempotent) {
Expand All @@ -58,12 +51,13 @@ export function createDbRoutes(depsIn: Partial<DbRoutesDeps>) {
req: IncomingMessage;
res: ServerResponse;
body?: Promise<Record<string, unknown>> | Record<string, unknown>;
moduleAuth?: ModuleAuth;
}): Promise<boolean> => {
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;
Expand Down
11 changes: 6 additions & 5 deletions db-server/src/routes/module-config-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<configKey>.json;整文件 read-modify-write(lock by
* 简单 mutex),不是细粒度 lock — 模块 config 文件本来就小;并发冲突概率极低。
Expand All @@ -18,15 +18,14 @@ 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;
enabled: Map<string, ModuleManifestV2>;
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}"`);
Expand Down Expand Up @@ -71,13 +70,14 @@ export function createModuleConfigRoutes(depsIn: Partial<ModuleConfigRoutesDeps>
req: IncomingMessage;
res: ServerResponse;
body?: Promise<Record<string, unknown>>;
moduleAuth?: ModuleAuth;
}): Promise<boolean> => {
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;
Expand Down Expand Up @@ -114,8 +114,9 @@ export function createModuleConfigRoutes(depsIn: Partial<ModuleConfigRoutesDeps>
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;
}
Expand Down
27 changes: 14 additions & 13 deletions db-server/src/routes/service-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,21 @@
* 端点:
* GET /api/sfmc/services → { services: [{name, moduleId}] }
* GET /api/sfmc/services/:name?input=<urlencoded json>
* → { 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;
enabled: Map<string, import("../manifest-loader.js").ModuleManifestV2>;
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<ServiceRoutesDeps>) {
const deps = depsIn as Partial<ServiceRoutesDeps>;
if (!deps.serviceRegistry || !deps.enabled) {
Expand All @@ -37,14 +32,16 @@ export function createServiceRoutes(depsIn: Partial<ServiceRoutesDeps>) {
params: URLSearchParams;
req: IncomingMessage;
res: ServerResponse;
moduleAuth?: ModuleAuth;
}): Promise<boolean> => {
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;
}

Expand All @@ -62,7 +59,11 @@ export function createServiceRoutes(depsIn: Partial<ServiceRoutesDeps>) {
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;
}
}
Expand All @@ -79,13 +80,13 @@ export function createServiceRoutes(depsIn: Partial<ServiceRoutesDeps>) {
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
);
}
Expand Down
28 changes: 28 additions & 0 deletions modules/sdk/@sfmc-sdk/src/sapi/runtime/http-envelope.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
20 changes: 20 additions & 0 deletions modules/sdk/@sfmc-sdk/src/sapi/runtime/http-envelope.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 5 additions & 1 deletion modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk/@sfmc-sdk/tsconfig.types.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@
"noUnusedParameters": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
Loading