Skip to content
Closed
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 db-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
9 changes: 1 addition & 8 deletions db-server/src/domain/economy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
32 changes: 23 additions & 9 deletions db-server/src/lib/sql-helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
}

66 changes: 65 additions & 1 deletion db-server/src/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand All @@ -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"
);
});
4 changes: 0 additions & 4 deletions db-server/src/service-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 24 additions & 15 deletions db-server/src/tx-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -382,14 +397,8 @@ export class TxRunner {
mod: ModuleManifestV2,
step: TxStepService
): Promise<TxStepResult> {
// 与 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:<name> 权限门(与 HTTP service-routes 对齐)。
assertModulePermission(mod.id, mod.permissions, Perm.service(step.name));
const result = await this.deps.serviceRegistry.dispatch(
this.deps.enabled,
Expand Down
4 changes: 1 addition & 3 deletions modules/sdk/@sfmc-sdk/src/sapi/config/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
4 changes: 1 addition & 3 deletions modules/sdk/@sfmc-sdk/src/sapi/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(path: string, body: unknown): Promise<T> {
Expand Down
10 changes: 10 additions & 0 deletions modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 1 addition & 3 deletions modules/sdk/@sfmc-sdk/src/sapi/service/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading