Compare commits

..

3 Commits

Author SHA1 Message Date
uncle6me-web f87d0e92f4 fix(migrations): 0005/0006 從來沒進過版控——被 *.sql 規則吃掉,每個用戶都收到「部署物缺」
leo 更新 leo21c 時撞到(他問「部分失敗?」):
  ✗ D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql
  ✗ D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql
(四顆 worker cypher/registry/kbdb/mcp 全部 ✓,失敗的只有這兩個檔)

根因不是誰忘了推:.gitignore:53 的 `*.sql` 是為了擋 D1 匯出備份(整庫全量=機敏),
但它連 migration 一起吃掉。0001-0004 還在,只因為它們在該規則之前就 commit 了
(gitignore 不影響已追蹤檔案)⇒ 0005/0006 從產生那天起就不在任何 clone 裡。

⇒ 這不是 leo 一台的事:更新指令從 Gitea 抓 main,那兩個檔不在那裡
   ⇒ **任何人裝/更新都會收到同一組失敗**,包含全新安裝。

修法照 rules/05-deploy-convention.md「WASM 來源」段已有的慣例
(`.component-builds/**/component.wasm` 就是用否定規則放行的):
  !kbdb/migrations/*.sql

範圍實測(沒開太大):
  kbdb/migrations/0005、0006      → 放行
  backup-2026.sql / kbdb/backup-x.sql / dump.sql / cypher-executor/export.sql → 仍被擋

進版控前確認過無機敏值:grep 命中的 token/secret/api_key 全是欄位名
(api_key、secret_ref)與註解;無 >=20 位英數的疑似真值。

殘項:leo21c 實查 templates 9 個、credential 不在其中 ⇒ 0005 從未套用,
那台仍停在 D38 之前(credentials 走 0002 的獨立表)。要補套需另跑一次更新。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:10:12 +08:00
uncle6me-web ba152bc83a chore(builds): 重編 tier2 成品——把 #105 放進執行檔(出貨路徑 A 的第 0 步)
leo 選 A(把出貨線推到能出一版含 #105 的 bundle)。查證後發現最前面還有一層:

  cypher 源碼最後動 10d150a(#105)  / 成品最後動 a24f291(更早)
  mcp    源碼最後動 10d150a(#105)  / 成品最後動 8e10f1d(更早)

⇒ #105 改了 cypher 與 mcp 兩邊源碼,但沒有重編成品。
   這正是 Arcrun#93 那道「源碼比執行檔新就停下來」的閘要擋的狀態。

用官方唯一編譯點 scripts/build-worker-artifacts.mjs(Arcrun#80 的機制,已存在)
重編五顆,全部 5/5:
  arcrun-cypher-executor  571KB  source=10d150ac
  arcrun-kbdb             146KB  source=10d150ac
  arcrun-mcp             1152KB  source=10d150ac
  arcrun-http-request      78KB  source=1e85dfb4(未變)
  arcrun-code             150KB  source=621cb8d9(未變)

交叉驗證(不只信它自記的 commit):/portal/data/ 這條 #105 才有的路徑
在 arcrun-mcp 成品裡出現 8 次。

下一步(等 leo 解閘):把修好的引擎部署到 geek6688 當出貨機
(ARCRUN_SHIP_BASE 可覆寫,預設是 leo21c——arcrun-rag#79 要搬離的正是這個),
再從那台跑出貨線,第 17 站 purge 的 wait 節點才有帶修法的引擎可跑。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:03:30 +08:00
Leo 89b80ff90e Merge PR #105: MCP 用登入者的身分查詢,不再去找服務內部金鑰
總管複驗(不聽自述,自己重跑,並與 base a24f291 逐條比對):
  mcp     tsc 乾淨;vitest 113/113 綠
  cypher  14 紅與 base 逐字元相同(diff 無輸出);passed 386→400,新增 14 條全過
  kbdb    5 紅與 base 相同
  安全邊界 11 條全綠(跨租戶 404/越庫 404/owner_id 由 server 定死)

殘項(已記,不擋併):
  · kbdb 的 owner_id 欄位改動沒有新測試守著(213 總數未變)
  · 工作流面仍靠 MCP_OWNER_NAMESPACE || leo 那個巧合,本 PR 刻意沒動(拔了 arcrun_* 會全面失效)
  · 端到端  未驗:要部署到 leo21c,那道閘要 leo 親手解
2026-08-12 11:42:03 +00:00
7 changed files with 629 additions and 190 deletions
+8
View File
@@ -52,6 +52,14 @@ scripts/__pycache__/
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
*.sql
backup-*.sql
# 🔴 但 migration 不是備份,它是**要出貨的程式碼**(2026-08-12 實撞):
# 上面那條 `*.sql` 的用意是擋 D1 匯出(整庫全量資料=機敏),卻連 migration 一起吃掉。
# 後果:0001-0004 因為在該規則之前就 commit 所以還在,**0005/0006 從此沒進過版控**
# ⇒ 更新指令從 Gitea 抓 main,那兩個檔根本不在那裡 ⇒ 每個用戶都會收到
# 「✗ D1 migration: 部署物缺 kbdb/migrations/0005…」——**不是誰忘了推,是規則吃掉的**。
# ⇒ 與 `.component-builds/**/component.wasm` 同慣例(見 rules/05-deploy-convention.md
# 「WASM 來源」段),用否定規則放行。備份檔仍由 `backup-*.sql` 與目錄位置擋住。
!kbdb/migrations/*.sql
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
.github-public/
@@ -13285,7 +13285,12 @@ portalRouter.post(
session_token: token,
display_name: rec.values.display_name ?? "",
role: rec.values.role ?? "user",
libraries: parseLibraries(rec.values.libraries)
libraries: parseLibraries(rec.values.libraries),
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
session_expires_in: sessionTtl(c.env)
// 絕不回租戶字串(design §3.3portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*
});
})
@@ -15363,6 +15368,151 @@ portalDataRouter.get(
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
})
);
function recordLibrary(values) {
const lib = values?.library;
return typeof lib === "string" && lib.trim() ? lib.trim() : null;
}
function canReadRecord(rec, tenant2, libraries) {
if ((rec.owner_id ?? "") !== tenant2) return false;
const lib = recordLibrary(rec.values);
return lib === null || canReadLibrary(libraries, lib);
}
portalDataRouter.get(
"/portal/data/map",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({ success: true, libraries: [], count: 0, 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))}`);
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.libraries)) {
return c.json({ error: "\u85CF\u66F8\u5730\u5716\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 libraries \u6E05\u55AE" }, 502);
}
const allowed = body.libraries.filter(
(l) => typeof l?.library === "string" && canReadLibrary(libraries, l.library)
);
return c.json({ success: true, libraries: allowed, count: allowed.length });
})
);
portalDataRouter.get(
"/portal/data/map/:library",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
const library = c.req.param("library");
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
})
);
portalDataRouter.get(
"/portal/data/templates",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const res = await kbdbFetch(c.env, "/templates");
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
})
);
portalDataRouter.post(
"/portal/data/templates",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const body = await c.req.json().catch(() => null);
if (!body || typeof body.name !== "string" || !body.name.trim() || !Array.isArray(body.slots)) {
return c.json({ error: "name \u8207 slots[] \u5FC5\u586B" }, 400);
}
const res = await kbdbFetch(c.env, "/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: body.name,
slots: body.slots,
description: typeof body.description === "string" ? body.description : void 0,
created_by: portalTenant(c.env)
})
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
})
);
portalDataRouter.get(
"/portal/data/records/by-template/:template",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
const tenant2 = portalTenant(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param("template"))}?owner_id=${encodeURIComponent(tenant2)}`
);
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.records)) {
return c.json({ error: "record \u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
}
const records = body.records.filter((r) => canReadRecord(r, tenant2, libraries));
return c.json({ success: true, records, count: records.length });
})
);
portalDataRouter.get(
"/portal/data/records/:recordId",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return notFound(c);
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param("recordId"))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
const body = await res.json().catch(() => null);
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
})
);
portalDataRouter.post(
"/portal/data/records",
(c) => run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({ error: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u7121\u6CD5\u5BEB\u5165" }, 403);
}
const body = await c.req.json().catch(() => null);
if (!body || typeof body.template !== "string" || !body.template.trim() || !body.values || typeof body.values !== "object") {
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
}
const values = body.values;
const targetLib = recordLibrary(values);
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
return c.json({ error: `\u7121\u300C${targetLib}\u300D\u5EAB\u7684\u6B0A\u9650\uFF0C\u4E0D\u80FD\u5BEB\u5165\u8A72\u5EAB` }, 403);
}
const res = await kbdbFetch(c.env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) })
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
})
);
portalDataRouter.get(
"/portal/data/diagnostics",
(c) => run(c, async () => {
+7 -5
View File
@@ -3281,7 +3281,7 @@ async function createRecord(db, input) {
});
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, tpl.id, slot, entry.id).run();
}
return { record_id: recordId, template_id: tpl.id, values: input.values };
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
}
async function updateRecord(db, recordId, values) {
const evRes = await db.prepare(
@@ -3312,7 +3312,7 @@ async function updateRecord(db, recordId, values) {
}
async function getRecord(db, recordId) {
const res = await db.prepare(
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.record_id = ?`
).bind(recordId).all();
@@ -3320,7 +3320,8 @@ async function getRecord(db, recordId) {
if (rows.length === 0) return null;
const values = {};
for (const r of rows) values[r.slot] = r.content;
return { record_id: recordId, template_id: rows[0].template_id, values };
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
}
async function searchByTemplate(db, template, owner_id, limit = 100) {
const tpl = await getTemplate(db, template);
@@ -3339,17 +3340,18 @@ async function searchByTemplate(db, template, owner_id, limit = 100) {
const chunk = ids.slice(i, i + 90);
const 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
`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: {} };
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;
}
}
return ids.map((id) => byId.get(id)).filter((r) => !!r);
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T07:29:58.626Z",
"repo_head": "1791ffa4972b4135dacd4208e805f67b747479c4",
"generated_at": "2026-08-12T12:02:15.698Z",
"repo_head": "89b80ff90e95f07b5d44978ef58090ac783f53bd",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "f1370e2275eea62b64a88821a096f2c2cfe76fb0",
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 577374,
"content_sha256": "8411ed59b7ad9e1a74ac0d8e3b620d7166e7d0178ac5939e6cc736f2e8d1d2be",
"js_bytes": 584610,
"content_sha256": "6a424274ecf7beb28b747672296c4c33f949c6d7b134e8054e640e92198910ba",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -58,11 +58,11 @@
{
"name": "arcrun-kbdb",
"source_dir": "kbdb",
"source_commit": "c497ec418eba6cd94b1d5872671c51fd5812c11c",
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
"main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 149533,
"content_sha256": "ffb8d43467d0cefbd7545fdc0d347f2b965e3c3de20b3315eed7613f20266891",
"js_bytes": 149797,
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -148,11 +148,11 @@
{
"name": "arcrun-mcp",
"source_dir": "mcp",
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
"main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1165388,
"content_sha256": "c5ff10f9b9d5a77217be343af12d2be3ee8f9792d3e1e091e48e5e6c8d24ca9d",
"js_bytes": 1179487,
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
"modules": [],
"compat_date": "2024-11-27",
"compat_flags": [
@@ -0,0 +1,25 @@
-- credential template seedD38 圍牆修復,總管交辦,2026-08-07)
-- SDD:無專屬 SDDD38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
--
-- D38 鐵律(leo 2026-06-14 立、2026-08-07 擴大):KBDB 三張表打天下,永遠不加新表;
-- 新資料類型一律用 template + entries,同 0003_library_map.sql / 0004_execution_log_template.sql
-- 的手法——對 templates 表 INSERT OR IGNORE 一列定義,不建新表、不動既有表的結構。
--
-- 這是「credential 目錄」的第二個家:原本 0002_credentials.sql 在 KBDB 裡多開了一張
-- 獨立表(違規,見 kbdb-usage skill「反例」),本檔 + 0006_drop_credentials_table.sql
-- 把它改回三張表的形狀——一筆 credentialentries 表一列(entry_type='credential'
-- page_name=name 當冪等鍵,owner_id=api_key 做租戶隔離,其餘欄位打包進 metadata_json),
-- 儲存精神比照既有 recipe_stat / execution_logtemplate 只負責文件化,實際資料不走
-- entry_values 全展開的多列 record)。
--
-- 密文本體不在這裡:值仍住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理
-- API 唯寫,D19「擁有目錄,不擁有內容物」不變)。這張 template 定義的 slots 全部是目錄
-- 欄位,零密文——與舊 0002_credentials.sql 的欄位定義一字不變,只是換了個家。
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
VALUES (
'tpl-credential',
'credential',
'credential 目錄(D38 圍牆修復:改走 entries 表 entry_type=credential,取代舊 credentials 表;零密文,密文本體住 Workers per-script Secrets',
'["name","service","sensitivity","secret_ref","last_used_at"]',
'system'
);
@@ -0,0 +1,47 @@
-- 退役 credentials 表(D38 圍牆修復,總管交辦,2026-08-07)
-- SDD:無專屬 SDDD38 事故修復任務,見 system-dev/wiki/decisions-summary.md D38 段)。
--
-- 這是本次唯一真的需要動表結構的一支 migration,理由(不是繞過鐵律,是鐵律要求的收尾):
-- D38 要求 KBDB 回到「只有三張核心表」的狀態。0002_credentials.sql 當初在 KBDB 裡多開了
-- 一張獨立表,是已知違規(kbdb-usage skill 明文列為反例)。要把違規清乾淨,唯一辦法就是
-- 真的把那張表拆掉——拆表本身不能只用 API 做(API 不提供「拆表」這種牆內維運操作,
-- 也不該提供),所以下面兩句 SQL 標 kbdb-sql-ok:這不是繞過圍牆去存取資料,是圍牆施工
-- 本身(kbdb/migrations/ 就是牆內,本檔存在的唯一目的就是讓舊表退場)。
--
-- 冪等設計(deploy.ts 每次部署都會重跑這支檔案,沒有 migration 追蹤表):
-- 1. 先補一份空表存在保底——self-hosted 各實例套用進度不一,有些從沒跑過 0002(表從不
-- 存在)、有些已經跑過本檔一次(表已被拆)。沒有這一步,下面的搬資料/退場語句會因表
-- 不存在直接整支失敗(D1 對不存在的表沒有條件式跳過語法)。
-- 2. 把舊表裡「entries 還沒有對應列」的 row 搬進 entriesentry_type='credential'
-- page_name=name 冪等鍵,owner_id=api_key,其餘欄位打包進 metadata_json,欄位對應
-- 0005_credential_template.sql 定義的 slots)。NOT EXISTS 判斷防止重跑造成重複列。
-- 3. 搬完資料後表就沒有存在的理由,最後一步讓它退場。下次部署若又被步驟 1 重新墊一份
-- 空殼,也只是空表、立刻搬 0 筆、立刻退場,不影響任何人(真資料只會被搬一次,因為
-- 步驟 2 的判斷是看 entries 裡有沒有,不是看這是不是第一次跑)。
CREATE TABLE IF NOT EXISTS credentials ( -- kbdb-sql-ok: 表退場施工步驟①保底存在,非資料存取違規,理由見檔頭
api_key TEXT NOT NULL,
name TEXT NOT NULL,
service TEXT,
sensitivity TEXT NOT NULL DEFAULT 'standard',
secret_ref TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
PRIMARY KEY (api_key, name)
);
INSERT INTO entries (id, entry_type, owner_id, page_name, metadata_json, created_at, updated_at)
SELECT
'e_cred_' || lower(hex(randomblob(8))),
'credential',
c.api_key,
c.name,
json_object('service', c.service, 'sensitivity', c.sensitivity, 'secret_ref', c.secret_ref, 'last_used_at', c.last_used_at),
c.created_at,
unixepoch()
FROM credentials c
WHERE NOT EXISTS (
SELECT 1 FROM entries e
WHERE e.entry_type = 'credential' AND e.owner_id = c.api_key AND e.page_name = c.name
);
DROP TABLE IF EXISTS credentials; -- kbdb-sql-ok: 表退場施工步驟③讓舊表退場,非資料存取違規,理由見檔頭