diff --git a/.claude/rules/02-forbidden.md b/.claude/rules/02-forbidden.md index 7542e15..0fb2e11 100644 --- a/.claude/rules/02-forbidden.md +++ b/.claude/rules/02-forbidden.md @@ -142,6 +142,37 @@ SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/` --- +--- + +## 第六類:租戶字串來源(Arcrun#108/#105 同族) + +### 6.1 靜態租戶字串不得用於資料面過濾 +**知識資料面的 `owner_id`(三元組/entries/records/藏書地圖/工作流 KV)必須與寫入端同源。** +寫入端只有一個真相源=使用者 `~/.arcrun/config.yaml` 的 `api_key`(=實例 namespace, +CLI push/小幫手上傳/MCP 都用它)。讀取端拿另一份手抄的環境變數預設值 → 全被過濾掉。 + +實害:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` 讓 leo 的 **1854 條三元組被過濾成 0 個庫** +(#108);前一天 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` 是同一句話(#105)。 + +**規則**: +1. `cypher-executor/src/lib/tenant.ts` 是租戶字串的**唯一產地**。 + `CONSOLE_TENANT` / `ARCRUN_NAMESPACE` 只能在該檔被讀取。 +2. 知識資料面用 `knowledgeOwner(env)`(回 `TenantId`),過濾一律經 + `ownerQuery()` / `ownerField()`——它們只吃 `TenantId`,`tsc` 就擋掉「隨手一個 string」。 +3. 帳號層用 `accountTenant(env)`(回 `string`,**刻意不是 TenantId**):帳號子 namespace + `{tenant}::portal` 與 cypher 自己寫的設定用它,型別上不可能流進知識資料面。 +4. 身分解析路徑上**不准有字面預設值**。解析不到 → 丟 `TenantUnresolvedError`, + 誠實回「讀不到」(不是「你沒有」,#100 同一條)。 + +**機械強制**(規則存在但沒機制驗證=它會再犯第三次): +- 出貨閘:`scripts/build-worker-artifacts.mjs` 編 tier2 成品前先掃,違規 → **編不出成品**。 +- 本機自查:`cd cypher-executor && npm run check:tenant`(`npm test` 也會先跑它)。 +- 規則本體:`cypher-executor/scripts/tenant-source-rules.mjs`(純函式); + 閘自己的測試:`cypher-executor/tests/tenant-gate.test.ts`(壞例子會擋+合法寫法零誤攔)。 + +> 尚未接上 PreToolUse hook(`.claude/hooks/` 為受保護檔案,需人類加入)。 +> 要加的話:檢查器已備妥 `--stdin <相對路徑>` 模式,可在寫入前擋。 + ## Hook Block 訊息格式 當 hook 擋住一個操作時,訊息格式統一為: diff --git a/.worker-builds/arcrun-code/worker.mjs b/.worker-builds/arcrun-code/worker.mjs index 44bb639..4096fc7 100644 --- a/.worker-builds/arcrun-code/worker.mjs +++ b/.worker-builds/arcrun-code/worker.mjs @@ -1,12 +1,7 @@ var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; -var __esm = (fn, res, err2) => function __init() { - if (err2) throw err2[0]; - try { - return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; - } catch (e) { - throw err2 = [e], e; - } +var __esm = (fn, res) => function __init() { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) @@ -107,7 +102,7 @@ function applyBaseRuntimeOptions(runtime, options) { function applyModuleEvalRuntimeOptions(runtime, options) { options.moduleLoader && runtime.setModuleLoader(options.moduleLoader), options.shouldInterrupt && runtime.setInterruptHandler(options.shouldInterrupt), options.memoryLimitBytes !== void 0 && runtime.setMemoryLimit(options.memoryLimitBytes), options.maxStackSizeBytes !== void 0 && runtime.setMaxStackSize(options.maxStackSizeBytes); } -var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule; +var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, UnstableSymbol, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule; var init_chunk_JTKJZQYV = __esm({ "registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() { init_dist(); @@ -195,7 +190,7 @@ var init_chunk_JTKJZQYV = __esm({ return this.dispose(); } }; - SymbolDispose = Symbol.dispose ?? /* @__PURE__ */ Symbol.for("Symbol.dispose"); + SymbolDispose = Symbol.dispose ?? Symbol.for("Symbol.dispose"); prototypeAsAny = UsingDisposable.prototype; prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() { return this.dispose(); @@ -414,6 +409,7 @@ Lifetime used`) : new QuickJSUseAfterFree("Lifetime not alive"); return this.module._free(ptr), str; } }; + UnstableSymbol = Symbol("Unstable"); DefaultIntrinsics = Object.freeze({ BaseObjects: true, Date: true, Eval: true, StringNormalize: true, RegExp: true, JSON: true, Proxy: true, MapSet: true, TypedArrays: true, Promise: true }); QuickJSIterator = class extends UsingDisposable { constructor(handle, context) { @@ -777,7 +773,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception; } return result.value; } - [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() { + [Symbol.for("nodejs.util.inspect.custom")]() { return this.alive ? `${this.constructor.name} { ctx: ${this.ctx.value} rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`; } getFunction(fn_id) { @@ -913,7 +909,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception; debugLog(...msg) { this._debugMode && console.log("quickjs-emscripten:", ...msg); } - [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() { + [Symbol.for("nodejs.util.inspect.custom")]() { return this.alive ? `${this.constructor.name} { rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`; } getSystemContext() { @@ -1343,10 +1339,10 @@ async function QuickJSRaw(moduleArg = {}) { x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m]; } b = a(...g); - return b = (function(k) { + return b = function(k) { 0 !== h && sa(h); return "string" === d ? R(k) : "boolean" === d ? !!k : k; - })(b); + }(b); }; c.wasmMemory ? r = c.wasmMemory : r = new WebAssembly.Memory({ initial: (c.INITIAL_MEMORY || 16777216) / 65536, maximum: 32768 }); K(); @@ -1468,7 +1464,7 @@ async function QuickJSRaw(moduleArg = {}) { }, t: function(a, d) { c.callbacks.freeHostRef(void 0, a, d); } }, Z; - Z = await (async function() { + Z = await async function() { function a(b) { b = Z = b.exports; c._malloc = b.v; @@ -1555,7 +1551,7 @@ async function QuickJSRaw(moduleArg = {}) { }); M ??= c.locateFile ? c.locateFile ? c.locateFile("emscripten-module.wasm", u) : u + "emscripten-module.wasm" : new URL("emscripten-module.wasm", import.meta.url).href; return a((await ea(d)).instance); - })(); + }(); (function() { function a() { c.calledRun = true; @@ -3038,7 +3034,7 @@ var Hono = class _Hono { var emptyParam = []; function match(method, path) { const matchers = this.buildAllMatchers(); - const match2 = ((method2, path2) => { + const match2 = (method2, path2) => { const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const staticMatch = matcher[2][path2]; if (staticMatch) { @@ -3050,7 +3046,7 @@ function match(method, path) { } const index = match3.indexOf("", 1); return [matcher[1][index], match3]; - }); + }; this.match = match2; return match2(method, path); } @@ -4012,7 +4008,7 @@ app.post("/", async (c) => { ); } }); -var index_default = app; +var code_default = app; export { - index_default as default + code_default as default }; diff --git a/.worker-builds/arcrun-cypher-executor/worker.mjs b/.worker-builds/arcrun-cypher-executor/worker.mjs index 3c0905b..162081d 100644 --- a/.worker-builds/arcrun-cypher-executor/worker.mjs +++ b/.worker-builds/arcrun-cypher-executor/worker.mjs @@ -1,12 +1,7 @@ var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; -var __esm = (fn, res, err) => function __init() { - if (err) throw err[0]; - try { - return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; - } catch (e) { - throw err = [e], e; - } +var __esm = (fn, res) => function __init() { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; var __export = (target, all) => { for (var name in all) @@ -1506,7 +1501,7 @@ var init_hono_base = __esm({ // cypher-executor/node_modules/.pnpm/hono@4.12.10/node_modules/hono/dist/router/reg-exp-router/matcher.js function match(method, path) { const matchers = this.buildAllMatchers(); - const match2 = ((method2, path2) => { + const match2 = (method2, path2) => { const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const staticMatch = matcher[2][path2]; if (staticMatch) { @@ -1518,7 +1513,7 @@ function match(method, path) { } const index = match3.indexOf("", 1); return [matcher[1][index], match3]; - }); + }; this.match = match2; return match2(method, path); } @@ -7438,7 +7433,7 @@ var init_lib = __esm({ ...processCreateParams(params) }); }; - BRAND = /* @__PURE__ */ Symbol("zod_brand"); + BRAND = Symbol("zod_brand"); ZodBranded = class extends ZodType { _parse(input) { const { ctx } = this._processInputParams(input); @@ -7612,14 +7607,14 @@ var init_lib = __esm({ onumber = () => numberType().optional(); oboolean = () => booleanType().optional(); coerce = { - string: ((arg) => ZodString.create({ ...arg, coerce: true })), - number: ((arg) => ZodNumber.create({ ...arg, coerce: true })), - boolean: ((arg) => ZodBoolean.create({ + string: (arg) => ZodString.create({ ...arg, coerce: true }), + number: (arg) => ZodNumber.create({ ...arg, coerce: true }), + boolean: (arg) => ZodBoolean.create({ ...arg, coerce: true - })), - bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })), - date: ((arg) => ZodDate.create({ ...arg, coerce: true })) + }), + bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }), + date: (arg) => ZodDate.create({ ...arg, coerce: true }) }; NEVER = INVALID; z = /* @__PURE__ */ Object.freeze({ @@ -7840,7 +7835,6 @@ var init_recipe_loader = __esm({ super(message); this.recipe = recipe; } - recipe; }; } }); @@ -8847,7 +8841,7 @@ function recordRecipeStats(env, recipeKeys, ok, at, ctx) { ) ).then(() => void 0); if (ctx?.waitUntil) ctx.waitUntil(promise); - else void promise; + else ; } function generateToken() { const tokenBytes = crypto.getRandomValues(new Uint8Array(16)); @@ -8889,7 +8883,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct result.trace ); if (ctx?.waitUntil) ctx.waitUntil(statsPromise); - else void statsPromise; + else ; } return { success: true, data: result.data, duration_ms }; } catch (err) { @@ -8913,7 +8907,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct err.trace ); if (ctx?.waitUntil) ctx.waitUntil(statsPromise); - else void statsPromise; + else ; } if (err instanceof ExecutionError) { const traceFormatted = err.trace.map((s) => ({ @@ -9380,7 +9374,6 @@ function extractTarget(input) { return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2); } async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) { - void nodes; try { const { base, headers } = kbdbBase(env); await fetch(`${base}/execution-log/record`, { @@ -12557,6 +12550,45 @@ init_kbdb_proxy(); // cypher-executor/src/routes/console-auth.ts init_dist(); + +// cypher-executor/src/lib/tenant.ts +var TenantUnresolvedError = class extends Error { + constructor(message) { + super(message); + this.name = "TenantUnresolvedError"; + } +}; +function knowledgeOwner(env) { + const injected = (env.ARCRUN_NAMESPACE ?? "").trim(); + if (injected) return injected; + const legacy = (env.CONSOLE_TENANT ?? "").trim(); + if (legacy) return legacy; + throw new TenantUnresolvedError( + "\u9019\u500B\u90E8\u7F72\u6C92\u6709\u77E5\u8B58\u547D\u540D\u7A7A\u9593\uFF08ARCRUN_NAMESPACE / CONSOLE_TENANT \u90FD\u6C92\u8A2D\uFF09\u2014\u2014\u4E0D\u77E5\u9053\u8981\u53BB\u54EA\u4E00\u683C\u627E\u8CC7\u6599\u3002\u8ACB\u8DD1 `acr update` \u8B93\u5B83\u5F9E\u4F60\u7684 ~/.arcrun/config.yaml \u6CE8\u5165\u3002" + ); +} +function tenantFromApiKey(apiKey) { + const key = (apiKey ?? "").trim(); + if (!key) throw new TenantUnresolvedError("\u7F3A\u5C11 X-Arcrun-API-Key\uFF0C\u7121\u6CD5\u6C7A\u5B9A\u67E5\u8A62\u7BC4\u570D"); + return key; +} +function accountTenant(env) { + return env.CONSOLE_TENANT || "leo"; +} +function ownerQuery(tenant2) { + return `owner_id=${encodeURIComponent(tenant2)}`; +} +function ownerField(tenant2) { + return tenant2; +} +function censusQueryAllTenants() { + return "owner_id="; +} +function isOwnedBy(value, tenant2) { + return typeof value === "string" && value === tenant2; +} + +// cypher-executor/src/routes/console-auth.ts var consoleAuthRouter = new Hono2(); var CREDS_KEY = "console:credentials"; var SESSION_PREFIX = "console_sess:"; @@ -12583,7 +12615,7 @@ async function hashPassword(password, salt) { return h; } function tenantOf(c) { - return c.env.CONSOLE_TENANT || "leo"; + return knowledgeOwner(c.env); } async function loadCredentials(env) { let fromStore = readAuthStore(env).console; @@ -12854,10 +12886,10 @@ var DEFAULT_SESSION_TTL = 604800; var USER_TEMPLATE = "portal_user"; var LIBRARY_TEMPLATE = "portal_library"; function portalTenant(env) { - return env.CONSOLE_TENANT || "leo"; + return accountTenant(env); } function portalNamespace(env) { - return `${portalTenant(env)}::portal`; + return `${accountTenant(env)}::portal`; } function sessionTtl(env) { const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? "", 10); @@ -12886,6 +12918,9 @@ async function run(c, fn) { if (e instanceof AuthStoreWriteError) { return c.json({ error: `\u8A8D\u8B49\u5132\u5B58\u5BEB\u5165\u5931\u6557\uFF1A${e.message}`, code: "auth_store_not_writable" }, 502); } + if (e instanceof TenantUnresolvedError) { + return c.json({ error: e.message, code: "tenant_unresolved" }, 500); + } if (e instanceof KbdbError) return c.json({ error: `KBDB \u4E0D\u53EF\u9054\u6216\u56DE\u932F\uFF1A${e.message}` }, 502); throw e; } @@ -13768,10 +13803,9 @@ portalRouter.post( return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401); } await clearLoginFail(c.env, email); - const tenant2 = portalTenant(c.env); const daemonCfg = { cypher_url: new URL(c.req.url).origin, - namespace: tenant2, + namespace: knowledgeOwner(c.env), library: "kb", email, instance_name: String(rec.values.display_name ?? "") @@ -13787,7 +13821,7 @@ portalRouter.post( const body = await c.req.json().catch(() => null); const key = String(body?.key ?? "").trim(); if (!key) return c.json({ error: "\u8ACB\u8CBC\u4E0A\u4F60\u7684 Google AI \u91D1\u9470" }, 400); - const tenant2 = portalTenant(c.env); + const tenant2 = knowledgeOwner(c.env); const kvKey2 = `${tenant2}:wf:rag_chat`; const raw2 = await c.env.WEBHOOKS.get(kvKey2, "text"); if (!raw2) return c.json({ error: "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u5B89\u88DD AI \u554F\u7B54\u5DE5\u4F5C\u6D41" }, 404); @@ -13839,8 +13873,8 @@ portalRouter.get( }); const known = new Set(out.map((l) => l.name)); try { - const tenant2 = portalTenant(c.env); - const ownerParam = `owner_id=${encodeURIComponent(tenant2)}`; + const tenant2 = knowledgeOwner(c.env); + const ownerParam = ownerQuery(tenant2); const [autoRes, cardRes, tripletRes] = await Promise.all([ kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null), kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null), @@ -13989,8 +14023,8 @@ portalRouter.get( (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; - const ownerId = portalTenant(c.env); - const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`); + const ownerId = knowledgeOwner(c.env); + const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`); if (!res.ok) throw new KbdbError(`GET /execution-log/retention \u2192 ${res.status}`); const data = await res.json(); return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 }); @@ -14006,10 +14040,10 @@ portalRouter.put( if (days !== null && days !== void 0 && (typeof days !== "number" || !Number.isFinite(days) || days <= 0)) { return c.json({ error: "retention_days \u5FC5\u9808\u662F\u6B63\u6574\u6578\uFF0C\u6216 null\uFF08\u4EE3\u8868\u4E0D\u522A\u9664\uFF09" }, 400); } - const ownerId = portalTenant(c.env); + const ownerId = knowledgeOwner(c.env); const res = await kbdbFetch(c.env, "/execution-log/retention", { method: "PUT", - body: JSON.stringify({ owner_id: ownerId, retention_days: days === void 0 ? null : days }) + body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === void 0 ? null : days }) }); if (!res.ok) throw new KbdbError(`PUT /execution-log/retention \u2192 ${res.status}`); const data = await res.json(); @@ -14026,10 +14060,10 @@ portalRouter.delete( const confirm = String(body?.confirm ?? "").trim(); if (!confirm) return c.json({ error: 'body \u9808\u5E36 { confirm: "<\u5EAB\u540D>" } \u624D\u57F7\u884C\uFF08\u79FB\u9664\u6703\u5F71\u97FF\u8CC7\u6599\u53EF\u641C\u6027\uFF09' }, 400); if (confirm !== name) return c.json({ error: `confirm \u503C\u300C${confirm}\u300D\u8207\u5EAB\u540D\u300C${name}\u300D\u4E0D\u7B26` }, 400); - const ownerId = portalTenant(c.env); + const ownerId = knowledgeOwner(c.env); const res = await kbdbFetch(c.env, "/entries/deprecate-by-library", { method: "PATCH", - body: JSON.stringify({ owner_id: ownerId, library: name }) + body: JSON.stringify({ owner_id: ownerField(ownerId), library: name }) }); if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library \u2192 ${res.status}`); const data = await res.json(); @@ -14085,8 +14119,8 @@ async function buildDiagnostics(env, tenant2) { let embedding = { checked: false }; try { const [statusRes, selftestRes] = await Promise.all([ - kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant2 }).toString()}`), - kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant2 }).toString()}`) + kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant2)}`), + kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant2)}`) ]); const statusBody = await statusRes.json().catch(() => null); const selftestBody = await selftestRes.json().catch(() => null); @@ -14108,7 +14142,7 @@ async function buildDiagnostics(env, tenant2) { } let library_count = 0; let triplet_count = 0; - const ownerParam = new URLSearchParams({ owner_id: tenant2 }).toString(); + const ownerParam = ownerQuery(tenant2); try { const [registeredLibs, autoRes, tripletRes] = await Promise.all([ listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []), @@ -14132,7 +14166,7 @@ async function buildDiagnostics(env, tenant2) { let library_scope_check = { ran: false }; if (library_count === 0 && triplet_count === 0) { try { - const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant2, limit: "1" }).toString()}`); + const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant2), limit: "1" }).toString()}`); const probeBody = await probeRes.json().catch(() => null); const total = probeBody?.total ?? 0; library_scope_check = { @@ -14155,7 +14189,7 @@ portalRouter.get( (c) => run(c, async () => { const apiKey = (c.req.header("X-Arcrun-API-Key") ?? "").trim(); if (!apiKey) return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401); - const core = await buildDiagnostics(c.env, apiKey); + const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey)); return c.json({ generated_at: (/* @__PURE__ */ new Date()).toISOString(), instance_url: new URL(c.req.url).origin, @@ -14710,7 +14744,7 @@ async function cachedGiteaSprint(env, nowMs, waitUntil, fetcher = fetchGiteaSpri return { ...fresh, cache: "miss" }; } consoleDashboardRouter.get("/console/dashboard-data", async (c) => { - const tenant2 = c.env.CONSOLE_TENANT || "leo"; + const tenant2 = knowledgeOwner(c.env); const now2 = Date.now(); const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env); const graphUrl = graphBase(c.env); @@ -14862,7 +14896,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => { }); }); consoleDashboardRouter.get("/console/kb-scale-data", async (c) => { - const tenant2 = c.env.CONSOLE_TENANT || "leo"; + const tenant2 = knowledgeOwner(c.env); const { base, headers } = kbdbBase(c.env); const now2 = Date.now(); const [wikiCards, tripletTotal, embedStatus] = await Promise.all([ @@ -14897,7 +14931,7 @@ consoleDashboardRouter.get("/console/settings-data", (c) => { consoleDashboardRouter.get("/console/triage-data", async (c) => { const ok = await validateConsoleSession(c.env, c.req.header("authorization")); if (!ok) return c.json({ error: "\u9700\u8981\u767B\u5165\uFF08console session\uFF09" }, 401); - const tenant2 = c.env.CONSOLE_TENANT || "leo"; + const tenant2 = knowledgeOwner(c.env); const [todoEntries, inboxEntries] = await Promise.all([ fetchEntries(c.env, tenant2, "todo", 500), fetchEntries(c.env, tenant2, "inbox", 200) @@ -14912,7 +14946,7 @@ consoleDashboardRouter.post("/console/triage-check", async (c) => { const entryId = typeof body?.entry_id === "string" ? body.entry_id.trim() : ""; if (!entryId) return c.json({ error: "entry_id \u5FC5\u586B" }, 400); const action = body?.action === "restore" ? "restore" : "check"; - const tenant2 = c.env.CONSOLE_TENANT || "leo"; + const tenant2 = knowledgeOwner(c.env); const { base, headers } = kbdbBase(c.env); const got = await fetchJson( `${base}/entries/${encodeURIComponent(entryId)}`, @@ -14940,7 +14974,7 @@ init_kbdb_proxy(); init_webhook_handlers(); var portalDataRouter = new Hono2(); async function getTenantWorkflowGraph(env, name) { - const raw2 = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, "text"); + const raw2 = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, "text"); if (!raw2) return null; try { const rec = JSON.parse(raw2); @@ -15033,7 +15067,7 @@ function findBestNodeMatch(searchTerm, nodeNames) { } async function tripletCount(env, owner) { try { - const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`); + const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`); if (!res.ok) return null; const body = await res.json().catch(() => null); if (!body || !Array.isArray(body.stats)) return null; @@ -15050,11 +15084,11 @@ async function tripletCount(env, owner) { async function tripletCensus(env, tenant2) { const owned = await tripletCount(env, tenant2); if (owned !== 0) return { owned, any: null }; - return { owned, any: await tripletCount(env, "") }; + return { owned, any: await tripletCount(env, null) }; } async function fuzzyFindNode(env, tenant2, searchTerm) { try { - const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`); + const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant2)}`); if (!res.ok) return null; const body = await res.json().catch(() => null); if (!body || !Array.isArray(body.records)) return null; @@ -15082,7 +15116,7 @@ portalDataRouter.get( if (libraries.length === 0) { return c.json({ success: true, entries: [], count: 0, mode: "keyword", note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" }); } - const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) }); + const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) }); if (!libraries.includes("*")) params.set("library", libraries.join(",")); if (c.req.query("mode") === "semantic") { params.set("mode", "semantic"); @@ -15118,7 +15152,7 @@ portalDataRouter.get( const body = await res.json(); const entry = body.entry; if (!entry) return notFound(c); - if ((entry.owner_id ?? "") !== portalTenant(c.env)) return notFound(c); + if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c); if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c); return c.json({ success: true, entry }); }) @@ -15133,7 +15167,7 @@ portalDataRouter.get( return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403); } const nodeName = normalizeCjkQuery(c.req.param("name")); - const tenant2 = portalTenant(c.env); + const tenant2 = knowledgeOwner(c.env); const wfGraph = await getTenantWorkflowGraph(c.env, "graph_neighbors"); if (wfGraph) { const depthRaw = c.req.query("depth") ?? ""; @@ -15187,9 +15221,9 @@ portalDataRouter.get( if (!await hasGraphAccess(c.env, libraries)) { return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403); } - const tenant2 = portalTenant(c.env); + const tenant2 = knowledgeOwner(c.env); const [res, census] = await Promise.all([ - kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}&limit=500`), + kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant2)}&limit=500`), tripletCensus(c.env, tenant2) ]); const tripletsTotal = census.owned; @@ -15260,7 +15294,7 @@ portalDataRouter.get( wfGraph, { question }, "rag_chat", - portalTenant(c.env), + knowledgeOwner(c.env), c.executionCtx ); if (!result.success) { @@ -15336,7 +15370,7 @@ portalDataRouter.get( if (!workflowsVisible(c.env, auth.user.values.role ?? "user")) { return c.json({ error: "\u9700\u8981 admin \u6B0A\u9650" }, 403); } - const tenant2 = portalTenant(c.env); + const tenant2 = knowledgeOwner(c.env); const prefix = `${tenant2}:wf:`; const list = await c.env.WEBHOOKS.list({ prefix }); const workflows = await Promise.all( @@ -15358,7 +15392,7 @@ portalDataRouter.get( let last_execution = null; const execRes = await kbdbFetch( c.env, - `/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant2 }).toString()}` + `/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant2) }).toString()}` ); const execBody = await execRes.json().catch(() => null); if (execRes.ok && execBody?.success && execBody.execution) { @@ -15375,7 +15409,7 @@ function recordLibrary(values) { return typeof lib === "string" && lib.trim() ? lib.trim() : null; } function canReadRecord(rec, tenant2, libraries) { - if ((rec.owner_id ?? "") !== tenant2) return false; + if (!isOwnedBy(rec.owner_id, tenant2)) return false; const lib = recordLibrary(rec.values); return lib === null || canReadLibrary(libraries, lib); } @@ -15386,9 +15420,17 @@ portalDataRouter.get( if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) { - return c.json({ success: true, libraries: [], count: 0, note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" }); + return c.json({ + success: true, + libraries: [], + count: 0, + empty_confirmed: true, + empty_reason: "no_library_grant", + note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" + }); } - const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`); + const tenant2 = knowledgeOwner(c.env); + const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant2)}`); if (!res.ok) { return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } }); } @@ -15399,7 +15441,49 @@ portalDataRouter.get( const allowed = body.libraries.filter( (l) => typeof l?.library === "string" && canReadLibrary(libraries, l.library) ); - return c.json({ success: true, libraries: allowed, count: allowed.length }); + if (allowed.length > 0) { + return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null }); + } + if (body.libraries.length > 0) { + return c.json({ + success: true, + libraries: [], + count: 0, + empty_confirmed: true, + empty_reason: "filtered_out", + note: "\u9019\u500B\u5E33\u865F\u76EE\u524D\u6C92\u6709\u4EFB\u4F55\u77E5\u8B58\u5EAB\u7684\u6AA2\u8996\u6B0A\u9650\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u958B\u901A\u3002" + }); + } + const census = await tripletCensus(c.env, tenant2); + if (census.owned === null || census.owned === 0 && census.any === null) { + return c.json({ + success: true, + libraries: [], + count: 0, + empty_confirmed: false, + empty_reason: "unreadable", + note: "\u8B80\u4E0D\u5230\u77E5\u8B58\u5EAB\u7684\u7D71\u8A08\uFF0C\u7121\u6CD5\u78BA\u8A8D\u5EAB\u88E1\u6709\u6C92\u6709\u6771\u897F\u2014\u2014\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u662F\u9019\u6B21\u8B80\u53D6\u5931\u6557\u3002\u8ACB\u7A0D\u5F8C\u91CD\u6574\u6216\u901A\u77E5\u7BA1\u7406\u54E1\u3002" + }); + } + if (census.owned === 0 && (census.any ?? 0) > 0) { + return c.json({ + success: true, + libraries: [], + count: 0, + empty_confirmed: false, + empty_reason: "scope_mismatch", + instance_triplet_count: census.any, + note: `\u8B80\u4E0D\u5230\u4F60\u9019\u500B\u5E33\u865F\u7BC4\u570D\u5167\u7684\u85CF\u66F8\u2014\u2014\u4F46\u9019\u53F0\u5BE6\u4F8B\u88E1\u6709 ${census.any} \u689D\u77E5\u8B58\u95DC\u806F\u3002\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u4E0D\u7528\u53BB\u91CD\u65B0\u4E0A\u50B3\uFF1B\u6BD4\u8F03\u50CF\u77E5\u8B58\u7684\u6B78\u5C6C\u547D\u540D\u7A7A\u9593\u5C0D\u4E0D\u4E0A\u3002\u8ACB\u901A\u77E5\u7BA1\u7406\u54E1\u8DD1\u4E00\u6B21 \`acr update\`\uFF08\u6703\u628A\u4F60\u5B89\u88DD\u6642\u7684\u547D\u540D\u7A7A\u9593\u540C\u6B65\u7D66\u96F2\u7AEF\uFF09\uFF0C\u6216\u6AA2\u67E5 ARCRUN_NAMESPACE \u8A2D\u5B9A\u3002` + }); + } + return c.json({ + success: true, + libraries: [], + count: 0, + empty_confirmed: true, + empty_reason: "confirmed_empty", + note: "\u77E5\u8B58\u5EAB\u9084\u6C92\u6709\u4EFB\u4F55\u5167\u5BB9\u2014\u2014\u4E0A\u50B3\u6587\u4EF6\u5F8C\u5C31\u6703\u51FA\u73FE\u5728\u9019\u88E1\u3002" + }); }) ); portalDataRouter.get( @@ -15412,7 +15496,7 @@ portalDataRouter.get( if (!canReadLibrary(libraries, library)) return notFound(c); const res = await kbdbFetch( c.env, - `/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}` + `/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}` ); if (res.status === 404) return notFound(c); if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502); @@ -15445,7 +15529,7 @@ portalDataRouter.post( name: body.name, slots: body.slots, description: typeof body.description === "string" ? body.description : void 0, - created_by: portalTenant(c.env) + created_by: knowledgeOwner(c.env) }) }); return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } }); @@ -15458,10 +15542,10 @@ portalDataRouter.get( if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 }); - const tenant2 = portalTenant(c.env); + const tenant2 = knowledgeOwner(c.env); const res = await kbdbFetch( c.env, - `/records/by-template/${encodeURIComponent(c.req.param("template"))}?owner_id=${encodeURIComponent(tenant2)}` + `/records/by-template/${encodeURIComponent(c.req.param("template"))}?${ownerQuery(tenant2)}` ); if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502); const body = await res.json().catch(() => null); @@ -15485,7 +15569,7 @@ portalDataRouter.get( const body = await res.json().catch(() => null); const record = body?.record; if (!record) return notFound(c); - if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c); + if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c); return c.json({ success: true, record }); }) ); @@ -15510,7 +15594,7 @@ portalDataRouter.post( const res = await kbdbFetch(c.env, "/records", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) }) + body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }) }); return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } }); }) @@ -15520,7 +15604,7 @@ portalDataRouter.get( (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; - const tenant2 = portalTenant(c.env); + const tenant2 = knowledgeOwner(c.env); const core = await buildDiagnostics(c.env, tenant2); return c.json({ generated_at: (/* @__PURE__ */ new Date()).toISOString(), @@ -15570,10 +15654,10 @@ app.route("/", consoleAuthRouter); app.route("/", consoleDashboardRouter); app.route("/", portalRouter); app.route("/", portalDataRouter); -var index_default = { +var src_default = { fetch: app.fetch, scheduled: handleScheduled }; export { - index_default as default + src_default as default }; diff --git a/.worker-builds/arcrun-http-request/worker.mjs b/.worker-builds/arcrun-http-request/worker.mjs index 4b70fad..0ab4b10 100644 --- a/.worker-builds/arcrun-http-request/worker.mjs +++ b/.worker-builds/arcrun-http-request/worker.mjs @@ -1427,7 +1427,7 @@ var Hono = class _Hono { var emptyParam = []; function match(method, path) { const matchers = this.buildAllMatchers(); - const match2 = ((method2, path2) => { + const match2 = (method2, path2) => { const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const staticMatch = matcher[2][path2]; if (staticMatch) { @@ -1439,7 +1439,7 @@ function match(method, path) { } const index = match3.indexOf("", 1); return [matcher[1][index], match3]; - }); + }; this.match = match2; return match2(method, path); } @@ -2522,7 +2522,7 @@ app.post("/", async (c) => { ); } }); -var index_default = app; +var src_default = app; async function runWasm(input) { const hostFunctions = { http_request: async (url, method, headersJson, body) => { @@ -2568,5 +2568,5 @@ async function runWasm(input) { return JSON.parse(stdout); } export { - index_default as default + src_default as default }; diff --git a/.worker-builds/arcrun-kbdb/worker.mjs b/.worker-builds/arcrun-kbdb/worker.mjs index 1bc7231..62783c1 100644 --- a/.worker-builds/arcrun-kbdb/worker.mjs +++ b/.worker-builds/arcrun-kbdb/worker.mjs @@ -1444,7 +1444,7 @@ var Hono = class _Hono { var emptyParam = []; function match(method, path) { const matchers = this.buildAllMatchers(); - const match2 = ((method2, path2) => { + const match2 = (method2, path2) => { const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const staticMatch = matcher[2][path2]; if (staticMatch) { @@ -1456,7 +1456,7 @@ function match(method, path) { } const index = match3.indexOf("", 1); return [matcher[1][index], match3]; - }); + }; this.match = match2; return match2(method, path); } @@ -4157,7 +4157,7 @@ app.route("/recipe-stats", recipeStatRoutes); app.route("/execution-log", executionLogRoutes); app.route("/embed", embedRoutes); app.route("/map", mapRoutes); -var index_default = app; +var src_default = app; export { - index_default as default + src_default as default }; diff --git a/.worker-builds/arcrun-mcp/worker.mjs b/.worker-builds/arcrun-mcp/worker.mjs index 54dc80a..9217d6a 100644 --- a/.worker-builds/arcrun-mcp/worker.mjs +++ b/.worker-builds/arcrun-mcp/worker.mjs @@ -5,11 +5,7 @@ var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __commonJS = (cb, mod) => function __require() { - try { - return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; - } catch (e) { - throw mod = 0, e; - } + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; var __export = (target, all) => { for (var name in all) @@ -8227,7 +8223,7 @@ var Hono = class _Hono { var emptyParam = []; function match(method, path) { const matchers = this.buildAllMatchers(); - const match2 = ((method2, path2) => { + const match2 = (method2, path2) => { const matcher = matchers[method2] || matchers[METHOD_NAME_ALL]; const staticMatch = matcher[2][path2]; if (staticMatch) { @@ -8239,7 +8235,7 @@ function match(method, path) { } const index = match3.indexOf("", 1); return [matcher[1][index], match3]; - }); + }; this.match = match2; return match2(method, path); } @@ -12963,7 +12959,7 @@ ZodNaN.create = (params) => { ...processCreateParams(params) }); }; -var BRAND = /* @__PURE__ */ Symbol("zod_brand"); +var BRAND = Symbol("zod_brand"); var ZodBranded = class extends ZodType { _parse(input) { const { ctx } = this._processInputParams(input); @@ -13165,14 +13161,14 @@ var ostring = () => stringType().optional(); var onumber = () => numberType().optional(); var oboolean = () => booleanType().optional(); var coerce = { - string: ((arg) => ZodString.create({ ...arg, coerce: true })), - number: ((arg) => ZodNumber.create({ ...arg, coerce: true })), - boolean: ((arg) => ZodBoolean.create({ + string: (arg) => ZodString.create({ ...arg, coerce: true }), + number: (arg) => ZodNumber.create({ ...arg, coerce: true }), + boolean: (arg) => ZodBoolean.create({ ...arg, coerce: true - })), - bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })), - date: ((arg) => ZodDate.create({ ...arg, coerce: true })) + }), + bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }), + date: (arg) => ZodDate.create({ ...arg, coerce: true }) }; var NEVER = INVALID; @@ -13223,6 +13219,7 @@ function $constructor(name, initializer3, params) { Object.defineProperty(_, "name", { value: name }); return _; } +var $brand = Symbol("zod_brand"); var $ZodAsyncError = class extends Error { constructor() { super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`); @@ -15726,6 +15723,8 @@ function en_default2() { } // mcp/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v4/core/registries.js +var $output = Symbol("ZodOutput"); +var $input = Symbol("ZodInput"); var $ZodRegistry = class { constructor() { this._map = /* @__PURE__ */ new Map(); @@ -17003,10 +17002,10 @@ var ZodMiniType = /* @__PURE__ */ $constructor("ZodMiniType", (inst, def) => { }; inst.clone = (_def, params) => clone(inst, _def, params); inst.brand = () => inst; - inst.register = ((reg, meta) => { + inst.register = (reg, meta) => { reg.add(inst, meta); return inst; - }); + }; }); var ZodMiniObject = /* @__PURE__ */ $constructor("ZodMiniObject", (inst, def) => { $ZodObject.init(inst, def); @@ -17269,10 +17268,10 @@ var ZodType2 = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { }; inst.clone = (def2, params) => clone(inst, def2, params); inst.brand = () => inst; - inst.register = ((reg, meta) => { + inst.register = (reg, meta) => { reg.add(inst, meta); return inst; - }); + }; inst.parse = (data, params) => parse2(inst, data, params, { callee: inst.parse }); inst.safeParse = (data, params) => safeParse3(inst, data, params); inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync }); @@ -19244,13 +19243,11 @@ function assertCompleteRequestPrompt(request) { if (request.params.ref.type !== "ref/prompt") { throw new TypeError(`Expected CompleteRequestPrompt, but got ${request.params.ref.type}`); } - void request; } function assertCompleteRequestResourceTemplate(request) { if (request.params.ref.type !== "ref/resource") { throw new TypeError(`Expected CompleteRequestResourceTemplate, but got ${request.params.ref.type}`); } - void request; } var CompleteResultSchema = ResultSchema.extend({ completion: looseObject({ @@ -19403,7 +19400,7 @@ function isTerminal(status) { } // mcp/node_modules/.pnpm/zod-to-json-schema@3.25.2_zod@3.25.76/node_modules/zod-to-json-schema/dist/esm/Options.js -var ignoreOverride = /* @__PURE__ */ Symbol("Let zodToJsonSchema decide on which parser to use"); +var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use"); var defaultOptions = { name: void 0, $refStrategy: "root", @@ -22379,7 +22376,7 @@ var Server = class extends Protocol { }; // mcp/node_modules/.pnpm/@modelcontextprotocol+sdk@1.29.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/completable.js -var COMPLETABLE_SYMBOL = /* @__PURE__ */ Symbol.for("mcp.completable"); +var COMPLETABLE_SYMBOL = Symbol.for("mcp.completable"); function isCompletable(schema4) { return !!schema4 && typeof schema4 === "object" && COMPLETABLE_SYMBOL in schema4; } @@ -24694,13 +24691,13 @@ function registerAllIntrospectionTools(server, env) { } // mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/nodes/identity.js -var ALIAS = /* @__PURE__ */ Symbol.for("yaml.alias"); -var DOC = /* @__PURE__ */ Symbol.for("yaml.document"); -var MAP = /* @__PURE__ */ Symbol.for("yaml.map"); -var PAIR = /* @__PURE__ */ Symbol.for("yaml.pair"); -var SCALAR = /* @__PURE__ */ Symbol.for("yaml.scalar"); -var SEQ = /* @__PURE__ */ Symbol.for("yaml.seq"); -var NODE_TYPE = /* @__PURE__ */ Symbol.for("yaml.node.type"); +var ALIAS = Symbol.for("yaml.alias"); +var DOC = Symbol.for("yaml.document"); +var MAP = Symbol.for("yaml.map"); +var PAIR = Symbol.for("yaml.pair"); +var SCALAR = Symbol.for("yaml.scalar"); +var SEQ = Symbol.for("yaml.seq"); +var NODE_TYPE = Symbol.for("yaml.node.type"); var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS; var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC; var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP; @@ -24730,9 +24727,9 @@ function isNode(node) { var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor; // mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/visit.js -var BREAK = /* @__PURE__ */ Symbol("break visit"); -var SKIP = /* @__PURE__ */ Symbol("skip children"); -var REMOVE = /* @__PURE__ */ Symbol("remove node"); +var BREAK = Symbol("break visit"); +var SKIP = Symbol("skip children"); +var REMOVE = Symbol("remove node"); function visit(node, visitor) { const visitor_ = initVisitor(visitor); if (isDocument(node)) { @@ -29325,9 +29322,9 @@ ${end.comment}` : end.comment; }; // mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/parse/cst-visit.js -var BREAK2 = /* @__PURE__ */ Symbol("break visit"); -var SKIP2 = /* @__PURE__ */ Symbol("skip children"); -var REMOVE2 = /* @__PURE__ */ Symbol("remove item"); +var BREAK2 = Symbol("break visit"); +var SKIP2 = Symbol("skip children"); +var REMOVE2 = Symbol("remove item"); function visit2(cst, visitor) { if ("type" in cst && cst.type === "document") cst = { start: cst.start, value: cst.value }; @@ -33599,7 +33596,7 @@ app.post("/", partnerAuthMiddleware, async (c) => { const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal")); return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity); }); -var index_default = _app; +var src_default = _app; export { - index_default as default + src_default as default }; diff --git a/.worker-builds/manifest.json b/.worker-builds/manifest.json index 4b32065..1d527ad 100644 --- a/.worker-builds/manifest.json +++ b/.worker-builds/manifest.json @@ -1,18 +1,18 @@ { "schema": 1, "built_for": "arcrun-tier2-worker-artifacts", - "generated_at": "2026-08-12T14:22:56.880Z", - "repo_head": "21293568d550ab7ef50cec2020165d8bf4376104", + "generated_at": "2026-08-12T16:16:33.433Z", + "repo_head": "b223a698844be289c1b01f99eb34a8e2ac85bb74", "repo_dirty": false, "workers": [ { "name": "arcrun-cypher-executor", "source_dir": "cypher-executor", - "source_commit": "53b05c6d3d4a5a02661880dd5565fa9feb743bb6", + "source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74", "main_module": "worker.mjs", "main_file": "arcrun-cypher-executor/worker.mjs", - "js_bytes": 584721, - "content_sha256": "b7c810d7654c05d197abe9a37acce2ed5f77e1af09595d5b4316b69166edf11a", + "js_bytes": 588397, + "content_sha256": "e0026a23792f8b6b02e35c91081604b9a761c608a2cae0e6ee8ab0f34a484501", "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -61,8 +61,8 @@ "source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b", "main_module": "worker.mjs", "main_file": "arcrun-kbdb/worker.mjs", - "js_bytes": 149797, - "content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1", + "js_bytes": 149791, + "content_sha256": "9c6d41895d78cbf3048fb539690591c86071b7d6ebf7fb0b8b51607c6b1f8ee9", "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -90,8 +90,8 @@ "source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3", "main_module": "worker.mjs", "main_file": "arcrun-http-request/worker.mjs", - "js_bytes": 80079, - "content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85", + "js_bytes": 80073, + "content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15", "modules": [ { "name": "component.wasm", @@ -122,8 +122,8 @@ "source_commit": "621cb8d948d61be6202063fd02effb3f538437fe", "main_module": "worker.mjs", "main_file": "arcrun-code/worker.mjs", - "js_bytes": 153758, - "content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6", + "js_bytes": 153671, + "content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5", "modules": [ { "name": "quickjs.wasm", @@ -151,8 +151,8 @@ "source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9", "main_module": "worker.mjs", "main_file": "arcrun-mcp/worker.mjs", - "js_bytes": 1179487, - "content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6", + "js_bytes": 1179229, + "content_sha256": "3ebc0d441bc04ae56a1205507da701f93bed9efa9b1e53c1777c04cbef5bdb67", "modules": [], "compat_date": "2024-11-27", "compat_flags": [ diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 49023aa..409fcd6 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -17,6 +17,7 @@ import { loadConfig } from '../lib/config.js'; import { wranglerAvailable, downloadAndDeploy, + namespaceHasKnowledge, type DeployContext, } from '../lib/deploy.js'; @@ -63,6 +64,32 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise { kbdbEmbed: config.kbdb_embed !== false, }; + // Arcrun#108:把「你的知識住在哪個命名空間」同步給雲端——但**先驗再寫**。 + // + // 病灶:你 push 工作流、小幫手上傳知識、MCP 查詢,用的都是 config 的 `api_key`; + // 而 cypher 讀藏書地圖/搜尋/工作流時,過濾用的 owner_id 來自 worker 的環境變數 + // (repo toml 帶的官方預設 `CONSOLE_TENANT = "leo"`)。兩個來源對不上 ⇒ 你的東西全被濾掉。 + // + // 為什麼不無條件寫:一鍵安裝的實例,知識可能本來就寫在 `CONSOLE_TENANT` 底下。 + // 無條件蓋成本機 api_key,會把一台**原本正常**的實例指向空的那一格 + // ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」。所以查得到才寫,查不到就不碰。 + if (config.api_key && config.cypher_executor_url) { + process.stdout.write(chalk.gray(' → 核對雲端要用哪個知識命名空間...')); + const hasKnowledge = await namespaceHasKnowledge(config.cypher_executor_url, config.api_key); + if (hasKnowledge === true) { + ctx.knowledgeNamespace = config.api_key; + console.log(chalk.green(' ✓')); + console.log(chalk.gray(` ARCRUN_NAMESPACE = ${config.api_key}(這個命名空間底下查得到你的知識庫)`)); + } else if (hasKnowledge === false) { + console.log(chalk.yellow(' ⚠')); + console.log(chalk.gray(` ${config.api_key} 底下目前查不到任何知識庫 → 這趟不動雲端的命名空間設定`)); + console.log(chalk.gray(' (若藏書地圖是空的,請把這行連同 acr update 的輸出一起回報)')); + } else { + console.log(chalk.yellow(' ⚠')); + console.log(chalk.gray(' 問不到實例(可能正在啟動或版本較舊)→ 這趟不動雲端的命名空間設定')); + } + } + // mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套 //(Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。 const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' }); diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index 029e714..ce65c2d 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -298,6 +298,44 @@ export interface DeployContext { // [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入, // base 維持 LIKE keyword(free-tier 友善)。 kbdbEmbed?: boolean; + /** + * Arcrun#108:這台實例的知識命名空間(=`~/.arcrun/config.yaml` 的 `api_key`), + * 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var,讓「讀」用的 owner_id 與「寫」的一致。 + * + * **只在驗證過該 namespace 底下真的有知識時才給值**(見 `resolveKnowledgeNamespace`)—— + * 給了就會覆蓋 worker 上的既有值,沒給則原封保留(preservedVars)。 + */ + knowledgeNamespace?: string; +} + +/** + * 這把 namespace 底下到底有沒有知識?(Arcrun#108 的「先驗再寫」) + * + * 打的是實例自己的 `GET /kbdb/map?owner_id=`(cypher 既有的純轉發端點,CLI 平常就在用 + * 這條路 + `X-Arcrun-API-Key`)。回傳: + * true = 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的 + * false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空) + * null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事 + * + * 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。 + */ +export async function namespaceHasKnowledge( + cypherUrl: string, + namespace: string, +): Promise { + if (!cypherUrl || !namespace) return null; + try { + const res = await fetch( + `${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`, + { headers: { 'X-Arcrun-API-Key': namespace } }, + ); + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null; + if (!body || !Array.isArray(body.libraries)) return null; + return body.libraries.length > 0; + } catch { + return null; + } } /** @@ -561,6 +599,21 @@ export async function downloadAndDeploy( vars.ARCRUN_BUNDLE_VERSION = stamp.version; if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit; } + // Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。 + // + // 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是 + // repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、 + // MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉 + //(leo 實撞:1854 條三元組被過濾成 0 個庫)。 + // + // 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見 + // resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能 + // 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常** + // 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。 + // 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。 + if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) { + vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace; + } if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars); } if (preservedTotal.length > 0) { diff --git a/cli/tests/knowledge-namespace.test.ts b/cli/tests/knowledge-namespace.test.ts new file mode 100644 index 0000000..b324975 --- /dev/null +++ b/cli/tests/knowledge-namespace.test.ts @@ -0,0 +1,170 @@ +/** + * Arcrun#108 迴歸守衛 —— 「雲端讀資料用的命名空間,要跟你寫資料用的那個一致」 + * + * 2026-08-12 實害:leo 的藏書地圖回 0 個庫,實際有 1854 條三元組。 + * 根因:你 push 工作流、小幫手上傳知識、MCP 查詢都用 `~/.arcrun/config.yaml` 的 `api_key` + * (leo = `bfezv28v`),但 cypher 讀取時的 owner_id 來自 worker 環境變數 + * ——而那個變數是 repo toml 帶的**官方 prod 值** `CONSOLE_TENANT = "leo"`。 + * 寫在 A、讀在 B,全被過濾掉。 + * + * 這份測試守兩件相反的事(本次的核心判斷): + * · 驗得到知識 → **寫** `ARCRUN_NAMESPACE`,讓讀寫兩端對齊 + * · 驗不到 / 問不到 → **一個字都不動**,既有值原封保留 + * (無條件覆蓋會把一台「知識本來就寫在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格 + * ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟) + * + * 全部離線跑:真的 wrangler.toml + 真的 render 程式碼,fetch 用假的,不碰任何實例。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + renderWranglerToml, + preservedVars, + namespaceHasKnowledge, + VERSION_STAMP_WORKER, + type DeployContext, +} from '../src/lib/deploy.ts'; + +const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); +const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8'); + +/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */ +const LEO_NS = 'bfezv28v'; + +const CTX: DeployContext = { + accountId: 'acc-user-123', + apiToken: 'token', + workerSubdomain: 'user-sub', + selfHosted: true, + kbdbEmbed: true, +}; + +function readVars(toml: string): Record { + const out: Record = {}; + let inVars = false; + for (const line of toml.split('\n')) { + if (/^\s*\[vars\]/.test(line)) { inVars = true; continue; } + if (/^\s*\[/.test(line)) { inVars = false; continue; } + if (!inVars) continue; + const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/); + if (m) out[m[1]] = m[2]; + } + return out; +} + +/** 模擬 downloadAndDeploy 那段:沿用既有 var,再疊上這趟 CLI 算出來的值。 */ +function deployedVars(ctx: DeployContext, live: Record): Record { + const keep = preservedVars(live, CYPHER_TOML); + const extra: Record = { ...keep }; + if (ctx.knowledgeNamespace) extra.ARCRUN_NAMESPACE = ctx.knowledgeNamespace; + return readVars(renderWranglerToml(CYPHER_TOML, ctx, new Map(), extra)); +} + +// ── ① 驗得到知識 → 寫進去 ──────────────────────────────────────────────────── + +test('#108 給了 knowledgeNamespace → cypher [vars] 出現 ARCRUN_NAMESPACE(讀寫兩端終於同一個值)', () => { + const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, {}); + assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS); + // CONSOLE_TENANT 一個字都不能動——它同時是帳號子 namespace 的組成,改了舊實例登不進去 + assert.equal(vars.CONSOLE_TENANT, 'leo'); +}); + +test('#108 蓋得過 worker 上的舊值(改名/搬遷後 acr update 要能修正,不是永遠沿用第一次那個)', () => { + const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, { ARCRUN_NAMESPACE: 'stale-ns' }); + assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS); +}); + +// ── ② 驗不到 → 什麼都不動(比 bug 更糟的是把人家原本正常的實例弄空)────────────── + +test('#108 沒給 knowledgeNamespace → 既有的 ARCRUN_NAMESPACE 原封保留(不因為這趟驗不到就洗掉)', () => { + const vars = deployedVars(CTX, { ARCRUN_NAMESPACE: 'user-existing-ns' }); + assert.equal(vars.ARCRUN_NAMESPACE, 'user-existing-ns'); +}); + +test('#108 沒給、worker 上也沒有 → 不注入(回退 CONSOLE_TENANT,舊實例行為一字不變)', () => { + const vars = deployedVars(CTX, {}); + assert.equal(vars.ARCRUN_NAMESPACE, undefined); + assert.equal(vars.CONSOLE_TENANT, 'leo'); +}); + +test('#108 ARCRUN_NAMESPACE 不在 CLI_MANAGED_VARS:它「不是每趟重算」而是「驗到才寫」,' + + '列進去會讓驗不到的那趟把既有值一起洗掉', async () => { + const { CLI_MANAGED_VARS } = await import('../src/lib/deploy.ts'); + assert.equal((CLI_MANAGED_VARS as readonly string[]).includes('ARCRUN_NAMESPACE'), false); +}); + +test('#108 只烙在 cypher 這顆 worker(其他 worker 不需要知識命名空間)', () => { + assert.equal(VERSION_STAMP_WORKER, 'arcrun-cypher-executor'); +}); + +// ── ③ 「先驗再寫」那支探針的三態 ─────────────────────────────────────────────── + +test('namespaceHasKnowledge:這個命名空間底下查得到庫 → true(可以安全寫進去)', async () => { + const calls: string[] = []; + const orig = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + calls.push(String(url)); + assert.equal((init?.headers as Record)['X-Arcrun-API-Key'], LEO_NS); + return new Response(JSON.stringify({ success: true, libraries: [{ library: 'kb' }], count: 1 }), { status: 200 }); + }) as typeof fetch; + try { + assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), true); + assert.equal(calls[0], `https://cypher.example.dev/kbdb/map?owner_id=${LEO_NS}`); + } finally { + globalThis.fetch = orig; + } +}); + +test('namespaceHasKnowledge:查得到但是空的 → false(知識可能在別的命名空間,不准蓋)', async () => { + const orig = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ success: true, libraries: [], count: 0 }), { status: 200 })) as typeof fetch; + try { + assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), false); + } finally { + globalThis.fetch = orig; + } +}); + +test('namespaceHasKnowledge:問不到(實例沒起來/舊版沒這條路/網路斷)→ null,不宣稱任何事', async () => { + const orig = globalThis.fetch; + globalThis.fetch = (async () => { throw new Error('ECONNREFUSED'); }) as typeof fetch; + try { + assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null); + } finally { + globalThis.fetch = orig; + } + globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch; + try { + assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null); + } finally { + globalThis.fetch = orig; + } +}); + +test('namespaceHasKnowledge:回應形狀不對 → null(讀不出來 ≠ 沒有資料,禁假綠)', async () => { + const orig = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ success: true }), { status: 200 })) as typeof fetch; + try { + assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null); + } finally { + globalThis.fetch = orig; + } +}); + +test('namespaceHasKnowledge:缺 url 或缺 namespace → null(不打任何請求)', async () => { + const orig = globalThis.fetch; + globalThis.fetch = (async () => { throw new Error('不該被呼叫'); }) as typeof fetch; + try { + assert.equal(await namespaceHasKnowledge('', LEO_NS), null); + assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', ''), null); + } finally { + globalThis.fetch = orig; + } +}); diff --git a/cypher-executor/package.json b/cypher-executor/package.json index f2a1c48..2325a02 100644 --- a/cypher-executor/package.json +++ b/cypher-executor/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", - "test": "vitest run" + "check:tenant": "node scripts/check-tenant-source.mjs", + "test": "node scripts/check-tenant-source.mjs && vitest run" }, "dependencies": { "@hono/zod-openapi": "^1.2.4", diff --git a/cypher-executor/scripts/check-tenant-source.mjs b/cypher-executor/scripts/check-tenant-source.mjs new file mode 100644 index 0000000..a63a858 --- /dev/null +++ b/cypher-executor/scripts/check-tenant-source.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * 「靜態租戶字串不得用於資料面過濾」機械閘的**執行殼**(Arcrun#108)。 + * + * 規則本體(純函式、零 node 相依)在 `tenant-source-rules.mjs`——拆開的理由是 + * **這道閘自己要能被測試**:Workers runtime 的 vitest 沒有 node:fs,規則若和走檔案系統的 + * 程式碼綁在一起就 import 不動,測試也就寫不出來(當晚有一道閘連讀自己的原始碼都擋, + * 結果沒人驗得了它會不會誤攔)。現在 tests/tenant-gate.test.ts 直接餵字串驗規則。 + * + * 用法: + * node scripts/check-tenant-source.mjs [projectRoot] # 掃 src/,有違規 → exit 1 + * node scripts/check-tenant-source.mjs --stdin <相對路徑> # 從 stdin 讀「即將寫入的內容」 + * npm run check:tenant + * + * `--stdin` 是給 `.claude/hooks/pre-write-guard.sh`(規則 8.1)用的:在檔案**還沒寫下去之前** + * 就擋,這樣違規根本進不了工作區。Edit 只給片段也沒關係——規則是逐行的,正好只看新寫的那幾行。 + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { scanSource } from './tenant-source-rules.mjs'; + +/** 遞迴列出目錄下的 .ts 檔(相對 root 的路徑)。 */ +function listTsFiles(root, dir = root, out = []) { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) listTsFiles(root, full, out); + else if (name.endsWith('.ts')) out.push(relative(root, full)); + } + return out; +} + +/** 掃整個 cypher-executor/src。回傳違規清單。 */ +export function scanProject(projectRoot) { + const srcRoot = join(projectRoot, 'src'); + const all = []; + for (const rel of listTsFiles(projectRoot, srcRoot)) { + const relPosix = rel.split(sep).join('/'); + all.push( + ...scanSource(relPosix, readFileSync(join(projectRoot, rel), 'utf8')).map((v) => ({ + ...v, + file: relPosix, + })), + ); + } + return all; +} + +/** stdin 模式:讀「即將寫入的內容」,印違規、有違規 → exit 1。 */ +async function runStdin(relPath) { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const violations = scanSource(relPath, Buffer.concat(chunks).toString('utf8')); + if (violations.length === 0) return 0; + for (const v of violations) { + console.error(`[${v.rule}] ${relPath}(新寫入的第 ${v.line} 行):${v.text}`); + console.error(` → ${v.message}`); + } + return 1; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + if (process.argv[2] === '--stdin') { + process.exit(await runStdin(process.argv[3] ?? 'src/unknown.ts')); + } + const projectRoot = process.argv[2] ?? process.cwd(); + const violations = scanProject(projectRoot); + if (violations.length === 0) { + console.log('✓ 租戶來源檢查通過:資料面 owner_id 全部來自 src/lib/tenant.ts'); + process.exit(0); + } + console.error('❌ 租戶來源檢查失敗(Arcrun#108 的閘)\n'); + for (const v of violations) { + console.error(` [${v.rule}] ${v.file}:${v.line}`); + console.error(` ${v.text}`); + console.error(` → ${v.message}\n`); + } + process.exit(1); +} diff --git a/cypher-executor/scripts/tenant-source-rules.mjs b/cypher-executor/scripts/tenant-source-rules.mjs new file mode 100644 index 0000000..76703a9 --- /dev/null +++ b/cypher-executor/scripts/tenant-source-rules.mjs @@ -0,0 +1,142 @@ +/** + * 「靜態租戶字串不得用於資料面過濾」— 機械閘(Arcrun#108)。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 為什麼要有這道閘 + * ───────────────────────────────────────────────────────────────────────────── + * 同一句話已經寫錯兩次: + * #105 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` + * #108 `portalTenant(env) = env.CONSOLE_TENANT || "leo"` + * 兩次都是「拿一個部署環境變數的字面預設值,當成使用者資料的歸屬」。規則早就在(rule 07 + * 薄殼、design §3.3 租戶不下發),但**沒有任何機制會擋**,所以它每隔幾週就長回來一次。 + * leo 2026-08-12:「做一個平台要減少 hotfix。」⇒ 修掉 bug 不算完成,要留下會擋的東西。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 判準:看「有沒有在做那件事」,不是看「有沒有出現那個詞」 + * ───────────────────────────────────────────────────────────────────────────── + * 誤攔比漏攔更容易殺死一道閘(被擋煩了就有人把它關掉),所以三條規則全部盯**行為**: + * + * T1 租戶環境變數只有一個產地 + * `env.CONSOLE_TENANT` / `env.ARCRUN_NAMESPACE` 只能在 src/lib/tenant.ts 被讀取。 + * 盯的是「你在把部署設定讀成身分」這個動作本身。註解裡寫這兩個字不算(只看 `env.X` 取值)。 + * + * T2 資料面租戶識別不得憑空捏造 + * `as TenantId` 只能出現在 src/lib/tenant.ts,且不得套在字面字串上。 + * 盯的是「繞過唯一產地自己造一個租戶」。 + * + * T3 帳號層字串不得流進知識資料面 + * 同一行同時「在組 owner_id」且「值來自 portalTenant()/accountTenant()」→ 擋。 + * 這正是 #108 那一行的形狀:`owner_id=${encodeURIComponent(portalTenant(c.env))}`。 + * `owner_id: ns`(帳號子 namespace,合法)不命中;`x.owner_id` 這種讀取也不命中。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 這道閘自己要能被測試 + * ───────────────────────────────────────────────────────────────────────────── + * 核心是純函式 `scanSource(relPath, text)`(不碰檔案系統),測試餵好例子/壞例子驗它會不會叫 + * (tests/tenant-gate.test.ts)——當晚有一道閘連讀自己的原始碼都擋,導致沒人驗得了它。 + * 本檔只掃 `src/`,測試與 fixture 都不在掃描範圍內,所以**不會擋到自己**。 + * + * 本檔是**純規則**(零 node 相依),所以 Workers runtime 的 vitest 也 import 得動; + * 走檔案系統的那半在 check-tenant-source.mjs。 + */ + +/** 唯一允許產出租戶識別的檔案(相對 cypher-executor/)。 */ +export const TENANT_SOURCE_FILE = 'src/lib/tenant.ts'; +/** 只宣告型別、不取值的檔案(`CONSOLE_TENANT?: string` 這種)。 */ +const TYPE_DECL_FILES = new Set(['src/types.ts']); + +/** 被視為「租戶來源」的環境變數——讀它們=在決定使用者資料的歸屬。 */ +const TENANT_ENV_VARS = ['CONSOLE_TENANT', 'ARCRUN_NAMESPACE']; + +/** 帳號層租戶字串的取得方式(回的是 string 不是 TenantId,不得用於知識資料面)。 */ +const ACCOUNT_TENANT_CALLS = ['portalTenant(', 'accountTenant(']; + +const ENV_READ = new RegExp(String.raw`\benv\s*\.\s*(${TENANT_ENV_VARS.join('|')})\b`); +const AS_TENANT_ID = /\bas\s+TenantId\b/; +const LITERAL_AS_TENANT_ID = /(['"`][^'"`]*['"`])\s*as\s+TenantId\b/; + +/** + * 「這一行在組 owner_id 嗎?」——**構造**才算,**讀取**不算。 + * 算:`owner_id=` 出現在字串/樣板裡、`owner_id:` 當成物件屬性在賦值 + * 不算:`x.owner_id`(讀)、`owner_id?:`(型別宣告)、`owner_id` 單獨出現在註解句子裡 + */ +function buildsOwnerFilter(line) { + const code = stripComment(line); + if (!code.includes('owner_id')) return false; + if (/owner_id\s*=/.test(code) && !/[.\w]owner_id\s*=/.test(code)) return true; // `?owner_id=` / `owner_id=${...}` + if (/(^|[^.\w])owner_id\s*:/.test(code) && !/owner_id\s*\?\s*:/.test(code)) return true; // `owner_id: X` + return false; +} + +/** 去掉行末 `//` 註解(不處理跨行 /* *\/——那種行本來就不含可執行的取值)。 */ +function stripComment(line) { + const i = line.indexOf('//'); + return i === -1 ? line : line.slice(0, i); +} + +/** 整行是註解?(`//` 開頭或位於 JSDoc 區塊的 ` *` 行) */ +function isCommentLine(line) { + const t = line.trim(); + return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*'); +} + +/** + * 掃一份原始碼,回傳違規清單(純函式,測試直接餵字串)。 + * @param {string} relPath 相對 cypher-executor/ 的路徑,例如 'src/routes/portal-data.ts' + * @param {string} text 檔案內容 + * @returns {{rule: string, line: number, text: string, message: string}[]} + */ +export function scanSource(relPath, text) { + const rel = relPath.split('\\').join('/'); + const violations = []; + const lines = text.split('\n'); + + lines.forEach((line, idx) => { + const n = idx + 1; + const push = (rule, message) => + violations.push({ rule, line: n, text: line.trim(), message }); + + if (isCommentLine(line)) return; + const code = stripComment(line); + + // T1:租戶環境變數只有一個產地 + if (rel !== TENANT_SOURCE_FILE && !TYPE_DECL_FILES.has(rel) && ENV_READ.test(code)) { + push( + 'T1', + `租戶環境變數只能在 ${TENANT_SOURCE_FILE} 讀取。` + + '在別處讀它=又一次「身分來自環境變數」(#105/#108 同形),' + + '請改呼叫 knowledgeOwner(env)(知識資料面)或 accountTenant(env)(帳號層)。', + ); + } + + // T2:資料面租戶識別不得憑空捏造 + if (AS_TENANT_ID.test(code)) { + if (rel !== TENANT_SOURCE_FILE) { + push( + 'T2', + `TenantId 只能由 ${TENANT_SOURCE_FILE} 產生。自己 cast 一個等於繞過唯一產地——` + + '請用 knowledgeOwner(env) 或 tenantFromApiKey(header)。', + ); + } else if (LITERAL_AS_TENANT_ID.test(code)) { + push( + 'T2', + '不得把**字面字串**當成租戶識別(那就是 `|| "leo"` 那個預設值的原形)。' + + '解析不到請丟 TenantUnresolvedError,誠實說讀不到。', + ); + } + } + + // T3:帳號層字串不得流進知識資料面 + if (buildsOwnerFilter(line) && ACCOUNT_TENANT_CALLS.some((fn) => code.includes(fn))) { + push( + 'T3', + '這一行拿**帳號層**租戶字串去組知識資料面的 owner_id 過濾——' + + '正是 #108 那一行(1854 條三元組被過濾成 0)。' + + '知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()。', + ); + } + }); + + return violations; +} + diff --git a/cypher-executor/src/lib/tenant.ts b/cypher-executor/src/lib/tenant.ts new file mode 100644 index 0000000..f82c126 --- /dev/null +++ b/cypher-executor/src/lib/tenant.ts @@ -0,0 +1,144 @@ +/** + * 租戶字串的**唯一產地**(Arcrun#108)。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 這個檔案存在的理由(不是為了整潔,是為了不再犯同一個錯) + * ───────────────────────────────────────────────────────────────────────────── + * #105:`ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` ——身分來自環境變數。 + * #108:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` ——同一句話換一個檔案。 + * + * 兩次的形狀一模一樣:**「這筆資料是誰的」與「這個請求是誰」來自兩個可以各自漂移的地方**。 + * leo 的知識在 `owner_id=bfezv28v`(08-11 回灌時定的名,也就是他 `~/.arcrun/config.yaml` + * 的 `api_key`、小幫手上傳時帶的 `X-Arcrun-API-Key`),而 cypher 拿 repo 預設值 `"leo"` + * 去過濾 ⇒ 1854 條三元組被過濾成 0,畫面卻只寫「沒有庫」。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 定案:租戶字串從哪裡來 + * ───────────────────────────────────────────────────────────────────────────── + * **從「寫入這批知識的那一方」來,而不是從一份手抄的環境變數預設值來。** + * + * 寫入端只有一個真相源:使用者 `~/.arcrun/config.yaml` 的 `api_key`(=實例 namespace)。 + * CLI 用它 push workflow(`{ns}:wf:*`)、小幫手用它上傳知識(`owner_id=ns`)、 + * MCP 用它當 Bearer。**讀取端必須用同一個值**,否則讀寫兩端各說各話。 + * 所以 `acr init/update` 把它注入成 `ARCRUN_NAMESPACE`(cli/src/lib/deploy.ts, + * 與 CF_ACCOUNT_ID / WORKER_SUBDOMAIN / MULTI_TENANT 同一批 CLI 管理值)—— + * 它不是「使用者要自己維護的設定」,是**從既有真相源導出的值**,因此不會漂。 + * + * 那為什麼不像 #105 一樣「掛在登入者身上」?因為在這個架構裡租戶**不是**每人一個: + * portal 帳號共用同一台實例的知識庫(design D-2,帳號自己住 `{tenant}::portal` 子 + * namespace),帳號之間的差別是 `libraries` 權限,不是 owner_id。把 owner_id 複製一份 + * 到每個帳號上,只會多一個可以各自過期的副本——那正是本票的病,不是解藥。 + * #105 真正的教訓不是「一律搬到帳號上」,而是: + * **過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、而且要能被機械驗證。** + * 這三件事就是本檔在做的事。 + * + * ───────────────────────────────────────────────────────────────────────────── + * 型別即閘(`TenantId`) + * ───────────────────────────────────────────────────────────────────────────── + * `TenantId` 是 branded string,**只能**由本檔產生(`knowledgeOwner` / `tenantFromApiKey`)。 + * 所有資料面 owner_id 過濾一律經 `ownerQuery()` / `ownerField()`,而那兩支只吃 `TenantId` + * ⇒ 想把「隨手一個 env 字串」拿去過濾,`tsc` 當場就不給過。 + * + * 配套的機械檢查在 `scripts/check-tenant-source.mjs`(測試 `tests/tenant-gate.test.ts` + * 會同時驗「repo 現況乾淨」與「這道閘真的擋得住壞例子」)。 + */ +import type { Bindings } from '../types'; + +/** + * 可以拿去做資料面過濾的租戶識別。 + * + * branded type:外面拿不到建構子,只能從本檔的兩支 minter 取得—— + * 一支從實例 namespace 來(`knowledgeOwner`),一支從請求本身來(`tenantFromApiKey`)。 + * 兩支都不含字面預設值。 + */ +export type TenantId = string & { readonly __tenantId: unique symbol }; + +/** 實例 namespace 解析不出來 → 誠實炸掉,不拿預設值當答案(#100「讀不到就說讀不到」同源)。 */ +export class TenantUnresolvedError extends Error { + constructor(message: string) { + super(message); + this.name = 'TenantUnresolvedError'; + } +} + +/** + * 這台實例的**知識 owner_id**——三元組 / entries / records / 藏書地圖 / 工作流 KV + * 全部掛在這個字串底下,由 CLI、小幫手、MCP 寫入時決定。 + * + * 解析順序(**沒有字面預設值**): + * 1. `ARCRUN_NAMESPACE`——`acr init/update` 從 `~/.arcrun/config.yaml` 的 `api_key` 注入。 + * 這是寫入端用的那個值本身,因此永遠對得上。 + * 2. `CONSOLE_TENANT`——官方 prod(`cypher.arcrun.dev`)與 #108 之前部署的實例走這條。 + * 官方 prod 的知識確實寫在 `leo` 底下,所以對它而言這是正解;對跑過 `acr update` + * 的 self-hosted 實例,第 1 條會先命中。 + * 3. 兩個都沒有 → **丟 TenantUnresolvedError**。不回 `"leo"`:那個預設值正是把 + * 「這台機器沒設定」偽裝成「你沒有資料」的元凶。 + */ +export function knowledgeOwner(env: Bindings): TenantId { + const injected = (env.ARCRUN_NAMESPACE ?? '').trim(); + if (injected) return injected as TenantId; + const legacy = (env.CONSOLE_TENANT ?? '').trim(); + if (legacy) return legacy as TenantId; + throw new TenantUnresolvedError( + '這個部署沒有知識命名空間(ARCRUN_NAMESPACE / CONSOLE_TENANT 都沒設)——' + + '不知道要去哪一格找資料。請跑 `acr update` 讓它從你的 ~/.arcrun/config.yaml 注入。', + ); +} + +/** + * 請求自帶的租戶(`X-Arcrun-API-Key`=namespace 明碼,self-hosted 身分模型)。 + * + * 這條路的租戶來自**請求本身**而不是環境變數,本來就沒有 #105/#108 的漂移問題; + * 收進本檔只是為了讓「所有 owner_id 過濾值都是 TenantId」這條型別閘沒有破口。 + * 空字串不給過——沒有身分就不該有查詢範圍。 + */ +export function tenantFromApiKey(apiKey: string): TenantId { + const key = (apiKey ?? '').trim(); + if (!key) throw new TenantUnresolvedError('缺少 X-Arcrun-API-Key,無法決定查詢範圍'); + return key as TenantId; +} + +/** + * 帳號子 namespace 用的租戶字串(design D-2:帳號資料住 `{tenant}::portal`)。 + * + * 🔴 **回傳的是 `string`,不是 `TenantId`——這是刻意的**:帳號那批資料是 cypher 自己 + * 寫進去的(用的就是這個值),所以它自洽;但它**不可以**拿去過濾知識資料面, + * 否則就是把 #108 再犯一次。型別上不給過,不必靠人記得。 + * + * 保留 `'leo'` 預設值是為了不動既有帳號的落點(改了會讓舊實例登不進去)。 + */ +export function accountTenant(env: Bindings): string { + return env.CONSOLE_TENANT || 'leo'; +} + +/** + * KBDB query string 的 owner_id 過濾片段——**資料面過濾的唯一入口之一**。 + * 用法:`kbdbFetch(env, `/map?${ownerQuery(tenant)}`)` + */ +export function ownerQuery(tenant: TenantId): string { + return `owner_id=${encodeURIComponent(tenant)}`; +} + +/** + * 要放進 JSON body / URLSearchParams 的 owner_id 值——**資料面過濾的唯一入口之一**。 + * 用法:`JSON.stringify({ owner_id: ownerField(tenant) })` + */ +export function ownerField(tenant: TenantId): string { + return tenant; +} + +/** + * 🔴 **刻意不帶租戶範圍**的查詢片段(KBDB 慣例:`owner_id` 空值=不過濾)。 + * + * 唯一合法用途:#100 的普查——「本租戶查到 0 筆」時再問一次「整台實例到底有沒有」, + * 用來分辨「查不到」與「沒有」。**回傳的是統計數字,不是任何人的內容**; + * 拿它去撈實際資料就是跨租戶外洩。名字取得這麼長就是要讓 review 一眼看見。 + */ +export function censusQueryAllTenants(): string { + return 'owner_id='; +} + +/** 逐筆核對歸屬(讀回來的 record/entry 是不是這個租戶的)。缺欄位一律視為不是。 */ +export function isOwnedBy(value: unknown, tenant: TenantId): boolean { + return typeof value === 'string' && value === (tenant as string); +} diff --git a/cypher-executor/src/routes/console-auth.ts b/cypher-executor/src/routes/console-auth.ts index 9a95cc6..5dcb12b 100644 --- a/cypher-executor/src/routes/console-auth.ts +++ b/cypher-executor/src/routes/console-auth.ts @@ -35,6 +35,8 @@ import { readAuthStore, type AuthConsoleRecord, } from '../lib/portal-auth-store'; +// Arcrun#108:租戶字串唯一產地。 +import { knowledgeOwner } from '../lib/tenant'; export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>(); @@ -79,8 +81,17 @@ async function hashPassword(password: string, salt: string): Promise { return h; } +/** + * console 登入後下發給前端當 api_key 用的租戶字串(舊 console 的設計,與 portal 不同: + * portal 絕不下發,console 會)。 + * + * Arcrun#108:這是**知識資料面**的 owner_id(前端拿它直打 `/kbdb/*`),所以必須與寫入端 + * (CLI/小幫手/MCP 用的實例 namespace)同源。以前直接讀 `env.CONSOLE_TENANT || 'leo'` + * ⇒ 與 portal 同一個病:資料在 `bfezv28v`、過濾拿 `leo`,console 首頁的藏書地圖同樣是空的。 + * 現在走唯一產地 `lib/tenant.ts`。 + */ function tenantOf(c: { env: Bindings }): string { - return c.env.CONSOLE_TENANT || 'leo'; + return knowledgeOwner(c.env); } // ── D61:帳密的家 ───────────────────────────────────────────────────────────── diff --git a/cypher-executor/src/routes/console-dashboard.ts b/cypher-executor/src/routes/console-dashboard.ts index 82af101..b7f0573 100644 --- a/cypher-executor/src/routes/console-dashboard.ts +++ b/cypher-executor/src/routes/console-dashboard.ts @@ -72,6 +72,9 @@ import { taipeiDayKey, } from '../lib/console-dashboard-model'; import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model'; +// Arcrun#108:租戶字串唯一產地。console 首頁的規模數字/藏書地圖也曾因為拿 CONSOLE_TENANT +// 過濾而看不到自己的資料——與 portal 同一個病,同一個修法。 +import { knowledgeOwner } from '../lib/tenant'; export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>(); @@ -265,7 +268,7 @@ export async function cachedGiteaSprint( // GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值) consoleDashboardRouter.get('/console/dashboard-data', async (c) => { - const tenant = c.env.CONSOLE_TENANT || 'leo'; + const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts) const now = Date.now(); const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env); const graphUrl = graphBase(c.env); @@ -451,7 +454,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => { // limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。 // 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。 consoleDashboardRouter.get('/console/kb-scale-data', async (c) => { - const tenant = c.env.CONSOLE_TENANT || 'leo'; + const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts) const { base, headers } = kbdbBase(c.env); const now = Date.now(); const [wikiCards, tripletTotal, embedStatus] = await Promise.all([ @@ -499,7 +502,7 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => { const ok = await validateConsoleSession(c.env, c.req.header('authorization')); if (!ok) return c.json({ error: '需要登入(console session)' }, 401); - const tenant = c.env.CONSOLE_TENANT || 'leo'; + const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts) const [todoEntries, inboxEntries] = await Promise.all([ fetchEntries(c.env, tenant, 'todo', 500), fetchEntries(c.env, tenant, 'inbox', 200), @@ -530,7 +533,7 @@ consoleDashboardRouter.post('/console/triage-check', async (c) => { if (!entryId) return c.json({ error: 'entry_id 必填' }, 400); const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check'; - const tenant = c.env.CONSOLE_TENANT || 'leo'; + const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts) const { base, headers } = kbdbBase(c.env); // 先 GET 原 entry(整串回寫的前提),順便守兩道邊界: diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 401f7ef..870c94a 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -23,7 +23,10 @@ import { Hono } from 'hono'; import type { Context } from 'hono'; import type { Bindings } from '../types'; -import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal'; +import { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal'; +// Arcrun#108:知識資料面的租戶字串只有一個產地(lib/tenant.ts)。這裡刻意**不再** import +// portalTenant——它是帳號層的值(回 string 不是 TenantId),拿來過濾知識就是本票的病。 +import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant'; import { graphBase, graphHeaders } from './kbdb-proxy'; import { executeWebhookGraph } from '../actions/webhook-handlers'; @@ -40,7 +43,9 @@ export const portalDataRouter = new Hono<{ Bindings: Bindings }>(); /** 讀 tenant 的 named workflow graph(`{tenant}:wf:{name}`)。不存在/壞 record → null。 */ async function getTenantWorkflowGraph(env: Bindings, name: string): Promise | null> { - const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text'); + // #108:workflow 是 CLI `acr push` 用實例 namespace 寫進來的(`{ns}:wf:*`), + // 所以讀的時候也要用同一個 namespace,不是帳號層那個字串。 + const raw = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, 'text'); if (!raw) return null; try { const rec = JSON.parse(raw) as { graph?: Record }; @@ -190,9 +195,11 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri * 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端 * `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。 */ -async function tripletCount(env: Bindings, owner: string): Promise { +async function tripletCount(env: Bindings, owner: TenantId | null): Promise { try { - const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`); + // owner=null = 普查全庫(#100 用來分辨「查不到」與「沒有」)。這是唯一一個 + // 刻意不帶租戶範圍的查詢,因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。 + const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`); if (!res.ok) return null; const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null; if (!body || !Array.isArray(body.stats)) return null; @@ -219,16 +226,16 @@ async function tripletCount(env: Bindings, owner: string): Promise0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到 * owned=null → 讀不到 → 畫面說讀不到 */ -async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> { +async function tripletCensus(env: Bindings, tenant: TenantId): Promise<{ owned: number | null; any: number | null }> { const owned = await tripletCount(env, tenant); if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次 - return { owned, any: await tripletCount(env, '') }; + return { owned, any: await tripletCount(env, null) }; } /** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */ -async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise { +async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise { try { - const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`); + const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`); if (!res.ok) return null; const body = (await res.json().catch(() => null)) as { records?: { values?: Record }[] } | null; if (!body || !Array.isArray(body.records)) return null; @@ -263,7 +270,7 @@ portalDataRouter.get('/portal/data/search', (c) => return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' }); } - const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) }); + const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) }); if (!libraries.includes('*')) params.set('library', libraries.join(',')); // 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死, // caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。 @@ -334,7 +341,7 @@ portalDataRouter.get('/portal/data/entries/:id', (c) => const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } }; const entry = body.entry; if (!entry) return notFound(c); - if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c); + if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c); if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c); return c.json({ success: true, entry }); }), @@ -359,7 +366,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => const nodeName = normalizeCjkQuery(c.req.param('name')); // ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant) - const tenant = portalTenant(c.env); + const tenant = knowledgeOwner(c.env); const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors'); if (wfGraph) { const depthRaw = c.req.query('depth') ?? ''; @@ -420,9 +427,9 @@ portalDataRouter.get('/portal/data/graph/overview', (c) => if (!(await hasGraphAccess(c.env, libraries))) { return c.json({ error: '無知識圖譜檢視權限' }, 403); } - const tenant = portalTenant(c.env); + const tenant = knowledgeOwner(c.env); const [res, census] = await Promise.all([ - kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`), + kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant)}&limit=500`), tripletCensus(c.env, tenant), ]); const tripletsTotal = census.owned; @@ -505,7 +512,7 @@ portalDataRouter.get('/portal/data/chat', (c) => wfGraph, { question }, 'rag_chat', - portalTenant(c.env), + knowledgeOwner(c.env), c.executionCtx, ); if (!result.success) { @@ -611,7 +618,7 @@ portalDataRouter.get('/portal/data/workflows', (c) => // 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。 // 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop), // 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。 - const tenant = portalTenant(c.env); + const tenant = knowledgeOwner(c.env); const prefix = `${tenant}:wf:`; const list = await c.env.WEBHOOKS.list({ prefix }); const workflows = await Promise.all( @@ -637,7 +644,7 @@ portalDataRouter.get('/portal/data/workflows', (c) => let last_execution: { timestamp: string; verdict?: string } | null = null; const execRes = await kbdbFetch( c.env, - `/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`, + `/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`, ); const execBody = await execRes.json().catch(() => null) as { success?: boolean; @@ -691,25 +698,42 @@ function recordLibrary(values: Record | undefined): string | nu /** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */ function canReadRecord( rec: { values?: Record; owner_id?: string | null }, - tenant: string, + tenant: TenantId, libraries: string[], ): boolean { - if ((rec.owner_id ?? '') !== tenant) return false; + if (!isOwnedBy(rec.owner_id, tenant)) return false; const lib = recordLibrary(rec.values); return lib === null || canReadLibrary(libraries, lib); } // GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。 // KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。 +// +// 🔴 Arcrun#108:一張空地圖有四種成因,**判準留在 server,不留給前端猜** +// (沿 #100 總圖那條「讀不到就說讀不到」,同一套 census 機制): +// no_library_grant :這個帳號一個庫都沒被授權 → 是權限問題,不是資料問題 +// filtered_out :實例有庫,但都不在這個帳號的權限內 → 正常且正確的隔離 +// confirmed_empty :實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」 +// scope_mismatch :實例有三元組,但本命名空間一條都撈不到 → **命名空間對不上** +// (就是本票:1854 條在 bfezv28v,卻拿 "leo" 去過濾) +// scope_mismatch 這一格以前不存在,所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。 +// +// ⚠️ 回應**絕不含租戶字串**(design §3.3 紅線:前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*)。 +// 只回代碼與數字,文字說明講「請通知管理員」,命名空間本身不下發。 portalDataRouter.get('/portal/data/map', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) { - return c.json({ success: true, libraries: [], count: 0, note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' }); + return c.json({ + success: true, libraries: [], count: 0, + empty_confirmed: true, empty_reason: 'no_library_grant', + note: '此帳號尚未被授權任何知識庫,請聯絡管理員。', + }); } - const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`); + const tenant = knowledgeOwner(c.env); + const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`); if (!res.ok) { return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } @@ -720,7 +744,42 @@ portalDataRouter.get('/portal/data/map', (c) => const allowed = body.libraries.filter( (l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library), ); - return c.json({ success: true, libraries: allowed, count: allowed.length }); + if (allowed.length > 0) { + return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null }); + } + // 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由,不猜。 + if (body.libraries.length > 0) { + // 命名空間對得上(撈得到庫),只是這個帳號沒有那些庫的權限=隔離正常運作。 + return c.json({ + success: true, libraries: [], count: 0, + empty_confirmed: true, empty_reason: 'filtered_out', + note: '這個帳號目前沒有任何知識庫的檢視權限,請聯絡管理員開通。', + }); + } + const census = await tripletCensus(c.env, tenant); + if (census.owned === null || (census.owned === 0 && census.any === null)) { + return c.json({ + success: true, libraries: [], count: 0, + empty_confirmed: false, empty_reason: 'unreadable', + note: '讀不到知識庫的統計,無法確認庫裡有沒有東西——這不是「還沒有知識」,是這次讀取失敗。請稍後重整或通知管理員。', + }); + } + if (census.owned === 0 && (census.any ?? 0) > 0) { + return c.json({ + success: true, libraries: [], count: 0, + empty_confirmed: false, empty_reason: 'scope_mismatch', + instance_triplet_count: census.any, + note: + `讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` + + '這不是「還沒有知識」,不用去重新上傳;比較像知識的歸屬命名空間對不上。' + + '請通知管理員跑一次 `acr update`(會把你安裝時的命名空間同步給雲端),或檢查 ARCRUN_NAMESPACE 設定。', + }); + } + return c.json({ + success: true, libraries: [], count: 0, + empty_confirmed: true, empty_reason: 'confirmed_empty', + note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。', + }); }), ); @@ -734,7 +793,7 @@ portalDataRouter.get('/portal/data/map/:library', (c) => if (!canReadLibrary(libraries, library)) return notFound(c); const res = await kbdbFetch( c.env, - `/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`, + `/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`, ); if (res.status === 404) return notFound(c); if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); @@ -775,7 +834,7 @@ portalDataRouter.post('/portal/data/templates', (c) => name: body.name, slots: body.slots, description: typeof body.description === 'string' ? body.description : undefined, - created_by: portalTenant(c.env), + created_by: knowledgeOwner(c.env), }), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); @@ -790,10 +849,10 @@ portalDataRouter.get('/portal/data/records/by-template/:template', (c) => if (!auth.ok) return auth.res; const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 }); - const tenant = portalTenant(c.env); + const tenant = knowledgeOwner(c.env); const res = await kbdbFetch( c.env, - `/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(tenant)}`, + `/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`, ); if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502); const body = (await res.json().catch(() => null)) as @@ -824,7 +883,7 @@ portalDataRouter.get('/portal/data/records/:recordId', (c) => | null; const record = body?.record; if (!record) return notFound(c); - if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c); + if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c); return c.json({ success: true, record }); }), ); @@ -856,7 +915,7 @@ portalDataRouter.post('/portal/data/records', (c) => const res = await kbdbFetch(c.env, '/records', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) }), + body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }), }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }), @@ -884,7 +943,7 @@ portalDataRouter.get('/portal/data/diagnostics', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; - const tenant = portalTenant(c.env); + const tenant = knowledgeOwner(c.env); const core = await buildDiagnostics(c.env, tenant); return c.json({ generated_at: new Date().toISOString(), diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 973d202..caf656e 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy'; import { validateConsoleSession } from './console-auth'; import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth'; import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds'; +// Arcrun#108:租戶字串只有一個產地(lib/tenant.ts)。帳號面用 accountTenant(普通 string), +// 知識資料面用 knowledgeOwner(TenantId)——型別分家,拿錯編不過。 +import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey, TenantUnresolvedError, type TenantId } from '../lib/tenant'; // arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑, // 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。 import { storeCredential, hasCredential } from './credentials'; @@ -58,14 +61,27 @@ export const LIBRARY_TEMPLATE = 'portal_library'; // ── 基礎 helpers ──────────────────────────────────────────────────────────── -/** 租戶字串(=知識資料的 owner_id)。預設沿 console-auth 同款 'leo'。**只在 server 側使用,永不下發前端**。 */ +/** + * 帳號層的租戶字串(**不是**知識資料的 owner_id,Arcrun#108 拆開)。 + * + * 只用來組帳號子 namespace(`{tenant}::portal`,design D-2)與 cypher 自己寫的設定 + * (extractor_config / credentials 目錄)——那些都是 cypher 用同一個值寫進去的,所以自洽。 + * + * 🔴 **不可以拿它過濾知識資料面**(三元組 / entries / records / 藏書地圖 / 工作流): + * 那批是 CLI/小幫手用實例 namespace 寫的,兩者對不上就是 #108 + * (leo 的 1854 條被 `CONSOLE_TENANT="leo"` 過濾成 0)。資料面請用 + * `lib/tenant.ts` 的 `knowledgeOwner(env)`——它回 `TenantId`,本函式回 `string`, + * 型別上就分得開,不必靠人記得。 + * + * **只在 server 側使用,永不下發前端**。 + */ export function portalTenant(env: Bindings): string { - return env.CONSOLE_TENANT || 'leo'; + return accountTenant(env); } /** 帳號子 namespace(design D-2)。 */ function portalNamespace(env: Bindings): string { - return `${portalTenant(env)}::portal`; + return `${accountTenant(env)}::portal`; } function sessionTtl(env: Bindings): number { @@ -102,6 +118,11 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise< if (e instanceof AuthStoreWriteError) { return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502); } + // Arcrun#108:連「這台實例的知識放在哪一格」都解析不出來 → 誠實講「讀不到」, + // 不拿 repo 預設值當答案然後回一頁空的(那正是本票的病:設定缺失被畫成「你沒有資料」)。 + if (e instanceof TenantUnresolvedError) { + return c.json({ error: e.message, code: 'tenant_unresolved' }, 500); + } if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502); throw e; } @@ -1409,7 +1430,6 @@ portalRouter.post('/portal/daemon/config', (c) => return c.json({ error: 'email 或密碼錯誤' }, 401); } await clearLoginFail(c.env, email); - const tenant = portalTenant(c.env); // t176(leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。 // 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。 // @@ -1422,9 +1442,12 @@ portalRouter.post('/portal/daemon/config', (c) => // // ⚠️ 只拔 LLM 欄位——連線欄位(cypher_url/namespace/library)與本 route 本身照舊, // daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。 + // #108:這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。 + // 以前給的是帳號層字串(CONSOLE_TENANT),與 CLI/MCP 用的實例 namespace 是兩個來源 + // ⇒ 寫進去的地方和讀出來的地方可以各自漂。改成同一個 knowledgeOwner,一台實例一個值。 const daemonCfg: Record = { cypher_url: new URL(c.req.url).origin, - namespace: tenant, + namespace: knowledgeOwner(c.env), library: 'kb', email, instance_name: String(rec.values.display_name ?? ''), @@ -1456,7 +1479,7 @@ portalRouter.post('/portal/admin/chat-key', (c) => const body = (await c.req.json().catch(() => null)) as { key?: string } | null; const key = String(body?.key ?? '').trim(); if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400); - const tenant = portalTenant(c.env); + const tenant = knowledgeOwner(c.env); const kvKey = `${tenant}:wf:rag_chat`; const raw = await c.env.WEBHOOKS.get(kvKey, 'text'); if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404); @@ -1518,8 +1541,8 @@ portalRouter.get('/portal/admin/libraries', (c) => // t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。 // 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。 try { - const tenant = portalTenant(c.env); - const ownerParam = `owner_id=${encodeURIComponent(tenant)}`; + const tenant = knowledgeOwner(c.env); + const ownerParam = ownerQuery(tenant); const [autoRes, cardRes, tripletRes] = await Promise.all([ kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null), kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null), @@ -1712,8 +1735,8 @@ portalRouter.get('/portal/admin/execution-log-retention', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; - const ownerId = portalTenant(c.env); - const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`); + const ownerId = knowledgeOwner(c.env); + const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`); if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`); const data = (await res.json()) as { retention_days?: number | null; default_days?: number }; return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 }); @@ -1732,10 +1755,10 @@ portalRouter.put('/portal/admin/execution-log-retention', (c) => if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) { return c.json({ error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400); } - const ownerId = portalTenant(c.env); + const ownerId = knowledgeOwner(c.env); const res = await kbdbFetch(c.env, '/execution-log/retention', { method: 'PUT', - body: JSON.stringify({ owner_id: ownerId, retention_days: days === undefined ? null : days }), + body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === undefined ? null : days }), }); if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`); const data = (await res.json()) as { retention_days?: number | null }; @@ -1756,10 +1779,10 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) => const confirm = String(body?.confirm ?? '').trim(); if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400); if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400); - const ownerId = portalTenant(c.env); + const ownerId = knowledgeOwner(c.env); const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', { method: 'PATCH', - body: JSON.stringify({ owner_id: ownerId, library: name }), + body: JSON.stringify({ owner_id: ownerField(ownerId), library: name }), }); if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`); const data = (await res.json()) as { deprecated_count?: number }; @@ -1850,16 +1873,19 @@ export interface DiagnosticsCore { notes: string[]; } -/** tenant=owner_id(session 版傳 portalTenant(env);daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */ -export async function buildDiagnostics(env: Bindings, tenant: string): Promise { +/** + * tenant=owner_id(session 版傳 `knowledgeOwner(env)`;daemon 版傳 `tenantFromApiKey(header)`)。 + * #108:型別收成 `TenantId`——診斷檔要是報了另一個命名空間的統計,等於用假數字排查真問題。 + */ +export async function buildDiagnostics(env: Bindings, tenant: TenantId): Promise { const notes: string[] = []; // ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。 let embedding: Record = { checked: false }; try { const [statusRes, selftestRes] = await Promise.all([ - kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`), - kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`), + kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant)}`), + kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant)}`), ]); const statusBody = (await statusRes.json().catch(() => null)) as | { success?: boolean; enabled?: boolean; pending?: number; embedded?: number } @@ -1895,7 +1921,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise []), @@ -1929,7 +1955,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise = { ran: false }; if (library_count === 0 && triplet_count === 0) { try { - const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant, limit: '1' }).toString()}`); + const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant), limit: '1' }).toString()}`); const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null; const total = probeBody?.total ?? 0; library_scope_check = { @@ -1976,7 +2002,8 @@ portalRouter.get('/portal/daemon/diagnostics', (c) => run(c, async () => { const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim(); if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); - const core = await buildDiagnostics(c.env, apiKey); + // 這條路的租戶來自**請求本身**(小幫手帶的 namespace),不是環境變數 → 沒有 #108 的漂移問題。 + const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey)); return c.json({ generated_at: new Date().toISOString(), instance_url: new URL(c.req.url).origin, diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 09470e8..3c4db04 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -91,6 +91,20 @@ export type Bindings = { // console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。 // 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。 CONSOLE_TENANT?: string; + /** + * 這台實例的**知識命名空間**(Arcrun#108)=使用者 `~/.arcrun/config.yaml` 的 `api_key`。 + * + * 由 `acr init/update`(cli/src/lib/deploy.ts CLI_MANAGED_VARS)自動注入,**使用者不必手動維護**: + * 它就是 CLI push workflow(`{ns}:wf:*`)、小幫手上傳知識(`owner_id=ns`)、MCP Bearer + * 用的同一個值 ⇒ 讀取端用它過濾,永遠對得上寫入端。 + * + * 為什麼不沿用 `CONSOLE_TENANT`:那是 repo toml 帶的**官方 prod 值**(`leo`), + * self-hosted 實例的資料根本不在它底下(#108 實撞:1854 條被過濾成 0), + * 而且 `CONSOLE_TENANT` 同時還是帳號子 namespace(`{tenant}::portal`)的組成, + * 改它會讓舊實例登不進去。兩件事拆成兩個 var,各自對應各自的真相源。 + * 解析邏輯只在 `src/lib/tenant.ts`(唯一產地,機械閘看守)。 + */ + ARCRUN_NAMESPACE?: string; // Console 顯示品牌/實例名(Arcrun#21 rebrand,非機密)。只影響 UI 字樣(title/header/logo), // 不影響任何行為。未設 → "Arcrun"(console 是引擎共用件,不寫死產品名)。 // 實例可覆蓋,例:arcrun-rag demo 可設 --var CONSOLE_BRAND:"Arcrun RAG"。 diff --git a/cypher-executor/tests/library-map-scope-108.test.ts b/cypher-executor/tests/library-map-scope-108.test.ts new file mode 100644 index 0000000..741f83d --- /dev/null +++ b/cypher-executor/tests/library-map-scope-108.test.ts @@ -0,0 +1,261 @@ +/** + * Arcrun#108 — 藏書地圖看得到自己的知識(租戶字串來源收斂)。 + * + * 釘住的事實: + * 1. 資料面 owner_id 來自 `knowledgeOwner(env)`:`ARCRUN_NAMESPACE` 優先、`CONSOLE_TENANT` 回退、 + * 兩者皆無 → 丟 TenantUnresolvedError(**沒有 `|| 'leo'` 這種靜默預設值**)。 + * 2. `/portal/data/map` 真的拿那個值去打 KBDB(leo 的情境:ARCRUN_NAMESPACE=bfezv28v + * → 打 `owner_id=bfezv28v` 拿回 9 個庫,而不是打 `owner_id=leo` 拿回 0 個)。 + * 3. **權限沒有被拿掉**:同一份 KBDB 回應,庫權限 ["kb"] 的帳號只看得到 kb。 + * 4. 空地圖分得出四種成因(#100 那條「讀不到就說讀不到」延伸到藏書地圖): + * no_library_grant / filtered_out / scope_mismatch / confirmed_empty。 + * 5. 回應**不含租戶字串**(design §3.3 紅線:前端拿到就能繞過庫過濾直打 /kbdb/*)。 + */ +import { env, fetchMock } from 'cloudflare:test'; +import { beforeAll, afterEach, describe, it, expect } from 'vitest'; +import { knowledgeOwner, accountTenant, TenantUnresolvedError, ownerQuery, censusQueryAllTenants } from '../src/lib/tenant'; +import { portalDataRouter } from '../src/routes/portal-data'; +import type { Bindings } from '../src/types'; + +const KBDB = 'https://kbdb.test'; +/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */ +const LEO_NS = 'bfezv28v'; + +beforeAll(() => { + fetchMock.activate(); + fetchMock.disableNetConnect(); +}); +afterEach(() => fetchMock.assertNoPendingInterceptors()); + +/** + * 直接餵 router 一份 env(不是 SELF.fetch)——`cloudflare:test` 的 `env` 物件改了不會傳進 + * SELF 那個 worker(實測:改 ARCRUN_BUNDLE_VERSION 後 /health 仍回舊值), + * 而本票要驗的正是「換一個命名空間,查詢就跟著換」。Hono router 吃 env 參數, + * 走的是同一支 handler、同一條 KBDB fetch,只有 env 這一項是測試給的。 + */ +const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext; + +async function seedSession(token: string, recordId: string) { + await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId })); +} + +function mockGetRecord(recordId: string, libraries: string) { + fetchMock + .get(KBDB) + .intercept({ path: `/records/${recordId}`, method: 'GET' }) + .reply(200, { + success: true, + record: { + record_id: recordId, + template_id: 'tpl_pu', + values: { + email: 'leo@example.com', + display_name: 'leo', + status: 'active', + role: 'admin', + password_hash: 'pbkdf2-sha256$600000$AA$BB', + libraries, + created_at: '2026-08-12T00:00:00.000Z', + updated_at: '2026-08-12T00:00:00.000Z', + }, + }, + }); +} + +/** 攔 `/map`,同時把「實際被查詢的 owner_id」記下來給斷言用。 */ +function mockMap(libraries: { library: string; triplet_count: number }[], seen: string[]) { + fetchMock + .get(KBDB) + .intercept({ + path: (p: string) => { + if (!p.startsWith('/map')) return false; + seen.push(new URL(p, KBDB).searchParams.get('owner_id') ?? ''); + return true; + }, + method: 'GET', + }) + .reply(200, { success: true, libraries, count: libraries.length }); +} + +function mockTripletStats(match: (ownerId: string) => boolean, tripletCount: number) { + fetchMock + .get(KBDB) + .intercept({ + path: (p: string) => + p.startsWith('/records/triplet-stats') && match(new URL(p, KBDB).searchParams.get('owner_id') ?? ''), + method: 'GET', + }) + .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: tripletCount }] }); +} + +async function getMap(token: string, overrides: Partial = {}) { + const res = await portalDataRouter.fetch( + new Request('http://localhost/portal/data/map', { headers: { authorization: `Bearer ${token}` } }), + { ...env, ...overrides } as Bindings, + ctx, + ); + return { status: res.status, body: (await res.json()) as Record }; +} + +/** undici 的 path matcher 可能被呼叫多次 → 比對前先去重(我們在意的是「查了哪些 owner_id」)。 */ +const distinct = (xs: string[]): string[] => [...new Set(xs)]; + +// ── ① 唯一產地的解析順序 ─────────────────────────────────────────────────────── + +describe('knowledgeOwner:租戶字串只有一個產地,且沒有靜默預設值', () => { + it('ARCRUN_NAMESPACE 優先(=acr update 從 ~/.arcrun/config.yaml 的 api_key 注入的那個值)', () => { + expect(knowledgeOwner({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe(LEO_NS); + }); + + it('沒注入 → 回退 CONSOLE_TENANT(官方 prod 與尚未 acr update 的實例,行為一字不變)', () => { + expect(knowledgeOwner({ CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo'); + }); + + it('空字串不算數(部署把 var 設成空字串 ≠ 有設定)', () => { + expect(knowledgeOwner({ ARCRUN_NAMESPACE: ' ', CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo'); + }); + + it('兩個都沒有 → 丟 TenantUnresolvedError,**不回 "leo"**(靜默預設值正是本票的病)', () => { + expect(() => knowledgeOwner({} as Bindings)).toThrow(TenantUnresolvedError); + }); + + it('帳號層 accountTenant 不受影響(改它會讓舊實例登不進去,所以刻意不動)', () => { + expect(accountTenant({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo'); + expect(accountTenant({} as Bindings)).toBe('leo'); + }); + + it('過濾片段只有兩種形狀:帶租戶的 ownerQuery,與明著喊全庫的普查', () => { + expect(ownerQuery(knowledgeOwner({ ARCRUN_NAMESPACE: 'a b' } as Bindings))).toBe('owner_id=a%20b'); + expect(censusQueryAllTenants()).toBe('owner_id='); + }); +}); + +// ── ② 地圖真的用那個 owner_id 去查 ───────────────────────────────────────────── + +describe('GET /portal/data/map — leo 的情境(1854 條 → 看得到,不是 0 個庫)', () => { + it('注入 ARCRUN_NAMESPACE 後,KBDB 收到的 owner_id 是它,而且庫都回得來', async () => { + await seedSession('t-map-1', 'rec_leo'); + mockGetRecord('rec_leo', '["*"]'); + const seen: string[] = []; + mockMap( + [ + { library: 'kb', triplet_count: 1851 }, + { library: 'general', triplet_count: 3 }, + ], + seen, + ); + + const { status, body } = await getMap('t-map-1', { ARCRUN_NAMESPACE: LEO_NS }); + expect(status).toBe(200); + expect(distinct(seen)).toEqual([LEO_NS]); // ← 這一行就是本票:以前送出去的是 'leo' + expect(body.count).toBe(2); + expect((body.libraries as { library: string; triplet_count: number }[]).map((l) => l.triplet_count)) + .toEqual([1851, 3]); + expect(body.empty_reason).toBeNull(); + }); + + it('回應不含租戶字串(前端拿到就能繞過庫過濾直打 /kbdb/*——design §3.3 紅線)', async () => { + await seedSession('t-map-2', 'rec_leo2'); + mockGetRecord('rec_leo2', '["*"]'); + mockMap([{ library: 'kb', triplet_count: 1851 }], []); + + const { body } = await getMap('t-map-2', { ARCRUN_NAMESPACE: LEO_NS }); + expect(JSON.stringify(body)).not.toContain(LEO_NS); + expect(JSON.stringify(body)).not.toContain('ARCRUN_NAMESPACE'); + }); + + it('沒注入時沿用 CONSOLE_TENANT(未跑 acr update 的實例行為不變,這次改動對它是惰性的)', async () => { + await seedSession('t-map-3', 'rec_leo3'); + mockGetRecord('rec_leo3', '["*"]'); + const seen: string[] = []; + mockMap([{ library: 'kb', triplet_count: 1 }], seen); + + await getMap('t-map-3'); + expect(distinct(seen)).toEqual(['leo']); // wrangler.test.toml CONSOLE_TENANT + }); +}); + +// ── ③ 權限沒有被拿掉(紅線:修這題不准把 owner_id 過濾或庫過濾拆掉)────────────── + +describe('權限:只被授權部分庫的帳號,只看得到那幾個庫', () => { + it('libraries=["kb"] → 同一份 KBDB 回應裡只剩 kb', async () => { + await seedSession('t-perm-1', 'rec_partial'); + mockGetRecord('rec_partial', '["kb"]'); + mockMap( + [ + { library: 'kb', triplet_count: 1851 }, + { library: 'finance', triplet_count: 42 }, + { library: 'general', triplet_count: 3 }, + ], + [], + ); + + const { body } = await getMap('t-perm-1', { ARCRUN_NAMESPACE: LEO_NS }); + expect((body.libraries as { library: string }[]).map((l) => l.library)).toEqual(['kb']); + expect(body.count).toBe(1); + }); + + it('一個庫都沒被授權 → 不打 KBDB,誠實說是權限問題', async () => { + await seedSession('t-perm-2', 'rec_nolib'); + mockGetRecord('rec_nolib', '[]'); + const { body } = await getMap('t-perm-2'); // 沒有 mockMap:打了就會 assertNoPendingInterceptors 失敗 + expect(body.count).toBe(0); + expect(body.empty_reason).toBe('no_library_grant'); + expect(body.empty_confirmed).toBe(true); + }); + + it('實例有庫但都不在權限內 → filtered_out(是隔離正常,不是資料不見)', async () => { + await seedSession('t-perm-3', 'rec_other'); + mockGetRecord('rec_other', '["finance"]'); + mockMap([{ library: 'kb', triplet_count: 1851 }], []); + + const { body } = await getMap('t-perm-3', { ARCRUN_NAMESPACE: LEO_NS }); + expect(body.empty_reason).toBe('filtered_out'); + expect(body.empty_confirmed).toBe(true); + }); +}); + +// ── ④ 空地圖的四種成因分得出來(不再把設定錯誤畫成「你沒有資料」)──────────────── + +describe('空地圖:分得出「讀不到」與「沒有」', () => { + it('命名空間對不上(本租戶 0、整台實例有)→ scope_mismatch,並指出該跑 acr update', async () => { + await seedSession('t-empty-1', 'rec_e1'); + mockGetRecord('rec_e1', '["*"]'); + mockMap([], []); + mockTripletStats((o) => o === 'wrong-ns', 0); // 本租戶 0 + mockTripletStats((o) => o === '', 1854); // 全庫普查:有 1854 條 + + const { body } = await getMap('t-empty-1', { ARCRUN_NAMESPACE: 'wrong-ns' }); + expect(body.empty_reason).toBe('scope_mismatch'); + expect(body.empty_confirmed).toBe(false); // 🔴 絕不宣稱「你沒有資料」 + expect(body.instance_triplet_count).toBe(1854); + expect(String(body.note)).toContain('acr update'); + expect(JSON.stringify(body)).not.toContain('wrong-ns'); // 仍不下發租戶字串 + }); + + it('整台實例真的空 → confirmed_empty(此時、也只有此時,才准說「還沒有內容」)', async () => { + await seedSession('t-empty-2', 'rec_e2'); + mockGetRecord('rec_e2', '["*"]'); + mockMap([], []); + mockTripletStats((o) => o === 'leo', 0); + mockTripletStats((o) => o === '', 0); + + const { body } = await getMap('t-empty-2'); + expect(body.empty_reason).toBe('confirmed_empty'); + expect(body.empty_confirmed).toBe(true); + }); + + it('連統計都讀不到 → unreadable(不假裝是空庫)', async () => { + await seedSession('t-empty-3', 'rec_e3'); + mockGetRecord('rec_e3', '["*"]'); + mockMap([], []); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(500, { error: 'boom' }); + + const { body } = await getMap('t-empty-3'); + expect(body.empty_reason).toBe('unreadable'); + expect(body.empty_confirmed).toBe(false); + }); +}); diff --git a/cypher-executor/tests/tenant-gate.test.ts b/cypher-executor/tests/tenant-gate.test.ts new file mode 100644 index 0000000..7d797f8 --- /dev/null +++ b/cypher-executor/tests/tenant-gate.test.ts @@ -0,0 +1,118 @@ +/** + * 「靜態租戶字串不得用於資料面過濾」這道閘**自己**的測試(Arcrun#108)。 + * + * 收工標準明列三條,這裡逐條釘: + * ① 會擋,不是只提醒 → 壞例子必須產出違規(CLI 端據此 exit 1、hook 據此 exit 2) + * ② 判準看「有沒有在做那件事」 → 一整組「長得像但沒在做」的合法寫法必須零誤攔 + * ③ 閘自己要能被測試 → 規則是純函式,這裡直接餵字串;不需要跑檔案系統、也擋不到自己 + * + * 外加一條回歸:現行 src/ 必須是乾淨的(`?raw` 讀真原始碼,不是讀我編的假字串)。 + */ +import { describe, it, expect } from 'vitest'; +// @ts-expect-error -- 純規則模組(.mjs,零 node 相依),型別非本檔關注重點 +import { scanSource, TENANT_SOURCE_FILE } from '../scripts/tenant-source-rules.mjs'; +// @ts-expect-error -- vite ?raw:build-time 讀檔,runtime 是純字串(Workers 沒有 node:fs) +import tenantLibSource from '../src/lib/tenant.ts?raw'; +// @ts-expect-error -- 同上 +import portalDataSource from '../src/routes/portal-data.ts?raw'; +// @ts-expect-error -- 同上 +import portalSource from '../src/routes/portal.ts?raw'; +// @ts-expect-error -- 同上 +import consoleAuthSource from '../src/routes/console-auth.ts?raw'; +// @ts-expect-error -- 同上 +import consoleDashboardSource from '../src/routes/console-dashboard.ts?raw'; + +type Violation = { rule: string; line: number; text: string; message: string }; +const scan = (path: string, text: string): Violation[] => scanSource(path, text) as Violation[]; +const rulesOf = (v: Violation[]): string[] => [...new Set(v.map((x) => x.rule))].sort(); + +const FILE = 'src/routes/portal-data.ts'; + +describe('閘會擋:三種「靜態租戶字串進資料面」的真實形狀', () => { + it('T1 — 在 tenant.ts 以外讀租戶環境變數(#105/#108 的原句)', () => { + const bad = `export function portalTenant(env: Bindings): string {\n return env.CONSOLE_TENANT || 'leo';\n}`; + const v = scan('src/routes/portal.ts', bad); + expect(rulesOf(v)).toContain('T1'); + expect(v[0].message).toContain('knowledgeOwner'); + }); + + it('T1 — `c.env.ARCRUN_NAMESPACE` 也一樣(換一個變數名不是換一個做法)', () => { + const v = scan('src/routes/console-dashboard.ts', `const t = c.env.ARCRUN_NAMESPACE || 'leo';`); + expect(rulesOf(v)).toEqual(['T1']); + }); + + it('T2 — 繞過唯一產地自己 cast 一個 TenantId', () => { + const v = scan(FILE, `const tenant = (c.env.SOMETHING ?? '') as TenantId;`); + expect(rulesOf(v)).toContain('T2'); + }); + + it('T2 — 連在 tenant.ts 裡都不准把「字面字串」當成租戶識別(那就是 `|| "leo"` 的原形)', () => { + const v = scan(TENANT_SOURCE_FILE, ` return 'leo' as TenantId;`); + expect(rulesOf(v)).toEqual(['T2']); + expect(v[0].message).toContain('TenantUnresolvedError'); + }); + + it('T3 — 拿帳號層字串去組知識資料面的 owner_id(#108 那一行,逐字)', () => { + const bad = " const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);"; + const v = scan(FILE, bad); + expect(rulesOf(v)).toContain('T3'); + expect(v[0].message).toContain('1854'); + }); + + it('T3 — 換成物件屬性寫法一樣擋(`owner_id: portalTenant(c.env)`)', () => { + const v = scan(FILE, ` body: JSON.stringify({ values, owner_id: portalTenant(c.env) }),`); + expect(rulesOf(v)).toContain('T3'); + }); + + it('T3 — accountTenant() 這個新名字也擋(規則盯的是「這是帳號層的值」,不是某個函式名字的拼法)', () => { + const v = scan(FILE, " kbdbFetch(env, `/entries?owner_id=${accountTenant(env)}`);"); + expect(rulesOf(v)).toContain('T3'); + }); +}); + +describe('閘不誤攔:長得像、但沒有在做那件事的合法寫法', () => { + const legit: [string, string, string][] = [ + ['讀取別人回傳的 owner_id(不是在組過濾)', FILE, ` if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);`], + ['型別宣告裡的 owner_id 欄位', FILE, ` | { record?: { values?: Record; owner_id?: string | null } }`], + ['走唯一入口組過濾', FILE, " const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);"], + ['走唯一入口填 body', FILE, ` body: JSON.stringify({ template, values, owner_id: ownerField(tenant) }),`], + ['帳號子 namespace 的過濾(`{tenant}::portal`,那是 cypher 自己寫的資料)', 'src/routes/portal.ts', ` const res = await kbdbFetch(env, \`/records/by-template/x?owner_id=\${encodeURIComponent(ns)}\`);`], + ['請求自帶的租戶(webhooks-named 慣例:呼叫端就是租戶)', 'src/routes/webhooks-named.ts', ` owner_id: apiKey,`], + ['註解裡整句在講 CONSOLE_TENANT 與 owner_id(文件不是行為)', FILE, `// 之前的病:owner_id 拿 env.CONSOLE_TENANT,portalTenant(c.env) 那條路整個空掉`], + ['JSDoc 區塊裡出現同樣的字', FILE, ` * 舊寫法 owner_id=\${portalTenant(env)} 已廢除,改走 knowledgeOwner。`], + ['行末註解裡出現(程式碼本身乾淨)', FILE, ` const tenant = knowledgeOwner(c.env); // 不是 portalTenant(c.env),也不是 owner_id=leo`], + ['tenant.ts 自己讀環境變數(它就是唯一產地)', TENANT_SOURCE_FILE, ` const injected = (env.ARCRUN_NAMESPACE ?? '').trim();`], + ['types.ts 只宣告型別不取值', 'src/types.ts', ` CONSOLE_TENANT?: string;`], + ]; + + for (const [name, path, line] of legit) { + it(`零誤攔:${name}`, () => { + expect(scan(path, line)).toEqual([]); + }); + } +}); + +describe('回歸:現行原始碼是乾淨的(讀真檔,不是讀我編的字串)', () => { + const files: [string, string][] = [ + [TENANT_SOURCE_FILE, tenantLibSource as string], + ['src/routes/portal-data.ts', portalDataSource as string], + ['src/routes/portal.ts', portalSource as string], + ['src/routes/console-auth.ts', consoleAuthSource as string], + ['src/routes/console-dashboard.ts', consoleDashboardSource as string], + ]; + for (const [path, text] of files) { + it(`${path} 零違規`, () => { + expect(scan(path, text)).toEqual([]); + }); + } + + it('唯一產地本身沒有字面預設值(knowledgeOwner 解析不到要用丟的,不是回 "leo")', () => { + const body = (tenantLibSource as string).slice( + (tenantLibSource as string).indexOf('export function knowledgeOwner'), + (tenantLibSource as string).indexOf('export function tenantFromApiKey'), + ); + expect(body).toContain('TenantUnresolvedError'); + // 解析路徑只准回 env 讀到的值;任何 `|| '...'` / `?? '...'` 形式的字面 fallback 都是本票的病本身 + expect(body).not.toMatch(/(\|\||\?\?)\s*['"][^'"]+['"]/); + }); +}); diff --git a/cypher-executor/wrangler.toml b/cypher-executor/wrangler.toml index eab33e7..924088c 100644 --- a/cypher-executor/wrangler.toml +++ b/cypher-executor/wrangler.toml @@ -138,6 +138,19 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev" # (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。 CONSOLE_TENANT = "leo" +# 這台實例的**知識命名空間**(Arcrun#108)=知識資料(三元組/卡片/藏書地圖/工作流 KV) +# 實際掛在哪個 owner_id 底下。**這裡刻意不寫死**:官方 prod 的知識確實在 `CONSOLE_TENANT` +# (leo)底下,未設就沿用它,行為一字不變。 +# +# self-hosted 實例由 `acr update` 自動注入(值=你 `~/.arcrun/config.yaml` 的 `api_key`, +# 也就是 CLI push 工作流、小幫手上傳知識、MCP 查詢用的同一個 namespace), +# 而且**只在確認那個 namespace 底下真的查得到知識時才寫**(見 cli/src/lib/deploy.ts +# namespaceHasKnowledge)——避免把一台原本正常的實例指向空的那一格。 +# +# 為什麼要跟 CONSOLE_TENANT 分開:CONSOLE_TENANT 同時是帳號子 namespace(`{tenant}::portal`) +# 的組成,改它會讓舊實例登不進去。兩個不同的事實,兩個 var。 +# ARCRUN_NAMESPACE = "your-namespace" + # Portal session TTL 秒數(portal-auth P2,#24/#25,routes/portal.ts)。預設 7 天(604800)—— # issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。 PORTAL_SESSION_TTL = "604800" diff --git a/scripts/build-worker-artifacts.mjs b/scripts/build-worker-artifacts.mjs index 61d0566..62fd8ce 100644 --- a/scripts/build-worker-artifacts.mjs +++ b/scripts/build-worker-artifacts.mjs @@ -42,6 +42,8 @@ import { join, resolve, basename, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; import { createHash } from 'node:crypto'; +// Arcrun#108 出貨閘(見 main() 內註解)。規則本體與掃描器住在 cypher-executor/scripts/。 +import { scanProject as scanTenantSources } from '../cypher-executor/scripts/check-tenant-source.mjs'; // REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基: // 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」, @@ -214,6 +216,29 @@ async function main() { console.log('✔ node_modules 檢查通過:'); for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`); + // ── 出貨閘:靜態租戶字串不得用於資料面過濾(Arcrun#108,#105 同族)───────────── + // + // 為什麼擋在**這裡**:這條路徑是成品的產地(.worker-builds/ → 使用者的機器)。 + // 擋在這裡=違規的碼**編不出成品、出不了貨**,而不是「有人記得跑檢查才會發現」。 + // leo 2026-08-12:「做一個平台要減少 hotfix。」規則存在但沒機制驗證,就是會再犯第三次。 + // + // 規則本體是純函式(cypher-executor/scripts/tenant-source-rules.mjs), + // 由 cypher-executor/tests/tenant-gate.test.ts 逐條驗「壞例子會擋、合法寫法零誤攔」 + // ——這道閘自己可測,也擋不到自己(掃描範圍只有 cypher-executor/src/)。 + const tenantViolations = scanTenantSources(join(REPO, 'cypher-executor')); + if (tenantViolations.length) { + console.error('\n❌ 建置中止:cypher-executor 有「靜態租戶字串用於資料面過濾」的寫法(Arcrun#108 的閘):\n'); + for (const v of tenantViolations) { + console.error(` [${v.rule}] ${v.file}:${v.line} ${v.text}`); + console.error(` → ${v.message}`); + } + console.error('\n知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()'); + console.error('(cypher-executor/src/lib/tenant.ts 是租戶字串的唯一產地)。'); + console.error('本機自查:cd cypher-executor && npm run check:tenant\n'); + process.exit(1); + } + console.log('✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts'); + if (CHECK_ONLY) { console.log('\n--check-only:只驗證依賴就緒,不編譯。'); return; diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 8c2898f..6fa1923 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -376,6 +376,23 @@ 真人用滑鼠點擊複製鈕(受限於自動化環境,上述已用既有鈕做過同構對照)。 執行範圍:`console-ui/public/portal/index.html`(新增面板 + JS)。未動後端、未部署。 +- [x] **Arcrun#108(任務層修正,2026-08-13):資料面租戶字串收斂到唯一產地** + P3 的 `/portal/data/*` 一律用 `portalTenant(env) = env.CONSOLE_TENANT || 'leo'` 注 `owner_id`。 + 那個字串是**部署環境變數**,而知識是 CLI/同步小幫手/MCP 用**實例 namespace** + (`~/.arcrun/config.yaml` 的 `api_key`)寫進去的——兩個來源會漂。leo 實撞: + 藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組(他的在 `owner_id=bfezv28v`)。 + 與 `Arcrun#105`(`env.MCP_OWNER_NAMESPACE || "leo"`)同形,低一層。 + **修法**:新增 `cypher-executor/src/lib/tenant.ts` 當唯一產地—— + `knowledgeOwner(env)` 回 branded `TenantId`(`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` → + 誠實丟錯,**無字面預設值**),資料面過濾一律經 `ownerQuery()/ownerField()`; + 帳號子 namespace(design D-2 的 `{tenant}::portal`)改用 `accountTenant(env)`(回 `string`, + 型別上不可能流進資料面),**帳號落點一字不動**(動了舊實例登不進去)。 + `acr update` 先驗(`GET /kbdb/map?owner_id=` 查得到庫)才注入 `ARCRUN_NAMESPACE`。 + 空地圖改回四態(`no_library_grant`/`filtered_out`/`scope_mismatch`/`confirmed_empty`), + 沿 Arcrun#100「讀不到就說讀不到」。庫權限過濾一字未動(回歸測試釘住)。 + 防複發:`scripts/build-worker-artifacts.mjs` 出貨前掃描,違規編不出成品。 + 規範寫入 `.claude/rules/02-forbidden.md` 第六類、`system-dev/wiki/mistakes.md` #26。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`) diff --git a/system-dev/wiki/mistakes.md b/system-dev/wiki/mistakes.md index 3bfe587..bbc0915 100644 --- a/system-dev/wiki/mistakes.md +++ b/system-dev/wiki/mistakes.md @@ -547,6 +547,43 @@ repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。 --- +## 26. 「身分來自環境變數」——同一句話寫錯兩次,因為規則有、機制沒有(2026-08-12,Arcrun#105/#108) + +**症狀**:leo 打開藏書地圖回 **0 個庫**,同一分鐘 KBDB 裡有 **1854 條三元組**; +`arcrun_whoami` 顯示 admin/全部知識庫,`kbdb_search` 也查得到東西——**只有地圖那格是空的**。 + +**根因**(不是資料掉了,是讀寫兩端各拿一個來源): + +| | 寫入端用什麼當 owner_id | 讀取端用什麼過濾 | +|---|---|---| +| 之前 | `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,leo = `bfezv28v`) | `env.CONSOLE_TENANT \|\| "leo"`(repo toml 帶的**官方 prod 值**) | + +`acr` 從來不注入 `CONSOLE_TENANT`,所以那個 `"leo"` 不是理論邊角,**是每台 self-hosted 實例的實際行為**。 + +**這是第二次**。`#105` 前一天才修掉 `env.MCP_OWNER_NAMESPACE || "leo"`——同一句話,換一個檔案。 + +**判準(下次照用)**: +1. **「這個字串是用來決定誰的資料嗎?」** 是 → 它是身分,不是部署設定。 + 身分要嘛來自請求(登入 session/`X-Arcrun-API-Key`),要嘛來自「寫入端用的那個值」, + **不可以是一個各自抄一份的環境變數預設值**。 +2. **`|| '預設值'` 出現在身分解析路徑上=把「這台機器沒設定」偽裝成「你沒有資料」**。 + 解析不到就誠實丟錯(#100 同一條:讀不到就說讀不到)。 +3. **「規則存在但沒有機制驗證」=它會再犯**。所以本次除了修 bug,還留下三道會擋的: + - 型別閘:`TenantId` 只能由 `cypher-executor/src/lib/tenant.ts` 產出, + 資料面過濾只吃 `ownerQuery()/ownerField()` → 拿隨手一個 string 去過濾,`tsc` 當場不給過。 + - 出貨閘:`scripts/build-worker-artifacts.mjs` 編成品前先跑租戶來源檢查 + → **違規的碼編不出成品、出不了貨**(不是「有人記得跑才會發現」)。 + - 這道閘自己可測:規則是純函式(`cypher-executor/scripts/tenant-source-rules.mjs`), + `tests/tenant-gate.test.ts` 逐條驗「壞例子會擋、11 種合法寫法零誤攔」。 + **誤攔比漏攔更容易殺死一道閘**——被擋煩了就有人把它關掉。 +4. **修法不能比 bug 更危險**:`acr update` 注入 `ARCRUN_NAMESPACE` 前**先驗** + (`GET /kbdb/map?owner_id=` 查得到庫才寫)。無條件覆蓋會把「知識本來就在 + `CONSOLE_TENANT` 底下」的一鍵安裝實例指向空的那一格——那是 #97/#106 那類 + 「更新一次把人家的東西弄不見」。 + +**順手挖出的同族**(同一道閘一次抓到):`console-dashboard.ts` 有 **4 處**、 +`console-auth.ts` 有 1 處相同寫法——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。 + ## 快速檢查清單(做新功能前) - [ ] 這是工作流還是零件?問「有必要嗎?」 @@ -568,3 +605,6 @@ repo 早已是 343,969 bytes 的新品牌世代,`Songti` 一處不剩。 - [ ] 本地/Gitea 改完 code 想 `acr update` 部署?先確認:它抓的是 GitHub codeload tarball,不是你剛改的目錄(#23) - [ ] 改完前端說「做完了」?先問**線上跑的是不是這一份**(`cd console-ui && npm run verify`)——組態綠不代表世代對(#25) - [ ] 要寫「含某關鍵字就擋」的閘?先想「有人寫一則說明它已被移除的註解時會怎樣」——關鍵字閘會腐爛,優先用指紋(#25) +- [ ] 寫下 `env.X || '預設值'`?先問「這個字串是用來決定誰的資料嗎?」是 → 它是身分不是設定,不准有字面預設值(#26) +- [ ] 要用某個字串過濾 owner_id?確認它與**寫入端**用的是同一個來源,不是另一份手抄的環境變數(#26) +- [ ] 留了一道新的閘?它自己有測試嗎、誤攔案例驗過嗎、擋不擋得到自己?(#26)