Compare commits
52 Commits
fad5da0e17
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a98f53e7 | |||
| cacaa33f7d | |||
| fda9d169d5 | |||
| 7b478378aa | |||
| ceb7638d74 | |||
| 9fa88065ab | |||
| 969acff325 | |||
| 87f6de7c9f | |||
| 576afa2364 | |||
| 730c2af245 | |||
| 9ba6ba75fc | |||
| aa49b4eeb2 | |||
| 5b22d569c9 | |||
| 30c9312c57 | |||
| 1616517ace | |||
| 7dc3e615e9 | |||
| ccd531863a | |||
| 537b520143 | |||
| 45169ab25e | |||
| 5dd1d1e7a9 | |||
| b8a5201e8a | |||
| 340b461f58 | |||
| 5318398849 | |||
| 9609924818 | |||
| 043e9acc31 | |||
| cfb759a774 | |||
| 73bdb90780 | |||
| 9ad95ee3a6 | |||
| 68f042cfd0 | |||
| 5d441aa7d3 | |||
| 43323b1a78 | |||
| 2b807da324 | |||
| 3f2e45f5dc | |||
| c543abaa8a | |||
| 49248b18c5 | |||
| 05e879faa7 | |||
| 0b72b818a0 | |||
| 7498911856 | |||
| 94d5452425 | |||
| 4b6cc159b8 | |||
| 8286c8afec | |||
| ca1ed2aaf6 | |||
| 88f308642e | |||
| c3856470ad | |||
| 1ccee0055e | |||
| 302e024804 | |||
| c2897ba68b | |||
| 614fe44812 | |||
| 9ff933ba98 | |||
| 2fcae722e7 | |||
| cac874601f | |||
| b223a69884 |
@@ -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 擋住一個操作時,訊息格式統一為:
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3243,15 +3243,20 @@ var init_kbdb_proxy = __esm({
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.template || !body.values) {
|
||||
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
|
||||
if (!body || !body.template || !body.values && !body.entry_ids) {
|
||||
return c.json({ error: "template \u5FC5\u586B\uFF0Cvalues \u8207 entry_ids \u81F3\u5C11\u8981\u6709\u4E00\u500B" }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
|
||||
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner })
|
||||
body: JSON.stringify({
|
||||
template: body.template,
|
||||
...body.values ? { values: body.values } : {},
|
||||
...body.entry_ids ? { entry_ids: body.entry_ids } : {},
|
||||
owner_id: owner
|
||||
})
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
});
|
||||
@@ -3400,8 +3405,9 @@ function validateName(name) {
|
||||
function validSensitivity(s) {
|
||||
return s === "standard" || s === "high";
|
||||
}
|
||||
async function putWorkerSecret(env, secretRef, value) {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
async function putWorkerSecret(env, secretRef, value, tokenOverride) {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error(
|
||||
"\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u5BEB\u5165\u8DEF\u5F91\u672A\u5C31\u7DD2\uFF08\u898B credential-store-migration.md T3\uFF1Aacr init/update \u61C9\u78BA\u4FDD\u9019\u5169\u9805\u5C31\u7DD2\uFF09"
|
||||
);
|
||||
@@ -3410,7 +3416,7 @@ async function putWorkerSecret(env, secretRef, value) {
|
||||
const res = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ name: secretRef, text: value, type: "secret_text" })
|
||||
@@ -3421,14 +3427,15 @@ async function putWorkerSecret(env, secretRef, value) {
|
||||
throw new Error(`CF Workers Secrets \u5BEB\u5165\u5931\u6557\uFF1A${detail}`);
|
||||
}
|
||||
}
|
||||
async function deleteWorkerSecret(env, secretRef) {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
async function deleteWorkerSecret(env, secretRef, tokenOverride) {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error("\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u522A\u9664\u8DEF\u5F91\u672A\u5C31\u7DD2");
|
||||
}
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
|
||||
const res = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` }
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.status === 404) return;
|
||||
const body = await res.json().catch(() => null);
|
||||
@@ -3789,6 +3796,9 @@ async function resolveCredentialRefs(data, env, apiKey) {
|
||||
if (names.size === 0) return data;
|
||||
const nameList = [...names];
|
||||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
|
||||
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
|
||||
return replaceCredentialRefs(data, resolvedSecrets);
|
||||
}
|
||||
const url = wasmWorkerUrl("auth_static_key", env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
@@ -9198,11 +9208,8 @@ function shardIndex(name) {
|
||||
function shardNameOf(index) {
|
||||
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
|
||||
}
|
||||
function authStorePresent(env) {
|
||||
return shardNames(env).length > 0 || overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS;
|
||||
}
|
||||
function authStoreWritable(env) {
|
||||
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
|
||||
function authStoreWritable(env, tokenOverride) {
|
||||
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
|
||||
}
|
||||
function readAuthStore(env) {
|
||||
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
|
||||
@@ -9238,15 +9245,10 @@ function findAuthUserById(env, id) {
|
||||
function isAuthStoreId(recordId) {
|
||||
return recordId.startsWith(AUTH_ID_PREFIX);
|
||||
}
|
||||
function newAuthUserId() {
|
||||
const arr = new Uint8Array(12);
|
||||
crypto.getRandomValues(arr);
|
||||
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
async function writeAuthStore(env, data) {
|
||||
if (!authStoreWritable(env)) {
|
||||
async function writeAuthStore(env, data, tokenOverride) {
|
||||
if (!authStoreWritable(env, tokenOverride)) {
|
||||
throw new AuthStoreWriteError(
|
||||
"\u9019\u53F0\u5BE6\u4F8B\u9084\u4E0D\u80FD\u5BEB\u5165\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID\uFF09\u3002\u8A8D\u8B49\u5206\u96E2\u9700\u8981\u9019\u5169\u9805\u624D\u5BEB\u5F97\u9032 Workers Secrets\u2014\u2014\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u8B93\u5B83\u5C31\u7DD2\u3002"
|
||||
"\u9019\u53F0\u5BE6\u4F8B\u76EE\u524D\u5BEB\u4E0D\u9032\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A\u53EF\u7528\u7684 Cloudflare \u5BEB\u5165\u6191\u8B49\uFF1ACF_SECRETS_API_TOKEN\uFF09\u3002\u9019\u662F\u5E73\u53F0\u7AEF\u7684\u5DF2\u77E5\u9650\u5236\uFF0C\u4E0D\u662F\u4F60\u64CD\u4F5C\u932F\u8AA4\u2014\u2014\u76EE\u524D\u6C92\u6709\u4F60\u81EA\u5DF1\u5728\u756B\u9762\u4E0A\u80FD\u505A\u7684\u4E0B\u4E00\u6B65\uFF0C\u8ACB\u628A\u9019\u5247\u8A0A\u606F\u5B8C\u6574\u622A\u5716\uFF0F\u8907\u88FD\u7D66\u652F\u63F4\uFF0C\u4E26\u8A3B\u660E\u4F60\u525B\u624D\u5728\u505A\u4EC0\u9EBC\uFF08\u4F8B\u5982\uFF1A\u5B89\u88DD\u7CBE\u9748\u88E1\u5EFA\u7ACB\u7B2C\u4E00\u500B\u5E33\u865F\u3001\u4E8B\u5F8C\u65B0\u589E\u4F7F\u7528\u8005\u3001\u6216\u4FEE\u6539\u5BC6\u78BC\uFF09\uFF0C\u6703\u9700\u8981\u4EBA\u5DE5\u5354\u52A9\u6392\u9664\u3002"
|
||||
);
|
||||
}
|
||||
const shards = [];
|
||||
@@ -9269,10 +9271,10 @@ async function writeAuthStore(env, data) {
|
||||
}
|
||||
const existing = shardNames(env);
|
||||
for (let i = 0; i < shards.length; i++) {
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i]);
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride);
|
||||
}
|
||||
for (const name of existing) {
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
|
||||
}
|
||||
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
|
||||
overlayAt = Date.now();
|
||||
@@ -9319,26 +9321,23 @@ function unionStores(a, b) {
|
||||
}
|
||||
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
|
||||
}
|
||||
async function mutateAuthStore(env, fn) {
|
||||
async function mutateAuthStore(env, fn, tokenOverride) {
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next);
|
||||
await writeAuthStore(env, next, tokenOverride);
|
||||
return next;
|
||||
}
|
||||
function authStoreStatus(env) {
|
||||
const data = readAuthStore(env);
|
||||
return {
|
||||
present: authStorePresent(env),
|
||||
writable: authStoreWritable(env),
|
||||
users: data.users.length,
|
||||
console_configured: Boolean(data.console),
|
||||
shards: shardNames(env).length
|
||||
};
|
||||
}
|
||||
|
||||
// cypher-executor/src/routes/health.ts
|
||||
var healthRouter = new Hono2();
|
||||
function authStoreStatus(env) {
|
||||
const legacy = readAuthStore(env);
|
||||
return {
|
||||
console: { home: "sessions-kv", writable: true, legacy_secrets_present: legacy.console !== null },
|
||||
portal_users: { home: "kbdb", writable: true, legacy_secrets_present: legacy.users.length > 0 }
|
||||
};
|
||||
}
|
||||
healthRouter.get("/health", (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
|
||||
@@ -12557,6 +12556,45 @@ init_kbdb_proxy();
|
||||
|
||||
// cypher-executor/src/routes/console-auth.ts
|
||||
init_dist();
|
||||
|
||||
// cypher-executor/src/lib/tenant.ts
|
||||
var TenantUnresolvedError = class extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "TenantUnresolvedError";
|
||||
}
|
||||
};
|
||||
function knowledgeOwner(env) {
|
||||
const injected = (env.ARCRUN_NAMESPACE ?? "").trim();
|
||||
if (injected) return injected;
|
||||
const legacy = (env.CONSOLE_TENANT ?? "").trim();
|
||||
if (legacy) return legacy;
|
||||
throw new TenantUnresolvedError(
|
||||
"\u9019\u500B\u90E8\u7F72\u6C92\u6709\u77E5\u8B58\u547D\u540D\u7A7A\u9593\uFF08ARCRUN_NAMESPACE / CONSOLE_TENANT \u90FD\u6C92\u8A2D\uFF09\u2014\u2014\u4E0D\u77E5\u9053\u8981\u53BB\u54EA\u4E00\u683C\u627E\u8CC7\u6599\u3002\u8ACB\u8DD1 `acr update` \u8B93\u5B83\u5F9E\u4F60\u7684 ~/.arcrun/config.yaml \u6CE8\u5165\u3002"
|
||||
);
|
||||
}
|
||||
function tenantFromApiKey(apiKey) {
|
||||
const key = (apiKey ?? "").trim();
|
||||
if (!key) throw new TenantUnresolvedError("\u7F3A\u5C11 X-Arcrun-API-Key\uFF0C\u7121\u6CD5\u6C7A\u5B9A\u67E5\u8A62\u7BC4\u570D");
|
||||
return key;
|
||||
}
|
||||
function accountTenant(env) {
|
||||
return env.CONSOLE_TENANT || "leo";
|
||||
}
|
||||
function ownerQuery(tenant2) {
|
||||
return `owner_id=${encodeURIComponent(tenant2)}`;
|
||||
}
|
||||
function ownerField(tenant2) {
|
||||
return tenant2;
|
||||
}
|
||||
function censusQueryAllTenants() {
|
||||
return "owner_id=";
|
||||
}
|
||||
function isOwnedBy(value, tenant2) {
|
||||
return typeof value === "string" && value === tenant2;
|
||||
}
|
||||
|
||||
// cypher-executor/src/routes/console-auth.ts
|
||||
var consoleAuthRouter = new Hono2();
|
||||
var CREDS_KEY = "console:credentials";
|
||||
var SESSION_PREFIX = "console_sess:";
|
||||
@@ -12583,38 +12621,38 @@ async function hashPassword(password, salt) {
|
||||
return h;
|
||||
}
|
||||
function tenantOf(c) {
|
||||
return c.env.CONSOLE_TENANT || "leo";
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
async function loadCredentials(env) {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
if (!fromStore && await hydrateFromAccelerator(env)) {
|
||||
fromStore = readAuthStore(env).console;
|
||||
}
|
||||
if (fromStore) return { creds: fromStore, source: "secrets" };
|
||||
const raw2 = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw2) return { creds: null, source: "none" };
|
||||
let legacy = null;
|
||||
try {
|
||||
legacy = JSON.parse(raw2);
|
||||
} catch {
|
||||
return { creds: null, source: "none" };
|
||||
if (raw2) {
|
||||
try {
|
||||
return { creds: JSON.parse(raw2), source: "kv" };
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
const legacy = readAuthStore(env).console;
|
||||
if (!legacy) return { creds: null, source: "none" };
|
||||
try {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (!data.console) data.console = legacy;
|
||||
});
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
|
||||
} catch {
|
||||
}
|
||||
return { creds: legacy, source: "legacy-kv" };
|
||||
return { creds: legacy, source: "legacy-secrets" };
|
||||
}
|
||||
async function saveCredentials(env, record) {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
});
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
}
|
||||
function consoleAuthStoreStatus(env) {
|
||||
return {
|
||||
home: "sessions-kv",
|
||||
writable: true,
|
||||
// binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
|
||||
legacy_secrets_present: readAuthStore(env).console !== null
|
||||
};
|
||||
}
|
||||
consoleAuthRouter.get("/console/auth-status", async (c) => {
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
|
||||
});
|
||||
consoleAuthRouter.post("/console/setup", async (c) => {
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
@@ -12640,8 +12678,7 @@ consoleAuthRouter.post("/console/setup", async (c) => {
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
|
||||
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
@@ -12666,8 +12703,7 @@ consoleAuthRouter.post("/console/setup/reset", async (c) => {
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
|
||||
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
@@ -12678,7 +12714,7 @@ consoleAuthRouter.post("/console/login", async (c) => {
|
||||
{
|
||||
error: "\u9019\u53F0\u5BE6\u4F8B\u9084\u6C92\u6709\u7BA1\u7406\u54E1\u5E33\u5BC6\uFF08\u6216\u8B80\u4E0D\u5230\uFF09\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8ACB\u5148\u5B8C\u6210\u9996\u6B21\u8A2D\u5B9A\u3002",
|
||||
code: "auth_store_empty",
|
||||
auth_store: authStoreStatus(c.env)
|
||||
auth_store: consoleAuthStoreStatus(c.env)
|
||||
},
|
||||
400
|
||||
);
|
||||
@@ -12687,18 +12723,8 @@ consoleAuthRouter.post("/console/login", async (c) => {
|
||||
const email = (body?.email ?? "").trim().toLowerCase();
|
||||
const password = body?.password ?? "";
|
||||
if (!email || !password) return c.json({ error: "email \u8207 password \u5FC5\u586B" }, 400);
|
||||
let creds = existing;
|
||||
let hash = await hashPassword(password, creds.salt);
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
if (await hydrateFromAccelerator(c.env)) {
|
||||
const again = (await loadCredentials(c.env)).creds;
|
||||
if (again) {
|
||||
creds = again;
|
||||
hash = await hashPassword(password, creds.salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
const token = randomHex(32);
|
||||
@@ -12854,10 +12880,10 @@ var DEFAULT_SESSION_TTL = 604800;
|
||||
var USER_TEMPLATE = "portal_user";
|
||||
var LIBRARY_TEMPLATE = "portal_library";
|
||||
function portalTenant(env) {
|
||||
return env.CONSOLE_TENANT || "leo";
|
||||
return accountTenant(env);
|
||||
}
|
||||
function portalNamespace(env) {
|
||||
return `${portalTenant(env)}::portal`;
|
||||
return `${accountTenant(env)}::portal`;
|
||||
}
|
||||
function sessionTtl(env) {
|
||||
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? "", 10);
|
||||
@@ -12886,6 +12912,9 @@ async function run(c, fn) {
|
||||
if (e instanceof AuthStoreWriteError) {
|
||||
return c.json({ error: `\u8A8D\u8B49\u5132\u5B58\u5BEB\u5165\u5931\u6557\uFF1A${e.message}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
if (e instanceof TenantUnresolvedError) {
|
||||
return c.json({ error: e.message, code: "tenant_unresolved" }, 500);
|
||||
}
|
||||
if (e instanceof KbdbError) return c.json({ error: `KBDB \u4E0D\u53EF\u9054\u6216\u56DE\u932F\uFF1A${e.message}` }, 502);
|
||||
throw e;
|
||||
}
|
||||
@@ -12966,24 +12995,31 @@ function recordValuesToAuthUser(id, v) {
|
||||
updated_at: v.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
||||
};
|
||||
}
|
||||
async function promoteLegacyUser(env, rec) {
|
||||
async function promoteToKbdb(env, rec) {
|
||||
try {
|
||||
const email = (rec.values.email ?? "").toLowerCase();
|
||||
if (!email) return;
|
||||
if (findAuthUserByEmail(env, email)) return;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (data.users.some((u) => u.email === email)) return;
|
||||
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
|
||||
if (!email) return null;
|
||||
const already = await findKbdbUserRecordId(env, email);
|
||||
if (already) return already;
|
||||
return await createKbdbUserRecord(env, email, {
|
||||
display_name: rec.values.display_name ?? "",
|
||||
status: rec.values.status ?? "active",
|
||||
role: rec.values.role ?? "user",
|
||||
password_hash: rec.values.password_hash ?? "",
|
||||
libraries: rec.values.libraries ?? "[]",
|
||||
created_at: rec.values.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
||||
updated_at: rec.values.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function findUserRecordId(env, email) {
|
||||
const inStore = findAuthUserByEmail(env, email);
|
||||
if (inStore) return inStore.id;
|
||||
return findLegacyUserRecordId(env, email);
|
||||
const inKbdb = await findKbdbUserRecordId(env, email);
|
||||
if (inKbdb) return inKbdb;
|
||||
return findAuthUserByEmail(env, email)?.id ?? null;
|
||||
}
|
||||
async function findLegacyUserRecordId(env, email) {
|
||||
async function findKbdbUserRecordId(env, email) {
|
||||
const ns = portalNamespace(env);
|
||||
const params = new URLSearchParams({
|
||||
page_name: email,
|
||||
@@ -13052,42 +13088,53 @@ function daemonActiveKey(env) {
|
||||
}
|
||||
async function listRecordsByTemplate(env, template) {
|
||||
if (template === USER_TEMPLATE) {
|
||||
const fromStore = readAuthStore(env).users.map(authUserToRecord);
|
||||
const seen = new Set(fromStore.map((r) => (r.values.email ?? "").toLowerCase()));
|
||||
let legacy = [];
|
||||
let fromKbdb = [];
|
||||
try {
|
||||
legacy = await listLegacyRecordsByTemplate(env, template);
|
||||
fromKbdb = await listKbdbRecordsByTemplate(env, template);
|
||||
} catch {
|
||||
legacy = [];
|
||||
fromKbdb = [];
|
||||
}
|
||||
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? "").toLowerCase()))];
|
||||
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? "").toLowerCase()));
|
||||
const fromLegacy = readAuthStore(env).users.map(authUserToRecord).filter((r) => !seen.has((r.values.email ?? "").toLowerCase()));
|
||||
return [...fromKbdb, ...fromLegacy];
|
||||
}
|
||||
return listLegacyRecordsByTemplate(env, template);
|
||||
return listKbdbRecordsByTemplate(env, template);
|
||||
}
|
||||
async function listLegacyRecordsByTemplate(env, template) {
|
||||
async function listKbdbRecordsByTemplate(env, template) {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} \u2192 ${res.status}`);
|
||||
const body = await res.json();
|
||||
return body.records ?? [];
|
||||
}
|
||||
async function createKbdbUserRecord(env, email, values) {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, "/records", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records\uFF08portal_user\uFF09\u2192 ${res.status}`);
|
||||
const body = await res.json();
|
||||
const recordId = body.record?.record_id;
|
||||
if (!recordId) throw new KbdbError("POST /records \u56DE\u61C9\u7F3A record_id");
|
||||
const head = await kbdbFetch(env, "/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns })
|
||||
});
|
||||
if (!head.ok) throw new KbdbError(`head entry \u5EFA\u7ACB\u5931\u6557\uFF08record ${recordId} \u5DF2\u5EFA\uFF0C\u9700\u4EBA\u5DE5\u6536\u62FE\uFF09\u2192 ${head.status}`);
|
||||
return recordId;
|
||||
}
|
||||
async function createPortalUser(env, input) {
|
||||
const now2 = (/* @__PURE__ */ new Date()).toISOString();
|
||||
const id = newAuthUserId();
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.users.push({
|
||||
id,
|
||||
email: input.email.toLowerCase(),
|
||||
display_name: input.display_name,
|
||||
status: "active",
|
||||
role: input.role,
|
||||
libraries: input.libraries,
|
||||
password_hash: input.password_hash,
|
||||
created_at: now2,
|
||||
updated_at: now2
|
||||
});
|
||||
return createKbdbUserRecord(env, input.email.toLowerCase(), {
|
||||
display_name: input.display_name,
|
||||
status: "active",
|
||||
role: input.role,
|
||||
password_hash: input.password_hash,
|
||||
libraries: JSON.stringify(input.libraries),
|
||||
created_at: now2,
|
||||
updated_at: now2
|
||||
});
|
||||
return id;
|
||||
}
|
||||
function parseLibraries(raw2) {
|
||||
if (!raw2) return [];
|
||||
@@ -13224,7 +13271,7 @@ async function clearLoginFail(env, email) {
|
||||
async function instanceHasNoAuthData(env) {
|
||||
if (readAuthStore(env).users.length > 0) return false;
|
||||
try {
|
||||
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
@@ -13261,7 +13308,7 @@ portalRouter.post(
|
||||
{
|
||||
error: "\u9019\u53F0\u5BE6\u4F8B\u8B80\u4E0D\u5230\u4EFB\u4F55\u767B\u5165\u8CC7\u6599\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8A8D\u8B49\u5132\u5B58\u662F\u7A7A\u7684\uFF0C\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u4EE5\u91CD\u65B0\u5EFA\u7ACB\u7BA1\u7406\u54E1\u5E33\u865F\u3002",
|
||||
code: "auth_store_empty",
|
||||
auth_store: authStoreStatus(c.env)
|
||||
auth_store: { home: "kbdb", writable: true, users: 0 }
|
||||
},
|
||||
503
|
||||
);
|
||||
@@ -13276,10 +13323,14 @@ portalRouter.post(
|
||||
await recordLoginFail(c.env, email);
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
|
||||
let sessionRecordId = recordId;
|
||||
if (isAuthStoreId(recordId)) {
|
||||
const migrated = await promoteToKbdb(c.env, rec);
|
||||
if (migrated) sessionRecordId = migrated;
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const token = randomHex2(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: recordId }), {
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
|
||||
expirationTtl: sessionTtl(c.env)
|
||||
});
|
||||
return c.json({
|
||||
@@ -13662,12 +13713,14 @@ portalRouter.post(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const pageName = String(body?.page_name ?? "").trim();
|
||||
const srcText = String(body?.text ?? "");
|
||||
if (!pageName || !srcText.trim()) return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400);
|
||||
const daemonPrompt = String(body?.prompt ?? "").trim();
|
||||
if (!daemonPrompt && (!pageName || !srcText.trim()))
|
||||
return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400);
|
||||
if (!c.env.AI) {
|
||||
return c.json({ error: "\u9019\u500B\u90E8\u7F72\u6C92\u6709\u7D81\u5B9A Workers AI\uFF08wrangler.toml \u9700\u6709 [ai] binding\uFF09\uFF0C\u8ACB\u66F4\u65B0\u77E5\u8B58\u5EAB\u7248\u672C" }, 501);
|
||||
}
|
||||
const REL = ">".repeat(2);
|
||||
const prompt = `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A
|
||||
const prompt = daemonPrompt || `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A
|
||||
# ${pageName}
|
||||
## \u4E00\u53E5\u8A71\u5B9A\u7FA9
|
||||
\uFF08\u4E00\u884C\uFF09
|
||||
@@ -13683,14 +13736,17 @@ ${srcText}`;
|
||||
try {
|
||||
const out = await c.env.AI.run("@cf/meta/llama-4-scout-17b-16e-instruct", {
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_tokens: 2048,
|
||||
max_tokens: daemonPrompt ? 8192 : 2048,
|
||||
temperature: 0.2
|
||||
});
|
||||
const card = String(out?.response ?? "").trim();
|
||||
if (!card) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502);
|
||||
const raw2 = String(out?.response ?? "").trim();
|
||||
if (!raw2) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502);
|
||||
if (daemonPrompt) {
|
||||
return c.json({ success: true, output: raw2 });
|
||||
}
|
||||
const marker = `# ${pageName}`;
|
||||
const idx = card.lastIndexOf(marker);
|
||||
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + "\n" });
|
||||
const idx = raw2.lastIndexOf(marker);
|
||||
return c.json({ success: true, card: (idx >= 0 ? raw2.slice(idx) : raw2).trim() + "\n" });
|
||||
} catch (e) {
|
||||
return c.json({ error: `Workers AI \u57F7\u884C\u5931\u6557\uFF1A${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
@@ -13768,10 +13824,9 @@ portalRouter.post(
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const daemonCfg = {
|
||||
cypher_url: new URL(c.req.url).origin,
|
||||
namespace: tenant2,
|
||||
namespace: knowledgeOwner(c.env),
|
||||
library: "kb",
|
||||
email,
|
||||
instance_name: String(rec.values.display_name ?? "")
|
||||
@@ -13787,7 +13842,7 @@ portalRouter.post(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const key = String(body?.key ?? "").trim();
|
||||
if (!key) return c.json({ error: "\u8ACB\u8CBC\u4E0A\u4F60\u7684 Google AI \u91D1\u9470" }, 400);
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const kvKey2 = `${tenant2}:wf:rag_chat`;
|
||||
const raw2 = await c.env.WEBHOOKS.get(kvKey2, "text");
|
||||
if (!raw2) return c.json({ error: "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u5B89\u88DD AI \u554F\u7B54\u5DE5\u4F5C\u6D41" }, 404);
|
||||
@@ -13839,8 +13894,8 @@ portalRouter.get(
|
||||
});
|
||||
const known = new Set(out.map((l) => l.name));
|
||||
try {
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const ownerParam = `owner_id=${encodeURIComponent(tenant2)}`;
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const ownerParam = ownerQuery(tenant2);
|
||||
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
||||
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
||||
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
||||
@@ -13989,8 +14044,8 @@ portalRouter.get(
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const ownerId = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /execution-log/retention \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
|
||||
@@ -14006,10 +14061,10 @@ portalRouter.put(
|
||||
if (days !== null && days !== void 0 && (typeof days !== "number" || !Number.isFinite(days) || days <= 0)) {
|
||||
return c.json({ error: "retention_days \u5FC5\u9808\u662F\u6B63\u6574\u6578\uFF0C\u6216 null\uFF08\u4EE3\u8868\u4E0D\u522A\u9664\uFF09" }, 400);
|
||||
}
|
||||
const ownerId = portalTenant(c.env);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, "/execution-log/retention", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ owner_id: ownerId, retention_days: days === void 0 ? null : days })
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === void 0 ? null : days })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -14026,10 +14081,10 @@ portalRouter.delete(
|
||||
const confirm = String(body?.confirm ?? "").trim();
|
||||
if (!confirm) return c.json({ error: 'body \u9808\u5E36 { confirm: "<\u5EAB\u540D>" } \u624D\u57F7\u884C\uFF08\u79FB\u9664\u6703\u5F71\u97FF\u8CC7\u6599\u53EF\u641C\u6027\uFF09' }, 400);
|
||||
if (confirm !== name) return c.json({ error: `confirm \u503C\u300C${confirm}\u300D\u8207\u5EAB\u540D\u300C${name}\u300D\u4E0D\u7B26` }, 400);
|
||||
const ownerId = portalTenant(c.env);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, "/entries/deprecate-by-library", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ owner_id: ownerId, library: name })
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -14085,8 +14140,8 @@ async function buildDiagnostics(env, tenant2) {
|
||||
let embedding = { checked: false };
|
||||
try {
|
||||
const [statusRes, selftestRes] = await Promise.all([
|
||||
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant2 }).toString()}`),
|
||||
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant2 }).toString()}`)
|
||||
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant2)}`),
|
||||
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant2)}`)
|
||||
]);
|
||||
const statusBody = await statusRes.json().catch(() => null);
|
||||
const selftestBody = await selftestRes.json().catch(() => null);
|
||||
@@ -14108,7 +14163,7 @@ async function buildDiagnostics(env, tenant2) {
|
||||
}
|
||||
let library_count = 0;
|
||||
let triplet_count = 0;
|
||||
const ownerParam = new URLSearchParams({ owner_id: tenant2 }).toString();
|
||||
const ownerParam = ownerQuery(tenant2);
|
||||
try {
|
||||
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
||||
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
||||
@@ -14132,7 +14187,7 @@ async function buildDiagnostics(env, tenant2) {
|
||||
let library_scope_check = { ran: false };
|
||||
if (library_count === 0 && triplet_count === 0) {
|
||||
try {
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant2, limit: "1" }).toString()}`);
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant2), limit: "1" }).toString()}`);
|
||||
const probeBody = await probeRes.json().catch(() => null);
|
||||
const total = probeBody?.total ?? 0;
|
||||
library_scope_check = {
|
||||
@@ -14155,7 +14210,7 @@ portalRouter.get(
|
||||
(c) => run(c, async () => {
|
||||
const apiKey = (c.req.header("X-Arcrun-API-Key") ?? "").trim();
|
||||
if (!apiKey) return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401);
|
||||
const core = await buildDiagnostics(c.env, apiKey);
|
||||
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
|
||||
return c.json({
|
||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
instance_url: new URL(c.req.url).origin,
|
||||
@@ -14710,7 +14765,7 @@ async function cachedGiteaSprint(env, nowMs, waitUntil, fetcher = fetchGiteaSpri
|
||||
return { ...fresh, cache: "miss" };
|
||||
}
|
||||
consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const now2 = Date.now();
|
||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
@@ -14862,7 +14917,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
});
|
||||
});
|
||||
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const now2 = Date.now();
|
||||
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
||||
@@ -14897,7 +14952,7 @@ consoleDashboardRouter.get("/console/settings-data", (c) => {
|
||||
consoleDashboardRouter.get("/console/triage-data", async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header("authorization"));
|
||||
if (!ok) return c.json({ error: "\u9700\u8981\u767B\u5165\uFF08console session\uFF09" }, 401);
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const [todoEntries, inboxEntries] = await Promise.all([
|
||||
fetchEntries(c.env, tenant2, "todo", 500),
|
||||
fetchEntries(c.env, tenant2, "inbox", 200)
|
||||
@@ -14912,7 +14967,7 @@ consoleDashboardRouter.post("/console/triage-check", async (c) => {
|
||||
const entryId = typeof body?.entry_id === "string" ? body.entry_id.trim() : "";
|
||||
if (!entryId) return c.json({ error: "entry_id \u5FC5\u586B" }, 400);
|
||||
const action = body?.action === "restore" ? "restore" : "check";
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const got = await fetchJson(
|
||||
`${base}/entries/${encodeURIComponent(entryId)}`,
|
||||
@@ -14940,7 +14995,7 @@ init_kbdb_proxy();
|
||||
init_webhook_handlers();
|
||||
var portalDataRouter = new Hono2();
|
||||
async function getTenantWorkflowGraph(env, name) {
|
||||
const raw2 = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, "text");
|
||||
const raw2 = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, "text");
|
||||
if (!raw2) return null;
|
||||
try {
|
||||
const rec = JSON.parse(raw2);
|
||||
@@ -15033,7 +15088,7 @@ function findBestNodeMatch(searchTerm, nodeNames) {
|
||||
}
|
||||
async function tripletCount(env, owner) {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.stats)) return null;
|
||||
@@ -15050,11 +15105,11 @@ async function tripletCount(env, owner) {
|
||||
async function tripletCensus(env, tenant2) {
|
||||
const owned = await tripletCount(env, tenant2);
|
||||
if (owned !== 0) return { owned, any: null };
|
||||
return { owned, any: await tripletCount(env, "") };
|
||||
return { owned, any: await tripletCount(env, null) };
|
||||
}
|
||||
async function fuzzyFindNode(env, tenant2, searchTerm) {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant2)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.records)) return null;
|
||||
@@ -15082,7 +15137,7 @@ portalDataRouter.get(
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ success: true, entries: [], count: 0, mode: "keyword", note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" });
|
||||
}
|
||||
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
|
||||
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
|
||||
if (!libraries.includes("*")) params.set("library", libraries.join(","));
|
||||
if (c.req.query("mode") === "semantic") {
|
||||
params.set("mode", "semantic");
|
||||
@@ -15118,7 +15173,7 @@ portalDataRouter.get(
|
||||
const body = await res.json();
|
||||
const entry = body.entry;
|
||||
if (!entry) return notFound(c);
|
||||
if ((entry.owner_id ?? "") !== portalTenant(c.env)) return notFound(c);
|
||||
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
|
||||
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||||
return c.json({ success: true, entry });
|
||||
})
|
||||
@@ -15133,7 +15188,7 @@ portalDataRouter.get(
|
||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
||||
}
|
||||
const nodeName = normalizeCjkQuery(c.req.param("name"));
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const wfGraph = await getTenantWorkflowGraph(c.env, "graph_neighbors");
|
||||
if (wfGraph) {
|
||||
const depthRaw = c.req.query("depth") ?? "";
|
||||
@@ -15187,9 +15242,9 @@ portalDataRouter.get(
|
||||
if (!await hasGraphAccess(c.env, libraries)) {
|
||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
||||
}
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const [res, census] = await Promise.all([
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}&limit=500`),
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant2)}&limit=500`),
|
||||
tripletCensus(c.env, tenant2)
|
||||
]);
|
||||
const tripletsTotal = census.owned;
|
||||
@@ -15260,7 +15315,7 @@ portalDataRouter.get(
|
||||
wfGraph,
|
||||
{ question },
|
||||
"rag_chat",
|
||||
portalTenant(c.env),
|
||||
knowledgeOwner(c.env),
|
||||
c.executionCtx
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -15336,7 +15391,7 @@ portalDataRouter.get(
|
||||
if (!workflowsVisible(c.env, auth.user.values.role ?? "user")) {
|
||||
return c.json({ error: "\u9700\u8981 admin \u6B0A\u9650" }, 403);
|
||||
}
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const prefix = `${tenant2}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
const workflows = await Promise.all(
|
||||
@@ -15358,7 +15413,7 @@ portalDataRouter.get(
|
||||
let last_execution = null;
|
||||
const execRes = await kbdbFetch(
|
||||
c.env,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant2 }).toString()}`
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant2) }).toString()}`
|
||||
);
|
||||
const execBody = await execRes.json().catch(() => null);
|
||||
if (execRes.ok && execBody?.success && execBody.execution) {
|
||||
@@ -15375,7 +15430,7 @@ function recordLibrary(values) {
|
||||
return typeof lib === "string" && lib.trim() ? lib.trim() : null;
|
||||
}
|
||||
function canReadRecord(rec, tenant2, libraries) {
|
||||
if ((rec.owner_id ?? "") !== tenant2) return false;
|
||||
if (!isOwnedBy(rec.owner_id, tenant2)) return false;
|
||||
const lib = recordLibrary(rec.values);
|
||||
return lib === null || canReadLibrary(libraries, lib);
|
||||
}
|
||||
@@ -15386,9 +15441,17 @@ portalDataRouter.get(
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ success: true, libraries: [], count: 0, note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" });
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "no_library_grant",
|
||||
note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002"
|
||||
});
|
||||
}
|
||||
const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant2)}`);
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
@@ -15399,7 +15462,49 @@ portalDataRouter.get(
|
||||
const allowed = body.libraries.filter(
|
||||
(l) => typeof l?.library === "string" && canReadLibrary(libraries, l.library)
|
||||
);
|
||||
return c.json({ success: true, libraries: allowed, count: allowed.length });
|
||||
if (allowed.length > 0) {
|
||||
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
|
||||
}
|
||||
if (body.libraries.length > 0) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "filtered_out",
|
||||
note: "\u9019\u500B\u5E33\u865F\u76EE\u524D\u6C92\u6709\u4EFB\u4F55\u77E5\u8B58\u5EAB\u7684\u6AA2\u8996\u6B0A\u9650\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u958B\u901A\u3002"
|
||||
});
|
||||
}
|
||||
const census = await tripletCensus(c.env, tenant2);
|
||||
if (census.owned === null || census.owned === 0 && census.any === null) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: false,
|
||||
empty_reason: "unreadable",
|
||||
note: "\u8B80\u4E0D\u5230\u77E5\u8B58\u5EAB\u7684\u7D71\u8A08\uFF0C\u7121\u6CD5\u78BA\u8A8D\u5EAB\u88E1\u6709\u6C92\u6709\u6771\u897F\u2014\u2014\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u662F\u9019\u6B21\u8B80\u53D6\u5931\u6557\u3002\u8ACB\u7A0D\u5F8C\u91CD\u6574\u6216\u901A\u77E5\u7BA1\u7406\u54E1\u3002"
|
||||
});
|
||||
}
|
||||
if (census.owned === 0 && (census.any ?? 0) > 0) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: false,
|
||||
empty_reason: "scope_mismatch",
|
||||
instance_triplet_count: census.any,
|
||||
note: `\u8B80\u4E0D\u5230\u4F60\u9019\u500B\u5E33\u865F\u7BC4\u570D\u5167\u7684\u85CF\u66F8\u2014\u2014\u4F46\u9019\u53F0\u5BE6\u4F8B\u88E1\u6709 ${census.any} \u689D\u77E5\u8B58\u95DC\u806F\u3002\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u4E0D\u7528\u53BB\u91CD\u65B0\u4E0A\u50B3\uFF1B\u6BD4\u8F03\u50CF\u77E5\u8B58\u7684\u6B78\u5C6C\u547D\u540D\u7A7A\u9593\u5C0D\u4E0D\u4E0A\u3002\u8ACB\u901A\u77E5\u7BA1\u7406\u54E1\u8DD1\u4E00\u6B21 \`acr update\`\uFF08\u6703\u628A\u4F60\u5B89\u88DD\u6642\u7684\u547D\u540D\u7A7A\u9593\u540C\u6B65\u7D66\u96F2\u7AEF\uFF09\uFF0C\u6216\u6AA2\u67E5 ARCRUN_NAMESPACE \u8A2D\u5B9A\u3002`
|
||||
});
|
||||
}
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "confirmed_empty",
|
||||
note: "\u77E5\u8B58\u5EAB\u9084\u6C92\u6709\u4EFB\u4F55\u5167\u5BB9\u2014\u2014\u4E0A\u50B3\u6587\u4EF6\u5F8C\u5C31\u6703\u51FA\u73FE\u5728\u9019\u88E1\u3002"
|
||||
});
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
@@ -15412,7 +15517,7 @@ portalDataRouter.get(
|
||||
if (!canReadLibrary(libraries, library)) return notFound(c);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`
|
||||
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`
|
||||
);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
@@ -15445,7 +15550,7 @@ portalDataRouter.post(
|
||||
name: body.name,
|
||||
slots: body.slots,
|
||||
description: typeof body.description === "string" ? body.description : void 0,
|
||||
created_by: portalTenant(c.env)
|
||||
created_by: knowledgeOwner(c.env)
|
||||
})
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
@@ -15458,10 +15563,10 @@ portalDataRouter.get(
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/records/by-template/${encodeURIComponent(c.req.param("template"))}?owner_id=${encodeURIComponent(tenant2)}`
|
||||
`/records/by-template/${encodeURIComponent(c.req.param("template"))}?${ownerQuery(tenant2)}`
|
||||
);
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
const body = await res.json().catch(() => null);
|
||||
@@ -15485,7 +15590,7 @@ portalDataRouter.get(
|
||||
const body = await res.json().catch(() => null);
|
||||
const record = body?.record;
|
||||
if (!record) return notFound(c);
|
||||
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
|
||||
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
|
||||
return c.json({ success: true, record });
|
||||
})
|
||||
);
|
||||
@@ -15510,7 +15615,7 @@ portalDataRouter.post(
|
||||
const res = await kbdbFetch(c.env, "/records", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) })
|
||||
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) })
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
@@ -15520,7 +15625,7 @@ portalDataRouter.get(
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const core = await buildDiagnostics(c.env, tenant2);
|
||||
return c.json({
|
||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,51 @@
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __esm = (fn, res, err) => function __init() {
|
||||
if (err) throw err[0];
|
||||
try {
|
||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
||||
} catch (e) {
|
||||
throw err = [e], e;
|
||||
}
|
||||
};
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
|
||||
// kbdb/src/actions/relation-orphans.ts
|
||||
var relation_orphans_exports = {};
|
||||
__export(relation_orphans_exports, {
|
||||
scanRelationOrphans: () => scanRelationOrphans
|
||||
});
|
||||
async function scanRelationOrphans(db, limit = 200) {
|
||||
const cap = Math.min(Math.max(limit, 1), 1e3);
|
||||
const res = await db.prepare(
|
||||
`SELECT relation_id, role, missing_id FROM (
|
||||
SELECT r.id AS relation_id, 'src' AS role, r.src_id AS missing_id
|
||||
FROM entries r LEFT JOIN entries t ON t.id = r.src_id
|
||||
WHERE r.src_id IS NOT NULL AND t.id IS NULL
|
||||
UNION ALL
|
||||
SELECT r.id, 'rel', r.rel_id
|
||||
FROM entries r LEFT JOIN entries t ON t.id = r.rel_id
|
||||
WHERE r.rel_id IS NOT NULL AND t.id IS NULL
|
||||
UNION ALL
|
||||
SELECT r.id, 'dst', r.dst_id
|
||||
FROM entries r LEFT JOIN entries t ON t.id = r.dst_id
|
||||
WHERE r.dst_id IS NOT NULL AND t.id IS NULL
|
||||
) LIMIT ?`
|
||||
).bind(cap + 1).all();
|
||||
const rows = res.results ?? [];
|
||||
const truncated = rows.length > cap;
|
||||
const orphans = truncated ? rows.slice(0, cap) : rows;
|
||||
return { orphans, count: orphans.length, truncated };
|
||||
}
|
||||
var init_relation_orphans = __esm({
|
||||
"kbdb/src/actions/relation-orphans.ts"() {
|
||||
"use strict";
|
||||
}
|
||||
});
|
||||
|
||||
// kbdb/node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/compose.js
|
||||
var compose = (middleware, onError, onNotFound) => {
|
||||
return (context, next) => {
|
||||
@@ -2101,12 +2149,15 @@ async function getEntry(db, id) {
|
||||
const row = await db.prepare("SELECT * FROM entries WHERE id = ?").bind(id).first();
|
||||
return row ?? null;
|
||||
}
|
||||
var NOT_MACHINERY_PREDICATE = "(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
|
||||
async function listEntries(db, f = {}) {
|
||||
const conds = [];
|
||||
const params = [];
|
||||
if (f.entry_type) {
|
||||
conds.push("entry_type = ?");
|
||||
params.push(f.entry_type);
|
||||
} else {
|
||||
conds.push(NOT_MACHINERY_PREDICATE);
|
||||
}
|
||||
if (f.owner_id) {
|
||||
conds.push("owner_id = ?");
|
||||
@@ -2158,7 +2209,10 @@ async function updateEntry(db, id, patch) {
|
||||
return getEntry(db, id);
|
||||
}
|
||||
async function deleteEntry(db, id) {
|
||||
const ref = await db.prepare("SELECT id FROM entries WHERE dst_id = ? LIMIT 1").bind(id).first();
|
||||
if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} \u2014 delete the record (or its slot) first`);
|
||||
await db.prepare("DELETE FROM entries WHERE id = ?").bind(id).run();
|
||||
await db.prepare("DELETE FROM entries WHERE src_id = ?").bind(id).run();
|
||||
}
|
||||
async function embeddedIdsByLibrary(db, ownerId, library) {
|
||||
const rows = await db.prepare(
|
||||
@@ -2392,6 +2446,8 @@ async function searchEntries(db, q, owner_id, entry_type, limit = 50, library, s
|
||||
if (entry_type) {
|
||||
conds.push("entry_type = ?");
|
||||
params.push(entry_type);
|
||||
} else {
|
||||
conds.push(NOT_MACHINERY_PREDICATE);
|
||||
}
|
||||
if (source) {
|
||||
conds.push("json_extract(metadata_json, '$.source') = ?");
|
||||
@@ -3236,9 +3292,38 @@ entryRoutes.delete("/:id", async (c) => {
|
||||
function uid2(prefix) {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
var SYS_ROOT = "sys_root";
|
||||
var SYS_BELONGS = "sys_belongs";
|
||||
var SYS_FIELD_OF = "sys_field_of";
|
||||
function fieldEntryId(templateId, slot) {
|
||||
return `fld_${templateId}_${slot}`;
|
||||
}
|
||||
async function ensureAnchors(db) {
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES
|
||||
('${SYS_ROOT}', 'root', 'system', NULL),
|
||||
('${SYS_BELONGS}', 'belongs', 'system', NULL),
|
||||
('${SYS_FIELD_OF}', 'field_of', 'system', NULL)`
|
||||
).run();
|
||||
}
|
||||
async function ensureFieldEntries(db, templateId, slots) {
|
||||
for (const slot of slots) {
|
||||
const fid = fieldEntryId(templateId, slot);
|
||||
await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'field')`).bind(fid, slot).run();
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_FIELD_OF}', ?)`
|
||||
).bind(`relf_${templateId}_${slot}`, fid, templateId).run();
|
||||
}
|
||||
}
|
||||
async function createTemplate(db, input) {
|
||||
const id = input.id ?? uid2("tpl");
|
||||
await db.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`).bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null).run();
|
||||
await ensureAnchors(db);
|
||||
await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'sheet')`).bind(id, input.name).run();
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_BELONGS}', '${SYS_ROOT}')`
|
||||
).bind(`relb_${id}`, id).run();
|
||||
await ensureFieldEntries(db, id, input.slots);
|
||||
const row = await getTemplate(db, id);
|
||||
if (!row) throw new Error("createTemplate: row not found after insert");
|
||||
return row;
|
||||
@@ -3265,104 +3350,187 @@ async function updateTemplate(db, id, patch) {
|
||||
if (cols.length === 0) return getTemplate(db, id);
|
||||
cols.push("updated_at = unixepoch()");
|
||||
await db.prepare(`UPDATE templates SET ${cols.join(", ")} WHERE id = ?`).bind(...params, id).run();
|
||||
if (patch.slots !== void 0) await ensureFieldEntries(db, id, patch.slots);
|
||||
return getTemplate(db, id);
|
||||
}
|
||||
async function loadReferencedEntries(db, entryIds, recordOwnerId) {
|
||||
const ids = [...new Set(Object.values(entryIds))];
|
||||
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
||||
const rows = [];
|
||||
for (let i = 0; i < ids.length; i += 90) {
|
||||
const chunk = ids.slice(i, i + 90);
|
||||
const res = await db.prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => "?").join(",")})`).bind(...chunk).all();
|
||||
rows.push(...res.results ?? []);
|
||||
}
|
||||
const found = new Map(rows.map((r) => [r.id, r]));
|
||||
const missing = ids.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) throw new Error(`entry not found: ${missing.join(", ")}`);
|
||||
if (recordOwnerId != null) {
|
||||
const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId);
|
||||
if (foreign.length > 0) {
|
||||
throw new Error(
|
||||
`entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(", ")} != ${recordOwnerId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return new Map(rows.map((r) => [r.id, r.content]));
|
||||
}
|
||||
async function recordBelongs(db, recordId) {
|
||||
const row = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id = '${SYS_BELONGS}' AND dst_id != '${SYS_ROOT}' LIMIT 1`).bind(recordId).first();
|
||||
return row ?? null;
|
||||
}
|
||||
async function insertCellRelation(db, recordId, templateId, slot, dstEntryId, ownerId) {
|
||||
await db.prepare(
|
||||
`INSERT INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, ?, ?)`
|
||||
).bind(uid2("relv"), ownerId, recordId, fieldEntryId(templateId, slot), dstEntryId).run();
|
||||
}
|
||||
async function createRecord(db, input) {
|
||||
const tpl = await getTemplate(db, input.template);
|
||||
if (!tpl) throw new Error(`template not found: ${input.template}`);
|
||||
const slots = JSON.parse(tpl.slots_json);
|
||||
const recordId = input.record_id ?? uid2("rec");
|
||||
for (const slot of slots) {
|
||||
if (!(slot in input.values)) continue;
|
||||
const values = input.values ?? {};
|
||||
const entryIds = input.entry_ids ?? {};
|
||||
const refSlots = Object.keys(entryIds);
|
||||
const ownerId = input.owner_id ?? null;
|
||||
const both = refSlots.filter((s) => s in values);
|
||||
if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(", ")}`);
|
||||
const unknown = refSlots.filter((s) => !slots.includes(s));
|
||||
if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(", ")}`);
|
||||
const referenced = await loadReferencedEntries(db, entryIds, ownerId);
|
||||
await db.prepare(`INSERT OR IGNORE INTO entries (id, entry_type, owner_id) VALUES (?, 'record', ?)`).bind(recordId, ownerId).run();
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, '${SYS_BELONGS}', ?)`
|
||||
).bind(`relb_${recordId}_${tpl.id}`, ownerId, recordId, tpl.id).run();
|
||||
const writtenSlots = slots.filter((s) => s in entryIds || s in values);
|
||||
await ensureFieldEntries(db, tpl.id, writtenSlots);
|
||||
for (const slot of writtenSlots) {
|
||||
if (slot in entryIds) {
|
||||
await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId);
|
||||
continue;
|
||||
}
|
||||
const entry = await createEntry(db, {
|
||||
content: input.values[slot],
|
||||
content: values[slot],
|
||||
entry_type: "value",
|
||||
owner_id: input.owner_id ?? null
|
||||
owner_id: ownerId
|
||||
});
|
||||
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 insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId);
|
||||
}
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||
const out = { ...values };
|
||||
for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? "";
|
||||
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: ownerId };
|
||||
}
|
||||
async function updateRecord(db, recordId, values) {
|
||||
const evRes = await db.prepare(
|
||||
`SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, 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
|
||||
WHERE ev.record_id = ?`
|
||||
const belongs = await recordBelongs(db, recordId);
|
||||
if (!belongs) return null;
|
||||
const templateId = belongs.dst_id;
|
||||
const cellRes = await db.prepare(
|
||||
`SELECT f.content AS slot_name, r.dst_id AS entry_id
|
||||
FROM entries r JOIN entries f ON r.rel_id = f.id
|
||||
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`
|
||||
).bind(recordId).all();
|
||||
const evRows = evRes.results ?? [];
|
||||
if (evRows.length === 0) return null;
|
||||
const templateId = evRows[0].template_id;
|
||||
const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
|
||||
const cells = cellRes.results ?? [];
|
||||
const slotToEntries = /* @__PURE__ */ new Map();
|
||||
for (const c of cells) {
|
||||
const list = slotToEntries.get(c.slot_name) ?? [];
|
||||
list.push(c.entry_id);
|
||||
slotToEntries.set(c.slot_name, list);
|
||||
}
|
||||
const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first();
|
||||
const recordOwnerId = identity?.owner_id ?? null;
|
||||
const tpl = await getTemplate(db, templateId);
|
||||
const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
|
||||
const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()];
|
||||
for (const [slot, content] of Object.entries(values)) {
|
||||
if (!allowed.includes(slot)) {
|
||||
throw new Error(`slot not in template: ${slot}`);
|
||||
}
|
||||
const entryId = slotToEntry.get(slot);
|
||||
if (entryId) {
|
||||
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
|
||||
const entryIds = slotToEntries.get(slot);
|
||||
if (entryIds && entryIds.length > 0) {
|
||||
for (const entryId of entryIds) {
|
||||
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
|
||||
}
|
||||
} else {
|
||||
await ensureFieldEntries(db, templateId, [slot]);
|
||||
const entry = await createEntry(db, { content, entry_type: "value", owner_id: recordOwnerId });
|
||||
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, templateId, slot, entry.id).run();
|
||||
await insertCellRelation(db, recordId, templateId, slot, entry.id, recordOwnerId);
|
||||
}
|
||||
}
|
||||
return getRecord(db, recordId);
|
||||
}
|
||||
async function getRecord(db, recordId) {
|
||||
const belongs = await recordBelongs(db, recordId);
|
||||
if (!belongs) return null;
|
||||
const res = await db.prepare(
|
||||
`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
|
||||
WHERE ev.record_id = ?`
|
||||
`SELECT f.content AS slot, v.content AS content
|
||||
FROM entries r
|
||||
JOIN entries f ON r.rel_id = f.id
|
||||
JOIN entries v ON r.dst_id = v.id
|
||||
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`
|
||||
).bind(recordId).all();
|
||||
const rows = res.results ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
const values = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
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 };
|
||||
for (const r of res.results ?? []) values[r.slot] = r.content;
|
||||
const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first();
|
||||
return { record_id: recordId, template_id: belongs.dst_id, values, owner_id: identity?.owner_id ?? null };
|
||||
}
|
||||
async function searchByTemplate(db, template, owner_id, limit = 100) {
|
||||
const tpl = await getTemplate(db, template);
|
||||
if (!tpl) return [];
|
||||
const cap = Math.min(limit, 500);
|
||||
const res = owner_id ? await db.prepare(
|
||||
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
|
||||
JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ? AND e.owner_id = ?
|
||||
ORDER BY ev.created_at DESC LIMIT ?`
|
||||
).bind(tpl.id, owner_id, cap).all() : await db.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`).bind(tpl.id, cap).all();
|
||||
`SELECT src_id AS record_id FROM entries
|
||||
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? AND owner_id = ?
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?`
|
||||
).bind(tpl.id, owner_id, cap).all() : await db.prepare(
|
||||
`SELECT src_id AS record_id FROM entries
|
||||
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ?
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?`
|
||||
).bind(tpl.id, cap).all();
|
||||
const ids = (res.results ?? []).map((r) => r.record_id);
|
||||
if (ids.length === 0) return [];
|
||||
const byId = /* @__PURE__ */ new Map();
|
||||
for (const id of ids) byId.set(id, { record_id: id, template_id: tpl.id, values: {}, owner_id: null });
|
||||
for (let i = 0; i < ids.length; i += 90) {
|
||||
const chunk = ids.slice(i, i + 90);
|
||||
const placeholders = chunk.map(() => "?").join(",");
|
||||
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, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id IN (${placeholders})`
|
||||
).bind(...chunk).all();
|
||||
for (const r of evRes.results ?? []) {
|
||||
let rec = byId.get(r.record_id);
|
||||
if (!rec) {
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||
byId.set(r.record_id, rec);
|
||||
}
|
||||
rec.values[r.slot] = r.content;
|
||||
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||
const [cellRes, identRes] = await Promise.all([
|
||||
db.prepare(
|
||||
`SELECT r.src_id AS record_id, f.content AS slot, v.content AS content
|
||||
FROM entries r
|
||||
JOIN entries f ON r.rel_id = f.id
|
||||
JOIN entries v ON r.dst_id = v.id
|
||||
WHERE r.src_id IN (${placeholders}) AND r.rel_id != '${SYS_BELONGS}'`
|
||||
).bind(...chunk).all(),
|
||||
db.prepare(`SELECT id, owner_id FROM entries WHERE id IN (${placeholders})`).bind(...chunk).all()
|
||||
]);
|
||||
for (const r of cellRes.results ?? []) {
|
||||
const rec = byId.get(r.record_id);
|
||||
if (rec) rec.values[r.slot] = r.content;
|
||||
}
|
||||
for (const r of identRes.results ?? []) {
|
||||
const rec = byId.get(r.id);
|
||||
if (rec) rec.owner_id = r.owner_id;
|
||||
}
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((r) => !!r);
|
||||
}
|
||||
async function deleteRecord(db, recordId) {
|
||||
const evRes = await db.prepare("SELECT entry_id FROM entry_values WHERE record_id = ?").bind(recordId).all();
|
||||
const rows = evRes.results ?? [];
|
||||
if (rows.length === 0) return false;
|
||||
await db.prepare("DELETE FROM entry_values WHERE record_id = ?").bind(recordId).run();
|
||||
for (const { entry_id } of rows) {
|
||||
await db.prepare("DELETE FROM entries WHERE id = ?").bind(entry_id).run();
|
||||
const belongs = await recordBelongs(db, recordId);
|
||||
if (!belongs) return false;
|
||||
const cellRes = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id != '${SYS_BELONGS}'`).bind(recordId).all();
|
||||
const dsts = (cellRes.results ?? []).map((r) => r.dst_id);
|
||||
await db.prepare(`DELETE FROM entries WHERE src_id = ?`).bind(recordId).run();
|
||||
await db.prepare(
|
||||
`DELETE FROM entries WHERE id = ?1 AND entry_type = 'record'
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)`
|
||||
).bind(recordId).run();
|
||||
for (const dst of dsts) {
|
||||
await db.prepare(
|
||||
`DELETE FROM entries WHERE id = ?1
|
||||
AND entry_type NOT IN ('sheet', 'field', 'system')
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE src_id = ?1)
|
||||
AND NOT EXISTS (SELECT 1 FROM entries WHERE rel_id = ?1)`
|
||||
).bind(dst).run();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -3395,10 +3563,17 @@ templateRoutes.patch("/:id", async (c) => {
|
||||
|
||||
// kbdb/src/routes/records.ts
|
||||
var recordRoutes = new Hono2();
|
||||
var isStringMap = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
|
||||
recordRoutes.post("/", async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.template || !body.values) {
|
||||
return c.json({ success: false, error: "template and values required" }, 400);
|
||||
if (!body || !body.template || !body.values && !body.entry_ids) {
|
||||
return c.json({ success: false, error: "template and values (or entry_ids) required" }, 400);
|
||||
}
|
||||
if (body.values !== void 0 && !isStringMap(body.values)) {
|
||||
return c.json({ success: false, error: "values must be an object of {slot: string}" }, 400);
|
||||
}
|
||||
if (body.entry_ids !== void 0 && !isStringMap(body.entry_ids)) {
|
||||
return c.json({ success: false, error: "entry_ids must be an object of {slot: entry_id}" }, 400);
|
||||
}
|
||||
try {
|
||||
const rec = await createRecord(c.env.DB, body);
|
||||
@@ -3411,20 +3586,14 @@ recordRoutes.get("/triplet-stats", async (c) => {
|
||||
const owner = c.req.query("owner_id") || "";
|
||||
const rows = await c.env.DB.prepare(
|
||||
`SELECT
|
||||
COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
|
||||
COALESCE(NULLIF(lib_v.content, ''), 'general') AS library,
|
||||
COUNT(*) AS triplet_count
|
||||
FROM (
|
||||
SELECT DISTINCT ev.record_id
|
||||
FROM entry_values ev
|
||||
JOIN templates t ON ev.template_id = t.id
|
||||
JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE t.name = 'triplet'
|
||||
AND (?1 = '' OR e.owner_id = ?1)
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev
|
||||
ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
|
||||
FROM entries b
|
||||
JOIN templates t ON b.dst_id = t.id AND t.name = 'triplet'
|
||||
LEFT JOIN entries lr ON lr.src_id = b.src_id AND lr.rel_id = ('fld_' || b.dst_id || '_library')
|
||||
LEFT JOIN entries lib_v ON lib_v.id = lr.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND (?1 = '' OR b.owner_id = ?1)
|
||||
GROUP BY COALESCE(NULLIF(lib_v.content, ''), 'general')
|
||||
ORDER BY library`
|
||||
).bind(owner).all();
|
||||
const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count }));
|
||||
@@ -3604,31 +3773,35 @@ async function ensureTripletLibrarySlot(db, tripletTemplate) {
|
||||
return true;
|
||||
}
|
||||
function tripletPivotSql(ownerFiltered) {
|
||||
return `SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject,
|
||||
MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object,
|
||||
MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
|
||||
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
|
||||
MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""}
|
||||
GROUP BY ev.record_id`;
|
||||
return `SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""}
|
||||
GROUP BY b.src_id`;
|
||||
}
|
||||
function mapPivotSql(ownerFiltered) {
|
||||
return `SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
|
||||
MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative,
|
||||
MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities,
|
||||
MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile,
|
||||
MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges,
|
||||
MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count,
|
||||
MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
|
||||
MAX(ev.created_at) AS ts
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""}
|
||||
GROUP BY ev.record_id`;
|
||||
return `SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||||
MAX(r.created_at) AS ts
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""}
|
||||
GROUP BY b.src_id`;
|
||||
}
|
||||
function parseJsonArray(raw2) {
|
||||
if (!raw2) return [];
|
||||
@@ -3736,6 +3909,7 @@ async function recomputeLibraryMap(db, input) {
|
||||
await updateRecord(db, row.rid, { status: "superseded" });
|
||||
superseded.push(row.rid);
|
||||
}
|
||||
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
|
||||
return {
|
||||
map: {
|
||||
record_id: blockEntry.id,
|
||||
@@ -3746,6 +3920,7 @@ async function recomputeLibraryMap(db, input) {
|
||||
relation_profile: relationProfile,
|
||||
bridges,
|
||||
triplet_count: tripletCount,
|
||||
entry_count: entryCount,
|
||||
commit_hash: input.commit_hash ?? null,
|
||||
status: "active",
|
||||
updated_at: blockEntry.created_at
|
||||
@@ -3759,18 +3934,36 @@ async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) {
|
||||
const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||
const res = await db.prepare(
|
||||
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
|
||||
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
|
||||
FROM (
|
||||
SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
|
||||
GROUP BY ev.record_id
|
||||
SELECT b.src_id AS rid,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
|
||||
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""}
|
||||
GROUP BY b.src_id
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
WHERE COALESCE(tr.status, 'active') = 'active'
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`
|
||||
GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')`
|
||||
).bind(...params).all();
|
||||
const m = /* @__PURE__ */ new Map();
|
||||
for (const r of res.results ?? []) m.set(r.library, r.n);
|
||||
return m;
|
||||
}
|
||||
async function liveEntryCountsByLibrary(db, owner_id) {
|
||||
const params = owner_id ? [owner_id] : [];
|
||||
const res = await db.prepare(
|
||||
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
|
||||
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
|
||||
COUNT(*) AS n
|
||||
FROM entries
|
||||
WHERE ${owner_id ? "owner_id = ? AND " : ""}entry_type != 'value'
|
||||
AND src_id IS NULL
|
||||
AND entry_type NOT IN ('record', 'sheet', 'field', 'system')
|
||||
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
|
||||
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`
|
||||
).bind(...params).all();
|
||||
const m = /* @__PURE__ */ new Map();
|
||||
for (const r of res.results ?? []) m.set(r.library, r.n);
|
||||
@@ -3788,10 +3981,12 @@ async function knownLibraryNames(db, owner_id) {
|
||||
if (libTpl) {
|
||||
const libParams = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
|
||||
const libRows = await db.prepare(
|
||||
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
|
||||
GROUP BY ev.record_id`
|
||||
`SELECT MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name
|
||||
FROM entries b
|
||||
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
|
||||
LEFT JOIN entries v ON v.id = r.dst_id
|
||||
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""}
|
||||
GROUP BY b.src_id`
|
||||
).bind(...libParams).all();
|
||||
for (const r of libRows.results ?? []) if (r.name) names.add(r.name);
|
||||
}
|
||||
@@ -3825,11 +4020,15 @@ async function listLibraryMaps(db, owner_id) {
|
||||
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
|
||||
if (!tpl) return [];
|
||||
const params = owner_id ? [tpl.id, owner_id] : [tpl.id];
|
||||
const res = await db.prepare(
|
||||
`WITH m AS (${mapPivotSql(!!owner_id)})
|
||||
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
|
||||
ORDER BY m.ts DESC`
|
||||
).bind(...params).all();
|
||||
const [res, entryCounts] = await Promise.all([
|
||||
db.prepare(
|
||||
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
|
||||
`WITH m AS (${mapPivotSql(!!owner_id)})
|
||||
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
|
||||
ORDER BY m.ts DESC`
|
||||
).bind(...params).all(),
|
||||
liveEntryCountsByLibrary(db, owner_id)
|
||||
]);
|
||||
const byLib = /* @__PURE__ */ new Map();
|
||||
for (const r of res.results ?? []) {
|
||||
if (!r.library || byLib.has(r.library)) continue;
|
||||
@@ -3838,6 +4037,7 @@ async function listLibraryMaps(db, owner_id) {
|
||||
narrative: r.narrative || null,
|
||||
top_entities: parseJsonArray(r.top_entities).slice(0, 3).map((t) => t.name),
|
||||
triplet_count: Number(r.triplet_count ?? 0) || 0,
|
||||
entry_count: entryCounts.get(r.library) ?? 0,
|
||||
updated_at: r.ts
|
||||
});
|
||||
}
|
||||
@@ -3853,7 +4053,10 @@ async function getLibraryMapDetail(db, library, owner_id) {
|
||||
ORDER BY m.ts DESC LIMIT 1`
|
||||
).bind(...params).first();
|
||||
if (!row) return null;
|
||||
const blockEntry = await getEntry(db, row.rid);
|
||||
const [blockEntry, entryCounts] = await Promise.all([
|
||||
getEntry(db, row.rid),
|
||||
liveEntryCountsByLibrary(db, owner_id)
|
||||
]);
|
||||
return {
|
||||
record_id: row.rid,
|
||||
library,
|
||||
@@ -3863,6 +4066,7 @@ async function getLibraryMapDetail(db, library, owner_id) {
|
||||
relation_profile: parseJsonArray(row.relation_profile),
|
||||
bridges: parseJsonArray(row.bridges),
|
||||
triplet_count: Number(row.triplet_count ?? 0) || 0,
|
||||
entry_count: entryCounts.get(library) ?? 0,
|
||||
commit_hash: row.commit_hash || null,
|
||||
status: row.status ?? "active",
|
||||
updated_at: row.ts
|
||||
@@ -4150,6 +4354,12 @@ app.use("*", async (c, next) => {
|
||||
});
|
||||
app.get("/", (c) => c.json({ service: "arcrun-kbdb", tier: "base", status: "ok" }));
|
||||
app.get("/health", (c) => c.json({ ok: true }));
|
||||
app.get("/maintenance/relation-orphans", async (c) => {
|
||||
const { scanRelationOrphans: scanRelationOrphans2 } = await Promise.resolve().then(() => (init_relation_orphans(), relation_orphans_exports));
|
||||
const limit = Number(c.req.query("limit") ?? "200");
|
||||
const report = await scanRelationOrphans2(c.env.DB, Number.isFinite(limit) ? limit : 200);
|
||||
return c.json({ success: true, ...report });
|
||||
});
|
||||
app.route("/entries", entryRoutes);
|
||||
app.route("/templates", templateRoutes);
|
||||
app.route("/records", recordRoutes);
|
||||
|
||||
@@ -31262,16 +31262,109 @@ function registerAllWorkflowCrudTools(server, env) {
|
||||
registerRunWorkflow(server, env);
|
||||
}
|
||||
|
||||
// mcp/src/lib/portal-client.ts
|
||||
async function portalFetch(env, session, path, opts = {}) {
|
||||
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 !== void 0 && 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 !== void 0 ? JSON.stringify(opts.body) : void 0
|
||||
});
|
||||
}
|
||||
function resolveKnowledgeIdentity(authPath, portal) {
|
||||
if (authPath !== "oauth") return { kind: "service" };
|
||||
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
|
||||
}
|
||||
function staleIdentityError() {
|
||||
return errorResponse(
|
||||
"identity_missing",
|
||||
"\u9019\u689D MCP \u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\uFF0C\u88E1\u9762\u6C92\u6709\u767B\u5165\u8005\u8EAB\u5206\uFF0C\u56E0\u6B64\u67E5\u4E0D\u5230\u4EFB\u4F55\u77E5\u8B58\u5167\u5BB9\u3002\u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u5728 claude.ai \u7684 connector \u8A2D\u5B9A\u88E1\u91CD\u65B0\u6388\u6B0A\u3001\u8F38\u5165\u4F60\u7684 Portal \u5E33\u5BC6\uFF09\u5373\u53EF\u2014\u2014\u4E0D\u9700\u8981\u53E6\u5916\u627E\u4EFB\u4F55 credential \u6216\u91D1\u9470\u3002",
|
||||
[
|
||||
"\u5230 claude.ai \u2192 Settings \u2192 Connectors\uFF0C\u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u6703\u8DF3\u51FA\u8F38\u5165 Portal \u5E33\u5BC6\u7684\u9801\u9762\uFF09",
|
||||
"\u91CD\u9023\u5F8C kbdb_* \u5168\u90E8\u5DE5\u5177\u90FD\u6703\u7528\u4F60\u9019\u500B\u5E33\u865F\u7684\u6B0A\u9650\u67E5\u8A62"
|
||||
]
|
||||
);
|
||||
}
|
||||
async function portalError(res, what) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
if (res.status === 401) {
|
||||
return errorResponse(
|
||||
"session_expired",
|
||||
`${what}\u5931\u6557\uFF1A\u767B\u5165\u968E\u6BB5\u5DF2\u904E\u671F\uFF08portal session \u5230\u671F\u6216\u5DF2\u767B\u51FA\uFF09\u3002`,
|
||||
[
|
||||
"\u5230 claude.ai \u2192 Settings \u2192 Connectors \u91CD\u65B0\u9023\u7DDA\u9019\u500B connector\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09",
|
||||
"\u91CD\u9023\u5F8C\u6B0A\u9650\u8207\u4F60\u5728 portal \u7DB2\u9801\u4E0A\u770B\u5230\u7684\u4E00\u81F4"
|
||||
],
|
||||
detail
|
||||
);
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return errorResponse(
|
||||
"forbidden",
|
||||
`${what}\u5931\u6557\uFF1A\u9019\u500B\u5E33\u865F\u6C92\u6709\u9019\u9805\u6B0A\u9650\uFF08\u5E33\u865F\u53EF\u80FD\u5DF2\u505C\u7528\uFF0C\u6216\u6C92\u6709\u88AB\u6388\u6B0A\u8A72\u77E5\u8B58\u5EAB\uFF09\u3002`,
|
||||
["\u8ACB\u77E5\u8B58\u5EAB\u7BA1\u7406\u54E1\u5728 portal \u7684\u5E33\u865F\u7BA1\u7406\u88E1\u78BA\u8A8D\u4F60\u7684\u72C0\u614B\u8207\u53EF\u7528\u77E5\u8B58\u5EAB"],
|
||||
detail
|
||||
);
|
||||
}
|
||||
return errorResponse(`portal_${res.status}`, `${what}\u5931\u6557\uFF08HTTP ${res.status}\uFF09`, ["\u7A0D\u5F8C\u91CD\u8A66"], detail);
|
||||
}
|
||||
|
||||
// mcp/src/tools/arcrun_skills_examples.ts
|
||||
var KbdbAccessError = class extends Error {
|
||||
constructor(status, what, detail) {
|
||||
super(`KBDB ${what} HTTP ${status}`);
|
||||
this.status = status;
|
||||
this.what = what;
|
||||
this.detail = detail;
|
||||
}
|
||||
status;
|
||||
what;
|
||||
detail;
|
||||
};
|
||||
function kbdbFailure(e, searchHint, identity) {
|
||||
const portalNote = identity.kind === "portal" ? "\u4F60\u9019\u689D\u662F\u5E33\u5BC6\u767B\u5165\u7684\u9023\u7DDA\uFF0C\u4F46 skill\uFF0Fexample \u9019\u6279\u5DE5\u5177\u76EE\u524D\u4ECD\u8D70**\u670D\u52D9\u5167\u90E8\u6191\u64DA**\uFF08\u9084\u6C92\u63A5\u4E0A\u767B\u5165\u8EAB\u5206\uFF09\u2014\u2014\u6240\u4EE5\u5B83\u8B80\u4E0D\u5230\uFF0C\u4E0D\u4EE3\u8868\u4F60\u7684\u5E33\u865F\u8B80\u4E0D\u5230\u3002" : "";
|
||||
if (e instanceof KbdbAccessError) {
|
||||
const unauthorized = e.status === 401 || e.status === 403;
|
||||
return errorResponse(
|
||||
unauthorized ? "kbdb_unauthorized" : "kbdb_unreachable",
|
||||
(unauthorized ? `\u8B80\u4E0D\u5230 KBDB\uFF08HTTP ${e.status}\uFF1A\u9019\u689D\u9023\u7DDA\u7684\u6191\u64DA\u88AB\u62D2\u6216\u6839\u672C\u6C92\u5E36\uFF09\u3002` : `\u8B80\u4E0D\u5230 KBDB\uFF08HTTP ${e.status}\uFF09\u3002`) + "\u{1F534} **\u9019\u662F\u300C\u8B80\u4E0D\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u4E0D\u5B58\u5728\u300D**\u2014\u2014\u5167\u5BB9\u9084\u5728\u5EAB\u88E1\uFF0C\u53EA\u662F\u9019\u689D\u8DEF\u88AB\u64CB\u4F4F\u4E86\u3002" + (portalNote ? ` ${portalNote}` : ""),
|
||||
[
|
||||
searchHint,
|
||||
"kbdb_get_map() \u770B\u9019\u53F0\u5BE6\u4F8B\u6709\u54EA\u4E9B\u5EAB\uFF08\u90A3\u689D\u8D70\u5F97\u901A\u5C31\u66F4\u78BA\u5B9A\u662F\u9019\u6279\u5DE5\u5177\u7684\u8DEF\u58DE\u4E86\uFF0C\u4E0D\u662F\u5EAB\u7A7A\u4E86\uFF09",
|
||||
"\u{1F534} \u4E0D\u51C6\u628A\u9019\u500B\u932F\u8AA4\u56DE\u5831\u6210\u300C\u627E\u4E0D\u5230\uFF0F\u6C92\u6709\u9019\u500B skill\u300D\u2014\u2014\u8ACB\u7167\u5BE6\u8AAA\u300CKBDB \u9019\u689D\u8DEF\u8B80\u4E0D\u5230\u300D",
|
||||
"\u6301\u7E8C\u5931\u6557\uFF1A\u544A\u8A34 leo \u9019\u53F0\u5BE6\u4F8B\u7684 arcrun-mcp \u5C11\u4E86 secret KBDB_INTERNAL_TOKEN"
|
||||
],
|
||||
e.detail
|
||||
);
|
||||
}
|
||||
return errorResponse(
|
||||
"kbdb_unreachable",
|
||||
`\u8B80\u4E0D\u5230 KBDB\uFF1A${e instanceof Error ? e.message : String(e)}\u3002\u{1F534} **\u9019\u662F\u300C\u8B80\u4E0D\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u4E0D\u5B58\u5728\u300D**\u3002` + (portalNote ? ` ${portalNote}` : ""),
|
||||
[searchHint, "\u7A0D\u5F8C\u91CD\u8A66", "\u{1F534} \u4E0D\u51C6\u628A\u5B83\u56DE\u5831\u6210\u300C\u6C92\u6709\u9019\u500B skill\uFF0Fexample\u300D"]
|
||||
);
|
||||
}
|
||||
async function kbdbList(env, entryType, limit = 100) {
|
||||
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
|
||||
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`);
|
||||
if (!resp.ok) {
|
||||
throw new KbdbAccessError(resp.status, `list entry_type=${entryType}`, await resp.text().catch(() => ""));
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.entries ?? [];
|
||||
}
|
||||
async function kbdbGetByPageName(env, pageName) {
|
||||
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
|
||||
if (!resp.ok) return null;
|
||||
if (!resp.ok) {
|
||||
throw new KbdbAccessError(resp.status, `get page_name=${pageName}`, await resp.text().catch(() => ""));
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.entries?.[0] ?? null;
|
||||
}
|
||||
@@ -31284,7 +31377,7 @@ function parseTags(tagsJson) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function registerListSkills(server, env) {
|
||||
function registerListSkills(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("list_skills"),
|
||||
"\u5217\u6240\u6709 agent-skill blocks\uFF08\u5F9E arcrun/registry/skills/ \u540C\u6B65\u9032 KBDB\uFF09\u3002\u6BCF\u500B skill \u662F\u500B markdown playbook\uFF0C\u63CF\u8FF0 AI \u9762\u5C0D X \u554F\u984C\u8A72\u600E\u9EBC\u60F3 + \u8A72\u7528\u54EA\u500B example\u3002\u56DE [{slug, title, tags}]\u3002call get_skill(slug) \u62FF\u5B8C\u6574\u5167\u6587\u3002",
|
||||
@@ -31292,6 +31385,7 @@ function registerListSkills(server, env) {
|
||||
tag: external_exports.string().optional().describe("optional \u6A19\u7C64\u904E\u6FFE\u3002\u5982 'rag' / 'watcher' / 'debug'")
|
||||
},
|
||||
async ({ tag }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const blocks = await kbdbList(env, "agent-skill", 100);
|
||||
const skills = blocks.map((b) => {
|
||||
@@ -31313,20 +31407,19 @@ function registerListSkills(server, env) {
|
||||
return successResponse(
|
||||
{ count: skills.length, skills },
|
||||
[
|
||||
skills.length === 0 ? "\u6C92\u6709 skill \u547D\u4E2D\u3002\u8A66 list_skills() \u4E0D\u5E36 tag \u770B\u5168\u90E8" : "call arcrun_get_skill(slug) \u62FF\u55AE\u500B skill \u5B8C\u6574 markdown"
|
||||
skills.length === 0 ? "\u6C92\u6709 skill \u547D\u4E2D\u3002\u8A66 list_skills() \u4E0D\u5E36 tag \u770B\u5168\u90E8" : "call arcrun_get_skill(slug) \u62FF\u55AE\u500B skill \u5B8C\u6574 markdown",
|
||||
// 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。
|
||||
// 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。
|
||||
"\u{1F534} \u53EA\u7528\u4E0A\u9762\u6E05\u55AE\u88E1\u771F\u7684\u6709\u7684 slug\uFF1B\u6E05\u55AE\u6C92\u6709\uFF1D\u9019\u53F0\u5BE6\u4F8B\u6C92 seed \u9032\u53BB\uFF0C\u4E0D\u8981\u786C\u731C\u540D\u5B57"
|
||||
]
|
||||
);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u7A0D\u5F8C\u91CD\u8A66", "\u82E5\u6301\u7E8C\u5931\u6557\uFF0C\u544A\u8A34 leo"]
|
||||
);
|
||||
return kbdbFailure(e, "\u6539\u7528 kbdb_search({ q: 'skill' }) \u76F4\u63A5\u5728\u77E5\u8B58\u5EAB\u88E1\u627E skill \u5361\u7247\uFF08\u90A3\u689D\u8DEF\u8D70\u7684\u662F\u53E6\u4E00\u7D44\u6191\u64DA\uFF09", identity);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerGetSkill(server, env) {
|
||||
function registerGetSkill(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("get_skill"),
|
||||
"\u62FF\u55AE\u4E00 agent-skill \u5B8C\u6574 markdown playbook\u3002slug \u5F9E list_skills \u53D6\u5F97\u3002",
|
||||
@@ -31334,15 +31427,17 @@ function registerGetSkill(server, env) {
|
||||
slug: external_exports.string().describe("skill slug\uFF0C\u4F8B\u5982 'build_watcher_workflow' / 'rag_with_arcrun'")
|
||||
},
|
||||
async ({ slug }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
|
||||
const block = await kbdbGetByPageName(env, pageName);
|
||||
if (!block) {
|
||||
return errorResponse(
|
||||
"not_found",
|
||||
`skill "${slug}" \u4E0D\u5B58\u5728`,
|
||||
`KBDB \u6B63\u5E38\u56DE\u61C9\uFF0C\u4F46\u6C92\u6709 page_name="${pageName}" \u9019\u5F35\u5361\u2014\u2014\u9019\u53F0\u5BE6\u4F8B\u6C92\u6709 seed \u9019\u652F skill\u3002\uFF08\u4E0D\u540C\u5BE6\u4F8B seed \u7684 skill \u4E0D\u4E00\u6A23\uFF0C\u5225\u7167\u820A\u6559\u6750\u5047\u8A2D\u67D0\u500B\u540D\u5B57\u4E00\u5B9A\u5728\u3002\uFF09`,
|
||||
[
|
||||
"call arcrun_list_skills() \u770B\u53EF\u7528 slug",
|
||||
"call arcrun_list_skills() \u770B**\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709**\u54EA\u5E7E\u652F",
|
||||
`kbdb_search({ q: '${slug}' }) \u770B\u5167\u5BB9\u662F\u4E0D\u662F\u88AB\u5B58\u6210\u5225\u7684\u540D\u5B57`,
|
||||
"\u78BA\u8A8D\u62FC\u5B57\u6B63\u78BA\uFF08\u4E0D\u9700\u8981 'skill-' prefix\uFF09"
|
||||
]
|
||||
);
|
||||
@@ -31354,16 +31449,16 @@ function registerGetSkill(server, env) {
|
||||
tags: parseTags(block.tags_json)
|
||||
});
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u7A0D\u5F8C\u91CD\u8A66"]
|
||||
return kbdbFailure(
|
||||
e,
|
||||
`\u6539\u7528 kbdb_search({ q: 'skill-${slug}' }) \u6488\u540C\u4E00\u5F35\u5361\u7247\uFF08skill \u5C31\u4F4F\u5728\u77E5\u8B58\u5EAB\u7684 entries \u88E1\uFF0C\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09`,
|
||||
identity
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerListExamples(server, env) {
|
||||
function registerListExamples(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("list_examples"),
|
||||
"\u5217\u6240\u6709 workflow-example blocks\uFF08\u5F9E arcrun/registry/examples/ \u540C\u6B65\u9032 KBDB\uFF09\u3002\u6BCF\u500B example \u662F\u53EF\u76F4\u63A5 push \u7684 workflow YAML \u7BC4\u672C + description\u3002\u56DE [{slug, tags}]\u3002call get_example / search_examples \u62FF\u7D30\u7BC0\u3002",
|
||||
@@ -31371,6 +31466,7 @@ function registerListExamples(server, env) {
|
||||
tag: external_exports.string().optional().describe("optional \u6A19\u7C64\u904E\u6FFE\u3002\u5982 'rag' / 'cron' / 'llm' / 'webhook'")
|
||||
},
|
||||
async ({ tag }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const blocks = await kbdbList(env, "workflow-example", 200);
|
||||
const examples = blocks.map((b) => {
|
||||
@@ -31389,16 +31485,12 @@ function registerListExamples(server, env) {
|
||||
]
|
||||
);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u7A0D\u5F8C\u91CD\u8A66"]
|
||||
);
|
||||
return kbdbFailure(e, "\u6539\u7528 kbdb_search({ q: 'example' }) \u76F4\u63A5\u5728\u77E5\u8B58\u5EAB\u88E1\u627E example \u5361\u7247\uFF08\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09", identity);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerGetExample(server, env) {
|
||||
function registerGetExample(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("get_example"),
|
||||
"\u62FF\u55AE\u4E00 workflow-example \u5B8C\u6574 YAML + description\u3002slug \u5F9E list_examples / search_examples \u53D6\u5F97\u3002\u53EF\u76F4\u63A5\u62FF YAML \u6539\u6210\u4F60\u81EA\u5DF1\u7684 \u2192 push\u3002",
|
||||
@@ -31406,16 +31498,18 @@ function registerGetExample(server, env) {
|
||||
slug: external_exports.string().describe("example slug\uFF0C\u4F8B\u5982 'rag-search-answer' / 'cron-watcher'")
|
||||
},
|
||||
async ({ slug }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
|
||||
const block = await kbdbGetByPageName(env, pageName);
|
||||
if (!block) {
|
||||
return errorResponse(
|
||||
"not_found",
|
||||
`example "${slug}" \u4E0D\u5B58\u5728`,
|
||||
`KBDB \u6B63\u5E38\u56DE\u61C9\uFF0C\u4F46\u6C92\u6709 page_name="${pageName}" \u9019\u5F35\u5361\u2014\u2014\u9019\u53F0\u5BE6\u4F8B\u6C92 seed \u9019\u500B example\u3002`,
|
||||
[
|
||||
"call arcrun_list_examples() \u770B\u53EF\u7528 slug",
|
||||
"\u6216 arcrun_search_examples(use_case) \u7528\u81EA\u7136\u8A9E\u8A00\u627E"
|
||||
"call arcrun_list_examples() \u770B**\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709**\u54EA\u4E9B slug",
|
||||
"\u6216 arcrun_search_examples(use_case) \u7528\u95DC\u9375\u5B57\u627E",
|
||||
`kbdb_search({ q: '${slug}' }) \u770B\u5167\u5BB9\u662F\u4E0D\u662F\u88AB\u5B58\u6210\u5225\u7684\u540D\u5B57`
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -31436,16 +31530,16 @@ function registerGetExample(server, env) {
|
||||
"\u770B description_md \u4E86\u89E3\u8A2D\u8A08\u610F\u5716 / \u6539\u9020\u65B9\u5411"
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"fetch_failed",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u7A0D\u5F8C\u91CD\u8A66"]
|
||||
return kbdbFailure(
|
||||
e,
|
||||
`\u6539\u7528 kbdb_search({ q: 'example-${slug}' }) \u6488\u540C\u4E00\u5F35\u5361\u7247\uFF08example \u5C31\u4F4F\u5728\u77E5\u8B58\u5EAB\u7684 entries \u88E1\uFF09`,
|
||||
identity
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerSearchExamples(server, env) {
|
||||
function registerSearchExamples(server, env, identity) {
|
||||
server.tool(
|
||||
toolName("search_examples"),
|
||||
"\u7528 use case \u95DC\u9375\u5B57\u641C workflow examples\uFF0C\u56DE\u6700\u76F8\u95DC N \u500B\u3002\u6CE8\u610F\uFF1A\u57FA\u672C\u76E4\u76EE\u524D\u662F D1 LIKE \u95DC\u9375\u5B57\u641C\u5C0B\uFF08\u975E\u8A9E\u7FA9 embedding\uFF1B\u8A9E\u7FA9\u662F kbdb-base Phase 1 \u7684 embed \u6A21\u7D44\uFF0C\u5C1A\u672A\u4E0A\uFF09\u3002\u2192 \u7528\u5177\u9AD4\u8A5E\uFF08'email'\u3001'cron'\u3001'rag'\uFF09\u6BD4\u6574\u53E5\u81EA\u7136\u8A9E\u8A00\u547D\u4E2D\u7387\u9AD8\u3002\u4E5F\u6703\u6BD4\u5C0D slug/tag\u3002",
|
||||
@@ -31454,6 +31548,7 @@ function registerSearchExamples(server, env) {
|
||||
top_k: external_exports.number().int().min(1).max(20).optional().describe("\u56DE\u5E7E\u500B\u7D50\u679C\uFF08\u9810\u8A2D 5\uFF09")
|
||||
},
|
||||
async ({ query, top_k }) => {
|
||||
if (identity.kind === "stale") return staleIdentityError();
|
||||
try {
|
||||
const k = top_k ?? 5;
|
||||
const q = query.trim();
|
||||
@@ -31498,21 +31593,17 @@ function registerSearchExamples(server, env) {
|
||||
]
|
||||
);
|
||||
} catch (e) {
|
||||
return errorResponse(
|
||||
"internal_error",
|
||||
e instanceof Error ? e.message : String(e),
|
||||
["\u91CD\u8A66\u4E00\u6B21"]
|
||||
);
|
||||
return kbdbFailure(e, `\u6539\u7528 kbdb_search({ q: '${query.trim()}' }) \u76F4\u63A5\u67E5\u77E5\u8B58\u5EAB\uFF08\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09`, identity);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
function registerAllSkillExampleTools(server, env) {
|
||||
registerListSkills(server, env);
|
||||
registerGetSkill(server, env);
|
||||
registerListExamples(server, env);
|
||||
registerGetExample(server, env);
|
||||
registerSearchExamples(server, env);
|
||||
function registerAllSkillExampleTools(server, env, identity) {
|
||||
registerListSkills(server, env, identity);
|
||||
registerGetSkill(server, env, identity);
|
||||
registerListExamples(server, env, identity);
|
||||
registerGetExample(server, env, identity);
|
||||
registerSearchExamples(server, env, identity);
|
||||
}
|
||||
|
||||
// mcp/src/tools/arcrun_recipe.ts
|
||||
@@ -31713,62 +31804,6 @@ function registerRecipeDelete(server, env) {
|
||||
);
|
||||
}
|
||||
|
||||
// mcp/src/lib/portal-client.ts
|
||||
async function portalFetch(env, session, path, opts = {}) {
|
||||
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 !== void 0 && 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 !== void 0 ? JSON.stringify(opts.body) : void 0
|
||||
});
|
||||
}
|
||||
function resolveKnowledgeIdentity(authPath, portal) {
|
||||
if (authPath !== "oauth") return { kind: "service" };
|
||||
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
|
||||
}
|
||||
function staleIdentityError() {
|
||||
return errorResponse(
|
||||
"identity_missing",
|
||||
"\u9019\u689D MCP \u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\uFF0C\u88E1\u9762\u6C92\u6709\u767B\u5165\u8005\u8EAB\u5206\uFF0C\u56E0\u6B64\u67E5\u4E0D\u5230\u4EFB\u4F55\u77E5\u8B58\u5167\u5BB9\u3002\u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u5728 claude.ai \u7684 connector \u8A2D\u5B9A\u88E1\u91CD\u65B0\u6388\u6B0A\u3001\u8F38\u5165\u4F60\u7684 Portal \u5E33\u5BC6\uFF09\u5373\u53EF\u2014\u2014\u4E0D\u9700\u8981\u53E6\u5916\u627E\u4EFB\u4F55 credential \u6216\u91D1\u9470\u3002",
|
||||
[
|
||||
"\u5230 claude.ai \u2192 Settings \u2192 Connectors\uFF0C\u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u6703\u8DF3\u51FA\u8F38\u5165 Portal \u5E33\u5BC6\u7684\u9801\u9762\uFF09",
|
||||
"\u91CD\u9023\u5F8C kbdb_* \u5168\u90E8\u5DE5\u5177\u90FD\u6703\u7528\u4F60\u9019\u500B\u5E33\u865F\u7684\u6B0A\u9650\u67E5\u8A62"
|
||||
]
|
||||
);
|
||||
}
|
||||
async function portalError(res, what) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
if (res.status === 401) {
|
||||
return errorResponse(
|
||||
"session_expired",
|
||||
`${what}\u5931\u6557\uFF1A\u767B\u5165\u968E\u6BB5\u5DF2\u904E\u671F\uFF08portal session \u5230\u671F\u6216\u5DF2\u767B\u51FA\uFF09\u3002`,
|
||||
[
|
||||
"\u5230 claude.ai \u2192 Settings \u2192 Connectors \u91CD\u65B0\u9023\u7DDA\u9019\u500B connector\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09",
|
||||
"\u91CD\u9023\u5F8C\u6B0A\u9650\u8207\u4F60\u5728 portal \u7DB2\u9801\u4E0A\u770B\u5230\u7684\u4E00\u81F4"
|
||||
],
|
||||
detail
|
||||
);
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return errorResponse(
|
||||
"forbidden",
|
||||
`${what}\u5931\u6557\uFF1A\u9019\u500B\u5E33\u865F\u6C92\u6709\u9019\u9805\u6B0A\u9650\uFF08\u5E33\u865F\u53EF\u80FD\u5DF2\u505C\u7528\uFF0C\u6216\u6C92\u6709\u88AB\u6388\u6B0A\u8A72\u77E5\u8B58\u5EAB\uFF09\u3002`,
|
||||
["\u8ACB\u77E5\u8B58\u5EAB\u7BA1\u7406\u54E1\u5728 portal \u7684\u5E33\u865F\u7BA1\u7406\u88E1\u78BA\u8A8D\u4F60\u7684\u72C0\u614B\u8207\u53EF\u7528\u77E5\u8B58\u5EAB"],
|
||||
detail
|
||||
);
|
||||
}
|
||||
return errorResponse(`portal_${res.status}`, `${what}\u5931\u6557\uFF08HTTP ${res.status}\uFF09`, ["\u7A0D\u5F8C\u91CD\u8A66"], detail);
|
||||
}
|
||||
|
||||
// mcp/src/tools/kbdb_data.ts
|
||||
var OWNER_IGNORED_HINT = "owner_id \u5728\u767B\u5165\u8EAB\u5206\u4E0B\u4E0D\u751F\u6548\uFF1A\u67E5\u8A62\u7BC4\u570D\u7531\u4F60\u7684\u5E33\u865F\u6B0A\u9650\u6C7A\u5B9A\uFF08\u8207\u4F60\u5728 portal \u7DB2\u9801\u770B\u5230\u7684\u4E00\u81F4\uFF09";
|
||||
function registerAllKbdbDataTools(server, env, identity) {
|
||||
@@ -32142,8 +32177,10 @@ function renderLibraryMapLines(libraries) {
|
||||
const narrative = (l.narrative ?? "").trim() || "\uFF08narrative \u5F85\u88DC\uFF09";
|
||||
const clipped = narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}\u2026` : narrative;
|
||||
const core = entityNames(l.top_entities, 3);
|
||||
const count = Number(l.triplet_count ?? 0) || 0;
|
||||
return `- ${l.library}\uFF1A${clipped}\uFF5C\u6838\u5FC3\uFF1A${core.length ? core.join("\u3001") : "\uFF08\u5C1A\u7121\uFF09"}\uFF5C${count} triplets`;
|
||||
const tripletCount = Number(l.triplet_count ?? 0) || 0;
|
||||
const entryCount = Number(l.entry_count ?? 0) || 0;
|
||||
const countPart = tripletCount > 0 ? `${tripletCount} triplets` : entryCount > 0 ? `0 triplets\uFF0F${entryCount} \u7B46\u539F\u59CB\u5167\u5BB9\uFF08\u5C1A\u672A\u8403\u53D6\u95DC\u4FC2\uFF0Ckbdb_search \u67E5\u5F97\u5230\uFF09` : `0 triplets\uFF0F0 \u5167\u5BB9`;
|
||||
return `- ${l.library}\uFF1A${clipped}\uFF5C\u6838\u5FC3\uFF1A${core.length ? core.join("\u3001") : "\uFF08\u5C1A\u7121\uFF09"}\uFF5C${countPart}`;
|
||||
});
|
||||
const omitted = rows.length > MAX_LIBRARY_LINES ? `
|
||||
\uFF08\u5176\u9918 ${rows.length - MAX_LIBRARY_LINES} \u5EAB\u7565\uFF0Ckbdb_get_map \u53EF\u770B\u5168\u90E8\uFF09` : "";
|
||||
@@ -32195,6 +32232,13 @@ var RECOMPUTE_HINTS = [
|
||||
function registerAllKbdbMapTools(server, env, identity) {
|
||||
registerGetMap(server, env, identity);
|
||||
}
|
||||
function entryOnlyHint(tripletCount, entryCount, library) {
|
||||
if (tripletCount > 0 || entryCount <= 0) return [];
|
||||
const lib = library ? `\u300C${library}\u300D` : "\u9019\u500B\u5EAB";
|
||||
return [
|
||||
`${lib} triplet_count\uFF1D0\uFF0C\u4F46\u6709 ${entryCount} \u7B46\u539F\u59CB\u5167\u5BB9\uFF08entries\uFF09\u2014\u2014\u4E09\u5143\u7D44\u8403\u53D6\u9084\u6C92\u5C0D\u5B83\u8DD1\u904E\uFF0C\u4E0D\u4EE3\u8868\u6C92\u6709\u77E5\u8B58\u3002\u7528 kbdb_search\uFF08\u95DC\u9375\u5B57\u6216\u8A9E\u7FA9\uFF09\u76F4\u63A5\u67E5\u5F97\u5230\u5167\u5BB9\u3002`
|
||||
];
|
||||
}
|
||||
function registerGetMap(server, env, identity) {
|
||||
server.tool(
|
||||
"kbdb_get_map",
|
||||
@@ -32229,7 +32273,8 @@ function registerGetMap(server, env, identity) {
|
||||
...l,
|
||||
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
|
||||
top_entities: entityNames(l.top_entities, 3),
|
||||
triplet_count: Number(l.triplet_count ?? 0) || 0
|
||||
triplet_count: Number(l.triplet_count ?? 0) || 0,
|
||||
entry_count: Number(l.entry_count ?? 0) || 0
|
||||
}));
|
||||
if (libraries.length === 0) {
|
||||
return successResponse({ libraries: [], count: 0 }, [
|
||||
@@ -32238,9 +32283,15 @@ function registerGetMap(server, env, identity) {
|
||||
...RECOMPUTE_HINTS
|
||||
]);
|
||||
}
|
||||
const entryOnlyLibs = libraries.filter(
|
||||
(l) => (l.triplet_count ?? 0) === 0 && (l.entry_count ?? 0) > 0
|
||||
);
|
||||
return successResponse({ libraries, count: libraries.length }, [
|
||||
"\u8981\u770B\u67D0\u5EAB\u7D30\u7BC0\uFF1Akbdb_get_map(library='\u5EAB\u540D')",
|
||||
"\u9032\u5EAB\u67E5\u5167\u5BB9\uFF1Akbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\uFF1B\u67E5\u95DC\u4FC2\uFF1Akbdb_graph_neighbors"
|
||||
"\u9032\u5EAB\u67E5\u5167\u5BB9\uFF1Akbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\uFF1B\u67E5\u95DC\u4FC2\uFF1Akbdb_graph_neighbors",
|
||||
...entryOnlyLibs.length > 0 ? [
|
||||
`${entryOnlyLibs.map((l) => l.library).join("\u3001")} \u9019\u5E7E\u5EAB triplet_count\uFF1D0 \u4F46 entry_count\uFF1E0\uFF1A\u6709\u539F\u59CB\u5167\u5BB9\uFF0C\u53EA\u662F\u9084\u6C92\u8403\u53D6\u51FA\u4E09\u5143\u7D44\u95DC\u4FC2\u2014\u2014\u5225\u628A 0 triplets \u8B80\u6210\u300C\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u76F4\u63A5 kbdb_search \u9032\u53BB\u67E5\u3002`
|
||||
] : []
|
||||
]);
|
||||
}
|
||||
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
|
||||
@@ -32268,11 +32319,13 @@ function registerGetMap(server, env, identity) {
|
||||
top_entities: parseSlotArray(raw2.top_entities),
|
||||
relation_profile: parseSlotArray(raw2.relation_profile),
|
||||
bridges: parseSlotArray(raw2.bridges),
|
||||
triplet_count: Number(raw2.triplet_count ?? 0) || 0
|
||||
triplet_count: Number(raw2.triplet_count ?? 0) || 0,
|
||||
entry_count: Number(raw2.entry_count ?? 0) || 0
|
||||
};
|
||||
return successResponse({ map: map2 }, [
|
||||
"bridges\uFF1D\u6B64\u5EAB entity \u540C\u6642\u51FA\u73FE\u5728\u54EA\u4E9B\u5176\u4ED6\u5EAB\uFF08\u53EA\u6709\u5169\u5074\u4E09\u5143\u7D44\u90FD\u6A19\u4E86 library \u503C\u624D\u6293\u5F97\u5230\uFF0C\u820A\u8CC7\u6599\u82E5\u6C92\u6A19\u6703\u504F\u7A00\u758F\uFF0C\u662F\u8AA0\u5BE6\u73FE\u6CC1\u4E0D\u662F bug\uFF09",
|
||||
"\u6CBF\u6838\u5FC3 entity \u6316\u95DC\u4FC2\uFF1Akbdb_graph_neighbors(subject=entity \u540D)"
|
||||
"\u6CBF\u6838\u5FC3 entity \u6316\u95DC\u4FC2\uFF1Akbdb_graph_neighbors(subject=entity \u540D)",
|
||||
...entryOnlyHint(map2.triplet_count, map2.entry_count, library)
|
||||
]);
|
||||
} catch (e) {
|
||||
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["\u7A0D\u5F8C\u91CD\u8A66"]);
|
||||
@@ -32340,7 +32393,7 @@ function registerAllTools(server, env, orgNamespace, partnerToken, identity) {
|
||||
registerReportFeedback(server, env, orgNamespace);
|
||||
registerAllIntrospectionTools(server, env);
|
||||
registerAllWorkflowCrudTools(server, env);
|
||||
registerAllSkillExampleTools(server, env);
|
||||
registerAllSkillExampleTools(server, env, identity);
|
||||
registerAllRecipeTools(server, env);
|
||||
registerAllKbdbDataTools(server, env, identity);
|
||||
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
|
||||
@@ -32349,7 +32402,47 @@ function registerAllTools(server, env, orgNamespace, partnerToken, identity) {
|
||||
}
|
||||
|
||||
// mcp/src/mcp-handler.ts
|
||||
async function handleMcpRequest(request, env, orgNamespace, partnerToken, identity) {
|
||||
var KNOWLEDGE_FIRST = [
|
||||
"\u3010\u9019\u689D\u9023\u7DDA\u4E0A\u6709\u4E3B\u4EBA\u7684\u77E5\u8B58\u5EAB\u2014\u2014\u5148\u67E5\u5B83\uFF0C\u518D\u67E5\u5225\u7684\u3011",
|
||||
"",
|
||||
"\u9019\u689D MCP \u9023\u7DDA\u5F8C\u9762\u63A5\u8457\u4E00\u500B **KBDB \u77E5\u8B58\u5EAB**\uFF1A\u9019\u53F0\u5BE6\u4F8B\u7684\u4E3B\u4EBA\u9577\u671F\u7D2F\u7A4D\u7684\u7B46\u8A18\u3001\u6C7A\u7B56\u3001",
|
||||
"\u8E29\u904E\u7684\u5751\u3001\u5C08\u6848\u73FE\u6CC1\u3001skill \u8207\u5DE5\u4F5C\u6D41\u7D00\u9304\uFF0C\u90FD\u5728\u88E1\u9762\u3002**\u4F60\u4E0D\u662F\u5F9E\u96F6\u958B\u59CB\u7684**\u2014\u2014",
|
||||
"\u4F60\u5C0D\u9019\u4E9B\u5C08\u6848\u7684\u5167\u5EFA\u5370\u8C61\u591A\u534A\u662F\u932F\u7684\u6216\u904E\u6642\u7684\uFF0C\u5EAB\u88E1\u90A3\u4EFD\u624D\u662F\u4E3B\u4EBA\u8A8D\u7684\u7248\u672C\u3002",
|
||||
"",
|
||||
"\u{1F534} **\u6709\u4EBA\u554F\u4F60\u300CX \u662F\u4EC0\u9EBC\uFF0F\u70BA\u4EC0\u9EBC\u9019\u6A23\u505A\uFF0F\u4E4B\u524D\u600E\u9EBC\u6C7A\u5B9A\u7684\uFF0F\u73FE\u5728\u505A\u5230\u54EA\u300D\u2014\u2014",
|
||||
"\u4F60\u7684\u7B2C\u4E00\u500B\u52D5\u4F5C\u662F `kbdb_search`\uFF0C\u4E0D\u662F grep \u539F\u59CB\u78BC\u3001\u4E0D\u662F\u4E0A\u7DB2\u641C\u3001\u4E0D\u662F\u56DE\u7B54\u300C\u6211\u4E0D\u77E5\u9053\u300D\u3002**",
|
||||
"",
|
||||
"- `kbdb_search({ q: \"Arcrun \u662F\u4EC0\u9EBC\" })` \u2014 \u95DC\u9375\u5B57\u67E5\uFF08\u9810\u8A2D `mode:'keyword'`\uFF0C\u57FA\u672C\u76E4\u6C38\u9060\u53EF\u7528\uFF09\u3002",
|
||||
" \u63DB\u5E7E\u7D44\u8B1B\u6CD5\u518D\u653E\u68C4\uFF1B\u60F3\u8981\u8A9E\u7FA9\u76F8\u4F3C\u5EA6\u7528 `mode:'semantic'`\u3002**\u9019\u4E00\u652F\u662F\u4F60\u7684\u7B2C\u4E00\u7AD9\u3002**",
|
||||
"- `kbdb_get_map()` \u2014 \u4E0D\u77E5\u9053\u8A72\u9032\u54EA\u500B\u5EAB\u6642\u5148\u770B\u85CF\u66F8\u5730\u5716\uFF08\u4E0B\u9762\u82E5\u6709\u3010\u85CF\u66F8\u5730\u5716\u3011\u5C31\u662F\u5B83\u7684\u5FEB\u7167\uFF09\u3002",
|
||||
'- `kbdb_graph_neighbors({ subject: "Arcrun" })` \u2014 \u67E5\u67D0\u500B\u6771\u897F\u8DDF\u8AB0\u6709\u95DC\u4FC2\uFF08\u4E09\u5143\u7D44\u904D\u6B77\uFF09\u3002',
|
||||
"- `kbdb_list_templates` / `kbdb_query` \u2014 \u6309 template \u53D6\u6574\u6279\u7D50\u69CB\u5316\u8CC7\u6599\u3002",
|
||||
"",
|
||||
"\u{1F534} **\u9019\u4E09\u4EF6\u4E8B\u4E0D\u53EF\u4EE5\u8B1B\u6210\u540C\u4E00\u53E5**\uFF08\u8B1B\u6210\u540C\u4E00\u53E5\u5C31\u662F\u5728\u9A19\u4EBA\uFF09\uFF1A",
|
||||
"\u2460 \u300C\u77E5\u8B58\u5EAB\u88E1\u6C92\u6709\u300D\u3000\u2461 \u300C\u6211\u6C92\u67E5\u300D\u3000\u2462 \u300C\u5730\u5716\u6C92\u53D6\u5230\uFF0F\u67D0\u5EAB\u986F\u793A 0\u300D\u3002",
|
||||
"\u67E5\u904E\u771F\u7684\u6C92\u6709 \u2192 \u660E\u8AAA\u300C\u77E5\u8B58\u5EAB\u88E1\u67E5\u4E0D\u5230\uFF0C\u4EE5\u4E0B\u662F\u6211\u5F9E\u539F\u59CB\u78BC\uFF0F\u7DB2\u8DEF\u63A8\u7684\u300D\uFF0C\u518D\u53BB\u8B80 code \u6216\u4E0A\u7DB2\u3002",
|
||||
"**\u6C92\u67E5\u5C31\u56DE\u7B54\uFF1D\u62FF\u4F60\u7684\u731C\u6E2C\u5192\u5145\u4E3B\u4EBA\u7684\u77E5\u8B58\uFF0C\u90A3\u662F\u9019\u689D\u9023\u7DDA\u4E0A\u6700\u56B4\u91CD\u7684\u932F\u3002**",
|
||||
"",
|
||||
"\u{1F534} **\u5730\u5716\u662F\u7D22\u5F15\uFF0C\u4E0D\u662F\u5EAB\u5B58\u6E05\u55AE**\uFF1A\u67D0\u5EAB\u986F\u793A `0 triplets`\u3001\u6216\u4E0B\u9762\u6574\u6BB5\u3010\u85CF\u66F8\u5730\u5716\u3011\u6C92\u51FA\u73FE\uFF0C",
|
||||
"\u90FD**\u4E0D\u4EE3\u8868**\u6C92\u6709\u77E5\u8B58\uFF08\u53EF\u80FD\u53EA\u662F\u9084\u6C92\u91CD\u7B97\u3001\u6216\u9019\u6B21\u6C92\u6293\u5230\uFF09\u3002\u8981\u77E5\u9053\u6709\u6C92\u6709\uFF0C\u53EA\u6709\u4E00\u500B\u65B9\u6CD5\uFF1A`kbdb_search` \u67E5\u904E\u3002",
|
||||
"\u540C\u7406\uFF0C\u4EFB\u4F55\u5DE5\u5177\u56DE 401\uFF0F\u9023\u4E0D\u4E0A\uFF0F\u6C92\u6B0A\u9650\uFF0C\u90A3\u662F**\u8B80\u4E0D\u5230**\uFF0C\u4E0D\u662F**\u4E0D\u5B58\u5728**\u2014\u2014\u7167\u5B83\u7D66\u7684 next_actions \u4FEE\uFF0C",
|
||||
"\u5225\u628A\u5B83\u6539\u53E3\u8B1B\u6210\u300C\u9019\u88E1\u6C92\u6709\u300D\uFF08`arcrun_get_skill` \u66FE\u628A KBDB \u7684 401 \u8B1B\u6210\u300Cskill \u4E0D\u5B58\u5728\u300D\uFF0C\u5C31\u662F\u9019\u500B\u75C5\uFF09\u3002"
|
||||
].join("\n");
|
||||
var MAP_UNAVAILABLE_NOTE = [
|
||||
"\u3010\u85CF\u66F8\u5730\u5716\uFF1A\u9019\u6B21\u6C92\u53D6\u5230\u3011",
|
||||
"\u5730\u5716\u6293\u53D6\u903E\u6642\uFF0F\u56DE\u932F\uFF0F\u6216\u5B83\u56DE\u5831\u7684\u6E05\u55AE\u662F\u7A7A\u7684\uFF08\u4E5F\u53EF\u80FD\u53EA\u662F\u9084\u6C92\u91CD\u7B97\u904E\uFF09\u3002",
|
||||
"\u{1F534} **\u9019\u662F\u300C\u5730\u5716\u6C92\u62FF\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u9019\u88E1\u6C92\u6709\u77E5\u8B58\u300D\u3002** \u4E0A\u9762\u90A3\u689D\u898F\u5247\u7167\u820A\uFF1A",
|
||||
"\u8981\u77E5\u9053\u5EAB\u88E1\u6709\u4EC0\u9EBC\uFF0C\u76F4\u63A5 `kbdb_search`\uFF1B\u60F3\u518D\u6293\u4E00\u6B21\u5730\u5716\u547C\u53EB `kbdb_get_map()`\uFF08\u5B83\u6703\u56DE\u5831\u771F\u6B63\u7684\u539F\u56E0\uFF09\u3002"
|
||||
].join("\n");
|
||||
var MAP_STALE_NOTE = [
|
||||
"\u3010\u85CF\u66F8\u5730\u5716\uFF1A\u62FF\u4E0D\u5230\uFF0C\u56E0\u70BA\u9019\u689D\u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\u3011",
|
||||
"\u9019\u689D\u9023\u7DDA\u7684 token \u6C92\u5E36\u767B\u5165\u8005\u8EAB\u5206\uFF0C`kbdb_*` \u6703\u56DE `identity_missing`\u3002",
|
||||
"\u{1F534} **\u9019\u4E0D\u4EE3\u8868\u77E5\u8B58\u5EAB\u662F\u7A7A\u7684**\u2014\u2014\u662F\u9019\u689D\u9023\u7DDA\u9084\u6C92\u8A8D\u5F97\u4F60\u3002",
|
||||
"\u4ECD\u7136\u5148\u547C\u53EB\u4E00\u6B21 `kbdb_search` \u78BA\u8A8D\u932F\u8AA4\u78BC\uFF1B\u82E5\u771F\u7684\u662F `identity_missing`\uFF0C",
|
||||
"\u8ACB\u4F7F\u7528\u8005\u5230 claude.ai \u2192 Settings \u2192 Connectors \u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09\uFF0C",
|
||||
"**\u4E0D\u8981\u6539\u53E3\u8AAA\u300C\u67E5\u4E0D\u5230\u8CC7\u6599\u300D\u6216\u81EA\u5DF1\u53BB\u731C\u7B54\u6848\u3002**"
|
||||
].join("\n");
|
||||
async function buildServerInstructions(env, identity) {
|
||||
const mapInstructions = await buildLibraryMapInstructions(env, identity);
|
||||
const startHere = [
|
||||
"# Arcrun \u2014 \u4F60\u5DF2\u7D93\u914D\u5099\u4E86\u9019\u5957\u5DE5\u5177\uFF0C\u5225\u4E0A\u7DB2\u627E",
|
||||
@@ -32363,9 +32456,13 @@ async function handleMcpRequest(request, env, orgNamespace, partnerToken, identi
|
||||
"",
|
||||
"1. `arcrun_get_skill('write_intent_workflow')` \u2014 **\u5FC5\u8B80\u7B2C\u4E00\u652F**\u3002",
|
||||
" \u6559\u4F60\u7528 `>>` \u5BEB\u300C\u610F\u5716\u5DE5\u4F5C\u6D41\u300D\u3002\u4F60**\u4E0D\u9700\u8981\u5148\u77E5\u9053\u6709\u54EA\u4E9B\u96F6\u4EF6**\uFF0C\u5148\u5BEB\u610F\u5716\u3002",
|
||||
" \u26A0\uFE0F \u9019\u652F\u82E5\u56DE\u932F\uFF08401\uFF0F\u9023\u4E0D\u4E0A\uFF0F`kbdb_unreachable`\uFF09\uFF0C\u90A3\u662F**\u9019\u689D\u9023\u7DDA\u8B80\u4E0D\u5230 KBDB**\uFF0C",
|
||||
" **\u4E0D\u662F skill \u4E0D\u5B58\u5728**\u2014\u2014\u540C\u4E00\u4EFD\u5167\u5BB9\u7528 `kbdb_search({ q: 'skill-write_intent_workflow' })` \u6488\u5F97\u5230\u3002",
|
||||
"2. `arcrun_whoami()` \u2014 \u78BA\u8A8D\u9023\u5230\u54EA\u500B\u5E33\u865F\uFF08\u52FF\u81EA\u884C curl \u731C\u5E33\u865F URL\uFF09\u3002",
|
||||
"3. \u628A\u610F\u5716\u4E1F `POST /cypher/search` \u6216 `arcrun_validate_yaml` \u2014 \u7CFB\u7D71\u544A\u8A34\u4F60\u54EA\u4E9B\u96F6\u4EF6\u5B58\u5728\u3002",
|
||||
"4. \u5361\u4F4F\uFF0F\u4E0D\u77E5\u9053\u8A72\u67E5\u4EC0\u9EBC \u2192 `arcrun_get_skill('INDEX')`\uFF08\u5168\u9928\u5C0E\u822A\uFF1A\u4EC0\u9EBC\u554F\u984C\u67E5\u54EA\u88E1\uFF0B\u5DF2\u77E5\u7684\u5751\uFF09\u3002",
|
||||
"4. \u5361\u4F4F\uFF0F\u4E0D\u77E5\u9053\u8A72\u67E5\u4EC0\u9EBC \u2192 \u5148 `arcrun_list_skills()` **\u770B\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709\u54EA\u5E7E\u652F**\uFF0C\u518D\u6311\u4E00\u652F\u8B80\u3002",
|
||||
" \uFF082026-08-13 \u5BE6\u6E2C\uFF1A\u4E0D\u540C\u5BE6\u4F8B seed \u7684 skill \u4E0D\u4E00\u6A23\uFF0C\u6709\u7684\u5BE6\u4F8B\u53EA\u6709\u5169\u652F\u3001\u9023 `INDEX` \u90FD\u6C92\u6709\u3002",
|
||||
" **\u4E0D\u8981\u7167\u6559\u6750\u76F4\u63A5\u6307\u540D\u4E00\u500B slug** \u2014\u2014\u5148\u5217\u6E05\u55AE\uFF0C\u6216 `kbdb_search({ q: 'skill' })` \u76F4\u63A5\u5728\u5EAB\u88E1\u627E\u3002\uFF09",
|
||||
"5. \u7F3A\u96F6\u4EF6\u6642\uFF1A\u7F3A API \u2192 \u5BEB recipe\uFF08`arcrun_recipe_push`\uFF09\uFF1B\u7F3A\u80FD\u529B \u2192 \u6295\u7A3F\u96F6\u4EF6 PR\u3002",
|
||||
" \u{1F534} **\u4E0D\u8981\u56E0\u70BA\u67E5\u4E0D\u5230\u96F6\u4EF6\u5C31\u6539\u5BEB\u6210 `code` \u7BC0\u9EDE**\u2014\u2014\u90A3\u53EB\u300C\u8179\u8A9E\u8853\u300D\uFF08\u8868\u9762\u7528 Arcrun\u3001",
|
||||
" \u5BE6\u969B\u5168\u5BEB JS\uFF09\u3002`code` \u53EA\u7528\u65BC\u5C40\u90E8\u6574\u5F62\uFF08\u4F8B\uFF1A\u525D\u6389 LLM \u56DE\u61C9\u7684\u96DC\u8A0A\uFF09\u3002",
|
||||
@@ -32381,11 +32478,11 @@ async function handleMcpRequest(request, env, orgNamespace, partnerToken, identi
|
||||
"\u4E0D\u662F\u5931\u6557\u2014\u2014\u5225\u56E0\u70BA\u300C\u53EA\u6709\u4E00\u689D\u8DEF\u6709\u8F38\u51FA\u300D\u5C31\u4EE5\u70BA\u58DE\u6389\u800C\u6539\u5BEB\u6210 code\uFF082026-08-01 \u5BE6\u649E\uFF09\u3002",
|
||||
"\u7B2C\u4E00\u500B\u7BC0\u9EDE\u56FA\u5B9A\u662F `input`\u3002"
|
||||
].join("\n");
|
||||
const instructions = mapInstructions ? `${startHere}
|
||||
|
||||
---
|
||||
|
||||
${mapInstructions}` : startHere;
|
||||
const mapSection = mapInstructions ?? (identity.kind === "stale" ? MAP_STALE_NOTE : MAP_UNAVAILABLE_NOTE);
|
||||
return [KNOWLEDGE_FIRST, startHere, mapSection].join("\n\n---\n\n");
|
||||
}
|
||||
async function handleMcpRequest(request, env, orgNamespace, partnerToken, identity) {
|
||||
const instructions = await buildServerInstructions(env, identity);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
|
||||
const server = new McpServer(
|
||||
{ name: "arcrun-mcp-server", version: "1.0.0" },
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+570
-31
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-12T14:22:56.880Z",
|
||||
"repo_head": "21293568d550ab7ef50cec2020165d8bf4376104",
|
||||
"generated_at": "2026-08-15T15:53:28.612Z",
|
||||
"repo_head": "cacaa33f7d4e51d0011096250a33bb5107c67074",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "53b05c6d3d4a5a02661880dd5565fa9feb743bb6",
|
||||
"source_commit": "9fa88065abdc5a795d3847711f04101480621af0",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 584721,
|
||||
"content_sha256": "b7c810d7654c05d197abe9a37acce2ed5f77e1af09595d5b4316b69166edf11a",
|
||||
"js_bytes": 590499,
|
||||
"content_sha256": "94a21ad012ee0c48d78e9a74752048917ddb4616f1ea089976834acd887a9d14",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -58,11 +58,11 @@
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"source_dir": "kbdb",
|
||||
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
|
||||
"source_commit": "ceb7638d746f0e8847552aef721f6f53ef87ff0b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 149797,
|
||||
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
|
||||
"js_bytes": 160306,
|
||||
"content_sha256": "47b09d60f8d122f7ec234d146a99bc68b2661c56ad6a98eca146d99ec5de03ea",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -84,6 +84,308 @@
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-code",
|
||||
"source_dir": "registry/components/code",
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153758,
|
||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "1ccee0055ebdb1f6d74094df3e807f56b849fa6f",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1192612,
|
||||
"content_sha256": "9bc8dffb34d0f36608a78b2cfeb8ee8b5c793f52b4497f5d4d5f69f70700972e",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"OAUTH_KV"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-array-ops",
|
||||
"source_dir": ".component-builds/array_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-array-ops/worker.mjs",
|
||||
"js_bytes": 67802,
|
||||
"content_sha256": "35ad5be6332dde2299efd10be726300c6cace1eb085ab24f08173536ca7fb950",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-array-ops/component.wasm",
|
||||
"sha256": "74dc9d99f8b9ef43628b55c85c05beb1af8f6f90d78d07cd132ffe4b865147ae"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "array_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-auth-oauth2",
|
||||
"source_dir": ".component-builds/auth_oauth2",
|
||||
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-auth-oauth2/worker.mjs",
|
||||
"js_bytes": 80472,
|
||||
"content_sha256": "a9b0d45ee6a63e0eebd45554df16d9fa4e6e717e0c31dac13b630e132f1a96db",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-auth-oauth2/component.wasm",
|
||||
"sha256": "3622c20644e6c8c39487109e7353e2590bad90b1207139e34dfb0cf1c2696664"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"CREDENTIALS_KV",
|
||||
"RECIPES"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "auth_oauth2"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-auth-service-account",
|
||||
"source_dir": ".component-builds/auth_service_account",
|
||||
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-auth-service-account/worker.mjs",
|
||||
"js_bytes": 81693,
|
||||
"content_sha256": "68675ec76f3f060309492e55301936a47f4ae0a5e9b00865eb861e0e631e40e5",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-auth-service-account/component.wasm",
|
||||
"sha256": "7d0808b4b9a2df4fbb54977eafe34a5bdb99e0cc941d97462c62e26f3651951e"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"CREDENTIALS_KV",
|
||||
"RECIPES"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "auth_service_account"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-auth-static-key",
|
||||
"source_dir": ".component-builds/auth_static_key",
|
||||
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-auth-static-key/worker.mjs",
|
||||
"js_bytes": 81217,
|
||||
"content_sha256": "140c163d2e8c609eebf281de25d16e521d85004f8437cc842613c249e44aa7ff",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-auth-static-key/component.wasm",
|
||||
"sha256": "cd0f69a80ced0979749e0ec30b1981795a49eaa56bdb2ba102cd4b64f4670369"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"CREDENTIALS_KV",
|
||||
"RECIPES"
|
||||
],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "auth_static_key"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-cron",
|
||||
"source_dir": ".component-builds/cron",
|
||||
"source_commit": "6e92ca03727bf22143499d6c0df06b40cb65c17b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cron/worker.mjs",
|
||||
"js_bytes": 78911,
|
||||
"content_sha256": "17068f03f686aee6c0aec9d7d748bbb1998edc8918260033f985697bc718aa76",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-cron/component.wasm",
|
||||
"sha256": "afb3a14f8c8f00f4f5573f44153cb1dcf6fc715b2fb47c2afb0aaf95b74d3cde"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "cron"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-date-ops",
|
||||
"source_dir": ".component-builds/date_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-date-ops/worker.mjs",
|
||||
"js_bytes": 67781,
|
||||
"content_sha256": "60ebc1d17ab0b9d3bb66f1c71ec63eb5d35bc97ed9f8b4cd1c1c95f4eeb171b7",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-date-ops/component.wasm",
|
||||
"sha256": "4e2c1329ce4014aa86a9a6449d8699e67c6c616f5c07088dc21216d4036ca41e"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "date_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-filter",
|
||||
"source_dir": ".component-builds/filter",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-filter/worker.mjs",
|
||||
"js_bytes": 67739,
|
||||
"content_sha256": "c7fd5070ff7c0f0341e9dd364805e393f9d68056a846f48e6bc61ef6f4e67187",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-filter/component.wasm",
|
||||
"sha256": "6a0692b386522918b3b5af79cf7877394d1f02c350018dbaef68ef380dad1b9e"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "filter"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-foreach-control",
|
||||
"source_dir": ".component-builds/foreach_control",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-foreach-control/worker.mjs",
|
||||
"js_bytes": 67928,
|
||||
"content_sha256": "e52410c9611a93ac7fe38c7b11291cc18711c7da3370492788c3c55bc0fb03d5",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-foreach-control/component.wasm",
|
||||
"sha256": "8ae161ab37cc78ef6fae65364a47f7accb572378b395c4637daa2ffa57f14632"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "foreach_control"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-http-request",
|
||||
"source_dir": ".component-builds/http_request",
|
||||
@@ -117,19 +419,19 @@
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-code",
|
||||
"source_dir": "registry/components/code",
|
||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||
"name": "arcrun-if-control",
|
||||
"source_dir": ".component-builds/if_control",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-code/worker.mjs",
|
||||
"js_bytes": 153758,
|
||||
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||
"main_file": "arcrun-if-control/worker.mjs",
|
||||
"js_bytes": 67823,
|
||||
"content_sha256": "557ecf4ee955105af07b2227b08365eb9de8c39ad8ffb323b101397704f438bd",
|
||||
"modules": [
|
||||
{
|
||||
"name": "quickjs.wasm",
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-code/quickjs.wasm",
|
||||
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
|
||||
"file": "arcrun-if-control/component.wasm",
|
||||
"sha256": "87c81e9cf5e664f60c06a364508dd365d49953e1b7afba6a5753fce5a4c82636"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
@@ -140,35 +442,272 @@
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "code"
|
||||
"COMPONENT_ID": "if_control"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
|
||||
"name": "arcrun-merge",
|
||||
"source_dir": ".component-builds/merge",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1179487,
|
||||
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
"nodejs_compat"
|
||||
"main_file": "arcrun-merge/worker.mjs",
|
||||
"js_bytes": 67718,
|
||||
"content_sha256": "e4216249436000bbb4db0318cc9028f936ca6537d3d656228cfdd3cb31df25f8",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-merge/component.wasm",
|
||||
"sha256": "55a4d143246df172f60b5e15e7b26e55279deda9db436e3d65bccc25c86d7cd9"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [
|
||||
"OAUTH_KV"
|
||||
],
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "merge"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-number-ops",
|
||||
"source_dir": ".component-builds/number_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-number-ops/worker.mjs",
|
||||
"js_bytes": 67823,
|
||||
"content_sha256": "91821a291117e033bfa4f013cd88de09479cd4ac43708e37a39df3f31c72aaa6",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-number-ops/component.wasm",
|
||||
"sha256": "452d1940f6e2b2b36dc97c145abcb7b5bacdbfa7631c742827c2f6f6db87b4ea"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "number_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-set",
|
||||
"source_dir": ".component-builds/set",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-set/worker.mjs",
|
||||
"js_bytes": 67676,
|
||||
"content_sha256": "6cac587802cae7dc1651d6b461232abafe40dcf0fbfe9e0adc5b1d8e35b41cc1",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-set/component.wasm",
|
||||
"sha256": "f5afcf6e57401d2d83b21fb971ec80ca56d5e8e4bdb74a004f3d118875ed57c4"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "set"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-string-ops",
|
||||
"source_dir": ".component-builds/string_ops",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-string-ops/worker.mjs",
|
||||
"js_bytes": 67823,
|
||||
"content_sha256": "c85166e357b76828c7020426d912b591006152ac38f024a095a4d9dcd10dee72",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-string-ops/component.wasm",
|
||||
"sha256": "41f3243ee18bb9f4fcbaa417bd773347128dec2971132e240eddec19ba6a9da2"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "string_ops"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-switch",
|
||||
"source_dir": ".component-builds/switch",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-switch/worker.mjs",
|
||||
"js_bytes": 67739,
|
||||
"content_sha256": "e0e7689dbba735a254556272d6d233396c7fa05cc0fba5835a3e20b0ddf00fca",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-switch/component.wasm",
|
||||
"sha256": "6d993dc9b1affe16b600aaa7cae5af977108cc08513bb11d9d9db908285fea8b"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "switch"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-try-catch",
|
||||
"source_dir": ".component-builds/try_catch",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-try-catch/worker.mjs",
|
||||
"js_bytes": 67802,
|
||||
"content_sha256": "a5fbcbf452ab00269dc857997df3059bee7eb1f2c1b322d249146ab4e5e5bac9",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-try-catch/component.wasm",
|
||||
"sha256": "10eaddc5ef0d3dc68e3f009970541ac7f5fce30dcdbecec9a333afd5a29e3486"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "try_catch"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-validate-json",
|
||||
"source_dir": ".component-builds/validate_json",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-validate-json/worker.mjs",
|
||||
"js_bytes": 67886,
|
||||
"content_sha256": "2d0efa26e9fe38db87eb9585daf72e0f2ba8c859d15d1b65f36798656cc4e83b",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-validate-json/component.wasm",
|
||||
"sha256": "8142ffd3cc57efbb9128faaf450b646111b0f2e2575cc29a7b562a7b185912ab"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "validate_json"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-wait",
|
||||
"source_dir": ".component-builds/wait",
|
||||
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-wait/worker.mjs",
|
||||
"js_bytes": 67697,
|
||||
"content_sha256": "3d1b46e7b1ff056abb2c1aeed738d23d58eaa7c8f36b674e4ea2ed1602015f9e",
|
||||
"modules": [
|
||||
{
|
||||
"name": "component.wasm",
|
||||
"type": "application/wasm",
|
||||
"file": "arcrun-wait/component.wasm",
|
||||
"sha256": "52ca2d184bf3dcc646bb94f29f7f3e69b5540276363e83a54cb435ff33ca2f3c"
|
||||
}
|
||||
],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {
|
||||
"COMPONENT_ID": "wait"
|
||||
}
|
||||
},
|
||||
"warnings": []
|
||||
},
|
||||
{
|
||||
"name": "arcrun-rag-ui",
|
||||
"source_dir": "console-ui/public",
|
||||
"source_commit": "614fe448124dbd25d8228e37723473f1704f3a7a",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-rag-ui/worker.mjs",
|
||||
"js_bytes": 524513,
|
||||
"content_sha256": "5825f427e4211dd4b0eee969c94c8bfa856f65b8a48fc718e137f3b5d074e1ff",
|
||||
"modules": [],
|
||||
"compat_date": "2026-07-01",
|
||||
"compat_flags": [],
|
||||
"requires": {
|
||||
"kv": [],
|
||||
"d1": [],
|
||||
"vectorize": 0,
|
||||
"ai": false,
|
||||
"vars": {}
|
||||
},
|
||||
"ui_fingerprint": "82bc636990e60172663f7ce90c75d8b434193b8532b21f1d4b57c5fb330cbe11",
|
||||
"ui_file_count": 9,
|
||||
"warnings": []
|
||||
}
|
||||
],
|
||||
"excluded": [
|
||||
{
|
||||
"name": "arcrun-claude-api",
|
||||
"dir": ".component-builds/claude_api",
|
||||
"reason": "component.wasm 不在版控(.gitignore 明文排除=這顆不是可出貨的零件)"
|
||||
}
|
||||
],
|
||||
"notes": []
|
||||
}
|
||||
@@ -65,15 +65,26 @@
|
||||
|
||||
## 🔴 第一鐵律:wiki 是判準,不准跳過(2026-07-20/21 leo 兩度點破)
|
||||
|
||||
**要查任何東西之前,先搜尋 wiki——用 grep,不是只讀開頭幾行。**
|
||||
**要查任何東西之前,先照索引走:`system-dev/wiki/INDEX.md` → 下層 `00-INDEX.md` → 卡片。**
|
||||
|
||||
🔴 **2026-08-15 leo 糾正:本條原本寫的是「用 grep」,那是錯的。**
|
||||
grep 是平面搜尋,它繞過索引,於是**索引永遠不會被驗證、也永遠不會變準**——
|
||||
而 AI 又用「索引可能不準」當理由繼續 grep。這是自我實現的。
|
||||
實測那天:`matrix/arcrun` 的 wiki 有 64 張卡,**只有 1 張走得到索引**
|
||||
(唯一那張是 leo 手寫的;63 張機器產的卡從沒加入任何索引),
|
||||
而 AI 從沒發現——**因為 grep 找得到,破損就永遠不會浮現。**
|
||||
|
||||
**grep 不是禁令,是異常訊號**:
|
||||
1. 先照索引走
|
||||
2. 索引走不到 → **那是索引壞了,先講出來**(哪一層缺、缺什麼)
|
||||
3. 講完才准 fallback 用 `grep -rin "<關鍵字>" system-dev/wiki/`
|
||||
|
||||
⇒ 每一次 grep 都該留下一筆「索引缺陷」,而不是變成習慣。
|
||||
(全面禁止 grep 也是錯的——索引壞掉那天 AI 會瞎掉,而且沒人知道。)
|
||||
|
||||
> leo:「花很多力氣去產生 wiki,最重要的就是要可以查詢,**結果要查的時候就跳過,那就白寫了**。」
|
||||
> 「重點是你自己的記憶對嗎?而你有按照規定去切實讀 wiki 嗎?」
|
||||
|
||||
```bash
|
||||
grep -rin "<本題關鍵字>" system-dev/wiki/
|
||||
```
|
||||
|
||||
**三條硬規則**:
|
||||
1. **wiki 與程式碼/歷史文件衝突 → 以 wiki 為準**。程式碼反映「還沒清乾淨」,不等於「還在用」。
|
||||
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。那是當時狀態,不是永久禁令。
|
||||
|
||||
@@ -17,6 +17,7 @@ import { loadConfig } from '../lib/config.js';
|
||||
import {
|
||||
wranglerAvailable,
|
||||
downloadAndDeploy,
|
||||
namespaceHasKnowledge,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
|
||||
@@ -63,6 +64,32 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
kbdbEmbed: config.kbdb_embed !== false,
|
||||
};
|
||||
|
||||
// Arcrun#108:把「你的知識住在哪個命名空間」同步給雲端——但**先驗再寫**。
|
||||
//
|
||||
// 病灶:你 push 工作流、小幫手上傳知識、MCP 查詢,用的都是 config 的 `api_key`;
|
||||
// 而 cypher 讀藏書地圖/搜尋/工作流時,過濾用的 owner_id 來自 worker 的環境變數
|
||||
// (repo toml 帶的官方預設 `CONSOLE_TENANT = "leo"`)。兩個來源對不上 ⇒ 你的東西全被濾掉。
|
||||
//
|
||||
// 為什麼不無條件寫:一鍵安裝的實例,知識可能本來就寫在 `CONSOLE_TENANT` 底下。
|
||||
// 無條件蓋成本機 api_key,會把一台**原本正常**的實例指向空的那一格
|
||||
// ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」。所以查得到才寫,查不到就不碰。
|
||||
if (config.api_key && config.cypher_executor_url) {
|
||||
process.stdout.write(chalk.gray(' → 核對雲端要用哪個知識命名空間...'));
|
||||
const hasKnowledge = await namespaceHasKnowledge(config.cypher_executor_url, config.api_key);
|
||||
if (hasKnowledge === true) {
|
||||
ctx.knowledgeNamespace = config.api_key;
|
||||
console.log(chalk.green(' ✓'));
|
||||
console.log(chalk.gray(` ARCRUN_NAMESPACE = ${config.api_key}(這個命名空間底下查得到你的知識庫)`));
|
||||
} else if (hasKnowledge === false) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(` ${config.api_key} 底下目前查不到任何知識庫 → 這趟不動雲端的命名空間設定`));
|
||||
console.log(chalk.gray(' (若藏書地圖是空的,請把這行連同 acr update 的輸出一起回報)'));
|
||||
} else {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(' 問不到實例(可能正在啟動或版本較舊)→ 這趟不動雲端的命名空間設定'));
|
||||
}
|
||||
}
|
||||
|
||||
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
|
||||
//(Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
|
||||
|
||||
@@ -298,6 +298,44 @@ export interface DeployContext {
|
||||
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
|
||||
// base 維持 LIKE keyword(free-tier 友善)。
|
||||
kbdbEmbed?: boolean;
|
||||
/**
|
||||
* Arcrun#108:這台實例的知識命名空間(=`~/.arcrun/config.yaml` 的 `api_key`),
|
||||
* 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var,讓「讀」用的 owner_id 與「寫」的一致。
|
||||
*
|
||||
* **只在驗證過該 namespace 底下真的有知識時才給值**(見 `resolveKnowledgeNamespace`)——
|
||||
* 給了就會覆蓋 worker 上的既有值,沒給則原封保留(preservedVars)。
|
||||
*/
|
||||
knowledgeNamespace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這把 namespace 底下到底有沒有知識?(Arcrun#108 的「先驗再寫」)
|
||||
*
|
||||
* 打的是實例自己的 `GET /kbdb/map?owner_id=<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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,6 +599,21 @@ export async function downloadAndDeploy(
|
||||
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
|
||||
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
|
||||
}
|
||||
// Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。
|
||||
//
|
||||
// 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是
|
||||
// repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、
|
||||
// MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
|
||||
//(leo 實撞:1854 條三元組被過濾成 0 個庫)。
|
||||
//
|
||||
// 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見
|
||||
// resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能
|
||||
// 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常**
|
||||
// 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
|
||||
// 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。
|
||||
if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
|
||||
vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||||
}
|
||||
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
|
||||
}
|
||||
if (preservedTotal.length > 0) {
|
||||
@@ -668,6 +721,22 @@ export async function downloadAndDeploy(
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql(${execLogMigPath})`);
|
||||
}
|
||||
|
||||
// 3.8 樹狀 record 模型(0007,v7 定稿 2026-08-15):record 有身分、關係是唯一機制、
|
||||
// entry_values 拆表。**必須排在所有 template seed 之後**(它把 templates 表既有列
|
||||
// 鏡射成池中 sheet/field entry)。逐句套用+容錯 duplicate column:檔內三句
|
||||
// ADD COLUMN 在 SQLite 沒有 IF NOT EXISTS 形式,重跑(每次部署都會重跑本段)時
|
||||
// 那三句報 duplicate column = 已套用,其餘語句全部語句級冪等(檔頭有完整說明)。
|
||||
const treeMigPath = join(root, 'kbdb', 'migrations', '0007_tree_record_model.sql');
|
||||
if (existsSync(treeMigPath)) {
|
||||
try {
|
||||
await applyD1MigrationTolerant(ctx, readFileSync(treeMigPath, 'utf8'));
|
||||
} catch (e) {
|
||||
failures.push(`D1 migration 0007_tree_record_model (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0007_tree_record_model.sql(${treeMigPath})`);
|
||||
}
|
||||
}
|
||||
|
||||
const cypherExecutorUrl = ctx.workerSubdomain
|
||||
@@ -698,6 +767,35 @@ export async function downloadAndDeploy(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐句套 migration,容錯 duplicate column(0007 專用)。
|
||||
*
|
||||
* 為什麼不能走 applyD1Migration 整檔送:/query 端點任何一句失敗整批中止——
|
||||
* 0007 的三句 ADD COLUMN 在重跑時必然報 duplicate column(SQLite 沒有欄位級
|
||||
* IF NOT EXISTS),整檔送 ⇒ 第二次部署起 migration 永遠假紅、後面的資料搬遷
|
||||
* 語句永遠不被執行。逐句+把 duplicate column 視為「已套用」,其餘錯誤照樣拋。
|
||||
* 切句手法與安裝器 compile-migrations.mjs 同款(剝 -- 註解、依分號切;
|
||||
* 0007 的字串常值不含分號,前提成立)。
|
||||
*/
|
||||
async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise<void> {
|
||||
const statements = sql
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/--.*$/, ''))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
await applyD1Migration(ctx, stmt);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
|
||||
throw new Error(`${stmt.slice(0, 60)}… → ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 對 D1 套 SQL migration(透過 CF API `/d1/database/{id}/query`,非 wrangler)。
|
||||
* 用 init 已驗的 ctx.apiToken + accountId;query 端點接受多語句檔,一次送整份 0001_base.sql。
|
||||
|
||||
@@ -24,6 +24,19 @@ import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
/**
|
||||
* 清單端點每頁抓幾筆。100 是 CF 這幾支端點通用的安全上限(KV 官方上限就是 100)。
|
||||
* 這個數字**不影響正確性**——`cfListAll` 會一直翻到底;它只決定要打幾次 API。
|
||||
*/
|
||||
const LIST_PER_PAGE = 100;
|
||||
|
||||
/**
|
||||
* 翻頁的安全上限。100 頁 × 100 筆 = 10,000 顆,遠超 CF 的帳號上限
|
||||
* (KV namespace 每帳號 1,000)⇒ 正常帳號永遠碰不到。
|
||||
* 碰到了就是 CF 那邊的行為變了,這種時候**寧可 throw 也不回一份不完整的清單**。
|
||||
*/
|
||||
const LIST_MAX_PAGES = 100;
|
||||
|
||||
/**
|
||||
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
|
||||
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
|
||||
@@ -57,9 +70,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
|
||||
/**
|
||||
* 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。
|
||||
* `resultInfo` = CF 回應裡的 `result_info`(不分頁的端點是 `null`),`cfListAll` 靠它翻頁。
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
|
||||
* @returns {Promise<{ok: boolean, status: number, result?: any, resultInfo?: any, error?: string}>}
|
||||
*/
|
||||
async function cfRaw(path, init) {
|
||||
const res = await doFetch(`${accountBase}${path}`, {
|
||||
@@ -76,7 +90,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
`HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, status: res.status, result: data.result };
|
||||
return { ok: true, status: res.status, result: data.result, resultInfo: data?.result_info ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +104,75 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一支「列出帳號上有什麼」的端點**翻到底**,回傳全部項目。
|
||||
*
|
||||
* 【為什麼非翻不可——這是 Arcrun#123 的續集,不是效能優化】
|
||||
* 三支清單方法原本只打 `?per_page=100`,也就是**只看第一頁**。同一個截斷,
|
||||
* 在 #123 的修法前後,後果**不一樣**:
|
||||
*
|
||||
* | 被截掉的那顆 | 規則走到哪 | 結果 |
|
||||
* |---|---|---|
|
||||
* | #123 修好**前**:worker 綁著它,但它落在第二頁 | 2b 判「綁著的資源不見了」 | 產生 blocker,**停手**(過度保守,但安全) |
|
||||
* | #123 修好**後**:名字落在第二頁 | 2c 判「這個名字沒被佔走」 | **去建 → CF 回 title already exists ⇒ #123 的死路原樣回來** |
|
||||
*
|
||||
* ⇒ 修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。所以規約是:
|
||||
* **看不完整就不准當作看完了**——翻不完、或翻出來的數量對不上 CF 自己回報的
|
||||
* `total_count`,一律 throw,讓 `planResources` 把它變成 blocker
|
||||
* (README 規則第 3 條:說不準就整趟停手,一顆都不建)。
|
||||
*
|
||||
* 【三支端點的分頁行為不一樣,這裡刻意不假設它們同款】(2026-08-14 在 geek6688 帳號實測)
|
||||
* - `/storage/kv/namespaces`:真分頁,`result_info` = `{page, per_page, count, total_count, total_pages}`
|
||||
* - `/d1/database`:真分頁,但 `result_info` **沒有 `total_pages`**(實測 `{page, per_page, count, total_count}`)
|
||||
* ⇒ **不准拿 `total_pages` 當終止條件**,那個欄位在 D1 上是 `undefined`
|
||||
* - `/vectorize/v2/indexes`:**不分頁**,`result_info` 是 `null`,帶 `page`/`per_page` 也被忽略(一次回全部)
|
||||
*
|
||||
* 所以終止條件只用「三支都有、或三支都沒有」的兩件事:`result_info` 在不在、`total_count` 對不對得上。
|
||||
* 對不分頁的那支,這支等於只打一次就回來(那兩個被忽略的參數實測無害);
|
||||
* 而萬一 CF 哪天替它補上分頁,這支會自己跟著翻——不必等下一次災情才想起來改。
|
||||
*
|
||||
* @param {string} path 不含分頁參數的端點路徑(可自帶其他 query)
|
||||
* @param {string} what 出錯訊息裡怎麼稱呼它
|
||||
* @returns {Promise<any[]>}
|
||||
*/
|
||||
async function cfListAll(path, what) {
|
||||
/** @type {any[]} */
|
||||
const items = [];
|
||||
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
|
||||
const sep = path.includes('?') ? '&' : '?';
|
||||
const res = await cfRaw(`${path}${sep}per_page=${LIST_PER_PAGE}&page=${page}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`列 ${what} 失敗(第 ${page} 頁):${res.error ?? `HTTP ${res.status}`}`);
|
||||
}
|
||||
const batch = Array.isArray(res.result) ? res.result : [];
|
||||
items.push(...batch);
|
||||
|
||||
const info = res.resultInfo;
|
||||
// 這支端點沒有分頁(Vectorize v2)⇒ 這一趟拿到的就是全部。
|
||||
if (!info) return items;
|
||||
|
||||
const total = Number(info.total_count);
|
||||
if (Number.isFinite(total)) {
|
||||
if (items.length >= total) return items;
|
||||
// CF 說還有,卻一筆都不給 ⇒ 我們看不到全部。**不准安靜地當作看完了。**
|
||||
if (batch.length === 0) {
|
||||
throw new Error(
|
||||
`列 ${what} 只讀到 ${items.length} 筆,但 Cloudflare 說共有 ${total} 筆,第 ${page} 頁卻是空的。` +
|
||||
`看不到帳號上的全部資源就沒辦法判斷該不該新建——停手。`,
|
||||
);
|
||||
}
|
||||
continue; // total_count 說還有就繼續翻(不看 total_pages:D1 根本沒這個欄位)
|
||||
}
|
||||
|
||||
// 沒有 total_count 可對,只剩「這一頁沒裝滿 ⇒ 沒有下一頁」可用。
|
||||
if (batch.length < LIST_PER_PAGE) return items;
|
||||
}
|
||||
throw new Error(
|
||||
`列 ${what} 翻超過 ${LIST_MAX_PAGES} 頁還沒到底(已讀 ${items.length} 筆)。` +
|
||||
`這不正常,寧可停手,也不拿一份不完整的清單去判斷該不該新建資源。`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cfRaw,
|
||||
|
||||
@@ -122,7 +205,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
/** @returns {Promise<Map<string, string>>} title → id */
|
||||
async listKvNamespaces() {
|
||||
/** @type {Array<{id: string, title: string}>} */
|
||||
const result = await cf('/storage/kv/namespaces?per_page=100');
|
||||
// 翻到底才算數(只看第一頁會讓 Arcrun#123 安靜復發,理由見 cfListAll)
|
||||
const result = await cfListAll('/storage/kv/namespaces', 'KV namespace');
|
||||
const map = new Map();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
@@ -131,7 +215,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
/** @returns {Promise<Map<string, string>>} name → uuid */
|
||||
async listD1Databases() {
|
||||
/** @type {Array<{uuid: string, name: string}>} */
|
||||
const result = await cf('/d1/database?per_page=100');
|
||||
// 翻到底才算數。D1 的 result_info **沒有 total_pages**,所以終止條件只認 total_count。
|
||||
const result = await cfListAll('/d1/database', 'D1 資料庫');
|
||||
const map = new Map();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
@@ -140,8 +225,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
|
||||
/** @returns {Promise<string[]>} */
|
||||
async listVectorizeIndexes() {
|
||||
/** @type {Array<{name: string}>} */
|
||||
const result = await cf('/vectorize/v2/indexes');
|
||||
return (result ?? []).map((i) => i.name);
|
||||
// 這支端點**目前不分頁**(`result_info` 是 null),走 cfListAll 等同只打一次;
|
||||
// 但 CF 哪天替它補上分頁,這裡會自己跟著翻,不必等下一次災情才想起來改。
|
||||
const result = await cfListAll('/vectorize/v2/indexes', 'Vectorize index');
|
||||
return result.map((i) => i.name);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,6 +106,19 @@
|
||||
* @property {string} binding
|
||||
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
|
||||
* @property {string} createName
|
||||
* @property {boolean} [createNameIsOurs]
|
||||
* 呼叫端在此**聲明**:`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
|
||||
* ⇒ 帳號上若已經有一顆**恰好同名**的資源,它只可能是我們上一次沒裝完留下的(Arcrun#123)。
|
||||
*
|
||||
* 🔴 這個聲明是「接管同名資源」的**唯一**依據,預設 false(fail-closed)。
|
||||
* 只有在名字**推導得出、而且推導的輸入是使用者自己的身分**時才准聲明 true——
|
||||
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
|
||||
* (slug = `SHA-256('arcrun-rag:' + email)` 取前 8 碼,同一個 email 每次算出同一組名字,
|
||||
* 別人算不到、也不會不小心撞上)。
|
||||
*
|
||||
* ⚠️ **不准**因為「名字看起來像我們的」就聲明 true。`acr` 那條從 wrangler.toml 讀到的
|
||||
* createName 是裸 binding 名(`WEBHOOKS`)或 toml 宣告的庫名(`arcrun-kbdb`)——
|
||||
* 那種名字使用者自己也可能拿去用,**證明不了是我們的**,所以那條路一律不聲明。
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -113,7 +126,12 @@
|
||||
* @property {ResourceKind} kind
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {string} from 從哪顆已部署的 worker 上讀到的
|
||||
* @property {string} from 從哪顆已部署的 worker 上讀到的。`reclaimed` 時為空字串——
|
||||
* **沒有任何 worker 綁著它正是接收它的前提**(Arcrun#123),不是漏填。
|
||||
* @property {boolean} [reclaimed]
|
||||
* true = 這顆不是從某顆 worker 的綁定讀出來的,而是「帳號上已經有一顆我們自己命名的同名資源、
|
||||
* 卻沒有人綁著」⇒ 上一次沒裝完留下的,這次把它接回來用(Arcrun#123)。
|
||||
* 給呼叫端做診斷/統計用;**使用者不必知道「殘骸」這個詞**,對外一律講「沿用你原本的資源」。
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -144,7 +162,11 @@
|
||||
* @property {string} binding
|
||||
* @property {string} value
|
||||
* @property {'adopted' | 'created'} origin
|
||||
* 🔴 接回上次沒裝完留下的那顆(`reclaimed`)**仍然算 `adopted`**,不另開第三種值——
|
||||
* 它本來就是「沿用既有資源」,而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
|
||||
* (安裝器那句「沿用你原本的 N 項資源」)不會因為多一種值就悄悄漏數。
|
||||
* @property {string} [from]
|
||||
* @property {boolean} [reclaimed] 見 PlannedAdopt.reclaimed(Arcrun#123)。
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -251,19 +273,25 @@ export async function planResources(api, requirements, mode) {
|
||||
else byKey.set(key, [req]);
|
||||
}
|
||||
|
||||
/** @type {Map<ResourceKind, Set<string>>} */
|
||||
// 帳號上現有的資源,一種只查一次。**名字 → 身分**(KV/D1 是 id,Vectorize 的身分就是名字)。
|
||||
//
|
||||
// 為什麼連名字都收下來(本來只留 `.values()`):
|
||||
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
|
||||
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
|
||||
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
|
||||
/** @type {Map<ResourceKind, Map<string, string>>} */
|
||||
const existingCache = new Map();
|
||||
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
|
||||
const listExisting = async (kind) => {
|
||||
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
|
||||
const listExistingByName = async (kind) => {
|
||||
const hit = existingCache.get(kind);
|
||||
if (hit) return hit;
|
||||
/** @type {Set<string>} */
|
||||
let set;
|
||||
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
|
||||
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
|
||||
else set = new Set(await api.listVectorizeIndexes());
|
||||
existingCache.set(kind, set);
|
||||
return set;
|
||||
/** @type {Map<string, string>} */
|
||||
let map;
|
||||
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
|
||||
else if (kind === 'd1') map = await api.listD1Databases();
|
||||
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
|
||||
existingCache.set(kind, map);
|
||||
return map;
|
||||
};
|
||||
|
||||
for (const [, reqs] of byKey) {
|
||||
@@ -291,10 +319,10 @@ export async function planResources(api, requirements, mode) {
|
||||
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
||||
if (distinct.length === 1) {
|
||||
const value = distinct[0];
|
||||
/** @type {Set<string>} */
|
||||
/** @type {Map<string, string>} */
|
||||
let existing;
|
||||
try {
|
||||
existing = await listExisting(kind);
|
||||
existing = await listExistingByName(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||
@@ -302,7 +330,7 @@ export async function planResources(api, requirements, mode) {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!existing.has(value)) {
|
||||
if (![...existing.values()].includes(value)) {
|
||||
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
||||
blockers.push(
|
||||
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
||||
@@ -316,12 +344,62 @@ export async function planResources(api, requirements, mode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
|
||||
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
|
||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding、全新帳號,
|
||||
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
|
||||
//
|
||||
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
|
||||
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
|
||||
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
|
||||
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
|
||||
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
|
||||
//
|
||||
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
|
||||
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
|
||||
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
|
||||
// ——會弄丟資料的是那個動作,不是這個。
|
||||
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
|
||||
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
|
||||
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
|
||||
// (`createNameIsOurs`),沒聲明就停手。
|
||||
/** @type {Map<string, string>} */
|
||||
let existingByName;
|
||||
try {
|
||||
existingByName = await listExistingByName(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
|
||||
`(${msg(e)})。不確定就不建——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const createName = reqs[0].createName;
|
||||
const sameName = existingByName.get(createName);
|
||||
if (sameName !== undefined) {
|
||||
if (!reqs[0].createNameIsOurs) {
|
||||
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
|
||||
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
|
||||
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
|
||||
blockers.push(
|
||||
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]},` +
|
||||
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
|
||||
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
|
||||
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
|
||||
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
|
||||
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
|
||||
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
|
||||
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
create.push({
|
||||
kind,
|
||||
binding,
|
||||
createName: reqs[0].createName,
|
||||
createName,
|
||||
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
||||
alsoBind: [],
|
||||
});
|
||||
@@ -401,6 +479,7 @@ export async function applyResourcePlan(api, plan) {
|
||||
value: a.value,
|
||||
origin: 'adopted',
|
||||
from: a.from,
|
||||
...(a.reclaimed ? { reclaimed: true } : {}),
|
||||
});
|
||||
}
|
||||
/** @type {string[]} */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -135,6 +135,13 @@ class FakeCloudflare implements ResourceApi {
|
||||
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
|
||||
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
|
||||
async createKvNamespace(title: string): Promise<string> {
|
||||
// 🔴 Arcrun#123:真的 Cloudflare **不准同名**——
|
||||
// `a namespace with this account ID and title already exists`。
|
||||
// 這個假帳號原本沒有模擬這條限制,於是「上一次裝到一半死掉」的帳號在測試裡
|
||||
// 看起來只是「多建幾顆孤兒」,實際上是**再也裝不起來**。少了這一行,#123 測不出來。
|
||||
if (this.kv.has(title)) {
|
||||
throw new Error('a namespace with this account ID and title already exists');
|
||||
}
|
||||
const id = `NEW-kvid-${this.createdKv.length}`;
|
||||
this.kv.set(title, id);
|
||||
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
|
||||
@@ -486,6 +493,100 @@ test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某
|
||||
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Arcrun#123 —— 「上一次裝到一半死掉」的帳號,再裝一次要能成功
|
||||
//
|
||||
// 這一格與 #97 的差別:#97 是「worker 綁著資源,卻被重新綁到新建的空殼」(會弄丟資料);
|
||||
// #123 是「資源建好了、worker 一顆都還沒部署」——**沒有任何綁定可以當事實**,
|
||||
// 而帳號上偏偏已經有一批同名資源 ⇒ 舊規則判「可以新建」⇒ CF 拒絕 ⇒ 這個帳號從此裝不起來。
|
||||
//
|
||||
// 封測者 1.4.45 實撞。逃過驗證的原因:我們只測乾淨帳號與完整安裝。
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 安裝器那條路的需求:`createName` 是安裝器**自己替這台實例算出來的**
|
||||
* (`arcrun-rag-<slugFromEmail(email)>-…`)⇒ 有資格聲明 createNameIsOurs。
|
||||
* 對照 collectRequirements()(走 toml,createName 是裸 binding 名 ⇒ 不得聲明)。
|
||||
*/
|
||||
function installerRequirements(claimOwnership = true): BindingRequirement[] {
|
||||
const own = claimOwnership ? { createNameIsOurs: true } : {};
|
||||
const out: BindingRequirement[] = [];
|
||||
for (const { requirements } of [collectRequirements()]) {
|
||||
for (const r of requirements) {
|
||||
if (r.kind === 'kv_namespace') {
|
||||
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kv-${r.binding.toLowerCase()}`, ...own });
|
||||
} else if (r.kind === 'd1') {
|
||||
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kbdb`, ...own });
|
||||
} else {
|
||||
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-embed`, ...own });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('#123 ①:上次裝到一半死掉的帳號 → 接回上次留下的那批,一顆都不必新建', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true }); // 資源在、worker 一顆都沒有
|
||||
const kvBefore = cf.kv.size;
|
||||
|
||||
const plan = await planResources(cf, installerRequirements(), 'init');
|
||||
assert.deepEqual(plan.blockers, [], '半殘帳號不該有 blocker——用戶只要再按一次就該裝得起來');
|
||||
assert.deepEqual(plan.create, [], '一顆都不該新建');
|
||||
assert.ok(plan.adopt.length > 0 && plan.adopt.every((a) => a.reclaimed === true),
|
||||
'全部都是「接回上次留下的」');
|
||||
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
|
||||
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
|
||||
assert.equal(cf.kv.size, kvBefore, '帳號上的顆數不變(沒有再留下一批孤兒)');
|
||||
|
||||
// 綁到的是上次建的那幾顆本尊
|
||||
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.value, 'kvid-webhooks');
|
||||
assert.equal(resolved.get(bindingKey('d1', 'DB'))!.value, 'd1id-kbdb');
|
||||
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.origin, 'adopted',
|
||||
'origin 維持 adopted——呼叫端既有的 adopted/created 統計不會漏數');
|
||||
});
|
||||
|
||||
test('#123 ①對照組:修好之前,同一個帳號會被 CF 用「title already exists」擋死', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
// 不聲明所有權 ⇒ 走的是修好之前的判斷(「沒人綁著就可以新建」)。
|
||||
// 舊版會直接送 POST → 撞 CF 的同名限制;新版在 plan 階段就停手,兩者都裝不起來,
|
||||
// 差別在**新版一顆資源都不會被建出來**,而且訊息說得出人話。
|
||||
const plan = await planResources(cf, installerRequirements(false), 'init');
|
||||
assert.ok(plan.blockers.length > 0, '證明不了是自己的 → fail-closed 停手');
|
||||
assert.match(plan.blockers.join('\n'), /RES-NAME-TAKEN/, '要給得出可回報的錯誤碼');
|
||||
assert.doesNotMatch(plan.blockers.join('\n'), /後台|dashboard/,
|
||||
'不准叫使用者自己去 Cloudflare 後台處理(#121/D88)');
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, [], '被擋下時一顆都不能被建出來');
|
||||
});
|
||||
|
||||
test('#123 ②紅線:名字真的不是我們的(走 toml 的裸 binding 名)→ 停手,不接管也不新建', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
await cf.createKvNamespace('WEBHOOKS'); // 使用者自己建的、剛好叫這個名字
|
||||
cf.createdKv.length = 0;
|
||||
const { requirements } = collectRequirements(); // createName = 裸 binding 名,不得聲明所有權
|
||||
|
||||
const plan = await planResources(cf, requirements, 'init');
|
||||
assert.ok(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
|
||||
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
|
||||
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
|
||||
assert.deepEqual(cf.createdKv, [], '一顆都沒建');
|
||||
});
|
||||
|
||||
test('#123 ③:全新帳號照舊建得出整套(D82 第一步不可退化)', async () => {
|
||||
const cf = new FakeCloudflare({ nothingDeployed: true });
|
||||
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0; // 真正的空帳號
|
||||
|
||||
const plan = await planResources(cf, installerRequirements(), 'init');
|
||||
assert.deepEqual(plan.blockers, [], '全新帳號不該有 blocker');
|
||||
assert.ok(plan.adopt.length === 0, '全新帳號沒有東西可沿用');
|
||||
const resolved = await applyResourcePlan(cf, plan);
|
||||
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length, '9 顆 KV 全部建出來');
|
||||
assert.equal(cf.createdD1.length, 1, 'D1 建一顆(兩個綁定共用)');
|
||||
assert.ok([...resolved.values()].every((r) => r.origin === 'created'), '全部都是新建的');
|
||||
});
|
||||
|
||||
test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
try {
|
||||
|
||||
@@ -840,7 +840,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
'<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' +
|
||||
'<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +
|
||||
(ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') +
|
||||
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' +
|
||||
// Arcrun#87 三次收尾(2026-08-13):triplet_count=0 不等於這庫沒有知識——entries 有標庫、
|
||||
// 只是三元組萃取沒對它跑過(kb 以外幾乎全庫皆此況)。entry_count>0 時附一句,別讓看板
|
||||
// 的「0 三元組」被讀成「這庫是空的」(同 MCP 端 kbdb_map.ts 的 entryOnlyHint 同一件事)。
|
||||
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組' +
|
||||
(!(Number(l.triplet_count) || 0) && (Number(l.entry_count) || 0) > 0
|
||||
? '<span class="dim">・' + (Number(l.entry_count) || 0) + ' 筆原始內容(尚未萃取)</span>' : '') + '</span>' +
|
||||
'<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run"
|
||||
"check:tenant": "node scripts/check-tenant-source.mjs",
|
||||
"test": "node scripts/check-tenant-source.mjs && vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "^1.2.4",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -245,6 +245,35 @@ export async function resolveCredentialRefs(
|
||||
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KV(T7)。
|
||||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
|
||||
|
||||
// ── 全部取到 → 不必打 auth_static_key worker(Arcrun#124,2026-08-14)────────────
|
||||
//
|
||||
// 這條路徑上,那顆 worker 唯一比這裡多做的事,是「幫**沒取到**的 name 回頭翻舊 KV」
|
||||
// (T7 雙讀 fallback)。當 nameList 每一個都已經從新家取到明文時,它收到
|
||||
// `resolved_secrets` 後就是原樣回傳——回填本來就由下面的 `replaceCredentialRefs`
|
||||
// 在本檔做。**所以那趟往返在「全取到」時不產生任何結果差異。**
|
||||
//
|
||||
// 為什麼這不是效能微調,是修 bug:`wasmWorkerUrl` 推導出的
|
||||
// `arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` **在網頁安裝器裝出來的實例上不存在**
|
||||
// (首裝只裝 cypher-executor / kbdb / http_request / code 四顆,auth primitive 不在其中,
|
||||
// 也沒有「用到才長」的機制)。於是 fetch 撞 CF 邊緣的 404 `error code: 1042`
|
||||
// ⇒ 下面的 `!res.ok` throw ⇒ **凡是工作流用到 {{credential.X}} 的節點全部 500**。
|
||||
// 實測:leo21c 與全新安裝的 youlin 兩台,問答與寫入路徑一字不差地同死。
|
||||
//
|
||||
// 🔴 給下一個人:**這個 1042 跟 d2048e2/95a1462 那兩輪的 1042 不是同一個病。**
|
||||
// 那兩輪是「same-zone fetch 被 CF 擋」(解法=`global_fetch_strictly_public` flag,
|
||||
// 且 service binding 的解法**已被 revert、不要再拿回來**)。本案是「**目標 worker 根本不存在**」
|
||||
// ——同一個錯誤碼、兩種原因。照舊帳去動 flag 或 binding 對本案完全無效(flag 早就在了)。
|
||||
//
|
||||
// 邊界(rule 02 §2.2)不變:本檔仍然不解密、不展開模板、不組 JWT。
|
||||
// 明文的來源是 `secret_get`(host function,實作 = env[ref]),與改動前完全同一條路;
|
||||
// 這裡只是不再為了「拿一份自己已經拿到的東西」去 fetch 一顆可能不存在的 worker。
|
||||
//
|
||||
// 刻意保留 WASM 路徑:只要有任何一個 name 沒從新家取到(舊租戶、遷移過渡期的 KV 存量),
|
||||
// 就照舊全量交給 WASM 走 T7 雙讀——**不縮減既有能力,只在等價時省掉那一跳**。
|
||||
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
|
||||
return replaceCredentialRefs(data, resolvedSecrets) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -131,9 +131,16 @@ export function authStorePresent(env: Bindings): boolean {
|
||||
return shardNames(env).length > 0 || (overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS);
|
||||
}
|
||||
|
||||
/** 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。 */
|
||||
export function authStoreWritable(env: Bindings): boolean {
|
||||
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
|
||||
/**
|
||||
* 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。
|
||||
*
|
||||
* `tokenOverride`(2026-08-14,arcrun-rag#99):`env.CF_SECRETS_API_TOKEN` 從沒被安裝器種過,
|
||||
* 這是每台新實例都會撞的硬斷點(不是 leo 個人的環境問題)。安裝精靈裝機當下手上有一把
|
||||
* 自己還有效的 OAuth token,讓 `/console/setup`/`/portal/admin/bootstrap` 把它隨請求帶入,
|
||||
* 這裡就把它算進「寫得進去嗎」的判斷——見 `routes/credentials.ts putWorkerSecret` 的完整說明。
|
||||
*/
|
||||
export function authStoreWritable(env: Bindings, tokenOverride?: string): boolean {
|
||||
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,11 +208,21 @@ export function newAuthUserId(): string {
|
||||
* 分片規則:console 一定放第 0 片;users 依序塞,塞不下就開下一片。
|
||||
* 多出來的舊分片會被刪掉(避免「刪了帳號卻還留在舊分片裡復活」)。
|
||||
*/
|
||||
export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise<void> {
|
||||
if (!authStoreWritable(env)) {
|
||||
export async function writeAuthStore(env: Bindings, data: AuthStoreData, tokenOverride?: string): Promise<void> {
|
||||
if (!authStoreWritable(env, tokenOverride)) {
|
||||
// arcrun-rag#99(2026-08-14,leo 傳實測畫面點破):舊文案叫用戶「重新執行安裝/更新」,
|
||||
// 但在這個訊息會出現的所有情況下(包含安裝精靈本身,若它沒能把 install token 帶到)那句話
|
||||
// 都不保證成立——安裝/更新從來不會把 CF_SECRETS_API_TOKEN 種成一個常駐的值。
|
||||
// 對一個做不到的動作下指令=把人導向死路(他會以為自己操作錯誤,反覆重試)。
|
||||
// 改成誠實描述現況+給得出去的下一步(回報支援),不再承諾一個我們自己都不確定會生效的動作。
|
||||
// 只列 CF_SECRETS_API_TOKEN:CF_ACCOUNT_ID 已查證全新實例一定有
|
||||
//(installer/oauth-prototype/worker.js:1354 無條件注入每一顆部署的 worker),
|
||||
// 列兩項只會讓下一個人以為兩項都要查,白工一次。
|
||||
throw new AuthStoreWriteError(
|
||||
'這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' +
|
||||
'認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。',
|
||||
'這台實例目前寫不進認證儲存(缺可用的 Cloudflare 寫入憑證:CF_SECRETS_API_TOKEN)。' +
|
||||
'這是平台端的已知限制,不是你操作錯誤——目前沒有你自己在畫面上能做的下一步,' +
|
||||
'請把這則訊息完整截圖/複製給支援,並註明你剛才在做什麼(例如:安裝精靈裡建立第一個帳號、' +
|
||||
'事後新增使用者、或修改密碼),會需要人工協助排除。',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,10 +249,10 @@ export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promis
|
||||
|
||||
const existing = shardNames(env);
|
||||
for (let i = 0; i < shards.length; i++) {
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i]);
|
||||
await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride);
|
||||
}
|
||||
for (const name of existing) {
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
|
||||
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
|
||||
}
|
||||
|
||||
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
|
||||
@@ -320,11 +337,12 @@ function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
|
||||
export async function mutateAuthStore(
|
||||
env: Bindings,
|
||||
fn: (data: AuthStoreData) => void | Promise<void>,
|
||||
tokenOverride?: string,
|
||||
): Promise<AuthStoreData> {
|
||||
await hydrateFromAccelerator(env);
|
||||
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
|
||||
await fn(next);
|
||||
await writeAuthStore(env, next);
|
||||
await writeAuthStore(env, next, tokenOverride);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -22,19 +22,20 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
// D61(ADR D61 / Leo/arcrun-rag#55):這組管理員帳密原本住 SESSIONS_KV(`console:credentials`,
|
||||
// 而且沒有 TTL)——KV 是靠 binding 指過去的,重裝會被指到**新建的空 KV** ⇒ 帳密憑空消失。
|
||||
// 這是「KV=暫存、非長期真相源」第三次被違反,而這一次違反的是大門的鎖。
|
||||
// 現改存進認證儲存(Workers Secrets,不靠 binding);舊 KV 只保留為回退讀路徑,
|
||||
// 讀到就順手搬過去(見 loadCredentials)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreStatus,
|
||||
hydrateFromAccelerator,
|
||||
mutateAuthStore,
|
||||
readAuthStore,
|
||||
type AuthConsoleRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
// D61 補充(2026-08-14,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
|
||||
// D61 把這組管理員帳密搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器
|
||||
// 照名字重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
|
||||
// `CF_SECRETS_API_TOKEN`,而這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
|
||||
// 第一個帳號(arcrun-rag#99)。
|
||||
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`(Arcrun#97)解掉——
|
||||
// 現在每次安裝/更新都會沿用既有 binding,不會再把 SESSIONS_KV 重指到空資源。既然病根已解,
|
||||
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳密改回住 SESSIONS_KV**
|
||||
// (`console:credentials`,binding,永不需要外部 CF token)。
|
||||
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
|
||||
// 零外部憑證需求(只有寫入才要 token)——查到就順手搬回 SESSIONS_KV(見 loadCredentials)。
|
||||
import { readAuthStore } from '../lib/portal-auth-store';
|
||||
// Arcrun#108:租戶字串唯一產地。
|
||||
import { knowledgeOwner } from '../lib/tenant';
|
||||
|
||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -79,56 +80,72 @@ async function hashPassword(password: string, salt: string): Promise<string> {
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* console 登入後下發給前端當 api_key 用的租戶字串(舊 console 的設計,與 portal 不同:
|
||||
* portal 絕不下發,console 會)。
|
||||
*
|
||||
* Arcrun#108:這是**知識資料面**的 owner_id(前端拿它直打 `/kbdb/*`),所以必須與寫入端
|
||||
* (CLI/小幫手/MCP 用的實例 namespace)同源。以前直接讀 `env.CONSOLE_TENANT || 'leo'`
|
||||
* ⇒ 與 portal 同一個病:資料在 `bfezv28v`、過濾拿 `leo`,console 首頁的藏書地圖同樣是空的。
|
||||
* 現在走唯一產地 `lib/tenant.ts`。
|
||||
*/
|
||||
function tenantOf(c: { env: Bindings }): string {
|
||||
return c.env.CONSOLE_TENANT || 'leo';
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
|
||||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||
// ── 帳密的家(2026-08-14 起:SESSIONS_KV 為主,認證儲存為舊實例回退讀路徑)────────────
|
||||
|
||||
/**
|
||||
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家(KV),
|
||||
* 且一旦從舊家讀到就順手搬過去(best-effort,搬不動不影響本次登入)。
|
||||
* 讀出 console 管理員帳密。**SESSIONS_KV(binding)優先**;沒有才回退讀舊家
|
||||
* (D61 的認證儲存,CF Workers Secrets——讀取零成本、零外部憑證需求),
|
||||
* 且一旦從舊家讀到就順手搬回 SESSIONS_KV(best-effort,搬不動不影響本次登入)。
|
||||
*/
|
||||
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'secrets' | 'legacy-kv' | 'none' }> {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
if (!fromStore && (await hydrateFromAccelerator(env))) {
|
||||
// 剛設定完帳密、secret 的新版本還沒鋪到這顆 isolate(實測有 15 秒以上的窗口)
|
||||
// → 先問一次加速器,免得「剛設好就說你沒設過」。細節見 lib 的 ACCEL_KEY 註解。
|
||||
fromStore = readAuthStore(env).console;
|
||||
}
|
||||
if (fromStore) return { creds: fromStore, source: 'secrets' };
|
||||
|
||||
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'kv' | 'legacy-secrets' | 'none' }> {
|
||||
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return { creds: null, source: 'none' };
|
||||
let legacy: StoredCredentials | null = null;
|
||||
try {
|
||||
legacy = JSON.parse(raw) as StoredCredentials;
|
||||
} catch {
|
||||
return { creds: null, source: 'none' };
|
||||
if (raw) {
|
||||
try {
|
||||
return { creds: JSON.parse(raw) as StoredCredentials, source: 'kv' };
|
||||
} catch {
|
||||
/* KV 這份壞了,當作沒有,往下查舊家 */
|
||||
}
|
||||
}
|
||||
|
||||
// 舊家(D61 的認證儲存):純讀 env 字串,零網路呼叫、不需要任何外部 CF 憑證。
|
||||
const legacy = readAuthStore(env).console;
|
||||
if (!legacy) return { creds: null, source: 'none' };
|
||||
try {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (!data.console) data.console = legacy as AuthConsoleRecord;
|
||||
});
|
||||
// best-effort 搬回 SESSIONS_KV——這是 binding put,本來就不需要外部 token,
|
||||
// 幾乎不會失敗;失敗也不影響本次用這份舊資料繼續(狀態看 /console/auth-status)。
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
|
||||
} catch {
|
||||
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store) */
|
||||
/* 照舊用這份,下次再試著搬一次 */
|
||||
}
|
||||
return { creds: legacy, source: 'legacy-kv' };
|
||||
return { creds: legacy as StoredCredentials, source: 'legacy-secrets' };
|
||||
}
|
||||
|
||||
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
|
||||
/**
|
||||
* 寫入 console 管理員帳密——**只寫 SESSIONS_KV**(binding,永不需要外部 CF token)。
|
||||
* 不再寫回認證儲存(Workers Secrets):那是要被淘汰的舊家,寫回去等於把債種回土裡。
|
||||
*/
|
||||
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
});
|
||||
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
}
|
||||
|
||||
/** `/console/auth-status`、`/health` 共用的儲存狀態區塊(不洩漏 email/雜湊,只回統計)。 */
|
||||
function consoleAuthStoreStatus(env: Bindings): { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean } {
|
||||
return {
|
||||
home: 'sessions-kv',
|
||||
writable: true, // binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
|
||||
legacy_secrets_present: readAuthStore(env).console !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
// D61:多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||
// 多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
|
||||
});
|
||||
|
||||
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
||||
@@ -160,11 +177,12 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
|
||||
try {
|
||||
// 2026-08-14 起:寫 SESSIONS_KV(binding),不再需要安裝精靈遞任何臨時 CF token
|
||||
// (arcrun-rag#99 那個結構性缺口——見檔頭說明——已經隨儲存層搬回 binding 一併解掉)。
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
// 寫不進去就誠實回報(不假綠:binding put 幾乎不會失敗,於是沒人處理過這條路)
|
||||
return c.json({ error: `帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
|
||||
const token = randomHex(32);
|
||||
@@ -195,8 +213,7 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `新帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
return c.json({ error: `新帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
@@ -205,12 +222,13 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
consoleAuthRouter.post('/console/login', async (c) => {
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) {
|
||||
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
|
||||
// 明顯失敗(#10「寧可明顯失敗,不要靜默錯置」):這是「這台實例讀不到認證資料」,
|
||||
// 不是「你帳密打錯」——兩句話混成一句正是 2026-08-09 leo 被誤鎖 15 分鐘的根因。
|
||||
return c.json(
|
||||
{
|
||||
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
auth_store: consoleAuthStoreStatus(c.env),
|
||||
},
|
||||
400,
|
||||
);
|
||||
@@ -221,19 +239,8 @@ consoleAuthRouter.post('/console/login', async (c) => {
|
||||
const password = body?.password ?? '';
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
|
||||
let creds = existing;
|
||||
let hash = await hashPassword(password, creds.salt);
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
// D61:剛改完帳密、secret 新版本還沒鋪開的窗口 → 問一次加速器再判失敗
|
||||
if (await hydrateFromAccelerator(c.env)) {
|
||||
const again = (await loadCredentials(c.env)).creds;
|
||||
if (again) {
|
||||
creds = again;
|
||||
hash = await hashPassword(password, creds.salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ import {
|
||||
taipeiDayKey,
|
||||
} from '../lib/console-dashboard-model';
|
||||
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
|
||||
// Arcrun#108:租戶字串唯一產地。console 首頁的規模數字/藏書地圖也曾因為拿 CONSOLE_TENANT
|
||||
// 過濾而看不到自己的資料——與 portal 同一個病,同一個修法。
|
||||
import { knowledgeOwner } from '../lib/tenant';
|
||||
|
||||
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -265,7 +268,7 @@ export async function cachedGiteaSprint(
|
||||
|
||||
// GET /console/dashboard-data — 聚合 JSON(無需登入;唯讀、不含機敏值)
|
||||
consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const now = Date.now();
|
||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
@@ -451,7 +454,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
// limit=1(只拿 total 欄)或現成 stats 聚合端點——不逐筆掃庫,不撞子請求上限。
|
||||
// 搜尋功能本身仍可搜全庫(資料不藏),只是規模感不再引用遺產總數。
|
||||
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const now = Date.now();
|
||||
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
||||
@@ -499,7 +502,7 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||
if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
|
||||
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const [todoEntries, inboxEntries] = await Promise.all([
|
||||
fetchEntries(c.env, tenant, 'todo', 500),
|
||||
fetchEntries(c.env, tenant, 'inbox', 200),
|
||||
@@ -530,7 +533,7 @@ consoleDashboardRouter.post('/console/triage-check', async (c) => {
|
||||
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
|
||||
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
|
||||
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const tenant = knowledgeOwner(c.env); // #108:知識資料面的 owner_id 只有一個產地(lib/tenant.ts)
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
|
||||
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
|
||||
|
||||
@@ -92,9 +92,21 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
|
||||
/**
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。
|
||||
* 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。
|
||||
*
|
||||
* `tokenOverride`(2026-08-14,arcrun-rag#99:全新帳號卡在註冊,`writable:false`):
|
||||
* 本 worker 自己的 `env.CF_SECRETS_API_TOKEN` 從安裝那天起就沒被種過(07-29 已知缺口,記在
|
||||
* pending-changes.md「credential 走 n8n 模式」——當時只降級某個功能;D61 認證分離之後升級成
|
||||
* 「連第一個帳號都建不起來」的硬斷點,因為 `/console/setup`/`/portal/admin/bootstrap`
|
||||
* 現在都走這條寫入路徑)。安裝精靈裝機當下手上有一把**自己還有效**的 OAuth token
|
||||
* (`workers-scripts.write` scope,跟部署零件、種 credential 用的是同一把——見
|
||||
* `installer/oauth-prototype/worker.js` 的 `putWorkerSecretDirect`/`seedCredential`,
|
||||
* 是同一個「安裝器代寫」精神,D36 第1步)。讓呼叫端把這把 token **隨請求帶入、不落地**,
|
||||
* 補的正是「cypher 自己永遠拿不到長效寫入憑證」這個結構性缺口,不是新開一條路。
|
||||
* 優先權:呼叫端提供 > worker 自身 env。
|
||||
*/
|
||||
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string, tokenOverride?: string): Promise<void> {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error(
|
||||
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
|
||||
'credential-store-migration.md T3:acr init/update 應確保這兩項就緒)',
|
||||
@@ -104,7 +116,7 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: secretRef, text: value, type: 'secret_text' }),
|
||||
@@ -121,15 +133,19 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s
|
||||
/**
|
||||
* 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。
|
||||
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
|
||||
*
|
||||
* `tokenOverride`:與 `putWorkerSecret` 同一組理由(見該函式註解)。auth store 分片重切時
|
||||
* 會刪多出來的舊分片,這條路徑也要能吃到安裝精靈臨時遞來的 token。
|
||||
*/
|
||||
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
|
||||
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
|
||||
export async function deleteWorkerSecret(env: Bindings, secretRef: string, tokenOverride?: string): Promise<void> {
|
||||
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
|
||||
if (!token || !env.CF_ACCOUNT_ID) {
|
||||
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
|
||||
}
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.status === 404) return;
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { authStoreStatus } from '../lib/portal-auth-store';
|
||||
import { readAuthStore } from '../lib/portal-auth-store';
|
||||
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/**
|
||||
* 認證儲存狀態(2026-08-14 起:console 帳密住 SESSIONS_KV、portal 帳號住 KBDB,
|
||||
* 兩者皆 binding-based,不再需要外部 CF token 才寫得進去——見 console-auth.ts/portal.ts
|
||||
* 檔頭「D61 補充」說明)。`legacy_*_present` 只回是否還有 D61 時代留在認證儲存(CF Workers
|
||||
* Secrets)裡尚未搬遷的資料,不洩漏任何 email/雜湊內容。
|
||||
*/
|
||||
function authStoreStatus(env: Bindings): {
|
||||
console: { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean };
|
||||
portal_users: { home: 'kbdb'; writable: true; legacy_secrets_present: boolean };
|
||||
} {
|
||||
const legacy = readAuthStore(env);
|
||||
return {
|
||||
console: { home: 'sessions-kv', writable: true, legacy_secrets_present: legacy.console !== null },
|
||||
portal_users: { home: 'kbdb', writable: true, legacy_secrets_present: legacy.users.length > 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// t162(leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」):
|
||||
// daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊——
|
||||
// 但本端點過去只回 {ok:true},**從沒吐這個欄位** ⇒ daemon 恆讀到空字串
|
||||
|
||||
@@ -81,20 +81,33 @@ kbdbProxyRouter.get('/kbdb/templates/:idOrName', async (c) => {
|
||||
|
||||
// ── records(以租戶 namespace 為 owner_id 隔離)────────────────────────────────
|
||||
|
||||
// POST /kbdb/records — 填一筆 record(template + values)。owner_id 自動注入。
|
||||
// POST /kbdb/records — 填一筆 record(template + values/entry_ids)。owner_id 自動注入。
|
||||
//
|
||||
// `entry_ids`(Arcrun#128)= slot 指向**既有** entry 的 id,不新建、不複製;與 values 並存
|
||||
// (給字串照舊新建)。這裡維持純轉發,判斷與擋人全在基本盤 kbdb(薄殼鐵律,見檔頭):
|
||||
// · 兩者都沒給/型別不對 → base 回 400
|
||||
// · 指到別人的 entry → base 擋(它比對被參照 entry 的 owner_id 與這裡注入的租戶身份,
|
||||
// 所以「呼叫端自己指定 entry_id」這條新路徑不會變成跨租戶的門)
|
||||
// 🔴 為什麼通道要一起開:#129(wiki template)與 #130(三元組正規化)的寫入端走這扇門。
|
||||
// 基本盤補好而通道不開=能力在、沒人打得到——同 PATCH 那次(b6ef0f0)的教訓。
|
||||
kbdbProxyRouter.post('/kbdb/records', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || !body.template || !body.values) {
|
||||
return c.json({ error: 'template 與 values 必填' }, 400);
|
||||
if (!body || !body.template || (!body.values && !body.entry_ids)) {
|
||||
return c.json({ error: 'template 必填,values 與 entry_ids 至少要有一個' }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
|
||||
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner }),
|
||||
body: JSON.stringify({
|
||||
template: body.template,
|
||||
...(body.values ? { values: body.values } : {}),
|
||||
...(body.entry_ids ? { entry_ids: body.entry_ids } : {}),
|
||||
owner_id: owner,
|
||||
}),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||||
// Arcrun#108:知識資料面的租戶字串只有一個產地(lib/tenant.ts)。這裡刻意**不再** import
|
||||
// portalTenant——它是帳號層的值(回 string 不是 TenantId),拿來過濾知識就是本票的病。
|
||||
import { knowledgeOwner, ownerField, ownerQuery, isOwnedBy, censusQueryAllTenants, type TenantId } from '../lib/tenant';
|
||||
import { graphBase, graphHeaders } from './kbdb-proxy';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
|
||||
@@ -40,7 +43,9 @@ export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/** 讀 tenant 的 named workflow graph(`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
|
||||
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<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;
|
||||
try {
|
||||
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 端
|
||||
* `?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 {
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
|
||||
// owner=null = 普查全庫(#100 用來分辨「查不到」與「沒有」)。這是唯一一個
|
||||
// 刻意不帶租戶範圍的查詢,因此走一支名字就在喊「我沒有租戶範圍」的專用 helper。
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
|
||||
if (!body || !Array.isArray(body.stats)) return null;
|
||||
@@ -219,16 +226,16 @@ async function tripletCount(env: Bindings, owner: string): Promise<number | null
|
||||
* owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到
|
||||
* owned=null → 讀不到 → 畫面說讀不到
|
||||
*/
|
||||
async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> {
|
||||
async function tripletCensus(env: Bindings, tenant: TenantId): Promise<{ owned: number | null; any: number | null }> {
|
||||
const owned = await tripletCount(env, tenant);
|
||||
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
|
||||
return { owned, any: await tripletCount(env, '') };
|
||||
return { owned, any: await tripletCount(env, null) };
|
||||
}
|
||||
|
||||
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
||||
async function fuzzyFindNode(env: Bindings, tenant: TenantId, searchTerm: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
|
||||
if (!body || !Array.isArray(body.records)) return null;
|
||||
@@ -263,7 +270,7 @@ portalDataRouter.get('/portal/data/search', (c) =>
|
||||
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
|
||||
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
|
||||
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
||||
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
||||
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
||||
@@ -334,7 +341,7 @@ portalDataRouter.get('/portal/data/entries/:id', (c) =>
|
||||
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
|
||||
const entry = body.entry;
|
||||
if (!entry) return notFound(c);
|
||||
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
|
||||
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
|
||||
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||||
return c.json({ success: true, entry });
|
||||
}),
|
||||
@@ -359,7 +366,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
const nodeName = normalizeCjkQuery(c.req.param('name'));
|
||||
|
||||
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
|
||||
if (wfGraph) {
|
||||
const depthRaw = c.req.query('depth') ?? '';
|
||||
@@ -420,9 +427,9 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
||||
if (!(await hasGraphAccess(c.env, libraries))) {
|
||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||
}
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const [res, census] = await Promise.all([
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`),
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant)}&limit=500`),
|
||||
tripletCensus(c.env, tenant),
|
||||
]);
|
||||
const tripletsTotal = census.owned;
|
||||
@@ -505,7 +512,7 @@ portalDataRouter.get('/portal/data/chat', (c) =>
|
||||
wfGraph,
|
||||
{ question },
|
||||
'rag_chat',
|
||||
portalTenant(c.env),
|
||||
knowledgeOwner(c.env),
|
||||
c.executionCtx,
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -611,7 +618,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
// 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
|
||||
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
|
||||
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const prefix = `${tenant}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
const workflows = await Promise.all(
|
||||
@@ -637,7 +644,7 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||||
const execRes = await kbdbFetch(
|
||||
c.env,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant) }).toString()}`,
|
||||
);
|
||||
const execBody = await execRes.json().catch(() => null) as {
|
||||
success?: boolean;
|
||||
@@ -691,25 +698,42 @@ function recordLibrary(values: Record<string, unknown> | undefined): string | nu
|
||||
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
|
||||
function canReadRecord(
|
||||
rec: { values?: Record<string, unknown>; owner_id?: string | null },
|
||||
tenant: string,
|
||||
tenant: TenantId,
|
||||
libraries: string[],
|
||||
): boolean {
|
||||
if ((rec.owner_id ?? '') !== tenant) return false;
|
||||
if (!isOwnedBy(rec.owner_id, tenant)) return false;
|
||||
const lib = recordLibrary(rec.values);
|
||||
return lib === null || canReadLibrary(libraries, lib);
|
||||
}
|
||||
|
||||
// GET /portal/data/map — 藏書地圖全館視圖,**只回這個帳號有權限的庫**。
|
||||
// KBDB 的 /map 對權限無知(它回全館),過濾在這裡做——MCP 不得比 portal 同一個帳號看得更多。
|
||||
//
|
||||
// 🔴 Arcrun#108:一張空地圖有四種成因,**判準留在 server,不留給前端猜**
|
||||
// (沿 #100 總圖那條「讀不到就說讀不到」,同一套 census 機制):
|
||||
// no_library_grant :這個帳號一個庫都沒被授權 → 是權限問題,不是資料問題
|
||||
// filtered_out :實例有庫,但都不在這個帳號的權限內 → 正常且正確的隔離
|
||||
// confirmed_empty :實例真的一條三元組都沒有 → **只有此時**才准說「還沒有知識」
|
||||
// scope_mismatch :實例有三元組,但本命名空間一條都撈不到 → **命名空間對不上**
|
||||
// (就是本票:1854 條在 bfezv28v,卻拿 "leo" 去過濾)
|
||||
// scope_mismatch 這一格以前不存在,所以設定錯誤被畫成「你沒有資料」——leo 看到的空地圖。
|
||||
//
|
||||
// ⚠️ 回應**絕不含租戶字串**(design §3.3 紅線:前端拿到租戶字串就能繞過庫過濾直打 /kbdb/*)。
|
||||
// 只回代碼與數字,文字說明講「請通知管理員」,命名空間本身不下發。
|
||||
portalDataRouter.get('/portal/data/map', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ success: true, libraries: [], count: 0, note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: true, empty_reason: 'no_library_grant',
|
||||
note: '此帳號尚未被授權任何知識庫,請聯絡管理員。',
|
||||
});
|
||||
}
|
||||
const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant)}`);
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
@@ -720,7 +744,42 @@ portalDataRouter.get('/portal/data/map', (c) =>
|
||||
const allowed = body.libraries.filter(
|
||||
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
|
||||
);
|
||||
return c.json({ success: true, libraries: allowed, count: allowed.length });
|
||||
if (allowed.length > 0) {
|
||||
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
|
||||
}
|
||||
// 以下都是「回空」的路徑——多花一次查詢換一個**有根據**的理由,不猜。
|
||||
if (body.libraries.length > 0) {
|
||||
// 命名空間對得上(撈得到庫),只是這個帳號沒有那些庫的權限=隔離正常運作。
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: true, empty_reason: 'filtered_out',
|
||||
note: '這個帳號目前沒有任何知識庫的檢視權限,請聯絡管理員開通。',
|
||||
});
|
||||
}
|
||||
const census = await tripletCensus(c.env, tenant);
|
||||
if (census.owned === null || (census.owned === 0 && census.any === null)) {
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: false, empty_reason: 'unreadable',
|
||||
note: '讀不到知識庫的統計,無法確認庫裡有沒有東西——這不是「還沒有知識」,是這次讀取失敗。請稍後重整或通知管理員。',
|
||||
});
|
||||
}
|
||||
if (census.owned === 0 && (census.any ?? 0) > 0) {
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: false, empty_reason: 'scope_mismatch',
|
||||
instance_triplet_count: census.any,
|
||||
note:
|
||||
`讀不到你這個帳號範圍內的藏書——但這台實例裡有 ${census.any} 條知識關聯。` +
|
||||
'這不是「還沒有知識」,不用去重新上傳;比較像知識的歸屬命名空間對不上。' +
|
||||
'請通知管理員跑一次 `acr update`(會把你安裝時的命名空間同步給雲端),或檢查 ARCRUN_NAMESPACE 設定。',
|
||||
});
|
||||
}
|
||||
return c.json({
|
||||
success: true, libraries: [], count: 0,
|
||||
empty_confirmed: true, empty_reason: 'confirmed_empty',
|
||||
note: '知識庫還沒有任何內容——上傳文件後就會出現在這裡。',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -734,7 +793,7 @@ portalDataRouter.get('/portal/data/map/:library', (c) =>
|
||||
if (!canReadLibrary(libraries, library)) return notFound(c);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`,
|
||||
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`,
|
||||
);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
@@ -775,7 +834,7 @@ portalDataRouter.post('/portal/data/templates', (c) =>
|
||||
name: body.name,
|
||||
slots: body.slots,
|
||||
description: typeof body.description === 'string' ? body.description : undefined,
|
||||
created_by: portalTenant(c.env),
|
||||
created_by: knowledgeOwner(c.env),
|
||||
}),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
@@ -790,10 +849,10 @@ portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(tenant)}`,
|
||||
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?${ownerQuery(tenant)}`,
|
||||
);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
@@ -824,7 +883,7 @@ portalDataRouter.get('/portal/data/records/:recordId', (c) =>
|
||||
| null;
|
||||
const record = body?.record;
|
||||
if (!record) return notFound(c);
|
||||
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
|
||||
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
|
||||
return c.json({ success: true, record });
|
||||
}),
|
||||
);
|
||||
@@ -856,7 +915,7 @@ portalDataRouter.post('/portal/data/records', (c) =>
|
||||
const res = await kbdbFetch(c.env, '/records', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) }),
|
||||
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) }),
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
@@ -884,7 +943,7 @@ portalDataRouter.get('/portal/data/diagnostics', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const core = await buildDiagnostics(c.env, tenant);
|
||||
return c.json({
|
||||
generated_at: new Date().toISOString(),
|
||||
|
||||
@@ -25,22 +25,31 @@ import { kbdbBase } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
|
||||
// Arcrun#108:租戶字串只有一個產地(lib/tenant.ts)。帳號面用 accountTenant(普通 string),
|
||||
// 知識資料面用 knowledgeOwner(TenantId)——型別分家,拿錯編不過。
|
||||
import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey, TenantUnresolvedError, type TenantId } from '../lib/tenant';
|
||||
// arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
|
||||
// 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。
|
||||
import { storeCredential, hasCredential } from './credentials';
|
||||
// D61(Leo/arcrun-rag#55,ADR D61):**帳號不再住知識資料庫**。
|
||||
// 讀寫一律先走 lib/portal-auth-store(CF Workers Secrets,不靠任何 binding),
|
||||
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
|
||||
// D61 補充(2026-08-14,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
|
||||
// D61 把帳號搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器照名字
|
||||
// 重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
|
||||
// `CF_SECRETS_API_TOKEN`,這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
|
||||
// 第一個帳號、也永遠加不了第二個(arcrun-rag#99)。
|
||||
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`(Arcrun#97)解掉——
|
||||
// 現在每次安裝/更新都會沿用既有 binding,不會再把 KBDB D1 重指到空資源。既然病根已解,
|
||||
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳號改回住 KBDB**
|
||||
// (走 base HTTP API,D38 零 SQL,binding,永不需要外部 CF token)。
|
||||
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
|
||||
// 零外部憑證需求(只有寫入才要 token)——登入成功就順手搬進 KBDB(見 promoteToKbdb)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreRecentlyWritten,
|
||||
authStoreStatus,
|
||||
findAuthUserByEmail,
|
||||
findAuthUserById,
|
||||
hydrateFromAccelerator,
|
||||
isAuthStoreId,
|
||||
mutateAuthStore,
|
||||
newAuthUserId,
|
||||
readAuthStore,
|
||||
type AuthUserRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
@@ -58,14 +67,27 @@ export const LIBRARY_TEMPLATE = 'portal_library';
|
||||
|
||||
// ── 基礎 helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 租戶字串(=知識資料的 owner_id)。預設沿 console-auth 同款 'leo'。**只在 server 側使用,永不下發前端**。 */
|
||||
/**
|
||||
* 帳號層的租戶字串(**不是**知識資料的 owner_id,Arcrun#108 拆開)。
|
||||
*
|
||||
* 只用來組帳號子 namespace(`{tenant}::portal`,design D-2)與 cypher 自己寫的設定
|
||||
* (extractor_config / credentials 目錄)——那些都是 cypher 用同一個值寫進去的,所以自洽。
|
||||
*
|
||||
* 🔴 **不可以拿它過濾知識資料面**(三元組 / entries / records / 藏書地圖 / 工作流):
|
||||
* 那批是 CLI/小幫手用實例 namespace 寫的,兩者對不上就是 #108
|
||||
* (leo 的 1854 條被 `CONSOLE_TENANT="leo"` 過濾成 0)。資料面請用
|
||||
* `lib/tenant.ts` 的 `knowledgeOwner(env)`——它回 `TenantId`,本函式回 `string`,
|
||||
* 型別上就分得開,不必靠人記得。
|
||||
*
|
||||
* **只在 server 側使用,永不下發前端**。
|
||||
*/
|
||||
export function portalTenant(env: Bindings): string {
|
||||
return env.CONSOLE_TENANT || 'leo';
|
||||
return accountTenant(env);
|
||||
}
|
||||
|
||||
/** 帳號子 namespace(design D-2)。 */
|
||||
function portalNamespace(env: Bindings): string {
|
||||
return `${portalTenant(env)}::portal`;
|
||||
return `${accountTenant(env)}::portal`;
|
||||
}
|
||||
|
||||
function sessionTtl(env: Bindings): number {
|
||||
@@ -98,10 +120,17 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
// D61:認證儲存寫不進去要**看得出來是這件事**(不是 KBDB 的錯,也不是密碼的錯)
|
||||
// 帳號還住在舊家(D61 認證儲存)時,寫入需要外部 CF_SECRETS_API_TOKEN——
|
||||
// 新家(KBDB)不需要,但尚未搬遷的既有帳號仍可能撞到這格,要看得出來是這件事
|
||||
// (不是 KBDB 的錯,也不是密碼的錯),且會在該帳號下次登入時自動搬進 KBDB 而解除。
|
||||
if (e instanceof AuthStoreWriteError) {
|
||||
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
// Arcrun#108:連「這台實例的知識放在哪一格」都解析不出來 → 誠實講「讀不到」,
|
||||
// 不拿 repo 預設值當答案然後回一頁空的(那正是本票的病:設定缺失被畫成「你沒有資料」)。
|
||||
if (e instanceof TenantUnresolvedError) {
|
||||
return c.json({ error: e.message, code: 'tenant_unresolved' }, 500);
|
||||
}
|
||||
if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502);
|
||||
throw e;
|
||||
}
|
||||
@@ -172,7 +201,7 @@ export async function ensurePortalTemplates(
|
||||
return { created, existing, errors };
|
||||
}
|
||||
|
||||
// ── D61 認證儲存 ⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)─────
|
||||
// ── 認證儲存(D61 舊家)⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)──
|
||||
|
||||
function authUserToRecord(u: AuthUserRecord): PortalRecord {
|
||||
return {
|
||||
@@ -206,33 +235,41 @@ function recordValuesToAuthUser(id: string, v: Record<string, string>): AuthUser
|
||||
}
|
||||
|
||||
/**
|
||||
* 舊實例自癒:在 KBDB 找到的既有帳號,原樣搬進認證儲存。
|
||||
* best-effort——搬不動(寫入路徑未就緒)不影響這次登入,只是下次還會再走一次舊路。
|
||||
* 這就是 #55「第一版不做跨版本遷移機制」的落地方式:**用一次成功的登入把自己搬過去**。
|
||||
* 舊實例自癒:在認證儲存(D61 舊家,CF Workers Secrets)找到的既有帳號,搬進 KBDB(新家)。
|
||||
* best-effort——搬不動(KBDB 不可達)不影響這次登入,只是下次還會再走一次舊路。
|
||||
* 沿用 #55「第一版不做跨版本遷移機制」的落地方式:**用一次成功的登入把自己搬過去**。
|
||||
* 回傳搬遷後的新 record_id;搬不動回 null(呼叫端沿用舊 record_id 繼續,讀路徑仍然通,
|
||||
* 只是這次的 session 仍會落在舊家,下次登入會再試一次)。
|
||||
*/
|
||||
async function promoteLegacyUser(env: Bindings, rec: PortalRecord): Promise<void> {
|
||||
async function promoteToKbdb(env: Bindings, rec: PortalRecord): Promise<string | null> {
|
||||
try {
|
||||
const email = (rec.values.email ?? '').toLowerCase();
|
||||
if (!email) return;
|
||||
if (findAuthUserByEmail(env, email)) return;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (data.users.some((u) => u.email === email)) return;
|
||||
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
|
||||
if (!email) return null;
|
||||
const already = await findKbdbUserRecordId(env, email);
|
||||
if (already) return already; // 更早一次登入已經搬過了,不重複建
|
||||
return await createKbdbUserRecord(env, email, {
|
||||
display_name: rec.values.display_name ?? '',
|
||||
status: rec.values.status ?? 'active',
|
||||
role: rec.values.role ?? 'user',
|
||||
password_hash: rec.values.password_hash ?? '',
|
||||
libraries: rec.values.libraries ?? '[]',
|
||||
created_at: rec.values.created_at ?? new Date().toISOString(),
|
||||
updated_at: rec.values.updated_at ?? new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
/* 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來) */
|
||||
return null; // 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來)
|
||||
}
|
||||
}
|
||||
|
||||
/** email → user record_id。**新家優先**;找不到才回退舊家(KBDB),並順手搬過去。 */
|
||||
/** email → user record_id。**KBDB(新家)優先**;找不到才回退舊家(認證儲存)。 */
|
||||
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
const inStore = findAuthUserByEmail(env, email);
|
||||
if (inStore) return inStore.id;
|
||||
return findLegacyUserRecordId(env, email);
|
||||
const inKbdb = await findKbdbUserRecordId(env, email);
|
||||
if (inKbdb) return inKbdb;
|
||||
return findAuthUserByEmail(env, email)?.id ?? null;
|
||||
}
|
||||
|
||||
/** 舊家(KBDB)的 email → record_id(design §2.3 head entry O(1) 查找)。 */
|
||||
async function findLegacyUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
/** KBDB 的 email → record_id(design §2.3 head entry O(1) 查找)。 */
|
||||
async function findKbdbUserRecordId(env: Bindings, email: string): Promise<string | null> {
|
||||
const ns = portalNamespace(env);
|
||||
const params = new URLSearchParams({
|
||||
page_name: email,
|
||||
@@ -248,7 +285,8 @@ async function findLegacyUserRecordId(env: Bindings, email: string): Promise<str
|
||||
}
|
||||
|
||||
async function getRecordById(env: Bindings, recordId: string): Promise<PortalRecord | null> {
|
||||
// D61:住新家的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到)
|
||||
// 舊家(D61 認證儲存)的帳號:零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到,
|
||||
// 這正是它當初被選為「不可能因重裝而不見」的理由——見 lib/portal-auth-store.ts 檔頭)。
|
||||
if (isAuthStoreId(recordId)) {
|
||||
const u = findAuthUserById(env, recordId);
|
||||
return u ? authUserToRecord(u) : null;
|
||||
@@ -261,7 +299,9 @@ async function getRecordById(env: Bindings, recordId: string): Promise<PortalRec
|
||||
}
|
||||
|
||||
async function patchRecordValues(env: Bindings, recordId: string, values: Record<string, string>): Promise<PortalRecord> {
|
||||
// D61:住新家的帳號改寫進 Workers Secrets(改密碼/停用/改權限都在這條路上)
|
||||
// 舊家(D61 認證儲存)的帳號仍改寫進 Workers Secrets(需要 CF_SECRETS_API_TOKEN;
|
||||
// 沒有 token 就誠實拋 AuthStoreWriteError → run() 轉 502。這批帳號會在下次登入時
|
||||
// 自動搬進 KBDB(見 promoteToKbdb),搬完之後就落進下面的 KBDB PATCH 分支。
|
||||
if (isAuthStoreId(recordId)) {
|
||||
let updated: AuthUserRecord | null = null;
|
||||
await mutateAuthStore(env, (data) => {
|
||||
@@ -308,24 +348,25 @@ function daemonActiveKey(env: Bindings): string {
|
||||
}
|
||||
|
||||
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
// D61:帳號清單=新家為主,舊家(KBDB)尚未搬走的補在後面(同 email 以新家為準)。
|
||||
// 舊家讀不到不算失敗——認證已經不靠它了,這裡只是把還沒搬完的人也列出來。
|
||||
// 帳號清單=KBDB(新家)為主,認證儲存(舊家)尚未搬走的補在後面(同 email 以新家為準)。
|
||||
// 新家讀不到(KBDB 不可達)不算失敗——舊家仍是完整真相源之一,這裡只是把還沒搬完的人也列出來。
|
||||
if (template === USER_TEMPLATE) {
|
||||
const fromStore = readAuthStore(env).users.map(authUserToRecord);
|
||||
const seen = new Set(fromStore.map((r) => (r.values.email ?? '').toLowerCase()));
|
||||
let legacy: PortalRecord[] = [];
|
||||
let fromKbdb: PortalRecord[] = [];
|
||||
try {
|
||||
legacy = await listLegacyRecordsByTemplate(env, template);
|
||||
fromKbdb = await listKbdbRecordsByTemplate(env, template);
|
||||
} catch {
|
||||
legacy = [];
|
||||
fromKbdb = [];
|
||||
}
|
||||
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()))];
|
||||
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? '').toLowerCase()));
|
||||
const fromLegacy = readAuthStore(env).users.map(authUserToRecord)
|
||||
.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()));
|
||||
return [...fromKbdb, ...fromLegacy];
|
||||
}
|
||||
return listLegacyRecordsByTemplate(env, template);
|
||||
return listKbdbRecordsByTemplate(env, template);
|
||||
}
|
||||
|
||||
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」仍走這條,那些本來就該住知識庫)。 */
|
||||
async function listLegacyRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」也走這條,那些本來就只住知識庫)。 */
|
||||
async function listKbdbRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} → ${res.status}`);
|
||||
@@ -342,27 +383,44 @@ interface CreateUserInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* 建帳號。**D61 起一律建在認證儲存(Workers Secrets),不再寫進 KBDB。**
|
||||
* 寫入路徑未就緒就誠實拋錯(AuthStoreWriteError → 502),不偷偷退回舊家——
|
||||
* 退回去等於這個帳號下次搬資料時又會不見,那正是本案要根治的病。
|
||||
* 建 portal_user record(子 namespace)+ email head entry(design §2.3)——低階寫入,
|
||||
* `createPortalUser`(一般建帳號)與 `promoteToKbdb`(舊帳號搬遷)共用同一條寫入路徑。
|
||||
*/
|
||||
async function createKbdbUserRecord(env: Bindings, email: string, values: Record<string, string>): Promise<string> {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, '/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } }),
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`POST /records(portal_user)→ ${res.status}`);
|
||||
const body = (await res.json()) as { record?: { record_id: string } };
|
||||
const recordId = body.record?.record_id;
|
||||
if (!recordId) throw new KbdbError('POST /records 回應缺 record_id');
|
||||
|
||||
// head entry:page_name=email(indexed)→ content=record_id,O(1) 登入查找
|
||||
const head = await kbdbFetch(env, '/entries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns }),
|
||||
});
|
||||
if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`);
|
||||
return recordId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建帳號。**2026-08-14 起改回一律建在 KBDB**(binding,走 base HTTP API,D38 零 SQL),
|
||||
* 不再需要外部 `CF_SECRETS_API_TOKEN`——這正是本次補的結構性缺口(見檔頭「D61 補充」)。
|
||||
*/
|
||||
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
|
||||
const now = new Date().toISOString();
|
||||
const id = newAuthUserId();
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.users.push({
|
||||
id,
|
||||
email: input.email.toLowerCase(),
|
||||
display_name: input.display_name,
|
||||
status: 'active',
|
||||
role: input.role,
|
||||
libraries: input.libraries,
|
||||
password_hash: input.password_hash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return createKbdbUserRecord(env, input.email.toLowerCase(), {
|
||||
display_name: input.display_name,
|
||||
status: 'active',
|
||||
role: input.role,
|
||||
password_hash: input.password_hash,
|
||||
libraries: JSON.stringify(input.libraries),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── user 值域 helpers ──────────────────────────────────────────────────────
|
||||
@@ -575,15 +633,15 @@ async function clearLoginFail(env: Bindings, email: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:這台實例是不是「一個帳號都沒有」(新家空、舊家也空/讀不到)。
|
||||
* 只在「查無此帳號」時才呼叫,不進正常登入熱路徑。
|
||||
* 這台實例是不是「一個帳號都沒有」(KBDB 新家空、認證儲存舊家也空/讀不到)。
|
||||
* 只在「查無此帳號」時才呼叫,不進正常登入熱路徑(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||
*/
|
||||
async function instanceHasNoAuthData(env: Bindings): Promise<boolean> {
|
||||
if (readAuthStore(env).users.length > 0) return false;
|
||||
if (readAuthStore(env).users.length > 0) return false; // 舊家還有尚未搬遷的帳號
|
||||
try {
|
||||
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
|
||||
} catch {
|
||||
return true; // 舊家讀不到 + 新家空 = 這台實例確實沒有可用的登入資料
|
||||
return true; // KBDB 讀不到 + 舊家空 = 這台實例確實沒有可用的登入資料
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,7 +692,7 @@ portalRouter.post('/portal/login', (c) =>
|
||||
|
||||
const { recordId, rec, ok } = await findAndVerifyUser(c.env, email, password);
|
||||
if (!recordId || !rec) {
|
||||
// D61 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
|
||||
// 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
|
||||
// 「這台實例一個帳號都沒有」跟「你密碼打錯」是兩件事,不准混成同一句話——
|
||||
// 2026-08-09 leo 就是被這個誤判鎖了 15 分鐘,而他的密碼從頭到尾都是對的。
|
||||
// ⇒ 回一個**分得出來**的錯,而且**不計入鎖定**。
|
||||
@@ -645,7 +703,7 @@ portalRouter.post('/portal/login', (c) =>
|
||||
'這台實例讀不到任何登入資料——不是密碼錯。認證儲存是空的,' +
|
||||
'請重新執行安裝/更新以重新建立管理員帳號。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
auth_store: { home: 'kbdb', writable: true, users: 0 },
|
||||
},
|
||||
503,
|
||||
);
|
||||
@@ -661,14 +719,20 @@ portalRouter.post('/portal/login', (c) =>
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
// D61 自癒:這次是拿舊家(KBDB)的帳號登進來的 → 順手搬進認證儲存,
|
||||
// 下次換庫/換租戶代號就不會再把他鎖在門外。
|
||||
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
|
||||
// 自癒:這次是拿舊家(D61 認證儲存)的帳號登進來的 → 順手搬進 KBDB(新家),
|
||||
// 且**這次登入發出的 session 就直接指向新 record_id**(搬遷成功的話)——不必等
|
||||
// 下一次登入,密碼變更/admin 編輯這類寫入這次就已經走得到不需要外部 token 的 KBDB 路。
|
||||
// 搬不動(KBDB 不可達)就沿用舊 record_id,讀路徑仍然通,只是寫入仍會走舊家那格。
|
||||
let sessionRecordId = recordId;
|
||||
if (isAuthStoreId(recordId)) {
|
||||
const migrated = await promoteToKbdb(c.env, rec);
|
||||
if (migrated) sessionRecordId = migrated;
|
||||
}
|
||||
|
||||
await clearLoginFail(c.env, email);
|
||||
const token = randomHex(32);
|
||||
// session 值只存 record_id(design §4.3)——權限/狀態每請求回讀 record,不快取進 session
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), {
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
|
||||
expirationTtl: sessionTtl(c.env),
|
||||
});
|
||||
return c.json({
|
||||
@@ -1052,6 +1116,8 @@ portalRouter.post('/portal/admin/bootstrap', (c) =>
|
||||
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
|
||||
if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);
|
||||
|
||||
// 2026-08-14 起:createPortalUser 寫 KBDB(binding),不再需要安裝精靈遞任何臨時
|
||||
// CF token(arcrun-rag#99 那個結構性缺口已隨儲存層搬回 binding 一併解掉)。
|
||||
const recordId = await createPortalUser(c.env, {
|
||||
email,
|
||||
display_name: displayName,
|
||||
@@ -1243,7 +1309,8 @@ function toPublicLibrary(rec: PortalRecord) {
|
||||
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
|
||||
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
|
||||
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。
|
||||
// body {email, password, page_name, text}。認證同 /portal/daemon/config(帳密)。
|
||||
// body {page_name, text}(舊 daemon)或 {page_name, text, prompt}(Arcrun#134 起:
|
||||
// daemon 自帶提示詞,回應改回 {success, output} 原文)。認證=X-Arcrun-API-Key(見下)。
|
||||
//
|
||||
// 🔴 t181(leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**——
|
||||
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」):
|
||||
@@ -1282,41 +1349,67 @@ portalRouter.post('/portal/daemon/extract', (c) =>
|
||||
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as
|
||||
| { page_name?: string; text?: string }
|
||||
| { page_name?: string; text?: string; prompt?: string }
|
||||
| null;
|
||||
const pageName = String(body?.page_name ?? '').trim();
|
||||
const srcText = String(body?.text ?? '');
|
||||
if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 400);
|
||||
// 🔴 Arcrun#134(2026-08-15):新版 daemon **自帶完整提示詞**(`prompt` 欄位)。
|
||||
//
|
||||
// 為什麼:卡片格式契約原本存在兩份——daemon 端 gemmaPrompt(Go)與本端點(TS),
|
||||
// 靠註解叮嚀「兩邊要一起改」同步。2026-08-15 daemon 端改成「LLM 只回 JSON 判斷、
|
||||
// wikishape.go 機械組卡」(InkStoneCo#44 ④),本端點沒跟上 ⇒ 免金鑰預設路
|
||||
// (=多數用戶實際走的路)繼續產舊格式卡——**叮嚀式同步實證失效,這張票就是證據**。
|
||||
//
|
||||
// ⇒ 格式契約唯一真相源改為 daemon(提示詞+JSON 解析+組卡同住 collector/,
|
||||
// 不可能再彼此漂移);本端點在 prompt 模式下只是「用實例自己的 env.AI 跑一段
|
||||
// 生成」的執行器,**不認得也不需要認得卡的形狀**——與 cypher TS「只做 routing
|
||||
// +提供能力、不長業務邏輯」的分工一致。
|
||||
// 安全面不變:認證同上(租戶識別 key)、只生成、不寫資料、用戶自己的 AI 額度;
|
||||
// `text` 本來就是任意內容,本欄位沒有放大任何攻擊面。
|
||||
// 相容:舊 daemon 不帶 prompt ⇒ 走下方 legacy 路(行為與回應完全不變)。
|
||||
const daemonPrompt = String(body?.prompt ?? '').trim();
|
||||
if (!daemonPrompt && (!pageName || !srcText.trim()))
|
||||
return c.json({ error: 'page_name 與 text 必填' }, 400);
|
||||
|
||||
if (!c.env.AI) {
|
||||
// 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠)
|
||||
return c.json({ error: '這個部署沒有綁定 Workers AI(wrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
|
||||
}
|
||||
|
||||
// 提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」),
|
||||
// 兩邊要一起改;daemon 端在 collector/extract_gemma.go。
|
||||
// legacy 提示詞:只服務**還沒更新的舊 daemon**(無 prompt 欄位)。舊 daemon 期待
|
||||
// 「# <頁名>」開頭的 markdown 卡(收端 lint 新舊雙軌仍接受);新格式不再從這裡出生,
|
||||
// 所以本段刻意凍結、不再演進——要改卡的形狀,去 daemon 端 collector/ 改那唯一一份。
|
||||
// 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。
|
||||
const REL = '>'.repeat(2);
|
||||
const prompt =
|
||||
daemonPrompt ||
|
||||
`把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` +
|
||||
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
|
||||
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
|
||||
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B(3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
|
||||
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
|
||||
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
|
||||
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B(3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
|
||||
|
||||
try {
|
||||
// 模型與 workers_ai_chat recipe 同一支(選型實測見 api-recipe-seeds.ts:140:
|
||||
// llama-4-scout 2373ms/答案最完整;對照 Gemini gemma-4-31b-it 16.87 秒且吐英文草稿)。
|
||||
// prompt 模式放寬 max_tokens 到 8192:對齊 gemma 路的 generationConfig——
|
||||
// JSON 契約要裝下「文件總覽+N 個原子概念」,2048 會把 JSON 攔腰截斷(解析必失敗)。
|
||||
const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', {
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 2048,
|
||||
max_tokens: daemonPrompt ? 8192 : 2048,
|
||||
temperature: 0.2,
|
||||
} as never)) as { response?: string } | undefined;
|
||||
const card = String(out?.response ?? '').trim();
|
||||
if (!card) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
|
||||
const raw = String(out?.response ?? '').trim();
|
||||
if (!raw) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
|
||||
if (daemonPrompt) {
|
||||
// 原文回傳(`output` 欄位):淨化與解析屬於契約,契約住 daemon ⇒ 這裡一個字都不動。
|
||||
// daemon 以「回應裡有沒有 output」分辨雲端新舊:舊雲端只會回 `card`(見下),
|
||||
// 新 daemon 據此 fallback 到 legacy 卡處理——兩個方向的版本歪斜都有路可走。
|
||||
return c.json({ success: true, output: raw });
|
||||
}
|
||||
// 淨化:模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起(同 daemon cleanGemmaCard)
|
||||
const marker = `# ${pageName}`;
|
||||
const idx = card.lastIndexOf(marker);
|
||||
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' });
|
||||
const idx = raw.lastIndexOf(marker);
|
||||
return c.json({ success: true, card: (idx >= 0 ? raw.slice(idx) : raw).trim() + '\n' });
|
||||
} catch (e) {
|
||||
return c.json({ error: `Workers AI 執行失敗:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
@@ -1409,7 +1502,6 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const tenant = portalTenant(c.env);
|
||||
// t176(leo 08-03 架構翻案):**不再下發任何 LLM 設定**(extractor/金鑰/模型)。
|
||||
// 地端用哪個模型、哪把金鑰,由使用者在同步小幫手的托盤「AI 設定…」自己設。
|
||||
//
|
||||
@@ -1422,9 +1514,12 @@ portalRouter.post('/portal/daemon/config', (c) =>
|
||||
//
|
||||
// ⚠️ 只拔 LLM 欄位——連線欄位(cypher_url/namespace/library)與本 route 本身照舊,
|
||||
// daemon 靠它上線;資料夾/庫管理(daemon/libraries)也完全不動(leo 明確劃界)。
|
||||
// #108:這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。
|
||||
// 以前給的是帳號層字串(CONSOLE_TENANT),與 CLI/MCP 用的實例 namespace 是兩個來源
|
||||
// ⇒ 寫進去的地方和讀出來的地方可以各自漂。改成同一個 knowledgeOwner,一台實例一個值。
|
||||
const daemonCfg: Record<string, string> = {
|
||||
cypher_url: new URL(c.req.url).origin,
|
||||
namespace: tenant,
|
||||
namespace: knowledgeOwner(c.env),
|
||||
library: 'kb',
|
||||
email,
|
||||
instance_name: String(rec.values.display_name ?? ''),
|
||||
@@ -1456,7 +1551,7 @@ portalRouter.post('/portal/admin/chat-key', (c) =>
|
||||
const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
|
||||
const key = String(body?.key ?? '').trim();
|
||||
if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
|
||||
const tenant = portalTenant(c.env);
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const kvKey = `${tenant}:wf:rag_chat`;
|
||||
const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
|
||||
if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
|
||||
@@ -1518,8 +1613,8 @@ portalRouter.get('/portal/admin/libraries', (c) =>
|
||||
// t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
|
||||
// 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
|
||||
try {
|
||||
const tenant = portalTenant(c.env);
|
||||
const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
|
||||
const tenant = knowledgeOwner(c.env);
|
||||
const ownerParam = ownerQuery(tenant);
|
||||
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
||||
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
||||
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
||||
@@ -1712,8 +1807,8 @@ portalRouter.get('/portal/admin/execution-log-retention', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const ownerId = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`);
|
||||
const data = (await res.json()) as { retention_days?: number | null; default_days?: number };
|
||||
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
|
||||
@@ -1732,10 +1827,10 @@ portalRouter.put('/portal/admin/execution-log-retention', (c) =>
|
||||
if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
|
||||
return c.json({ error: 'retention_days 必須是正整數,或 null(代表不刪除)' }, 400);
|
||||
}
|
||||
const ownerId = portalTenant(c.env);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, '/execution-log/retention', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ owner_id: ownerId, retention_days: days === undefined ? null : days }),
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === undefined ? null : days }),
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`);
|
||||
const data = (await res.json()) as { retention_days?: number | null };
|
||||
@@ -1756,10 +1851,10 @@ portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
|
||||
const confirm = String(body?.confirm ?? '').trim();
|
||||
if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400);
|
||||
if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
|
||||
const ownerId = portalTenant(c.env);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ owner_id: ownerId, library: name }),
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name }),
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
|
||||
const data = (await res.json()) as { deprecated_count?: number };
|
||||
@@ -1850,16 +1945,19 @@ export interface DiagnosticsCore {
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** tenant=owner_id(session 版傳 portalTenant(env);daemon 版傳 X-Arcrun-API-Key 原值,見下方呼叫端)。 */
|
||||
export async function buildDiagnostics(env: Bindings, tenant: string): Promise<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[] = [];
|
||||
|
||||
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
|
||||
let embedding: Record<string, unknown> = { checked: false };
|
||||
try {
|
||||
const [statusRes, selftestRes] = await Promise.all([
|
||||
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`),
|
||||
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`),
|
||||
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant)}`),
|
||||
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant)}`),
|
||||
]);
|
||||
const statusBody = (await statusRes.json().catch(() => null)) as
|
||||
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
|
||||
@@ -1895,7 +1993,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
|
||||
// - GET /records/triplet-stats:per-library 即時聚合 SQL(t142,COUNT,非快取)。
|
||||
let library_count = 0;
|
||||
let triplet_count = 0;
|
||||
const ownerParam = new URLSearchParams({ owner_id: tenant }).toString();
|
||||
const ownerParam = ownerQuery(tenant);
|
||||
try {
|
||||
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
||||
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
||||
@@ -1929,7 +2027,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise<D
|
||||
let library_scope_check: Record<string, unknown> = { ran: false };
|
||||
if (library_count === 0 && triplet_count === 0) {
|
||||
try {
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant, limit: '1' }).toString()}`);
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant), limit: '1' }).toString()}`);
|
||||
const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null;
|
||||
const total = probeBody?.total ?? 0;
|
||||
library_scope_check = {
|
||||
@@ -1976,7 +2074,8 @@ portalRouter.get('/portal/daemon/diagnostics', (c) =>
|
||||
run(c, async () => {
|
||||
const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
|
||||
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
const core = await buildDiagnostics(c.env, apiKey);
|
||||
// 這條路的租戶來自**請求本身**(小幫手帶的 namespace),不是環境變數 → 沒有 #108 的漂移問題。
|
||||
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
|
||||
return c.json({
|
||||
generated_at: new Date().toISOString(),
|
||||
instance_url: new URL(c.req.url).origin,
|
||||
|
||||
@@ -91,6 +91,20 @@ export type Bindings = {
|
||||
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
|
||||
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
|
||||
CONSOLE_TENANT?: string;
|
||||
/**
|
||||
* 這台實例的**知識命名空間**(Arcrun#108)=使用者 `~/.arcrun/config.yaml` 的 `api_key`。
|
||||
*
|
||||
* 由 `acr init/update`(cli/src/lib/deploy.ts CLI_MANAGED_VARS)自動注入,**使用者不必手動維護**:
|
||||
* 它就是 CLI push workflow(`{ns}:wf:*`)、小幫手上傳知識(`owner_id=ns`)、MCP Bearer
|
||||
* 用的同一個值 ⇒ 讀取端用它過濾,永遠對得上寫入端。
|
||||
*
|
||||
* 為什麼不沿用 `CONSOLE_TENANT`:那是 repo toml 帶的**官方 prod 值**(`leo`),
|
||||
* self-hosted 實例的資料根本不在它底下(#108 實撞:1854 條被過濾成 0),
|
||||
* 而且 `CONSOLE_TENANT` 同時還是帳號子 namespace(`{tenant}::portal`)的組成,
|
||||
* 改它會讓舊實例登不進去。兩件事拆成兩個 var,各自對應各自的真相源。
|
||||
* 解析邏輯只在 `src/lib/tenant.ts`(唯一產地,機械閘看守)。
|
||||
*/
|
||||
ARCRUN_NAMESPACE?: string;
|
||||
// Console 顯示品牌/實例名(Arcrun#21 rebrand,非機密)。只影響 UI 字樣(title/header/logo),
|
||||
// 不影響任何行為。未設 → "Arcrun"(console 是引擎共用件,不寫死產品名)。
|
||||
// 實例可覆蓋,例:arcrun-rag demo 可設 --var CONSOLE_BRAND:"Arcrun RAG"。
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
/**
|
||||
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過)
|
||||
* console-auth.ts —— 舊實例相容(帳密還在 D61 的認證儲存,尚未搬回 SESSIONS_KV)
|
||||
*
|
||||
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
|
||||
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
|
||||
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
|
||||
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
|
||||
* 2026-08-14 起(D61 補充,leo confirm「走C」):console 帳密的家改回 SESSIONS_KV(binding)。
|
||||
* 已經在跑 D61(帳密住 CF Workers Secrets)的實例不能因為這次改動而登不進去——讀取認證儲存
|
||||
* 零成本、零外部憑證需求(只有寫入才需要 CF_SECRETS_API_TOKEN),所以永遠讀得到;讀到就
|
||||
* 順手搬回 SESSIONS_KV(best-effort,binding put 幾乎不會失敗)。
|
||||
*
|
||||
* 拆成獨立檔案的理由:portal-auth-store.ts 內部沒有跨測試檔案共享的可變狀態問題(每個測試
|
||||
* 檔案是獨立 worker 執行個體),但為了跟 console-auth.test.ts(一開始就會把帳密設進
|
||||
* SESSIONS_KV)的「全新、尚未設定」起始狀態互不干擾,仍分開一支檔案,語意更清楚。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { mutateAuthStore } from '../src/lib/portal-auth-store';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const CREDS_KEY = 'console:credentials';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
@@ -27,18 +31,12 @@ function json(method: string, path: string, body?: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
/** 種一筆進 D61 認證儲存(CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP route。 */
|
||||
function mockLegacySecretsWrite(): void {
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
.reply(200, { success: true });
|
||||
}
|
||||
|
||||
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
|
||||
@@ -57,45 +55,45 @@ const EMAIL = 'legacy-owner@example.com';
|
||||
const PASSWORD = 'legacy-owner-pw-1';
|
||||
const SALT = 'deadbeef00112233';
|
||||
|
||||
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
|
||||
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
|
||||
// 🔴 全部收在**同一個 `it()`** 裡(不拆成多則):`isolatedStorage`(vitest-pool-workers 預設開)
|
||||
// 在每一個 `it()` 前重置 SESSIONS_KV,但 D61 認證儲存的模組級記憶體變數 `overlay` 不受影響
|
||||
// (見 lib/portal-auth-store.ts 檔頭)。若拆成多則,「搬回 SESSIONS_KV 後再打一次直接命中
|
||||
// 新家」這一步在下一個 `it()` 會因為 KV 被重置而又落回 legacy-secrets 分支,驗不出「新家優先」
|
||||
// 這件事——所以要在同一次測試、同一份 KV 狀態內連續打兩次才驗得出來。
|
||||
describe('舊實例相容:console 帳密只在認證儲存(尚未搬回 SESSIONS_KV)', () => {
|
||||
it('GET /console/auth-status 讀到認證儲存這筆、順手搬回 SESSIONS_KV;再打一次直接命中新家;帳密登得進去', async () => {
|
||||
const hash = await legacyHash(PASSWORD, SALT);
|
||||
await env.SESSIONS_KV.put(
|
||||
CREDS_KEY,
|
||||
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
|
||||
);
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
mockLegacySecretsWrite();
|
||||
await mutateAuthStore(env as unknown as Bindings, (data) => {
|
||||
data.console = { email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' };
|
||||
});
|
||||
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as {
|
||||
configured: boolean;
|
||||
credentials_source: string;
|
||||
auth_store: { console_configured: boolean };
|
||||
auth_store: { legacy_secrets_present: boolean };
|
||||
};
|
||||
expect(data.configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
|
||||
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成,
|
||||
// 故 authStoreStatus 已經反映搬遷後的狀態
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('legacy-secrets'); // 這次是靠回退讀到的
|
||||
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
|
||||
expect(data.auth_store.legacy_secrets_present).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
|
||||
expect(shard.console.email).toBe(EMAIL);
|
||||
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
|
||||
});
|
||||
const kvRaw = await env.SESSIONS_KV.get('console:credentials');
|
||||
expect(kvRaw).toBeTruthy();
|
||||
const stored = JSON.parse(kvRaw!) as { email: string; hash: string };
|
||||
expect(stored.email).toBe(EMAIL);
|
||||
expect(stored.hash).toBe(hash); // 原樣搬過去,不重新雜湊
|
||||
|
||||
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
const data = (await res.json()) as { credentials_source: string };
|
||||
expect(data.credentials_source).toBe('secrets');
|
||||
});
|
||||
// 搬回後再打一次:SESSIONS_KV 已經有了,直接命中新家(不用再查認證儲存)
|
||||
const res2 = await json('GET', '/console/auth-status');
|
||||
const data2 = (await res2.json()) as { credentials_source: string };
|
||||
expect(data2.credentials_source).toBe('kv');
|
||||
|
||||
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
// 用搬回去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)
|
||||
const login = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
expect((await login.json() as { success: boolean }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,81 +1,64 @@
|
||||
/**
|
||||
* console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55)
|
||||
* console-auth.ts 測試(2026-08-14 起:console 管理員帳密改回住 SESSIONS_KV,
|
||||
* D61 補充/pending-changes.md「認證儲存要不要搬回 D1/KV」,leo confirm「走C」)
|
||||
*
|
||||
* 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials`
|
||||
* (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失
|
||||
* (console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。
|
||||
* D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。
|
||||
* 這組帳密(/console/setup、/console/login…)曾在 D61(ADR D61 / Leo/arcrun-rag#55)搬進
|
||||
* 認證儲存(CF Workers Secrets)以躲開「重裝時 binding 被重新指到新建空資源」的病根——但
|
||||
* Workers Secrets 的寫入需要外部 `CF_SECRETS_API_TOKEN`,這把 token 從安裝那天起就沒被種過,
|
||||
* 於是每一台全新實例永遠建不出第一個帳號(arcrun-rag#99)。病根本身已被更通用的
|
||||
* `shared/resource-rule`(Arcrun#97)解掉,故改回 SESSIONS_KV(binding,永不需要外部 token)。
|
||||
* 認證儲存留為舊實例的回退讀路徑,見 tests/console-auth-legacy.test.ts。
|
||||
*
|
||||
* 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋):
|
||||
* 覆蓋:
|
||||
* 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。
|
||||
* 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版
|
||||
* 2. 首次設定成功:POST /console/setup 寫進 SESSIONS_KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(明顯失敗,取代舊版
|
||||
* 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。
|
||||
* 4. 登入對錯:帳密正確 200;密碼錯 401。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進新家;換密碼後舊密碼立即失效。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進 SESSIONS_KV;換密碼後舊密碼立即失效。
|
||||
*
|
||||
* 認證儲存寫入會呼叫 `https://api.cloudflare.com/.../secrets`(PUT),走 fetchMock 假 host
|
||||
* 攔截(同 portal-auth.test.ts 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設
|
||||
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。
|
||||
* 全程不需要任何 CF API mock——SESSIONS_KV 是 binding,wrangler.test.toml 已提供 mock KV。
|
||||
*
|
||||
* ⚠️ 測試順序不可打亂:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某則測試讓 /console/setup 或 reset 真的寫成功,overlay.console 就會在**這支檔案**
|
||||
* 剩下的測試裡持續存在(同檔案不會在測試之間重置模組全域,只有 KV/D1 等 storage 才有
|
||||
* isolatedStorage 重置)。因此本檔刻意排成一條線性故事:先驗證「全新、尚未設定」的分支,
|
||||
* 再做一次成功的 /console/setup(之後永久變成「已設定」),後面的測試都建立在這個已設定
|
||||
* 的基礎上。「帳密只存在舊 KV(尚未搬遷過)」這個分支需要 overlay 是空的,因此另開一支
|
||||
* 檔案 tests/console-auth-legacy.test.ts(不同檔案=不同 worker 執行個體,狀態不互相污染)。
|
||||
* 🔴 每則測試自帶前置狀態(不依賴前一則測試的寫入殘留):`@cloudflare/vitest-pool-workers`
|
||||
* 的 `isolatedStorage`(預設開)在**每一個 `it()`** 前重置 KV/D1 等 storage bindings——
|
||||
* 這正是本檔要驗證的儲存介質(SESSIONS_KV),故「已設定過」的情境一律在該則測試內先呼叫一次
|
||||
* 真正的 POST /console/setup 自建,不能沿用其他 `it()` 建立的帳密(那套「同檔案內殘留」的
|
||||
* 假設只對 D61 認證儲存的模組級記憶體變數 `overlay` 成立,SESSIONS_KV 不是那個機制)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { SELF, env } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
function json(method: string, path: string, body?: unknown) {
|
||||
return SELF.fetch(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,那邊有完整說明)。 */
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
/** 每則測試自建一組帳密(POST /console/setup),回傳供後續斷言使用。 */
|
||||
async function setupOwner(email: string, password: string): Promise<void> {
|
||||
const res = await json('POST', '/console/setup', { email, password });
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const OWNER_EMAIL = 'owner@example.com';
|
||||
const OWNER_PW = 'owner-first-pw-1';
|
||||
|
||||
// ═══════════════ 1. 全新實例(尚未設定過,必須排最前面)═══════════════
|
||||
// ═══════════════ 1. 全新實例(尚未設定過任何管理員帳密)═══════════════
|
||||
|
||||
describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||
it('GET /console/auth-status → configured:false,不洩漏 email', async () => {
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { present: boolean } };
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { home: string } };
|
||||
expect(data.configured).toBe(false);
|
||||
expect(data.credentials_source).toBe('none');
|
||||
expect(data.auth_store.home).toBe('sessions-kv');
|
||||
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
|
||||
});
|
||||
|
||||
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
|
||||
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(明顯失敗)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(400);
|
||||
const data = (await res.json()) as { code: string; error: string };
|
||||
@@ -91,36 +74,31 @@ describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
|
||||
// ═══════════════ 2. 首次設定:成功寫進 SESSIONS_KV ═══════════════
|
||||
|
||||
describe('POST /console/setup — 首次設定', () => {
|
||||
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
it('成功:寫進 SESSIONS_KV(binding,不需要任何外部 CF token),回 session_token', async () => {
|
||||
const res = await json('POST', '/console/setup', { email: OWNER_EMAIL.toUpperCase(), password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string; tenant: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.session_token).toBe('string');
|
||||
|
||||
// 寫入認證儲存:一片、含小寫 email,明碼密碼絕不落地
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
expect(shards[0].text).not.toContain(OWNER_PW);
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string; salt: string; hash: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL); // 存小寫
|
||||
expect(typeof shard.console.salt).toBe('string');
|
||||
expect(typeof shard.console.hash).toBe('string');
|
||||
|
||||
// D61:不再寫舊 KV——這是本次變更的核心(舊版寫 SESSIONS_KV,重裝就蒸發)
|
||||
expect(await env.SESSIONS_KV.get('console:credentials')).toBeNull();
|
||||
const raw = await env.SESSIONS_KV.get('console:credentials');
|
||||
expect(raw).toBeTruthy();
|
||||
expect(raw).not.toContain(OWNER_PW); // 明碼絕不落地
|
||||
const stored = JSON.parse(raw!) as { email: string; salt: string; hash: string };
|
||||
expect(stored.email).toBe(OWNER_EMAIL); // 存小寫
|
||||
expect(typeof stored.salt).toBe('string');
|
||||
expect(typeof stored.hash).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
|
||||
// ═══════════════ 3. 已設定過 → 409(明顯失敗:說得出「沒有被採用」)═══════════════
|
||||
|
||||
describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/setup', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(409);
|
||||
const data = (await res.json()) as {
|
||||
@@ -130,22 +108,29 @@ describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||
expect(data.password_applied).toBe(false);
|
||||
expect(data.error).toContain('沒有被採用');
|
||||
expect(data.reset_path).toBe('/console/setup/reset');
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
|
||||
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗,原帳密仍有效
|
||||
const attackerLogin = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(attackerLogin.status).toBe(401);
|
||||
const ownerLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(ownerLogin.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /console/auth-status → configured:true,credentials_source:secrets(新家優先命中)', async () => {
|
||||
it('GET /console/auth-status → configured:true,credentials_source:kv', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { writable: boolean } };
|
||||
expect(data.configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('secrets');
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('kv');
|
||||
expect(data.auth_store.writable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
|
||||
// ═══════════════ 4. 登入對錯 ═══════════════
|
||||
|
||||
describe('POST /console/login', () => {
|
||||
it('帳密正確 → 200,發 session token', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
@@ -154,30 +139,27 @@ describe('POST /console/login', () => {
|
||||
});
|
||||
|
||||
it('密碼錯 → 401', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('攻擊者在第 3 節試圖搶注的帳密登不進來(證明真的「沒有被採用」)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 5. /console/setup/reset:換密碼,寫進新家 ═══════════════
|
||||
// ═══════════════ 5. /console/setup/reset:換密碼,寫進 SESSIONS_KV ═══════════════
|
||||
|
||||
describe('POST /console/setup/reset', () => {
|
||||
const NEW_PW = 'brand-new-owner-pw-1';
|
||||
|
||||
it('舊密碼錯 → 401,不寫入', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: 'still-wrong', email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('舊密碼對 → 200,新 hash 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
it('舊密碼對 → 200,新 hash 寫進 SESSIONS_KV;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
@@ -185,11 +167,10 @@ describe('POST /console/setup/reset', () => {
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(NEW_PW); // 明碼不落地
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL);
|
||||
const raw = await env.SESSIONS_KV.get('console:credentials');
|
||||
expect(raw).not.toContain(NEW_PW); // 明碼不落地
|
||||
const stored = JSON.parse(raw!) as { email: string };
|
||||
expect(stored.email).toBe(OWNER_EMAIL);
|
||||
|
||||
// 舊密碼立即失效
|
||||
const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* POST /kbdb/records — `entry_ids` 通道(Arcrun#128)
|
||||
*
|
||||
* 背景:基本盤 kbdb 的 createRecord 現在接受 `entry_ids`(slot 指向**既有** entry 的 id,
|
||||
* 不新建、不複製)。這條 proxy 之前寫死只轉發 `values`,且沒有 values 就 400
|
||||
* ⇒ 走 X-Arcrun-API-Key 的呼叫者(#129 的 wiki 寫入端、#130 的三元組正規化)打不到新能力,
|
||||
* 等於基本盤補好了、通道沒開(同 b6ef0f0 那次 PATCH 的形狀)。
|
||||
*
|
||||
* 驗的是 IO 接線(判斷真身在基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts):
|
||||
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
|
||||
* 2. 只給 entry_ids(沒有 values)→ 轉發成功(舊版這裡是 400)
|
||||
* 3. 轉發的 body:帶 entry_ids + **注入租戶當 owner_id**(caller 自帶的 owner_id 被忽略)
|
||||
* 4. values 與 entry_ids 混用 → 兩個都轉過去
|
||||
* 5. 兩個都沒給 → 400,不轉發
|
||||
* 6. base 擋跨租戶(400)→ 原樣透傳,不假裝成功
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——測試絕不外連。
|
||||
*/
|
||||
import { SELF, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
|
||||
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
describe('POST /kbdb/records(entry_ids)— 租戶閘與參數', () => {
|
||||
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1' } }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('values 與 entry_ids 都沒給 → 400,不轉發', async () => {
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ template: 'wiki' }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /kbdb/records(entry_ids)— 轉發', () => {
|
||||
it('只給 entry_ids(沒有 values)→ 轉發,且 owner_id 由租戶身份注入', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
// 沒有 values 這個 key(不要憑空塞一個空物件給基本盤)
|
||||
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'leo' }),
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
record: { record_id: 'rec_1', template_id: 'tpl-wiki', values: { gloss: '既有', points: '既有2' }, owner_id: 'leo' },
|
||||
});
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
// caller 自帶 owner_id:必須被忽略(防跨租戶寫入,本檔既有慣例)
|
||||
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'alice' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.record.values.gloss).toBe('既有');
|
||||
});
|
||||
|
||||
it('values 與 entry_ids 混用 → 兩個都轉過去', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' }, owner_id: 'leo' }),
|
||||
})
|
||||
.reply(200, { success: true, record: { record_id: 'rec_2', template_id: 'tpl-wiki', values: {}, owner_id: 'leo' } });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('舊呼叫端(只給 values)→ 轉發的 body 不夾帶 entry_ids,行為與過去相同', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({
|
||||
path: '/records',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }),
|
||||
})
|
||||
.reply(200, { success: true, record: { record_id: 'rec_3', template_id: 'tpl-triplet', values: {}, owner_id: 'leo' } });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('base 擋下跨租戶參照(400)→ 原樣透傳,不假裝成功', async () => {
|
||||
fetchMock
|
||||
.get('https://kbdb.test')
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(400, { success: false, error: 'entry owner mismatch: e_x(alice) != leo' });
|
||||
const res = await SELF.fetch('http://localhost/kbdb/records', {
|
||||
method: 'POST',
|
||||
headers: KEY,
|
||||
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_x' } }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = (await res.json()) as { success: boolean; error: string };
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('owner mismatch');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -16,20 +16,20 @@
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證(PR 證據表)。
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55):本檔測試裡的帳號 fixture(rec_admin/rec_u1/rec_admin2…)
|
||||
* 全部沿用「record_id 不是 auth: 開頭」這個既有慣例——這正是 portal.ts 的相容分流點
|
||||
* (isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走原本的 KBDB 路徑,行為與 D61 之前
|
||||
* 完全一致,故本檔絕大多數測試不需要改。**只有「新建帳號」這個動作**(POST /portal/admin/users、
|
||||
* POST /portal/admin/bootstrap 走同一支 createPortalUser)改成寫進認證儲存(CF Workers
|
||||
* Secrets),需要額外攔截 `https://api.cloudflare.com/.../secrets`(PUT)——見 mockAuthStoreWrite。
|
||||
* 2026-08-14 起(D61 補充,leo confirm「走C」):帳號改回住 KBDB(binding,見
|
||||
* pending-changes.md「認證儲存要不要搬回 D1/KV」)。本檔測試裡的帳號 fixture
|
||||
* (rec_admin/rec_u1/rec_admin2…)全部沿用「record_id 不是 auth: 開頭」這個既有慣例——
|
||||
* 這正是 portal.ts 的相容分流點(isAuthStoreId(recordId)),非 auth: 開頭的 id 一律走
|
||||
* KBDB 路徑,行為與 D61 之前完全一致,故本檔絕大多數測試不需要改。
|
||||
* **只有「新建帳號」這個動作**(POST /portal/admin/users、POST /portal/admin/bootstrap
|
||||
* 走同一支 createPortalUser)改回直接寫 KBDB(POST /records + POST /entries head),
|
||||
* 不再需要 `https://api.cloudflare.com/.../secrets`。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
|
||||
|
||||
let storedHash: string;
|
||||
@@ -49,19 +49,21 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
/** 建帳號的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
|
||||
function mockCreateUser(recordId: string): { recordBody: () => string } {
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
|
||||
return { recordBody: () => recordBody };
|
||||
}
|
||||
|
||||
function mockHeadLookup(email: string, recordId: string | null) {
|
||||
@@ -202,11 +204,12 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id)', () => {
|
||||
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
|
||||
|
||||
describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼(D61)', async () => {
|
||||
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
mockHeadLookup('new@example.com', null); // email 未占用
|
||||
const { recordBody } = mockCreateUser('rec_new');
|
||||
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
@@ -218,23 +221,21 @@ describe('POST /portal/admin/users(一次性密碼)', () => {
|
||||
expect(typeof data.generated_password).toBe('string');
|
||||
expect(data.generated_password!.length).toBe(16);
|
||||
expect('password_hash' in data.user).toBe(false);
|
||||
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
|
||||
expect((data.user as { record_id: string }).record_id).toBe('rec_new'); // 住 KBDB
|
||||
|
||||
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(data.generated_password!);
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const stored = shard.users.find((u) => u.email === 'new@example.com');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
// 一次性密碼不落地:KBDB 收到的 record body 只有 hash、無明碼
|
||||
expect(recordBody()).not.toContain(data.generated_password!);
|
||||
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
});
|
||||
|
||||
it('自帶 password → 回應**無** generated_password', async () => {
|
||||
await seedAdminSession();
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
mockHeadLookup('own@example.com', null);
|
||||
mockAuthStoreWrite();
|
||||
mockCreateUser('rec_own');
|
||||
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
|
||||
const res = await json(
|
||||
'POST',
|
||||
'/portal/admin/users',
|
||||
@@ -747,4 +748,22 @@ describe('POST /portal/daemon/extract(t181:Workers AI 萃卡,免金鑰)'
|
||||
expect(raw).not.toContain('gemini_api_key');
|
||||
expect(raw).not.toContain('credential');
|
||||
});
|
||||
|
||||
// 🔴 Arcrun#134:新 daemon 自帶提示詞(卡片格式契約的唯一真相源在 daemon 端
|
||||
// collector/——提示詞+JSON 解析+wikishape 組卡同住一處,才不會像 t181 那樣
|
||||
// 兩份 prompt 靠註解叮嚀同步而漂移)。雲端在 prompt 模式下只是執行器。
|
||||
//
|
||||
// 測試環境刻意沒有 [ai] binding ⇒ 走到 AI 分支必回 501。這裡拿 501 當「驗證已通過、
|
||||
// 已進入 AI 分支」的探針:若有人把 prompt 模式改回要求 page_name/text,這則會看到 400。
|
||||
it('帶 prompt 時不再要求 page_name/text(#134:契約住 daemon 端)', async () => {
|
||||
const res = await json('POST', '/portal/daemon/extract', { prompt: '任意契約內容' }, KEY);
|
||||
expect(res.status).toBe(501); // 不是 400 ⇒ prompt 單獨成立
|
||||
});
|
||||
|
||||
// 相容守衛:舊 daemon(無 prompt 欄位)行為凍結——缺欄仍 400(上面那則),
|
||||
// 齊欄則同樣走到 AI 分支(501 探針)。若這則變 400,代表 legacy 驗證被動到了。
|
||||
it('legacy(無 prompt)齊欄仍走原路(相容不回退)', async () => {
|
||||
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' }, KEY);
|
||||
expect(res.status).toBe(501);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,31 +4,29 @@
|
||||
* 覆蓋(=tasks.md P2 測試項):
|
||||
* 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、
|
||||
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進**認證儲存**(D61);
|
||||
* 已有 admin → 409
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫進 **KBDB**;已有 admin → 409
|
||||
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
|
||||
* 4. 節流:5 次失敗 → 429(KV TTL 計數)
|
||||
* 5. session:每請求回讀 record;停用即拒(既有 session 立即失效)
|
||||
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
|
||||
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
||||
*
|
||||
* D61(ADR D61 / Leo/arcrun-rag#55)補的覆蓋(原本沒有,這次變更的重點):
|
||||
* 2026-08-14 起(D61 補充,leo confirm「走C」,pending-changes.md「認證儲存要不要搬回
|
||||
* D1/KV」):帳號的家從認證儲存(CF Workers Secrets,D61)改回 **KBDB**(binding,走 base
|
||||
* HTTP API,不需要外部 CF token)。額外覆蓋:
|
||||
* 8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」(不是密碼錯),且不計入鎖定
|
||||
* 9. 舊實例相容:帳號只存在 KBDB(舊家)時仍登得進去,登入成功後自動搬進認證儲存
|
||||
* 9. 舊實例相容:帳號只存在認證儲存(D61 舊家)時仍登得進去,登入成功後自動搬進 KBDB,
|
||||
* 且**這次登入發出的 session 就直接指向新 record_id**;搬遷失敗(KBDB 拒寫)不擋登入
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
* disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
|
||||
* 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。
|
||||
*
|
||||
* D61 起,帳號的家從 KBDB 換成認證儲存(CF Workers Secrets)——寫入會呼叫
|
||||
* `https://api.cloudflare.com/.../secrets`(PUT),同樣走 fetchMock 假 host 攔截,不外連。
|
||||
* wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒(比照真實裝妥的實例)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
|
||||
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
|
||||
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
|
||||
import { AUTH_ID_PREFIX, isAuthStoreId, mutateAuthStore } from '../src/lib/portal-auth-store';
|
||||
import { portalRouter } from '../src/routes/portal';
|
||||
import type { Bindings, ExecutionContext } from '../src/types';
|
||||
|
||||
@@ -56,38 +54,13 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* D61:認證儲存的寫入路徑(單元測試層級——一次呼叫=一片,測試資料量小不會觸發溢位分片)。
|
||||
* 攔截 CF Workers Scripts secrets 管理 API 的 PUT,捕捉 body 供斷言(片名/內容)。
|
||||
* 用法:每個會觸發寫入的測試呼叫一次,回傳的 `puts()` 拿到依序捕捉到的 {name, text}[]。
|
||||
*
|
||||
* ⚠️ 讀路徑沒有對應的「seed 進 env」捷徑可用:`cloudflare:test` 的 `env` 物件是傳給
|
||||
* `vitest` 主 context 用的,對 `SELF.fetch()` 打的那個 worker isolate **不生效**(實測驗證,
|
||||
* mutate `env.XXX` 後 SELF 端讀到的仍是 wrangler.test.toml 的原值)。因此「舊實例相容」
|
||||
* 一類的讀路徑測試,一律靠**既有的 KBDB fetchMock**(新家預設空,天然等於「帳號只在舊家」);
|
||||
* 要驗證「新家已經有資料」則靠**真的呼叫一次寫入端點**(bootstrap/新增同仁),讓 portal-auth-store
|
||||
* 模組內的 per-isolate overlay 落地——這個 overlay 在同一支測試檔案裡的後續測試\*也讀得到\*
|
||||
* (模組級全域變數不隨 test 重置,只有 KV/D1 等 storage 才有 isolatedStorage 重置),
|
||||
* 這是刻意善用而非意外:想要「乾淨無帳號」的情境,該測試必須排在檔案裡**第一個寫入動作之前**。
|
||||
*/
|
||||
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
||||
const captured: Array<{ name: string; text: string }> = [];
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, (opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
||||
captured.push(body);
|
||||
return { success: true };
|
||||
})
|
||||
.times(times);
|
||||
return { puts: () => captured };
|
||||
}
|
||||
|
||||
// ── KBDB mock helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1) */
|
||||
function mockHeadLookup(email: string, recordId: string | null) {
|
||||
/**
|
||||
* head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1)。
|
||||
* `times`:`findUserRecordId`/`promoteToKbdb` 都可能各查一次同一個 email,需要幾次命中就傳幾。
|
||||
*/
|
||||
function mockHeadLookup(email: string, recordId: string | null, times = 1) {
|
||||
const needle = new URLSearchParams({ page_name: email }).toString();
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
@@ -96,7 +69,61 @@ function mockHeadLookup(email: string, recordId: string | null) {
|
||||
p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
|
||||
method: 'GET',
|
||||
})
|
||||
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 });
|
||||
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 })
|
||||
.times(times);
|
||||
}
|
||||
|
||||
/** 建 portal_user 的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
|
||||
function mockCreateUser(recordId: string): { recordBody: () => string } {
|
||||
let recordBody = '';
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(200, (opts) => {
|
||||
recordBody = String(opts.body);
|
||||
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
|
||||
});
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/entries', method: 'POST' })
|
||||
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
|
||||
return { recordBody: () => recordBody };
|
||||
}
|
||||
|
||||
/** POST /records 建立失敗(模擬 KBDB 拒寫,用於「搬遷失敗不擋登入」的測試)。 */
|
||||
function mockCreateUserFails(status = 500) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: '/records', method: 'POST' })
|
||||
.reply(status, { success: false, error: 'kbdb write failed (test)' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 種一筆帳號進**認證儲存**(D61 舊家,CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP
|
||||
* route(consoleAuthRouter/portalRouter 現在都不再寫這個家了)。需要攔截一次 CF Secrets PUT
|
||||
* (wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒)。
|
||||
*/
|
||||
async function seedLegacySecretsUser(overrides: Partial<{
|
||||
email: string; display_name: string; status: string; role: string; libraries: string[]; password_hash: string;
|
||||
}> = {}): Promise<void> {
|
||||
fetchMock
|
||||
.get(CF_API)
|
||||
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
||||
.reply(200, { success: true });
|
||||
const now = new Date().toISOString();
|
||||
await mutateAuthStore(env as unknown as Bindings, (data) => {
|
||||
data.users.push({
|
||||
id: `auth:${crypto.randomUUID().replace(/-/g, '')}`,
|
||||
email: (overrides.email ?? EMAIL).toLowerCase(),
|
||||
display_name: overrides.display_name ?? '舊實例同仁',
|
||||
status: overrides.status ?? 'active',
|
||||
role: overrides.role ?? 'user',
|
||||
libraries: overrides.libraries ?? ['general'],
|
||||
password_hash: overrides.password_hash ?? storedHash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mockGetRecord(recordId: string, values: Record<string, string>) {
|
||||
@@ -176,20 +203,20 @@ describe('PBKDF2 模組(lib/portal-auth)', () => {
|
||||
|
||||
// ═══════════════ 1.5 D61:整台實例沒有任何認證資料 ═══════════════
|
||||
//
|
||||
// 🔴 這個 describe 必須留在檔案裡「第一個會寫入認證儲存的測試」之前(下面 2. bootstrap
|
||||
// 的「console session OK」那則)——見 mockAuthStoreWrite 檔頭註解:portal-auth-store.ts
|
||||
// 的 per-isolate overlay 是模組級全域變數,同一支測試檔案跑起來不會在測試之間重置,
|
||||
// 一旦有測試寫入過,後面的測試都會看到那筆資料,「乾淨無帳號」的前提就不成立了。
|
||||
describe('D61:整台實例沒有任何認證資料(arcrun-rag#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
|
||||
// 🔴 這個 describe 必須留在檔案裡「第一個會種入認證儲存(舊家)的測試」之前——見
|
||||
// seedLegacySecretsUser 檔頭註解:portal-auth-store.ts 的 per-isolate overlay 是模組級全域
|
||||
// 變數,同一支測試檔案跑起來不會在測試之間重置,一旦有測試種過舊家資料,後面的測試都會看到
|
||||
// 那筆資料,「乾淨無帳號」的前提就不成立了。
|
||||
describe('整台實例沒有任何認證資料(arcrun-rag#55,leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
|
||||
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
|
||||
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前);
|
||||
// 舊家(KBDB)也回空——head lookup 查無此人+by-template 列表也空,兩邊都沒有帳號,
|
||||
// KBDB(新家)此刻回空——head lookup 查無此人+by-template 列表也空;
|
||||
// 認證儲存(舊家)此刻也還是空的(本測試特意排在任何種子測試之前)——兩邊都沒有帳號,
|
||||
// 才是「這台實例真的沒有認證資料」。
|
||||
mockHeadLookup('anyone@example.com', null);
|
||||
mockListByTemplate('portal_user', []);
|
||||
const res = await json('POST', '/portal/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(503);
|
||||
const data = (await res.json()) as { error: string; code: string; auth_store: { present: boolean; users: number } };
|
||||
const data = (await res.json()) as { error: string; code: string; auth_store: { users: number } };
|
||||
expect(data.code).toBe('auth_store_empty');
|
||||
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
|
||||
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
|
||||
@@ -209,12 +236,12 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB)', async () => {
|
||||
it('console session OK → 建第一個 admin:寫進 KBDB,不需要任何外部 CF token', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin(新家空,舊家也空)
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin(KBDB 空,認證儲存舊家也空)
|
||||
mockHeadLookup('admin@example.com', null); // email 未占用
|
||||
const { recordBody } = mockCreateUser('rec_admin_new');
|
||||
|
||||
const res = await json(
|
||||
'POST',
|
||||
@@ -225,25 +252,18 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.record_id).toBe('string');
|
||||
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61)
|
||||
expect(data.record_id).toBe('rec_admin_new'); // 住 KBDB(新 record_id 來自 KBDB 回應)
|
||||
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1)
|
||||
|
||||
// D61:一次寫入=一片,落進認證儲存(Workers Secrets),不再有 KBDB record/head entry
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as {
|
||||
users: Array<{ email: string; role: string; status: string; libraries: string[]; password_hash: string }>;
|
||||
};
|
||||
expect(shard.users.length).toBe(1);
|
||||
const stored = shard.users[0];
|
||||
expect(stored.email).toBe('admin@example.com');
|
||||
expect(stored.role).toBe('admin');
|
||||
expect(stored.status).toBe('active');
|
||||
expect(stored.libraries).toEqual(['*']);
|
||||
expect(stored.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(shards[0].text).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
|
||||
// 一次 POST /records=一筆,body 含 owner_id 子 namespace,明碼絕不落地
|
||||
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.email).toBe('admin@example.com');
|
||||
expect(rec.values.role).toBe('admin');
|
||||
expect(rec.values.status).toBe('active');
|
||||
expect(JSON.parse(rec.values.libraries)).toEqual(['*']);
|
||||
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
|
||||
expect(recordBody()).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
|
||||
});
|
||||
|
||||
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
|
||||
@@ -263,12 +283,10 @@ describe('POST /portal/admin/bootstrap', () => {
|
||||
// ═══════════════ 3. 登入對錯 ═══════════════
|
||||
|
||||
describe('POST /portal/login', () => {
|
||||
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組舊家 fixture(原本就是),**故意不**在這裡驗證
|
||||
// 「登入成功後搬進新家」——promoteLegacyUser 一旦真的寫成功,會把 EMAIL 留進 overlay,
|
||||
// 而 overlay 是模組級全域、同檔案後面的測試都讀得到,會讓後面每一則「查 KBDB 的 EMAIL」
|
||||
// 全部改成「命中新家」而跳過 KBDB mock,導致假性的 pending-interceptor 骨牌。
|
||||
// 搬遷本身的驗證另開一組使用**專屬、不共用**email 的 describe(見檔案最後
|
||||
// 「D61:舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||||
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組 **KBDB 原生** fixture(非 auth: 開頭 id)——
|
||||
// 2026-08-14 起 KBDB 是新家,這批帳號本來就住在該住的地方,**不會**觸發任何搬遷嘗試
|
||||
// (isAuthStoreId('rec_1') 為 false)。搬遷本身的驗證另開一組使用**專屬、不共用**email
|
||||
// 的 describe(見檔案最後「舊實例登入自癒」),避免污染這裡的既有 fixture。
|
||||
it('成功:發 session token;回 display_name/role/libraries;**無任何租戶字串欄位**', async () => {
|
||||
mockHeadLookup(EMAIL, 'rec_1');
|
||||
mockGetRecord('rec_1', activeUserValues());
|
||||
@@ -286,11 +304,6 @@ describe('POST /portal/login', () => {
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
expect(sess).toBeTruthy();
|
||||
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
|
||||
// D61:promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mock,disableNetConnect 之下
|
||||
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
|
||||
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
|
||||
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
|
||||
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
|
||||
});
|
||||
|
||||
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
|
||||
@@ -305,6 +318,10 @@ describe('POST /portal/login', () => {
|
||||
|
||||
it('未知 email → 401 同樣通用訊息(不洩帳號存在性)', async () => {
|
||||
mockHeadLookup('ghost@example.com', null);
|
||||
// instanceHasNoAuthData 在「查無此帳號」時會確認「是不是整台實例都沒帳號」——
|
||||
// 這裡要證明的是「查無此人」而非「這台實例是空的」,故 by-template 要回非空列表
|
||||
// (這台實例確實有別的帳號,只是不是 ghost@example.com)。
|
||||
mockListByTemplate('portal_user', [{ record_id: 'rec_1', values: activeUserValues() }]);
|
||||
const res = await json('POST', '/portal/login', { email: 'ghost@example.com', password: 'whatever-123' });
|
||||
expect(res.status).toBe(401);
|
||||
const data = (await res.json()) as { error: string };
|
||||
@@ -529,57 +546,52 @@ describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,e
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ D61:舊實例登入自癒(搬進新家)═══════════════
|
||||
// ═══════════════ 舊實例登入自癒(認證儲存舊家 → 搬進 KBDB 新家)═══════════════
|
||||
//
|
||||
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
|
||||
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
|
||||
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||||
// per-isolate overlay 是模組級全域變數,seedLegacySecretsUser 種一次資料就會留在同一支測試
|
||||
// 檔案的後續測試裡(見該函式檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
|
||||
// 不會有更後面的測試共用這個 email 而被污染。
|
||||
describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
|
||||
describe('舊實例登入自癒(帳號只在認證儲存舊家,登入成功後搬進 KBDB 新家)', () => {
|
||||
const LEGACY_EMAIL = 'legacy-promote@example.com';
|
||||
|
||||
it('登入成功;promoteLegacyUser 把這筆帳號寫進認證儲存(一片、含正確 email/hash)', async () => {
|
||||
mockHeadLookup(LEGACY_EMAIL, 'rec_legacy_1');
|
||||
mockGetRecord('rec_legacy_1', activeUserValues({ email: LEGACY_EMAIL }));
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
it('登入成功;自動把這筆帳號寫進 KBDB(含正確 email/hash),且**這次的 session 就指向新 record_id**', async () => {
|
||||
await seedLegacySecretsUser({ email: LEGACY_EMAIL });
|
||||
// KBDB 查無此人:一次給 findUserRecordId(登入查找)、一次給 promoteToKbdb(搬遷前的
|
||||
// 「已經搬過了嗎」確認)——兩次都要 mock,見 mockHeadLookup 的 times 參數說明。
|
||||
mockHeadLookup(LEGACY_EMAIL, null, 2);
|
||||
const { recordBody } = mockCreateUser('rec_legacy_promoted_1');
|
||||
|
||||
const res = await json('POST', '/portal/login', { email: LEGACY_EMAIL, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
|
||||
const promoted = shard.users.find((u) => u.email === LEGACY_EMAIL);
|
||||
expect(promoted).toBeDefined();
|
||||
expect(promoted!.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
|
||||
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
|
||||
expect(rec.owner_id).toBe(NS);
|
||||
expect(rec.values.email).toBe(LEGACY_EMAIL);
|
||||
expect(rec.values.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
|
||||
|
||||
// 這次登入發出的 session 已經指向新 record_id(不必等下一次登入才生效)
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_legacy_promoted_1');
|
||||
});
|
||||
|
||||
it('若新家寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN),照樣登入成功——搬不動不擋門', async () => {
|
||||
// 直接呼叫 router、帶一份缺寫入路徑的 env(health.test.ts 已有的直呼叫慣例),
|
||||
// 證明 promoteLegacyUser 的失敗被 best-effort 吞掉,不影響登入本身。
|
||||
const email = 'legacy-promote-writeless@example.com';
|
||||
mockHeadLookup(email, 'rec_legacy_2');
|
||||
mockGetRecord('rec_legacy_2', activeUserValues({ email }));
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
|
||||
const res = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: PASSWORD }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
it('KBDB 拒寫(搬遷失敗)——照樣登入成功,session 沿用舊家 record_id,搬不動不擋門', async () => {
|
||||
const email = 'legacy-promote-writefail@example.com';
|
||||
await seedLegacySecretsUser({ email });
|
||||
mockHeadLookup(email, null, 2); // 登入查找 + 搬遷前確認,KBDB 兩次都查無此人
|
||||
mockCreateUserFails(500); // 搬遷寫入本身失敗(模擬 KBDB 不可達/拒寫)
|
||||
|
||||
const res = await json('POST', '/portal/login', { email, password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
expect(data.success).toBe(true);
|
||||
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
|
||||
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
|
||||
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
|
||||
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
|
||||
|
||||
// 搬不動 → session 沿用舊家(認證儲存)的 record_id("auth:" 開頭),讀路徑仍然通
|
||||
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
|
||||
const sessRecordId = (JSON.parse(sess!) as { record_id: string }).record_id;
|
||||
expect(isAuthStoreId(sessRecordId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -587,7 +599,7 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
|
||||
//
|
||||
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
|
||||
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
|
||||
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
|
||||
// 而 overlay 是模組級全域變數、不隨 test 重置(見 seedLegacySecretsUser 檔頭長註解)。
|
||||
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
|
||||
|
||||
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
|
||||
@@ -682,3 +694,63 @@ describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
|
||||
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ arcrun-rag#99(2026-08-14):全新安裝從沒種過 CF_SECRETS_API_TOKEN ═══════════════
|
||||
//
|
||||
// 每一台裝好的新實例過去都會在 bootstrap 這裡卡死(leo 本人+封測者都撞到「裝得起來,
|
||||
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。止血版(安裝精靈遞一把
|
||||
// 臨時 OAuth token)只解掉「建第一個帳號」這一格;2026-08-14 confirm 走 C 之後,帳號改回
|
||||
// 住 KBDB(binding),**從根拔掉整個問題**:`x-arcrun-install-token` 這條路已經不存在了,
|
||||
// 因為建帳號這件事本來就不再需要任何 CF API 憑證,無論裝機當下還是裝完之後都一樣。
|
||||
//
|
||||
// 下面這則是回歸守衛:**一台連 `CF_SECRETS_API_TOKEN`/`CF_ACCOUNT_ID` 都沒有的全新實例**,
|
||||
// 從 bootstrap 建第一個帳號、到之後用 /portal/admin/users 加第二個帳號,全程都要成功——
|
||||
// 若哪天有人把帳號寫入路徑又改回去揹 CF Secrets 依賴,這裡會紅。
|
||||
describe('arcrun-rag#99:全新實例(連 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 都沒有)不再卡死', () => {
|
||||
it('bootstrap 建第一個 admin、再用 admin session 加第二個 user,全程零 CF token', async () => {
|
||||
await env.SESSIONS_KV.put('console_sess:owner-token-fresh', JSON.stringify({ created_at: Date.now() }));
|
||||
mockTemplatesExist();
|
||||
mockListByTemplate('portal_user', []); // 尚無 admin
|
||||
mockHeadLookup('fresh-install-admin@example.com', null);
|
||||
const { recordBody: bootstrapBody } = mockCreateUser('rec_fresh_admin');
|
||||
|
||||
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
|
||||
const bootstrapRes = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/admin/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner-token-fresh' },
|
||||
body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-3' }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
expect(bootstrapRes.status).toBe(200);
|
||||
const bootstrapData = (await bootstrapRes.json()) as Record<string, unknown>;
|
||||
expect(bootstrapData.success).toBe(true);
|
||||
expect(bootstrapData.record_id).toBe('rec_fresh_admin');
|
||||
expect(JSON.parse(bootstrapBody()).values.password_hash.startsWith('pbkdf2-sha256$')).toBe(true);
|
||||
expect(bootstrapBody()).not.toContain('bootstrap-pw-3'); // 明碼絕不落地
|
||||
|
||||
// 安裝精靈離場之後:管理員事後手動加第二個人(POST /portal/admin/users),
|
||||
// 這條路本來就沒帶過任何臨時 token,過去在 D61 底下永遠 502——現在也要成功。
|
||||
await env.SESSIONS_KV.put('portal_sess:tok-fresh-admin', JSON.stringify({ record_id: 'rec_fresh_admin' }));
|
||||
mockGetRecord('rec_fresh_admin', activeUserValues({ email: 'fresh-install-admin@example.com', role: 'admin' }));
|
||||
mockHeadLookup('second-user@example.com', null);
|
||||
const { recordBody: secondBody } = mockCreateUser('rec_fresh_second');
|
||||
mockGetRecord('rec_fresh_second', activeUserValues({ email: 'second-user@example.com' }));
|
||||
|
||||
const addUserRes = await portalRouter.fetch(
|
||||
new Request('http://localhost/portal/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok-fresh-admin' },
|
||||
body: JSON.stringify({ email: 'second-user@example.com', password: 'second-user-pw-1' }),
|
||||
}),
|
||||
fakeEnv,
|
||||
{} as ExecutionContext,
|
||||
);
|
||||
expect(addUserRes.status).toBe(200);
|
||||
const addUserData = (await addUserRes.json()) as { success: boolean };
|
||||
expect(addUserData.success).toBe(true);
|
||||
expect(JSON.parse(secondBody()).owner_id).toBe(NS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:改成你自己資料實際所在的租戶字串。
|
||||
CONSOLE_TENANT = "leo"
|
||||
|
||||
# 這台實例的**知識命名空間**(Arcrun#108)=知識資料(三元組/卡片/藏書地圖/工作流 KV)
|
||||
# 實際掛在哪個 owner_id 底下。**這裡刻意不寫死**:官方 prod 的知識確實在 `CONSOLE_TENANT`
|
||||
# (leo)底下,未設就沿用它,行為一字不變。
|
||||
#
|
||||
# self-hosted 實例由 `acr update` 自動注入(值=你 `~/.arcrun/config.yaml` 的 `api_key`,
|
||||
# 也就是 CLI push 工作流、小幫手上傳知識、MCP 查詢用的同一個 namespace),
|
||||
# 而且**只在確認那個 namespace 底下真的查得到知識時才寫**(見 cli/src/lib/deploy.ts
|
||||
# namespaceHasKnowledge)——避免把一台原本正常的實例指向空的那一格。
|
||||
#
|
||||
# 為什麼要跟 CONSOLE_TENANT 分開:CONSOLE_TENANT 同時是帳號子 namespace(`{tenant}::portal`)
|
||||
# 的組成,改它會讓舊實例登不進去。兩個不同的事實,兩個 var。
|
||||
# ARCRUN_NAMESPACE = "your-namespace"
|
||||
|
||||
# Portal session TTL 秒數(portal-auth P2,#24/#25,routes/portal.ts)。預設 7 天(604800)——
|
||||
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
|
||||
PORTAL_SESSION_TTL = "604800"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# 00-INDEX(arcrun/docs)
|
||||
|
||||
> 機器整理的 wiki。**原稿在上一層**,這裡不放原文。有爭議時以原稿為準。
|
||||
|
||||
## 子節點
|
||||
|
||||
- **user_requirements** → `../user_requirements/.wiki/00-INDEX.md`
|
||||
主題:u6u 四層架構、credential 三層模型|概念:Polaris 意圖層、零件宇宙、AuthBroker…
|
||||
`#規格 #架構 #credential` 4 篇(含 1 篇指針) 更 2026-08-15
|
||||
|
||||
## 文件(本層每一篇一張)
|
||||
|
||||
- [[HANDOFF-config-scope-and-vectorize]] — 一份交辦文件,指出 arcrun 設定分層的兩條缺口,萃出兩個概念。
|
||||
`#文件 #設定` 建 2026-06-15 更 2026-08-15
|
||||
- [[HANDOFF-matrix-rearrange]] — Matrix 重整交給 arcrun 的待辦清單,用指針式考古不複製素材。
|
||||
`#文件 #遷移` 建 2026-06-13 更 2026-08-15
|
||||
- [[HANDOFF-mira-repair]] — 一份修復交辦,把調查做完了:哪些不用改、哪些要改、哪些要降級。
|
||||
`#文件 #事故` 建 2026-06-03 更 2026-08-15
|
||||
- [[HANDOFF-self-host-harness]] — 一份把「今天要做的三件事」連同已查證實況整理好的交辦,核心是戰法轉向。
|
||||
`#文件 #策略` 建 2026-06-01 更 2026-08-15
|
||||
- [[component-pr-review-standard]] — 零件與 binding 的 PR 審核規範,五段 checklist,任一否就打回。
|
||||
`#文件 #流程` 建 2026-06-03 更 2026-08-15
|
||||
|
||||
## 主題(跨文件聚類產生)
|
||||
|
||||
- [[結構性閘]] — 把錯誤路徑做成「根本碰不到」,而不是叮嚀人不要走。
|
||||
`#方法論 #流程 #架構` 建 2026-06-03 更 2026-08-15
|
||||
|
||||
## 本層概念(9)
|
||||
|
||||
- [[AI 讀不到的隱形分層]] — arcrun 是給 AI 操作的工具,而 harness 說明 41 行對 scope 零字提及。
|
||||
`#設定 #AI 協作` 建 2026-06-15 更 2026-08-15
|
||||
- [[Mira workflow 斷鏈]] — Mira 六個 workflow 全斷,但真因不是 arcrun 壞了,是 Mira 當初把東西錯做成假零件。
|
||||
`#事故 #零件` 建 2026-06-03 更 2026-08-15
|
||||
- [[cypher 雙份分岔]] — matrix 與 arcrun 各有一份 cypher-executor 且已分岔——整合後只留 arcrun 一份。
|
||||
`#架構 #遷移` 建 2026-06-13 更 2026-08-15
|
||||
- [[scope 誤部風險]] — 部署到哪個 CF 帳號,取決於你當下站在哪個資料夾——而這件事沒有任何地方寫著。
|
||||
`#設定 #風險` 建 2026-06-15 更 2026-08-15
|
||||
- [[self-hosted 戰法轉向]] — 2026-06-01 從 SaaS 改成 self-hosted 開源——它改的不只是商業模式,是「成功」的定義。
|
||||
`#策略 #部署` 建 2026-06-01 更 2026-08-15
|
||||
- [[假零件降級]] — 打固定 endpoint 的東西不是零件是 recipe——33 顆降到 22 顆,代價是所有引用它的人斷鏈。
|
||||
`#零件 #架構` 建 2026-06-03 更 2026-08-15
|
||||
- [[成功的定義]] — 舊:在我的帳號上能跑。新:任何人在自己的帳號上跑得通,而且寫錯會被擋住。
|
||||
`#策略 #驗收` 建 2026-06-01 更 2026-08-15
|
||||
- [[規則記不住]] — 規定說幾次都沒用——這不是紀律問題,是機制缺席。
|
||||
`#流程 #AI 協作` 建 2026-06-03 更 2026-08-15
|
||||
- [[零件 PR 審核閘]] — 靠「AI 記住規則」防架構錯誤不 scale,所以把錯誤擋在 PR 這道結構性閘上。
|
||||
`#流程 #架構 #審核` 建 2026-06-03 更 2026-08-15
|
||||
|
||||
## 說明
|
||||
|
||||
> 上面四節是**機器讀的**,只准放連結/路徑;說明寫在這一節,**不要用連結語法**。
|
||||
> 每一行的摘要/標籤/日期都是從卡的 frontmatter 複製過來的 ⇒ index 可機械產生。
|
||||
>
|
||||
> 🔴 **`user_requirements/arcrun-landing-page/` 不在任何清單上,這是對的**——
|
||||
> 它只有 `.jsx`/`.html`/`.css`,**沒有可掃文件 ⇒ 依規範它不是節點**,不建 `.wiki`。
|
||||
>
|
||||
> 本層只長出一個主題(結構性閘),因為另外幾張卡各自獨立、還湊不出第二個主題。
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
tags: [設定, AI 協作]
|
||||
gloss: arcrun 是給 AI 操作的工具,而 harness 說明 41 行對 scope 零字提及。
|
||||
created: 2026-06-15
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# AI 讀不到的隱形分層
|
||||
|
||||
← [[HANDOFF-config-scope-and-vectorize]]
|
||||
|
||||
## 摘要
|
||||
|
||||
真實事故:mira 被交代遷移時沒看懂 scope 存在,差點誤部官方帳號。病根不在它笨,在它讀的東西裡沒有這一層。
|
||||
|
||||
## 重點
|
||||
|
||||
- **arcrun 的操作者是 AI 不是人**(mindset §2),所以「AI 讀得到什麼」就是「系統有什麼」
|
||||
- 而 harness block 全文 41 行,**對 scope/帳號歸屬/誤部風險 0 字**
|
||||
- ⇒ mira 把「搬資料」跟「用哪個帳號」混為一談——**那是兩件獨立的事**
|
||||
- 這是 [[結構性閘]] 的另一面:規則沒被寫在會被讀到的地方,等於不存在
|
||||
|
||||
## 實體
|
||||
|
||||
- **harness block**(介面)— AI 讀 arcrun 時看到的說明,`cli/harness/CLAUDE.block.md`。
|
||||
- **遷移 ≠ scope**(判準)— 搬資料與用哪個帳號是兩件獨立的事。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- harness block >> 沒有提及 >> scope
|
||||
- AI >> 只知道 >> harness block 寫的東西
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- AI 讀不到的隱形分層 >> 屬於 >> [[HANDOFF-config-scope-and-vectorize]]
|
||||
- AI 讀不到的隱形分層 >> 屬於 >> [[結構性閘]]
|
||||
- AI 讀不到的隱形分層 >> 被誤部風險放大 >> [[scope 誤部風險]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> AI 讀不到的隱形分層
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, 設定]
|
||||
gloss: 一份交辦文件,指出 arcrun 設定分層的兩條缺口,萃出兩個概念。
|
||||
created: 2026-06-15
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# HANDOFF-config-scope-and-vectorize
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
2026-06-15 頂層總管交辦。由 mira self-hosted dogfood 踩出來的兩條框架缺口,兩條都走 SDD 協議。
|
||||
|
||||
## 重點
|
||||
|
||||
- **缺口 1** 是功能面:讀分層完整但只能寫全域 ⇒ [[scope 誤部風險]]
|
||||
- **缺口 2 被作者標為最重要**:AI 讀的說明裡完全沒有這一層 ⇒ [[AI 讀不到的隱形分層]]
|
||||
- 文件還帶了第三塊(KBDB Vectorize 開關),但那部分在本次萃取中沒有獨立成概念
|
||||
|
||||
## 實體
|
||||
|
||||
- **HANDOFF-config-scope-and-vectorize**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- HANDOFF-config-scope-and-vectorize >> 涵蓋 >> [[AI 讀不到的隱形分層]]
|
||||
- HANDOFF-config-scope-and-vectorize >> 涵蓋 >> [[scope 誤部風險]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> HANDOFF-config-scope-and-vectorize
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
tags: [文件, 遷移]
|
||||
gloss: Matrix 重整交給 arcrun 的待辦清單,用指針式考古不複製素材。
|
||||
created: 2026-06-13
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# HANDOFF-matrix-rearrange
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
頂層重整的產物。第一條就是關掉 matrix 版 cypher-executor。
|
||||
|
||||
## 重點
|
||||
|
||||
- **主要問題**:[[cypher 雙份分岔]],含逐檔的勘查結果
|
||||
- **指針式考古**:素材真身留在 `_archive/`,這份只給路徑——**不複製進來,避免第二份真相**
|
||||
|
||||
## 實體
|
||||
|
||||
- **HANDOFF-matrix-rearrange**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- HANDOFF-matrix-rearrange >> 涵蓋 >> [[cypher 雙份分岔]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-matrix-rearrange.md` >> 提及 >> HANDOFF-matrix-rearrange
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, 事故]
|
||||
gloss: 一份修復交辦,把調查做完了:哪些不用改、哪些要改、哪些要降級。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# HANDOFF-mira-repair
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
arcrun 端的 CC 寫給 mira 端的 CC。特色是「調查已做完,照著改即可,不必重跑」。
|
||||
|
||||
## 重點
|
||||
|
||||
- **事故**:[[Mira workflow 斷鏈]]——六個 workflow 全掃過
|
||||
- **真因**:[[假零件降級]],而 mira 當初錯做了假零件
|
||||
- **這份文件本身示範了一件事**:交辦時把調查結果一起交出去,接手的人才不會重跑一遍
|
||||
|
||||
## 實體
|
||||
|
||||
- **HANDOFF-mira-repair**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- HANDOFF-mira-repair >> 涵蓋 >> [[Mira workflow 斷鏈]]
|
||||
- HANDOFF-mira-repair >> 涵蓋 >> [[假零件降級]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-mira-repair.md` >> 提及 >> HANDOFF-mira-repair
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, 策略]
|
||||
gloss: 一份把「今天要做的三件事」連同已查證實況整理好的交辦,核心是戰法轉向。
|
||||
created: 2026-06-01
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# HANDOFF-self-host-harness
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
2026-06-01 撰寫。它最重要的一段不是任務,是背景:戰法已經變了。
|
||||
|
||||
## 重點
|
||||
|
||||
- **背景**:[[self-hosted 戰法轉向]]
|
||||
- **後果**:[[成功的定義]] 被整個改寫
|
||||
- **它的體例值得學**:先講「戰法已轉變(最重要的背景)」,再講今天做什麼
|
||||
|
||||
## 實體
|
||||
|
||||
- **HANDOFF-self-host-harness**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- HANDOFF-self-host-harness >> 涵蓋 >> [[self-hosted 戰法轉向]]
|
||||
- HANDOFF-self-host-harness >> 涵蓋 >> [[成功的定義]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-self-host-harness.md` >> 提及 >> HANDOFF-self-host-harness
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
tags: [事故, 零件]
|
||||
gloss: Mira 六個 workflow 全斷,但真因不是 arcrun 壞了,是 Mira 當初把東西錯做成假零件。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# Mira workflow 斷鏈
|
||||
|
||||
← [[HANDOFF-mira-repair]]
|
||||
|
||||
## 摘要
|
||||
|
||||
整修後 mira 的 workflow 斷了。調查結論:prod 活著、降級的 recipe 都在 KV,大部分只需小改不必重寫。
|
||||
|
||||
## 重點
|
||||
|
||||
- **不是 arcrun 壞了**——`cypher.arcrun.dev` 活著,降級後的 recipe 都在 prod KV(已驗)
|
||||
- **是 Mira 當初自己把一堆東西錯做成假零件** ⇒ [[假零件降級]] 一動它就斷
|
||||
- **教訓**:引用「不該存在的東西」的人,會在那個東西被修正時付出代價
|
||||
|
||||
## 實體
|
||||
|
||||
- **六個 workflow**(受害範圍)— mira 全部的 workflow 都掃過了。
|
||||
- **降級後的 recipe**(現況)— kbdb_get/telegram_send/gmail_send 等都在 prod KV。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- Mira >> 錯做成 >> 假零件
|
||||
- 假零件降級 >> 導致 >> Mira workflow 斷鏈
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- Mira workflow 斷鏈 >> 屬於 >> [[HANDOFF-mira-repair]]
|
||||
- Mira workflow 斷鏈 >> 肇因於 >> [[假零件降級]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-mira-repair.md` >> 提及 >> Mira workflow 斷鏈
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, 流程]
|
||||
gloss: 零件與 binding 的 PR 審核規範,五段 checklist,任一否就打回。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# component-pr-review-standard
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
它的開場白就是整份文件的論點:靠 AI 記住規則不 scale,所以把錯誤擋在結構上。
|
||||
|
||||
## 重點
|
||||
|
||||
- **論點**:[[規則記不住]]
|
||||
- **手法**:[[零件 PR 審核閘]],五段 A–E 逐條過
|
||||
- 每一段都附了**真實反例**(`km_wiki_card_parse` 被否、drainer 本地綠但撞 1042)——**反例比規則本身有用**
|
||||
|
||||
## 實體
|
||||
|
||||
- **component-pr-review-standard**(原稿)— 本卡對應的那一份交辦文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- component-pr-review-standard >> 涵蓋 >> [[規則記不住]]
|
||||
- component-pr-review-standard >> 涵蓋 >> [[零件 PR 審核閘]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../component-pr-review-standard.md` >> 提及 >> component-pr-review-standard
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [架構, 遷移]
|
||||
gloss: matrix 與 arcrun 各有一份 cypher-executor 且已分岔——整合後只留 arcrun 一份。
|
||||
created: 2026-06-13
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# cypher 雙份分岔
|
||||
|
||||
← [[HANDOFF-matrix-rearrange]]
|
||||
|
||||
## 摘要
|
||||
|
||||
勘查結果:matrix 版獨有 5 檔(皆非 SaaS 遺留,是 self-hosted 核心)、兩邊都有但內容不同 10 檔要逐檔比對。
|
||||
|
||||
## 重點
|
||||
|
||||
- **matrix 獨有的 5 檔是要保留的**:版本選擇策略、缺件自動生成、雙模式路由、WASM 執行、proxy
|
||||
- **10 檔內容分岔**要逐檔比對合併,取較完整的一邊但保留 arcrun 較新的 auth 演進
|
||||
- **同一個東西有兩份且都在演化 ⇒ 必然分岔**,這是要儘早合併的理由
|
||||
|
||||
## 實體
|
||||
|
||||
- **diverged 副本**(狀態)— 同源但各自演化後內容不同的兩份。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 兩份副本 >> 各自演化 >> 內容分岔
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- cypher 雙份分岔 >> 屬於 >> [[HANDOFF-matrix-rearrange]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-matrix-rearrange.md` >> 提及 >> cypher 雙份分岔
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"note": "卡→原文寫在卡的『### 出處』;原文→卡寫在這裡。多對多。",
|
||||
"docs": [
|
||||
{
|
||||
"doc_id": "d_000",
|
||||
"node": "(根)",
|
||||
"path": "HANDOFF-config-scope-and-vectorize.md",
|
||||
"提及": [
|
||||
"HANDOFF-config-scope-and-vectorize",
|
||||
"AI 讀不到的隱形分層",
|
||||
"結構性閘",
|
||||
"scope 誤部風險"
|
||||
]
|
||||
},
|
||||
{
|
||||
"doc_id": "d_001",
|
||||
"node": "(根)",
|
||||
"path": "HANDOFF-matrix-rearrange.md",
|
||||
"提及": [
|
||||
"HANDOFF-matrix-rearrange",
|
||||
"cypher 雙份分岔"
|
||||
]
|
||||
},
|
||||
{
|
||||
"doc_id": "d_002",
|
||||
"node": "(根)",
|
||||
"path": "HANDOFF-mira-repair.md",
|
||||
"提及": [
|
||||
"假零件降級",
|
||||
"Mira workflow 斷鏈",
|
||||
"HANDOFF-mira-repair"
|
||||
]
|
||||
},
|
||||
{
|
||||
"doc_id": "d_003",
|
||||
"node": "(根)",
|
||||
"path": "HANDOFF-self-host-harness.md",
|
||||
"提及": [
|
||||
"成功的定義",
|
||||
"self-hosted 戰法轉向",
|
||||
"HANDOFF-self-host-harness"
|
||||
]
|
||||
},
|
||||
{
|
||||
"doc_id": "d_004",
|
||||
"node": "(根)",
|
||||
"path": "component-pr-review-standard.md",
|
||||
"提及": [
|
||||
"零件 PR 審核閘",
|
||||
"規則記不住",
|
||||
"結構性閘",
|
||||
"component-pr-review-standard"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [設定, 風險]
|
||||
gloss: 部署到哪個 CF 帳號,取決於你當下站在哪個資料夾——而這件事沒有任何地方寫著。
|
||||
created: 2026-06-15
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# scope 誤部風險
|
||||
|
||||
← [[HANDOFF-config-scope-and-vectorize]]
|
||||
|
||||
## 摘要
|
||||
|
||||
arcrun 的設定讀取分三層(env > 專案 > 全域),但寫入只能寫全域,而「我現在會部到哪」沒有任何提示。
|
||||
|
||||
## 重點
|
||||
|
||||
- **讀分層完整、寫只能全域**:`saveConfig()` 寫死 `~/.arcrun/config.yaml`,`acr init` 四處呼叫全寫全域
|
||||
- ⇒ 想用專案層的人被迫污染全域,**怕誤打官方帳號**
|
||||
- **最陰險的是 fallback**:任一層都沒 `mode` 就靜默當 `local`,不報錯——你會看到「成功」但雲端沒有 trace
|
||||
- 真正的病灶不是缺功能,是 [[AI 讀不到的隱形分層]]
|
||||
|
||||
## 實體
|
||||
|
||||
- **scope 分層**(機制)— env > 專案 `.arcrun.yaml` > 全域 `~/.arcrun/config.yaml`。
|
||||
- **靜默 fallback**(陷阱)— 沒設 mode 就當 local,不報錯。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- scope 分層 >> 決定 >> 部署到哪個帳號
|
||||
- 靜默 fallback >> 造成 >> 看到成功但沒有雲端 trace
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- scope 誤部風險 >> 屬於 >> [[HANDOFF-config-scope-and-vectorize]]
|
||||
- scope 誤部風險 >> 根因是 >> [[AI 讀不到的隱形分層]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> scope 誤部風險
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
tags: [策略, 部署]
|
||||
gloss: 2026-06-01 從 SaaS 改成 self-hosted 開源——它改的不只是商業模式,是「成功」的定義。
|
||||
created: 2026-06-01
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# self-hosted 戰法轉向
|
||||
|
||||
← [[HANDOFF-self-host-harness]]
|
||||
|
||||
## 摘要
|
||||
|
||||
richblack 的決定。連帶把 self-hosted 一鍵起得來從「重要但非阻擋項」升為第一優先。
|
||||
|
||||
## 重點
|
||||
|
||||
- **改的是驗收標準**,見 [[成功的定義]]
|
||||
- ⇒ 優先序跟著翻轉:self-host 起得來變成當日第一優先
|
||||
- **戰法一變,原本排在後面的東西可能立刻變成阻擋項**——這是排序時要先問「戰法變了嗎」的理由
|
||||
|
||||
## 實體
|
||||
|
||||
- **self-hosted 開源策略**(戰法)— 使用者在自己的 CF 帳號上跑,而非平台託管。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 戰法轉向 >> 改變 >> 優先序
|
||||
- self-hosted >> 取代 >> SaaS
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- self-hosted 戰法轉向 >> 屬於 >> [[HANDOFF-self-host-harness]]
|
||||
- self-hosted 戰法轉向 >> 改寫了 >> [[成功的定義]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-self-host-harness.md` >> 提及 >> self-hosted 戰法轉向
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [零件, 架構]
|
||||
gloss: 打固定 endpoint 的東西不是零件是 recipe——33 顆降到 22 顆,代價是所有引用它的人斷鏈。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 假零件降級
|
||||
|
||||
← [[HANDOFF-mira-repair]]
|
||||
|
||||
## 摘要
|
||||
|
||||
arcrun 大整修:把「打固定 API 卻被做成零件」的假零件降級成 recipe 並刪掉零件目錄。判準是 DECISIONS §1:零件=endpoint 薄殼。
|
||||
|
||||
## 重點
|
||||
|
||||
- **判準**:零件是可複用的原語;**打固定 API 的是 recipe**
|
||||
- **33 → 22 顆**,被降級的解析方式變了 ⇒ 造成 [[Mira workflow 斷鏈]]
|
||||
- 這正是 [[零件 PR 審核閘]] 要在源頭擋掉的東西——**降級是補救,審核才是預防**
|
||||
|
||||
## 實體
|
||||
|
||||
- **假零件**(反模式)— 打固定 API 卻被做成零件的東西。
|
||||
- **recipe**(正解)— 具名封裝的單一 API 呼叫。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 假零件 >> 應降級為 >> recipe
|
||||
- 降級 >> 改變了 >> 零件名的解析方式
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 假零件降級 >> 屬於 >> [[HANDOFF-mira-repair]]
|
||||
- 假零件降級 >> 被擋於 >> [[零件 PR 審核閘]]
|
||||
- 假零件降級 >> 觸發了 >> [[Mira workflow 斷鏈]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-mira-repair.md` >> 提及 >> 假零件降級
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
tags: [策略, 驗收]
|
||||
gloss: 舊:在我的帳號上能跑。新:任何人在自己的帳號上跑得通,而且寫錯會被擋住。
|
||||
created: 2026-06-01
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 成功的定義
|
||||
|
||||
← [[HANDOFF-self-host-harness]]
|
||||
|
||||
## 摘要
|
||||
|
||||
戰法轉向後,harness 的成功定義被整個改寫。新定義多了一個常被忽略的下半句。
|
||||
|
||||
## 重點
|
||||
|
||||
- **舊**:在 richblack 的 prod 帳號(`cypher.arcrun.dev`)上能跑
|
||||
- **新**:任何 CC 在自己的 CF 帳號 `acr init --self-hosted` 後跑通一個含 recipe 的 workflow
|
||||
- 🔴 **而且「寫錯時會被程式擋住」是定義的一部分**——不是加分項
|
||||
- ⇒ 這句話直接推導出 [[零件 PR 審核閘]] 這類 [[結構性閘]] 的必要性
|
||||
|
||||
## 實體
|
||||
|
||||
- **新成功定義**(驗收)— 任何人在自己帳號跑通,且寫錯會被擋。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 新定義 >> 包含 >> 寫錯會被擋住
|
||||
- 舊定義 >> 只涵蓋 >> 作者自己的帳號
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 成功的定義 >> 屬於 >> [[HANDOFF-self-host-harness]]
|
||||
- 成功的定義 >> 被改寫於 >> [[self-hosted 戰法轉向]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../HANDOFF-self-host-harness.md` >> 提及 >> 成功的定義
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [方法論, 流程, 架構]
|
||||
gloss: 把錯誤路徑做成「根本碰不到」,而不是叮嚀人不要走。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 結構性閘
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
這個主題把三張卡收在一起,因為它們是同一個手法的三個場合:不靠自覺,靠結構。
|
||||
|
||||
## 重點
|
||||
|
||||
- 把 [[零件 PR 審核閘]] 收進來,是因為它明講了理由——**零件本來就要走 PR,所以把檢查放在那裡,錯誤路徑就碰不到**
|
||||
- 而 [[規則記不住]] 是這個手法的前提:**規定說幾次都沒用,這不是紀律問題是機制缺席**
|
||||
- [[AI 讀不到的隱形分層]] 則是同一件事的反面——**規則沒被放在會被讀到的地方,等於不存在**
|
||||
- ⇒ 三張卡合起來給出一條可複用的判準:**看到「說過很多次還是會犯」,就去找那個缺席的閘**
|
||||
|
||||
## 實體
|
||||
|
||||
- **結構性閘**(手法)— 讓錯誤路徑在結構上碰不到,而非靠遵守。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 結構性閘 >> 收攏 >> [[AI 讀不到的隱形分層]]
|
||||
- 結構性閘 >> 收攏 >> [[規則記不住]]
|
||||
- 結構性閘 >> 收攏 >> [[零件 PR 審核閘]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../component-pr-review-standard.md` >> 提及 >> 結構性閘
|
||||
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> 結構性閘
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
tags: [流程, AI 協作]
|
||||
gloss: 規定說幾次都沒用——這不是紀律問題,是機制缺席。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 規則記不住
|
||||
|
||||
← [[component-pr-review-standard]]
|
||||
|
||||
## 摘要
|
||||
|
||||
審核規範開宗明義的一句:「靠 AI 記住規則防架構錯誤不 scale」。它是整份文件存在的理由。
|
||||
|
||||
## 重點
|
||||
|
||||
- **規定寫得再清楚,沒有機制驗證就等於沒有**
|
||||
- ⇒ 對策不是寫更多規定,是**把錯誤路徑做成碰不到**
|
||||
- 這條可以推廣到任何「說過很多次還是會犯」的問題——**去找那個缺席的閘**
|
||||
|
||||
## 實體
|
||||
|
||||
- **不 scale**(性質)— 規則數量增加時,靠記憶遵守的成功率下降。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 靠記憶遵守規則 >> 不 scale
|
||||
- 缺席的機制 >> 才是 >> 真正的病灶
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 規則記不住 >> 屬於 >> [[component-pr-review-standard]]
|
||||
- 規則記不住 >> 屬於 >> [[結構性閘]]
|
||||
- 規則記不住 >> 治的是 >> [[零件 PR 審核閘]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../component-pr-review-standard.md` >> 提及 >> 規則記不住
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
tags: [流程, 架構, 審核]
|
||||
gloss: 靠「AI 記住規則」防架構錯誤不 scale,所以把錯誤擋在 PR 這道結構性閘上。
|
||||
created: 2026-06-03
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 零件 PR 審核閘
|
||||
|
||||
← [[component-pr-review-standard]]
|
||||
|
||||
## 摘要
|
||||
|
||||
零件與 service binding 本來就要走 PR,所以把檢查放在那裡,讓錯誤路徑「根本碰不到」。五段 checklist,任一否就打回。
|
||||
|
||||
## 重點
|
||||
|
||||
- **A 反過度工程**:判準是「三個月後會有第二個 workflow 用它嗎」,不會就不是零件
|
||||
- **B binding 層級**(最常踩):跨-worker 編排一律走 cypher binding,service binding 只准組 wasm
|
||||
- **D 驗證誠實**:要打**部署後的真端點**,本地 miniflare 會假綠——實例是 drainer 本地綠、production 撞 1042
|
||||
- **E 部署鐵律**:wrangler 直推,不用 `acr update`(會假綠蓋掉改動)
|
||||
- 它治的是 [[規則記不住]],手法屬於 [[結構性閘]]
|
||||
|
||||
## 實體
|
||||
|
||||
- **PR 審核閘**(機制)— 零件與 binding 變更的強制檢查點。
|
||||
- **假綠**(反模式)— 本地測試通過但生產環境會失敗。
|
||||
- **三個月判準**(判準)— 未來三個月會不會有第二個使用者。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- PR 審核 >> 擋下 >> 架構錯誤
|
||||
- 本地 miniflare >> 產生 >> 假綠
|
||||
- 三個月判準 >> 決定 >> 是不是零件
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 零件 PR 審核閘 >> 實作 >> [[規則記不住]]
|
||||
- 零件 PR 審核閘 >> 屬於 >> [[component-pr-review-standard]]
|
||||
- 零件 PR 審核閘 >> 屬於 >> [[結構性閘]]
|
||||
- 零件 PR 審核閘 >> 擋的正是 >> [[假零件降級]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../component-pr-review-standard.md` >> 提及 >> 零件 PR 審核閘
|
||||
@@ -0,0 +1,55 @@
|
||||
# 00-INDEX(user_requirements)
|
||||
|
||||
← `../../.wiki/00-INDEX.md`
|
||||
|
||||
## 子節點
|
||||
|
||||
- **ADR-lib-and-landingPage** → `../ADR-lib-and-landingPage/.wiki/00-INDEX.md` — 3 篇(未萃)
|
||||
- **simplify_mvp** → `../simplify_mvp/.wiki/00-INDEX.md` — 1 篇(未萃)
|
||||
- **u6u-long-term** → `../u6u-long-term/.wiki/00-INDEX.md` — 1 篇+子節點 SourceDocs 3 篇(未萃)
|
||||
|
||||
## 文件(本層每一篇一張)
|
||||
|
||||
- [[credential_parts]] — 29KB 的 credential 規格,本次只萃了 §0–§2,其餘尚未分段。
|
||||
`#文件 #credential` 建 2026-05-25 更 2026-08-15
|
||||
- [[u6u_design]] — 一份兩千字的設計筆記,密度很高,萃出五個概念。
|
||||
`#文件 #設計` 建 2026-05-20 更 2026-08-15
|
||||
- `../wishlist.md` — **空**:指針檔,全文都在說「已移至頂層」。真身在 `InkStoneCo/docs/1-vision/product-wishlist.md`
|
||||
|
||||
## 主題(跨文件聚類產生)
|
||||
|
||||
- [[讓生態自己長]] — u6u 對「零件從哪來、誰把關、爛的怎麼淘汰」的整套答案:三件事互相咬合。
|
||||
`#生態 #零件 #方法論` 建 2026-05-20 更 2026-08-15
|
||||
|
||||
## 本層概念(7)
|
||||
|
||||
- [[AI 的 marketplace]] — marketplace 給 AI 不給人:強制用完回評價,被評差的零件其他 AI 自動避開。
|
||||
`#生態 #零件` 建 2026-05-20 更 2026-08-15
|
||||
- [[n8n 的每服務一零件]] — n8n 為每個服務寫一個 credential 零件——這份文件開宗明義說那是錯的。
|
||||
`#credential #反模式` 建 2026-05-25 更 2026-08-15
|
||||
- [[四個 primitive 不是每服務一個零件]] — 做四個 TinyGo/WASM primitive,每個服務只要一份 YAML recipe + 用戶自己的 secret。
|
||||
`#credential #架構` 建 2026-05-25 更 2026-08-15
|
||||
- [[自動審核取代人工]] — call API 的只要 call 通就過,功能性的只要通過它自己設的 Gherkin 就過。
|
||||
`#流程 #零件` 建 2026-05-20 更 2026-08-15
|
||||
- [[視覺優先開發]] — 用戶拉一個按鈕,後面自然綁上 webhook——不用解釋 webhook 是什麼。
|
||||
`#產品 #介面` 建 2026-05-20 更 2026-08-15
|
||||
- [[解釋 webhook 的困難]] — 要跟一般用戶解釋什麼是 webhook 很難——這是低門檻產品真正的門檻。
|
||||
`#產品 #介面` 建 2026-05-20 更 2026-08-15
|
||||
- [[零件長尾誰來補]] — 沒有人會為了 delete table 去寫完整的 Google Sheets 零件——但缺的那個端點會被下一個需要的人補上。
|
||||
`#生態 #零件` 建 2026-05-20 更 2026-08-15
|
||||
|
||||
## 說明
|
||||
|
||||
> 上面四節是機器讀的;說明寫這裡,不要用連結語法。
|
||||
>
|
||||
> 🔴 **`arcrun-landing-page/` 沒有出現在子節點清單裡,這是對的**——
|
||||
> 它只有 `.jsx`/`.html`/`.css`,**沒有可掃文件 ⇒ 依規範不是節點**。
|
||||
>
|
||||
> 🔴 **三個子節點標「未萃」是誠實的殘缺**:本次真實測試只做了 `docs/` 與本層兩個節點。
|
||||
> 標出來,使用者才分得出「沒東西」和「還沒做」。
|
||||
>
|
||||
> 🔴 **`wishlist.md` 是「空」的第三種**:不是沒東西(待辦)、不是紀錄(報價單),是**指針**。
|
||||
> 依規範標 `no_concept` **不產卡**——我第一版給它產了卡,`wiki-lint` 當場報孤島卡抓到。
|
||||
>
|
||||
> 🔴 **`credential_parts.md` 是 29KB、12 個 H2**,超過 20KB 分段門檻。
|
||||
> 本次只萃了 §0–§2,其餘八段**如實標明未做**——一張卡蓋不住 29KB。
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
tags: [生態, 零件]
|
||||
gloss: marketplace 給 AI 不給人:強制用完回評價,被評差的零件其他 AI 自動避開。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# AI 的 marketplace
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
u6u 的零件市集設計。它跟一般 marketplace 的差別在使用者是 AI,所以回饋可以被強制。
|
||||
|
||||
## 重點
|
||||
|
||||
- **強制回評價**——AI 不像人會忘記或懶得評
|
||||
- **被評差幾次的零件,其他 AI 自動避開** ⇒ 淘汰不需要人介入
|
||||
- 它配的是 [[自動審核取代人工]],兩者合起來讓 [[零件長尾誰來補]] 有解
|
||||
|
||||
## 實體
|
||||
|
||||
- **AI marketplace**(機制)— 使用者是 AI 的零件市集,回饋可強制。
|
||||
- **自然淘汰**(效果)— 差評零件被自動避開。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- AI >> 被強制 >> 回覆評價
|
||||
- 低評零件 >> 被 >> 其他 AI 避開
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- AI 的 marketplace >> 屬於 >> [[u6u_design]]
|
||||
- AI 的 marketplace >> 屬於 >> [[讓生態自己長]]
|
||||
- AI 的 marketplace >> 機制是 >> [[自動審核取代人工]]
|
||||
- AI 的 marketplace >> 解的是 >> [[零件長尾誰來補]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> AI 的 marketplace
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags: [文件, credential]
|
||||
gloss: 29KB 的 credential 規格,本次只萃了 §0–§2,其餘尚未分段。
|
||||
created: 2026-05-25
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# credential_parts
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
這份文件超過本規範的 20KB 分段門檻,共 12 個 H2 段落。本次示範「只萃前段、其餘誠實標明未做」。
|
||||
|
||||
## 重點
|
||||
|
||||
- 已萃:[[四個 primitive 不是每服務一個零件]]、[[n8n 的每服務一零件]](來自 §0 TL;DR 與 §1–2)
|
||||
- 🔴 **未萃**:§3 四個 primitive 詳細規格/§4 Recipe Schema/§5 繼承/§6 WASM 介面/§7 AuthBroker API/§8 Storage/§9 授權 UI/§10 TODO/§11 風險
|
||||
- **這正是「大檔要按 H2 分段」那個洞的實例**——一張卡蓋不住 29KB
|
||||
|
||||
## 實體
|
||||
|
||||
- **credential_parts**(原稿)— 本卡對應的那一份文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- credential_parts >> 涵蓋 >> [[n8n 的每服務一零件]]
|
||||
- credential_parts >> 涵蓋 >> [[四個 primitive 不是每服務一個零件]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../credential_parts.md` >> 提及 >> credential_parts
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [credential, 反模式]
|
||||
gloss: n8n 為每個服務寫一個 credential 零件——這份文件開宗明義說那是錯的。
|
||||
created: 2026-05-25
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# n8n 的每服務一零件
|
||||
|
||||
← [[credential_parts]]
|
||||
|
||||
## 摘要
|
||||
|
||||
被點名的反模式。它的問題是零件數量隨服務數量線性成長,而認證方式其實只有幾種。
|
||||
|
||||
## 重點
|
||||
|
||||
- **零件數 ∝ 服務數**,但認證方式其實只有四種
|
||||
- ⇒ 正解是 [[四個 primitive 不是每服務一個零件]]
|
||||
|
||||
## 實體
|
||||
|
||||
- **每服務一零件**(反模式)— n8n 的 credential 做法。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 零件數 >> 隨服務數 >> 線性成長
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- n8n 的每服務一零件 >> 否定 >> [[四個 primitive 不是每服務一個零件]]
|
||||
- n8n 的每服務一零件 >> 屬於 >> [[credential_parts]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../credential_parts.md` >> 提及 >> n8n 的每服務一零件
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [文件, 設計]
|
||||
gloss: 一份兩千字的設計筆記,密度很高,萃出五個概念。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# u6u_design
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
開場一句「u6u 是一個 AI Friendly 的 n8n」,後面是條列的設計主張,最後一段是觀念想法。
|
||||
|
||||
## 重點
|
||||
|
||||
- **生態那三條**被收成 [[讓生態自己長]]
|
||||
- **介面策略**是 [[視覺優先開發]],它迴避 [[解釋 webhook 的困難]]
|
||||
- ⚠️ 文件最後一句「考慮讓他自己 OWN,企業版資料獨立」**沒有展開,本次未萃成卡**
|
||||
|
||||
## 實體
|
||||
|
||||
- **u6u_design**(原稿)— 本卡對應的那一份文件。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- u6u_design >> 涵蓋 >> [[AI 的 marketplace]]
|
||||
- u6u_design >> 涵蓋 >> [[自動審核取代人工]]
|
||||
- u6u_design >> 涵蓋 >> [[視覺優先開發]]
|
||||
- u6u_design >> 涵蓋 >> [[解釋 webhook 的困難]]
|
||||
- u6u_design >> 涵蓋 >> [[零件長尾誰來補]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> u6u_design
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
tags: [credential, 架構]
|
||||
gloss: 做四個 TinyGo/WASM primitive,每個服務只要一份 YAML recipe + 用戶自己的 secret。
|
||||
created: 2026-05-25
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 四個 primitive 不是每服務一個零件
|
||||
|
||||
← [[credential_parts]]
|
||||
|
||||
## 摘要
|
||||
|
||||
credential 設計的核心主張。它是三層模型:primitive(程式)/recipe(公共設定)/secret(私有)。
|
||||
|
||||
## 重點
|
||||
|
||||
- **四個 primitive** 涵蓋所有認證方式,不隨服務數量成長
|
||||
- **recipe 存平台 KV(公共)、secret 存 tenant KV(私有)**,runtime 由 AuthBroker 組裝
|
||||
- 它明確反對 [[n8n 的每服務一零件]]
|
||||
- ⚠️ 本卡只涵蓋這份 29KB 文件的 §0–§2;`§3 四個 primitive 詳細規格`、`§4 Recipe Schema` 等**尚未分段萃取**
|
||||
|
||||
## 實體
|
||||
|
||||
- **primitive**(零件)— TinyGo/WASM 寫的認證原語,共四個。
|
||||
- **recipe**(設定)— 每服務一份 YAML,存公共 KV。
|
||||
- **AuthBroker**(組裝者)— runtime 把 recipe 與 secret 組成可用的 HTTP client。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- primitive >> 加上 >> recipe 與 secret
|
||||
- AuthBroker >> 組裝 >> 可用的 HTTP client
|
||||
- recipe >> 存於 >> 公共 KV
|
||||
- secret >> 存於 >> tenant KV
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 四個 primitive 不是每服務一個零件 >> 反的是 >> [[n8n 的每服務一零件]]
|
||||
- 四個 primitive 不是每服務一個零件 >> 屬於 >> [[credential_parts]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../credential_parts.md` >> 提及 >> 四個 primitive 不是每服務一個零件
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [流程, 零件]
|
||||
gloss: call API 的只要 call 通就過,功能性的只要通過它自己設的 Gherkin 就過。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 自動審核取代人工
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
把「零件能不能上架」的判斷交給可執行的檢查,省掉人工審核。
|
||||
|
||||
## 重點
|
||||
|
||||
- **打 API 的**:成功 call 通就算過
|
||||
- **功能性的**:通過投稿者自己寫的 Gherkin 就算過
|
||||
- ⚠️ 這條跟後來的 `component-pr-review-standard` 有張力——那份明講 **Gherkin 全綠 ≠ 零件安全**(投稿者可寫避重就輕的 Gherkin)
|
||||
- ⇒ **兩份文件寫於不同時期,後者修正了前者**。真正框死破壞力的是純 WASI 沙箱,不是 Gherkin
|
||||
|
||||
## 實體
|
||||
|
||||
- **Gherkin 自審**(機制)— 投稿者自寫驗收條件,通過即上架。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- call API 成功 >> 即通過 >> 審核
|
||||
- Gherkin 全綠 >> 不等於 >> 零件安全
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 自動審核取代人工 >> 屬於 >> [[u6u_design]]
|
||||
- 自動審核取代人工 >> 屬於 >> [[讓生態自己長]]
|
||||
- 自動審核取代人工 >> 支撐 >> [[AI 的 marketplace]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 自動審核取代人工
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [產品, 介面]
|
||||
gloss: 用戶拉一個按鈕,後面自然綁上 webhook——不用解釋 webhook 是什麼。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 視覺優先開發
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
u6u 的低門檻策略:讓抽象概念從具體操作裡浮現,而不是先教會使用者術語。
|
||||
|
||||
## 重點
|
||||
|
||||
- **先做前端按鈕、輸入框,後端邏輯跟著綁上去**
|
||||
- **點下去就看到工作流** ⇒ 概念從操作裡長出來
|
||||
- 它迴避的是 [[解釋 webhook 的困難]]
|
||||
- 延伸:cypher 放大就是 graph,**每個功能可以摺疊成一點,也可以 zoom in 展開調整** ⇒ 同一個碎型結構
|
||||
|
||||
## 實體
|
||||
|
||||
- **視覺優先**(策略)— 先給看得見的操作,再讓概念浮現。
|
||||
- **摺疊與展開**(互動)— 功能可收成一點也可展開調整。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 前端按鈕 >> 綁定 >> webhook
|
||||
- cypher 放大 >> 就是 >> graph
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 視覺優先開發 >> 屬於 >> [[u6u_design]]
|
||||
- 視覺優先開發 >> 迴避了 >> [[解釋 webhook 的困難]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 視覺優先開發
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [產品, 介面]
|
||||
gloss: 要跟一般用戶解釋什麼是 webhook 很難——這是低門檻產品真正的門檻。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 解釋 webhook 的困難
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
一句被順帶寫下、但其實是整個介面策略起點的觀察。
|
||||
|
||||
## 重點
|
||||
|
||||
- **門檻不在功能,在術語**
|
||||
- ⇒ 對策不是寫更好的說明文件,是 [[視覺優先開發]]——讓他不必知道那個詞
|
||||
|
||||
## 實體
|
||||
|
||||
- **術語門檻**(障礙)— 使用者卡在概念名稱而非功能本身。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 術語 >> 構成 >> 使用門檻
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 解釋 webhook 的困難 >> 屬於 >> [[u6u_design]]
|
||||
- 解釋 webhook 的困難 >> 被迴避於 >> [[視覺優先開發]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 解釋 webhook 的困難
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
tags: [生態, 零件, 方法論]
|
||||
gloss: u6u 對「零件從哪來、誰把關、爛的怎麼淘汰」的整套答案:三件事互相咬合。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 讓生態自己長
|
||||
|
||||
← [[00-INDEX]]
|
||||
|
||||
## 摘要
|
||||
|
||||
這個主題把三張卡收在一起,因為它們是同一個迴圈的三段:怎麼補、怎麼把關、怎麼淘汰。
|
||||
|
||||
## 重點
|
||||
|
||||
- 起點是 [[零件長尾誰來補]]——**不預先做完整,缺的時候由需要的人補**
|
||||
- 把關交給 [[自動審核取代人工]],因為人工審核會變成瓶頸
|
||||
- 淘汰交給 [[AI 的 marketplace]],**而它能成立的唯一原因是使用者是 AI**(回饋可以被強制)
|
||||
- ⇒ **三段缺一段這個迴圈就停**:沒人補、沒人審、或爛的不會消失,生態都長不起來
|
||||
|
||||
## 實體
|
||||
|
||||
- **生態迴圈**(結構)— 補件、把關、淘汰三段互相咬合。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 讓生態自己長 >> 收攏 >> [[AI 的 marketplace]]
|
||||
- 讓生態自己長 >> 收攏 >> [[自動審核取代人工]]
|
||||
- 讓生態自己長 >> 收攏 >> [[零件長尾誰來補]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 讓生態自己長
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
tags: [生態, 零件]
|
||||
gloss: 沒有人會為了 delete table 去寫完整的 Google Sheets 零件——但缺的那個端點會被下一個需要的人補上。
|
||||
created: 2026-05-20
|
||||
updated: 2026-08-15
|
||||
---
|
||||
# 零件長尾誰來補
|
||||
|
||||
← [[u6u_design]]
|
||||
|
||||
## 摘要
|
||||
|
||||
u6u 對「零件覆蓋率」的答案:不追求完整,追求需要時補得上,而搜尋會把碎片整合起來。
|
||||
|
||||
## 重點
|
||||
|
||||
- **每個 API call 獨立,但搜尋會整合**——有人做了 create table,下一個人要 delete 時發現沒有就直接做一個
|
||||
- **下次搜 google sheets 就同時看到兩個端點** ⇒ 覆蓋率由需求長出來,不由規劃決定
|
||||
- 這正是 [[AI 的 marketplace]] 要服務的形狀
|
||||
|
||||
## 實體
|
||||
|
||||
- **長尾覆蓋**(問題)— 沒人會預先寫完整的 API 包裝。
|
||||
- **搜尋整合**(機制)— 碎片端點在搜尋時被聚合成一組。
|
||||
|
||||
## 關聯
|
||||
|
||||
### 內文知識關係
|
||||
|
||||
- 需求 >> 決定 >> 覆蓋率
|
||||
- 搜尋 >> 整合 >> 獨立的端點
|
||||
|
||||
### 卡片關係
|
||||
|
||||
- 零件長尾誰來補 >> 對策是 >> [[AI 的 marketplace]]
|
||||
- 零件長尾誰來補 >> 屬於 >> [[u6u_design]]
|
||||
- 零件長尾誰來補 >> 屬於 >> [[讓生態自己長]]
|
||||
|
||||
### 出處(原文 >> 提及 >> 本卡,可多筆)
|
||||
|
||||
- `../u6u_design.md` >> 提及 >> 零件長尾誰來補
|
||||
@@ -0,0 +1,138 @@
|
||||
-- 0007 — record 有身分,關係是唯一機制(樹狀 record 模型第一刀)
|
||||
-- 規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)
|
||||
--
|
||||
-- 模型一句話(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
|
||||
-- · record = 池中一顆有身分的 entry(沿用原 record_id 字串當 id,既有引用不失效)
|
||||
-- · 欄位是關係、歸屬也是關係——同一種機制:一列關係 = src/rel/dst 三個型別化指標欄
|
||||
-- (「池上型別化指標欄」,parent_id 是這族欄位的既有先例。紅線:不准用關係實作關係、
|
||||
-- 不准把指標塞回 content/JSON——那是 D91 的位置換個門進來)
|
||||
-- · 舊 entry_values 四欄的下場:record_id → record entry 自己的 id/entry_id → 指標終點(dst)
|
||||
-- /slot_name → 指標謂詞(rel = field entry)/template_id → 一條屬於關係(rel = sys_belongs)
|
||||
-- · 那張表該死的理由:它是「關係」的第二套實作——同一件事兩個實作必然漂移(D92)
|
||||
--
|
||||
-- 資料遷移形狀(v7 §3):既有 value entries 一列都不動(id 穩定、向量索引不失效)、
|
||||
-- 全部純 INSERT、零 UPDATE。以舊儲存格列 id 衍生關係列 id ⇒ INSERT OR IGNORE 天然冪等。
|
||||
--
|
||||
-- 冪等設計(三條套用路徑:官方 wrangler migrations/cli deploy.ts 每次重跑/安裝器逐句重放):
|
||||
-- · 除了三句 ADD COLUMN 之外,每一句都是語句級冪等(IF NOT EXISTS/INSERT OR IGNORE)
|
||||
-- · SQLite 沒有「ADD COLUMN IF NOT EXISTS」——重跑時那三句會報 duplicate column,
|
||||
-- 套用端必須把「duplicate column」視為已套用(cli deploy.ts 的 applyD1MigrationTolerant、
|
||||
-- 安裝器逐句 try/catch 本就容錯)。官方路徑走 wrangler migrations 追蹤表,只跑一次。
|
||||
-- · 不走「重建整張 entries」的做法:那雖然能純語句冪等,但 deploy.ts 每次部署都重跑
|
||||
-- migration ⇒ 每次部署全表複製一輪,直接吃掉 D1 每日列寫入額度。
|
||||
-- · entry_values 拆表用 0006 的「墊表、搬、拆」手法:重跑時先墊一份空殼,搬 0 筆,再拆,無害。
|
||||
--
|
||||
-- 施工窗口注意:本檔跑完 entry_values 就不存在了,必須與讀寫端改版(kbdb/src 同一批)一起部署。
|
||||
-- 舊 worker 碰新庫會炸 entry_values 不存在——這是刻意的(leo:「不做長期雙讀相容層,一次翻」)。
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 關係的物理載體:池上型別化指標欄 + 一對方向索引
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE entries ADD COLUMN src_id TEXT; -- kbdb-sql-ok: 牆內 migration 本體(v7 定案載體「池上型別化指標欄」),重跑由套用端容錯 duplicate column
|
||||
ALTER TABLE entries ADD COLUMN rel_id TEXT; -- kbdb-sql-ok: 同上
|
||||
ALTER TABLE entries ADD COLUMN dst_id TEXT; -- kbdb-sql-ok: 同上
|
||||
|
||||
-- 一對方向索引(v7 §2-1):src 端服務「r1 的所有欄位」,(dst,rel) 端服務
|
||||
-- 「某張 sheet 的所有 record」(rel=屬於, dst=sheet)與「誰指到 e1」(dst=e1)。
|
||||
-- partial index:內容 entry(指標欄全 NULL)不進索引,索引大小 ≈ 關係列數。
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_rel_src ON entries(src_id) WHERE src_id IS NOT NULL; -- kbdb-sql-ok: 牆內 migration 本體,普通欄位索引(0001 十條索引的同族)
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_rel_dst ON entries(dst_id, rel_id) WHERE dst_id IS NOT NULL; -- kbdb-sql-ok: 同上
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 啟動常數(v7 §2-3:一組、極小、只讀)
|
||||
-- ============================================================
|
||||
|
||||
-- 保留根:屬於鏈的終點。某顆 entry ─屬於→ sys_root = 它是一張 sheet。
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_root', 'root', 'system', NULL);
|
||||
-- 「屬於」謂詞:record ─屬於→ sheet(歸屬關係的 rel)。
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_belongs', 'belongs', 'system', NULL);
|
||||
-- 「欄位屬於表」謂詞:field ─field_of→ sheet(sheet 的欄位名冊,與 record 歸屬分開,
|
||||
-- 免得「某張 sheet 的所有 record」把欄位也撈進來)。
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_field_of', 'field_of', 'system', NULL);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 墊表(0006 手法):本檔尾端會拆掉 entry_values,重跑時先墊空殼
|
||||
-- 讓下面的搬遷語句永遠合法(搬 0 筆),最後再拆一次。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_values ( -- kbdb-sql-ok: 表退場施工步驟①保底存在(0006 同款),非資料存取違規
|
||||
id TEXT PRIMARY KEY,
|
||||
record_id TEXT NOT NULL,
|
||||
template_id TEXT NOT NULL,
|
||||
slot_name TEXT NOT NULL,
|
||||
entry_id TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
UNIQUE(record_id, slot_name)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. sheet/field 落池(templates 表遷移期雙軌:欄位定義的真相源暫仍在 templates 表,
|
||||
-- sheet/field entry 是它在池中的身分——第二刀把 templates 表整個退役)
|
||||
-- ============================================================
|
||||
|
||||
-- 每個 template 一顆 sheet entry(id 沿用 template id,既有引用不失效)
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id, created_at, updated_at)
|
||||
SELECT t.id, t.name, 'sheet', NULL, t.created_at, t.updated_at FROM templates t;
|
||||
|
||||
-- sheet ─屬於→ 保留根(「什麼讓一顆 entry 成為 sheet」的答案:這條關係)
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
|
||||
SELECT 'relb_' || t.id, 'relation', t.id, 'sys_belongs', 'sys_root' FROM templates t;
|
||||
|
||||
-- 每個 slot 一顆 field entry(id 決定性衍生:fld_<template>_<slot>,欄名=關係的謂詞)
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type)
|
||||
SELECT 'fld_' || t.id || '_' || j.value, j.value, 'field'
|
||||
FROM templates t, json_each(t.slots_json) j;
|
||||
|
||||
-- field ─field_of→ sheet(欄位名冊)
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
|
||||
SELECT 'relf_' || t.id || '_' || j.value, 'relation', 'fld_' || t.id || '_' || j.value, 'sys_field_of', t.id
|
||||
FROM templates t, json_each(t.slots_json) j;
|
||||
|
||||
-- 保險網:entry_values 裡實際用過、但 slots_json 沒宣告的 slot(宣告與實作漂移的實證),
|
||||
-- 一樣補出 field entry 與名冊,資料一筆都不能掉。
|
||||
INSERT OR IGNORE INTO entries (id, content, entry_type)
|
||||
SELECT DISTINCT 'fld_' || ev.template_id || '_' || ev.slot_name, ev.slot_name, 'field' FROM entry_values ev;
|
||||
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
|
||||
SELECT DISTINCT 'relf_' || ev.template_id || '_' || ev.slot_name, 'relation',
|
||||
'fld_' || ev.template_id || '_' || ev.slot_name, 'sys_field_of', ev.template_id
|
||||
FROM entry_values ev;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. record 有身分:每筆 record 一顆池中 entry(id = 原 record_id 字串)
|
||||
-- 歸屬 owner 沿用「第一個非 NULL 的 slot entry owner」——與舊讀端的推導逐字同義。
|
||||
-- INSERT OR IGNORE 的另一層意義:record_id 已經是池中既有 entry(library_map 的
|
||||
-- block 即 record 慣例)時,那顆 entry 本人就是身分,不另建。
|
||||
-- ============================================================
|
||||
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, created_at, updated_at)
|
||||
SELECT ev.record_id, 'record',
|
||||
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
|
||||
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
|
||||
MIN(ev.created_at), MIN(ev.created_at)
|
||||
FROM entry_values ev GROUP BY ev.record_id;
|
||||
|
||||
-- record ─屬於→ sheet(舊 template_id 欄的下場:變成一條關係)
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
|
||||
SELECT 'relb_' || ev.record_id || '_' || ev.template_id, 'relation',
|
||||
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
|
||||
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
|
||||
ev.record_id, 'sys_belongs', ev.template_id, MIN(ev.created_at)
|
||||
FROM entry_values ev GROUP BY ev.record_id, ev.template_id;
|
||||
|
||||
-- ============================================================
|
||||
-- 6. 每個儲存格 → 一條關係列(src=record、rel=欄位謂詞、dst=原 value entry)
|
||||
-- 關係列 id 以舊儲存格列 id 衍生(relv_<ev.id>)⇒ 重跑天然冪等。
|
||||
-- ============================================================
|
||||
|
||||
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
|
||||
SELECT 'relv_' || ev.id, 'relation', e.owner_id,
|
||||
ev.record_id, 'fld_' || ev.template_id || '_' || ev.slot_name, ev.entry_id, ev.created_at
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id;
|
||||
|
||||
-- ============================================================
|
||||
-- 7. 拆表(本提案的完成證明:關係的第二套實作不再有位置)
|
||||
-- ============================================================
|
||||
|
||||
DROP TABLE IF EXISTS entry_values; -- kbdb-sql-ok: 表退場施工(0006 同款),v7 定案「兩套機制合成一套」的落地
|
||||
@@ -73,10 +73,20 @@ export interface ListEntriesResult {
|
||||
// 當 count 回傳,容易被誤讀成「總共只有這幾筆」。total 才是真總數,count 仍保留=本頁筆數。
|
||||
}
|
||||
|
||||
// ── 機制列隔離(0007 樹狀 record 模型)───────────────────────────────
|
||||
// 關係列/裸身分/sheet/field/啟動常數是模型的機械零件,不是使用者的「一筆知識」。
|
||||
// caller 沒指定 entry_type 時預設排除,免得 owner-scoped 列表被 content=NULL 的關係列灌爆;
|
||||
// caller 明白指定 entry_type(含指定成機制型別)→ 尊重他要的,不攔。
|
||||
// 判「是不是關係列」認指標欄(src_id IS NOT NULL),不認 entry_type 標記——
|
||||
// entry_type 在 v7 §7 白名單終局會整欄消失,這條謂詞到時只剩前半。
|
||||
const NOT_MACHINERY_PREDICATE =
|
||||
"(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
|
||||
|
||||
export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Promise<ListEntriesResult> {
|
||||
const conds: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (f.entry_type) { conds.push('entry_type = ?'); params.push(f.entry_type); }
|
||||
else { conds.push(NOT_MACHINERY_PREDICATE); }
|
||||
if (f.owner_id) { conds.push('owner_id = ?'); params.push(f.owner_id); }
|
||||
if (f.parent_id) { conds.push('parent_id = ?'); params.push(f.parent_id); }
|
||||
if (f.page_name) { conds.push('page_name = ?'); params.push(f.page_name); }
|
||||
@@ -131,7 +141,14 @@ export async function updateEntry(db: D1Database, id: string, patch: UpdateEntry
|
||||
}
|
||||
|
||||
export async function deleteEntry(db: D1Database, id: string): Promise<void> {
|
||||
// 舊世界靠 FK(entry_values.entry_id REFERENCES entries)擋「刪掉還被 record 指著的
|
||||
// entry」;新模型(0007)關係列的 dst_id 沒有 FK → 這條不變量改由牆自己保,
|
||||
// 否則會產出指向不存在 id 的孤兒關係列(孤兒巡檢見 relation-orphans.ts)。
|
||||
const ref = await db.prepare('SELECT id FROM entries WHERE dst_id = ? LIMIT 1').bind(id).first<{ id: string }>();
|
||||
if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} — delete the record (or its slot) first`);
|
||||
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
|
||||
// 這顆 entry 自己發出的關係列(它是 record 身分時的格子與歸屬)失去意義,一併拆
|
||||
await db.prepare('DELETE FROM entries WHERE src_id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -583,6 +600,9 @@ export async function searchEntries(
|
||||
const params: unknown[] = [...plan.scoreParams];
|
||||
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
|
||||
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
|
||||
// 機制列隔離(0007):沒指定 entry_type 時,sheet/field 這類有 content 的機制節點
|
||||
// 不進關鍵字搜尋(搜 "status" 不該撈回一顆欄位謂詞 entry);關係列 content=NULL 本就 0 分。
|
||||
else { conds.push(NOT_MACHINERY_PREDICATE); }
|
||||
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
|
||||
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
|
||||
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user