diff --git a/db-server/package.json b/db-server/package.json index e88d8aa..84574ff 100644 --- a/db-server/package.json +++ b/db-server/package.json @@ -11,7 +11,7 @@ "build": "tsc -p tsconfig.json", "typecheck": "tsc --noEmit", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", - "test": "node --test src/*.test.js", + "test": "npm run build && node --test dist/*.test.js", "format": "prettier --write \"src/**/*.{ts,tsx,js,json}\"" }, "engines": { diff --git a/db-server/src/domain/economy.ts b/db-server/src/domain/economy.ts index a365cf7..2ebb9f2 100644 --- a/db-server/src/domain/economy.ts +++ b/db-server/src/domain/economy.ts @@ -14,17 +14,10 @@ import type { EconomyAccountRow, EconomyTransactionRow } from "@sfmc/sdk/contrac import type { DatabaseSync } from "node:sqlite"; import type { SQLStatement } from "sql-template-strings"; import { isValidIdempotencyKey } from "../lib/idempotency.js"; +import { sql } from "../lib/sql-helpers.js"; import type { TxResult } from "./transaction.js"; export type { TxResult }; -/** - * 表名是信任常量,必须嵌入 SQL 文本;值走 ? 绑定。 - * 不可对 sql-template-strings 插值表名(会变成 FROM ? → near "?": syntax error)。 - */ -function sql(text: string, values: unknown[] = []): { sql: string; values: unknown[] } { - return { sql: text, values }; -} - const TABLE_ACCOUNTS = "sfmc_economy_accounts"; const TABLE_TRANSACTIONS = "sfmc_economy_transactions"; const TABLE_IDEMPOTENCY = "sfmc_economy_idempotency"; diff --git a/db-server/src/lib/sql-helpers.ts b/db-server/src/lib/sql-helpers.ts index 2223b64..658eab4 100644 --- a/db-server/src/lib/sql-helpers.ts +++ b/db-server/src/lib/sql-helpers.ts @@ -1,21 +1,35 @@ /** - * lib/sql-helpers.ts — sql-template-strings 缺失的工具函数 + * lib/sql-helpers.ts — SQL 构造辅助 * - * 该库没有暴露 raw(...) / join(...) 这类便捷函数。抽出一个轻量封装。 + * sql-template-strings 会把每一次 ${} 插值都变成 `?` 绑定值。 + * 表名/列名等信任标识符必须嵌入 SQL 文本,不能走绑定 —— 否则会出现 + * `FROM ?` → `near "?": syntax error`(见 economy 域修复)。 */ import { SQL, type SQLStatement } from "sql-template-strings"; +/** 参数化查询信封,与 createQuery / AnyQuery 的格式 2 对齐 */ +export type BoundSql = { sql: string; values: unknown[] }; + /** - * 把一段静态 SQL 文本包装成 SQLStatement,使其可以嵌入其它 SQL 模板。 - * - 不携带任何占位符 / values; - * - 与 SQL\`...\` 一起使用时行为一致:`append()` 调用会把字符串合并进 strings 数组。 + * 构造「标识符已嵌入、值走 ?」的查询。 + * 表名等信任常量用模板字符串嵌入;用户输入只放进 values。 * - * 实现注意:sql-template-strings 的 SQL 函数对 strings array 长度 = 1 + values 长度 - * 的情况友好处理 —— 我们传入 `["text"]` 单元素数组,得到一个无占位符、无参数的 - * SQLStatement,正好等价于把 `text` 整段直接插入。 + * @example + * sql(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]) + */ +export function sql(text: string, values: unknown[] = []): BoundSql { + return { sql: text, values }; +} + +/** + * 把一段静态 SQL 文本包装成 SQLStatement,仅可与 `.append(...)` 合用。 + * + * 警告(LSP):切勿写成 `SQL\`... FROM ${raw(table)} ...\`` —— 模板插值仍会变成 `?`, + * 与直接插值表名同病。正确用法: + * SQL`SELECT * FROM `.append(raw(TABLE)).append(SQL` WHERE id = ${id}`) + * 纯字符串 `.append(TABLE)` 效果相同;优先用 {@link sql}。 */ export function raw(text: string): SQLStatement { return SQL([text]) as SQLStatement; } - diff --git a/db-server/src/runtime.test.ts b/db-server/src/runtime.test.ts index 1809337..41000df 100644 --- a/db-server/src/runtime.test.ts +++ b/db-server/src/runtime.test.ts @@ -1,7 +1,10 @@ -import { deepEqual, equal, throws } from "node:assert/strict"; +import { deepEqual, equal, rejects, throws } from "node:assert/strict"; import test from "node:test"; +import { SQL } from "sql-template-strings"; import { assertIdentifier, quoteIdentifier } from "./lib/identifiers.js"; import { parseNodeVersion } from "./lib/runtime.js"; +import { raw, sql } from "./lib/sql-helpers.js"; +import { DispatchError, ServiceRegistry } from "./service-registry.js"; test("parses Node versions", () => { deepEqual(parseNodeVersion("22.5.1"), { major: 22, minor: 5, patch: 1 }); @@ -12,3 +15,64 @@ test("accepts only safe SQL identifiers", () => { equal(quoteIdentifier("sfmc_players", "table"), '"sfmc_players"'); throws(() => assertIdentifier("users; DROP TABLE x"), /Invalid SQL/); }); + +test("sql() embeds trusted identifiers and binds values", () => { + const TABLE = "sfmc_economy_accounts"; + const q = sql(`SELECT * FROM ${TABLE} WHERE player_id = ?`, ["p1"]); + equal(q.sql, "SELECT * FROM sfmc_economy_accounts WHERE player_id = ?"); + deepEqual(q.values, ["p1"]); +}); + +test("raw() only works via append — template interpolation still becomes ?", () => { + const TABLE = "sfmc_economy_accounts"; + const broken = SQL`SELECT * FROM ${raw(TABLE)} WHERE id = ${"p1"}`; + equal(broken.sql, "SELECT * FROM ? WHERE id = ?"); + + const ok = SQL`SELECT * FROM `.append(raw(TABLE)).append(SQL` WHERE id = ${"p1"}`); + equal(ok.sql, "SELECT * FROM sfmc_economy_accounts WHERE id = ?"); + deepEqual(ok.values, ["p1"]); +}); + +test("ServiceRegistry: provider self-call skips requires (LSP with tx.call path)", async () => { + const reg = new ServiceRegistry(); + reg.registerHandler("feature-economy", "economy.stats.monthly", async () => ({ ok: true })); + + const enabled = new Map([ + [ + "feature-economy", + { + id: "feature-economy", + version: "1.0.0", + permissions: [] as string[], + services: { provides: [{ name: "economy.stats.monthly" }], requires: [] as Array<{ name: string }> }, + db: { tables: [] as unknown[] }, + config: { key: "economy" }, + } as unknown as import("./manifest-loader.js").ModuleManifestV2, + ], + ]); + + const out = await reg.dispatch(enabled, "feature-economy", "economy.stats.monthly", {}); + deepEqual(out, { ok: true, result: { ok: true } }); +}); + +test("ServiceRegistry: missing service → no_such_service (not not_in_requires)", async () => { + const reg = new ServiceRegistry(); + const enabled = new Map([ + [ + "feature-chat", + { + id: "feature-chat", + version: "1.0.0", + permissions: [] as string[], + services: { provides: [], requires: [] }, + db: { tables: [] }, + config: { key: "chat" }, + } as unknown as import("./manifest-loader.js").ModuleManifestV2, + ], + ]); + + await rejects( + () => reg.dispatch(enabled, "feature-chat", "economy.account.get", {}), + (e: unknown) => e instanceof DispatchError && e.code === "no_such_service" + ); +}); diff --git a/db-server/src/service-registry.ts b/db-server/src/service-registry.ts index 8ef79af..cccd18d 100644 --- a/db-server/src/service-registry.ts +++ b/db-server/src/service-registry.ts @@ -102,10 +102,6 @@ export class ServiceRegistry { } } - /** 查询 service 提供方 moduleId(供 tx.call 与 HTTP 共用鉴权策略) */ - getProvider(name: string): string | undefined { - return this.handlers.get(name)?.moduleId; - } } export class DispatchError extends Error { diff --git a/db-server/src/tx-runner.ts b/db-server/src/tx-runner.ts index 1497ca2..1227836 100644 --- a/db-server/src/tx-runner.ts +++ b/db-server/src/tx-runner.ts @@ -32,7 +32,7 @@ import type { SchemaRegistry } from "./schema-registry.js"; import type { WhereExpr } from "./where.js"; import { compile } from "./where.js"; import { log } from "./lib/log.js"; -import type { ServiceRegistry } from "./service-registry.js"; +import { DispatchError, type ServiceRegistry } from "./service-registry.js"; import type { ModuleManifestV2 } from "./manifest-loader.js"; import { PermissionDeniedError, @@ -124,7 +124,14 @@ export type TxError = { ok: false; step: number; error: string; - code: "permission_denied" | "forbidden" | "no_such_service" | "not_in_requires" | "internal" | "no_such_table"; + code: + | "permission_denied" + | "forbidden" + | "no_such_service" + | "not_in_requires" + | "domain_error" + | "internal" + | "no_such_table"; }; export interface TxRequest { @@ -171,11 +178,19 @@ export class TxRunner { const code: TxError["code"] = err instanceof PermissionDeniedError ? "permission_denied" - : (err as { code?: string }).code === "no_such_service" - ? "no_such_service" - : (err as { code?: string }).code === "not_in_requires" - ? "not_in_requires" - : "internal"; + : err instanceof DispatchError && + (err.code === "no_such_service" || + err.code === "not_in_requires" || + err.code === "forbidden" || + err.code === "domain_error") + ? err.code + : (err as { code?: string }).code === "no_such_service" + ? "no_such_service" + : (err as { code?: string }).code === "not_in_requires" + ? "not_in_requires" + : (err as { code?: string }).code === "no_such_table" + ? "no_such_table" + : "internal"; log.warn(`[tx ${traceId}] step=${i} failed: ${(err as Error).message}`); return { ok: false, step: i, error: (err as Error).message, code }; } @@ -382,14 +397,8 @@ export class TxRunner { mod: ModuleManifestV2, step: TxStepService ): Promise { - // 与 ServiceRegistry.dispatch 对齐:提供方自调用跳过 requires(DRY/LSP) - const providerId = this.deps.serviceRegistry.getProvider(step.name); - const declared = mod.services.requires.find((r) => r.name === step.name); - if (!declared && providerId !== mod.id) { - const err = new Error(`模块 ${mod.id} 未声明 service.requires ${step.name}`); - (err as { code?: string }).code = "not_in_requires"; - throw err; - } + // requires / 自调用豁免的唯一权威在 ServiceRegistry.dispatch(DRY/LSP); + // 此处只补 dispatch 不做的 service: 权限门(与 HTTP service-routes 对齐)。 assertModulePermission(mod.id, mod.permissions, Perm.service(step.name)); const result = await this.deps.serviceRegistry.dispatch( this.deps.enabled, diff --git a/modules/sdk/@sfmc-sdk/src/sapi/config/client.ts b/modules/sdk/@sfmc-sdk/src/sapi/config/client.ts index aba0986..3abcf84 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/config/client.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/config/client.ts @@ -41,9 +41,7 @@ export function clearConfigModuleContext(): void { * 之前 config 漏带 query → 即便注入了 token 也会 401(LSP/DRY 违规)。 */ function withModuleId(path: string): string { - if (!_moduleId) return path; - const sep = path.includes("?") ? "&" : "?"; - return `${path}${sep}moduleId=${encodeURIComponent(_moduleId)}`; + return HttpDB.withModuleId(path, _moduleId); } async function ensureLoaded(): Promise { diff --git a/modules/sdk/@sfmc-sdk/src/sapi/db/client.ts b/modules/sdk/@sfmc-sdk/src/sapi/db/client.ts index b377e3b..1a335ed 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/db/client.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/db/client.ts @@ -52,9 +52,7 @@ export class DbError extends Error { } function withModuleId(path: string): string { - if (!_moduleId) return path; - const sep = path.includes("?") ? "&" : "?"; - return `${path}${sep}moduleId=${encodeURIComponent(_moduleId)}`; + return HttpDB.withModuleId(path, _moduleId); } async function post(path: string, body: unknown): Promise { diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts index 11969cc..6a3930d 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts @@ -24,6 +24,16 @@ export class HttpDB { this.authToken = token.trim(); } + /** + * 给路径附上 ?moduleId= / &moduleId=(db/config/service 客户端共用,DRY)。 + * verifyModuleAuth 只认 query 上的 moduleId。 + */ + static withModuleId(path: string, moduleId: string): string { + if (!moduleId) return path; + const sep = path.includes("?") ? "&" : "?"; + return `${path}${sep}moduleId=${encodeURIComponent(moduleId)}`; + } + static isAvailable(): boolean { return this.available; } diff --git a/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts b/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts index 9ceefa8..ee89468 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts @@ -44,9 +44,7 @@ export class ServiceError extends Error { } function withModuleId(path: string): string { - if (!_moduleId) return path; - const sep = path.includes("?") ? "&" : "?"; - return `${path}${sep}moduleId=${encodeURIComponent(_moduleId)}`; + return HttpDB.withModuleId(path, _moduleId); } export const service = {