Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cac874601f | |||
| b223a69884 | |||
| e05518a2b4 | |||
| 21293568d5 | |||
| 53b05c6d3d | |||
| f87d0e92f4 | |||
| ba152bc83a | |||
| 89b80ff90e | |||
| 10d150ac2b |
@@ -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 Block 訊息格式
|
||||||
|
|
||||||
當 hook 擋住一個操作時,訊息格式統一為:
|
當 hook 擋住一個操作時,訊息格式統一為:
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ scripts/__pycache__/
|
|||||||
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
|
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
|
||||||
*.sql
|
*.sql
|
||||||
backup-*.sql
|
backup-*.sql
|
||||||
|
# 🔴 但 migration 不是備份,它是**要出貨的程式碼**(2026-08-12 實撞):
|
||||||
|
# 上面那條 `*.sql` 的用意是擋 D1 匯出(整庫全量資料=機敏),卻連 migration 一起吃掉。
|
||||||
|
# 後果:0001-0004 因為在該規則之前就 commit 所以還在,**0005/0006 從此沒進過版控**
|
||||||
|
# ⇒ 更新指令從 Gitea 抓 main,那兩個檔根本不在那裡 ⇒ 每個用戶都會收到
|
||||||
|
# 「✗ D1 migration: 部署物缺 kbdb/migrations/0005…」——**不是誰忘了推,是規則吃掉的**。
|
||||||
|
# ⇒ 與 `.component-builds/**/component.wasm` 同慣例(見 rules/05-deploy-convention.md
|
||||||
|
# 「WASM 來源」段),用否定規則放行。備份檔仍由 `backup-*.sql` 與目錄位置擋住。
|
||||||
|
!kbdb/migrations/*.sql
|
||||||
|
|
||||||
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
||||||
.github-public/
|
.github-public/
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
var __defProp = Object.defineProperty;
|
var __defProp = Object.defineProperty;
|
||||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
var __esm = (fn, res, err2) => function __init() {
|
var __esm = (fn, res) => function __init() {
|
||||||
if (err2) throw err2[0];
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
||||||
try {
|
|
||||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
||||||
} catch (e) {
|
|
||||||
throw err2 = [e], e;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
var __export = (target, all) => {
|
var __export = (target, all) => {
|
||||||
for (var name in all)
|
for (var name in all)
|
||||||
@@ -107,7 +102,7 @@ function applyBaseRuntimeOptions(runtime, options) {
|
|||||||
function applyModuleEvalRuntimeOptions(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);
|
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({
|
var init_chunk_JTKJZQYV = __esm({
|
||||||
"registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() {
|
"registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() {
|
||||||
init_dist();
|
init_dist();
|
||||||
@@ -195,7 +190,7 @@ var init_chunk_JTKJZQYV = __esm({
|
|||||||
return this.dispose();
|
return this.dispose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
SymbolDispose = Symbol.dispose ?? /* @__PURE__ */ Symbol.for("Symbol.dispose");
|
SymbolDispose = Symbol.dispose ?? Symbol.for("Symbol.dispose");
|
||||||
prototypeAsAny = UsingDisposable.prototype;
|
prototypeAsAny = UsingDisposable.prototype;
|
||||||
prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() {
|
prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() {
|
||||||
return this.dispose();
|
return this.dispose();
|
||||||
@@ -414,6 +409,7 @@ Lifetime used`) : new QuickJSUseAfterFree("Lifetime not alive");
|
|||||||
return this.module._free(ptr), str;
|
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 });
|
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 {
|
QuickJSIterator = class extends UsingDisposable {
|
||||||
constructor(handle, context) {
|
constructor(handle, context) {
|
||||||
@@ -777,7 +773,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
|
|||||||
}
|
}
|
||||||
return result.value;
|
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 }`;
|
return this.alive ? `${this.constructor.name} { ctx: ${this.ctx.value} rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
|
||||||
}
|
}
|
||||||
getFunction(fn_id) {
|
getFunction(fn_id) {
|
||||||
@@ -913,7 +909,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
|
|||||||
debugLog(...msg) {
|
debugLog(...msg) {
|
||||||
this._debugMode && console.log("quickjs-emscripten:", ...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 }`;
|
return this.alive ? `${this.constructor.name} { rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
|
||||||
}
|
}
|
||||||
getSystemContext() {
|
getSystemContext() {
|
||||||
@@ -1343,10 +1339,10 @@ async function QuickJSRaw(moduleArg = {}) {
|
|||||||
x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m];
|
x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m];
|
||||||
}
|
}
|
||||||
b = a(...g);
|
b = a(...g);
|
||||||
return b = (function(k) {
|
return b = function(k) {
|
||||||
0 !== h && sa(h);
|
0 !== h && sa(h);
|
||||||
return "string" === d ? R(k) : "boolean" === d ? !!k : k;
|
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 });
|
c.wasmMemory ? r = c.wasmMemory : r = new WebAssembly.Memory({ initial: (c.INITIAL_MEMORY || 16777216) / 65536, maximum: 32768 });
|
||||||
K();
|
K();
|
||||||
@@ -1468,7 +1464,7 @@ async function QuickJSRaw(moduleArg = {}) {
|
|||||||
}, t: function(a, d) {
|
}, t: function(a, d) {
|
||||||
c.callbacks.freeHostRef(void 0, a, d);
|
c.callbacks.freeHostRef(void 0, a, d);
|
||||||
} }, Z;
|
} }, Z;
|
||||||
Z = await (async function() {
|
Z = await async function() {
|
||||||
function a(b) {
|
function a(b) {
|
||||||
b = Z = b.exports;
|
b = Z = b.exports;
|
||||||
c._malloc = b.v;
|
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;
|
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);
|
return a((await ea(d)).instance);
|
||||||
})();
|
}();
|
||||||
(function() {
|
(function() {
|
||||||
function a() {
|
function a() {
|
||||||
c.calledRun = true;
|
c.calledRun = true;
|
||||||
@@ -3038,7 +3034,7 @@ var Hono = class _Hono {
|
|||||||
var emptyParam = [];
|
var emptyParam = [];
|
||||||
function match(method, path) {
|
function match(method, path) {
|
||||||
const matchers = this.buildAllMatchers();
|
const matchers = this.buildAllMatchers();
|
||||||
const match2 = ((method2, path2) => {
|
const match2 = (method2, path2) => {
|
||||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||||
const staticMatch = matcher[2][path2];
|
const staticMatch = matcher[2][path2];
|
||||||
if (staticMatch) {
|
if (staticMatch) {
|
||||||
@@ -3050,7 +3046,7 @@ function match(method, path) {
|
|||||||
}
|
}
|
||||||
const index = match3.indexOf("", 1);
|
const index = match3.indexOf("", 1);
|
||||||
return [matcher[1][index], match3];
|
return [matcher[1][index], match3];
|
||||||
});
|
};
|
||||||
this.match = match2;
|
this.match = match2;
|
||||||
return match2(method, path);
|
return match2(method, path);
|
||||||
}
|
}
|
||||||
@@ -4012,7 +4008,7 @@ app.post("/", async (c) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
var index_default = app;
|
var code_default = app;
|
||||||
export {
|
export {
|
||||||
index_default as default
|
code_default as default
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
var __defProp = Object.defineProperty;
|
var __defProp = Object.defineProperty;
|
||||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
var __esm = (fn, res, err) => function __init() {
|
var __esm = (fn, res) => function __init() {
|
||||||
if (err) throw err[0];
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
||||||
try {
|
|
||||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
||||||
} catch (e) {
|
|
||||||
throw err = [e], e;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
var __export = (target, all) => {
|
var __export = (target, all) => {
|
||||||
for (var name in 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
|
// cypher-executor/node_modules/.pnpm/hono@4.12.10/node_modules/hono/dist/router/reg-exp-router/matcher.js
|
||||||
function match(method, path) {
|
function match(method, path) {
|
||||||
const matchers = this.buildAllMatchers();
|
const matchers = this.buildAllMatchers();
|
||||||
const match2 = ((method2, path2) => {
|
const match2 = (method2, path2) => {
|
||||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||||
const staticMatch = matcher[2][path2];
|
const staticMatch = matcher[2][path2];
|
||||||
if (staticMatch) {
|
if (staticMatch) {
|
||||||
@@ -1518,7 +1513,7 @@ function match(method, path) {
|
|||||||
}
|
}
|
||||||
const index = match3.indexOf("", 1);
|
const index = match3.indexOf("", 1);
|
||||||
return [matcher[1][index], match3];
|
return [matcher[1][index], match3];
|
||||||
});
|
};
|
||||||
this.match = match2;
|
this.match = match2;
|
||||||
return match2(method, path);
|
return match2(method, path);
|
||||||
}
|
}
|
||||||
@@ -7438,7 +7433,7 @@ var init_lib = __esm({
|
|||||||
...processCreateParams(params)
|
...processCreateParams(params)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
BRAND = /* @__PURE__ */ Symbol("zod_brand");
|
BRAND = Symbol("zod_brand");
|
||||||
ZodBranded = class extends ZodType {
|
ZodBranded = class extends ZodType {
|
||||||
_parse(input) {
|
_parse(input) {
|
||||||
const { ctx } = this._processInputParams(input);
|
const { ctx } = this._processInputParams(input);
|
||||||
@@ -7612,14 +7607,14 @@ var init_lib = __esm({
|
|||||||
onumber = () => numberType().optional();
|
onumber = () => numberType().optional();
|
||||||
oboolean = () => booleanType().optional();
|
oboolean = () => booleanType().optional();
|
||||||
coerce = {
|
coerce = {
|
||||||
string: ((arg) => ZodString.create({ ...arg, coerce: true })),
|
string: (arg) => ZodString.create({ ...arg, coerce: true }),
|
||||||
number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
|
number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
|
||||||
boolean: ((arg) => ZodBoolean.create({
|
boolean: (arg) => ZodBoolean.create({
|
||||||
...arg,
|
...arg,
|
||||||
coerce: true
|
coerce: true
|
||||||
})),
|
}),
|
||||||
bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
|
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
|
||||||
date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
|
date: (arg) => ZodDate.create({ ...arg, coerce: true })
|
||||||
};
|
};
|
||||||
NEVER = INVALID;
|
NEVER = INVALID;
|
||||||
z = /* @__PURE__ */ Object.freeze({
|
z = /* @__PURE__ */ Object.freeze({
|
||||||
@@ -7840,7 +7835,6 @@ var init_recipe_loader = __esm({
|
|||||||
super(message);
|
super(message);
|
||||||
this.recipe = recipe;
|
this.recipe = recipe;
|
||||||
}
|
}
|
||||||
recipe;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -8847,7 +8841,7 @@ function recordRecipeStats(env, recipeKeys, ok, at, ctx) {
|
|||||||
)
|
)
|
||||||
).then(() => void 0);
|
).then(() => void 0);
|
||||||
if (ctx?.waitUntil) ctx.waitUntil(promise);
|
if (ctx?.waitUntil) ctx.waitUntil(promise);
|
||||||
else void promise;
|
else ;
|
||||||
}
|
}
|
||||||
function generateToken() {
|
function generateToken() {
|
||||||
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
|
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
@@ -8889,7 +8883,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
|
|||||||
result.trace
|
result.trace
|
||||||
);
|
);
|
||||||
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
||||||
else void statsPromise;
|
else ;
|
||||||
}
|
}
|
||||||
return { success: true, data: result.data, duration_ms };
|
return { success: true, data: result.data, duration_ms };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -8913,7 +8907,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
|
|||||||
err.trace
|
err.trace
|
||||||
);
|
);
|
||||||
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
||||||
else void statsPromise;
|
else ;
|
||||||
}
|
}
|
||||||
if (err instanceof ExecutionError) {
|
if (err instanceof ExecutionError) {
|
||||||
const traceFormatted = err.trace.map((s) => ({
|
const traceFormatted = err.trace.map((s) => ({
|
||||||
@@ -9341,9 +9335,11 @@ function authStoreStatus(env) {
|
|||||||
var healthRouter = new Hono2();
|
var healthRouter = new Hono2();
|
||||||
healthRouter.get("/health", (c) => {
|
healthRouter.get("/health", (c) => {
|
||||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||||
|
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
|
||||||
return c.json({
|
return c.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
...bundleVersion ? { bundle_version: bundleVersion } : {},
|
...bundleVersion ? { bundle_version: bundleVersion } : {},
|
||||||
|
...bundleCommit ? { bundle_commit: bundleCommit } : {},
|
||||||
auth_store: authStoreStatus(c.env),
|
auth_store: authStoreStatus(c.env),
|
||||||
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
||||||
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
||||||
@@ -9378,7 +9374,6 @@ function extractTarget(input) {
|
|||||||
return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2);
|
return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2);
|
||||||
}
|
}
|
||||||
async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) {
|
async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) {
|
||||||
void nodes;
|
|
||||||
try {
|
try {
|
||||||
const { base, headers } = kbdbBase(env);
|
const { base, headers } = kbdbBase(env);
|
||||||
await fetch(`${base}/execution-log/record`, {
|
await fetch(`${base}/execution-log/record`, {
|
||||||
@@ -12555,6 +12550,45 @@ init_kbdb_proxy();
|
|||||||
|
|
||||||
// cypher-executor/src/routes/console-auth.ts
|
// cypher-executor/src/routes/console-auth.ts
|
||||||
init_dist();
|
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 consoleAuthRouter = new Hono2();
|
||||||
var CREDS_KEY = "console:credentials";
|
var CREDS_KEY = "console:credentials";
|
||||||
var SESSION_PREFIX = "console_sess:";
|
var SESSION_PREFIX = "console_sess:";
|
||||||
@@ -12581,7 +12615,7 @@ async function hashPassword(password, salt) {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
function tenantOf(c) {
|
function tenantOf(c) {
|
||||||
return c.env.CONSOLE_TENANT || "leo";
|
return knowledgeOwner(c.env);
|
||||||
}
|
}
|
||||||
async function loadCredentials(env) {
|
async function loadCredentials(env) {
|
||||||
let fromStore = readAuthStore(env).console;
|
let fromStore = readAuthStore(env).console;
|
||||||
@@ -12852,10 +12886,10 @@ var DEFAULT_SESSION_TTL = 604800;
|
|||||||
var USER_TEMPLATE = "portal_user";
|
var USER_TEMPLATE = "portal_user";
|
||||||
var LIBRARY_TEMPLATE = "portal_library";
|
var LIBRARY_TEMPLATE = "portal_library";
|
||||||
function portalTenant(env) {
|
function portalTenant(env) {
|
||||||
return env.CONSOLE_TENANT || "leo";
|
return accountTenant(env);
|
||||||
}
|
}
|
||||||
function portalNamespace(env) {
|
function portalNamespace(env) {
|
||||||
return `${portalTenant(env)}::portal`;
|
return `${accountTenant(env)}::portal`;
|
||||||
}
|
}
|
||||||
function sessionTtl(env) {
|
function sessionTtl(env) {
|
||||||
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? "", 10);
|
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? "", 10);
|
||||||
@@ -12884,6 +12918,9 @@ async function run(c, fn) {
|
|||||||
if (e instanceof AuthStoreWriteError) {
|
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);
|
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);
|
if (e instanceof KbdbError) return c.json({ error: `KBDB \u4E0D\u53EF\u9054\u6216\u56DE\u932F\uFF1A${e.message}` }, 502);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -13285,7 +13322,12 @@ portalRouter.post(
|
|||||||
session_token: token,
|
session_token: token,
|
||||||
display_name: rec.values.display_name ?? "",
|
display_name: rec.values.display_name ?? "",
|
||||||
role: rec.values.role ?? "user",
|
role: rec.values.role ?? "user",
|
||||||
libraries: parseLibraries(rec.values.libraries)
|
libraries: parseLibraries(rec.values.libraries),
|
||||||
|
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
|
||||||
|
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
|
||||||
|
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
|
||||||
|
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
|
||||||
|
session_expires_in: sessionTtl(c.env)
|
||||||
// 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
|
// 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -13761,10 +13803,9 @@ portalRouter.post(
|
|||||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||||
}
|
}
|
||||||
await clearLoginFail(c.env, email);
|
await clearLoginFail(c.env, email);
|
||||||
const tenant2 = portalTenant(c.env);
|
|
||||||
const daemonCfg = {
|
const daemonCfg = {
|
||||||
cypher_url: new URL(c.req.url).origin,
|
cypher_url: new URL(c.req.url).origin,
|
||||||
namespace: tenant2,
|
namespace: knowledgeOwner(c.env),
|
||||||
library: "kb",
|
library: "kb",
|
||||||
email,
|
email,
|
||||||
instance_name: String(rec.values.display_name ?? "")
|
instance_name: String(rec.values.display_name ?? "")
|
||||||
@@ -13780,7 +13821,7 @@ portalRouter.post(
|
|||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const key = String(body?.key ?? "").trim();
|
const key = String(body?.key ?? "").trim();
|
||||||
if (!key) return c.json({ error: "\u8ACB\u8CBC\u4E0A\u4F60\u7684 Google AI \u91D1\u9470" }, 400);
|
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 kvKey2 = `${tenant2}:wf:rag_chat`;
|
||||||
const raw2 = await c.env.WEBHOOKS.get(kvKey2, "text");
|
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);
|
if (!raw2) return c.json({ error: "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u5B89\u88DD AI \u554F\u7B54\u5DE5\u4F5C\u6D41" }, 404);
|
||||||
@@ -13832,8 +13873,8 @@ portalRouter.get(
|
|||||||
});
|
});
|
||||||
const known = new Set(out.map((l) => l.name));
|
const known = new Set(out.map((l) => l.name));
|
||||||
try {
|
try {
|
||||||
const tenant2 = portalTenant(c.env);
|
const tenant2 = knowledgeOwner(c.env);
|
||||||
const ownerParam = `owner_id=${encodeURIComponent(tenant2)}`;
|
const ownerParam = ownerQuery(tenant2);
|
||||||
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
||||||
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
||||||
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
||||||
@@ -13982,8 +14023,8 @@ portalRouter.get(
|
|||||||
(c) => run(c, async () => {
|
(c) => run(c, async () => {
|
||||||
const auth = await requirePortalAdmin(c);
|
const auth = await requirePortalAdmin(c);
|
||||||
if (!auth.ok) return auth.res;
|
if (!auth.ok) return auth.res;
|
||||||
const ownerId = portalTenant(c.env);
|
const ownerId = knowledgeOwner(c.env);
|
||||||
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
|
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
|
||||||
if (!res.ok) throw new KbdbError(`GET /execution-log/retention \u2192 ${res.status}`);
|
if (!res.ok) throw new KbdbError(`GET /execution-log/retention \u2192 ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
|
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
|
||||||
@@ -13999,10 +14040,10 @@ portalRouter.put(
|
|||||||
if (days !== null && days !== void 0 && (typeof days !== "number" || !Number.isFinite(days) || days <= 0)) {
|
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);
|
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", {
|
const res = await kbdbFetch(c.env, "/execution-log/retention", {
|
||||||
method: "PUT",
|
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}`);
|
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention \u2192 ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -14019,10 +14060,10 @@ portalRouter.delete(
|
|||||||
const confirm = String(body?.confirm ?? "").trim();
|
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) 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);
|
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", {
|
const res = await kbdbFetch(c.env, "/entries/deprecate-by-library", {
|
||||||
method: "PATCH",
|
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}`);
|
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library \u2192 ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -14078,8 +14119,8 @@ async function buildDiagnostics(env, tenant2) {
|
|||||||
let embedding = { checked: false };
|
let embedding = { checked: false };
|
||||||
try {
|
try {
|
||||||
const [statusRes, selftestRes] = await Promise.all([
|
const [statusRes, selftestRes] = await Promise.all([
|
||||||
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant2 }).toString()}`),
|
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant2)}`),
|
||||||
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant2 }).toString()}`)
|
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant2)}`)
|
||||||
]);
|
]);
|
||||||
const statusBody = await statusRes.json().catch(() => null);
|
const statusBody = await statusRes.json().catch(() => null);
|
||||||
const selftestBody = await selftestRes.json().catch(() => null);
|
const selftestBody = await selftestRes.json().catch(() => null);
|
||||||
@@ -14101,7 +14142,7 @@ async function buildDiagnostics(env, tenant2) {
|
|||||||
}
|
}
|
||||||
let library_count = 0;
|
let library_count = 0;
|
||||||
let triplet_count = 0;
|
let triplet_count = 0;
|
||||||
const ownerParam = new URLSearchParams({ owner_id: tenant2 }).toString();
|
const ownerParam = ownerQuery(tenant2);
|
||||||
try {
|
try {
|
||||||
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
||||||
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
||||||
@@ -14125,7 +14166,7 @@ async function buildDiagnostics(env, tenant2) {
|
|||||||
let library_scope_check = { ran: false };
|
let library_scope_check = { ran: false };
|
||||||
if (library_count === 0 && triplet_count === 0) {
|
if (library_count === 0 && triplet_count === 0) {
|
||||||
try {
|
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 probeBody = await probeRes.json().catch(() => null);
|
||||||
const total = probeBody?.total ?? 0;
|
const total = probeBody?.total ?? 0;
|
||||||
library_scope_check = {
|
library_scope_check = {
|
||||||
@@ -14148,7 +14189,7 @@ portalRouter.get(
|
|||||||
(c) => run(c, async () => {
|
(c) => run(c, async () => {
|
||||||
const apiKey = (c.req.header("X-Arcrun-API-Key") ?? "").trim();
|
const apiKey = (c.req.header("X-Arcrun-API-Key") ?? "").trim();
|
||||||
if (!apiKey) return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401);
|
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({
|
return c.json({
|
||||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||||
instance_url: new URL(c.req.url).origin,
|
instance_url: new URL(c.req.url).origin,
|
||||||
@@ -14703,7 +14744,7 @@ async function cachedGiteaSprint(env, nowMs, waitUntil, fetcher = fetchGiteaSpri
|
|||||||
return { ...fresh, cache: "miss" };
|
return { ...fresh, cache: "miss" };
|
||||||
}
|
}
|
||||||
consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
const tenant2 = knowledgeOwner(c.env);
|
||||||
const now2 = Date.now();
|
const now2 = Date.now();
|
||||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||||
const graphUrl = graphBase(c.env);
|
const graphUrl = graphBase(c.env);
|
||||||
@@ -14855,7 +14896,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
consoleDashboardRouter.get("/console/kb-scale-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 { base, headers } = kbdbBase(c.env);
|
||||||
const now2 = Date.now();
|
const now2 = Date.now();
|
||||||
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
||||||
@@ -14890,7 +14931,7 @@ consoleDashboardRouter.get("/console/settings-data", (c) => {
|
|||||||
consoleDashboardRouter.get("/console/triage-data", async (c) => {
|
consoleDashboardRouter.get("/console/triage-data", async (c) => {
|
||||||
const ok = await validateConsoleSession(c.env, c.req.header("authorization"));
|
const ok = await validateConsoleSession(c.env, c.req.header("authorization"));
|
||||||
if (!ok) return c.json({ error: "\u9700\u8981\u767B\u5165\uFF08console session\uFF09" }, 401);
|
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([
|
const [todoEntries, inboxEntries] = await Promise.all([
|
||||||
fetchEntries(c.env, tenant2, "todo", 500),
|
fetchEntries(c.env, tenant2, "todo", 500),
|
||||||
fetchEntries(c.env, tenant2, "inbox", 200)
|
fetchEntries(c.env, tenant2, "inbox", 200)
|
||||||
@@ -14905,7 +14946,7 @@ consoleDashboardRouter.post("/console/triage-check", async (c) => {
|
|||||||
const entryId = typeof body?.entry_id === "string" ? body.entry_id.trim() : "";
|
const entryId = typeof body?.entry_id === "string" ? body.entry_id.trim() : "";
|
||||||
if (!entryId) return c.json({ error: "entry_id \u5FC5\u586B" }, 400);
|
if (!entryId) return c.json({ error: "entry_id \u5FC5\u586B" }, 400);
|
||||||
const action = body?.action === "restore" ? "restore" : "check";
|
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 { base, headers } = kbdbBase(c.env);
|
||||||
const got = await fetchJson(
|
const got = await fetchJson(
|
||||||
`${base}/entries/${encodeURIComponent(entryId)}`,
|
`${base}/entries/${encodeURIComponent(entryId)}`,
|
||||||
@@ -14933,7 +14974,7 @@ init_kbdb_proxy();
|
|||||||
init_webhook_handlers();
|
init_webhook_handlers();
|
||||||
var portalDataRouter = new Hono2();
|
var portalDataRouter = new Hono2();
|
||||||
async function getTenantWorkflowGraph(env, name) {
|
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;
|
if (!raw2) return null;
|
||||||
try {
|
try {
|
||||||
const rec = JSON.parse(raw2);
|
const rec = JSON.parse(raw2);
|
||||||
@@ -15026,7 +15067,7 @@ function findBestNodeMatch(searchTerm, nodeNames) {
|
|||||||
}
|
}
|
||||||
async function tripletCount(env, owner) {
|
async function tripletCount(env, owner) {
|
||||||
try {
|
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;
|
if (!res.ok) return null;
|
||||||
const body = await res.json().catch(() => null);
|
const body = await res.json().catch(() => null);
|
||||||
if (!body || !Array.isArray(body.stats)) return null;
|
if (!body || !Array.isArray(body.stats)) return null;
|
||||||
@@ -15043,11 +15084,11 @@ async function tripletCount(env, owner) {
|
|||||||
async function tripletCensus(env, tenant2) {
|
async function tripletCensus(env, tenant2) {
|
||||||
const owned = await tripletCount(env, tenant2);
|
const owned = await tripletCount(env, tenant2);
|
||||||
if (owned !== 0) return { owned, any: null };
|
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) {
|
async function fuzzyFindNode(env, tenant2, searchTerm) {
|
||||||
try {
|
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;
|
if (!res.ok) return null;
|
||||||
const body = await res.json().catch(() => null);
|
const body = await res.json().catch(() => null);
|
||||||
if (!body || !Array.isArray(body.records)) return null;
|
if (!body || !Array.isArray(body.records)) return null;
|
||||||
@@ -15075,7 +15116,7 @@ portalDataRouter.get(
|
|||||||
if (libraries.length === 0) {
|
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" });
|
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 (!libraries.includes("*")) params.set("library", libraries.join(","));
|
||||||
if (c.req.query("mode") === "semantic") {
|
if (c.req.query("mode") === "semantic") {
|
||||||
params.set("mode", "semantic");
|
params.set("mode", "semantic");
|
||||||
@@ -15111,7 +15152,7 @@ portalDataRouter.get(
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
const entry = body.entry;
|
const entry = body.entry;
|
||||||
if (!entry) return notFound(c);
|
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);
|
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||||||
return c.json({ success: true, entry });
|
return c.json({ success: true, entry });
|
||||||
})
|
})
|
||||||
@@ -15126,7 +15167,7 @@ portalDataRouter.get(
|
|||||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
||||||
}
|
}
|
||||||
const nodeName = normalizeCjkQuery(c.req.param("name"));
|
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");
|
const wfGraph = await getTenantWorkflowGraph(c.env, "graph_neighbors");
|
||||||
if (wfGraph) {
|
if (wfGraph) {
|
||||||
const depthRaw = c.req.query("depth") ?? "";
|
const depthRaw = c.req.query("depth") ?? "";
|
||||||
@@ -15180,9 +15221,9 @@ portalDataRouter.get(
|
|||||||
if (!await hasGraphAccess(c.env, libraries)) {
|
if (!await hasGraphAccess(c.env, libraries)) {
|
||||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
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([
|
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)
|
tripletCensus(c.env, tenant2)
|
||||||
]);
|
]);
|
||||||
const tripletsTotal = census.owned;
|
const tripletsTotal = census.owned;
|
||||||
@@ -15253,7 +15294,7 @@ portalDataRouter.get(
|
|||||||
wfGraph,
|
wfGraph,
|
||||||
{ question },
|
{ question },
|
||||||
"rag_chat",
|
"rag_chat",
|
||||||
portalTenant(c.env),
|
knowledgeOwner(c.env),
|
||||||
c.executionCtx
|
c.executionCtx
|
||||||
);
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -15329,7 +15370,7 @@ portalDataRouter.get(
|
|||||||
if (!workflowsVisible(c.env, auth.user.values.role ?? "user")) {
|
if (!workflowsVisible(c.env, auth.user.values.role ?? "user")) {
|
||||||
return c.json({ error: "\u9700\u8981 admin \u6B0A\u9650" }, 403);
|
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 prefix = `${tenant2}:wf:`;
|
||||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||||
const workflows = await Promise.all(
|
const workflows = await Promise.all(
|
||||||
@@ -15351,7 +15392,7 @@ portalDataRouter.get(
|
|||||||
let last_execution = null;
|
let last_execution = null;
|
||||||
const execRes = await kbdbFetch(
|
const execRes = await kbdbFetch(
|
||||||
c.env,
|
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);
|
const execBody = await execRes.json().catch(() => null);
|
||||||
if (execRes.ok && execBody?.success && execBody.execution) {
|
if (execRes.ok && execBody?.success && execBody.execution) {
|
||||||
@@ -15363,12 +15404,207 @@ portalDataRouter.get(
|
|||||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
function recordLibrary(values) {
|
||||||
|
const lib = values?.library;
|
||||||
|
return typeof lib === "string" && lib.trim() ? lib.trim() : null;
|
||||||
|
}
|
||||||
|
function canReadRecord(rec, tenant2, libraries) {
|
||||||
|
if (!isOwnedBy(rec.owner_id, tenant2)) return false;
|
||||||
|
const lib = recordLibrary(rec.values);
|
||||||
|
return lib === null || canReadLibrary(libraries, lib);
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
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 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" } });
|
||||||
|
}
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
if (!body || !Array.isArray(body.libraries)) {
|
||||||
|
return c.json({ error: "\u85CF\u66F8\u5730\u5716\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 libraries \u6E05\u55AE" }, 502);
|
||||||
|
}
|
||||||
|
const allowed = body.libraries.filter(
|
||||||
|
(l) => typeof l?.library === "string" && canReadLibrary(libraries, l.library)
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
"/portal/data/map/:library",
|
||||||
|
(c) => run(c, async () => {
|
||||||
|
const auth = await requirePortalUser(c);
|
||||||
|
if (!auth.ok) return auth.res;
|
||||||
|
const libraries = parseLibraries(auth.user.values.libraries);
|
||||||
|
const library = c.req.param("library");
|
||||||
|
if (!canReadLibrary(libraries, library)) return notFound(c);
|
||||||
|
const res = await kbdbFetch(
|
||||||
|
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);
|
||||||
|
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
portalDataRouter.get(
|
||||||
|
"/portal/data/templates",
|
||||||
|
(c) => run(c, async () => {
|
||||||
|
const auth = await requirePortalUser(c);
|
||||||
|
if (!auth.ok) return auth.res;
|
||||||
|
const res = await kbdbFetch(c.env, "/templates");
|
||||||
|
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||||
|
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
portalDataRouter.post(
|
||||||
|
"/portal/data/templates",
|
||||||
|
(c) => run(c, async () => {
|
||||||
|
const auth = await requirePortalUser(c);
|
||||||
|
if (!auth.ok) return auth.res;
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (!body || typeof body.name !== "string" || !body.name.trim() || !Array.isArray(body.slots)) {
|
||||||
|
return c.json({ error: "name \u8207 slots[] \u5FC5\u586B" }, 400);
|
||||||
|
}
|
||||||
|
const res = await kbdbFetch(c.env, "/templates", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: body.name,
|
||||||
|
slots: body.slots,
|
||||||
|
description: typeof body.description === "string" ? body.description : void 0,
|
||||||
|
created_by: knowledgeOwner(c.env)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
portalDataRouter.get(
|
||||||
|
"/portal/data/records/by-template/:template",
|
||||||
|
(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, records: [], count: 0 });
|
||||||
|
const tenant2 = knowledgeOwner(c.env);
|
||||||
|
const res = await kbdbFetch(
|
||||||
|
c.env,
|
||||||
|
`/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);
|
||||||
|
if (!body || !Array.isArray(body.records)) {
|
||||||
|
return c.json({ error: "record \u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
|
||||||
|
}
|
||||||
|
const records = body.records.filter((r) => canReadRecord(r, tenant2, libraries));
|
||||||
|
return c.json({ success: true, records, count: records.length });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
portalDataRouter.get(
|
||||||
|
"/portal/data/records/:recordId",
|
||||||
|
(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 notFound(c);
|
||||||
|
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param("recordId"))}`);
|
||||||
|
if (res.status === 404) return notFound(c);
|
||||||
|
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
const record = body?.record;
|
||||||
|
if (!record) return notFound(c);
|
||||||
|
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
|
||||||
|
return c.json({ success: true, record });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
portalDataRouter.post(
|
||||||
|
"/portal/data/records",
|
||||||
|
(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({ error: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u7121\u6CD5\u5BEB\u5165" }, 403);
|
||||||
|
}
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (!body || typeof body.template !== "string" || !body.template.trim() || !body.values || typeof body.values !== "object") {
|
||||||
|
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
|
||||||
|
}
|
||||||
|
const values = body.values;
|
||||||
|
const targetLib = recordLibrary(values);
|
||||||
|
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
|
||||||
|
return c.json({ error: `\u7121\u300C${targetLib}\u300D\u5EAB\u7684\u6B0A\u9650\uFF0C\u4E0D\u80FD\u5BEB\u5165\u8A72\u5EAB` }, 403);
|
||||||
|
}
|
||||||
|
const res = await kbdbFetch(c.env, "/records", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
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" } });
|
||||||
|
})
|
||||||
|
);
|
||||||
portalDataRouter.get(
|
portalDataRouter.get(
|
||||||
"/portal/data/diagnostics",
|
"/portal/data/diagnostics",
|
||||||
(c) => run(c, async () => {
|
(c) => run(c, async () => {
|
||||||
const auth = await requirePortalUser(c);
|
const auth = await requirePortalUser(c);
|
||||||
if (!auth.ok) return auth.res;
|
if (!auth.ok) return auth.res;
|
||||||
const tenant2 = portalTenant(c.env);
|
const tenant2 = knowledgeOwner(c.env);
|
||||||
const core = await buildDiagnostics(c.env, tenant2);
|
const core = await buildDiagnostics(c.env, tenant2);
|
||||||
return c.json({
|
return c.json({
|
||||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||||
@@ -15418,10 +15654,10 @@ app.route("/", consoleAuthRouter);
|
|||||||
app.route("/", consoleDashboardRouter);
|
app.route("/", consoleDashboardRouter);
|
||||||
app.route("/", portalRouter);
|
app.route("/", portalRouter);
|
||||||
app.route("/", portalDataRouter);
|
app.route("/", portalDataRouter);
|
||||||
var index_default = {
|
var src_default = {
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
scheduled: handleScheduled
|
scheduled: handleScheduled
|
||||||
};
|
};
|
||||||
export {
|
export {
|
||||||
index_default as default
|
src_default as default
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1427,7 +1427,7 @@ var Hono = class _Hono {
|
|||||||
var emptyParam = [];
|
var emptyParam = [];
|
||||||
function match(method, path) {
|
function match(method, path) {
|
||||||
const matchers = this.buildAllMatchers();
|
const matchers = this.buildAllMatchers();
|
||||||
const match2 = ((method2, path2) => {
|
const match2 = (method2, path2) => {
|
||||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||||
const staticMatch = matcher[2][path2];
|
const staticMatch = matcher[2][path2];
|
||||||
if (staticMatch) {
|
if (staticMatch) {
|
||||||
@@ -1439,7 +1439,7 @@ function match(method, path) {
|
|||||||
}
|
}
|
||||||
const index = match3.indexOf("", 1);
|
const index = match3.indexOf("", 1);
|
||||||
return [matcher[1][index], match3];
|
return [matcher[1][index], match3];
|
||||||
});
|
};
|
||||||
this.match = match2;
|
this.match = match2;
|
||||||
return match2(method, path);
|
return match2(method, path);
|
||||||
}
|
}
|
||||||
@@ -2522,7 +2522,7 @@ app.post("/", async (c) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
var index_default = app;
|
var src_default = app;
|
||||||
async function runWasm(input) {
|
async function runWasm(input) {
|
||||||
const hostFunctions = {
|
const hostFunctions = {
|
||||||
http_request: async (url, method, headersJson, body) => {
|
http_request: async (url, method, headersJson, body) => {
|
||||||
@@ -2568,5 +2568,5 @@ async function runWasm(input) {
|
|||||||
return JSON.parse(stdout);
|
return JSON.parse(stdout);
|
||||||
}
|
}
|
||||||
export {
|
export {
|
||||||
index_default as default
|
src_default as default
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1444,7 +1444,7 @@ var Hono = class _Hono {
|
|||||||
var emptyParam = [];
|
var emptyParam = [];
|
||||||
function match(method, path) {
|
function match(method, path) {
|
||||||
const matchers = this.buildAllMatchers();
|
const matchers = this.buildAllMatchers();
|
||||||
const match2 = ((method2, path2) => {
|
const match2 = (method2, path2) => {
|
||||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||||
const staticMatch = matcher[2][path2];
|
const staticMatch = matcher[2][path2];
|
||||||
if (staticMatch) {
|
if (staticMatch) {
|
||||||
@@ -1456,7 +1456,7 @@ function match(method, path) {
|
|||||||
}
|
}
|
||||||
const index = match3.indexOf("", 1);
|
const index = match3.indexOf("", 1);
|
||||||
return [matcher[1][index], match3];
|
return [matcher[1][index], match3];
|
||||||
});
|
};
|
||||||
this.match = match2;
|
this.match = match2;
|
||||||
return match2(method, path);
|
return match2(method, path);
|
||||||
}
|
}
|
||||||
@@ -3281,7 +3281,7 @@ async function createRecord(db, input) {
|
|||||||
});
|
});
|
||||||
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, tpl.id, slot, entry.id).run();
|
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, tpl.id, slot, entry.id).run();
|
||||||
}
|
}
|
||||||
return { record_id: recordId, template_id: tpl.id, values: input.values };
|
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||||
}
|
}
|
||||||
async function updateRecord(db, recordId, values) {
|
async function updateRecord(db, recordId, values) {
|
||||||
const evRes = await db.prepare(
|
const evRes = await db.prepare(
|
||||||
@@ -3312,7 +3312,7 @@ async function updateRecord(db, recordId, values) {
|
|||||||
}
|
}
|
||||||
async function getRecord(db, recordId) {
|
async function getRecord(db, recordId) {
|
||||||
const res = await db.prepare(
|
const res = await db.prepare(
|
||||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||||
WHERE ev.record_id = ?`
|
WHERE ev.record_id = ?`
|
||||||
).bind(recordId).all();
|
).bind(recordId).all();
|
||||||
@@ -3320,7 +3320,8 @@ async function getRecord(db, recordId) {
|
|||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
const values = {};
|
const values = {};
|
||||||
for (const r of rows) values[r.slot] = r.content;
|
for (const r of rows) values[r.slot] = r.content;
|
||||||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||||
|
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
|
||||||
}
|
}
|
||||||
async function searchByTemplate(db, template, owner_id, limit = 100) {
|
async function searchByTemplate(db, template, owner_id, limit = 100) {
|
||||||
const tpl = await getTemplate(db, template);
|
const tpl = await getTemplate(db, template);
|
||||||
@@ -3339,17 +3340,18 @@ async function searchByTemplate(db, template, owner_id, limit = 100) {
|
|||||||
const chunk = ids.slice(i, i + 90);
|
const chunk = ids.slice(i, i + 90);
|
||||||
const placeholders = chunk.map(() => "?").join(",");
|
const placeholders = chunk.map(() => "?").join(",");
|
||||||
const evRes = await db.prepare(
|
const evRes = await db.prepare(
|
||||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||||
WHERE ev.record_id IN (${placeholders})`
|
WHERE ev.record_id IN (${placeholders})`
|
||||||
).bind(...chunk).all();
|
).bind(...chunk).all();
|
||||||
for (const r of evRes.results ?? []) {
|
for (const r of evRes.results ?? []) {
|
||||||
let rec = byId.get(r.record_id);
|
let rec = byId.get(r.record_id);
|
||||||
if (!rec) {
|
if (!rec) {
|
||||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
|
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||||
byId.set(r.record_id, rec);
|
byId.set(r.record_id, rec);
|
||||||
}
|
}
|
||||||
rec.values[r.slot] = r.content;
|
rec.values[r.slot] = r.content;
|
||||||
|
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ids.map((id) => byId.get(id)).filter((r) => !!r);
|
return ids.map((id) => byId.get(id)).filter((r) => !!r);
|
||||||
@@ -4155,7 +4157,7 @@ app.route("/recipe-stats", recipeStatRoutes);
|
|||||||
app.route("/execution-log", executionLogRoutes);
|
app.route("/execution-log", executionLogRoutes);
|
||||||
app.route("/embed", embedRoutes);
|
app.route("/embed", embedRoutes);
|
||||||
app.route("/map", mapRoutes);
|
app.route("/map", mapRoutes);
|
||||||
var index_default = app;
|
var src_default = app;
|
||||||
export {
|
export {
|
||||||
index_default as default
|
src_default as default
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"built_for": "arcrun-tier2-worker-artifacts",
|
"built_for": "arcrun-tier2-worker-artifacts",
|
||||||
"generated_at": "2026-08-12T07:29:58.626Z",
|
"generated_at": "2026-08-12T16:16:33.433Z",
|
||||||
"repo_head": "1791ffa4972b4135dacd4208e805f67b747479c4",
|
"repo_head": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
|
||||||
"repo_dirty": false,
|
"repo_dirty": false,
|
||||||
"workers": [
|
"workers": [
|
||||||
{
|
{
|
||||||
"name": "arcrun-cypher-executor",
|
"name": "arcrun-cypher-executor",
|
||||||
"source_dir": "cypher-executor",
|
"source_dir": "cypher-executor",
|
||||||
"source_commit": "f1370e2275eea62b64a88821a096f2c2cfe76fb0",
|
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||||
"js_bytes": 577374,
|
"js_bytes": 588397,
|
||||||
"content_sha256": "8411ed59b7ad9e1a74ac0d8e3b620d7166e7d0178ac5939e6cc736f2e8d1d2be",
|
"content_sha256": "e0026a23792f8b6b02e35c91081604b9a761c608a2cae0e6ee8ab0f34a484501",
|
||||||
"modules": [],
|
"modules": [],
|
||||||
"compat_date": "2025-02-19",
|
"compat_date": "2025-02-19",
|
||||||
"compat_flags": [
|
"compat_flags": [
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
{
|
{
|
||||||
"name": "arcrun-kbdb",
|
"name": "arcrun-kbdb",
|
||||||
"source_dir": "kbdb",
|
"source_dir": "kbdb",
|
||||||
"source_commit": "c497ec418eba6cd94b1d5872671c51fd5812c11c",
|
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-kbdb/worker.mjs",
|
"main_file": "arcrun-kbdb/worker.mjs",
|
||||||
"js_bytes": 149533,
|
"js_bytes": 149791,
|
||||||
"content_sha256": "ffb8d43467d0cefbd7545fdc0d347f2b965e3c3de20b3315eed7613f20266891",
|
"content_sha256": "9c6d41895d78cbf3048fb539690591c86071b7d6ebf7fb0b8b51607c6b1f8ee9",
|
||||||
"modules": [],
|
"modules": [],
|
||||||
"compat_date": "2025-02-19",
|
"compat_date": "2025-02-19",
|
||||||
"compat_flags": [
|
"compat_flags": [
|
||||||
@@ -90,8 +90,8 @@
|
|||||||
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-http-request/worker.mjs",
|
"main_file": "arcrun-http-request/worker.mjs",
|
||||||
"js_bytes": 80079,
|
"js_bytes": 80073,
|
||||||
"content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
|
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15",
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"name": "component.wasm",
|
"name": "component.wasm",
|
||||||
@@ -122,8 +122,8 @@
|
|||||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-code/worker.mjs",
|
"main_file": "arcrun-code/worker.mjs",
|
||||||
"js_bytes": 153758,
|
"js_bytes": 153671,
|
||||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5",
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"name": "quickjs.wasm",
|
"name": "quickjs.wasm",
|
||||||
@@ -148,11 +148,11 @@
|
|||||||
{
|
{
|
||||||
"name": "arcrun-mcp",
|
"name": "arcrun-mcp",
|
||||||
"source_dir": "mcp",
|
"source_dir": "mcp",
|
||||||
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-mcp/worker.mjs",
|
"main_file": "arcrun-mcp/worker.mjs",
|
||||||
"js_bytes": 1165388,
|
"js_bytes": 1179229,
|
||||||
"content_sha256": "c5ff10f9b9d5a77217be343af12d2be3ee8f9792d3e1e091e48e5e6c8d24ca9d",
|
"content_sha256": "3ebc0d441bc04ae56a1205507da701f93bed9efa9b1e53c1777c04cbef5bdb67",
|
||||||
"modules": [],
|
"modules": [],
|
||||||
"compat_date": "2024-11-27",
|
"compat_date": "2024-11-27",
|
||||||
"compat_flags": [
|
"compat_flags": [
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"build:harness": "node scripts/build-harness-skill.mjs",
|
"build:harness": "node scripts/build-harness-skill.mjs",
|
||||||
"check:harness": "node scripts/check-harness-generation.mjs",
|
"check:harness": "node scripts/check-harness-generation.mjs",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
"test": "node --test \"tests/**/*.test.ts\"",
|
"test": "node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"",
|
||||||
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { loadConfig } from '../lib/config.js';
|
|||||||
import {
|
import {
|
||||||
wranglerAvailable,
|
wranglerAvailable,
|
||||||
downloadAndDeploy,
|
downloadAndDeploy,
|
||||||
|
namespaceHasKnowledge,
|
||||||
type DeployContext,
|
type DeployContext,
|
||||||
} from '../lib/deploy.js';
|
} from '../lib/deploy.js';
|
||||||
|
|
||||||
@@ -63,6 +64,32 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
|||||||
kbdbEmbed: config.kbdb_embed !== false,
|
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 都找不到」時會停手而不是重建一整套
|
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
|
||||||
//(Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
|
//(Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
|
||||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
|
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
|
||||||
|
|||||||
+21
-2
@@ -170,10 +170,13 @@ export class CfAccountClient implements ResourceApi {
|
|||||||
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
|
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
|
||||||
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
|
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 404) return { deployed: false, bindings: [] };
|
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
|
||||||
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
||||||
}
|
}
|
||||||
return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) };
|
const raw = res.result?.bindings ?? [];
|
||||||
|
// #106:同一份回應裡也帶著 plain_text var(實測 CF `/settings` 會回 `text` 值)。
|
||||||
|
// 舊版只挑資源類、把 var 整批丟掉 → 重部署等於把它們洗掉。
|
||||||
|
return { deployed: true, bindings: normalizeBindings(raw), vars: normalizeVars(raw) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
||||||
@@ -234,6 +237,22 @@ interface RawWorkerBinding {
|
|||||||
id?: string;
|
id?: string;
|
||||||
database_id?: string;
|
database_id?: string;
|
||||||
index_name?: string;
|
index_name?: string;
|
||||||
|
/** `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。 */
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽出已部署 worker 上的 `plain_text` var(#106)。
|
||||||
|
*
|
||||||
|
* 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被 CLI 搬來搬去;
|
||||||
|
* wrangler deploy 不會動 secret,它們自己會留著)。
|
||||||
|
*/
|
||||||
|
function normalizeVars(raw: RawWorkerBinding[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const b of raw) {
|
||||||
|
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
|
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
|
||||||
|
|||||||
+311
-5
@@ -98,6 +98,119 @@ function giteaToken(): string | undefined {
|
|||||||
return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
|
return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本標籤的「發行頻道」來源(Arcrun#106)。
|
||||||
|
*
|
||||||
|
* Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」,
|
||||||
|
* 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號,
|
||||||
|
* 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
|
||||||
|
* fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。
|
||||||
|
*/
|
||||||
|
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';
|
||||||
|
|
||||||
|
/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */
|
||||||
|
export const CLI_MANAGED_VARS = [
|
||||||
|
'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入
|
||||||
|
'CF_ACCOUNT_ID', // 由 ctx.accountId 注入
|
||||||
|
'MULTI_TENANT', // 由 selfHosted 注入
|
||||||
|
'KBDB_BASE_URL', // 由 workerSubdomain 組
|
||||||
|
'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp)
|
||||||
|
'ARCRUN_BUNDLE_COMMIT',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */
|
||||||
|
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';
|
||||||
|
|
||||||
|
/** 這趟部署要烙上去的版本標籤。 */
|
||||||
|
export interface BundleStamp {
|
||||||
|
/** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
|
||||||
|
version: string;
|
||||||
|
/** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */
|
||||||
|
commit?: string;
|
||||||
|
/** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。
|
||||||
|
*
|
||||||
|
* 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。
|
||||||
|
* 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
|
||||||
|
* 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定,
|
||||||
|
* 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。
|
||||||
|
*
|
||||||
|
* 誠實邊界(mindset §7,這段要留著):
|
||||||
|
* - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的,
|
||||||
|
* 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」,
|
||||||
|
* 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去**
|
||||||
|
* (`ARCRUN_BUNDLE_COMMIT`/`/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。
|
||||||
|
* - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+<commit7>` 這個
|
||||||
|
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
|
||||||
|
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
|
||||||
|
*/
|
||||||
|
export async function resolveBundleStamp(
|
||||||
|
ref: string,
|
||||||
|
commit?: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<BundleStamp> {
|
||||||
|
const short = commit ? commit.slice(0, 7) : ref;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
try {
|
||||||
|
const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const body = (await res.json()) as { release?: string } | null;
|
||||||
|
const release = String(body?.release ?? '').trim();
|
||||||
|
if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver(${release || '空'})`);
|
||||||
|
return {
|
||||||
|
version: release,
|
||||||
|
commit,
|
||||||
|
note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''})`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const version = `${today}+${short}`;
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
commit,
|
||||||
|
note:
|
||||||
|
`${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)})` +
|
||||||
|
`\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `ref`(branch / tag / sha)解析成確切的 commit sha(Arcrun#106)。
|
||||||
|
*
|
||||||
|
* 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**——
|
||||||
|
* sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
|
||||||
|
* 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。
|
||||||
|
*/
|
||||||
|
export async function resolveGiteaCommit(
|
||||||
|
ref: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const headers = buildDownloadHeaders();
|
||||||
|
const tryUrls = [
|
||||||
|
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
|
||||||
|
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
|
||||||
|
];
|
||||||
|
for (const url of tryUrls) {
|
||||||
|
try {
|
||||||
|
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const body = (await res.json()) as
|
||||||
|
| { commit?: { id?: string } }
|
||||||
|
| Array<{ sha?: string }>
|
||||||
|
| null;
|
||||||
|
const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
|
||||||
|
if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
|
||||||
|
} catch {
|
||||||
|
/* 換下一種問法;全都問不到就回 undefined */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
|
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
|
||||||
* Gitea archive API:`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
|
* Gitea archive API:`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
|
||||||
@@ -185,6 +298,44 @@ export interface DeployContext {
|
|||||||
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
|
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
|
||||||
// base 維持 LIKE keyword(free-tier 友善)。
|
// base 維持 LIKE keyword(free-tier 友善)。
|
||||||
kbdbEmbed?: boolean;
|
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=<ns>`(cypher 既有的純轉發端點,CLI 平常就在用
|
||||||
|
* 這條路 + `X-Arcrun-API-Key`)。回傳:
|
||||||
|
* true = 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的
|
||||||
|
* false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空)
|
||||||
|
* null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事
|
||||||
|
*
|
||||||
|
* 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。
|
||||||
|
*/
|
||||||
|
export async function namespaceHasKnowledge(
|
||||||
|
cypherUrl: string,
|
||||||
|
namespace: string,
|
||||||
|
): Promise<boolean | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -253,9 +404,12 @@ export async function downloadAndDeploy(
|
|||||||
const mode = opts.mode ?? 'update';
|
const mode = opts.mode ?? 'update';
|
||||||
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
|
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
|
||||||
// 1. 下載 + 解壓 Gitea archive tarball
|
// 1. 下載 + 解壓 Gitea archive tarball
|
||||||
|
// #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題),
|
||||||
|
// 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。
|
||||||
|
const commit = await resolveGiteaCommit(ref);
|
||||||
let root: string;
|
let root: string;
|
||||||
try {
|
try {
|
||||||
root = await downloadRepoTarball(ref);
|
root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
implemented: true,
|
implemented: true,
|
||||||
@@ -310,6 +464,7 @@ export async function downloadAndDeploy(
|
|||||||
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
|
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
|
||||||
const requirements: BindingRequirement[] = [];
|
const requirements: BindingRequirement[] = [];
|
||||||
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
|
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
|
||||||
|
const dirScript = new Map<string, string>(); // dir → worker script 名(#106:var 沿用要逐顆對號)
|
||||||
for (const dir of allDirs) {
|
for (const dir of allDirs) {
|
||||||
const tomlPath = join(dir, 'wrangler.toml');
|
const tomlPath = join(dir, 'wrangler.toml');
|
||||||
if (!existsSync(tomlPath)) continue;
|
if (!existsSync(tomlPath)) continue;
|
||||||
@@ -318,12 +473,14 @@ export async function downloadAndDeploy(
|
|||||||
const preview = renderWranglerToml(raw, ctx, new Map());
|
const preview = renderWranglerToml(raw, ctx, new Map());
|
||||||
const parsed = parseWranglerRequirements(preview);
|
const parsed = parseWranglerRequirements(preview);
|
||||||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||||||
|
dirScript.set(dir, parsed.script);
|
||||||
for (const b of parsed.bindings) {
|
for (const b of parsed.bindings) {
|
||||||
requirements.push({ ...b, worker: parsed.script });
|
requirements.push({ ...b, worker: parsed.script });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolved = new Map<string, ResolvedResource>();
|
let resolved = new Map<string, ResolvedResource>();
|
||||||
|
let liveVars = new Map<string, Record<string, string>>();
|
||||||
if (requirements.length > 0) {
|
if (requirements.length > 0) {
|
||||||
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
|
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
|
||||||
let plan;
|
let plan;
|
||||||
@@ -369,6 +526,7 @@ export async function downloadAndDeploy(
|
|||||||
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
|
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
liveVars = plan.liveVars;
|
||||||
console.log(chalk.green(' ✓'));
|
console.log(chalk.green(' ✓'));
|
||||||
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
|
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
|
||||||
const created = [...resolved.values()].filter((r) => r.origin === 'created');
|
const created = [...resolved.values()].filter((r) => r.origin === 'created');
|
||||||
@@ -407,6 +565,63 @@ export async function downloadAndDeploy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 2.8 var(plain_text):既有的沿用、版本標籤重烙(Arcrun#106)─────────────────
|
||||||
|
//
|
||||||
|
// 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**:
|
||||||
|
// wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
|
||||||
|
// 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
|
||||||
|
//
|
||||||
|
// 兩種 var 走**相反**的規則,這是本次的核心判斷:
|
||||||
|
// · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用
|
||||||
|
// · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤
|
||||||
|
//
|
||||||
|
// 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。
|
||||||
|
// 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
|
||||||
|
// toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。
|
||||||
|
const extraVarsByDir = new Map<string, Record<string, string>>();
|
||||||
|
let stamp: BundleStamp | undefined;
|
||||||
|
if (dirScript.size > 0) {
|
||||||
|
const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
|
||||||
|
if (needStamp) {
|
||||||
|
process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...'));
|
||||||
|
stamp = await resolveBundleStamp(ref, commit);
|
||||||
|
console.log(chalk.green(' ✓'));
|
||||||
|
console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
|
||||||
|
}
|
||||||
|
const preservedTotal: string[] = [];
|
||||||
|
for (const [dir, script] of dirScript) {
|
||||||
|
const raw = tomlPreviews.get(dir);
|
||||||
|
if (!raw) continue;
|
||||||
|
const keep = preservedVars(liveVars.get(script), raw);
|
||||||
|
for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
|
||||||
|
const vars: Record<string, string> = { ...keep };
|
||||||
|
if (stamp && script === VERSION_STAMP_WORKER) {
|
||||||
|
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) {
|
||||||
|
console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`));
|
||||||
|
for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||||
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
|
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
|
||||||
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
|
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
|
||||||
@@ -422,7 +637,7 @@ export async function downloadAndDeploy(
|
|||||||
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
|
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
|
||||||
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
|
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
|
||||||
try {
|
try {
|
||||||
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir));
|
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir));
|
||||||
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
|
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
|
||||||
const hash = dirContentHash(dir, ctx.accountId);
|
const hash = dirContentHash(dir, ctx.accountId);
|
||||||
if (manifest[label] === hash) {
|
if (manifest[label] === hash) {
|
||||||
@@ -599,11 +814,13 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: str
|
|||||||
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。
|
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。
|
||||||
*
|
*
|
||||||
* Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/
|
* Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/
|
||||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
|
||||||
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
|
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
|
||||||
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const url = buildArchiveUrl(ref, bust);
|
const url = buildArchiveUrl(ref, bust);
|
||||||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`));
|
// fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。
|
||||||
|
const label = fromRef ? `${fromRef} → ${ref.slice(0, 7)}` : ref;
|
||||||
|
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 10–30 秒,視網速)...`));
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
signal: AbortSignal.timeout(120_000),
|
signal: AbortSignal.timeout(120_000),
|
||||||
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。
|
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。
|
||||||
@@ -701,11 +918,91 @@ function injectWranglerConfig(
|
|||||||
ctx: DeployContext,
|
ctx: DeployContext,
|
||||||
resolved: Map<string, ResolvedResource>,
|
resolved: Map<string, ResolvedResource>,
|
||||||
original?: string,
|
original?: string,
|
||||||
|
extraVars: Record<string, string> = {},
|
||||||
): void {
|
): void {
|
||||||
if (!existsSync(tomlPath)) return;
|
if (!existsSync(tomlPath)) return;
|
||||||
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
|
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
|
||||||
const toml = original ?? readFileSync(tomlPath, 'utf8');
|
const toml = original ?? readFileSync(tomlPath, 'utf8');
|
||||||
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8');
|
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text var(Arcrun#106)。
|
||||||
|
*
|
||||||
|
* 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話,
|
||||||
|
* 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外:
|
||||||
|
* ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤),
|
||||||
|
* 沿用等於拿舊值蓋掉正解。
|
||||||
|
* ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。
|
||||||
|
*
|
||||||
|
* ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病
|
||||||
|
* ——repo toml 裡的 `CONSOLE_TENANT = "leo"`/`WORKER_SUBDOMAIN` 之類是**官方 prod 的值**,
|
||||||
|
* 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。
|
||||||
|
*/
|
||||||
|
export function preservedVars(
|
||||||
|
live: Record<string, string> | undefined,
|
||||||
|
toml: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!live) return out;
|
||||||
|
const managed = new Set<string>(CLI_MANAGED_VARS);
|
||||||
|
for (const key of Object.keys(live).sort()) {
|
||||||
|
if (managed.has(key)) continue;
|
||||||
|
if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報)
|
||||||
|
if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動
|
||||||
|
out[key] = live[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */
|
||||||
|
function readVar(toml: string, key: string): string | undefined {
|
||||||
|
const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
|
||||||
|
return m?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */
|
||||||
|
function tomlEscape(value: string): string {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一組 var 寫進 toml 的 `[vars]`(Arcrun#106)。純函式。
|
||||||
|
*
|
||||||
|
* 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級):
|
||||||
|
* 1. 已有未註解的同名行 → 換值
|
||||||
|
* 2. 只有被註解掉的同名行 → 取消註解並填值
|
||||||
|
* 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段
|
||||||
|
*/
|
||||||
|
export function applyVars(toml: string, vars: Record<string, string>): string {
|
||||||
|
let out = toml;
|
||||||
|
for (const key of Object.keys(vars).sort()) {
|
||||||
|
// 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。
|
||||||
|
if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
|
||||||
|
const value = tomlEscape(vars[key]);
|
||||||
|
// 🔴 一律用「函式版 replace」:值裡若有 `$&`/`$1` 這種字元,字串版 replace 會把它當成
|
||||||
|
// 反向參照展開,寫出來的就不是使用者那個值了。
|
||||||
|
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
|
||||||
|
out = out.replace(
|
||||||
|
new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
|
||||||
|
(_m, head: string, tail: string) => `${head}${value}${tail}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
|
||||||
|
out = out.replace(
|
||||||
|
new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
|
||||||
|
(_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\s*\[vars\]\s*$/m.test(out)) {
|
||||||
|
out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -715,11 +1012,15 @@ function injectWranglerConfig(
|
|||||||
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
|
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
|
||||||
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
|
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
|
||||||
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
|
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
|
||||||
|
*
|
||||||
|
* `extraVars`(Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
|
||||||
|
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
|
||||||
*/
|
*/
|
||||||
export function renderWranglerToml(
|
export function renderWranglerToml(
|
||||||
toml: string,
|
toml: string,
|
||||||
ctx: DeployContext,
|
ctx: DeployContext,
|
||||||
resolved: Map<string, ResolvedResource>,
|
resolved: Map<string, ResolvedResource>,
|
||||||
|
extraVars: Record<string, string> = {},
|
||||||
): string {
|
): string {
|
||||||
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
||||||
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
||||||
@@ -770,6 +1071,11 @@ export function renderWranglerToml(
|
|||||||
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
|
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**:
|
||||||
|
// CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的
|
||||||
|
// WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
|
||||||
|
toml = applyVars(toml, extraVars);
|
||||||
|
|
||||||
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
|
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
|
||||||
// 空 map = 預覽模式,這步什麼也不做。
|
// 空 map = 預覽模式,這步什麼也不做。
|
||||||
return applyResolvedBindings(toml, resolved);
|
return applyResolvedBindings(toml, resolved);
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ export interface ScriptBindings {
|
|||||||
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
|
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
|
||||||
deployed: boolean;
|
deployed: boolean;
|
||||||
bindings: LiveBinding[];
|
bindings: LiveBinding[];
|
||||||
|
/**
|
||||||
|
* 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。
|
||||||
|
*
|
||||||
|
* 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize),
|
||||||
|
* plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。
|
||||||
|
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)——
|
||||||
|
* 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。
|
||||||
|
* **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。
|
||||||
|
*/
|
||||||
|
vars?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
|
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
|
||||||
@@ -87,6 +97,14 @@ export interface ResourcePlan {
|
|||||||
create: PlannedCreate[];
|
create: PlannedCreate[];
|
||||||
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
|
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
|
/**
|
||||||
|
* 每顆**已部署** worker 現在掛著的 plain_text var(script → 名/值)。未部署的不在裡面。
|
||||||
|
*
|
||||||
|
* Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡——
|
||||||
|
* 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式
|
||||||
|
* (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。
|
||||||
|
*/
|
||||||
|
liveVars: Map<string, Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedResource {
|
export interface ResolvedResource {
|
||||||
@@ -137,11 +155,16 @@ export async function planResources(
|
|||||||
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
|
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
|
||||||
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
|
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
|
||||||
const live = new Map<string, LiveBinding[]>();
|
const live = new Map<string, LiveBinding[]>();
|
||||||
|
const liveVars = new Map<string, Record<string, string>>();
|
||||||
let readFailed = false;
|
let readFailed = false;
|
||||||
for (const script of scripts) {
|
for (const script of scripts) {
|
||||||
try {
|
try {
|
||||||
const res = await api.getScriptBindings(script);
|
const res = await api.getScriptBindings(script);
|
||||||
if (res.deployed) live.set(script, res.bindings);
|
if (res.deployed) {
|
||||||
|
live.set(script, res.bindings);
|
||||||
|
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
|
||||||
|
liveVars.set(script, res.vars ?? {});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
readFailed = true;
|
readFailed = true;
|
||||||
blockers.push(
|
blockers.push(
|
||||||
@@ -242,7 +265,7 @@ export async function planResources(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { adopt, create: shareSameResource(adopt, create, byKey), blockers };
|
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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<string, string>): Record<string, string> {
|
||||||
|
const keep = preservedVars(live, CYPHER_TOML);
|
||||||
|
const extra: Record<string, string> = { ...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<string, string>)['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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** `node --import ./tests/register-ts-hooks.mjs --test ...` 的進入點:註冊 ts-hooks.mjs。 */
|
||||||
|
import { register } from 'node:module';
|
||||||
|
|
||||||
|
register('./ts-hooks.mjs', import.meta.url);
|
||||||
@@ -493,7 +493,7 @@ test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 t
|
|||||||
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
|
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
|
||||||
) as typeof fetch;
|
) as typeof fetch;
|
||||||
const cf = new CfAccountClient('a', 't');
|
const cf = new CfAccountClient('a', 't');
|
||||||
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] });
|
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [], vars: {} });
|
||||||
|
|
||||||
globalThis.fetch = (async () =>
|
globalThis.fetch = (async () =>
|
||||||
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
|
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
|
||||||
@@ -526,6 +526,8 @@ test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize
|
|||||||
{ kind: 'd1', binding: 'DB', value: 'db1' },
|
{ kind: 'd1', binding: 'DB', value: 'db1' },
|
||||||
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
|
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
|
||||||
]);
|
]);
|
||||||
|
// #106:plain_text 也要收下來(service 這種不認得的仍略過)。
|
||||||
|
assert.deepEqual(res.vars, { ENVIRONMENT: 'production' });
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = orig;
|
globalThis.fetch = orig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 測試用 resolve hook:把 `./x.js` 這種 import 指回同名的 `./x.ts`(Arcrun#106 附帶修復)。
|
||||||
|
*
|
||||||
|
* 為什麼需要:`src/` 內部的 import 一律寫成 `.js`(NodeNext 慣例,編譯後才會有那個檔),
|
||||||
|
* 但測試是**直接載入 `src/**\/*.ts`**、不經過 tsc(`outDir: dist`,所以 `src/` 底下永遠不會有 .js)。
|
||||||
|
* Node 的型別剝離不會自己把 `.js` 對回 `.ts` ⇒ 三份測試在 node 22 上**一支都跑不起來**
|
||||||
|
* (`ERR_MODULE_NOT_FOUND: .../src/lib/cf-api.js`)——包含 #97 那份「使用者的東西還在不在」的迴歸守衛。
|
||||||
|
* 跑不起來的守衛等於沒有守衛,所以這裡補上。
|
||||||
|
*
|
||||||
|
* 只在「預設解析失敗」時才動作,且只換副檔名 → 對本來就解析得到的環境(新版 node / 已編譯)零影響。
|
||||||
|
*/
|
||||||
|
export async function resolve(specifier, context, next) {
|
||||||
|
try {
|
||||||
|
return await next(specifier, context);
|
||||||
|
} catch (err) {
|
||||||
|
if (typeof specifier === 'string' && specifier.endsWith('.js')) {
|
||||||
|
return next(specifier.slice(0, -3) + '.ts', context);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Arcrun#106 迴歸守衛 —— 「更新完,設定頁還看得到版本號,而且是**這次**的版本號」
|
||||||
|
*
|
||||||
|
* 2026-08-12 實害:leo 更新完 leo21c,Portal 設定頁的版本欄變成
|
||||||
|
* 「無法讀取目前版本(知識庫服務可能正在啟動)」。
|
||||||
|
* 根因:`ARCRUN_BUNDLE_VERSION` 是部署時注入的 plain_text var,**只有安裝器會注入**;
|
||||||
|
* CLI 這條路重部署時 wrangler 整份覆蓋 toml,沒寫的 var 直接消失 ⇒ 標籤被洗掉。
|
||||||
|
* #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),**沒修「櫃子上的標籤」**。
|
||||||
|
*
|
||||||
|
* 這份測試守兩件相反的事(本次的核心判斷):
|
||||||
|
* · 設定類 var(安裝器注入的 PORTAL_MAIL_RELAY_BASE 之類)=使用者實例的事實 → **沿用**
|
||||||
|
* · 版本標籤 ARCRUN_BUNDLE_VERSION =這份成品的屬性 → **每趟重烙,絕不沿用舊值**
|
||||||
|
* (沿用舊值 = 一個永遠停在安裝當天的假標籤,比沒有標籤更糟)
|
||||||
|
*
|
||||||
|
* 全部離線跑:真的 wrangler.toml + 真的 render/inject 程式碼,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,
|
||||||
|
applyVars,
|
||||||
|
resolveBundleStamp,
|
||||||
|
CLI_MANAGED_VARS,
|
||||||
|
VERSION_STAMP_WORKER,
|
||||||
|
type DeployContext,
|
||||||
|
} from '../src/lib/deploy.ts';
|
||||||
|
import { planResources, type ResourceApi, type ScriptBindings } from '../src/lib/resource-resolver.ts';
|
||||||
|
|
||||||
|
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||||
|
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
|
||||||
|
|
||||||
|
const CTX: DeployContext = {
|
||||||
|
accountId: 'acc-user-123',
|
||||||
|
apiToken: 'token',
|
||||||
|
workerSubdomain: 'user-sub',
|
||||||
|
selfHosted: true,
|
||||||
|
kbdbEmbed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 一台「安裝器裝出來、已經跑過的」實例上,cypher worker 現在掛著的 plain_text var。 */
|
||||||
|
const LIVE_VARS: Record<string, string> = {
|
||||||
|
ARCRUN_BUNDLE_VERSION: '1.4.29', // 安裝當時的舊標籤
|
||||||
|
PORTAL_MAIL_RELAY_BASE: 'https://mail.example.com', // 安裝器注入、repo toml 沒有 → 洗掉就寄不出信
|
||||||
|
CONSOLE_TENANT: 'someone-else', // repo toml 寫死 "leo",不能拿官方值蓋掉人家的
|
||||||
|
WORKER_SUBDOMAIN: 'user-sub', // CLI 自己算
|
||||||
|
CF_ACCOUNT_ID: 'acc-user-123', // CLI 自己算
|
||||||
|
MULTI_TENANT: 'false', // CLI 自己算
|
||||||
|
ENVIRONMENT: 'production', // 與 toml 同值 → 不必重寫
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 從 render 過的 toml 讀 [vars] 區塊(只看未註解的行)。 */
|
||||||
|
function readVars(toml: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
let inVars = false;
|
||||||
|
for (const raw of toml.split('\n')) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (/^\[\[?[A-Za-z0-9_]+\]?\]$/.test(line)) { inVars = line === '[vars]'; continue; }
|
||||||
|
if (!inVars || line.startsWith('#')) continue;
|
||||||
|
const m = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||||
|
if (m) out[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// ① 病灶本身:舊行為會把標籤洗掉
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test('#106 ①:repo 的 cypher toml 本來就沒有 ARCRUN_BUNDLE_VERSION——不補就是洗掉(病灶重現)', () => {
|
||||||
|
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||||||
|
assert.equal(
|
||||||
|
readVars(rendered).ARCRUN_BUNDLE_VERSION,
|
||||||
|
undefined,
|
||||||
|
'若這行開始有值,表示 toml 自己帶了版本標籤,本測試的前提要重寫',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// ② 設定類 var:沿用實例上的事實
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test('#106 ②:安裝器注入、repo toml 沒有的 var 會被沿用(不再被重部署洗掉)', () => {
|
||||||
|
const keep = preservedVars(LIVE_VARS, CYPHER_TOML);
|
||||||
|
assert.equal(keep.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||||
|
// repo toml 寫死的是官方值,使用者實例上的值才是事實
|
||||||
|
assert.equal(keep.CONSOLE_TENANT, 'someone-else');
|
||||||
|
// 與 toml 同值 → 不需要重寫進去(雜訊)
|
||||||
|
assert.equal(keep.ENVIRONMENT, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#106 ③:CLI 自己算的 var 一律不沿用(沿用等於拿舊值蓋掉這趟的正解)', () => {
|
||||||
|
const keep = preservedVars({ ...LIVE_VARS, WORKER_SUBDOMAIN: 'OLD-sub', CF_ACCOUNT_ID: 'OLD-acc' }, CYPHER_TOML);
|
||||||
|
for (const managed of CLI_MANAGED_VARS) {
|
||||||
|
assert.equal(keep[managed], undefined, `${managed} 不該被沿用`);
|
||||||
|
}
|
||||||
|
// 而且注入完的 toml 裡,這些值仍是這趟算出來的那個
|
||||||
|
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map(), keep);
|
||||||
|
const vars = readVars(rendered);
|
||||||
|
assert.equal(vars.WORKER_SUBDOMAIN, 'user-sub');
|
||||||
|
assert.equal(vars.CF_ACCOUNT_ID, 'acc-user-123');
|
||||||
|
assert.equal(vars.MULTI_TENANT, 'false');
|
||||||
|
assert.equal(vars.KBDB_BASE_URL, 'https://arcrun-kbdb.user-sub.workers.dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// ③ 版本標籤:重烙,不沿用
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test('#106 ④:版本標籤取「發行頻道公告的 release」+ 實際 commit,不是沿用舊值', async () => {
|
||||||
|
const fakeFetch = (async () =>
|
||||||
|
new Response(JSON.stringify({ release: '1.4.41', pin: 'ba81439' }), { status: 200 })) as typeof fetch;
|
||||||
|
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||||
|
assert.equal(stamp.version, '1.4.41');
|
||||||
|
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); // ← 這就是本 issue
|
||||||
|
assert.equal(stamp.commit, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||||
|
assert.match(stamp.version, /^\d+\.\d+\.\d+$/, 'Portal 拿它跟 /api/latest 比 semver,必須是純 semver');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#106 ⑤:查不到發行版號時誠實標成 commit 版,**不**沿用舊值、也不掰一個 semver', async () => {
|
||||||
|
const fakeFetch = (async () => { throw new Error('offline'); }) as typeof fetch;
|
||||||
|
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||||
|
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+f87d0e9$/);
|
||||||
|
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION);
|
||||||
|
assert.doesNotMatch(stamp.version, /^\d+\.\d+\.\d+$/, '掰一個 semver 會讓 Portal 假裝「已是最新版」');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#106 ⑥:發行頻道回了不是 semver 的東西 → 當成查不到(不把垃圾當版號烙上去)', async () => {
|
||||||
|
const fakeFetch = (async () =>
|
||||||
|
new Response(JSON.stringify({ release: 'latest' }), { status: 200 })) as typeof fetch;
|
||||||
|
const stamp = await resolveBundleStamp('main', 'abc1234def', fakeFetch);
|
||||||
|
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+abc1234$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// ④ 端到端(離線):一台已安裝的實例跑一次更新,Portal 讀得到的那個欄位長什麼樣
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test('#106 ⑦:模擬更新——版本標籤變新、設定 var 一個不少、資源沿用不受影響', async () => {
|
||||||
|
const api: ResourceApi = {
|
||||||
|
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||||
|
if (script !== VERSION_STAMP_WORKER) return { deployed: false, bindings: [], vars: {} };
|
||||||
|
return {
|
||||||
|
deployed: true,
|
||||||
|
bindings: [
|
||||||
|
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv-webhooks' },
|
||||||
|
{ kind: 'kv_namespace', binding: 'CREDENTIALS_KV', value: 'kv-creds' },
|
||||||
|
{ kind: 'kv_namespace', binding: 'RECIPES', value: 'kv-recipes' },
|
||||||
|
{ kind: 'kv_namespace', binding: 'USERS_KV', value: 'kv-users' },
|
||||||
|
{ kind: 'kv_namespace', binding: 'SESSIONS_KV', value: 'kv-sessions' },
|
||||||
|
{ kind: 'kv_namespace', binding: 'ANALYTICS_KV', value: 'kv-analytics' },
|
||||||
|
{ kind: 'kv_namespace', binding: 'EXEC_CONTEXT', value: 'kv-exec' },
|
||||||
|
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1-kbdb' },
|
||||||
|
],
|
||||||
|
vars: LIVE_VARS,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async listKvNamespaces() {
|
||||||
|
return new Map([
|
||||||
|
['a', 'kv-webhooks'], ['b', 'kv-creds'], ['c', 'kv-recipes'], ['d', 'kv-users'],
|
||||||
|
['e', 'kv-sessions'], ['f', 'kv-analytics'], ['g', 'kv-exec'],
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
async listD1Databases() { return new Map([['arcrun-kbdb', 'd1-kbdb']]); },
|
||||||
|
async listVectorizeIndexes() { return []; },
|
||||||
|
async createKvNamespace() { throw new Error('這趟不該新建任何 KV'); },
|
||||||
|
async createD1Database() { throw new Error('這趟不該新建 D1'); },
|
||||||
|
async createVectorizeIndex() { throw new Error('這趟不該新建 Vectorize'); },
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = renderWranglerToml(CYPHER_TOML, CTX, new Map());
|
||||||
|
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||||||
|
const parsed = parseWranglerRequirements(preview);
|
||||||
|
const plan = await planResources(
|
||||||
|
api,
|
||||||
|
parsed.bindings.map((b) => ({ ...b, worker: parsed.script })),
|
||||||
|
'update',
|
||||||
|
);
|
||||||
|
assert.deepEqual(plan.blockers, []);
|
||||||
|
// 讀綁定時順手把 var 帶回來——不另外打一次 API
|
||||||
|
assert.equal(plan.liveVars.get(VERSION_STAMP_WORKER)?.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||||
|
|
||||||
|
const fakeFetch = (async () =>
|
||||||
|
new Response(JSON.stringify({ release: '1.4.41' }), { status: 200 })) as typeof fetch;
|
||||||
|
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
|
||||||
|
const extra = {
|
||||||
|
...preservedVars(plan.liveVars.get(parsed.script), CYPHER_TOML),
|
||||||
|
ARCRUN_BUNDLE_VERSION: stamp.version,
|
||||||
|
ARCRUN_BUNDLE_COMMIT: stamp.commit!,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deployed = readVars(renderWranglerToml(CYPHER_TOML, CTX, new Map(), extra));
|
||||||
|
|
||||||
|
// ① Portal 設定頁讀的就是這個欄位——更新完必須有值,且是**這趟**的版本
|
||||||
|
assert.equal(deployed.ARCRUN_BUNDLE_VERSION, '1.4.41');
|
||||||
|
assert.equal(deployed.ARCRUN_BUNDLE_COMMIT, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||||
|
// ② 安裝器注入的設定沒有在更新中消失
|
||||||
|
assert.equal(deployed.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
|
||||||
|
assert.equal(deployed.CONSOLE_TENANT, 'someone-else');
|
||||||
|
// ③ CLI 自己算的仍然是這趟算出來的
|
||||||
|
assert.equal(deployed.WORKER_SUBDOMAIN, 'user-sub');
|
||||||
|
assert.equal(deployed.MULTI_TENANT, 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// ⑤ applyVars 的三種既有狀態 + 不弄壞別的區塊
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test('#106 ⑧:applyVars——改既有行/取消註解/插進 [vars]/連 [vars] 都沒有時新開一段', () => {
|
||||||
|
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'new' }), /^\[vars\]\nA = "new"\n$/);
|
||||||
|
assert.match(applyVars('[vars]\n# A = "old"\n', { A: 'new' }), /A = "new"/);
|
||||||
|
assert.match(applyVars('[vars]\nB = "b"\n', { A: 'a' }), /\[vars\]\nA = "a"\nB = "b"/);
|
||||||
|
const noVars = applyVars('name = "w"\n', { A: 'a' });
|
||||||
|
assert.match(noVars, /\[vars\]\nA = "a"/);
|
||||||
|
assert.match(noVars, /^name = "w"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#106 ⑨:var 值裡的引號/反斜線會被轉義(不會產生壞掉的 toml)', () => {
|
||||||
|
const out = applyVars('[vars]\n', { A: 'say "hi"\\path' });
|
||||||
|
assert.match(out, /A = "say \\"hi\\"\\\\path"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#106 ⑨b:值裡有 $& / $1 也照原樣寫出(replace 反向參照陷阱)', () => {
|
||||||
|
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'x$&y$1z' }), /A = "x\$&y\$1z"/);
|
||||||
|
assert.match(applyVars('[vars]\n', { A: 'x$&y' }), /A = "x\$&y"/);
|
||||||
|
// 怪名字不寫進去(不拿它組正規式)
|
||||||
|
assert.equal(applyVars('[vars]\n', { 'BAD NAME': 'v' }), '[vars]\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#106 ⑩:注入 var 不影響資源綁定解析(預覽與實際寫入看到的是同一份需求)', async () => {
|
||||||
|
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
|
||||||
|
const withoutVars = parseWranglerRequirements(renderWranglerToml(CYPHER_TOML, CTX, new Map()));
|
||||||
|
const withVars = parseWranglerRequirements(
|
||||||
|
renderWranglerToml(CYPHER_TOML, CTX, new Map(), { ARCRUN_BUNDLE_VERSION: '1.4.41', X: 'y' }),
|
||||||
|
);
|
||||||
|
assert.equal(withVars.script, withoutVars.script);
|
||||||
|
assert.deepEqual(withVars.bindings, withoutVars.bindings);
|
||||||
|
});
|
||||||
@@ -2039,6 +2039,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
// 兩邊都是 semver(例 1.4.2),用數字逐段比,不用字串比('1.4.10' < '1.4.9' 會出錯)。
|
// 兩邊都是 semver(例 1.4.2),用數字逐段比,不用字串比('1.4.10' < '1.4.9' 會出錯)。
|
||||||
var INSTALLER_ORIGIN = 'https://install.arcrun.dev';
|
var INSTALLER_ORIGIN = 'https://install.arcrun.dev';
|
||||||
|
|
||||||
|
// Arcrun#106:版號後面可以帶 build metadata(`1.4.41+d61`、`1.4.41+a1b2c3d`)——
|
||||||
|
// 那是 semver 規格裡「比大小時要忽略」的那一段。舊寫法拿整串去比對正規式,
|
||||||
|
// 一律落到「較舊版本」(youlin 實例就是這樣,明明有版號卻顯示不出來)。
|
||||||
|
// 這裡只取前面的 `x.y.z` 當比較用的核心,顯示仍顯示完整原字串。
|
||||||
|
function semverCore(v) {
|
||||||
|
var m = String(v || '').match(/^(\d+\.\d+\.\d+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
function cmpSemver(a, b) {
|
function cmpSemver(a, b) {
|
||||||
var x = String(a || '').split('.').map(Number);
|
var x = String(a || '').split('.').map(Number);
|
||||||
var y = String(b || '').split('.').map(Number);
|
var y = String(b || '').split('.').map(Number);
|
||||||
@@ -2055,9 +2064,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
var btn = $('st-ver-update');
|
var btn = $('st-ver-update');
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
|
|
||||||
|
// #106:順便把 bundle_commit 帶回來(有注入才有)——版號是頻道編號,commit 才是「真的部了哪份碼」。
|
||||||
|
var mineCommit = '';
|
||||||
var mineP = fetch(window.ARCRUN_API_BASE + '/health', { cache: 'no-store' })
|
var mineP = fetch(window.ARCRUN_API_BASE + '/health', { cache: 'no-store' })
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
.then(function (j) { return (j && j.bundle_version) || ''; })
|
.then(function (j) {
|
||||||
|
mineCommit = (j && j.bundle_commit) || '';
|
||||||
|
return (j && j.bundle_version) || '';
|
||||||
|
})
|
||||||
.catch(function () { return ''; });
|
.catch(function () { return ''; });
|
||||||
var latestP = fetch(INSTALLER_ORIGIN + '/api/latest')
|
var latestP = fetch(INSTALLER_ORIGIN + '/api/latest')
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
@@ -2070,15 +2084,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
if (!mine) { line.textContent = '無法讀取目前版本(知識庫服務可能正在啟動)'; return; }
|
if (!mine) { line.textContent = '無法讀取目前版本(知識庫服務可能正在啟動)'; return; }
|
||||||
// 舊實例的 bundle_version 是舊格式(2026-07-31+8e83589),比不了 semver。
|
// 舊實例的 bundle_version 是舊格式(2026-07-31+8e83589),比不了 semver。
|
||||||
// 這種情況一律當成「落後」——因為新版才會寫 semver 進來。
|
// 這種情況一律當成「落後」——因為新版才會寫 semver 進來。
|
||||||
var mineIsSemver = /^\d+\.\d+\.\d+$/.test(mine);
|
// #106:`1.4.41+<commit>` 這種帶 build metadata 的**是** semver,取核心比即可。
|
||||||
|
var mineCore = semverCore(mine);
|
||||||
|
var mineIsSemver = !!mineCore;
|
||||||
|
// commit 是輔助資訊(有才顯示):版號說「哪一版」,commit 說「真的是哪份碼」。
|
||||||
|
var commitNote = mineCommit ? ' <span class="muted">commit ' + esc(String(mineCommit).slice(0, 7)) + '</span>' : '';
|
||||||
|
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
line.textContent = '目前版本 ' + mine + '(暫時查不到最新版,稍後再試)';
|
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong>(暫時查不到最新版,稍後再試)' + commitNote;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var behind = !mineIsSemver || cmpSemver(mine, latest) < 0;
|
var behind = !mineIsSemver || cmpSemver(mineCore, latest) < 0;
|
||||||
if (!behind) {
|
if (!behind) {
|
||||||
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版';
|
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版' + commitNote;
|
||||||
dot.style.display = 'none';
|
dot.style.display = 'none';
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev",
|
"dev": "wrangler dev",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"test": "vitest run"
|
"check:tenant": "node scripts/check-tenant-source.mjs",
|
||||||
|
"test": "node scripts/check-tenant-source.mjs && vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/zod-openapi": "^1.2.4",
|
"@hono/zod-openapi": "^1.2.4",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
readAuthStore,
|
readAuthStore,
|
||||||
type AuthConsoleRecord,
|
type AuthConsoleRecord,
|
||||||
} from '../lib/portal-auth-store';
|
} from '../lib/portal-auth-store';
|
||||||
|
// Arcrun#108:租戶字串唯一產地。
|
||||||
|
import { knowledgeOwner } from '../lib/tenant';
|
||||||
|
|
||||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
@@ -79,8 +81,17 @@ async function hashPassword(password: string, salt: string): Promise<string> {
|
|||||||
return h;
|
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 {
|
function tenantOf(c: { env: Bindings }): string {
|
||||||
return c.env.CONSOLE_TENANT || 'leo';
|
return knowledgeOwner(c.env);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ import {
|
|||||||
taipeiDayKey,
|
taipeiDayKey,
|
||||||
} from '../lib/console-dashboard-model';
|
} from '../lib/console-dashboard-model';
|
||||||
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-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 }>();
|
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
@@ -265,7 +268,7 @@ export async function cachedGiteaSprint(
|
|||||||
|
|
||||||
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
||||||
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
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 now = Date.now();
|
||||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||||
const graphUrl = graphBase(c.env);
|
const graphUrl = graphBase(c.env);
|
||||||
@@ -451,7 +454,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
|||||||
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
|
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
|
||||||
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
|
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
|
||||||
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
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 { base, headers } = kbdbBase(c.env);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
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'));
|
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||||
if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
|
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([
|
const [todoEntries, inboxEntries] = await Promise.all([
|
||||||
fetchEntries(c.env, tenant, 'todo', 500),
|
fetchEntries(c.env, tenant, 'todo', 500),
|
||||||
fetchEntries(c.env, tenant, 'inbox', 200),
|
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);
|
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
|
||||||
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
|
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);
|
const { base, headers } = kbdbBase(c.env);
|
||||||
|
|
||||||
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
|
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
|
||||||
|
|||||||
@@ -15,11 +15,19 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
|||||||
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
|
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
|
||||||
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
|
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
|
||||||
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
|
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
|
||||||
|
// Arcrun#106(leo 08-12 實撞:更新完設定頁變成「無法讀取目前版本」):
|
||||||
|
// `bundle_version` 只在部署時被注入,而**只有安裝器會注入**——CLI 更新那條路重部署
|
||||||
|
// 等於把這個標籤洗掉(wrangler deploy 整份覆蓋,toml 沒寫的 var 直接消失)。
|
||||||
|
// 修在 CLI 那側(cli/src/lib/deploy.ts:既有 var 沿用 + 版本標籤每趟重烙)。
|
||||||
|
// 這裡只多吐一個 `bundle_commit`:版號是「發行頻道的編號」,commit 才是「真的部了哪份碼」——
|
||||||
|
// 兩個一起看才有辦法查「標籤有沒有跟成品漂掉」。沒注入就省略該欄(同 bundle_version 的既有行為)。
|
||||||
healthRouter.get('/health', (c) => {
|
healthRouter.get('/health', (c) => {
|
||||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||||
|
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
|
||||||
return c.json({
|
return c.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
|
||||||
|
...(bundleCommit ? { bundle_commit: bundleCommit } : {}),
|
||||||
auth_store: authStoreStatus(c.env),
|
auth_store: authStoreStatus(c.env),
|
||||||
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
||||||
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
||||||
|
|||||||
@@ -23,7 +23,10 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
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 { graphBase, graphHeaders } from './kbdb-proxy';
|
||||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
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。 */
|
/** 讀 tenant 的 named workflow graph(`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
|
||||||
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
|
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | 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;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
|
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
|
||||||
@@ -190,9 +195,11 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
|
|||||||
* 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端
|
* 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端
|
||||||
* `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。
|
* `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。
|
||||||
*/
|
*/
|
||||||
async function tripletCount(env: Bindings, owner: string): Promise<number | null> {
|
async function tripletCount(env: Bindings, owner: TenantId | null): Promise<number | null> {
|
||||||
try {
|
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;
|
if (!res.ok) return null;
|
||||||
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
|
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
|
||||||
if (!body || !Array.isArray(body.stats)) return null;
|
if (!body || !Array.isArray(body.stats)) return null;
|
||||||
@@ -219,16 +226,16 @@ async function tripletCount(env: Bindings, owner: string): Promise<number | null
|
|||||||
* owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到
|
* owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到
|
||||||
* owned=null → 讀不到 → 畫面說讀不到
|
* 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);
|
const owned = await tripletCount(env, tenant);
|
||||||
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
|
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 用)。 */
|
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||||
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise<string | null> {
|
||||||
try {
|
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;
|
if (!res.ok) return null;
|
||||||
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
|
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
|
||||||
if (!body || !Array.isArray(body.records)) return 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: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
|
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(','));
|
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
||||||
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
||||||
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
// 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 body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
|
||||||
const entry = body.entry;
|
const entry = body.entry;
|
||||||
if (!entry) return notFound(c);
|
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);
|
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||||||
return c.json({ success: true, entry });
|
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'));
|
const nodeName = normalizeCjkQuery(c.req.param('name'));
|
||||||
|
|
||||||
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
|
// ① 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');
|
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
|
||||||
if (wfGraph) {
|
if (wfGraph) {
|
||||||
const depthRaw = c.req.query('depth') ?? '';
|
const depthRaw = c.req.query('depth') ?? '';
|
||||||
@@ -420,9 +427,9 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
|||||||
if (!(await hasGraphAccess(c.env, libraries))) {
|
if (!(await hasGraphAccess(c.env, libraries))) {
|
||||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||||
}
|
}
|
||||||
const tenant = portalTenant(c.env);
|
const tenant = knowledgeOwner(c.env);
|
||||||
const [res, census] = await Promise.all([
|
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),
|
tripletCensus(c.env, tenant),
|
||||||
]);
|
]);
|
||||||
const tripletsTotal = census.owned;
|
const tripletsTotal = census.owned;
|
||||||
@@ -505,7 +512,7 @@ portalDataRouter.get('/portal/data/chat', (c) =>
|
|||||||
wfGraph,
|
wfGraph,
|
||||||
{ question },
|
{ question },
|
||||||
'rag_chat',
|
'rag_chat',
|
||||||
portalTenant(c.env),
|
knowledgeOwner(c.env),
|
||||||
c.executionCtx,
|
c.executionCtx,
|
||||||
);
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -611,7 +618,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
|||||||
// 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
|
// 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
|
||||||
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
|
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
|
||||||
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
|
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
|
||||||
const tenant = portalTenant(c.env);
|
const tenant = knowledgeOwner(c.env);
|
||||||
const prefix = `${tenant}:wf:`;
|
const prefix = `${tenant}:wf:`;
|
||||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||||
const workflows = await Promise.all(
|
const workflows = await Promise.all(
|
||||||
@@ -637,7 +644,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
|||||||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||||||
const execRes = await kbdbFetch(
|
const execRes = await kbdbFetch(
|
||||||
c.env,
|
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 {
|
const execBody = await execRes.json().catch(() => null) as {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
@@ -653,6 +660,267 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 授權的 AI(arcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
|
||||||
|
// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||||
|
// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
|
||||||
|
//
|
||||||
|
// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`),
|
||||||
|
// 於是查詢時只好去找一把**服務內部金鑰**(KBDB_INTERNAL_TOKEN)直打 KBDB——
|
||||||
|
// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。
|
||||||
|
//
|
||||||
|
// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支:
|
||||||
|
// ① 一律 requirePortalUser(session → 回讀 user record → 停用即時生效),
|
||||||
|
// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線:
|
||||||
|
// 呼叫端自己帶租戶字串=繞過庫過濾),
|
||||||
|
// ③ 越權與不存在同回 404(不洩存在性)。
|
||||||
|
//
|
||||||
|
// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤,
|
||||||
|
// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* record 的庫歸屬。與 entry 不同:**沒有 `library` slot 的 record 不套庫過濾**。
|
||||||
|
*
|
||||||
|
* 為什麼不比照 entry 用 'general' fallback:entry 是知識內容(庫是它的第一屬性,沒標就歸
|
||||||
|
* general 是對的);record 是結構化資料列(contact / workflow_metadata / triplet…),
|
||||||
|
* 「庫」只對 triplet 這種有標 library slot 的才有意義。若照抄 general fallback,
|
||||||
|
* 一個庫權限是 ["kb"] 的帳號會連自己建的 contact 都讀不回——那是誤殺,不是隔離。
|
||||||
|
* 租戶邊界仍然守著(owner_id 由 server 注入/逐筆比對),這裡只多守「有標庫的別越庫」。
|
||||||
|
*/
|
||||||
|
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
|
||||||
|
const lib = values?.library;
|
||||||
|
return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
|
||||||
|
function canReadRecord(
|
||||||
|
rec: { values?: Record<string, unknown>; owner_id?: string | null },
|
||||||
|
tenant: TenantId,
|
||||||
|
libraries: string[],
|
||||||
|
): boolean {
|
||||||
|
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,
|
||||||
|
empty_confirmed: true, empty_reason: 'no_library_grant',
|
||||||
|
note: '此帳號尚未被授權任何知識庫,請聯絡管理員。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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' } });
|
||||||
|
}
|
||||||
|
const body = (await res.json().catch(() => null)) as { libraries?: { library?: string }[] } | null;
|
||||||
|
if (!body || !Array.isArray(body.libraries)) {
|
||||||
|
return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502);
|
||||||
|
}
|
||||||
|
const allowed = body.libraries.filter(
|
||||||
|
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
|
||||||
|
);
|
||||||
|
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: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。
|
||||||
|
portalDataRouter.get('/portal/data/map/:library', (c) =>
|
||||||
|
run(c, async () => {
|
||||||
|
const auth = await requirePortalUser(c);
|
||||||
|
if (!auth.ok) return auth.res;
|
||||||
|
const libraries = parseLibraries(auth.user.values.libraries);
|
||||||
|
const library = c.req.param('library');
|
||||||
|
if (!canReadLibrary(libraries, library)) return notFound(c);
|
||||||
|
const res = await kbdbFetch(
|
||||||
|
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);
|
||||||
|
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /portal/data/templates — template 清單。
|
||||||
|
// template=虛擬表定義(schema),**全域共享不分租戶**(kbdb-proxy 同一裁定,leo 2026-06-14):
|
||||||
|
// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。
|
||||||
|
portalDataRouter.get('/portal/data/templates', (c) =>
|
||||||
|
run(c, async () => {
|
||||||
|
const auth = await requirePortalUser(c);
|
||||||
|
if (!auth.ok) return auth.res;
|
||||||
|
const res = await kbdbFetch(c.env, '/templates');
|
||||||
|
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||||
|
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /portal/data/templates — 建 template(name + slots)。
|
||||||
|
// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。
|
||||||
|
// created_by 記租戶(溯源),template 本身全域可見可用。
|
||||||
|
portalDataRouter.post('/portal/data/templates', (c) =>
|
||||||
|
run(c, async () => {
|
||||||
|
const auth = await requirePortalUser(c);
|
||||||
|
if (!auth.ok) return auth.res;
|
||||||
|
const body = (await c.req.json().catch(() => null)) as
|
||||||
|
| { name?: unknown; slots?: unknown; description?: unknown }
|
||||||
|
| null;
|
||||||
|
if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
|
||||||
|
return c.json({ error: 'name 與 slots[] 必填' }, 400);
|
||||||
|
}
|
||||||
|
const res = await kbdbFetch(c.env, '/templates', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: body.name,
|
||||||
|
slots: body.slots,
|
||||||
|
description: typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
created_by: knowledgeOwner(c.env),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
|
||||||
|
// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。
|
||||||
|
portalDataRouter.get('/portal/data/records/by-template/:template', (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, records: [], count: 0 });
|
||||||
|
const tenant = knowledgeOwner(c.env);
|
||||||
|
const res = await kbdbFetch(
|
||||||
|
c.env,
|
||||||
|
`/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
|
||||||
|
| { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
|
||||||
|
| null;
|
||||||
|
if (!body || !Array.isArray(body.records)) {
|
||||||
|
return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
|
||||||
|
}
|
||||||
|
// KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。
|
||||||
|
const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
|
||||||
|
return c.json({ success: true, records, count: records.length });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /portal/data/records/:recordId — 單筆 record。
|
||||||
|
// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。
|
||||||
|
portalDataRouter.get('/portal/data/records/:recordId', (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 notFound(c);
|
||||||
|
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
|
||||||
|
if (res.status === 404) return notFound(c);
|
||||||
|
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||||
|
const body = (await res.json().catch(() => null)) as
|
||||||
|
| { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
|
||||||
|
| null;
|
||||||
|
const record = body?.record;
|
||||||
|
if (!record) return notFound(c);
|
||||||
|
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
|
||||||
|
return c.json({ success: true, record });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /portal/data/records — 依 template 填一筆 record。
|
||||||
|
// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬,
|
||||||
|
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。
|
||||||
|
portalDataRouter.post('/portal/data/records', (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({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403);
|
||||||
|
}
|
||||||
|
const body = (await c.req.json().catch(() => null)) as
|
||||||
|
| { template?: unknown; values?: unknown }
|
||||||
|
| null;
|
||||||
|
if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
|
||||||
|
return c.json({ error: 'template 與 values 必填' }, 400);
|
||||||
|
}
|
||||||
|
const values = body.values as Record<string, unknown>;
|
||||||
|
const targetLib = recordLibrary(values);
|
||||||
|
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
|
||||||
|
// 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則:
|
||||||
|
// 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。
|
||||||
|
return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403);
|
||||||
|
}
|
||||||
|
const res = await kbdbFetch(c.env, '/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
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' } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
||||||
//
|
//
|
||||||
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
||||||
@@ -675,7 +943,7 @@ portalDataRouter.get('/portal/data/diagnostics', (c) =>
|
|||||||
run(c, async () => {
|
run(c, async () => {
|
||||||
const auth = await requirePortalUser(c);
|
const auth = await requirePortalUser(c);
|
||||||
if (!auth.ok) return auth.res;
|
if (!auth.ok) return auth.res;
|
||||||
const tenant = portalTenant(c.env);
|
const tenant = knowledgeOwner(c.env);
|
||||||
const core = await buildDiagnostics(c.env, tenant);
|
const core = await buildDiagnostics(c.env, tenant);
|
||||||
return c.json({
|
return c.json({
|
||||||
generated_at: new Date().toISOString(),
|
generated_at: new Date().toISOString(),
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { kbdbBase } from './kbdb-proxy';
|
|||||||
import { validateConsoleSession } from './console-auth';
|
import { validateConsoleSession } from './console-auth';
|
||||||
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
|
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
|
||||||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
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 的**唯一**寫入路徑,
|
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||||
import { storeCredential, hasCredential } from './credentials';
|
import { storeCredential, hasCredential } from './credentials';
|
||||||
@@ -58,14 +61,27 @@ export const LIBRARY_TEMPLATE = 'portal_library';
|
|||||||
|
|
||||||
// ── 基礎 helpers ────────────────────────────────────────────────────────────
|
// ── 基礎 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 {
|
export function portalTenant(env: Bindings): string {
|
||||||
return env.CONSOLE_TENANT || 'leo';
|
return accountTenant(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 帳號子 namespace(design D-2)。 */
|
/** 帳號子 namespace(design D-2)。 */
|
||||||
function portalNamespace(env: Bindings): string {
|
function portalNamespace(env: Bindings): string {
|
||||||
return `${portalTenant(env)}::portal`;
|
return `${accountTenant(env)}::portal`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionTtl(env: Bindings): number {
|
function sessionTtl(env: Bindings): number {
|
||||||
@@ -102,6 +118,11 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
|
|||||||
if (e instanceof AuthStoreWriteError) {
|
if (e instanceof AuthStoreWriteError) {
|
||||||
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
|
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);
|
if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -677,6 +698,11 @@ portalRouter.post('/portal/login', (c) =>
|
|||||||
display_name: rec.values.display_name ?? '',
|
display_name: rec.values.display_name ?? '',
|
||||||
role: rec.values.role ?? 'user',
|
role: rec.values.role ?? 'user',
|
||||||
libraries: parseLibraries(rec.values.libraries),
|
libraries: parseLibraries(rec.values.libraries),
|
||||||
|
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
|
||||||
|
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
|
||||||
|
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
|
||||||
|
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
|
||||||
|
session_expires_in: sessionTtl(c.env),
|
||||||
// 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
|
// 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -1404,7 +1430,6 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
|||||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||||
}
|
}
|
||||||
await clearLoginFail(c.env, email);
|
await clearLoginFail(c.env, email);
|
||||||
const tenant = portalTenant(c.env);
|
|
||||||
// t176(leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
|
// t176(leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
|
||||||
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
|
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
|
||||||
//
|
//
|
||||||
@@ -1417,9 +1442,12 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
|||||||
//
|
//
|
||||||
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_url/namespace/library)與本 route 本身照舊,
|
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_url/namespace/library)與本 route 本身照舊,
|
||||||
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
|
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
|
||||||
|
// #108:這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。
|
||||||
|
// 以前給的是帳號層字串(CONSOLE_TENANT),與 CLI/MCP 用的實例 namespace 是兩個來源
|
||||||
|
// ⇒ 寫進去的地方和讀出來的地方可以各自漂。改成同一個 knowledgeOwner,一台實例一個值。
|
||||||
const daemonCfg: Record<string, string> = {
|
const daemonCfg: Record<string, string> = {
|
||||||
cypher_url: new URL(c.req.url).origin,
|
cypher_url: new URL(c.req.url).origin,
|
||||||
namespace: tenant,
|
namespace: knowledgeOwner(c.env),
|
||||||
library: 'kb',
|
library: 'kb',
|
||||||
email,
|
email,
|
||||||
instance_name: String(rec.values.display_name ?? ''),
|
instance_name: String(rec.values.display_name ?? ''),
|
||||||
@@ -1451,7 +1479,7 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
|
|||||||
const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
|
const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
|
||||||
const key = String(body?.key ?? '').trim();
|
const key = String(body?.key ?? '').trim();
|
||||||
if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
|
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 kvKey = `${tenant}:wf:rag_chat`;
|
||||||
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
|
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
|
||||||
if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
|
if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
|
||||||
@@ -1513,8 +1541,8 @@ portalRouter.get('/portal/admin/libraries', (c) =>
|
|||||||
// t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
|
// t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
|
||||||
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
|
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
|
||||||
try {
|
try {
|
||||||
const tenant = portalTenant(c.env);
|
const tenant = knowledgeOwner(c.env);
|
||||||
const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
|
const ownerParam = ownerQuery(tenant);
|
||||||
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
||||||
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
||||||
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
||||||
@@ -1707,8 +1735,8 @@ portalRouter.get('/portal/admin/execution-log-retention', (c) =>
|
|||||||
run(c, async () => {
|
run(c, async () => {
|
||||||
const auth = await requirePortalAdmin(c);
|
const auth = await requirePortalAdmin(c);
|
||||||
if (!auth.ok) return auth.res;
|
if (!auth.ok) return auth.res;
|
||||||
const ownerId = portalTenant(c.env);
|
const ownerId = knowledgeOwner(c.env);
|
||||||
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
|
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
|
||||||
if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`);
|
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 };
|
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 });
|
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
|
||||||
@@ -1727,10 +1755,10 @@ portalRouter.put('/portal/admin/execution-log-retention', (c) =>
|
|||||||
if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
|
if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
|
||||||
return c.json({ error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
|
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', {
|
const res = await kbdbFetch(c.env, '/execution-log/retention', {
|
||||||
method: 'PUT',
|
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}`);
|
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`);
|
||||||
const data = (await res.json()) as { retention_days?: number | null };
|
const data = (await res.json()) as { retention_days?: number | null };
|
||||||
@@ -1751,10 +1779,10 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
|
|||||||
const confirm = String(body?.confirm ?? '').trim();
|
const confirm = String(body?.confirm ?? '').trim();
|
||||||
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
|
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
|
||||||
if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 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', {
|
const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
|
||||||
method: 'PATCH',
|
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}`);
|
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
|
||||||
const data = (await res.json()) as { deprecated_count?: number };
|
const data = (await res.json()) as { deprecated_count?: number };
|
||||||
@@ -1845,16 +1873,19 @@ export interface DiagnosticsCore {
|
|||||||
notes: string[];
|
notes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** tenant=owner_id(session 版傳 portalTenant(env);daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */
|
/**
|
||||||
export async function buildDiagnostics(env: Bindings, tenant: string): Promise<DiagnosticsCore> {
|
* tenant=owner_id(session 版傳 `knowledgeOwner(env)`;daemon 版傳 `tenantFromApiKey(header)`)。
|
||||||
|
* #108:型別收成 `TenantId`——診斷檔要是報了另一個命名空間的統計,等於用假數字排查真問題。
|
||||||
|
*/
|
||||||
|
export async function buildDiagnostics(env: Bindings, tenant: TenantId): Promise<DiagnosticsCore> {
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
|
|
||||||
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
|
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
|
||||||
let embedding: Record<string, unknown> = { checked: false };
|
let embedding: Record<string, unknown> = { checked: false };
|
||||||
try {
|
try {
|
||||||
const [statusRes, selftestRes] = await Promise.all([
|
const [statusRes, selftestRes] = await Promise.all([
|
||||||
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`),
|
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant)}`),
|
||||||
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`),
|
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant)}`),
|
||||||
]);
|
]);
|
||||||
const statusBody = (await statusRes.json().catch(() => null)) as
|
const statusBody = (await statusRes.json().catch(() => null)) as
|
||||||
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
|
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
|
||||||
@@ -1890,7 +1921,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
|
|||||||
// - GET /records/triplet-stats:per-library 即時聚合 SQL(t142,COUNT,非快取)。
|
// - GET /records/triplet-stats:per-library 即時聚合 SQL(t142,COUNT,非快取)。
|
||||||
let library_count = 0;
|
let library_count = 0;
|
||||||
let triplet_count = 0;
|
let triplet_count = 0;
|
||||||
const ownerParam = new URLSearchParams({ owner_id: tenant }).toString();
|
const ownerParam = ownerQuery(tenant);
|
||||||
try {
|
try {
|
||||||
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
||||||
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
||||||
@@ -1924,7 +1955,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
|
|||||||
let library_scope_check: Record<string, unknown> = { ran: false };
|
let library_scope_check: Record<string, unknown> = { ran: false };
|
||||||
if (library_count === 0 && triplet_count === 0) {
|
if (library_count === 0 && triplet_count === 0) {
|
||||||
try {
|
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 probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null;
|
||||||
const total = probeBody?.total ?? 0;
|
const total = probeBody?.total ?? 0;
|
||||||
library_scope_check = {
|
library_scope_check = {
|
||||||
@@ -1971,7 +2002,8 @@ portalRouter.get('/portal/daemon/diagnostics', (c) =>
|
|||||||
run(c, async () => {
|
run(c, async () => {
|
||||||
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
|
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
|
||||||
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
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({
|
return c.json({
|
||||||
generated_at: new Date().toISOString(),
|
generated_at: new Date().toISOString(),
|
||||||
instance_url: new URL(c.req.url).origin,
|
instance_url: new URL(c.req.url).origin,
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ export type Bindings = {
|
|||||||
* 未注入(本地 dev/舊實例)= undefined,/health 省略該欄。
|
* 未注入(本地 dev/舊實例)= undefined,/health 省略該欄。
|
||||||
*/
|
*/
|
||||||
ARCRUN_BUNDLE_VERSION?: string;
|
ARCRUN_BUNDLE_VERSION?: string;
|
||||||
|
/**
|
||||||
|
* Arcrun#106:這份成品實際來自哪個 commit(40 碼 sha)。
|
||||||
|
* `ARCRUN_BUNDLE_VERSION` 是**發行頻道的編號**(semver,Portal/daemon 拿它比新舊),
|
||||||
|
* 這個是**真的部了哪份碼**——兩個一起吐,標籤跟成品漂掉時查得出來。
|
||||||
|
* 由 `acr init/update`(cli/src/lib/deploy.ts)注入;安裝器那條路沒有此 var → /health 省略該欄。
|
||||||
|
*/
|
||||||
|
ARCRUN_BUNDLE_COMMIT?: string;
|
||||||
// Platform telemetry api_key(可選,wrangler secret)
|
// Platform telemetry api_key(可選,wrangler secret)
|
||||||
// 對應 SDD .agents/specs/llm-interface/ M1.2
|
// 對應 SDD .agents/specs/llm-interface/ M1.2
|
||||||
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
|
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
|
||||||
@@ -84,6 +91,20 @@ export type Bindings = {
|
|||||||
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
||||||
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
||||||
CONSOLE_TENANT?: string;
|
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),
|
// Console 顯示品牌/實例名(Arcrun#21 rebrand,非機密)。只影響 UI 字樣(title/header/logo),
|
||||||
// 不影響任何行為。未設 → "Arcrun"(console 是引擎共用件,不寫死產品名)。
|
// 不影響任何行為。未設 → "Arcrun"(console 是引擎共用件,不寫死產品名)。
|
||||||
// 實例可覆蓋,例:arcrun-rag demo 可設 --var CONSOLE_BRAND:"Arcrun RAG"。
|
// 實例可覆蓋,例:arcrun-rag demo 可設 --var CONSOLE_BRAND:"Arcrun RAG"。
|
||||||
@@ -103,9 +124,8 @@ export type Bindings = {
|
|||||||
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token)
|
||||||
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
|
||||||
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
|
||||||
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
|
// (ARCRUN_BUNDLE_VERSION 原本在這裡重複宣告了一次——TS2300 重複識別字,
|
||||||
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
|
// #106 順手併回上面那一處,說明同源,行為零變化。)
|
||||||
ARCRUN_BUNDLE_VERSION?: string;
|
|
||||||
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
|
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
|
||||||
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
// 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份
|
||||||
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
|
||||||
|
|||||||
@@ -26,4 +26,33 @@ describe('GET /health — bundle_version 欄位', () => {
|
|||||||
expect(data.ok).toBe(true);
|
expect(data.ok).toBe(true);
|
||||||
expect(data.bundle_version).toBe('2026-07-28/6d06162');
|
expect(data.bundle_version).toBe('2026-07-28/6d06162');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Arcrun#106:CLI 更新那條路會多烙一個 commit(版號=發行頻道編號,commit=真的部了哪份碼)。
|
||||||
|
it('有 ARCRUN_BUNDLE_COMMIT 時一起回(acr update 注入情境)', async () => {
|
||||||
|
const fakeEnv = {
|
||||||
|
ARCRUN_BUNDLE_VERSION: '1.4.41',
|
||||||
|
ARCRUN_BUNDLE_COMMIT: 'f87d0e92f49690253e7c89c5badc82a08eb5d21b',
|
||||||
|
} as unknown as Bindings;
|
||||||
|
const res = await healthRouter.fetch(
|
||||||
|
new Request('http://localhost/health'),
|
||||||
|
fakeEnv,
|
||||||
|
{} as ExecutionContext,
|
||||||
|
);
|
||||||
|
const data = await res.json() as { bundle_version: string; bundle_commit: string };
|
||||||
|
expect(data.bundle_version).toBe('1.4.41');
|
||||||
|
expect(data.bundle_commit).toBe('f87d0e92f49690253e7c89c5badc82a08eb5d21b');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 安裝器那條路沒有這個 var(回歸:不能因為多了新欄位就讓舊路徑多吐一個空字串出來)。
|
||||||
|
it('沒 ARCRUN_BUNDLE_COMMIT 就省略該欄(安裝器路徑不受影響)', async () => {
|
||||||
|
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '1.4.41' } as unknown as Bindings;
|
||||||
|
const res = await healthRouter.fetch(
|
||||||
|
new Request('http://localhost/health'),
|
||||||
|
fakeEnv,
|
||||||
|
{} as ExecutionContext,
|
||||||
|
);
|
||||||
|
const data = await res.json() as { bundle_version: string; bundle_commit?: string };
|
||||||
|
expect(data.bundle_version).toBe('1.4.41');
|
||||||
|
expect(data.bundle_commit).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<Bindings> = {}) {
|
||||||
|
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<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -232,6 +232,214 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════
|
||||||
|
//
|
||||||
|
// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||||
|
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、
|
||||||
|
// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。
|
||||||
|
|
||||||
|
describe('藏書地圖 /portal/data/map(MCP 走的那條)', () => {
|
||||||
|
it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => {
|
||||||
|
await seedSession('tok-m1', 'rec_1');
|
||||||
|
mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||||
|
.reply(200, {
|
||||||
|
success: true,
|
||||||
|
libraries: [
|
||||||
|
{ library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
|
||||||
|
{ library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
|
||||||
|
],
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { libraries: { library: string }[]; count: number };
|
||||||
|
expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
|
||||||
|
expect(data.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('["*"] 全庫 → 全部庫都回', async () => {
|
||||||
|
await seedSession('tok-m2', 'rec_2');
|
||||||
|
mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
|
||||||
|
.reply(200, {
|
||||||
|
success: true,
|
||||||
|
libraries: [
|
||||||
|
{ library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
|
||||||
|
{ library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
|
||||||
|
],
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
|
||||||
|
const data = (await res.json()) as { libraries: { library: string }[] };
|
||||||
|
expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => {
|
||||||
|
await seedSession('tok-m3', 'rec_3');
|
||||||
|
mockGetRecord('rec_3', userValues({ libraries: '[]' }));
|
||||||
|
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = (await res.json()) as { count: number; note?: string };
|
||||||
|
expect(data.count).toBe(0);
|
||||||
|
expect(data.note).toContain('尚未被授權');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => {
|
||||||
|
await seedSession('tok-m4', 'rec_4');
|
||||||
|
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||||
|
const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('單庫詳圖:有權該庫 → 200 轉發', async () => {
|
||||||
|
await seedSession('tok-m5', 'rec_5');
|
||||||
|
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
|
||||||
|
.reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
|
||||||
|
const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未登入 → 401', async () => {
|
||||||
|
expect((await get('/portal/data/map')).status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('結構化資料 /portal/data/records、/portal/data/templates(MCP 走的那條)', () => {
|
||||||
|
it('by-template:server 注入 owner_id;caller 自帶的被靜默覆蓋(繞不過)', async () => {
|
||||||
|
await seedSession('tok-r1', 'rec_1');
|
||||||
|
mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
|
||||||
|
let captured = '';
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({
|
||||||
|
path: (p: string) => {
|
||||||
|
if (!p.startsWith('/records/by-template/contact')) return false;
|
||||||
|
captured = p;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
.reply(200, { success: true, records: [], count: 0 });
|
||||||
|
const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
|
||||||
|
Authorization: 'Bearer tok-r1',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => {
|
||||||
|
await seedSession('tok-r2', 'rec_2');
|
||||||
|
mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||||
|
.reply(200, {
|
||||||
|
success: true,
|
||||||
|
records: [
|
||||||
|
{ record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
|
||||||
|
{ record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
|
||||||
|
{ record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列
|
||||||
|
],
|
||||||
|
count: 3,
|
||||||
|
});
|
||||||
|
const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
|
||||||
|
const data = (await res.json()) as { records: { record_id: string }[] };
|
||||||
|
expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => {
|
||||||
|
await seedSession('tok-r3', 'rec_3');
|
||||||
|
mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: '/records/r_other', method: 'GET' })
|
||||||
|
.reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
|
||||||
|
const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => {
|
||||||
|
await seedSession('tok-r4', 'rec_4');
|
||||||
|
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: '/records/r_hr', method: 'GET' })
|
||||||
|
.reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
|
||||||
|
expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);
|
||||||
|
|
||||||
|
await seedSession('tok-r5', 'rec_5');
|
||||||
|
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: '/records/r_fin', method: 'GET' })
|
||||||
|
.reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
|
||||||
|
expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => {
|
||||||
|
await seedSession('tok-r6', 'rec_6');
|
||||||
|
mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({
|
||||||
|
path: '/records',
|
||||||
|
method: 'POST',
|
||||||
|
body: (b: string) => {
|
||||||
|
body = JSON.parse(b) as Record<string, unknown>;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.reply(200, { success: true, record: { record_id: 'r_new' } });
|
||||||
|
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.owner_id).toBe(TENANT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => {
|
||||||
|
await seedSession('tok-r7', 'rec_7');
|
||||||
|
mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
|
||||||
|
const res = await SELF.fetch('http://localhost/portal/data/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('templates 全域共享(schema 非內容):登入即可列', async () => {
|
||||||
|
await seedSession('tok-t1', 'rec_t1');
|
||||||
|
mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
|
||||||
|
fetchMock
|
||||||
|
.get(KBDB)
|
||||||
|
.intercept({ path: '/templates', method: 'GET' })
|
||||||
|
.reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
|
||||||
|
const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(((await res.json()) as { count: number }).count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未登入 → 401(records / templates 都是)', async () => {
|
||||||
|
expect((await get('/portal/data/templates')).status).toBe(401);
|
||||||
|
expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
|
||||||
|
expect((await get('/portal/data/records/r1')).status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
|
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
|
||||||
|
|
||||||
describe('GET /portal/data/graph/neighbors/:name(D-4 粗閘)', () => {
|
describe('GET /portal/data/graph/neighbors/:name(D-4 粗閘)', () => {
|
||||||
|
|||||||
@@ -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<string, unknown>; 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*['"][^'"]+['"]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -138,6 +138,19 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev"
|
|||||||
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
|
# (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。
|
||||||
CONSOLE_TENANT = "leo"
|
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)——
|
# Portal session TTL 秒數(portal-auth P2,#24/#25,routes/portal.ts)。預設 7 天(604800)——
|
||||||
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
|
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
|
||||||
PORTAL_SESSION_TTL = "604800"
|
PORTAL_SESSION_TTL = "604800"
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- credential template seed(D38 圍牆修復,總管交辦,2026-08-07)
|
||||||
|
-- SDD:無專屬 SDD(D38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
|
||||||
|
--
|
||||||
|
-- D38 鐵律(leo 2026-06-14 立、2026-08-07 擴大):KBDB 三張表打天下,永遠不加新表;
|
||||||
|
-- 新資料類型一律用 template + entries,同 0003_library_map.sql / 0004_execution_log_template.sql
|
||||||
|
-- 的手法——對 templates 表 INSERT OR IGNORE 一列定義,不建新表、不動既有表的結構。
|
||||||
|
--
|
||||||
|
-- 這是「credential 目錄」的第二個家:原本 0002_credentials.sql 在 KBDB 裡多開了一張
|
||||||
|
-- 獨立表(違規,見 kbdb-usage skill「反例」),本檔 + 0006_drop_credentials_table.sql
|
||||||
|
-- 把它改回三張表的形狀——一筆 credential=entries 表一列(entry_type='credential',
|
||||||
|
-- page_name=name 當冪等鍵,owner_id=api_key 做租戶隔離,其餘欄位打包進 metadata_json),
|
||||||
|
-- 儲存精神比照既有 recipe_stat / execution_log(template 只負責文件化,實際資料不走
|
||||||
|
-- entry_values 全展開的多列 record)。
|
||||||
|
--
|
||||||
|
-- 密文本體不在這裡:值仍住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理
|
||||||
|
-- API 唯寫,D19「擁有目錄,不擁有內容物」不變)。這張 template 定義的 slots 全部是目錄
|
||||||
|
-- 欄位,零密文——與舊 0002_credentials.sql 的欄位定義一字不變,只是換了個家。
|
||||||
|
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
|
||||||
|
VALUES (
|
||||||
|
'tpl-credential',
|
||||||
|
'credential',
|
||||||
|
'credential 目錄(D38 圍牆修復:改走 entries 表 entry_type=credential,取代舊 credentials 表;零密文,密文本體住 Workers per-script Secrets)',
|
||||||
|
'["name","service","sensitivity","secret_ref","last_used_at"]',
|
||||||
|
'system'
|
||||||
|
);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- 退役 credentials 表(D38 圍牆修復,總管交辦,2026-08-07)
|
||||||
|
-- SDD:無專屬 SDD(D38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
|
||||||
|
--
|
||||||
|
-- 這是本次唯一真的需要動表結構的一支 migration,理由(不是繞過鐵律,是鐵律要求的收尾):
|
||||||
|
-- D38 要求 KBDB 回到「只有三張核心表」的狀態。0002_credentials.sql 當初在 KBDB 裡多開了
|
||||||
|
-- 一張獨立表,是已知違規(kbdb-usage skill 明文列為反例)。要把違規清乾淨,唯一辦法就是
|
||||||
|
-- 真的把那張表拆掉——拆表本身不能只用 API 做(API 不提供「拆表」這種牆內維運操作,
|
||||||
|
-- 也不該提供),所以下面兩句 SQL 標 kbdb-sql-ok:這不是繞過圍牆去存取資料,是圍牆施工
|
||||||
|
-- 本身(kbdb/migrations/ 就是牆內,本檔存在的唯一目的就是讓舊表退場)。
|
||||||
|
--
|
||||||
|
-- 冪等設計(deploy.ts 每次部署都會重跑這支檔案,沒有 migration 追蹤表):
|
||||||
|
-- 1. 先補一份空表存在保底——self-hosted 各實例套用進度不一,有些從沒跑過 0002(表從不
|
||||||
|
-- 存在)、有些已經跑過本檔一次(表已被拆)。沒有這一步,下面的搬資料/退場語句會因表
|
||||||
|
-- 不存在直接整支失敗(D1 對不存在的表沒有條件式跳過語法)。
|
||||||
|
-- 2. 把舊表裡「entries 還沒有對應列」的 row 搬進 entries(entry_type='credential',
|
||||||
|
-- page_name=name 冪等鍵,owner_id=api_key,其餘欄位打包進 metadata_json,欄位對應
|
||||||
|
-- 0005_credential_template.sql 定義的 slots)。NOT EXISTS 判斷防止重跑造成重複列。
|
||||||
|
-- 3. 搬完資料後表就沒有存在的理由,最後一步讓它退場。下次部署若又被步驟 1 重新墊一份
|
||||||
|
-- 空殼,也只是空表、立刻搬 0 筆、立刻退場,不影響任何人(真資料只會被搬一次,因為
|
||||||
|
-- 步驟 2 的判斷是看 entries 裡有沒有,不是看這是不是第一次跑)。
|
||||||
|
CREATE TABLE IF NOT EXISTS credentials ( -- kbdb-sql-ok: 表退場施工步驟①保底存在,非資料存取違規,理由見檔頭
|
||||||
|
api_key TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
service TEXT,
|
||||||
|
sensitivity TEXT NOT NULL DEFAULT 'standard',
|
||||||
|
secret_ref TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER,
|
||||||
|
PRIMARY KEY (api_key, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
'e_cred_' || lower(hex(randomblob(8))),
|
||||||
|
'credential',
|
||||||
|
c.api_key,
|
||||||
|
c.name,
|
||||||
|
json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at),
|
||||||
|
c.created_at,
|
||||||
|
unixepoch()
|
||||||
|
FROM credentials c
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM entries e
|
||||||
|
WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS credentials; -- kbdb-sql-ok: 表退場施工步驟③讓舊表退場,非資料存取違規,理由見檔頭
|
||||||
@@ -65,6 +65,13 @@ export interface RecordResult {
|
|||||||
record_id: string;
|
record_id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
values: Record<string, string>;
|
values: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* record 的歸屬(=其底層 slot entries 的 owner_id,createRecord 寫入時同一值)。
|
||||||
|
* 2026-08-12 補:`GET /records/:id` 原本不回這欄,所以**呼叫端無從判斷這筆是不是自己的**
|
||||||
|
* ——按 id 直讀等於沒有租戶邊界。要讓 cypher 的 portal 資料面(授權的人/AI 走的那條)
|
||||||
|
* 能對單筆做「不是我的就回 404」,歸屬必須跟著資料一起回來。無歸屬的舊資料 → null。
|
||||||
|
*/
|
||||||
|
owner_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
|
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
|
||||||
@@ -85,7 +92,7 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
|
|||||||
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
return { record_id: recordId, template_id: tpl.id, values: input.values };
|
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
|
||||||
@@ -147,17 +154,19 @@ export async function updateRecord(
|
|||||||
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
|
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||||
WHERE ev.record_id = ?`,
|
WHERE ev.record_id = ?`,
|
||||||
)
|
)
|
||||||
.bind(recordId)
|
.bind(recordId)
|
||||||
.all<{ slot: string; content: string; template_id: string }>();
|
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||||
const rows = res.results ?? [];
|
const rows = res.results ?? [];
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
const values: Record<string, string> = {};
|
const values: Record<string, string> = {};
|
||||||
for (const r of rows) values[r.slot] = r.content;
|
for (const r of rows) values[r.slot] = r.content;
|
||||||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
|
||||||
|
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||||
|
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
|
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
|
||||||
@@ -192,19 +201,20 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
|
|||||||
const placeholders = chunk.map(() => '?').join(',');
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
const evRes = await db
|
const evRes = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||||
WHERE ev.record_id IN (${placeholders})`,
|
WHERE ev.record_id IN (${placeholders})`,
|
||||||
)
|
)
|
||||||
.bind(...chunk)
|
.bind(...chunk)
|
||||||
.all<{ record_id: string; slot: string; content: string; template_id: string }>();
|
.all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
|
||||||
for (const r of evRes.results ?? []) {
|
for (const r of evRes.results ?? []) {
|
||||||
let rec = byId.get(r.record_id);
|
let rec = byId.get(r.record_id);
|
||||||
if (!rec) {
|
if (!rec) {
|
||||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
|
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||||
byId.set(r.record_id, rec);
|
byId.set(r.record_id, rec);
|
||||||
}
|
}
|
||||||
rec.values[r.slot] = r.content;
|
rec.values[r.slot] = r.content;
|
||||||
|
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
|
return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r);
|
||||||
|
|||||||
+16
-3
@@ -1,13 +1,25 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { Env } from "./types.js";
|
import { Env } from "./types.js";
|
||||||
import { partnerAuthMiddleware } from "./middleware/partner-auth.js";
|
import { partnerAuthMiddleware, type AuthPath } from "./middleware/partner-auth.js";
|
||||||
import { handleMcpRequest } from "./mcp-handler.js";
|
import { handleMcpRequest } from "./mcp-handler.js";
|
||||||
|
import { resolveKnowledgeIdentity } from "./lib/portal-client.js";
|
||||||
|
import type { PortalIdentity } from "./oauth/store.js";
|
||||||
import { inspectorHtml } from "./pages/inspector.js";
|
import { inspectorHtml } from "./pages/inspector.js";
|
||||||
import { kbdbFetch } from "./lib/kbdb-client.js";
|
import { kbdbFetch } from "./lib/kbdb-client.js";
|
||||||
import { registerOAuthRoutes } from "./oauth/routes.js";
|
import { registerOAuthRoutes } from "./oauth/routes.js";
|
||||||
|
|
||||||
const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
|
const _app = new Hono<{
|
||||||
|
Bindings: Env;
|
||||||
|
Variables: {
|
||||||
|
org_namespace: string;
|
||||||
|
partner_token: string;
|
||||||
|
// 登入者身分(以帳密走 OAuth 連進來時才有)+ 這條連線是哪種憑據。
|
||||||
|
// 知識面工具(kbdb_*)據此決定走 portal 資料面還是既有 KBDB 直連(見 lib/portal-client.ts)。
|
||||||
|
portal?: PortalIdentity;
|
||||||
|
auth_path: AuthPath;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)──────────────────────────
|
// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)──────────────────────────
|
||||||
// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。
|
// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。
|
||||||
@@ -261,7 +273,8 @@ app.options("/mcp", (c) => {
|
|||||||
app.post("/", partnerAuthMiddleware, async (c) => {
|
app.post("/", partnerAuthMiddleware, async (c) => {
|
||||||
const orgNamespace = c.get("org_namespace");
|
const orgNamespace = c.get("org_namespace");
|
||||||
const partnerToken = c.get("partner_token");
|
const partnerToken = c.get("partner_token");
|
||||||
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
|
const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal"));
|
||||||
|
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 輸出根 app(_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與
|
// 輸出根 app(_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
import type { Env } from "../types.js";
|
import type { Env } from "../types.js";
|
||||||
import { kbdbFetch } from "./kbdb-client.js";
|
import { kbdbFetch } from "./kbdb-client.js";
|
||||||
|
import { portalFetch, type KnowledgeIdentity } from "./portal-client.js";
|
||||||
|
|
||||||
/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */
|
/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */
|
||||||
export interface LibraryMapRow {
|
export interface LibraryMapRow {
|
||||||
@@ -86,25 +87,45 @@ const MAP_FETCH_TIMEOUT_MS = 1500;
|
|||||||
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
|
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
|
||||||
const CACHE_TTL_FAIL_MS = 60 * 1000;
|
const CACHE_TTL_FAIL_MS = 60 * 1000;
|
||||||
|
|
||||||
let instructionsCache: { text: string | null; expiresAt: number } | null = null;
|
/**
|
||||||
|
* 快取以「身分」分格(2026-08-12)。
|
||||||
|
*
|
||||||
|
* 為什麼不能共用一格:地圖本身就是情報(哪些庫存在、各有多少關聯、核心 entity 是誰)。
|
||||||
|
* 以帳密連線時只該看到自己有權限的庫;若跟服務級連線共用同一格快取,先連上的那個人
|
||||||
|
* 會把自己的視野留給下一個人——那是跨帳號外洩,不是效能問題。
|
||||||
|
*/
|
||||||
|
const instructionsCache = new Map<string, { text: string | null; expiresAt: number }>();
|
||||||
|
|
||||||
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
|
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
|
||||||
export function __resetLibraryMapInstructionsCacheForTests(): void {
|
export function __resetLibraryMapInstructionsCacheForTests(): void {
|
||||||
instructionsCache = null;
|
instructionsCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含
|
* 組 MCP server instructions 的藏書地圖段(design §4 / §6「session 啟動 → instructions 已含
|
||||||
* 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ null(caller 靜默略過)。
|
* 全館地圖(push 零查詢)」)。任何失敗(超時/HTTP 錯/空庫/壞 JSON)→ null(caller 靜默略過)。
|
||||||
|
*
|
||||||
|
* 以帳密連線(identity.kind === 'portal')時走 cypher `/portal/data/map`——只拿得到這個
|
||||||
|
* 帳號有權限的庫;服務級憑據維持既有 KBDB `/map` 直連。舊 token(stale)不給地圖。
|
||||||
*/
|
*/
|
||||||
export async function buildLibraryMapInstructions(env: Env): Promise<string | null> {
|
export async function buildLibraryMapInstructions(
|
||||||
|
env: Env,
|
||||||
|
identity: KnowledgeIdentity,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (identity.kind === "stale") return null;
|
||||||
|
// 快取 key:portal 用 session(=這個人這次登入),service 用固定字串。
|
||||||
|
// session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。
|
||||||
|
const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service";
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (instructionsCache && instructionsCache.expiresAt > now) return instructionsCache.text;
|
const hit = instructionsCache.get(cacheKey);
|
||||||
|
if (hit && hit.expiresAt > now) return hit.text;
|
||||||
|
|
||||||
let text: string | null = null;
|
let text: string | null = null;
|
||||||
try {
|
try {
|
||||||
const res = await Promise.race([
|
const res = await Promise.race([
|
||||||
kbdbFetch(env, "/map"),
|
identity.kind === "portal"
|
||||||
|
? portalFetch(env, identity.portal.session, "/portal/data/map")
|
||||||
|
: kbdbFetch(env, "/map"),
|
||||||
new Promise<never>((_, reject) =>
|
new Promise<never>((_, reject) =>
|
||||||
setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
|
setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
|
||||||
),
|
),
|
||||||
@@ -124,6 +145,13 @@ export async function buildLibraryMapInstructions(env: Env): Promise<string | nu
|
|||||||
text = null;
|
text = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
instructionsCache = { text, expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS) };
|
instructionsCache.set(cacheKey, {
|
||||||
|
text,
|
||||||
|
expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS),
|
||||||
|
});
|
||||||
|
// isolate 內的快取,不做失效協議;但別讓不同帳號的格子無上限長大(isolate 可活很久)。
|
||||||
|
if (instructionsCache.size > 64) {
|
||||||
|
for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k);
|
||||||
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Portal 資料面 client — 「授權的 AI」用登入者的身分查東西的唯一管道。
|
||||||
|
*
|
||||||
|
* leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
|
||||||
|
* AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||||
|
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
|
||||||
|
*
|
||||||
|
* 所以這裡帶的是 **portal session token**(同意頁輸入帳密時 cypher 發的那張,
|
||||||
|
* 與人類在 portal 網頁上拿到的完全同一種),不是任何服務內部金鑰。
|
||||||
|
* 端點是 cypher 的 `/portal/data/*`——庫過濾、租戶注入、停用即時生效全在那邊 server 側做完,
|
||||||
|
* 本檔不做任何判斷(薄殼鐵律 rule 07:能力長在 API,介面只轉換)。
|
||||||
|
*
|
||||||
|
* 走既有 CYPHER_EXECUTOR service binding,不新增 binding、不新增金鑰。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Env } from "../types.js";
|
||||||
|
import type { PortalIdentity } from "../oauth/store.js";
|
||||||
|
import { errorResponse } from "./cypher-client.js";
|
||||||
|
|
||||||
|
export interface PortalCallOpts {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
query?: Record<string, string | number | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用登入者的 session 打 cypher 的 portal 資料面。 */
|
||||||
|
export async function portalFetch(
|
||||||
|
env: Env,
|
||||||
|
session: string,
|
||||||
|
path: string,
|
||||||
|
opts: PortalCallOpts = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!env.CYPHER_EXECUTOR) {
|
||||||
|
throw new Error("CYPHER_EXECUTOR service binding not configured");
|
||||||
|
}
|
||||||
|
const url = new URL(`https://cypher${path}`);
|
||||||
|
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
||||||
|
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
|
||||||
|
method: opts.method ?? "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${session}`,
|
||||||
|
},
|
||||||
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知識面工具的身分解析結果。
|
||||||
|
*
|
||||||
|
* 三態刻意分開,因為「查不到」和「沒有」不可以長得一樣(leo 的老原則):
|
||||||
|
* - portal :有登入者 → 走 portal 資料面(權限=這個人的權限)
|
||||||
|
* - service :服務級憑據(static token / partner key)→ 維持既有 KBDB 直連(零回歸)
|
||||||
|
* - stale :OAuth token 但沒帶身分(本次改版前簽發的舊 token)→ **誠實要求重新連線**,
|
||||||
|
* 不偷偷退回服務金鑰那條老路(那正是要修掉的「不管誰登入都看到同一格」)
|
||||||
|
*/
|
||||||
|
export type KnowledgeIdentity =
|
||||||
|
| { kind: "portal"; portal: PortalIdentity }
|
||||||
|
| { kind: "service" }
|
||||||
|
| { kind: "stale" };
|
||||||
|
|
||||||
|
export function resolveKnowledgeIdentity(
|
||||||
|
authPath: "oauth" | "service",
|
||||||
|
portal: PortalIdentity | undefined,
|
||||||
|
): KnowledgeIdentity {
|
||||||
|
if (authPath !== "oauth") return { kind: "service" };
|
||||||
|
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 舊 token(沒帶身分)時的統一回覆:講清楚怎麼修,不假裝查不到資料。 */
|
||||||
|
export function staleIdentityError() {
|
||||||
|
return errorResponse(
|
||||||
|
"identity_missing",
|
||||||
|
"這條 MCP 連線是舊版簽發的 token,裡面沒有登入者身分,因此查不到任何知識內容。" +
|
||||||
|
"重新連線一次(在 claude.ai 的 connector 設定裡重新授權、輸入你的 Portal 帳密)即可——" +
|
||||||
|
"不需要另外找任何 credential 或金鑰。",
|
||||||
|
[
|
||||||
|
"到 claude.ai → Settings → Connectors,把這個 connector 重新連線一次(會跳出輸入 Portal 帳密的頁面)",
|
||||||
|
"重連後 kbdb_* 全部工具都會用你這個帳號的權限查詢",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* portal 資料面的錯誤 → 給 AI 看的訊息。
|
||||||
|
* 401/403 特別處理:那代表**登入階段過期或帳號被停用**,不是「資料不存在」——
|
||||||
|
* 兩者混在一起會讓 AI 對使用者說「你的知識庫是空的」,那是畫面在說謊。
|
||||||
|
*/
|
||||||
|
export async function portalError(res: Response, what: string) {
|
||||||
|
const detail = await res.text().catch(() => "");
|
||||||
|
if (res.status === 401) {
|
||||||
|
return errorResponse(
|
||||||
|
"session_expired",
|
||||||
|
`${what}失敗:登入階段已過期(portal session 到期或已登出)。`,
|
||||||
|
[
|
||||||
|
"到 claude.ai → Settings → Connectors 重新連線這個 connector(重新輸入 Portal 帳密)",
|
||||||
|
"重連後權限與你在 portal 網頁上看到的一致",
|
||||||
|
],
|
||||||
|
detail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (res.status === 403) {
|
||||||
|
return errorResponse(
|
||||||
|
"forbidden",
|
||||||
|
`${what}失敗:這個帳號沒有這項權限(帳號可能已停用,或沒有被授權該知識庫)。`,
|
||||||
|
["請知識庫管理員在 portal 的帳號管理裡確認你的狀態與可用知識庫"],
|
||||||
|
detail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return errorResponse(`portal_${res.status}`, `${what}失敗(HTTP ${res.status})`, ["稍後重試"], detail);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||||
import { registerAllTools } from "./tools/registry.js";
|
import { registerAllTools } from "./tools/registry.js";
|
||||||
import { buildLibraryMapInstructions } from "./lib/library-map.js";
|
import { buildLibraryMapInstructions } from "./lib/library-map.js";
|
||||||
|
import type { KnowledgeIdentity } from "./lib/portal-client.js";
|
||||||
import { Env } from "./types.js";
|
import { Env } from "./types.js";
|
||||||
|
|
||||||
export async function handleMcpRequest(
|
export async function handleMcpRequest(
|
||||||
@@ -9,11 +10,16 @@ export async function handleMcpRequest(
|
|||||||
env: Env,
|
env: Env,
|
||||||
orgNamespace: string,
|
orgNamespace: string,
|
||||||
partnerToken: string,
|
partnerToken: string,
|
||||||
|
identity: KnowledgeIdentity,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
// library-map SDD M4(design §4/§6):連線時把全館藏書地圖嵌進 server instructions,
|
// library-map SDD M4(design §4/§6):連線時把全館藏書地圖嵌進 server instructions,
|
||||||
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeout+isolate TTL 快取
|
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeout+isolate TTL 快取
|
||||||
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
|
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
|
||||||
const mapInstructions = await buildLibraryMapInstructions(env);
|
//
|
||||||
|
// 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把
|
||||||
|
// 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。
|
||||||
|
// 快取也因此改成 per-session key(見 lib/library-map.ts)。
|
||||||
|
const mapInstructions = await buildLibraryMapInstructions(env, identity);
|
||||||
|
|
||||||
// 2026-07-30(leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
|
// 2026-07-30(leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
|
||||||
// 如果不會,要寫什麼在外面讓它一聽到就知道?」):
|
// 如果不會,要寫什麼在外面讓它一聽到就知道?」):
|
||||||
@@ -60,7 +66,7 @@ export async function handleMcpRequest(
|
|||||||
{ instructions },
|
{ instructions },
|
||||||
);
|
);
|
||||||
|
|
||||||
registerAllTools(server, env, orgNamespace, partnerToken);
|
registerAllTools(server, env, orgNamespace, partnerToken, identity);
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|
||||||
return transport.handleRequest(request);
|
return transport.handleRequest(request);
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { Context, Next } from "hono";
|
import { Context, Next } from "hono";
|
||||||
import { Env } from "../types.js";
|
import { Env } from "../types.js";
|
||||||
import { getAccessToken } from "../oauth/store.js";
|
import { getAccessToken, type PortalIdentity } from "../oauth/store.js";
|
||||||
import { constantTimeEqual } from "../oauth/crypto.js";
|
import { constantTimeEqual } from "../oauth/crypto.js";
|
||||||
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
|
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 這條連線是**用誰的身分**進來的。決定知識面(kbdb_*)走哪條路:
|
||||||
|
* - "oauth":有人在同意頁輸入過 Portal 帳密 → 帶著他的 portal session 走 portal 資料面,
|
||||||
|
* 權限=他在 portal 網頁上看得到的那些(庫過濾照吃)。
|
||||||
|
* - "service":static token / partner key 這類**服務級**憑據(本身就是真祕密,
|
||||||
|
* 代表整個實例或整個租戶,不是某個人)→ 維持既有的 KBDB 直連行為,零回歸。
|
||||||
|
* 兩條路刻意分開命名,因為「這張 token 背後有沒有一個人」正是本次要能分辨的事。
|
||||||
|
*/
|
||||||
|
export type AuthPath = "oauth" | "service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP / GUI 端點認證中介層。
|
* MCP / GUI 端點認證中介層。
|
||||||
*
|
*
|
||||||
@@ -19,7 +29,15 @@ import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.
|
|||||||
* 已從預設路徑移除;只在明確設 ALLOW_PLAINTEXT_NAMESPACE="true" 的遷移情境才恢復。
|
* 已從預設路徑移除;只在明確設 ALLOW_PLAINTEXT_NAMESPACE="true" 的遷移情境才恢復。
|
||||||
*/
|
*/
|
||||||
export async function partnerAuthMiddleware(
|
export async function partnerAuthMiddleware(
|
||||||
c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>,
|
c: Context<{
|
||||||
|
Bindings: Env;
|
||||||
|
Variables: {
|
||||||
|
org_namespace: string;
|
||||||
|
partner_token: string;
|
||||||
|
portal?: PortalIdentity;
|
||||||
|
auth_path: AuthPath;
|
||||||
|
};
|
||||||
|
}>,
|
||||||
next: Next
|
next: Next
|
||||||
) {
|
) {
|
||||||
const origin = originOf(c.req.url);
|
const origin = originOf(c.req.url);
|
||||||
@@ -50,6 +68,11 @@ export async function partnerAuthMiddleware(
|
|||||||
}
|
}
|
||||||
c.set("org_namespace", at.namespace);
|
c.set("org_namespace", at.namespace);
|
||||||
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
|
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
|
||||||
|
// 登入者的身分(2026-08-12):知識面工具(kbdb_*)帶著它打 cypher 的 portal 資料面,
|
||||||
|
// 權限與這個人在 portal 網頁上看到的完全一致。舊 token 沒有這欄 → undefined,
|
||||||
|
// 知識面工具會要求重新連線(不偷偷退回服務金鑰那條老路)。
|
||||||
|
c.set("portal", at.portal);
|
||||||
|
c.set("auth_path", "oauth");
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,6 +83,7 @@ export async function partnerAuthMiddleware(
|
|||||||
const ns = c.env.MCP_OWNER_NAMESPACE || "leo";
|
const ns = c.env.MCP_OWNER_NAMESPACE || "leo";
|
||||||
c.set("org_namespace", ns);
|
c.set("org_namespace", ns);
|
||||||
c.set("partner_token", ns);
|
c.set("partner_token", ns);
|
||||||
|
c.set("auth_path", "service");
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,6 +103,7 @@ export async function partnerAuthMiddleware(
|
|||||||
}
|
}
|
||||||
c.set("org_namespace", info.org_namespace);
|
c.set("org_namespace", info.org_namespace);
|
||||||
c.set("partner_token", token);
|
c.set("partner_token", token);
|
||||||
|
c.set("auth_path", "service");
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,6 +114,7 @@ export async function partnerAuthMiddleware(
|
|||||||
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
|
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
|
||||||
c.set("org_namespace", token);
|
c.set("org_namespace", token);
|
||||||
c.set("partner_token", token);
|
c.set("partner_token", token);
|
||||||
|
c.set("auth_path", "service");
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-6
@@ -14,6 +14,7 @@ import {
|
|||||||
consumeAuthCode,
|
consumeAuthCode,
|
||||||
putAccessToken,
|
putAccessToken,
|
||||||
AUTH_CODE_TTL_SECONDS,
|
AUTH_CODE_TTL_SECONDS,
|
||||||
|
type PortalIdentity,
|
||||||
} from "./store.js";
|
} from "./store.js";
|
||||||
import {
|
import {
|
||||||
originOf,
|
originOf,
|
||||||
@@ -34,10 +35,25 @@ const CORS_JSON = {
|
|||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function ownerNamespace(env: Env): string {
|
/**
|
||||||
|
* **工作流面**(arcrun_* 工具)的租戶代號。知識面(kbdb_*)已不再讀它——
|
||||||
|
* 那邊改成跟著登入者的 portal session 走(見 store.ts PortalIdentity)。
|
||||||
|
*
|
||||||
|
* 為什麼這裡還留著、而且還有預設值:cypher 的 workflow API 是用「租戶代號當 opaque key」
|
||||||
|
* (X-Arcrun-API-Key)認的,不吃 portal session;要拆掉它得先在 cypher 開一組
|
||||||
|
* 吃 portal session 的 workflow 端點。那是下一步,不在本次範圍——
|
||||||
|
* 硬拆會把現在好好的 arcrun_* 弄壞。**誠實記在這裡,不假裝已經解決。**
|
||||||
|
*
|
||||||
|
* ⚠️ 預設值 "leo" 的**知識面**用法已消滅:它曾經是「不管誰登入都看到同一格」的根因
|
||||||
|
* (namespace 直接當 KBDB 的 owner_id 用)。現在它只當工作流面的 API key。
|
||||||
|
*/
|
||||||
|
function workflowTenant(env: Env): string {
|
||||||
return env.MCP_OWNER_NAMESPACE || "leo";
|
return env.MCP_OWNER_NAMESPACE || "leo";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** portal session TTL 讀不到時的保守假設(秒):短的那邊贏,寧可早點要求重連。 */
|
||||||
|
const FALLBACK_PORTAL_SESSION_TTL = 604800; // 7 天(cypher portal.ts 的預設值)
|
||||||
|
|
||||||
function tokenTtl(env: Env): number {
|
function tokenTtl(env: Env): number {
|
||||||
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
|
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
|
||||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
|
||||||
@@ -248,7 +264,13 @@ export function registerOAuthRoutes<
|
|||||||
}
|
}
|
||||||
// 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。
|
// 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。
|
||||||
// 走 service binding(MCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。
|
// 走 service binding(MCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。
|
||||||
let loginOk = false;
|
//
|
||||||
|
// 🔴 2026-08-12(leo:「用登入能做的 mcp 就應該能做,結果要你去打 MCP 時自己找
|
||||||
|
// credential 問題很大」):這裡**接住登入回來的身分**,不再只留 `res.ok`。
|
||||||
|
// 舊版把身分丟掉 ⇒ 查詢時無身分可帶 ⇒ 只好去撈服務內部金鑰(KBDB_INTERNAL_TOKEN)
|
||||||
|
// 直打 KBDB ⇒ 繞過所有庫過濾、而且不管誰登入都看到同一格。根因就在這幾行。
|
||||||
|
let portal: PortalIdentity | null = null;
|
||||||
|
let portalTtl = FALLBACK_PORTAL_SESSION_TTL;
|
||||||
try {
|
try {
|
||||||
const res = await c.env.CYPHER_EXECUTOR.fetch(
|
const res = await c.env.CYPHER_EXECUTOR.fetch(
|
||||||
new Request("https://cypher/portal/login", {
|
new Request("https://cypher/portal/login", {
|
||||||
@@ -257,11 +279,34 @@ export function registerOAuthRoutes<
|
|||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
loginOk = res.ok;
|
if (res.ok) {
|
||||||
|
const body = (await res.json().catch(() => null)) as {
|
||||||
|
session_token?: unknown;
|
||||||
|
display_name?: unknown;
|
||||||
|
role?: unknown;
|
||||||
|
libraries?: unknown;
|
||||||
|
session_expires_in?: unknown;
|
||||||
|
} | null;
|
||||||
|
const session = typeof body?.session_token === "string" ? body.session_token : "";
|
||||||
|
if (session) {
|
||||||
|
portal = {
|
||||||
|
session,
|
||||||
|
display_name: typeof body?.display_name === "string" ? body.display_name : "",
|
||||||
|
role: typeof body?.role === "string" ? body.role : "user",
|
||||||
|
libraries: Array.isArray(body?.libraries)
|
||||||
|
? body.libraries.filter((x): x is string => typeof x === "string")
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
const ttl = Number(body?.session_expires_in);
|
||||||
|
if (Number.isFinite(ttl) && ttl > 0) portalTtl = ttl;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
|
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
|
||||||
}
|
}
|
||||||
if (!loginOk) {
|
if (!portal) {
|
||||||
|
// 帳密不對,或這台 cypher 舊到還不回 session_token。兩者都不可以發碼——
|
||||||
|
// 發了也是一張沒有身分的 token,查什麼都得再找一次 credential,正是要修的病。
|
||||||
return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 401);
|
return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 401);
|
||||||
}
|
}
|
||||||
if (!c.env.OAUTH_KV) {
|
if (!c.env.OAUTH_KV) {
|
||||||
@@ -275,7 +320,9 @@ export function registerOAuthRoutes<
|
|||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
scope: consent.scope,
|
scope: consent.scope,
|
||||||
resource: consent.resource,
|
resource: consent.resource,
|
||||||
namespace: ownerNamespace(c.env),
|
namespace: workflowTenant(c.env),
|
||||||
|
portal,
|
||||||
|
portal_session_expires_in: portalTtl,
|
||||||
});
|
});
|
||||||
const location = redirectWith(redirectUri, {
|
const location = redirectWith(redirectUri, {
|
||||||
code,
|
code,
|
||||||
@@ -318,7 +365,9 @@ export function registerOAuthRoutes<
|
|||||||
return err("invalid_grant", "PKCE verification failed");
|
return err("invalid_grant", "PKCE verification failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttl = tokenTtl(c.env);
|
// token 活不過它底下的 portal session:否則第 8 天會出現「MCP 還連著、卻什麼都查不到」
|
||||||
|
// ——使用者看到的是壞掉,實際是身分過期。兩者一起到期,重連就是重新輸入帳密,一次搞定。
|
||||||
|
const ttl = Math.min(tokenTtl(c.env), data.portal_session_expires_in || FALLBACK_PORTAL_SESSION_TTL);
|
||||||
const accessToken = randomToken(32);
|
const accessToken = randomToken(32);
|
||||||
await putAccessToken(
|
await putAccessToken(
|
||||||
c.env.OAUTH_KV,
|
c.env.OAUTH_KV,
|
||||||
@@ -327,6 +376,7 @@ export function registerOAuthRoutes<
|
|||||||
namespace: data.namespace,
|
namespace: data.namespace,
|
||||||
client_id: data.client_id,
|
client_id: data.client_id,
|
||||||
scope: data.scope,
|
scope: data.scope,
|
||||||
|
portal: data.portal,
|
||||||
// RFC 8707:aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
|
// RFC 8707:aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
|
||||||
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
|
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
|
||||||
aud: resourceUri(originOf(c.req.url)),
|
aud: resourceUri(originOf(c.req.url)),
|
||||||
|
|||||||
@@ -4,6 +4,28 @@
|
|||||||
// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。
|
// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。
|
||||||
import { sha256Hex } from "./crypto.js";
|
import { sha256Hex } from "./crypto.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登入者的身分(authorize 時用帳密換到,之後跟著 token 走)。
|
||||||
|
*
|
||||||
|
* leo 2026-08-12:「掛上 MCP 並輸入帳密,那個動作本身就是授權。」
|
||||||
|
* ⇒ 驗完帳密**不能只留一個布林值**——身分要接住並攜帶,下游才不必再要一次認證。
|
||||||
|
*
|
||||||
|
* `session` 是 cypher `/portal/login` 發的 portal session token,與人類在 portal 網頁上
|
||||||
|
* 拿到的完全同一種。它是「取得的暫時性認證」,正合本檔開頭的儲存鐵律(可進 KV、帶 TTL);
|
||||||
|
* access_token 的 TTL 會被夾到不超過它(見 routes.ts),兩者一起到期,不會出現
|
||||||
|
* 「MCP 還連著、底下 session 早死」的鬼打牆。
|
||||||
|
*
|
||||||
|
* display_name / role / libraries 只是**給人看的回報值**(arcrun_whoami)。
|
||||||
|
* 真正的權限判定每次都由 cypher 回讀 user record 現算——這裡的副本不是判準,
|
||||||
|
* 所以管理員改權限或停用帳號會立刻生效,不必等 token 過期。
|
||||||
|
*/
|
||||||
|
export interface PortalIdentity {
|
||||||
|
session: string;
|
||||||
|
display_name: string;
|
||||||
|
role: string;
|
||||||
|
libraries: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */
|
/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */
|
||||||
export interface AuthCodeData {
|
export interface AuthCodeData {
|
||||||
client_id: string;
|
client_id: string;
|
||||||
@@ -15,6 +37,10 @@ export interface AuthCodeData {
|
|||||||
resource: string;
|
resource: string;
|
||||||
/** 換發後 token 綁定的資料分區(owner namespace)。 */
|
/** 換發後 token 綁定的資料分區(owner namespace)。 */
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
/** 這張 code 是誰換的(帳密驗過的那個人)。 */
|
||||||
|
portal: PortalIdentity;
|
||||||
|
/** portal session 剩餘秒數(authorize 當下);access_token TTL 不得超過它。 */
|
||||||
|
portal_session_expires_in: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** access token 綁定的資料。 */
|
/** access token 綁定的資料。 */
|
||||||
@@ -26,6 +52,11 @@ export interface AccessTokenData {
|
|||||||
aud: string;
|
aud: string;
|
||||||
/** 過期時間(epoch 秒),與 KV TTL 雙保險。 */
|
/** 過期時間(epoch 秒),與 KV TTL 雙保險。 */
|
||||||
exp: number;
|
exp: number;
|
||||||
|
/**
|
||||||
|
* 持這張 token 的是誰。**舊 token(本次改版前簽發的)沒有這欄** → undefined,
|
||||||
|
* 知識面工具會誠實要求重新連線,而不是偷偷退回服務金鑰那條老路(fail-closed)。
|
||||||
|
*/
|
||||||
|
portal?: PortalIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CODE_PREFIX = "oauth:code:";
|
const CODE_PREFIX = "oauth:code:";
|
||||||
|
|||||||
@@ -5,31 +5,75 @@
|
|||||||
* 治本是給 AI 無腦入口:問工具拿身份。CLI 有 acr whoami,MCP 必須對齊(薄殼一致,rule 07 §5)——
|
* 治本是給 AI 無腦入口:問工具拿身份。CLI 有 acr whoami,MCP 必須對齊(薄殼一致,rule 07 §5)——
|
||||||
* 否則「AI 偏好 MCP」時又得繞回 curl。
|
* 否則「AI 偏好 MCP」時又得繞回 curl。
|
||||||
*
|
*
|
||||||
* 薄殼:只回報 MCP 已解析的 orgNamespace(綁哪個帳號)+ cypher binding 連向,無業務邏輯。
|
* 2026-08-12 改:以帳密連線時,「我是誰」的答案是**登入的那個人**(display_name / role /
|
||||||
|
* 可用知識庫),不是一個租戶代號。原本回的 account_namespace 是租戶字串——那東西一旦落到
|
||||||
|
* 呼叫端手上就能拿去直打 /kbdb/*、繞過所有庫過濾(portal-data.ts 檔頭紅線),所以登入身分下
|
||||||
|
* 不再回它。工作流面(arcrun_*)仍用它當 API key,但那只在 server 內部用。
|
||||||
|
*
|
||||||
|
* 薄殼:只如實回報 MCP 已解析的身分,不做推斷、不打任何查詢。
|
||||||
*/
|
*/
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { toolName } from "../brand.js";
|
import { toolName } from "../brand.js";
|
||||||
import { Env } from "../types.js";
|
import { Env } from "../types.js";
|
||||||
|
import type { KnowledgeIdentity } from "../lib/portal-client.js";
|
||||||
|
|
||||||
export function registerWhoami(server: McpServer, env: Env, orgNamespace: string) {
|
export function registerWhoami(
|
||||||
|
server: McpServer,
|
||||||
|
env: Env,
|
||||||
|
orgNamespace: string,
|
||||||
|
identity: KnowledgeIdentity,
|
||||||
|
) {
|
||||||
server.tool(
|
server.tool(
|
||||||
toolName("whoami"),
|
toolName("whoami"),
|
||||||
"回報這個 MCP 連線目前生效的身份:綁哪個帳號 / namespace、cypher 連向哪。" +
|
"回報這個 MCP 連線目前生效的身份:以帳密連線時回「登入的是誰、能看哪些知識庫」;" +
|
||||||
"部署 / 觸發 / 查 workflow 前先 call 此 tool 確認帳號,**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
|
"服務級 token 連線時回綁定的帳號 namespace。部署 / 觸發 / 查 workflow 前先 call 此 tool 確認身份," +
|
||||||
|
"**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
|
||||||
{},
|
{},
|
||||||
async () => {
|
async () => {
|
||||||
// 薄殼:MCP 透過 service binding(CYPHER_EXECUTOR)連 cypher,binding 本身決定連哪台;
|
const base = {
|
||||||
// 身份來自啟動時解析的 orgNamespace(綁哪個帳號的資料分區)。這裡只如實回報,不做推斷。
|
|
||||||
const identity = {
|
|
||||||
account_namespace: orgNamespace || "(未設)",
|
|
||||||
cypher: "service-binding:CYPHER_EXECUTOR",
|
cypher: "service-binding:CYPHER_EXECUTOR",
|
||||||
kbdb: "service-binding:KBDB",
|
kbdb: "service-binding:KBDB",
|
||||||
note:
|
|
||||||
"此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: JSON.stringify(identity, null, 2) }],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (identity.kind === "portal") {
|
||||||
|
const { display_name, role, libraries } = identity.portal;
|
||||||
|
return json({
|
||||||
|
...base,
|
||||||
|
auth: "portal-login(這條連線是有人輸入 Portal 帳密授權的)",
|
||||||
|
logged_in_as: display_name || "(未設顯示名稱)",
|
||||||
|
role,
|
||||||
|
libraries: libraries.length ? libraries : ["(尚未被授權任何知識庫)"],
|
||||||
|
knowledge_scope:
|
||||||
|
libraries.includes("*")
|
||||||
|
? "全部知識庫(此帳號有全庫權限)"
|
||||||
|
: `僅限上列知識庫——kbdb_* 查得到的東西與這個帳號在 portal 網頁上看得到的完全一致`,
|
||||||
|
note:
|
||||||
|
"你是「主人授權的 AI」:主人查得到的你查得到,主人查不到的你也查不到。" +
|
||||||
|
"kbdb_* 不需要任何額外的 credential / 金鑰 / kbdb_base——已經登入過了,不會再問第二次。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identity.kind === "stale") {
|
||||||
|
return json({
|
||||||
|
...base,
|
||||||
|
auth: "舊版 token(沒有登入者身分)",
|
||||||
|
knowledge_scope: "查不到任何知識內容",
|
||||||
|
note:
|
||||||
|
"這條連線是本次改版前簽發的 token。到 claude.ai → Settings → Connectors " +
|
||||||
|
"重新連線一次(輸入 Portal 帳密)即可恢復,不需要找任何 credential。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
...base,
|
||||||
|
auth: "service token(static token / partner key,代表整個實例或租戶,不是某個人)",
|
||||||
|
account_namespace: orgNamespace || "(未設)",
|
||||||
|
note: "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function json(obj: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify(obj, null, 2) }] };
|
||||||
|
}
|
||||||
|
|||||||
+148
-59
@@ -1,23 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* KBDB 資料層 MCP 薄殼(kbdb-base Phase 9.1,HANDOFF §2)
|
* KBDB 資料層 MCP 薄殼(kbdb-base Phase 9.1,HANDOFF §2)
|
||||||
*
|
*
|
||||||
* rule 07 §5(薄殼鐵律):能力長在基本盤 API,MCP 只做介面轉換 + 暴露,無業務邏輯。
|
* rule 07 §5(薄殼鐵律):能力長在 API,MCP 只做介面轉換 + 暴露,無業務邏輯。
|
||||||
* 全走既有 kbdbFetch(KBDB service binding)打基本盤 HTTP API(kbdb/src/routes/*)。
|
*
|
||||||
|
* ── 2026-08-12:改用「登入進來的那個人的身分」查詢 ────────────────────────────
|
||||||
|
* leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的
|
||||||
|
* MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||||
|
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
|
||||||
|
*
|
||||||
|
* 之前的路:MCP 驗完帳密只留一個布林值 → 查詢時無身分可帶 → 只好帶**服務內部金鑰**
|
||||||
|
* (KBDB_INTERNAL_TOKEN)直打 KBDB。那條路繞過所有庫過濾,而且不管誰登入都看到同一格。
|
||||||
|
*
|
||||||
|
* 現在的路(identity.kind === 'portal'):帶登入者的 portal session 打 cypher
|
||||||
|
* `/portal/data/*`——庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁
|
||||||
|
* 是**同一道閘、同一份權限**。MCP 這邊一個判斷都不做。
|
||||||
|
*
|
||||||
|
* 服務級憑據(static token / partner key,identity.kind === 'service')維持既有 KBDB 直連,
|
||||||
|
* 零回歸——那類憑據本身就是真祕密、代表整個實例或租戶,不是某個人。
|
||||||
*
|
*
|
||||||
* KBDB 鐵律(leo 2026-06-14,頂層 DECISION-kbdb-v3-baseplane.md):
|
* KBDB 鐵律(leo 2026-06-14,頂層 DECISION-kbdb-v3-baseplane.md):
|
||||||
* - 任何人不准動表;**不提供建表 / SQL tool**。
|
* - 任何人不准動表;**不提供建表 / SQL tool**。
|
||||||
* - AI 想存新類型的資料時只有「建 template(name+slots)+ 填 record(slot→content)」可用
|
* - AI 想存新類型的資料時只有「建 template(name+slots)+ 填 record(slot→content)」可用。
|
||||||
* ——類 Supabase 萬用表,schema 由 template/slot 表達,不是真的 CREATE TABLE。
|
* - 薄殼只調 HTTP API,不直連 D1、不寫 SQL。
|
||||||
* - 薄殼只調基本盤 HTTP API,不直連 D1、不寫 SQL。
|
|
||||||
*
|
|
||||||
* 基本盤 API 契約(已存在,kbdb/src/routes):
|
|
||||||
* POST /templates { name, slots[], description?, created_by? } → { template }
|
|
||||||
* GET /templates → { templates[], count }
|
|
||||||
* GET /templates/:idOrName → { template }
|
|
||||||
* POST /records { template, values:{slot:content}, owner_id? } → { record }
|
|
||||||
* GET /records/by-template/:t ?owner_id= → { records[], count }
|
|
||||||
* GET /records/:recordId → { record }
|
|
||||||
* GET /entries/search ?q=&owner_id= → { entries[], count, mode:'keyword' }
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
@@ -25,22 +29,32 @@ import { z } from "zod";
|
|||||||
import type { Env } from "../types.js";
|
import type { Env } from "../types.js";
|
||||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||||
|
import {
|
||||||
|
portalFetch,
|
||||||
|
portalError,
|
||||||
|
staleIdentityError,
|
||||||
|
type KnowledgeIdentity,
|
||||||
|
} from "../lib/portal-client.js";
|
||||||
|
|
||||||
|
/** 走 portal 資料面時,呼叫端傳的 owner_id 一律無效(server 用登入者的歸屬)——如實告訴 AI。 */
|
||||||
|
const OWNER_IGNORED_HINT =
|
||||||
|
"owner_id 在登入身分下不生效:查詢範圍由你的帳號權限決定(與你在 portal 網頁看到的一致)";
|
||||||
|
|
||||||
/** 註冊全部 KBDB 資料層工具(kbdb-base Phase 9.1)。不含建表/SQL tool(鐵律)。 */
|
/** 註冊全部 KBDB 資料層工具(kbdb-base Phase 9.1)。不含建表/SQL tool(鐵律)。 */
|
||||||
export function registerAllKbdbDataTools(server: McpServer, env: Env) {
|
export function registerAllKbdbDataTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||||
registerCreateTemplate(server, env);
|
registerCreateTemplate(server, env, identity);
|
||||||
registerListTemplates(server, env);
|
registerListTemplates(server, env, identity);
|
||||||
registerCreateRecord(server, env);
|
registerCreateRecord(server, env, identity);
|
||||||
registerGetRecord(server, env);
|
registerGetRecord(server, env, identity);
|
||||||
registerQuery(server, env);
|
registerQuery(server, env, identity);
|
||||||
registerSearch(server, env);
|
registerSearch(server, env, identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* kbdb_create_template — 建一個 template(= 萬用表裡的一種「虛擬表/資料形狀」)。
|
* kbdb_create_template — 建一個 template(= 萬用表裡的一種「虛擬表/資料形狀」)。
|
||||||
* 這是 AI 想存「新類型資料」時的唯一入口:沒有建表 API,改用 template + slots 描述欄位。
|
* 這是 AI 想存「新類型資料」時的唯一入口:沒有建表 API,改用 template + slots 描述欄位。
|
||||||
*/
|
*/
|
||||||
export function registerCreateTemplate(server: McpServer, env: Env) {
|
export function registerCreateTemplate(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"kbdb_create_template",
|
"kbdb_create_template",
|
||||||
"建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" +
|
"建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" +
|
||||||
@@ -50,16 +64,24 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
|
|||||||
name: z.string().min(1).describe("template 名稱(唯一識別,之後填 record 用這個名字),如 'contact' / 'note'"),
|
name: z.string().min(1).describe("template 名稱(唯一識別,之後填 record 用這個名字),如 'contact' / 'note'"),
|
||||||
slots: z.array(z.string().min(1)).min(1).describe("欄位名清單,如 ['name','email','phone']"),
|
slots: z.array(z.string().min(1)).min(1).describe("欄位名清單,如 ['name','email','phone']"),
|
||||||
description: z.string().optional().describe("這個 template 用途的簡述(選填)"),
|
description: z.string().optional().describe("這個 template 用途的簡述(選填)"),
|
||||||
created_by: z.string().optional().describe("建立者標記(選填)"),
|
created_by: z.string().optional().describe("建立者標記(選填;登入身分下由 server 記錄,不吃此值)"),
|
||||||
},
|
},
|
||||||
async ({ name, slots, description, created_by }) => {
|
async ({ name, slots, description, created_by }) => {
|
||||||
|
if (identity.kind === "stale") return staleIdentityError();
|
||||||
try {
|
try {
|
||||||
const res = await kbdbFetch(env, "/templates", {
|
const res =
|
||||||
method: "POST",
|
identity.kind === "portal"
|
||||||
headers: { "Content-Type": "application/json" },
|
? await portalFetch(env, identity.portal.session, "/portal/data/templates", {
|
||||||
body: JSON.stringify({ name, slots, description, created_by }),
|
method: "POST",
|
||||||
});
|
body: { name, slots, description },
|
||||||
|
})
|
||||||
|
: await kbdbFetch(env, "/templates", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, slots, description, created_by }),
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (identity.kind === "portal") return portalError(res, `建 template「${name}」`);
|
||||||
return errorResponse("create_template_failed", `建 template 失敗`, ["檢查 name 是否重複", "確認 slots 是非空字串陣列"], await res.text().catch(() => ""));
|
return errorResponse("create_template_failed", `建 template 失敗`, ["檢查 name 是否重複", "確認 slots 是非空字串陣列"], await res.text().catch(() => ""));
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -74,17 +96,28 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */
|
/** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */
|
||||||
export function registerListTemplates(server: McpServer, env: Env) {
|
export function registerListTemplates(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"kbdb_list_templates",
|
"kbdb_list_templates",
|
||||||
"列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。",
|
"列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。",
|
||||||
{},
|
{},
|
||||||
async () => {
|
async () => {
|
||||||
|
if (identity.kind === "stale") return staleIdentityError();
|
||||||
try {
|
try {
|
||||||
const res = await kbdbFetch(env, "/templates");
|
const res =
|
||||||
if (!res.ok) return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
identity.kind === "portal"
|
||||||
|
? await portalFetch(env, identity.portal.session, "/portal/data/templates")
|
||||||
|
: await kbdbFetch(env, "/templates");
|
||||||
|
if (!res.ok) {
|
||||||
|
if (identity.kind === "portal") return portalError(res, "列 template");
|
||||||
|
return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return successResponse(data, ["每個 template 的 slots_json 是它的欄位清單", "填資料用 kbdb_create_record"]);
|
return successResponse(data, [
|
||||||
|
"每個 template 的 slots_json 是它的欄位清單",
|
||||||
|
"填資料用 kbdb_create_record",
|
||||||
|
"template 是全域共享的「資料形狀」定義(schema),不含任何人的內容——內容的權限在 record/entry 那層",
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||||
}
|
}
|
||||||
@@ -93,7 +126,7 @@ export function registerListTemplates(server: McpServer, env: Env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** kbdb_create_record — 依某 template 填一筆 record(slot → 內容)。 */
|
/** kbdb_create_record — 依某 template 填一筆 record(slot → 內容)。 */
|
||||||
export function registerCreateRecord(server: McpServer, env: Env) {
|
export function registerCreateRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"kbdb_create_record",
|
"kbdb_create_record",
|
||||||
"依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容},slot 名要對得上 template 的 slots。" +
|
"依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容},slot 名要對得上 template 的 slots。" +
|
||||||
@@ -101,23 +134,34 @@ export function registerCreateRecord(server: McpServer, env: Env) {
|
|||||||
{
|
{
|
||||||
template: z.string().min(1).describe("template 的 name 或 id"),
|
template: z.string().min(1).describe("template 的 name 或 id"),
|
||||||
values: z.record(z.string()).describe("欄位內容 {slot名: 字串內容},如 {name:'Leo', email:'leo@x.com'}"),
|
values: z.record(z.string()).describe("欄位內容 {slot名: 字串內容},如 {name:'Leo', email:'leo@x.com'}"),
|
||||||
owner_id: z.string().optional().describe("資料歸屬標記(選填,如專案 id / 用戶 id)"),
|
owner_id: z.string().optional().describe("資料歸屬標記(選填;登入身分下一律由 server 定成你的歸屬,不吃此值)"),
|
||||||
},
|
},
|
||||||
async ({ template, values, owner_id }) => {
|
async ({ template, values, owner_id }) => {
|
||||||
|
if (identity.kind === "stale") return staleIdentityError();
|
||||||
try {
|
try {
|
||||||
const res = await kbdbFetch(env, "/records", {
|
const res =
|
||||||
method: "POST",
|
identity.kind === "portal"
|
||||||
headers: { "Content-Type": "application/json" },
|
? await portalFetch(env, identity.portal.session, "/portal/data/records", {
|
||||||
body: JSON.stringify({ template, values, owner_id }),
|
method: "POST",
|
||||||
});
|
body: { template, values },
|
||||||
|
})
|
||||||
|
: await kbdbFetch(env, "/records", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ template, values, owner_id }),
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (identity.kind === "portal") return portalError(res, `填 record(template「${template}」)`);
|
||||||
return errorResponse("create_record_failed", `填 record 失敗`, [
|
return errorResponse("create_record_failed", `填 record 失敗`, [
|
||||||
`確認 template「${template}」存在(kbdb_list_templates)`,
|
`確認 template「${template}」存在(kbdb_list_templates)`,
|
||||||
"values 的 slot 名要對得上 template 的 slots",
|
"values 的 slot 名要對得上 template 的 slots",
|
||||||
], await res.text().catch(() => ""));
|
], await res.text().catch(() => ""));
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return successResponse(data, [`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`]);
|
return successResponse(data, [
|
||||||
|
`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`,
|
||||||
|
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||||
}
|
}
|
||||||
@@ -126,7 +170,7 @@ export function registerCreateRecord(server: McpServer, env: Env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** kbdb_get_record — 用 record_id 取單筆 record。 */
|
/** kbdb_get_record — 用 record_id 取單筆 record。 */
|
||||||
export function registerGetRecord(server: McpServer, env: Env) {
|
export function registerGetRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"kbdb_get_record",
|
"kbdb_get_record",
|
||||||
"用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。",
|
"用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。",
|
||||||
@@ -134,10 +178,23 @@ export function registerGetRecord(server: McpServer, env: Env) {
|
|||||||
record_id: z.string().min(1).describe("record 的 id(rec_xxx)"),
|
record_id: z.string().min(1).describe("record 的 id(rec_xxx)"),
|
||||||
},
|
},
|
||||||
async ({ record_id }) => {
|
async ({ record_id }) => {
|
||||||
|
if (identity.kind === "stale") return staleIdentityError();
|
||||||
try {
|
try {
|
||||||
const res = await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
|
const res =
|
||||||
if (res.status === 404) return errorResponse("not_found", `record「${record_id}」不存在`, ["確認 record_id 正確", "用 kbdb_query 列出某 template 的 record 取 id"]);
|
identity.kind === "portal"
|
||||||
if (!res.ok) return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
? await portalFetch(env, identity.portal.session, `/portal/data/records/${encodeURIComponent(record_id)}`)
|
||||||
|
: await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
|
||||||
|
if (res.status === 404) {
|
||||||
|
// 登入身分下,「不是你的」與「不存在」刻意同回 404(不洩存在性,portal 同一條紅線)。
|
||||||
|
return errorResponse("not_found", `查無 record「${record_id}」(不存在,或不在你的權限範圍內)`, [
|
||||||
|
"確認 record_id 正確",
|
||||||
|
"用 kbdb_query 列出某 template 的 record 取 id",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (identity.kind === "portal") return portalError(res, "取 record");
|
||||||
|
return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return successResponse(data);
|
return successResponse(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -148,21 +205,39 @@ export function registerGetRecord(server: McpServer, env: Env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */
|
/** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */
|
||||||
export function registerQuery(server: McpServer, env: Env) {
|
export function registerQuery(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"kbdb_query",
|
"kbdb_query",
|
||||||
"列出某 template 底下的所有 record(結構化查詢,按 template 取整批資料)。要按關鍵字找內容用 kbdb_search。",
|
"列出某 template 底下的所有 record(結構化查詢,按 template 取整批資料)。要按關鍵字找內容用 kbdb_search。",
|
||||||
{
|
{
|
||||||
template: z.string().min(1).describe("template 的 name 或 id"),
|
template: z.string().min(1).describe("template 的 name 或 id"),
|
||||||
owner_id: z.string().optional().describe("只取某歸屬的 record(選填)"),
|
owner_id: z.string().optional().describe("只取某歸屬的 record(選填;登入身分下不生效,範圍由你的權限決定)"),
|
||||||
},
|
},
|
||||||
async ({ template, owner_id }) => {
|
async ({ template, owner_id }) => {
|
||||||
|
if (identity.kind === "stale") return staleIdentityError();
|
||||||
try {
|
try {
|
||||||
const path = `/records/by-template/${encodeURIComponent(template)}` + (owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "");
|
const res =
|
||||||
const res = await kbdbFetch(env, path);
|
identity.kind === "portal"
|
||||||
if (!res.ok) return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
|
? await portalFetch(
|
||||||
|
env,
|
||||||
|
identity.portal.session,
|
||||||
|
`/portal/data/records/by-template/${encodeURIComponent(template)}`,
|
||||||
|
)
|
||||||
|
: await kbdbFetch(
|
||||||
|
env,
|
||||||
|
`/records/by-template/${encodeURIComponent(template)}` +
|
||||||
|
(owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""),
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (identity.kind === "portal") return portalError(res, `查詢 template「${template}」的 record`);
|
||||||
|
return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return successResponse(data, ["用 kbdb_get_record(record_id) 取單筆全文", "按關鍵字找內容改用 kbdb_search"]);
|
return successResponse(data, [
|
||||||
|
"用 kbdb_get_record(record_id) 取單筆全文",
|
||||||
|
"按關鍵字找內容改用 kbdb_search",
|
||||||
|
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||||
}
|
}
|
||||||
@@ -175,26 +250,38 @@ export function registerQuery(server: McpServer, env: Env) {
|
|||||||
* 語義/關鍵字都在同一 KBDB MCP(用戶資料 RAG),不分散(issue #7 / D17 邊界)。
|
* 語義/關鍵字都在同一 KBDB MCP(用戶資料 RAG),不分散(issue #7 / D17 邊界)。
|
||||||
* mode=semantic 但沒開 vectorize → base 自動降級 keyword + 回 capability_hint(發現閉環,叫 CC 幫開)。
|
* mode=semantic 但沒開 vectorize → base 自動降級 keyword + 回 capability_hint(發現閉環,叫 CC 幫開)。
|
||||||
*/
|
*/
|
||||||
export function registerSearch(server: McpServer, env: Env) {
|
export function registerSearch(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"kbdb_search",
|
"kbdb_search",
|
||||||
"搜尋 KBDB 內容。mode='keyword'(預設,D1 LIKE 關鍵字,基本盤永遠可用)或 'semantic'(AI 向量語義搜尋," +
|
"搜尋 KBDB 內容。mode='keyword'(預設,D1 LIKE 關鍵字,基本盤永遠可用)或 'semantic'(AI 向量語義搜尋," +
|
||||||
"需先開 embed 模組)。語義沒開時會自動降級關鍵字並告訴你怎麼開。要按 template 取整批結構化資料用 kbdb_query。",
|
"需先開 embed 模組)。語義沒開時會自動降級關鍵字並告訴你怎麼開。要按 template 取整批結構化資料用 kbdb_query。",
|
||||||
{
|
{
|
||||||
q: z.string().min(1).describe("搜尋關鍵字 / 語義查詢句"),
|
q: z.string().min(1).describe("搜尋關鍵字 / 語義查詢句"),
|
||||||
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填)"),
|
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填;登入身分下不生效,範圍由你的權限決定)"),
|
||||||
source: z.string().optional().describe("只搜某來源(ingest source.uri,選填)"),
|
source: z.string().optional().describe("只搜某來源(ingest source.uri,選填)"),
|
||||||
mode: z.enum(["keyword", "semantic"]).optional().describe("keyword(預設)或 semantic(需開 vectorize)"),
|
mode: z.enum(["keyword", "semantic"]).optional().describe("keyword(預設)或 semantic(需開 vectorize)"),
|
||||||
},
|
},
|
||||||
async ({ q, owner_id, source, mode }) => {
|
async ({ q, owner_id, source, mode }) => {
|
||||||
|
if (identity.kind === "stale") return staleIdentityError();
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams({ q });
|
let res: Response;
|
||||||
if (owner_id) qs.set("owner_id", owner_id);
|
if (identity.kind === "portal") {
|
||||||
if (source) qs.set("source", source);
|
// /portal/data/search 只吃在權限範圍內「再收窄」的 filter;owner_id/library 由 server 定死。
|
||||||
if (mode) qs.set("mode", mode);
|
res = await portalFetch(env, identity.portal.session, "/portal/data/search", {
|
||||||
const res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
|
query: { q, mode },
|
||||||
if (!res.ok) return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
});
|
||||||
const data = (await res.json()) as { mode?: string; capability_hint?: string };
|
} else {
|
||||||
|
const qs = new URLSearchParams({ q });
|
||||||
|
if (owner_id) qs.set("owner_id", owner_id);
|
||||||
|
if (source) qs.set("source", source);
|
||||||
|
if (mode) qs.set("mode", mode);
|
||||||
|
res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (identity.kind === "portal") return portalError(res, "搜尋");
|
||||||
|
return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { mode?: string; capability_hint?: string; note?: string };
|
||||||
// base 回 capability_hint → 語義沒開、已降級 keyword。把它當 next-step 傳給 AI(發現閉環)。
|
// base 回 capability_hint → 語義沒開、已降級 keyword。把它當 next-step 傳給 AI(發現閉環)。
|
||||||
const hints =
|
const hints =
|
||||||
data.capability_hint
|
data.capability_hint
|
||||||
@@ -202,6 +289,8 @@ export function registerSearch(server: McpServer, env: Env) {
|
|||||||
: data.mode === "semantic"
|
: data.mode === "semantic"
|
||||||
? ["mode:semantic = AI 向量語義搜尋"]
|
? ["mode:semantic = AI 向量語義搜尋"]
|
||||||
: ["mode:keyword = D1 LIKE(基本盤)", "想要語義搜尋:mode='semantic'(需先開 vectorize)"];
|
: ["mode:keyword = D1 LIKE(基本盤)", "想要語義搜尋:mode='semantic'(需先開 vectorize)"];
|
||||||
|
if (identity.kind === "portal") hints.push(OWNER_IGNORED_HINT);
|
||||||
|
if (data.note) hints.push(data.note);
|
||||||
return successResponse(data, hints);
|
return successResponse(data, hints);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Env } from "../types.js";
|
import type { Env } from "../types.js";
|
||||||
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
|
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||||
|
import {
|
||||||
|
portalFetch,
|
||||||
|
portalError,
|
||||||
|
staleIdentityError,
|
||||||
|
type KnowledgeIdentity,
|
||||||
|
} from "../lib/portal-client.js";
|
||||||
|
|
||||||
/** graph 查詢 workflow 名(與 registry/examples/graph-neighbors/workflow.yaml 的 name 一致)。 */
|
/** graph 查詢 workflow 名(與 registry/examples/graph-neighbors/workflow.yaml 的 name 一致)。 */
|
||||||
export const GRAPH_NEIGHBORS_WORKFLOW = "graph_neighbors";
|
export const GRAPH_NEIGHBORS_WORKFLOW = "graph_neighbors";
|
||||||
@@ -35,8 +41,13 @@ const INSTALL_HINTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** 註冊全部 KBDB graph 查詢工具(issue #68)。 */
|
/** 註冊全部 KBDB graph 查詢工具(issue #68)。 */
|
||||||
export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamespace: string) {
|
export function registerAllKbdbGraphTools(
|
||||||
registerGraphNeighbors(server, env, orgNamespace);
|
server: McpServer,
|
||||||
|
env: Env,
|
||||||
|
orgNamespace: string,
|
||||||
|
identity: KnowledgeIdentity,
|
||||||
|
) {
|
||||||
|
registerGraphNeighbors(server, env, orgNamespace, identity);
|
||||||
// graph_traverse:repo 內目前只有 graph-neighbors 有 workflow 定義(registry/examples/),
|
// graph_traverse:repo 內目前只有 graph-neighbors 有 workflow 定義(registry/examples/),
|
||||||
// traverse 尚無可對齊的 input 形狀 → 不猜、不過度工程;等 workflow 進 registry 再加薄殼。
|
// traverse 尚無可對齊的 input 形狀 → 不猜、不過度工程;等 workflow 進 registry 再加薄殼。
|
||||||
}
|
}
|
||||||
@@ -45,7 +56,12 @@ export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamesp
|
|||||||
* kbdb_graph_neighbors — knowledge graph 1-hop/N-hop 鄰居查詢。
|
* kbdb_graph_neighbors — knowledge graph 1-hop/N-hop 鄰居查詢。
|
||||||
* 薄殼調 GET /q/{ns}/graph_neighbors,結果(最終節點輸出)原樣回給 MCP client。
|
* 薄殼調 GET /q/{ns}/graph_neighbors,結果(最終節點輸出)原樣回給 MCP client。
|
||||||
*/
|
*/
|
||||||
export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace: string) {
|
export function registerGraphNeighbors(
|
||||||
|
server: McpServer,
|
||||||
|
env: Env,
|
||||||
|
orgNamespace: string,
|
||||||
|
identity: KnowledgeIdentity,
|
||||||
|
) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"kbdb_graph_neighbors",
|
"kbdb_graph_neighbors",
|
||||||
"knowledge graph 鄰居查詢(1-hop/N-hop 關係遍歷):給一個節點名,沿 KBDB triplet" +
|
"knowledge graph 鄰居查詢(1-hop/N-hop 關係遍歷):給一個節點名,沿 KBDB triplet" +
|
||||||
@@ -60,10 +76,10 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
|
|||||||
depth: z.number().int().min(1).max(10).optional().describe(
|
depth: z.number().int().min(1).max(10).optional().describe(
|
||||||
"最大跳數(N-hop),預設 1(只看直接鄰居)",
|
"最大跳數(N-hop),預設 1(只看直接鄰居)",
|
||||||
),
|
),
|
||||||
kbdb_base: z.string().min(1).describe(
|
kbdb_base: z.string().min(1).optional().describe(
|
||||||
"你自己部署的 KBDB 對外 base URL(如 https://arcrun-kbdb.<你的subdomain>.workers.dev " +
|
"【登入身分下不需要,留空即可】你自己部署的 KBDB 對外 base URL。" +
|
||||||
"或 KBDB custom domain)。workflow 刻意不寫死任何一家的庫——" +
|
"以帳密連線的 MCP 由 server 端自己知道要查哪個庫——不必、也不該由你指定" +
|
||||||
"帶錯(或照抄別人的值)=查詢打進別人的庫",
|
"(指定了也不會採用)。只有服務級 token(static token / partner key)連線時才需要填。",
|
||||||
),
|
),
|
||||||
template: z.string().optional().describe(
|
template: z.string().optional().describe(
|
||||||
"triplet 記錄的 template 名,預設 'graph_triplet'(以實際部署的 kbdb-graph-plugin " +
|
"triplet 記錄的 template 名,預設 'graph_triplet'(以實際部署的 kbdb-graph-plugin " +
|
||||||
@@ -74,6 +90,43 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
async ({ subject, depth, kbdb_base, template, directed }) => {
|
async ({ subject, depth, kbdb_base, template, directed }) => {
|
||||||
|
if (identity.kind === "stale") return staleIdentityError();
|
||||||
|
|
||||||
|
// ── 登入身分:走 cypher 的 portal 資料面(與人類在 portal 按「關聯」同一支端點)──
|
||||||
|
// 那支已經有 D-4 graph 粗閘(沒有 graph 來源庫權限 → 403),也已經處理好
|
||||||
|
// 「這台實例沒裝 graph plugin 就改用 tenant 的 graph_neighbors workflow」的兩條路。
|
||||||
|
// ⇒ MCP 不必要 kbdb_base、不必知道租戶、不必再認證一次。
|
||||||
|
if (identity.kind === "portal") {
|
||||||
|
try {
|
||||||
|
const res = await portalFetch(
|
||||||
|
env,
|
||||||
|
identity.portal.session,
|
||||||
|
`/portal/data/graph/neighbors/${encodeURIComponent(subject)}`,
|
||||||
|
{ query: { depth: depth ?? 1 } },
|
||||||
|
);
|
||||||
|
if (!res.ok) return portalError(res, `查「${subject}」的鄰居`);
|
||||||
|
const out = (await res.json().catch(() => null)) as
|
||||||
|
| { neighbors?: unknown[]; edges?: unknown[]; count?: number }
|
||||||
|
| null;
|
||||||
|
return successResponse(out, [
|
||||||
|
`${out?.count ?? 0} 個鄰居(depth 上限 ${depth ?? 1})`,
|
||||||
|
"count=0 且不確定資料有沒有進圖:kbdb_query(template='triplet') 看三元組記錄",
|
||||||
|
"找關鍵字內容改用 kbdb_search;取單筆全文用 kbdb_get_record",
|
||||||
|
"查詢範圍=你這個帳號被授權的知識庫(與 portal 網頁上的關聯檢視一致)",
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 服務級憑據:既有路徑(打 /q/:ns/graph_neighbors workflow),行為零變更 ──
|
||||||
|
if (!kbdb_base) {
|
||||||
|
return errorResponse(
|
||||||
|
"kbdb_base_required",
|
||||||
|
"以服務級 token 連線時,graph 查詢需要 kbdb_base(你自己 KBDB 的對外 URL)",
|
||||||
|
["改用帳密連線(OAuth)則不需要此參數", "或帶上 kbdb_base 再試一次"],
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!orgNamespace) {
|
if (!orgNamespace) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"no_namespace",
|
"no_namespace",
|
||||||
|
|||||||
+36
-11
@@ -22,6 +22,12 @@ import type { Env } from "../types.js";
|
|||||||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||||||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||||||
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
|
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
|
||||||
|
import {
|
||||||
|
portalFetch,
|
||||||
|
portalError,
|
||||||
|
staleIdentityError,
|
||||||
|
type KnowledgeIdentity,
|
||||||
|
} from "../lib/portal-client.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 空庫/404 時的指引(誠實回報+給下一步,鐵律:不假綠)。
|
* 空庫/404 時的指引(誠實回報+給下一步,鐵律:不假綠)。
|
||||||
@@ -39,8 +45,8 @@ const RECOMPUTE_HINTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** 註冊全部藏書地圖工具(library-map M4)。 */
|
/** 註冊全部藏書地圖工具(library-map M4)。 */
|
||||||
export function registerAllKbdbMapTools(server: McpServer, env: Env) {
|
export function registerAllKbdbMapTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 單庫詳圖回傳形狀(GET /map/:library 的 map,slot 陣列已 parse 成物件)。 */
|
/** 單庫詳圖回傳形狀(GET /map/:library 的 map,slot 陣列已 parse 成物件)。 */
|
||||||
@@ -62,7 +68,7 @@ interface LibraryMapDetail {
|
|||||||
* kbdb_get_map — 藏書地圖。無參數=全館(每庫一行);帶 library=該庫詳圖。
|
* kbdb_get_map — 藏書地圖。無參數=全館(每庫一行);帶 library=該庫詳圖。
|
||||||
* design §6 retrieval 流程的第一站:地圖 → get_map(library) 細節 → graph/search 進庫。
|
* design §6 retrieval 流程的第一站:地圖 → get_map(library) 細節 → graph/search 進庫。
|
||||||
*/
|
*/
|
||||||
export function registerGetMap(server: McpServer, env: Env) {
|
export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeIdentity) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"kbdb_get_map",
|
"kbdb_get_map",
|
||||||
"藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities+" +
|
"藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities+" +
|
||||||
@@ -73,15 +79,26 @@ export function registerGetMap(server: McpServer, env: Env) {
|
|||||||
library: z.string().min(1).optional().describe(
|
library: z.string().min(1).optional().describe(
|
||||||
"庫名(如 'kb'/'notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)",
|
"庫名(如 'kb'/'notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)",
|
||||||
),
|
),
|
||||||
owner_id: z.string().optional().describe("限定某資料歸屬範圍(選填,與其他 kbdb_* 工具同義)"),
|
owner_id: z.string().optional().describe(
|
||||||
|
"限定某資料歸屬範圍(選填;登入身分下不生效,看得到哪些庫由你的帳號權限決定)",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
async ({ library, owner_id }) => {
|
async ({ library, owner_id }) => {
|
||||||
|
if (identity.kind === "stale") return staleIdentityError();
|
||||||
try {
|
try {
|
||||||
const qs = owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
|
// 登入身分:走 cypher 的 portal 資料面 —— 只會回這個帳號有權限的庫
|
||||||
|
//(KBDB 的 /map 對權限無知,會回全館;過濾在 cypher 那邊 server 側做)。
|
||||||
|
const isPortal = identity.kind === "portal";
|
||||||
|
const qs = !isPortal && owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
|
||||||
|
const mapFetch = (path: string) =>
|
||||||
|
identity.kind === "portal"
|
||||||
|
? portalFetch(env, identity.portal.session, `/portal/data${path}`)
|
||||||
|
: kbdbFetch(env, path);
|
||||||
|
|
||||||
if (!library) {
|
if (!library) {
|
||||||
// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。
|
// 全館地圖:每庫一行(library+narrative+top 3 entities+triplet_count)。
|
||||||
const res = await kbdbFetch(env, `/map${qs}`);
|
const res = await mapFetch(`/map${qs}`);
|
||||||
|
if (!res.ok && isPortal) return portalError(res, "取全館地圖");
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"map_fetch_failed",
|
"map_fetch_failed",
|
||||||
@@ -90,7 +107,7 @@ export function registerGetMap(server: McpServer, env: Env) {
|
|||||||
await res.text().catch(() => ""),
|
await res.text().catch(() => ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number };
|
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number; note?: string };
|
||||||
const libraries = (Array.isArray(data.libraries) ? data.libraries : []).map((l) => ({
|
const libraries = (Array.isArray(data.libraries) ? data.libraries : []).map((l) => ({
|
||||||
...l,
|
...l,
|
||||||
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
|
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
|
||||||
@@ -101,8 +118,13 @@ export function registerGetMap(server: McpServer, env: Env) {
|
|||||||
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
|
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
|
||||||
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
|
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
|
||||||
// 不再是「沒人跑過 recompute」那種曖昧狀態。
|
// 不再是「沒人跑過 recompute」那種曖昧狀態。
|
||||||
|
// 登入身分下還有第二種可能:這個帳號一個庫都沒被授權——「沒權限看」與「沒有資料」
|
||||||
|
// 不可以長得一樣,所以分開講(cypher 端會附 note 說明)。
|
||||||
return successResponse({ libraries: [], count: 0 }, [
|
return successResponse({ libraries: [], count: 0 }, [
|
||||||
"全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
|
isPortal
|
||||||
|
? "看不到任何庫:可能是這個知識庫真的還沒有三元組資料,也可能是你的帳號還沒被授權任何庫——請向管理員確認你的可用知識庫"
|
||||||
|
: "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
|
||||||
|
...(data.note ? [data.note] : []),
|
||||||
...RECOMPUTE_HINTS,
|
...RECOMPUTE_HINTS,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -113,7 +135,7 @@ export function registerGetMap(server: McpServer, env: Env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。
|
// 單庫詳圖:完整 slots(slot 陣列 parse 成物件再回)。
|
||||||
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
|
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
|
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
|
||||||
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
|
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
|
||||||
@@ -121,10 +143,13 @@ export function registerGetMap(server: McpServer, env: Env) {
|
|||||||
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
|
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"map_not_found",
|
"map_not_found",
|
||||||
`查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
|
isPortal
|
||||||
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
|
? `查無庫「${library}」——這個名字不存在,或不在你被授權的知識庫範圍內(兩者刻意同一句話,不洩漏某個庫存不存在)`
|
||||||
|
: `查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
|
||||||
|
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名/確認你有權限的庫)", ...RECOMPUTE_HINTS],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!res.ok && isPortal) return portalError(res, `取庫「${library}」詳圖`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
"map_fetch_failed",
|
"map_fetch_failed",
|
||||||
|
|||||||
@@ -20,8 +20,15 @@ import { registerAllKbdbDataTools } from "./kbdb_data.js";
|
|||||||
import { registerAllKbdbGraphTools } from "./kbdb_graph.js";
|
import { registerAllKbdbGraphTools } from "./kbdb_graph.js";
|
||||||
import { registerAllKbdbMapTools } from "./kbdb_map.js";
|
import { registerAllKbdbMapTools } from "./kbdb_map.js";
|
||||||
import { registerWhoami } from "./arcrun_whoami.js";
|
import { registerWhoami } from "./arcrun_whoami.js";
|
||||||
|
import type { KnowledgeIdentity } from "../lib/portal-client.js";
|
||||||
|
|
||||||
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
|
export function registerAllTools(
|
||||||
|
server: McpServer,
|
||||||
|
env: Env,
|
||||||
|
orgNamespace: string,
|
||||||
|
partnerToken: string,
|
||||||
|
identity: KnowledgeIdentity,
|
||||||
|
) {
|
||||||
registerSearchComponents(server, env, orgNamespace);
|
registerSearchComponents(server, env, orgNamespace);
|
||||||
// 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。
|
// 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。
|
||||||
// 零件貢獻**只有一條路=PR 人審**(leo 2026-08-01:「已經沒有 publish 了,
|
// 零件貢獻**只有一條路=PR 人審**(leo 2026-08-01:「已經沒有 publish 了,
|
||||||
@@ -53,13 +60,15 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
|
|||||||
registerAllRecipeTools(server, env);
|
registerAllRecipeTools(server, env);
|
||||||
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/search,HANDOFF §2)
|
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/search,HANDOFF §2)
|
||||||
// 鐵律:不提供建表/SQL tool,AI 只有 template+slot 可用(類 Supabase 萬用表)
|
// 鐵律:不提供建表/SQL tool,AI 只有 template+slot 可用(類 Supabase 萬用表)
|
||||||
registerAllKbdbDataTools(server, env);
|
// 2026-08-12:知識面(kbdb_*)全部改吃 identity——以帳密連線者走 portal 資料面
|
||||||
|
// (權限=那個人的權限),服務級憑據維持既有 KBDB 直連。見 lib/portal-client.ts。
|
||||||
|
registerAllKbdbDataTools(server, env, identity);
|
||||||
// issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點)
|
// issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點)
|
||||||
// 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷)
|
// 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷)
|
||||||
registerAllKbdbGraphTools(server, env, orgNamespace);
|
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
|
||||||
// library-map SDD M4(Arcrun#39): 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map//map/:library)
|
// library-map SDD M4(Arcrun#39): 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map//map/:library)
|
||||||
// retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6)
|
// retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6)
|
||||||
registerAllKbdbMapTools(server, env);
|
registerAllKbdbMapTools(server, env, identity);
|
||||||
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
|
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
|
||||||
registerWhoami(server, env, orgNamespace);
|
registerWhoami(server, env, orgNamespace, identity);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-4
@@ -2,6 +2,15 @@ export interface Env {
|
|||||||
COMPONENT_REGISTRY: Fetcher;
|
COMPONENT_REGISTRY: Fetcher;
|
||||||
CYPHER_EXECUTOR: Fetcher;
|
CYPHER_EXECUTOR: Fetcher;
|
||||||
KBDB: Fetcher;
|
KBDB: Fetcher;
|
||||||
|
/**
|
||||||
|
* KBDB 的服務內部金鑰。
|
||||||
|
*
|
||||||
|
* 2026-08-12 後**知識面(kbdb_*)以帳密連線時完全不用它**——那條路改走 cypher 的
|
||||||
|
* `/portal/data/*`,帶的是登入者自己的 portal session。它現在只剩兩個用途:
|
||||||
|
* ① 官方 SaaS 的 partner-key 驗證(middleware/partner-auth.ts 第 3 條)
|
||||||
|
* ② 服務級 token(static token)連線時的既有 KBDB 直連(零回歸)
|
||||||
|
* 兩者都拆掉之後,這個 binding 才能從 MCP 移除。
|
||||||
|
*/
|
||||||
KBDB_INTERNAL_TOKEN: string;
|
KBDB_INTERNAL_TOKEN: string;
|
||||||
API_KEY?: string;
|
API_KEY?: string;
|
||||||
// Platform telemetry / feedback aggregation key (optional)
|
// Platform telemetry / feedback aggregation key (optional)
|
||||||
@@ -20,11 +29,16 @@ export interface Env {
|
|||||||
// 短效認證儲存:authorization code(TTL ~600s)+ access token(TTL = MCP_TOKEN_TTL)。
|
// 短效認證儲存:authorization code(TTL ~600s)+ access token(TTL = MCP_TOKEN_TTL)。
|
||||||
// 只放「取得的暫時性認證」,key 用 SHA-256 hash(KV list 不外洩可用 token)。長效機密不進 KV。
|
// 只放「取得的暫時性認證」,key 用 SHA-256 hash(KV list 不外洩可用 token)。長效機密不進 KV。
|
||||||
OAUTH_KV?: KVNamespace;
|
OAUTH_KV?: KVNamespace;
|
||||||
// Owner 祕密(CF Secret,非 KV、非明碼 var):/authorize 同意頁的把關密碼。
|
// 【已停用,2026-07-30】舊的 owner 祕密。把關改成「使用者自己的 Portal 帳密」——
|
||||||
// 只有 owner 知道 → 「只知 URL + 明碼 namespace」的人走不完 OAuth,拿不到 token。
|
// 沒人給得了封測者這把祕密(安裝器產生後從不顯示、CF secret 又讀不回),
|
||||||
// 未設 → OAuth /authorize 回 503(拒絕在無把關下發碼,不留不安全預設)。
|
// 而且全實例共用一把、分不出是誰連上來的。程式已不再讀它;欄位留著只為不讓舊 toml 炸掉。
|
||||||
MCP_OWNER_SECRET?: string;
|
MCP_OWNER_SECRET?: string;
|
||||||
// OAuth 換發出的 access_token 綁定的 namespace(owner 的資料分區)。預設 "leo"。
|
// **工作流面**(arcrun_* 工具)的租戶代號,當 cypher 的 X-Arcrun-API-Key 用。預設 "leo"。
|
||||||
|
//
|
||||||
|
// ⚠️ 2026-08-12 起**知識面(kbdb_*)不再讀這個欄位**:那邊改成跟著登入者的 portal session
|
||||||
|
// 走(oauth/store.ts PortalIdentity)。此欄位曾被當成 KBDB 的 owner_id ⇒ 不管誰登入
|
||||||
|
// 都看到同一格、而且是全部——那個用法已經消滅。
|
||||||
|
// 要連工作流面也拆掉它,得先在 cypher 開一組吃 portal session 的 workflow 端點(下一步)。
|
||||||
MCP_OWNER_NAMESPACE?: string;
|
MCP_OWNER_NAMESPACE?: string;
|
||||||
// access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 2592000(30 天)。
|
// access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 2592000(30 天)。
|
||||||
// 過期後 claude.ai 重走 OAuth(owner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。
|
// 過期後 claude.ai 重走 OAuth(owner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。
|
||||||
|
|||||||
+202
-12
@@ -59,14 +59,55 @@ async function pkcePair() {
|
|||||||
return { verifier, challenge };
|
return { verifier, challenge };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cypher `/portal/login` 的假替身(2026-08-12 起 MCP 的把關就是這支——用使用者自己的
|
||||||
|
* Portal 帳密,沒有另一把 owner secret)。帳密對 → 回 session_token + 身分欄位;不對 → 401。
|
||||||
|
*/
|
||||||
|
const GOOD_EMAIL = "leo@example.com";
|
||||||
|
const GOOD_PASSWORD = "correct horse";
|
||||||
|
|
||||||
|
function cypherMock(
|
||||||
|
over: {
|
||||||
|
/** null = 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */
|
||||||
|
sessionToken?: string | null;
|
||||||
|
displayName?: string;
|
||||||
|
role?: string;
|
||||||
|
libraries?: string[];
|
||||||
|
sessionExpiresIn?: number;
|
||||||
|
} = {},
|
||||||
|
): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } {
|
||||||
|
const calls: Array<{ email: string; password: string }> = [];
|
||||||
|
const fetcher = {
|
||||||
|
async fetch(req: Request) {
|
||||||
|
const body = (await req.json()) as { email: string; password: string };
|
||||||
|
calls.push(body);
|
||||||
|
if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) {
|
||||||
|
return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 });
|
||||||
|
}
|
||||||
|
const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
...(sessionToken ? { session_token: sessionToken } : {}),
|
||||||
|
display_name: over.displayName ?? "Leo",
|
||||||
|
role: over.role ?? "admin",
|
||||||
|
libraries: over.libraries ?? ["*"],
|
||||||
|
session_expires_in: over.sessionExpiresIn ?? 604800,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} as unknown as Fetcher;
|
||||||
|
return { fetcher, calls };
|
||||||
|
}
|
||||||
|
|
||||||
function baseEnv(over: Partial<Env> = {}): Env {
|
function baseEnv(over: Partial<Env> = {}): Env {
|
||||||
return {
|
return {
|
||||||
COMPONENT_REGISTRY: {} as Fetcher,
|
COMPONENT_REGISTRY: {} as Fetcher,
|
||||||
CYPHER_EXECUTOR: {} as Fetcher,
|
CYPHER_EXECUTOR: cypherMock().fetcher,
|
||||||
KBDB: {} as Fetcher,
|
KBDB: {} as Fetcher,
|
||||||
KBDB_INTERNAL_TOKEN: "internal",
|
KBDB_INTERNAL_TOKEN: "internal",
|
||||||
OAUTH_KV: makeKV(),
|
OAUTH_KV: makeKV(),
|
||||||
MCP_OWNER_SECRET: "s3cr3t-owner",
|
|
||||||
MCP_OWNER_NAMESPACE: "leo",
|
MCP_OWNER_NAMESPACE: "leo",
|
||||||
...over,
|
...over,
|
||||||
} as Env;
|
} as Env;
|
||||||
@@ -121,6 +162,8 @@ describe("oauth/store", () => {
|
|||||||
scope: "mcp",
|
scope: "mcp",
|
||||||
resource: "https://mcp/mcp",
|
resource: "https://mcp/mcp",
|
||||||
namespace: "leo",
|
namespace: "leo",
|
||||||
|
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||||||
|
portal_session_expires_in: 604800,
|
||||||
});
|
});
|
||||||
const first = await consumeAuthCode(kv, "code-1");
|
const first = await consumeAuthCode(kv, "code-1");
|
||||||
expect(first?.namespace).toBe("leo");
|
expect(first?.namespace).toBe("leo");
|
||||||
@@ -271,7 +314,11 @@ describe("oauth flow (整合)", () => {
|
|||||||
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
|
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
|
||||||
);
|
);
|
||||||
expect(ok.status).toBe(200);
|
expect(ok.status).toBe(200);
|
||||||
expect(await ok.text()).toContain("Owner 祕密");
|
const consentHtml = await ok.text();
|
||||||
|
// 同意頁問的是 Portal 帳密(不是另一把 owner secret)
|
||||||
|
expect(consentHtml).toContain("Portal");
|
||||||
|
expect(consentHtml).toContain('name="email"');
|
||||||
|
expect(consentHtml).toContain('name="password"');
|
||||||
// 缺 PKCE → 400
|
// 缺 PKCE → 400
|
||||||
const bad = await app.req(
|
const bad = await app.req(
|
||||||
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
||||||
@@ -281,18 +328,19 @@ describe("oauth flow (整合)", () => {
|
|||||||
expect(bad.status).toBe(400);
|
expect(bad.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("GET /authorize:MCP_OWNER_SECRET 未設 → 503(不留不安全預設)", async () => {
|
it("GET /authorize:不需要任何 owner 祕密就看得到同意頁(封測者接自己的 AI 不會死在這頁)", async () => {
|
||||||
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
|
// 舊行為:未設 MCP_OWNER_SECRET → 503 ⇒ 每個封測者都卡住。現在把關是 Portal 帳密。
|
||||||
|
const app = buildApp(baseEnv());
|
||||||
const { challenge } = await pkcePair();
|
const { challenge } = await pkcePair();
|
||||||
const r = await app.req(
|
const r = await app.req(
|
||||||
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
|
||||||
"https://claude.ai/cb",
|
"https://claude.ai/cb",
|
||||||
)}&code_challenge=${challenge}&code_challenge_method=S256`,
|
)}&code_challenge=${challenge}&code_challenge_method=S256`,
|
||||||
);
|
);
|
||||||
expect(r.status).toBe(503);
|
expect(r.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => {
|
it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => {
|
||||||
const env = baseEnv();
|
const env = baseEnv();
|
||||||
const app = buildApp(env);
|
const app = buildApp(env);
|
||||||
const { verifier, challenge } = await pkcePair();
|
const { verifier, challenge } = await pkcePair();
|
||||||
@@ -310,7 +358,8 @@ describe("oauth flow (整合)", () => {
|
|||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
scope: "mcp",
|
scope: "mcp",
|
||||||
resource: "https://mcp.arcrun.dev/mcp",
|
resource: "https://mcp.arcrun.dev/mcp",
|
||||||
owner_secret: "s3cr3t-owner",
|
email: GOOD_EMAIL,
|
||||||
|
password: GOOD_PASSWORD,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
@@ -344,6 +393,143 @@ describe("oauth flow (整合)", () => {
|
|||||||
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
|
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 2026-08-12:身分要接住並攜帶(本次修的病根)─────────────────────────────
|
||||||
|
describe("登入者身分跟著 token 走(leo:掛上 MCP 並輸入帳密=授權,下游不得再問一次)", () => {
|
||||||
|
it("驗完帳密不是只留布林值:token 帶得出 portal session 與該帳號的可用知識庫", async () => {
|
||||||
|
const env = baseEnv({ CYPHER_EXECUTOR: cypherMock({ libraries: ["kb"], displayName: "小明", role: "user" }).fetcher });
|
||||||
|
const app = buildApp(env);
|
||||||
|
const { verifier, challenge } = await pkcePair();
|
||||||
|
const redirect = "https://claude.ai/cb";
|
||||||
|
const authRes = await app.req("/authorize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: "c1",
|
||||||
|
redirect_uri: redirect,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
email: GOOD_EMAIL,
|
||||||
|
password: GOOD_PASSWORD,
|
||||||
|
}).toString(),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
||||||
|
const tokRes = await app.req("/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
redirect_uri: redirect,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
|
||||||
|
expect(at?.portal?.session).toBe("sess-abc");
|
||||||
|
expect(at?.portal?.display_name).toBe("小明");
|
||||||
|
expect(at?.portal?.role).toBe("user");
|
||||||
|
expect(at?.portal?.libraries).toEqual(["kb"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("**不同帳號登入 → token 帶的身分跟著換**(不是不管誰登入都同一格)", async () => {
|
||||||
|
// 兩個帳號權限不同:一個全庫、一個只有 kb。token 裡的身分必須各自不同。
|
||||||
|
const envA = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-A", displayName: "Leo", libraries: ["*"] }).fetcher });
|
||||||
|
const envB = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-B", displayName: "小明", libraries: ["kb"] }).fetcher });
|
||||||
|
|
||||||
|
async function tokenFor(env: Env) {
|
||||||
|
const app = buildApp(env);
|
||||||
|
const { verifier, challenge } = await pkcePair();
|
||||||
|
const redirect = "https://claude.ai/cb";
|
||||||
|
const a = await app.req("/authorize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: "c1",
|
||||||
|
redirect_uri: redirect,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
email: GOOD_EMAIL,
|
||||||
|
password: GOOD_PASSWORD,
|
||||||
|
}).toString(),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
|
||||||
|
const t = await app.req("/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
redirect_uri: redirect,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
return getAccessToken(env.OAUTH_KV!, (await t.json()).access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = await tokenFor(envA);
|
||||||
|
const b = await tokenFor(envB);
|
||||||
|
expect(a?.portal?.session).not.toBe(b?.portal?.session);
|
||||||
|
expect(a?.portal?.libraries).toEqual(["*"]);
|
||||||
|
expect(b?.portal?.libraries).toEqual(["kb"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("access_token 活不過它底下的 portal session(TTL 取兩者較小)", async () => {
|
||||||
|
const env = baseEnv({
|
||||||
|
MCP_TOKEN_TTL: "2592000", // 30 天
|
||||||
|
CYPHER_EXECUTOR: cypherMock({ sessionExpiresIn: 3600 }).fetcher, // session 只有 1 小時
|
||||||
|
});
|
||||||
|
const app = buildApp(env);
|
||||||
|
const { verifier, challenge } = await pkcePair();
|
||||||
|
const redirect = "https://claude.ai/cb";
|
||||||
|
const a = await app.req("/authorize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: "c1",
|
||||||
|
redirect_uri: redirect,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
email: GOOD_EMAIL,
|
||||||
|
password: GOOD_PASSWORD,
|
||||||
|
}).toString(),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
|
||||||
|
const t = await app.req("/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
redirect_uri: redirect,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
expect((await t.json()).expires_in).toBe(3600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cypher 回 200 但沒給 session_token(舊版 cypher)→ 不發碼(不發一張沒有身分的 token)", async () => {
|
||||||
|
const app = buildApp(baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: null }).fetcher }));
|
||||||
|
const { challenge } = await pkcePair();
|
||||||
|
const r = await app.req("/authorize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: "c1",
|
||||||
|
redirect_uri: "https://claude.ai/cb",
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
email: GOOD_EMAIL,
|
||||||
|
password: GOOD_PASSWORD,
|
||||||
|
}).toString(),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(401);
|
||||||
|
expect(r.headers.get("location")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("錯誤 owner 祕密 → 401、不發 code", async () => {
|
it("錯誤 owner 祕密 → 401、不發 code", async () => {
|
||||||
const app = buildApp(baseEnv());
|
const app = buildApp(baseEnv());
|
||||||
const { challenge } = await pkcePair();
|
const { challenge } = await pkcePair();
|
||||||
@@ -355,7 +541,8 @@ describe("oauth flow (整合)", () => {
|
|||||||
redirect_uri: "https://claude.ai/cb",
|
redirect_uri: "https://claude.ai/cb",
|
||||||
code_challenge: challenge,
|
code_challenge: challenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
owner_secret: "WRONG",
|
email: GOOD_EMAIL,
|
||||||
|
password: "WRONG",
|
||||||
}).toString(),
|
}).toString(),
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
@@ -377,7 +564,8 @@ describe("oauth flow (整合)", () => {
|
|||||||
redirect_uri: redirect,
|
redirect_uri: redirect,
|
||||||
code_challenge: challenge,
|
code_challenge: challenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
owner_secret: "s3cr3t-owner",
|
email: GOOD_EMAIL,
|
||||||
|
password: GOOD_PASSWORD,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
@@ -445,7 +633,8 @@ describe("oauth resource(RFC 8707)簽發端把關", () => {
|
|||||||
code_challenge: challenge,
|
code_challenge: challenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
resource,
|
resource,
|
||||||
owner_secret: "s3cr3t-owner",
|
email: GOOD_EMAIL,
|
||||||
|
password: GOOD_PASSWORD,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
@@ -570,7 +759,8 @@ describe("oauth store drift guard:OAUTH_KV 的 put 一律帶 TTL", () => {
|
|||||||
redirect_uri: redirect,
|
redirect_uri: redirect,
|
||||||
code_challenge: challenge,
|
code_challenge: challenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
owner_secret: "s3cr3t-owner",
|
email: GOOD_EMAIL,
|
||||||
|
password: GOOD_PASSWORD,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* kbdb_* 資料層工具:**用登入進來的那個人的身分查詢**(2026-08-12)。
|
||||||
|
*
|
||||||
|
* leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的
|
||||||
|
* MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||||||
|
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
|
||||||
|
*
|
||||||
|
* 本檔守三件事:
|
||||||
|
* ① 以帳密連線時,查詢**帶登入者的 portal session** 打 cypher `/portal/data/*`
|
||||||
|
* ——不再拿 KBDB 的服務內部金鑰直打 KBDB(那條路繞過所有庫過濾)。
|
||||||
|
* ② 呼叫端自帶的 owner_id **一律不生效**(範圍由帳號權限決定,不由呼叫端指定)。
|
||||||
|
* ③ 舊 token(沒有身分)**fail-closed**:誠實要求重新連線,不偷偷退回服務金鑰那條老路。
|
||||||
|
* ④ 服務級憑據(static token / partner key)維持既有 KBDB 直連(零回歸)。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import type { Env } from "../../../src/types.js";
|
||||||
|
import { registerAllKbdbDataTools } from "../../../src/tools/kbdb_data.js";
|
||||||
|
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||||
|
|
||||||
|
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||||
|
content: { type: string; text: string }[];
|
||||||
|
isError?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function makeServer() {
|
||||||
|
const tools = new Map<string, { description: string; handler: ToolHandler }>();
|
||||||
|
const server = {
|
||||||
|
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
|
||||||
|
tools.set(name, { description, handler });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { server: server as unknown as McpServer, tools };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兩個 binding 都掛上,才驗得出「該走哪一條」——走錯的那條會被記錄下來。 */
|
||||||
|
function makeEnv(respond: (which: "cypher" | "kbdb", url: URL, init?: RequestInit) => Response) {
|
||||||
|
const cypherCalls: { url: URL; init?: RequestInit }[] = [];
|
||||||
|
const kbdbCalls: { url: URL; init?: RequestInit }[] = [];
|
||||||
|
const env = {
|
||||||
|
CYPHER_EXECUTOR: {
|
||||||
|
fetch: async (input: string, init?: RequestInit) => {
|
||||||
|
const url = new URL(input);
|
||||||
|
cypherCalls.push({ url, init });
|
||||||
|
return respond("cypher", url, init);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
KBDB: {
|
||||||
|
fetch: async (input: string, init?: RequestInit) => {
|
||||||
|
const url = new URL(input);
|
||||||
|
kbdbCalls.push({ url, init });
|
||||||
|
return respond("kbdb", url, init);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
KBDB_INTERNAL_TOKEN: "service-key-should-not-be-used-on-portal-path",
|
||||||
|
} as unknown as Env;
|
||||||
|
return { env, cypherCalls, kbdbCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResult(r: { content: { text: string }[] }) {
|
||||||
|
return JSON.parse(r.content[0].text) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORTAL: KnowledgeIdentity = {
|
||||||
|
kind: "portal",
|
||||||
|
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
|
||||||
|
};
|
||||||
|
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||||
|
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||||
|
|
||||||
|
function tools(identity: KnowledgeIdentity, respond: Parameters<typeof makeEnv>[0]) {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const e = makeEnv(respond);
|
||||||
|
registerAllKbdbDataTools(server, e.env, identity);
|
||||||
|
return { tools, ...e };
|
||||||
|
}
|
||||||
|
|
||||||
|
const OK = () => new Response(JSON.stringify({ success: true, entries: [], records: [], count: 0 }));
|
||||||
|
|
||||||
|
describe("kbdb_* 以登入者身分查詢(portal 資料面)", () => {
|
||||||
|
const cases: Array<{ tool: string; args: Record<string, unknown>; path: string; method?: string }> = [
|
||||||
|
{ tool: "kbdb_search", args: { q: "火星座標" }, path: "/portal/data/search" },
|
||||||
|
{ tool: "kbdb_query", args: { template: "triplet" }, path: "/portal/data/records/by-template/triplet" },
|
||||||
|
{ tool: "kbdb_get_record", args: { record_id: "rec_1" }, path: "/portal/data/records/rec_1" },
|
||||||
|
{ tool: "kbdb_list_templates", args: {}, path: "/portal/data/templates" },
|
||||||
|
{ tool: "kbdb_create_template", args: { name: "contact", slots: ["name"] }, path: "/portal/data/templates", method: "POST" },
|
||||||
|
{ tool: "kbdb_create_record", args: { template: "contact", values: { name: "Leo" } }, path: "/portal/data/records", method: "POST" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
it(`${c.tool} → 打 ${c.path},帶登入者 session,完全不碰 KBDB 服務金鑰`, async () => {
|
||||||
|
const { tools: t, cypherCalls, kbdbCalls } = tools(PORTAL, OK);
|
||||||
|
const res = await t.get(c.tool)!.handler(c.args);
|
||||||
|
expect(res.isError).toBeUndefined();
|
||||||
|
|
||||||
|
// 走的是 cypher 的 portal 資料面,不是 KBDB 直連
|
||||||
|
expect(kbdbCalls, `${c.tool} 不該直打 KBDB`).toHaveLength(0);
|
||||||
|
expect(cypherCalls).toHaveLength(1);
|
||||||
|
expect(cypherCalls[0].url.pathname).toBe(c.path);
|
||||||
|
expect(cypherCalls[0].init?.method ?? "GET").toBe(c.method ?? "GET");
|
||||||
|
|
||||||
|
// 帶的是「那個人的 session」,不是任何服務金鑰
|
||||||
|
const auth = new Headers(cypherCalls[0].init!.headers as HeadersInit).get("Authorization");
|
||||||
|
expect(auth).toBe("Bearer sess-abc");
|
||||||
|
expect(auth).not.toContain("service-key");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("呼叫端自帶 owner_id 一律不生效(不讓呼叫端自己挑租戶/歸屬)", async () => {
|
||||||
|
const { tools: t, cypherCalls } = tools(PORTAL, OK);
|
||||||
|
await t.get("kbdb_search")!.handler({ q: "x", owner_id: "someone-else" });
|
||||||
|
await t.get("kbdb_query")!.handler({ template: "triplet", owner_id: "someone-else" });
|
||||||
|
for (const call of cypherCalls) {
|
||||||
|
expect(call.url.searchParams.get("owner_id")).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("寫入時 owner_id 不從呼叫端 body 走(server 定死成登入者的歸屬)", async () => {
|
||||||
|
const { tools: t, cypherCalls } = tools(PORTAL, OK);
|
||||||
|
await t.get("kbdb_create_record")!.handler({
|
||||||
|
template: "contact",
|
||||||
|
values: { name: "Leo" },
|
||||||
|
owner_id: "someone-else",
|
||||||
|
});
|
||||||
|
const body = JSON.parse(String(cypherCalls[0].init!.body)) as Record<string, unknown>;
|
||||||
|
expect(body).not.toHaveProperty("owner_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("越庫寫入被擋(403)→ 誠實講是權限問題", async () => {
|
||||||
|
const { tools: t } = tools(PORTAL, () =>
|
||||||
|
new Response(JSON.stringify({ error: '無「secret」庫的權限,不能寫入該庫' }), { status: 403 }),
|
||||||
|
);
|
||||||
|
const res = await t.get("kbdb_create_record")!.handler({
|
||||||
|
template: "note",
|
||||||
|
values: { library: "secret", body: "x" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(parseResult(res).error_code).toBe("forbidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("查不是自己的 record(404)→ 與「不存在」同一句話(不洩存在性)", async () => {
|
||||||
|
const { tools: t } = tools(PORTAL, () =>
|
||||||
|
new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }),
|
||||||
|
);
|
||||||
|
const res = await t.get("kbdb_get_record")!.handler({ record_id: "rec_someone_else" });
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(parseResult(res).error_code).toBe("not_found");
|
||||||
|
expect(String(parseResult(res).human_message)).toContain("不在你的權限範圍內");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session 過期(401)→ session_expired,不謊稱資料是空的", async () => {
|
||||||
|
const { tools: t } = tools(PORTAL, () =>
|
||||||
|
new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||||
|
);
|
||||||
|
const res = await t.get("kbdb_search")!.handler({ q: "x" });
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(parseResult(res).error_code).toBe("session_expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fail-closed:舊 token 沒有身分就查不到東西(不退回服務金鑰)", () => {
|
||||||
|
for (const name of [
|
||||||
|
"kbdb_search",
|
||||||
|
"kbdb_query",
|
||||||
|
"kbdb_get_record",
|
||||||
|
"kbdb_list_templates",
|
||||||
|
"kbdb_create_template",
|
||||||
|
"kbdb_create_record",
|
||||||
|
]) {
|
||||||
|
it(`${name} → identity_missing,且一個查詢都不發`, async () => {
|
||||||
|
const { tools: t, cypherCalls, kbdbCalls } = tools(STALE, OK);
|
||||||
|
const res = await t.get(name)!.handler({
|
||||||
|
q: "x",
|
||||||
|
template: "t",
|
||||||
|
record_id: "r",
|
||||||
|
name: "n",
|
||||||
|
slots: ["a"],
|
||||||
|
values: { a: "b" },
|
||||||
|
});
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||||
|
expect(cypherCalls).toHaveLength(0);
|
||||||
|
expect(kbdbCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("回歸:服務級憑據維持既有 KBDB 直連", () => {
|
||||||
|
it("kbdb_search 仍直打 KBDB /entries/search,且照舊吃 owner_id", async () => {
|
||||||
|
const { tools: t, cypherCalls, kbdbCalls } = tools(SERVICE, OK);
|
||||||
|
const res = await t.get("kbdb_search")!.handler({ q: "x", owner_id: "leo" });
|
||||||
|
expect(res.isError).toBeUndefined();
|
||||||
|
expect(cypherCalls).toHaveLength(0);
|
||||||
|
expect(kbdbCalls).toHaveLength(1);
|
||||||
|
expect(kbdbCalls[0].url.pathname).toBe("/entries/search");
|
||||||
|
expect(kbdbCalls[0].url.searchParams.get("owner_id")).toBe("leo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kbdb_query / kbdb_get_record 路徑不變", async () => {
|
||||||
|
const { tools: t, kbdbCalls } = tools(SERVICE, OK);
|
||||||
|
await t.get("kbdb_query")!.handler({ template: "triplet" });
|
||||||
|
await t.get("kbdb_get_record")!.handler({ record_id: "rec_1" });
|
||||||
|
expect(kbdbCalls.map((c) => c.url.pathname)).toEqual([
|
||||||
|
"/records/by-template/triplet",
|
||||||
|
"/records/rec_1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,17 @@ import {
|
|||||||
registerGraphNeighbors,
|
registerGraphNeighbors,
|
||||||
GRAPH_NEIGHBORS_WORKFLOW,
|
GRAPH_NEIGHBORS_WORKFLOW,
|
||||||
} from "../../../src/tools/kbdb_graph.js";
|
} from "../../../src/tools/kbdb_graph.js";
|
||||||
|
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||||
|
|
||||||
|
/** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */
|
||||||
|
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||||
|
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */
|
||||||
|
const PORTAL: KnowledgeIdentity = {
|
||||||
|
kind: "portal",
|
||||||
|
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||||||
|
};
|
||||||
|
/** 本次改版前簽發的舊 token(沒有身分)。 */
|
||||||
|
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||||
|
|
||||||
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ─────────────────────────
|
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ─────────────────────────
|
||||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||||
@@ -45,7 +56,7 @@ describe("kbdb_graph_neighbors: registration", () => {
|
|||||||
it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => {
|
it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => {
|
||||||
const { server, tools } = makeServer();
|
const { server, tools } = makeServer();
|
||||||
const { env } = makeEnv(() => new Response("{}"));
|
const { env } = makeEnv(() => new Response("{}"));
|
||||||
registerGraphNeighbors(server, env, "leo");
|
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||||
expect(tools.has("kbdb_graph_neighbors")).toBe(true);
|
expect(tools.has("kbdb_graph_neighbors")).toBe(true);
|
||||||
expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph");
|
expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph");
|
||||||
});
|
});
|
||||||
@@ -61,7 +72,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
|
|||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
registerGraphNeighbors(server, env, "leo");
|
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||||
subject: "Arcrun",
|
subject: "Arcrun",
|
||||||
depth: 2,
|
depth: 2,
|
||||||
@@ -88,7 +99,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
|
|||||||
const { env, calls } = makeEnv(
|
const { env, calls } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })),
|
() => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })),
|
||||||
);
|
);
|
||||||
registerGraphNeighbors(server, env, "leo");
|
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||||
await tools.get("kbdb_graph_neighbors")!.handler({
|
await tools.get("kbdb_graph_neighbors")!.handler({
|
||||||
subject: "A",
|
subject: "A",
|
||||||
kbdb_base: "https://kbdb.example.com",
|
kbdb_base: "https://kbdb.example.com",
|
||||||
@@ -108,7 +119,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
|||||||
const { env } = makeEnv(
|
const { env } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }),
|
() => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }),
|
||||||
);
|
);
|
||||||
registerGraphNeighbors(server, env, "leo");
|
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||||
subject: "A",
|
subject: "A",
|
||||||
kbdb_base: "https://kbdb.example.com",
|
kbdb_base: "https://kbdb.example.com",
|
||||||
@@ -124,7 +135,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
|||||||
const { env } = makeEnv(
|
const { env } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }),
|
() => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }),
|
||||||
);
|
);
|
||||||
registerGraphNeighbors(server, env, "leo");
|
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||||
subject: "A",
|
subject: "A",
|
||||||
kbdb_base: "https://kbdb.example.com",
|
kbdb_base: "https://kbdb.example.com",
|
||||||
@@ -143,7 +154,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
|||||||
status: 200,
|
status: 200,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
registerGraphNeighbors(server, env, "leo");
|
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||||
subject: "A",
|
subject: "A",
|
||||||
kbdb_base: "https://kbdb.example.com",
|
kbdb_base: "https://kbdb.example.com",
|
||||||
@@ -156,7 +167,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
|||||||
it("empty orgNamespace → no_namespace error, no fetch made", async () => {
|
it("empty orgNamespace → no_namespace error, no fetch made", async () => {
|
||||||
const { server, tools } = makeServer();
|
const { server, tools } = makeServer();
|
||||||
const { env, calls } = makeEnv(() => new Response("{}"));
|
const { env, calls } = makeEnv(() => new Response("{}"));
|
||||||
registerGraphNeighbors(server, env, "");
|
registerGraphNeighbors(server, env, "", SERVICE);
|
||||||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||||||
subject: "A",
|
subject: "A",
|
||||||
kbdb_base: "https://kbdb.example.com",
|
kbdb_base: "https://kbdb.example.com",
|
||||||
@@ -166,3 +177,77 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
|
|||||||
expect(calls).toHaveLength(0);
|
expect(calls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)──
|
||||||
|
describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => {
|
||||||
|
it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env, calls } = makeEnv(
|
||||||
|
() =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }],
|
||||||
|
edges: [],
|
||||||
|
count: 1,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||||
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 });
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A");
|
||||||
|
expect(calls[0].url.searchParams.get("depth")).toBe("2");
|
||||||
|
const headers = new Headers(calls[0].init!.headers as HeadersInit);
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer sess-abc");
|
||||||
|
|
||||||
|
expect(res.isError).toBeUndefined();
|
||||||
|
expect((parseResult(res).data as { count: number }).count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env, calls } = makeEnv(
|
||||||
|
() => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })),
|
||||||
|
);
|
||||||
|
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||||
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||||
|
expect(res.isError).toBeUndefined();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
// 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫)
|
||||||
|
expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env } = makeEnv(
|
||||||
|
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||||
|
);
|
||||||
|
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||||
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(parseResult(res).error_code).toBe("session_expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env } = makeEnv(
|
||||||
|
() => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }),
|
||||||
|
);
|
||||||
|
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||||||
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(parseResult(res).error_code).toBe("forbidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env, calls } = makeEnv(() => new Response("{}"));
|
||||||
|
registerGraphNeighbors(server, env, "leo", STALE);
|
||||||
|
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||||
|
expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import {
|
|||||||
renderLibraryMapLines,
|
renderLibraryMapLines,
|
||||||
__resetLibraryMapInstructionsCacheForTests,
|
__resetLibraryMapInstructionsCacheForTests,
|
||||||
} from "../../../src/lib/library-map.js";
|
} from "../../../src/lib/library-map.js";
|
||||||
|
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||||
|
|
||||||
|
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
|
||||||
|
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||||
|
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
|
||||||
|
const PORTAL: KnowledgeIdentity = {
|
||||||
|
kind: "portal",
|
||||||
|
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
|
||||||
|
};
|
||||||
|
/** 本次改版前簽發的舊 token(沒有身分)。 */
|
||||||
|
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||||
|
|
||||||
|
/** 假 CYPHER_EXECUTOR binding(portal 資料面用)。 */
|
||||||
|
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
|
||||||
|
const calls: { url: URL; init?: RequestInit }[] = [];
|
||||||
|
const env = {
|
||||||
|
CYPHER_EXECUTOR: {
|
||||||
|
fetch: async (input: string, init?: RequestInit) => {
|
||||||
|
const url = new URL(input);
|
||||||
|
calls.push({ url, init });
|
||||||
|
return respond(url, init);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Env;
|
||||||
|
return { env, calls };
|
||||||
|
}
|
||||||
|
|
||||||
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
|
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
|
||||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||||
@@ -56,7 +82,7 @@ describe("kbdb_get_map: registration", () => {
|
|||||||
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
|
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
|
||||||
const { server, tools } = makeServer();
|
const { server, tools } = makeServer();
|
||||||
const { env } = makeEnv(() => new Response("{}"));
|
const { env } = makeEnv(() => new Response("{}"));
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
expect(tools.has("kbdb_get_map")).toBe(true);
|
expect(tools.has("kbdb_get_map")).toBe(true);
|
||||||
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
|
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
|
||||||
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
|
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
|
||||||
@@ -69,7 +95,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
|||||||
const { env, calls } = makeEnv(
|
const { env, calls } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||||
);
|
);
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||||
|
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
@@ -90,7 +116,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
|||||||
const { env, calls } = makeEnv(
|
const { env, calls } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||||
);
|
);
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
|
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
|
||||||
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
|
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
|
||||||
});
|
});
|
||||||
@@ -105,7 +131,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
|||||||
const { env } = makeEnv(
|
const { env } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
|
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
|
||||||
);
|
);
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||||
const data = parseResult(res).data as {
|
const data = parseResult(res).data as {
|
||||||
libraries: { top_entities: string[]; triplet_count: number }[];
|
libraries: { top_entities: string[]; triplet_count: number }[];
|
||||||
@@ -119,7 +145,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
|||||||
const { env } = makeEnv(
|
const { env } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||||
);
|
);
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||||
const body = parseResult(res);
|
const body = parseResult(res);
|
||||||
expect(body.ok).toBe(true);
|
expect(body.ok).toBe(true);
|
||||||
@@ -135,7 +161,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
|||||||
const { env } = makeEnv(
|
const { env } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||||
);
|
);
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||||
const body = parseResult(res);
|
const body = parseResult(res);
|
||||||
const hintsText = JSON.stringify(body.hints);
|
const hintsText = JSON.stringify(body.hints);
|
||||||
@@ -149,7 +175,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
|||||||
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
|
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
|
||||||
const { server, tools } = makeServer();
|
const { server, tools } = makeServer();
|
||||||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||||
expect(res.isError).toBe(true);
|
expect(res.isError).toBe(true);
|
||||||
const body = parseResult(res);
|
const body = parseResult(res);
|
||||||
@@ -178,7 +204,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
|||||||
const { env, calls } = makeEnv(
|
const { env, calls } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
|
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
|
||||||
);
|
);
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||||
expect(calls[0].url.pathname).toBe("/map/kb");
|
expect(calls[0].url.pathname).toBe("/map/kb");
|
||||||
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
|
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
|
||||||
@@ -197,7 +223,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
|||||||
triplet_count: "111",
|
triplet_count: "111",
|
||||||
};
|
};
|
||||||
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
|
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||||
expect(res.isError).toBeUndefined();
|
expect(res.isError).toBeUndefined();
|
||||||
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
|
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
|
||||||
@@ -212,7 +238,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
|||||||
const { env } = makeEnv(
|
const { env } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
|
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
|
||||||
);
|
);
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
|
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
|
||||||
expect(res.isError).toBe(true);
|
expect(res.isError).toBe(true);
|
||||||
const body = parseResult(res);
|
const body = parseResult(res);
|
||||||
@@ -233,7 +259,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as Env;
|
} as unknown as Env;
|
||||||
registerGetMap(server, env);
|
registerGetMap(server, env, SERVICE);
|
||||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||||
expect(res.isError).toBe(true);
|
expect(res.isError).toBe(true);
|
||||||
expect(parseResult(res).error_code).toBe("internal_error");
|
expect(parseResult(res).error_code).toBe("internal_error");
|
||||||
@@ -258,7 +284,7 @@ describe("buildLibraryMapInstructions", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const text = await buildLibraryMapInstructions(env);
|
const text = await buildLibraryMapInstructions(env, SERVICE);
|
||||||
expect(text).not.toBeNull();
|
expect(text).not.toBeNull();
|
||||||
// design §4 格式:{library}:{narrative}|核心:{top3}|{triplet_count} triplets
|
// design §4 格式:{library}:{narrative}|核心:{top3}|{triplet_count} triplets
|
||||||
expect(text!).toContain("kb:leo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea|111 triplets");
|
expect(text!).toContain("kb:leo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea|111 triplets");
|
||||||
@@ -269,7 +295,7 @@ describe("buildLibraryMapInstructions", () => {
|
|||||||
|
|
||||||
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
|
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
|
||||||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("binding throws → null(靜默略過)", async () => {
|
it("binding throws → null(靜默略過)", async () => {
|
||||||
@@ -280,22 +306,22 @@ describe("buildLibraryMapInstructions", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as Env;
|
} as unknown as Env;
|
||||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
|
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
|
||||||
const { env } = makeEnv(
|
const { env } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||||
);
|
);
|
||||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caches within TTL:same isolate 第二次不再打 /map", async () => {
|
it("caches within TTL:same isolate 第二次不再打 /map", async () => {
|
||||||
const { env, calls } = makeEnv(
|
const { env, calls } = makeEnv(
|
||||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||||
);
|
);
|
||||||
const first = await buildLibraryMapInstructions(env);
|
const first = await buildLibraryMapInstructions(env, SERVICE);
|
||||||
const second = await buildLibraryMapInstructions(env);
|
const second = await buildLibraryMapInstructions(env, SERVICE);
|
||||||
expect(second).toBe(first);
|
expect(second).toBe(first);
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -318,3 +344,95 @@ describe("renderLibraryMapLines", () => {
|
|||||||
expect(renderLibraryMapLines([])).toBeNull();
|
expect(renderLibraryMapLines([])).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
|
||||||
|
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
|
||||||
|
// 一個只有部分權限的帳號。
|
||||||
|
describe("藏書地圖:登入身分(portal 資料面)", () => {
|
||||||
|
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
|
||||||
|
|
||||||
|
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env, calls } = makePortalEnv(
|
||||||
|
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||||
|
);
|
||||||
|
registerGetMap(server, env, PORTAL);
|
||||||
|
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||||
|
expect(res.isError).toBeUndefined();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||||||
|
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env, calls } = makePortalEnv(
|
||||||
|
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||||
|
);
|
||||||
|
registerGetMap(server, env, PORTAL);
|
||||||
|
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
|
||||||
|
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
|
||||||
|
registerGetMap(server, env, PORTAL);
|
||||||
|
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
const body = parseResult(res);
|
||||||
|
expect(body.error_code).toBe("map_not_found");
|
||||||
|
expect(String(body.human_message)).toContain("不在你被授權");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env } = makePortalEnv(
|
||||||
|
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||||
|
);
|
||||||
|
registerGetMap(server, env, PORTAL);
|
||||||
|
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(parseResult(res).error_code).toBe("session_expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed)", async () => {
|
||||||
|
const { server, tools } = makeServer();
|
||||||
|
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||||||
|
registerGetMap(server, env, STALE);
|
||||||
|
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||||
|
expect(res.isError).toBe(true);
|
||||||
|
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
|
||||||
|
const { env, calls } = makePortalEnv(
|
||||||
|
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||||
|
);
|
||||||
|
const text = await buildLibraryMapInstructions(env, PORTAL);
|
||||||
|
expect(text).toContain("kb");
|
||||||
|
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
|
||||||
|
const { env, calls } = makePortalEnv(
|
||||||
|
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||||
|
);
|
||||||
|
const other: KnowledgeIdentity = {
|
||||||
|
kind: "portal",
|
||||||
|
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
|
||||||
|
};
|
||||||
|
await buildLibraryMapInstructions(env, PORTAL);
|
||||||
|
await buildLibraryMapInstructions(env, other);
|
||||||
|
expect(calls).toHaveLength(2); // 兩次真的各打一次
|
||||||
|
await buildLibraryMapInstructions(env, PORTAL);
|
||||||
|
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
|
||||||
|
});
|
||||||
|
|
||||||
|
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
|
||||||
|
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||||||
|
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import { join, resolve, basename, relative } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
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——這是踩坑①解法的地基:
|
// REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基:
|
||||||
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
|
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
|
||||||
@@ -214,6 +216,29 @@ async function main() {
|
|||||||
console.log('✔ node_modules 檢查通過:');
|
console.log('✔ node_modules 檢查通過:');
|
||||||
for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`);
|
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) {
|
if (CHECK_ONLY) {
|
||||||
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
|
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -376,6 +376,23 @@
|
|||||||
真人用滑鼠點擊複製鈕(受限於自動化環境,上述已用既有鈕做過同構對照)。
|
真人用滑鼠點擊複製鈕(受限於自動化環境,上述已用既有鈕做過同構對照)。
|
||||||
執行範圍:`console-ui/public/portal/index.html`(新增面板 + JS)。未動後端、未部署。
|
執行範圍:`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=<api_key>` 查得到庫)才注入 `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 動工範圍,掛號)
|
## 第二波(不在本 SDD 動工範圍,掛號)
|
||||||
|
|
||||||
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)
|
||||||
|
|||||||
@@ -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=<api_key>` 查得到庫才寫)。無條件覆蓋會把「知識本來就在
|
||||||
|
`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)
|
- [ ] 本地/Gitea 改完 code 想 `acr update` 部署?先確認:它抓的是 GitHub codeload tarball,不是你剛改的目錄(#23)
|
||||||
- [ ] 改完前端說「做完了」?先問**線上跑的是不是這一份**(`cd console-ui && npm run verify`)——組態綠不代表世代對(#25)
|
- [ ] 改完前端說「做完了」?先問**線上跑的是不是這一份**(`cd console-ui && npm run verify`)——組態綠不代表世代對(#25)
|
||||||
- [ ] 要寫「含某關鍵字就擋」的閘?先想「有人寫一則說明它已被移除的註解時會怎樣」——關鍵字閘會腐爛,優先用指紋(#25)
|
- [ ] 要寫「含某關鍵字就擋」的閘?先想「有人寫一則說明它已被移除的註解時會怎樣」——關鍵字閘會腐爛,優先用指紋(#25)
|
||||||
|
- [ ] 寫下 `env.X || '預設值'`?先問「這個字串是用來決定誰的資料嗎?」是 → 它是身分不是設定,不准有字面預設值(#26)
|
||||||
|
- [ ] 要用某個字串過濾 owner_id?確認它與**寫入端**用的是同一個來源,不是另一份手抄的環境變數(#26)
|
||||||
|
- [ ] 留了一道新的閘?它自己有測試嗎、誤攔案例驗過嗎、擋不擋得到自己?(#26)
|
||||||
|
|||||||
Reference in New Issue
Block a user