Compare commits

..

10 Commits

Author SHA1 Message Date
uncle6me-web fad5da0e17 rules/07 §3.6:自舉例外——能力只實作一次,但不一定要是 HTTP API
代補 PR #111 的 agent 想加但改不動的段落(該檔受保護)。

立這條的原因不是理論:#97 的修法一開始寫在 cli/src/lib/resource-resolver.ts
(能力住在介面層,違反 §0)⇒ 安裝器拿不到它 ⇒ 同一個 bug 只修了一半,
走 acr 的人有保護、走 install.arcrun.dev 的人沒有——而所有真實用戶走後者。

leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」

記三件,都是為了不讓下一個人「修正」回去:
① 為什麼不放 cypher API(自舉/輸入是使用者自己的帳號狀態/它是純函式)
② §0 的正確讀法是「只准有一份、不准住在單一介面裡」,放 API 只是常見手段
③ npm 打包例外:cli/ 下的逐位元組副本由 sync --check 機械擋漂移,不是第二份實作

📍 repo:matrix/arcrun(shared/resource-rule/、cli/、.claude/rules/07)
   + products/arcrun-rag(安裝器待接上,已派工)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:50:53 +08:00
Leo 13155a1d7b Merge PR #111: 把「該用哪些資源」搬出 CLI——一份實作,兩條路吃同一條規則
leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」

總管複驗(不聽自述,自己重跑):
  兩份 rule.mjs 逐位元組相同;--check 綠
  故意加一行製造漂移 → --check 當場擋下;還原後恢復綠 ⇒ 閘是真的,不是假綠
  cli 測試 58 pass / 0 fail(main 上是 49 ⇒ 多 9 條全過)
  邊界乾淨:沒動 cypher/kbdb/mcp;resource-resolver.ts 減 461 行變薄殼

兩條新測試比我寫的收工標準更嚴:
  two-paths-agree 斷言「一邊停手、一邊照做 = 最危險的分歧」與「停手的理由也要一樣」
  single-implementation 帶 assert.ok(files.length > 0) 防「檢查 0 個卻通過」
  ——後者正是總管當晚剛撞過的假綠形狀

殘項(agent 誠實標的,不擋併):
  · youlin 上走安裝器那條路的實測沒跑成——安裝器在 arcrun-rag,不在它的工作副本裡,
    且沙箱無 CF 憑證。它依紅線「不確定時寧可中止」沒有猜著跑。
  · 安裝器那一行 import 尚未接上(規格在 shared/resource-rule/README.md §4)
  · rules/07 想補「自舉例外」正例,該檔受保護,內容留在 README §2 待總管代補
2026-08-12 15:44:50 +00:00
uncle6me-web bb548b6fdf refactor(shared): 「該用哪些資源」搬出 CLI——一份實作,acr 與安裝器吃同一條規則
leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」

「這個實例該用哪些資源」換到安裝器就要重寫一次 ⇒ 依 rules/07-thin-shell.md 的判準
它是**能力**,而它原本住在 cli/src/lib/resource-resolver.ts ⇒ 那本身就是違規。
後果已經真的發生:acr 那條有 Arcrun#97 的修法、安裝器那條沒有,於是安裝器照名字
找、找不到就建一顆空的綁上去 ⇒「我按了更新,工作流和登入全不見了」。

規則搬到 shared/resource-rule/(零依賴 ESM,Node 與 Workers runtime 都直接跑):

  · rule.mjs           規則本體+把 CF 回應讀成事實的 normalizeLive*
  · cf-resource-api.mjs ResourceApi 的 CF REST 實作——**眼睛也共用**:
                        兩條路各自解讀 CF 回應,只要一邊看不到既有綁定就會去新建,
                        #97 不需要規則寫錯就能重演
  · installer-entry.mjs 安裝器唯一該碰的入口 resolveInstanceResources()

不是做成 cypher 端點的理由(自舉):這條規則要在「決定怎麼裝」的當下就用得到,
而那時 cypher 可能還不存在(安裝器的工作正是把它生出來);且輸入是使用者自己帳號的
綁定狀態,不該送去平台換答案。它是純函式,用不著變成服務。

只有一份,機械看守:
  · 安裝器直接 import repo archive 裡的原稿,**不需要副本**
  · acr 因為 npm pack 打不進套件目錄外的檔案,帶一份逐位元組鏡射
    (scripts/sync-resource-rule.mjs 產生;build/test 先跑 --check,差一位元組就紅)
    ——同 cli/harness/ 產生物+世代閘的既有慣例
  · cli/tests/single-implementation.test.ts 掃全 repo:7 支規則函式的實作只有一處

CLI 淨 -496 行(邏輯是搬走,不是複製)。cf-api.ts 的 CfAccountClient 保留公開介面,
ResourceApi 那七個方法全部委派共用 client。

驗證:cli 58/58 綠(含新增的兩條路一致性 fixture + 三種情境),tsc --noEmit 乾淨。
2026-08-12 23:37:01 +08:00
uncle6me-web e05518a2b4 chore(builds): 重編成品——把 #107 的版本標籤修法放進執行檔
出貨線第 1 站的 #93 新鮮度閘擋下:cypher-executor 成品記 10d150a,
而 HEAD 已是 2129356(#107 改了 routes/health.ts 與 types.ts)。

這是該閘今晚第三次擋對——沒有它,1.4.42 會送出一個「版本標籤修法只在源碼裡」的成品。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:22:57 +08:00
Leo 21293568d5 Merge PR #107: 更新完還看得到版本號——CLI 重部署不再把版本標籤洗掉(Arcrun#106)
總管複驗(不聽自述,自己重跑並與 base 逐條比對):
  cli      main 是 0 pass / 3 fail(測試根本跑不起來)→ PR 49 pass / 0 fail
           ⇒ agent 那句「#97 的守衛測試一直沒在跑」是真的,又一個假綠
  cypher   14 failed / 402 passed(base 14 / 400);失敗清單 diff 無輸出=零回歸

設計比我建議的對:版本標籤每趟重烙、其他 vars 才沿用。
我原本建議「一律沿用」會讓版本號永遠停在安裝當時。

殘項(agent 誠實標的,不擋併):沒在真實例上跑過、沒有真 Portal 截圖
(它的環境指向 leo21c 紅線、無 youlin 憑證、瀏覽器未授權)。總管接手補這段。
2026-08-12 14:05:57 +00:00
uncle6me-web 53b05c6d3d fix(cli): 更新完還看得到版本號——CLI 重部署不再把版本標籤(和你的設定)洗掉
leo 08-12 實撞:更新完 leo21c,Portal 設定頁的「版本」變成
「無法讀取目前版本(知識庫服務可能正在啟動)」。版本號是 leo 唯一的驗收介面,
看不到就等於他無法自己確認任何一次更新有沒有生效。

根因(Arcrun#106):`bundle_version` 來自部署時注入的 plain_text var
`ARCRUN_BUNDLE_VERSION`,而**只有安裝器會注入**。wrangler deploy 是整份覆蓋,
toml 沒寫的 var 直接消失 ⇒ CLI 更新那條路每跑一次就把標籤洗掉一次。
#97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),沒修「櫃子上的標籤」。

修法(兩種 var 走相反的規則,這是本次的判斷):
· 設定類 var = 使用者實例的事實 → **沿用**(讀綁定時同一份回應就帶回來,不多打 API)
  ——把 #97「已部署的 worker 上綁著什麼就是事實」原封不動套用到 plain_text var。
· 版本標籤 = 這份成品的屬性 → **每趟重烙,絕不沿用舊值**。
  沿用舊值會得到一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
  因為它會讓人以為驗收過了。
  版號取部署當下發行頻道公告的 release(Portal/daemon 就是拿它當「最新版」比),
  另外把**真正部署的 commit** 一起烙上去(/health 多吐 `bundle_commit`)→ 漂掉查得出來。
  查不到 release 就誠實退成 `YYYY-MM-DD+<commit7>`,不掰一個 semver 假裝已是最新。

順帶(都是同一條路上的東西):
· ref 先解析成 commit sha 再用 sha 下載 archive——不可變,順手解掉 branch tarball 被快取的老病
· Portal 版本行接受帶 build metadata 的 semver(`1.4.41+d61` 這種先前一律被當成「較舊版本」)
· cli 測試在 node 22 上本來一支都跑不起來(.js→.ts 解析 + parameter property),補上 resolve hook
  ——#97 那份「使用者的東西還在不在」的迴歸守衛也在其中,跑不起來的守衛等於沒有守衛
· types.ts 的 ARCRUN_BUNDLE_VERSION 重複宣告(TS2300)併回一處

驗證見 PR:cli 49/49 綠、cypher health 4/4 綠、Portal 版本行原始碼實跑五種情境、
對真實已部署 worker 的唯讀 dry-run。**未做**:真實實例上的 acr update 端到端
(本機唯一有憑證的帳號是 leo21c=紅線禁碰,youlin 無憑證)。

Refs: Leo/Arcrun#106, #97, #95

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:58:43 +08:00
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
uncle6me-web 10d150ac2b fix(mcp): MCP 用登入者的身分查詢,不再去找一把服務內部金鑰
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。

病根(不是金鑰沒同步,是身分沒接住):
  oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
  namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
  只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
  而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
  所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。

修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
   display_name/role/libraries 存進 authorization code → access token。
   /portal/login 補回 session_expires_in,access_token TTL 夾成
   min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
   cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
   session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
   kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
   藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
   分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
   服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。

新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
  GET  /portal/data/map、/portal/data/map/:library
  GET  /portal/data/templates、POST /portal/data/templates
  GET  /portal/data/records/by-template/:t、GET /portal/data/records/:id
  POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。

KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。

沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。

驗證:
  mcp        tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
  cypher     vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
  kbdb       vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
  端到端     ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:33:12 +08:00
68 changed files with 5610 additions and 1294 deletions
+32
View File
@@ -99,6 +99,38 @@ CLI / MCP / Python lib / JS lib 全是薄殼:只做「介面轉換 + 暴露」
---
## 3.6 自舉例外:能力該「只實作一次」,但不一定要是 HTTP API2026-08-12 立)
> 立這條的原因:`Arcrun#97`(更新把使用者的工作流與登入弄不見)的修法一開始寫在
> `cli/src/lib/resource-resolver.ts` ——**能力住在介面層,違反 §0**。
> 後果不是理論:**安裝器(arcrun-rag)拿不到它,於是同一個 bug 只修了一半**,
> 走 `acr` 的人有保護、走 `install.arcrun.dev` 的人沒有——**而所有真實用戶走後者**。
> leo 2026-08-12:「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
修法(PR #111)把它搬到 **`shared/resource-rule/`:一份零依賴 ESM**
`acr` 與安裝器共用。**它刻意不是 cypher 的 API 端點**,三個理由:
| 為什麼不放 API | 說明 |
|---|---|
| **自舉** | 這條規則要在「決定怎麼裝」的當下用得到,而安裝器的工作正是把 cypher 生出來。放進 cypher = 要先有雞才能有蛋。 |
| **輸入是使用者自己的帳號狀態** | 判斷依據是使用者 CF 帳號上的綁定。送去平台託管的 worker 換答案 ⇒ ①「能不能安裝」綁在平台是否活著 ②使用者的帳號拓撲交給第三方。 |
| **它根本不需要是服務** | 這是**純函式**,唯一的 IO 由呼叫端注入。**§0 要求「能力只實作一次」,不是「能力一定要是 HTTP」。** |
🔴 **所以本檔 §0 的正確讀法是**:能力**只准有一份**,且**不准住在任何單一介面裡**。
「放 API」是達成它的**常見手段**,不是唯一手段。
**判準仍然是那句口訣**:「這段邏輯換一個介面要不要重寫?」要 → 它是能力。
📌 **給下一個人**:看到 `shared/` 底下的純函式**不要「修正」成 API 端點**——
先讀 `shared/resource-rule/README.md §2`,那裡記著評估過並否決的其他形態
(共用 npm 套件=自舉問題換位置;做成零件=要用 TinyGo 重寫一次,那才是第二份實作)。
📌 **打包例外**`acr` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒
`cli/` 下必須有一份**逐位元組副本**。那不是第二份實作——
`scripts/sync-resource-rule.mjs --check` 一有漂移就 exit 1,且 `build``test` 都會先跑它
(同 `cli/harness/` 的既有慣例)。**手改副本 = build 紅 publish 擋下。**
---
## 4. 統一帳號來源(薄殼共用同一身份)
所有薄殼讀**同一份**身份設定:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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/
@@ -9341,9 +9341,11 @@ function authStoreStatus(env) {
var healthRouter = new Hono2();
healthRouter.get("/health", (c) => {
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
return c.json({
ok: true,
...bundleVersion ? { bundle_version: bundleVersion } : {},
...bundleCommit ? { bundle_commit: bundleCommit } : {},
auth_store: authStoreStatus(c.env),
// arcrun-rag#38/#69/#252026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
@@ -13285,7 +13287,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 +15370,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-12T14:22:56.880Z",
"repo_head": "21293568d550ab7ef50cec2020165d8bf4376104",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "f1370e2275eea62b64a88821a096f2c2cfe76fb0",
"source_commit": "53b05c6d3d4a5a02661880dd5565fa9feb743bb6",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 577374,
"content_sha256": "8411ed59b7ad9e1a74ac0d8e3b620d7166e7d0178ac5939e6cc736f2e8d1d2be",
"js_bytes": 584721,
"content_sha256": "b7c810d7654c05d197abe9a37acce2ed5f77e1af09595d5b4316b69166edf11a",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -58,11 +58,11 @@
{
"name": "arcrun-kbdb",
"source_dir": "kbdb",
"source_commit": "c497ec418eba6cd94b1d5872671c51fd5812c11c",
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
"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": [
+3 -2
View File
@@ -8,11 +8,12 @@
"main": "./dist/index.js",
"type": "module",
"scripts": {
"build": "npm run build:harness && npm run check:harness && tsc",
"build": "npm run build:harness && npm run check:harness && npm run check:rule && tsc",
"build:harness": "node scripts/build-harness-skill.mjs",
"check:harness": "node scripts/check-harness-generation.mjs",
"check:rule": "node ../scripts/sync-resource-rule.mjs --check",
"dev": "tsc --watch",
"test": "node --test \"tests/**/*.test.ts\"",
"test": "npm run check:rule && node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"",
"prepublishOnly": "npm run build && chmod +x dist/index.js"
},
"dependencies": {
+49 -137
View File
@@ -3,7 +3,8 @@
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
*/
import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js';
import { createCloudflareResourceApi } from './resource-rule/cf-resource-api.mjs';
import type { ResourceApi, ScriptBindings } from './resource-resolver.js';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
@@ -86,170 +87,81 @@ export class CfKvClient {
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
*/
export class CfAccountClient implements ResourceApi {
private accountBase: string;
private headers: Record<string, string>;
/**
* `ResourceApi` 的七個方法**全部委派**給共用規則附的那支 client
* `shared/resource-rule/cf-resource-api.mjs`)。
*
* 🔴 為什麼不是在這裡自己實作一份:判斷一致還不夠,**看到的東西**也要一致。
* 兩條路各自寫一份 CF client,只要有一邊把 404 當錯誤、漏了 per_page、少認一種
* 欄位名,那一邊就會「看不到既有綁定」——而看不到既有綁定的下一步,依規則就是新建。
* Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。
*/
private readonly rule: ReturnType<typeof createCloudflareResourceApi>;
constructor(accountId: string, apiToken: string) {
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`;
this.headers = {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
this.rule = createCloudflareResourceApi({ accountId, apiToken });
}
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
const { ok, status, result, error } = await this.cfRaw<T>(path, init);
const { ok, status, result, error } = await this.rule.cfRaw(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result as T;
}
/** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */
private async cfRaw<T>(
path: string,
init?: RequestInit,
): Promise<{ ok: boolean; status: number; result?: T; error?: string }> {
const res = await fetch(`${this.accountBase}${path}`, {
...init,
headers: { ...this.headers, ...(init?.headers ?? {}) },
});
const data = await res.json().catch(() => null) as
| { success: boolean; result: T; errors?: Array<{ message: string }> }
| null;
if (!res.ok || !data?.success) {
return {
ok: false,
status: res.status,
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
}
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
async verifyAccess(): Promise<void> {
// GET /accounts/{id} 能通 = token 有此 account 的基本讀權限
await this.cf<{ id: string; name: string }>('');
}
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
async listKvNamespaces(): Promise<Map<string, string>> {
const result = await this.cf<Array<{ id: string; title: string }>>(
'/storage/kv/namespaces?per_page=100',
);
const map = new Map<string, string>();
for (const ns of result) map.set(ns.title, ns.id);
return map;
}
/**
* 無條件新建一顆 KV namespace。
*
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
* (安裝器取的名字跟我們的 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
* 要不要建,一律先經過 resource-resolver 的 planResources 判斷;那裡只有在
* 「確定沒有任何已部署的 worker 綁過這個 binding」時才會排進 create。
*/
async createKvNamespace(title: string): Promise<string> {
const result = await this.cf<{ id: string; title: string }>(
'/storage/kv/namespaces',
{ method: 'POST', body: JSON.stringify({ title }) },
);
return result.id;
}
/**
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
* CF`GET /accounts/{id}/workers/scripts/{script}/settings` → `result.bindings[]`。
*
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
* 把查不到當成不存在,就是 #97 的根因。
*/
async getScriptBindings(script: string): Promise<ScriptBindings> {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [] };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) };
}
/** 查 workers.dev subdomaincypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
async getWorkersSubdomain(): Promise<string> {
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
return result.subdomain;
}
// D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified).
async listD1Databases(): Promise<Map<string, string>> {
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
const map = new Map<string, string>();
for (const db of result) map.set(db.name, db.uuid);
return map;
// ── 以下七支=`ResourceApi`,一律委派共用規則,**這個檔案不得自己實作** ────────────
// `shared/resource-rule/cf-resource-api.mjs`;委派而非複製的理由見本 class 開頭)
/** 讀一顆已部署 worker 現在綁著哪些資源——使用者那側的事實(Arcrun#97 的唯一真相源)。 */
getScriptBindings(script: string): Promise<ScriptBindings> {
return this.rule.getScriptBindings(script);
}
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97。 */
async createD1Database(name: string): Promise<string> {
const result = await this.cf<{ uuid: string; name: string }>(
'/d1/database',
{ method: 'POST', body: JSON.stringify({ name }) },
);
return result.uuid;
/** 帳號上現有的 KV namespacetitle → id)。判斷「綁著的那顆還在不在」用。 */
listKvNamespaces(): Promise<Map<string, string>> {
return this.rule.listKvNamespaces();
}
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
async listVectorizeIndexes(): Promise<string[]> {
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
return (result ?? []).map(i => i.name);
/** 帳號上現有的 D1name → uuid)。 */
listD1Databases(): Promise<Map<string, string>> {
return this.rule.listD1Databases();
}
/** 帳號上現有的 Vectorize index 名單。 */
listVectorizeIndexes(): Promise<string[]> {
return this.rule.listVectorizeIndexes();
}
/**
* 新建 KBDB embed 用的 Vectorize index**bge-m3 = 1024 維 / cosine**,見 deploy.ts 常數說明)
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。沒有 ensure 版本:
* 「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)
* 無條件新建一顆 KV namespace
*
* 🔴 Arcrun#97:**故意沒有**「找不到同名就順手建一顆」的 ensure 版本
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路。
* 要不要建,一律先經過 planResources;那裡只有在「確定沒有任何已部署的 worker
* 綁過這個 binding」時才會排進 create。
*/
async createVectorizeIndex(name: string): Promise<string> {
const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', {
method: 'POST',
body: JSON.stringify({
name,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
});
if (res.ok) return name;
const detail = (res.error ?? '').toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
createKvNamespace(title: string): Promise<string> {
return this.rule.createKvNamespace(title);
}
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。 */
createD1Database(name: string): Promise<string> {
return this.rule.createD1Database(name);
}
/** 新建 KBDB embed 用的 Vectorize index。沒有 ensure 版本,理由同上(Arcrun#97)。 */
createVectorizeIndex(name: string): Promise<string> {
return this.rule.createVectorizeIndex(name);
}
}
/** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */
interface RawWorkerBinding {
type?: string;
name?: string;
namespace_id?: string;
id?: string;
database_id?: string;
index_name?: string;
}
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] {
const out: LiveBinding[] = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
}
return out;
}
+258 -5
View File
@@ -98,6 +98,119 @@ function giteaToken(): string | undefined {
return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
}
/**
* 版本標籤的「發行頻道」來源(Arcrun#106)。
*
* Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」,
* 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號,
* 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
* fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。
*/
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';
/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */
export const CLI_MANAGED_VARS = [
'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入
'CF_ACCOUNT_ID', // 由 ctx.accountId 注入
'MULTI_TENANT', // 由 selfHosted 注入
'KBDB_BASE_URL', // 由 workerSubdomain 組
'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp
'ARCRUN_BUNDLE_COMMIT',
] as const;
/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';
/** 這趟部署要烙上去的版本標籤。 */
export interface BundleStamp {
/** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
version: string;
/** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */
commit?: string;
/** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */
note: string;
}
/**
* 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。
*
* 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。
* 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
* 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定,
* 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。
*
* 誠實邊界(mindset §7,這段要留著):
* - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的,
* 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」,
* 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去**
* `ARCRUN_BUNDLE_COMMIT``/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。
* - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+<commit7>` 這個
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
*/
export async function resolveBundleStamp(
ref: string,
commit?: string,
fetchImpl: typeof fetch = fetch,
): Promise<BundleStamp> {
const short = commit ? commit.slice(0, 7) : ref;
const today = new Date().toISOString().slice(0, 10);
try {
const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as { release?: string } | null;
const release = String(body?.release ?? '').trim();
if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver${release || '空'}`);
return {
version: release,
commit,
note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''}`,
};
} catch (e) {
const version = `${today}+${short}`;
return {
version,
commit,
note:
`${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)}` +
`\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
};
}
}
/**
* 把 `ref`branch / tag / sha)解析成確切的 commit shaArcrun#106)。
*
* 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**——
* sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
* 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。
*/
export async function resolveGiteaCommit(
ref: string,
fetchImpl: typeof fetch = fetch,
): Promise<string | undefined> {
const headers = buildDownloadHeaders();
const tryUrls = [
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
];
for (const url of tryUrls) {
try {
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
if (!res.ok) continue;
const body = (await res.json()) as
| { commit?: { id?: string } }
| Array<{ sha?: string }>
| null;
const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
} catch {
/* 換下一種問法;全都問不到就回 undefined */
}
}
return undefined;
}
/**
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
* Gitea archive API`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
@@ -253,9 +366,12 @@ export async function downloadAndDeploy(
const mode = opts.mode ?? 'update';
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
// 1. 下載 + 解壓 Gitea archive tarball
// #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題),
// 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。
const commit = await resolveGiteaCommit(ref);
let root: string;
try {
root = await downloadRepoTarball(ref);
root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
} catch (e) {
return {
implemented: true,
@@ -310,6 +426,7 @@ export async function downloadAndDeploy(
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
const requirements: BindingRequirement[] = [];
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
const dirScript = new Map<string, string>(); // dir → worker script 名(#106var 沿用要逐顆對號)
for (const dir of allDirs) {
const tomlPath = join(dir, 'wrangler.toml');
if (!existsSync(tomlPath)) continue;
@@ -318,12 +435,14 @@ export async function downloadAndDeploy(
const preview = renderWranglerToml(raw, ctx, new Map());
const parsed = parseWranglerRequirements(preview);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
dirScript.set(dir, parsed.script);
for (const b of parsed.bindings) {
requirements.push({ ...b, worker: parsed.script });
}
}
let resolved = new Map<string, ResolvedResource>();
let liveVars = new Map<string, Record<string, string>>();
if (requirements.length > 0) {
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
let plan;
@@ -369,6 +488,7 @@ export async function downloadAndDeploy(
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
};
}
liveVars = plan.liveVars;
console.log(chalk.green(' ✓'));
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
const created = [...resolved.values()].filter((r) => r.origin === 'created');
@@ -407,6 +527,48 @@ export async function downloadAndDeploy(
}
}
// ── 2.8 varplain_text):既有的沿用、版本標籤重烙(Arcrun#106)─────────────────
//
// 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**:
// wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
// 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
//
// 兩種 var 走**相反**的規則,這是本次的核心判斷:
// · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用
// · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤
//
// 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。
// 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
// toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。
const extraVarsByDir = new Map<string, Record<string, string>>();
let stamp: BundleStamp | undefined;
if (dirScript.size > 0) {
const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
if (needStamp) {
process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...'));
stamp = await resolveBundleStamp(ref, commit);
console.log(chalk.green(' ✓'));
console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
}
const preservedTotal: string[] = [];
for (const [dir, script] of dirScript) {
const raw = tomlPreviews.get(dir);
if (!raw) continue;
const keep = preservedVars(liveVars.get(script), raw);
for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
const vars: Record<string, string> = { ...keep };
if (stamp && script === VERSION_STAMP_WORKER) {
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
}
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
}
if (preservedTotal.length > 0) {
console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`));
for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`));
}
}
// 3. 對每個 worker:注入 KV id+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
@@ -422,7 +584,7 @@ export async function downloadAndDeploy(
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
try {
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir));
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir));
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
const hash = dirContentHash(dir, ctx.accountId);
if (manifest[label] === hash) {
@@ -599,11 +761,13 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: str
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。
*
* Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/
async function downloadRepoTarball(ref: string): Promise<string> {
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const url = buildArchiveUrl(ref, bust);
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 1030 秒,視網速)...`));
// fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。
const label = fromRef ? `${fromRef}${ref.slice(0, 7)}` : ref;
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 1030 秒,視網速)...`));
const res = await fetch(url, {
signal: AbortSignal.timeout(120_000),
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball#13 P2 假綠根因)。
@@ -701,11 +865,91 @@ function injectWranglerConfig(
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
original?: string,
extraVars: Record<string, string> = {},
): void {
if (!existsSync(tomlPath)) return;
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
const toml = original ?? readFileSync(tomlPath, 'utf8');
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8');
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8');
}
/**
* 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text varArcrun#106)。
*
* 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話,
* 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外:
* ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤),
* 沿用等於拿舊值蓋掉正解。
* ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。
*
* ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病
* ——repo toml 裡的 `CONSOLE_TENANT = "leo"``WORKER_SUBDOMAIN` 之類是**官方 prod 的值**
* 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。
*/
export function preservedVars(
live: Record<string, string> | undefined,
toml: string,
): Record<string, string> {
const out: Record<string, string> = {};
if (!live) return out;
const managed = new Set<string>(CLI_MANAGED_VARS);
for (const key of Object.keys(live).sort()) {
if (managed.has(key)) continue;
if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報)
if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動
out[key] = live[key];
}
return out;
}
/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */
function readVar(toml: string, key: string): string | undefined {
const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
return m?.[1];
}
/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */
function tomlEscape(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
/**
* 把一組 var 寫進 toml 的 `[vars]`Arcrun#106)。純函式。
*
* 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級):
* 1. 已有未註解的同名行 → 換值
* 2. 只有被註解掉的同名行 → 取消註解並填值
* 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段
*/
export function applyVars(toml: string, vars: Record<string, string>): string {
let out = toml;
for (const key of Object.keys(vars).sort()) {
// 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。
if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
const value = tomlEscape(vars[key]);
// 🔴 一律用「函式版 replace」:值裡若有 `$&``$1` 這種字元,字串版 replace 會把它當成
// 反向參照展開,寫出來的就不是使用者那個值了。
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
out = out.replace(
new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
(_m, head: string, tail: string) => `${head}${value}${tail}`,
);
continue;
}
if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
out = out.replace(
new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
(_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
);
continue;
}
if (/^\s*\[vars\]\s*$/m.test(out)) {
out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
continue;
}
out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
}
return out;
}
/**
@@ -715,11 +959,15 @@ function injectWranglerConfig(
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
*
* `extraVars`Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
*/
export function renderWranglerToml(
toml: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
extraVars: Record<string, string> = {},
): string {
// cypher-executor 的 WORKER_SUBDOMAINvars)換成用戶帳號 subdomain
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
@@ -770,6 +1018,11 @@ export function renderWranglerToml(
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
}
// 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**:
// CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的
// WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
toml = applyVars(toml, extraVars);
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
// 空 map = 預覽模式,這步什麼也不做。
return applyResolvedBindings(toml, resolved);
+35 -401
View File
@@ -1,408 +1,42 @@
/**
* resource-resolver.ts — 資源解析:「已部署的 worker 現在綁著什麼,那就是事實」
* resource-resolver.ts — **這裡沒有邏輯**,只是把共用規則接到 CLI 的既有 import 路徑上。
*
* 🔴 Arcrun#972026-08-12 實害,leo 的實例中了):
* 舊做法叫「照名字 ensure」——`acr update` 拿 **binding 名**`WEBHOOKS`)當成 Cloudflare 上的
* **資源標題**去找,找不到就**新建一顆空的、然後綁到 worker 上**
* 安裝器建的資源不叫那個名字(它叫 `arcrun-rag-<instance>-kv-webhooks`)⇒ 一次例行更新
* 新建了 9 顆 KV、1 顆 D1,使用者的工作流/登入狀態/子庫**在畫面上全部消失**。
* 資料沒有被刪,但 worker 被綁去空的那幾顆——從使用者的角度,他的東西就是不見了。
* 「這個實例該用哪些資源」的規則住在 `shared/resource-rule/`repo 根目錄),
* 那是**唯一一份人手維護的實作**`./resource-rule/` 是該目錄的逐位元組鏡射
* `scripts/sync-resource-rule.mjs` 產生,`npm run build` / `npm test` 會跑 `--check` 擋漂移)
* 之所以要有這份鏡射:`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案。
*
* 根因不是「KV 那段寫錯」,是**「用名字猜使用者的資源」這個做法本身**
* 名字是**使用者那側的事實**(安裝器要怎麼取名由它決定,而且它有權改),
* 我們不能拿自己的命名慣例去對號入座,更不能在對不上的時候自作主張生一顆新的。
* ——所以修法不是「多比對幾種名字」,是**不再用名字當識別**
* 為什麼規則不在 CLIleo 2026-08-12
* 「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
* ——`acr` 有這條規則、安裝器沒有,結果就是 Arcrun#97:
* 安裝器照名字找、找不到就建一顆空的綁上去,使用者的工作流與登入狀態整片消失
* 規則搬到共用層之後,安裝器直接 import 同一份原稿,**不再有第二種答案**。
*
* ── 新規則(三句話)────────────────────────────────────────────────
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
*
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
* 🔴 不要把任何判斷寫回這個檔案。要改規則 → 改 `shared/resource-rule/rule.mjs`。
*/
/** 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。 */
export type ResourceKind = 'kv_namespace' | 'd1' | 'vectorize';
export {
planResources,
applyResourcePlan,
parseWranglerRequirements,
normalizeLiveBindings,
normalizeLiveVars,
bindingKey,
ResourcePlanBlocked,
KIND_LABEL,
TABLE_KIND,
} from './resource-rule/rule.mjs';
/** 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。 */
export interface LiveBinding {
kind: ResourceKind;
binding: string;
value: string;
}
export interface ScriptBindings {
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
deployed: boolean;
bindings: LiveBinding[];
}
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
export interface ResourceApi {
getScriptBindings(script: string): Promise<ScriptBindings>;
/** title → id */
listKvNamespaces(): Promise<Map<string, string>>;
/** name → uuid */
listD1Databases(): Promise<Map<string, string>>;
listVectorizeIndexes(): Promise<string[]>;
createKvNamespace(title: string): Promise<string>;
createD1Database(name: string): Promise<string>;
createVectorizeIndex(name: string): Promise<string>;
}
/** 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 */
export interface BindingRequirement {
kind: ResourceKind;
binding: string;
/** 需要它的 worker script 名(= wrangler.toml 的 `name`)。 */
worker: string;
createName: string;
}
export interface PlannedAdopt {
kind: ResourceKind;
binding: string;
value: string;
/** 從哪顆已部署的 worker 上讀到的 */
from: string;
}
export interface PlannedCreate {
kind: ResourceKind;
binding: string;
createName: string;
wantedBy: string[];
/** 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 */
alsoBind: string[];
}
export interface ResourcePlan {
adopt: PlannedAdopt[];
create: PlannedCreate[];
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
blockers: string[];
}
export interface ResolvedResource {
kind: ResourceKind;
binding: string;
value: string;
origin: 'adopted' | 'created';
from?: string;
}
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
constructor(readonly blockers: string[]) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
}
}
export function bindingKey(kind: ResourceKind, binding: string): string {
return `${kind}:${binding}`;
}
const KIND_LABEL: Record<ResourceKind, string> = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
function msg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
*
* @param mode 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
*/
export async function planResources(
api: ResourceApi,
requirements: readonly BindingRequirement[],
mode: 'update' | 'init',
): Promise<ResourcePlan> {
const blockers: string[] = [];
const adopt: PlannedAdopt[] = [];
const create: PlannedCreate[] = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
const live = new Map<string, LiveBinding[]>();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) live.set(script, res.bindings);
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
const byKey = new Map<string, BindingRequirement[]>();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
const existingCache = new Map<ResourceKind, Set<string>>();
const listExisting = async (kind: ResourceKind): Promise<Set<string>> => {
const hit = existingCache.get(kind);
if (hit) return hit;
let set: Set<string>;
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;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
const found: Array<{ value: string; script: string }> = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
let existing: Set<string>;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers };
}
/**
* 收斂「不同 binding 其實是同一顆資源」的情況。
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`),不是使用者那側的資源名——
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
*
* 沒有這一步會出兩種錯:
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
*/
function shareSameResource(
adopt: PlannedAdopt[],
create: PlannedCreate[],
byKey: Map<string, BindingRequirement[]>,
): PlannedCreate[] {
const declaredName = (kind: ResourceKind, binding: string): string | undefined =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
const out: PlannedCreate[] = [];
const groups = new Map<string, PlannedCreate>();
for (const c of create) {
const groupKey = `${c.kind}${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* 照 plan 動手:沿用的原樣帶出來,該建的才建。
* 有任何 blocker 直接丟 ResourcePlanBlocked**一顆都不建**。
*/
export async function applyResourcePlan(
api: ResourceApi,
plan: ResourcePlan,
): Promise<Map<string, ResolvedResource>> {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
const out = new Map<string, ResolvedResource>();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
const madeSoFar: string[] = [];
for (const c of plan.create) {
let value: string;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
export interface WranglerRequirements {
/** worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。 */
script: string;
bindings: Array<{ kind: ResourceKind; binding: string; createName: string }>;
}
/** wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。 */
export const TABLE_KIND: Record<string, ResourceKind> = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。
*
* 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作,
* 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求**
* kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。
*/
export function parseWranglerRequirements(toml: string): WranglerRequirements {
let script = '';
let seenTable = false;
const bindings: WranglerRequirements['bindings'] = [];
let kind: ResourceKind | null = null;
let binding = '';
let createName = '';
const flush = (): void => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
export type {
ResourceKind,
LiveBinding,
ScriptBindings,
ResourceApi,
BindingRequirement,
PlannedAdopt,
PlannedCreate,
ResourcePlan,
ResolvedResource,
WranglerRequirements,
RawWorkerBinding,
} from './resource-rule/rule.mjs';
@@ -0,0 +1,202 @@
// @ts-check
/**
* cf-resource-api.mjs — 規則的**眼睛與手**:對 Cloudflare 帳號的那七個動作,也只有一份。
*
* `rule.mjs` 是純判斷,IO 由呼叫端注入(`ResourceApi`)。本檔就是那個注入物的正貨:
* 用 CF REST API 實作 `ResourceApi`,零依賴、只用 global `fetch`
* ⇒ Node 18+ 與 Cloudflare Workers runtime 都能直接跑。
*
* 【為什麼連這層也要共用】
* 判斷一致還不夠——**看到的東西**也要一致。
* 「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的;
* 如果兩條路各自寫一份 client,隨便一個差異(打錯端點、把 404 當錯誤、漏了 per_page、
* 少認一種欄位名)都會讓其中一條路「看不到既有綁定」——而看不到既有綁定的下一步,
* 依規則就是**新建**。Arcrun#97 的災情不需要規則寫錯,只要眼睛不一樣就會重演。
*
* 這裡**故意只有 `ResourceApi` 那七個方法**。verifyAccess / 查 subdomain / KV 讀寫
* 這些跟「該用哪些資源」無關的帳號操作留在各自的呼叫端,不往共用層堆。
*
* 🔴 除了同目錄的 `./rule.mjs`,這支不准 import 任何東西——共用層的價值在於
* 「整個目錄複製到哪個 runtime 都能直接跑」,多一個外部依賴就少一條路吃得到。
*/
import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
* @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding
*/
/**
* @typedef {object} CfResourceApiOptions
* @property {string} accountId
* @property {string} apiToken
* @property {typeof globalThis.fetch} [fetch]
* 注入用(離線測試餵假帳號、或宿主要用自己的 fetch)。預設 global fetch。
*/
/**
* 建一個打真實 Cloudflare 的 `ResourceApi`。
*
* @param {CfResourceApiOptions} options
* @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }}
*/
export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) {
const doFetch = fetchImpl ?? globalThis.fetch;
if (typeof doFetch !== 'function') {
throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。');
}
const accountBase = `${CF_API_BASE}/accounts/${accountId}`;
const headers = {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
/**
* 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.success) {
return {
ok: false,
status: res.status,
error:
(data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') ||
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
}
/**
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<any>}
*/
async function cf(path, init) {
const { ok, status, result, error } = await cfRaw(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result;
}
return {
cfRaw,
/**
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
*
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
* 把查不到當成不存在,就是 #97 的根因。
*
* @param {string} script
* @returns {Promise<ScriptBindings>}
*/
async getScriptBindings(script) {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await cfRaw(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
/** @type {RawWorkerBinding[]} */
const raw = res.result?.bindings ?? [];
return {
deployed: true,
bindings: normalizeLiveBindings(raw),
vars: normalizeLiveVars(raw),
};
},
/** @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');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
},
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
const result = await cf('/d1/database?per_page=100');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
},
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
},
/**
* 無條件新建一顆 KV namespace。
*
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
* (安裝器取的名字跟 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
* 要不要建一律先過 `planResources`。
*
* @param {string} title
* @returns {Promise<string>}
*/
async createKvNamespace(title) {
const result = await cf('/storage/kv/namespaces', {
method: 'POST',
body: JSON.stringify({ title }),
});
return result.id;
},
/**
* 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespaceArcrun#97)。
* @param {string} name
* @returns {Promise<string>}
*/
async createD1Database(name) {
const result = await cf('/d1/database', {
method: 'POST',
body: JSON.stringify({ name }),
});
return result.uuid;
},
/**
* 新建 KBDB embed 用的 Vectorize index**bge-m3 = 1024 維 / cosine**)。
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。
* 沒有 ensure 版本:「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
*
* @param {string} name
* @returns {Promise<string>}
*/
async createVectorizeIndex(name) {
const res = await cfRaw('/vectorize/v2/indexes', {
method: 'POST',
body: JSON.stringify({
name,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
});
if (res.ok) return name;
const detail = (res.error ?? '').toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
},
};
}
@@ -0,0 +1,100 @@
// @ts-check
/**
* installer-entry.mjs — 安裝器那條路的**唯一入口**。
*
* 安裝器(arcrun-rag `installer/oauth-prototype/worker.js`)不必、也不准自己判斷
* 「該建哪些資源」——它只要呼叫這一支,拿回「每個 binding 該用哪顆資源」。
*
* ```js
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
*
* const r = await resolveInstanceResources({
* accountId, apiToken,
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
* mode: isUpdate ? 'update' : 'init',
* });
* if (r.blocked) {
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
* return showAndStop(r.blockers);
* }
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
* ```
*
* 為什麼安裝器不需要副本:安裝器本來就會下載本 repo 的 archive 當部署來源
* (見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
* `shared/resource-rule/` 就在那份 archive 裡,直接 import 即可——
* **不必再編一次、不必貼一份、也就不會有第二種答案。**
*/
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
/**
* @typedef {object} ResolveOptions
* @property {string} accountId
* @property {string} apiToken
* @property {string[]} wranglerTomls 各 worker 的 wrangler.toml **內容**(不是路徑)。
* @property {'update' | 'init'} mode 這台照定義裝過了沒。
* @property {typeof globalThis.fetch} [fetch] 注入用(測試/宿主自帶 fetch)。
*/
/**
* @typedef {object} ResolveResult
* @property {boolean} blocked true = 什麼都沒建、什麼都不該部署。
* @property {string[]} blockers blocked 時的原因原文(要原樣轉給使用者)。
* @property {Record<string, string>} bindings `${kind}:${binding}` → 資源 idindex 名。
* @property {Record<string, 'adopted'|'created'>} origin 同上 key → 這顆是沿用還是新建。
* @property {Record<string, Record<string, string>>} liveVars script → 現有 plain_text var#106)。
*/
/**
* 決定這台實例每個 binding 該用哪顆資源;照規則沿用既有、只在確定沒人綁過時才新建。
*
* @param {ResolveOptions} options
* @returns {Promise<ResolveResult>}
*/
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
/** @type {import('./rule.mjs').BindingRequirement[]} */
const requirements = [];
for (const toml of wranglerTomls) {
const parsed = parseWranglerRequirements(toml);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
/** @param {string[]} blockers @returns {ResolveResult} */
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
if (requirements.length === 0) {
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
}
let plan;
try {
plan = await planResources(api, requirements, mode);
} catch (e) {
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
}
if (plan.blockers.length > 0) return stop(plan.blockers);
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
let resolved;
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
}
/** @type {Record<string, string>} */
const bindings = {};
/** @type {Record<string, 'adopted'|'created'>} */
const origin = {};
for (const [key, r] of resolved) {
bindings[key] = r.value;
origin[key] = r.origin;
}
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
}
+570
View File
@@ -0,0 +1,570 @@
// @ts-check
/**
* rule.mjs — 「這個實例該用哪些資源」的**唯一一份**規則。
*
* ─────────────────────────────────────────────────────────────────────────────
* 這份檔案為什麼在這裡(`shared/`),不在 `cli/`
* ─────────────────────────────────────────────────────────────────────────────
* leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
*
* `.claude/rules/07-thin-shell.md` 的判準口訣:
* 「這段邏輯換一個介面要不要重寫?」要重寫 → 它是能力,該在共用層。
*
* 「該沿用哪幾顆資源」換到安裝器就得重寫一次 ⇒ 它是**能力**,不是薄殼的事。
* 而它原本住在 `cli/src/lib/resource-resolver.ts` ⇒ 那本身就是違規,
* 後果也真的發生了:`acr` 那條有這條規則、安裝器那條沒有,於是安裝器照名字找、
* 找不到就建新的空的 ⇒ Arcrun#97「我按了更新,工作流和登入全不見了」。
*
* ── 為什麼不是 cypher-executor 的 API 端點(薄殼原則的標準答案)────────────
* **自舉**:這條規則要在「決定怎麼裝/怎麼更新」的當下就用得到,而那個當下
* cypher 可能還不存在(安裝器的工作正是把它生出來),或正要被覆蓋。
* 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**——
* 把它送去一顆平台託管的 worker 換一個答案,等於①讓「能不能安裝」綁在平台是否活著,
* ②把使用者的帳號拓撲交給第三方。兩件都不該為了形式上的漂亮而做。
*
* 薄殼原則要求的是「能力只實作一次」,不是「能力一定要是 HTTP」。
* 這條規則是**純函式**(唯一的 IO 由呼叫端注入 `ResourceApi`),
* 所以它用不著變成服務——一份零依賴的 ESM 就能讓每條路吃到同一份判斷。
*
* ── 怎麼讓兩條路吃到「同一份」而不是各留一份 ───────────────────────────────
* 本檔是**唯一被人手維護的實作**,零依賴、不吃任何 node 內建、Workers runtime 可直接跑。
* · `acr``cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**,
* 由 `scripts/sync-resource-rule.mjs` 產生(CLI 要能單獨 npm publish
* 套件目錄外的檔案打不進 tarball,故必須有這一份)。
* `npm run build` / `npm test` 都會跑 `--check`,內容一漂就紅。
* ——同 `cli/harness/`(產生物+世代閘)的既有慣例。
* · 安裝器 / 任何 Worker:安裝器本來就會下載本 repo 的 archive(部署來源,
* 見 `.claude/rules/05-deploy-convention.md`「WASM 來源」),
* 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可,**不需要再編一次、也不留副本**。
* 用法見同目錄 README.md。
*
* ─────────────────────────────────────────────────────────────────────────────
* 規則本身(leo 的兩句話)
* ─────────────────────────────────────────────────────────────────────────────
* 「如果你沒有裝,就是新的;如果你已經有,原來叫什麼名字就繼續用下去。」
*
* 判準是「**這顆 worker 現在綁著誰**」,不是「有沒有叫這個名字的資源」:
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
*
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
*
* 🔴 這份檔案沒有 import、也不准有。任何依賴都會讓某一條路吃不到它。
*/
/**
* 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。
* @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
*/
/**
* 從已部署 worker 上讀回來的一條綁定。`value`KV/D1 是資源 idVectorize 是 index 名。
* @typedef {object} LiveBinding
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
*/
/**
* @typedef {object} ScriptBindings
* @property {boolean} deployed
* false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。
* @property {LiveBinding[]} bindings
* @property {Record<string, string>} [vars]
* 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。
*
* 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize),
* plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)——
* 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。
* **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。
*/
/**
* 規則需要的 CF 能力(收窄成介面,方便離線測試餵假帳號,也讓安裝器用自己的 fetch 實作)。
* @typedef {object} ResourceApi
* @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
* @property {() => Promise<Map<string, string>>} listKvNamespaces title → id
* @property {() => Promise<Map<string, string>>} listD1Databases name → uuid
* @property {() => Promise<string[]>} listVectorizeIndexes
* @property {(title: string) => Promise<string>} createKvNamespace
* @property {(name: string) => Promise<string>} createD1Database
* @property {(name: string) => Promise<string>} createVectorizeIndex
*/
/**
* 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。
* @typedef {object} BindingRequirement
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
* @property {string} createName
*/
/**
* @typedef {object} PlannedAdopt
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的
*/
/**
* @typedef {object} PlannedCreate
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} createName
* @property {string[]} wantedBy
* @property {string[]} alsoBind 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。
*/
/**
* @typedef {object} ResourcePlan
* @property {PlannedAdopt[]} adopt
* @property {PlannedCreate[]} create
* @property {string[]} blockers 非空 = 整趟停手。applyResourcePlan 會拒絕執行。
* @property {Map<string, Record<string, string>>} liveVars
* 每顆**已部署** worker 現在掛著的 plain_text varscript → 名/值)。未部署的不在裡面。
*
* Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡——
* 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式
* (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。
*/
/**
* @typedef {object} ResolvedResource
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* @property {string} [from]
*/
/**
* @typedef {object} WranglerRequirements
* @property {string} script worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。
* @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
*/
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
/** @param {string[]} blockers */
constructor(blockers) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
/** @type {string[]} */
this.blockers = blockers;
}
}
/**
* @param {ResourceKind} kind
* @param {string} binding
* @returns {string}
*/
export function bindingKey(kind, binding) {
return `${kind}:${binding}`;
}
/** @type {Record<ResourceKind, string>} */
export const KIND_LABEL = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
/**
* @param {unknown} e
* @returns {string}
*/
function msg(e) {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
*
* @param {ResourceApi} api
* @param {readonly BindingRequirement[]} requirements
* @param {'update' | 'init'} mode
* 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
* @returns {Promise<ResourcePlan>}
*/
export async function planResources(api, requirements, mode) {
/** @type {string[]} */
const blockers = [];
/** @type {PlannedAdopt[]} */
const adopt = [];
/** @type {PlannedCreate[]} */
const create = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
/** @type {Map<string, LiveBinding[]>} */
const live = new Map();
/** @type {Map<string, Record<string, string>>} */
const liveVars = new Map();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) {
live.set(script, res.bindings);
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
liveVars.set(script, res.vars ?? {});
}
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
/** @type {Map<string, BindingRequirement[]>} */
const byKey = new Map();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
/** @type {Map<ResourceKind, Set<string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = 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;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
/** @type {Array<{value: string, script: string}>} */
const found = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
/** @type {Set<string>} */
let existing;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
}
/**
* 收斂「不同 binding 其實是同一顆資源」的情況。
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`),不是使用者那側的資源名——
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
*
* 沒有這一步會出兩種錯:
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
*
* @param {PlannedAdopt[]} adopt
* @param {PlannedCreate[]} create
* @param {Map<string, BindingRequirement[]>} byKey
* @returns {PlannedCreate[]}
*/
function shareSameResource(adopt, create, byKey) {
/** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
const declaredName = (kind, binding) =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
/** @type {PlannedCreate[]} */
const out = [];
/** @type {Map<string, PlannedCreate>} */
const groups = new Map();
for (const c of create) {
const groupKey = `${c.kind} ${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* 照 plan 動手:沿用的原樣帶出來,該建的才建。
* 有任何 blocker 直接丟 ResourcePlanBlocked**一顆都不建**。
*
* @param {ResourceApi} api
* @param {ResourcePlan} plan
* @returns {Promise<Map<string, ResolvedResource>>}
*/
export async function applyResourcePlan(api, plan) {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
/** @type {Map<string, ResolvedResource>} */
const out = new Map();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
/** @type {string[]} */
const madeSoFar = [];
for (const c of plan.create) {
/** @type {string} */
let value;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
/**
* wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。
* @type {Record<string, ResourceKind>}
*/
export const TABLE_KIND = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。
*
* 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作,
* 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求**
* kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。
*
* 也是「零依賴」的一部分:不引 TOML parser ⇒ 安裝器 import 這支不必多裝任何東西。
*
* @param {string} toml
* @returns {WranglerRequirements}
*/
export function parseWranglerRequirements(toml) {
let script = '';
let seenTable = false;
/** @type {WranglerRequirements['bindings']} */
const bindings = [];
/** @type {ResourceKind | null} */
let kind = null;
let binding = '';
let createName = '';
const flush = () => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
// ─────────────────────────────────────────────────────────────────────────────
// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看)
// ─────────────────────────────────────────────────────────────────────────────
/**
* CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
* (同一種資源在不同 API 版本欄位名不一,故全都收)。
*
* @typedef {object} RawWorkerBinding
* @property {string} [type]
* @property {string} [name]
* @property {string} [namespace_id]
* @property {string} [id]
* @property {string} [database_id]
* @property {string} [index_name]
* @property {string} [text] `plain_text` 綁定的值(#106secret_text 不會回值,本來就讀不到,也不該讀)。
*/
/**
* 把 CF 的 binding 陣列收斂成規則認得的三種資源。不認得的型別直接略過。
*
* 🔴 這支**刻意放在規則裡**,不留在各自的 CF client
* 「什麼才算『這顆 worker 綁著某顆資源』」是規則的一部分。
* 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來(例如一邊認 `namespace_id`、
* 另一邊只認 `id`,於是一邊看得到綁定、另一邊看不到 → 後者又去新建了)。
*
* @param {RawWorkerBinding[]} raw
* @returns {LiveBinding[]}
*/
export function normalizeLiveBindings(raw) {
/** @type {LiveBinding[]} */
const out = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
}
return out;
}
/**
* 抽出已部署 worker 上的 `plain_text` var#106)。
*
* 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被搬來搬去;
* wrangler deploy 不會動 secret,它們自己會留著)。
*
* @param {RawWorkerBinding[]} raw
* @returns {Record<string, string>}
*/
export function normalizeLiveVars(raw) {
/** @type {Record<string, string>} */
const out = {};
for (const b of raw) {
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
}
return out;
}
+4
View File
@@ -0,0 +1,4 @@
/** `node --import ./tests/register-ts-hooks.mjs --test ...` 的進入點:註冊 ts-hooks.mjs。 */
import { register } from 'node:module';
register('./ts-hooks.mjs', import.meta.url);
+3 -1
View File
@@ -493,7 +493,7 @@ test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 t
new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
) as typeof fetch;
const cf = new CfAccountClient('a', 't');
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] });
assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [], vars: {} });
globalThis.fetch = (async () =>
new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
@@ -526,6 +526,8 @@ test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize
{ kind: 'd1', binding: 'DB', value: 'db1' },
{ kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
]);
// #106plain_text 也要收下來(service 這種不認得的仍略過)。
assert.deepEqual(res.vars, { ENVIRONMENT: 'production' });
} finally {
globalThis.fetch = orig;
}
+111
View File
@@ -0,0 +1,111 @@
/**
*
*
* leo `grep` ****
*
*
* grep 西
* repo `shared/resource-rule/`
* `cli/src/lib/resource-rule/` sync
* 稿sync --check `npm test`
*
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
const SOURCE_DIR = join(REPO, 'shared/resource-rule');
const MIRROR_DIR = join(REPO, 'cli/src/lib/resource-rule');
/** 規則的實作特徵:這些**宣告**只准出現在原稿目錄(與它的鏡射)裡。 */
const RULE_DECLARATIONS = [
'function planResources',
'function applyResourcePlan',
'function shareSameResource',
'function parseWranglerRequirements',
'function normalizeLiveBindings',
'function normalizeLiveVars',
'function createCloudflareResourceApi',
];
const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', '.wrangler', '.worker-builds', '.component-builds',
'.github-public', 'coverage',
]);
/** 只掃「人會寫程式的地方」;產生物與二進位不掃。 */
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const abs = join(dir, name);
const st = statSync(abs);
if (st.isDirectory()) walk(abs, out);
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(name)) out.push(abs);
}
return out;
}
const sha256 = (b: Buffer): string => createHash('sha256').update(b).digest('hex');
test('① 規則的實作全 repo 只有一份(原稿目錄 + 它的鏡射,沒有第三處)', () => {
const files = walk(REPO);
const offenders: string[] = [];
for (const abs of files) {
const rel = relative(REPO, abs);
// 原稿與鏡射本來就該有;測試檔在講規則、不是實作規則
if (rel.startsWith('shared/resource-rule/')) continue;
if (rel.startsWith('cli/src/lib/resource-rule/')) continue;
if (rel.startsWith('cli/tests/')) continue;
if (rel === 'scripts/sync-resource-rule.mjs') continue;
const src = readFileSync(abs, 'utf8');
for (const decl of RULE_DECLARATIONS) {
if (src.includes(decl)) offenders.push(`${rel}${decl}`);
}
}
assert.deepEqual(offenders, [],
'「決定用哪些資源」的實作出現在共用層之外——這正是本票要消滅的東西:\n' +
offenders.map((o) => `${o}`).join('\n') +
'\n要改規則就改 shared/resource-rule/,呼叫端只准 import。');
console.log(`\n ① 掃過 ${files.length} 個原始碼檔,${RULE_DECLARATIONS.length} 支規則函式的實作` +
' 全部只出現在 shared/resource-rule/(+機械鏡射)');
});
test('② CLI 帶的那份與原稿逐位元組相同(漂移=第二份實作偷偷長出來)', () => {
const files = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort();
assert.ok(files.length > 0, 'shared/resource-rule/ 裡沒有任何 .mjs 原稿');
const mirrored = readdirSync(MIRROR_DIR).filter((f) => f.endsWith('.mjs')).sort();
assert.deepEqual(mirrored, files, '鏡射目錄的檔案清單與原稿不一致');
for (const f of files) {
const a = sha256(readFileSync(join(SOURCE_DIR, f)));
const b = sha256(readFileSync(join(MIRROR_DIR, f)));
assert.equal(b, a, `cli/src/lib/resource-rule/${f} 與原稿不一致——不要手改產生物,` +
'改 shared/resource-rule/ 後跑 node scripts/sync-resource-rule.mjs');
console.log(`${f.padEnd(24)} sha256 ${a.slice(0, 16)} 原稿 = 鏡射`);
}
});
test('③ 共用層零外部依賴(只准 import 同目錄的兄弟檔)', () => {
for (const f of readdirSync(SOURCE_DIR).filter((x) => x.endsWith('.mjs'))) {
const src = readFileSync(join(SOURCE_DIR, f), 'utf8');
const imports = [...src.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)].map((m) => m[1]);
for (const spec of imports) {
assert.ok(spec.startsWith('./'),
`shared/resource-rule/${f} import 了 "${spec}"——共用層一旦有外部依賴,` +
'就會有某條路(Workers runtime/安裝器)吃不到它。');
}
assert.doesNotMatch(src, /require\(|from\s+['"]node:/,
`shared/resource-rule/${f} 用到 node 專屬 API——Cloudflare Workers 上跑不起來。`);
console.log(`${f.padEnd(24)} import: ${imports.length ? imports.join(', ') : '(無)'}`);
}
});
+21
View File
@@ -0,0 +1,21 @@
/**
* 測試用 resolve hook `./x.js` 這種 import 指回同名的 `./x.ts`Arcrun#106 附帶修復
*
* 為什麼需要`src/` 內部的 import 一律寫成 `.js`NodeNext 慣例編譯後才會有那個檔
* 但測試是**直接載入 `src/**\/*.ts`**不經過 tsc`outDir: dist`所以 `src/` 底下永遠不會有 .js
* Node 的型別剝離不會自己把 `.js` 對回 `.ts` 三份測試在 node 22 **一支都跑不起來**
* `ERR_MODULE_NOT_FOUND: .../src/lib/cf-api.js`包含 #97 那份使用者的東西還在不在的迴歸守衛
* 跑不起來的守衛等於沒有守衛所以這裡補上
*
* 只在預設解析失敗時才動作且只換副檔名 對本來就解析得到的環境新版 node / 已編譯零影響
*/
export async function resolve(specifier, context, next) {
try {
return await next(specifier, context);
} catch (err) {
if (typeof specifier === 'string' && specifier.endsWith('.js')) {
return next(specifier.slice(0, -3) + '.ts', context);
}
throw err;
}
}
+159
View File
@@ -0,0 +1,159 @@
/**
*
*
* leo 2026-08-12 CLI
*
* `acr` Arcrun#97
* 使
*
* ****
* A. `acr` `CfAccountClient` + `resource-resolver`CLI import
* B. import `shared/resource-rule/`
* **resource id **
*
* `fetch` `ResourceApi` HTTP
* CF #97
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
// ── A:acr 那條(CLI 真正用的東西)
import { CfAccountClient } from '../src/lib/cf-api.ts';
import { planResources, applyResourcePlan, bindingKey } from '../src/lib/resource-resolver.ts';
import type { BindingRequirement } from '../src/lib/resource-resolver.ts';
// ── B:安裝器那條(只碰 shared/)
import { resolveInstanceResources } from '../../shared/resource-rule/installer-entry.mjs';
// ── 共用 fixture
import {
makeAccount,
requirements,
SCENARIOS,
WORKER_NEEDS,
type Scenario,
} from '../../shared/resource-rule/tests/fixture-account.mjs';
const ACCOUNT = 'acct-fixture';
const TOKEN = 'tok-fixture';
/** 把 fixture 的需求組成安裝器吃的 wrangler.toml 文字(它的入口是從 toml 讀需求的)。 */
function tomlsFor(): string[] {
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
for (const d of need.d1) {
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
}
return t;
});
}
/** A:跑 acr 那條。CfAccountClient 走 global fetch,所以這裡把它換成 fixture。 */
async function runAcrPath(scenario: Scenario, mode: 'update' | 'init') {
const account = makeAccount(scenario);
const realFetch = globalThis.fetch;
globalThis.fetch = account.fetch;
try {
const api = new CfAccountClient(ACCOUNT, TOKEN);
const plan = await planResources(api, requirements() as BindingRequirement[], mode);
if (plan.blockers.length > 0) {
return { blocked: true, blockers: plan.blockers, bindings: {} as Record<string, string>, account };
}
const resolved = await applyResourcePlan(api, plan);
const bindings: Record<string, string> = {};
for (const [k, r] of resolved) bindings[k] = r.value;
return { blocked: false, blockers: [] as string[], bindings, account };
} finally {
globalThis.fetch = realFetch;
}
}
/** B:跑安裝器那條。只用 shared/ 的入口,fetch 直接注入。 */
async function runInstallerPath(scenario: Scenario, mode: 'update' | 'init') {
const account = makeAccount(scenario);
const r = await resolveInstanceResources({
accountId: ACCOUNT,
apiToken: TOKEN,
wranglerTomls: tomlsFor(),
mode,
fetch: account.fetch,
});
return { blocked: r.blocked, blockers: r.blockers, bindings: r.bindings, account };
}
/** 把兩邊的決定印出來——PR 要貼的就是這張對照表。 */
function report(scenario: Scenario, a: Record<string, string>, b: Record<string, string>): void {
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
console.log(`\n ── ${scenario}${SCENARIOS[scenario].label}`);
console.log(` ${'binding'.padEnd(34)} ${'acr 選的'.padEnd(24)} 安裝器選的 一致?`);
for (const k of keys) {
const same = a[k] === b[k] ? '✓' : '✗';
console.log(` ${k.padEnd(34)} ${(a[k] ?? '—').padEnd(24)} ${(b[k] ?? '—').padEnd(18)} ${same}`);
}
}
// ─────────────────────────────────────────────────────────────────────────────
for (const scenario of ['fresh', 'installed', 'renamed'] as const) {
const mode = scenario === 'fresh' ? 'init' : 'update';
test(`兩條路一致 — ${scenario}${SCENARIOS[scenario].label}`, async () => {
const a = await runAcrPath(scenario, mode);
const b = await runInstallerPath(scenario, mode);
assert.equal(a.blocked, b.blocked, '一邊停手、一邊照做 = 最危險的分歧');
assert.deepEqual(a.blockers, b.blockers, '停手的理由也要一樣');
report(scenario, a.bindings, b.bindings);
assert.deepEqual(
a.bindings,
b.bindings,
`${scenario}:兩條路選出的 resource id 不同——這就是 Arcrun#97 的形狀`,
);
// 建立行為也要一致(一邊沿用、一邊新建 = 使用者的東西在其中一條路上會消失)
assert.deepEqual(a.account.created, b.account.created, '兩條路「建了什麼」必須一樣');
});
}
// ── 三種情境各自該有的行為(不只是「兩邊一樣」,還要「一樣地對」)─────────────
test('情境① 沒裝過 → 正常建新的(不能為了沿用而變成永遠不建)', async () => {
const { blocked, bindings, account } = await runInstallerPath('fresh', 'init');
assert.equal(blocked, false, '全新帳號要裝得起來');
assert.equal(account.created.kv.length, 9, `應新建 9 顆 KV,實際 ${account.created.kv.length}`);
assert.equal(account.created.d1.length, 1, `應新建 1 顆 D1,實際 ${account.created.d1.length}`);
// cypher 的 CREDENTIALS_DB 與 kbdb 的 DB 宣告同一個 database_name → 只該建一顆,兩邊共用
assert.equal(bindings['d1:CREDENTIALS_DB'], bindings['d1:DB'], '同一顆 D1 不該被建成兩顆');
console.log(`\n ① 新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length}` +
`D1 共用:CREDENTIALS_DB = DB = ${bindings['d1:DB']}`);
});
test('情境② 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在', async () => {
const { blocked, bindings, account } = await runInstallerPath('installed', 'update');
assert.equal(blocked, false);
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, '更新不該建出任何新資源');
// 使用者的東西掛在資源 id 上:綁定還指向原本那顆 = 東西還在
assert.equal(bindings['kv_namespace:WEBHOOKS'], account.kvIdFor('WEBHOOKS'));
assert.equal(bindings['kv_namespace:SESSIONS_KV'], account.kvIdFor('SESSIONS_KV'));
assert.equal(bindings['d1:DB'], account.d1Id);
console.log(`\n ② 沿用:WEBHOOKS → ${bindings['kv_namespace:WEBHOOKS']}` +
`(工作流 ${account.userData.workflows.length} 支還在)|` +
`SESSIONS_KV → ${bindings['kv_namespace:SESSIONS_KV']}(登入 session 還在)|` +
`DB → ${bindings['d1:DB']}(子庫 ${account.userData.libraries.length} 個還在)|新建 0 顆`);
});
test('情境③ 資源在但名字與預期完全不同 → 仍然沿用(#97 的病根,專門驗)', async () => {
const { blocked, bindings, account } = await runInstallerPath('renamed', 'update');
assert.equal(blocked, false);
assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] },
'名字對不上就新建 = 正是 #97:一次更新生出 9 顆空 KV,使用者的東西從畫面上消失');
for (const b of ['WEBHOOKS', 'SESSIONS_KV', 'RECIPES', 'USERS_KV']) {
assert.equal(bindings[bindingKey('kv_namespace', b)], account.kvIdFor(b),
`${b} 沒有沿用到原本那顆`);
}
console.log(`\n ③ 名字全不同(例:WEBHOOKS 那顆實際叫 "${SCENARIOS.renamed.titleFor('WEBHOOKS')}"` +
` → 仍沿用 ${bindings['kv_namespace:WEBHOOKS']},新建 0 顆`);
});
+243
View File
@@ -0,0 +1,243 @@
/**
* Arcrun#106 ****
*
* 2026-08-12 leo leo21cPortal
*
* `ARCRUN_BUNDLE_VERSION` plain_text var****
* CLI wrangler toml var
* #97 KV/D1/Vectorize 沿****
*
*
* · var PORTAL_MAIL_RELAY_BASE 使 **沿**
* · ARCRUN_BUNDLE_VERSION **沿**
* 沿 =
*
* wrangler.toml + render/inject fetch
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
renderWranglerToml,
preservedVars,
applyVars,
resolveBundleStamp,
CLI_MANAGED_VARS,
VERSION_STAMP_WORKER,
type DeployContext,
} from '../src/lib/deploy.ts';
import { planResources, type ResourceApi, type ScriptBindings } from '../src/lib/resource-resolver.ts';
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
const CTX: DeployContext = {
accountId: 'acc-user-123',
apiToken: 'token',
workerSubdomain: 'user-sub',
selfHosted: true,
kbdbEmbed: true,
};
/** 一台「安裝器裝出來、已經跑過的」實例上,cypher worker 現在掛著的 plain_text var。 */
const LIVE_VARS: Record<string, string> = {
ARCRUN_BUNDLE_VERSION: '1.4.29', // 安裝當時的舊標籤
PORTAL_MAIL_RELAY_BASE: 'https://mail.example.com', // 安裝器注入、repo toml 沒有 → 洗掉就寄不出信
CONSOLE_TENANT: 'someone-else', // repo toml 寫死 "leo",不能拿官方值蓋掉人家的
WORKER_SUBDOMAIN: 'user-sub', // CLI 自己算
CF_ACCOUNT_ID: 'acc-user-123', // CLI 自己算
MULTI_TENANT: 'false', // CLI 自己算
ENVIRONMENT: 'production', // 與 toml 同值 → 不必重寫
};
/** 從 render 過的 toml 讀 [vars] 區塊(只看未註解的行)。 */
function readVars(toml: string): Record<string, string> {
const out: Record<string, string> = {};
let inVars = false;
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (/^\[\[?[A-Za-z0-9_]+\]?\]$/.test(line)) { inVars = line === '[vars]'; continue; }
if (!inVars || line.startsWith('#')) continue;
const m = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (m) out[m[1]] = m[2];
}
return out;
}
// ═════════════════════════════════════════════════════════════════════════════
// ① 病灶本身:舊行為會把標籤洗掉
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ①:repo 的 cypher toml 本來就沒有 ARCRUN_BUNDLE_VERSION——不補就是洗掉(病灶重現)', () => {
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map());
assert.equal(
readVars(rendered).ARCRUN_BUNDLE_VERSION,
undefined,
'若這行開始有值,表示 toml 自己帶了版本標籤,本測試的前提要重寫',
);
});
// ═════════════════════════════════════════════════════════════════════════════
// ② 設定類 var:沿用實例上的事實
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ②:安裝器注入、repo toml 沒有的 var 會被沿用(不再被重部署洗掉)', () => {
const keep = preservedVars(LIVE_VARS, CYPHER_TOML);
assert.equal(keep.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
// repo toml 寫死的是官方值,使用者實例上的值才是事實
assert.equal(keep.CONSOLE_TENANT, 'someone-else');
// 與 toml 同值 → 不需要重寫進去(雜訊)
assert.equal(keep.ENVIRONMENT, undefined);
});
test('#106 ③:CLI 自己算的 var 一律不沿用(沿用等於拿舊值蓋掉這趟的正解)', () => {
const keep = preservedVars({ ...LIVE_VARS, WORKER_SUBDOMAIN: 'OLD-sub', CF_ACCOUNT_ID: 'OLD-acc' }, CYPHER_TOML);
for (const managed of CLI_MANAGED_VARS) {
assert.equal(keep[managed], undefined, `${managed} 不該被沿用`);
}
// 而且注入完的 toml 裡,這些值仍是這趟算出來的那個
const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map(), keep);
const vars = readVars(rendered);
assert.equal(vars.WORKER_SUBDOMAIN, 'user-sub');
assert.equal(vars.CF_ACCOUNT_ID, 'acc-user-123');
assert.equal(vars.MULTI_TENANT, 'false');
assert.equal(vars.KBDB_BASE_URL, 'https://arcrun-kbdb.user-sub.workers.dev');
});
// ═════════════════════════════════════════════════════════════════════════════
// ③ 版本標籤:重烙,不沿用
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ④:版本標籤取「發行頻道公告的 release」+ 實際 commit,不是沿用舊值', async () => {
const fakeFetch = (async () =>
new Response(JSON.stringify({ release: '1.4.41', pin: 'ba81439' }), { status: 200 })) as typeof fetch;
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
assert.equal(stamp.version, '1.4.41');
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); // ← 這就是本 issue
assert.equal(stamp.commit, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
assert.match(stamp.version, /^\d+\.\d+\.\d+$/, 'Portal 拿它跟 /api/latest 比 semver,必須是純 semver');
});
test('#106 ⑤:查不到發行版號時誠實標成 commit 版,**不**沿用舊值、也不掰一個 semver', async () => {
const fakeFetch = (async () => { throw new Error('offline'); }) as typeof fetch;
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+f87d0e9$/);
assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION);
assert.doesNotMatch(stamp.version, /^\d+\.\d+\.\d+$/, '掰一個 semver 會讓 Portal 假裝「已是最新版」');
});
test('#106 ⑥:發行頻道回了不是 semver 的東西 → 當成查不到(不把垃圾當版號烙上去)', async () => {
const fakeFetch = (async () =>
new Response(JSON.stringify({ release: 'latest' }), { status: 200 })) as typeof fetch;
const stamp = await resolveBundleStamp('main', 'abc1234def', fakeFetch);
assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+abc1234$/);
});
// ═════════════════════════════════════════════════════════════════════════════
// ④ 端到端(離線):一台已安裝的實例跑一次更新,Portal 讀得到的那個欄位長什麼樣
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ⑦:模擬更新——版本標籤變新、設定 var 一個不少、資源沿用不受影響', async () => {
const api: ResourceApi = {
async getScriptBindings(script: string): Promise<ScriptBindings> {
if (script !== VERSION_STAMP_WORKER) return { deployed: false, bindings: [], vars: {} };
return {
deployed: true,
bindings: [
{ kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv-webhooks' },
{ kind: 'kv_namespace', binding: 'CREDENTIALS_KV', value: 'kv-creds' },
{ kind: 'kv_namespace', binding: 'RECIPES', value: 'kv-recipes' },
{ kind: 'kv_namespace', binding: 'USERS_KV', value: 'kv-users' },
{ kind: 'kv_namespace', binding: 'SESSIONS_KV', value: 'kv-sessions' },
{ kind: 'kv_namespace', binding: 'ANALYTICS_KV', value: 'kv-analytics' },
{ kind: 'kv_namespace', binding: 'EXEC_CONTEXT', value: 'kv-exec' },
{ kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1-kbdb' },
],
vars: LIVE_VARS,
};
},
async listKvNamespaces() {
return new Map([
['a', 'kv-webhooks'], ['b', 'kv-creds'], ['c', 'kv-recipes'], ['d', 'kv-users'],
['e', 'kv-sessions'], ['f', 'kv-analytics'], ['g', 'kv-exec'],
]);
},
async listD1Databases() { return new Map([['arcrun-kbdb', 'd1-kbdb']]); },
async listVectorizeIndexes() { return []; },
async createKvNamespace() { throw new Error('這趟不該新建任何 KV'); },
async createD1Database() { throw new Error('這趟不該新建 D1'); },
async createVectorizeIndex() { throw new Error('這趟不該新建 Vectorize'); },
};
const preview = renderWranglerToml(CYPHER_TOML, CTX, new Map());
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
const parsed = parseWranglerRequirements(preview);
const plan = await planResources(
api,
parsed.bindings.map((b) => ({ ...b, worker: parsed.script })),
'update',
);
assert.deepEqual(plan.blockers, []);
// 讀綁定時順手把 var 帶回來——不另外打一次 API
assert.equal(plan.liveVars.get(VERSION_STAMP_WORKER)?.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
const fakeFetch = (async () =>
new Response(JSON.stringify({ release: '1.4.41' }), { status: 200 })) as typeof fetch;
const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch);
const extra = {
...preservedVars(plan.liveVars.get(parsed.script), CYPHER_TOML),
ARCRUN_BUNDLE_VERSION: stamp.version,
ARCRUN_BUNDLE_COMMIT: stamp.commit!,
};
const deployed = readVars(renderWranglerToml(CYPHER_TOML, CTX, new Map(), extra));
// ① Portal 設定頁讀的就是這個欄位——更新完必須有值,且是**這趟**的版本
assert.equal(deployed.ARCRUN_BUNDLE_VERSION, '1.4.41');
assert.equal(deployed.ARCRUN_BUNDLE_COMMIT, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b');
// ② 安裝器注入的設定沒有在更新中消失
assert.equal(deployed.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com');
assert.equal(deployed.CONSOLE_TENANT, 'someone-else');
// ③ CLI 自己算的仍然是這趟算出來的
assert.equal(deployed.WORKER_SUBDOMAIN, 'user-sub');
assert.equal(deployed.MULTI_TENANT, 'false');
});
// ═════════════════════════════════════════════════════════════════════════════
// ⑤ applyVars 的三種既有狀態 + 不弄壞別的區塊
// ═════════════════════════════════════════════════════════════════════════════
test('#106 ⑧:applyVars——改既有行/取消註解/插進 [vars]/連 [vars] 都沒有時新開一段', () => {
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'new' }), /^\[vars\]\nA = "new"\n$/);
assert.match(applyVars('[vars]\n# A = "old"\n', { A: 'new' }), /A = "new"/);
assert.match(applyVars('[vars]\nB = "b"\n', { A: 'a' }), /\[vars\]\nA = "a"\nB = "b"/);
const noVars = applyVars('name = "w"\n', { A: 'a' });
assert.match(noVars, /\[vars\]\nA = "a"/);
assert.match(noVars, /^name = "w"/);
});
test('#106 ⑨:var 值裡的引號/反斜線會被轉義(不會產生壞掉的 toml)', () => {
const out = applyVars('[vars]\n', { A: 'say "hi"\\path' });
assert.match(out, /A = "say \\"hi\\"\\\\path"/);
});
test('#106 ⑨b:值裡有 $& / $1 也照原樣寫出(replace 反向參照陷阱)', () => {
assert.match(applyVars('[vars]\nA = "old"\n', { A: 'x$&y$1z' }), /A = "x\$&y\$1z"/);
assert.match(applyVars('[vars]\n', { A: 'x$&y' }), /A = "x\$&y"/);
// 怪名字不寫進去(不拿它組正規式)
assert.equal(applyVars('[vars]\n', { 'BAD NAME': 'v' }), '[vars]\n');
});
test('#106 ⑩:注入 var 不影響資源綁定解析(預覽與實際寫入看到的是同一份需求)', async () => {
const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts');
const withoutVars = parseWranglerRequirements(renderWranglerToml(CYPHER_TOML, CTX, new Map()));
const withVars = parseWranglerRequirements(
renderWranglerToml(CYPHER_TOML, CTX, new Map(), { ARCRUN_BUNDLE_VERSION: '1.4.41', X: 'y' }),
);
assert.equal(withVars.script, withoutVars.script);
assert.deepEqual(withVars.bindings, withoutVars.bindings);
});
+5
View File
@@ -6,6 +6,11 @@
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
// resource-rule.mjs JS + JSDoc
// allowJs tsc dist npm MODULE_NOT_FOUND
// checkJs JSDoc any
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
+23 -5
View File
@@ -2039,6 +2039,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// 兩邊都是 semver(例 1.4.2),用數字逐段比,不用字串比('1.4.10' < '1.4.9' 會出錯)。
var INSTALLER_ORIGIN = 'https://install.arcrun.dev';
// Arcrun#106:版號後面可以帶 build metadata`1.4.41+d61`、`1.4.41+a1b2c3d`)——
// 那是 semver 規格裡「比大小時要忽略」的那一段。舊寫法拿整串去比對正規式,
// 一律落到「較舊版本」(youlin 實例就是這樣,明明有版號卻顯示不出來)。
// 這裡只取前面的 `x.y.z` 當比較用的核心,顯示仍顯示完整原字串。
function semverCore(v) {
var m = String(v || '').match(/^(\d+\.\d+\.\d+)/);
return m ? m[1] : '';
}
function cmpSemver(a, b) {
var x = String(a || '').split('.').map(Number);
var y = String(b || '').split('.').map(Number);
@@ -2055,9 +2064,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var btn = $('st-ver-update');
if (!line) return;
// #106:順便把 bundle_commit 帶回來(有注入才有)——版號是頻道編號,commit 才是「真的部了哪份碼」。
var mineCommit = '';
var mineP = fetch(window.ARCRUN_API_BASE + '/health', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { return (j && j.bundle_version) || ''; })
.then(function (j) {
mineCommit = (j && j.bundle_commit) || '';
return (j && j.bundle_version) || '';
})
.catch(function () { return ''; });
var latestP = fetch(INSTALLER_ORIGIN + '/api/latest')
.then(function (r) { return r.ok ? r.json() : null; })
@@ -2070,15 +2084,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
if (!mine) { line.textContent = '無法讀取目前版本(知識庫服務可能正在啟動)'; return; }
// 舊實例的 bundle_version 是舊格式(2026-07-31+8e83589),比不了 semver。
// 這種情況一律當成「落後」——因為新版才會寫 semver 進來。
var mineIsSemver = /^\d+\.\d+\.\d+$/.test(mine);
// #106`1.4.41+<commit>` 這種帶 build metadata 的**是** semver,取核心比即可。
var mineCore = semverCore(mine);
var mineIsSemver = !!mineCore;
// commit 是輔助資訊(有才顯示):版號說「哪一版」,commit 說「真的是哪份碼」。
var commitNote = mineCommit ? ' <span class="muted">commit ' + esc(String(mineCommit).slice(0, 7)) + '</span>' : '';
if (!latest) {
line.textContent = '目前版本 ' + mine + '(暫時查不到最新版,稍後再試)';
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong>(暫時查不到最新版,稍後再試)' + commitNote;
return;
}
var behind = !mineIsSemver || cmpSemver(mine, latest) < 0;
var behind = !mineIsSemver || cmpSemver(mineCore, latest) < 0;
if (!behind) {
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版';
line.innerHTML = '目前版本 <strong>' + esc(mine) + '</strong> 已是最新版' + commitNote;
dot.style.display = 'none';
btn.style.display = 'none';
return;
+2 -7
View File
@@ -273,13 +273,8 @@ function makeHttpRunner(url: string): ComponentRunner {
const text = await res.text();
return { success: false, status: res.status, error: text.slice(0, 200) };
}
// 只讀一次 body(同檔 readBodyOnce 的註解已寫明這個坑,這裡以前卻正好踩到):
// 舊寫法 `try { res.json() } catch { res.text() }` 在零件回非 JSON 時,
// res.json() 失敗當下 body 已被消費 → 第二次讀丟 "Body has already been used"
// 使用者看到的是這句跟真因(零件回了非 JSON)完全無關的訊息(Arcrun#92 同類)。
const text = await res.text();
try { return JSON.parse(text); }
catch { return { success: true, data: text }; }
try { return await res.json(); }
catch { return { success: true, data: await res.text() }; }
};
}
+8 -106
View File
@@ -27,21 +27,6 @@ export interface ArcrunHostEnv {
const WASI_ESUCCESS = 0;
const WASI_ENOSYS = 76;
// ── host function 回傳碼(u6u.*)─────────────────────────────────────────────
// 零件(main.go)用同一組數字判斷,改這裡要同步改 registry/components/*/main.go。
export const HOST_OK = 0;
/** host 端出錯(memory 不可用 / 例外)— 零件無從得知細節 */
export const HOST_ERROR = 1;
/** 查無此 key / refkv_get、secret_get 用) */
export const HOST_NOT_FOUND = 2;
/**
* Arcrun#92********
* data outBuf
* `outBuf[:outLen]` panic writeOut memory 1
* "HTTP request failed"使
*/
export const HOST_TOO_LARGE = 3;
// fd 常數
const FD_STDIN = 0;
const FD_STDOUT = 1;
@@ -90,51 +75,6 @@ export interface WasiHostFunctions {
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
}
/**
* Arcrun#92
* declaredCapacity === 0 host
* http_request 64KBclaude_api 1MB
*
*/
export function outFitsCapacity(declaredCapacity: number, dataLength: number): boolean {
return declaredCapacity === 0 || dataLength <= declaredCapacity;
}
/** 位元組數轉人看得懂的單位(訊息裡要出現真實數字,不能只說「太大」) */
export function formatBytes(n: number): string {
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
return `${n} bytes`;
}
/**
* Arcrun#92 error envelope
*
*
* 1. ****
* 2. ****使
* 3. ****
* code / actual_bytes / limit_bytes
*
* status 0 413 413 413
* fetch envelope 0 = HTTP
*/
export function oversizeResponseEnvelope(actualBytes: number, limitBytes: number) {
return {
error:
`回應太大,裝不下:對方回了 ${formatBytes(actualBytes)}` +
`超過這個零件單次能接收的 ${formatBytes(limitBytes)} 上限。` +
`這不是連線失敗,資料也沒有被截掉一半——是整包放不進零件。` +
`做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試;` +
`真的需要整包資料時,改成分頁多抓幾次、每次處理一批。`,
code: 'response_too_large',
actual_bytes: actualBytes,
limit_bytes: limitBytes,
status: 0,
body: '',
};
}
/**
* WASI shim
* @param stdinData - stdin UTF-8 JSON.stringify(input)
@@ -155,34 +95,14 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
}
// 寫入結果到 WASM 的 outPtr bufferhost function 共用)
// 回傳 HOST_OK / HOST_ERROR / HOST_TOO_LARGE
//
// 容量握手(Arcrun#92):零件在呼叫 host function 前,把自己 outBuf 的長度預先寫進
// *outLenPtrhost 在寫回前讀這個值當容量上限。塞不下就**不寫**(避免覆寫零件記憶體)
// 並回 HOST_TOO_LARGE,讓上層改寫一段講真話的訊息。
//
// 舊零件(沒做握手)讀到 0 = 「未宣告容量」→ 維持舊行為。這裡不能自作聰明假設 64KB:
// 各零件緩衝區大小不同(http_request 64KB、claude_api 1MB),統一硬套會把原本
// 跑得好好的大回應誤判成太大。
// 回傳 0 = 成功,1 = memory 不可用
function writeOut(buf: ArrayBuffer, outPtr: number, outLenPtr: number, data: Uint8Array): number {
try {
const view = new DataView(buf);
const declaredCapacity = view.getUint32(outLenPtr, true);
if (!outFitsCapacity(declaredCapacity, data.length)) return HOST_TOO_LARGE;
new Uint8Array(buf, outPtr, data.length).set(data);
view.setUint32(outLenPtr, data.length, true);
return HOST_OK;
} catch {
return HOST_ERROR;
}
}
/** 讀零件宣告的緩衝區容量(0 = 舊零件沒宣告) */
function declaredCapacityOf(buf: ArrayBuffer, outLenPtr: number): number {
try {
return new DataView(buf).getUint32(outLenPtr, true);
} catch {
new DataView(buf).setUint32(outLenPtr, data.length, true);
return 0;
} catch {
return 1;
}
}
@@ -432,28 +352,12 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
try {
const result = await hostFunctions!.http_request!(url, method, headers, body);
// await 後重新拿 memory.buffergrow 會產生新的 ArrayBuffer
const encoded = new TextEncoder().encode(result);
const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded);
if (status !== HOST_TOO_LARGE) return status;
// Arcrun#92:回應塞不進零件緩衝區。以前這裡會硬寫(覆寫零件記憶體)或回 1,
// 零件對外只講得出 "HTTP request failed"——訊息與真因脫節。
// 現在改寫一個講真話的 error envelope(零件既有的 parsed["error"] 判定鏈
// 會原樣帶到使用者面前,不必改零件也能講對原因)。
const capacity = declaredCapacityOf(memory.buffer, outLenPtr);
const envelope = new TextEncoder().encode(
JSON.stringify(oversizeResponseEnvelope(encoded.length, capacity)),
);
const envStatus = writeOut(memory.buffer, outPtr, outLenPtr, envelope);
// 連這段說明都塞不下(緩衝區極小)→ 回 3,由零件自己講「回應太大」
return envStatus === HOST_OK ? HOST_OK : HOST_TOO_LARGE;
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
} catch (e) {
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
// 取代只 return 1WASM 寫無資訊的 "HTTP request failed")。
// writeOut 失敗(memory 壞)才 fallback return 1。
// 訊息截到 200 字:這段本身若超過零件緩衝區會被判成 HOST_TOO_LARGE
// 零件就會把「連不上」說成「回應太大」——又一次訊息與真因脫節(Arcrun#92)。
const errDetail = (e instanceof Error ? e.message : String(e)).slice(0, 200);
const errDetail = e instanceof Error ? e.message : String(e);
const errEnv = new TextEncoder().encode(
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
);
@@ -462,8 +366,7 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
})
: () => 1,
// kv_get(keyPtr, keyLen, outPtr, outLenPtr)
// → 0 成功;1 錯誤;2 找不到 key;3 值太大塞不進零件緩衝區(Arcrun#92)
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
kv_get: hostFunctions?.kv_get
? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) { console.error('[kv_get] memory null'); return 1; }
@@ -484,8 +387,7 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
})
: () => 1,
// secret_get(refPtr, refLen, outPtr, outLenPtr)
// → 0 成功;1 錯誤;2 找不到 ref;3 值太大塞不進零件緩衝區(Arcrun#92)
// secret_get(refPtr, refLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 ref
// 與 kv_get 同款 pointer/memory-write 機制;差別只在 host 端實作來源(env[ref] 而非 KV.get)。
secret_get: hostFunctions?.secret_get
? hostWrap(async (refPtr: number, refLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
+8
View File
@@ -15,11 +15,19 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>();
// 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。
// 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。
// bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。
// Arcrun#106leo 08-12 實撞:更新完設定頁變成「無法讀取目前版本」):
// `bundle_version` 只在部署時被注入,而**只有安裝器會注入**——CLI 更新那條路重部署
// 等於把這個標籤洗掉(wrangler deploy 整份覆蓋,toml 沒寫的 var 直接消失)。
// 修在 CLI 那側(cli/src/lib/deploy.ts:既有 var 沿用 + 版本標籤每趟重烙)。
// 這裡只多吐一個 `bundle_commit`:版號是「發行頻道的編號」,commit 才是「真的部了哪份碼」——
// 兩個一起看才有辦法查「標籤有沒有跟成品漂掉」。沒注入就省略該欄(同 bundle_version 的既有行為)。
healthRouter.get('/health', (c) => {
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
return c.json({
ok: true,
...(bundleVersion ? { bundle_version: bundleVersion } : {}),
...(bundleCommit ? { bundle_commit: bundleCommit } : {}),
auth_store: authStoreStatus(c.env),
// arcrun-rag#38/#69/#252026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
+209
View File
@@ -653,6 +653,215 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
}),
);
// ═══════════════════════════════════════════════════════════════════════════
// 授權的 AIarcrun-mcp)走的資料面 — 與人類 portal 同一道閘、同一份權限
// ═══════════════════════════════════════════════════════════════════════════
//
// leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
// AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
// 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
//
// 之前的病:MCP 驗完帳密只留下一個布林值,身分當場丟掉(oauth/routes.ts 舊 `loginOk = res.ok`),
// 於是查詢時只好去找一把**服務內部金鑰**KBDB_INTERNAL_TOKEN)直打 KBDB——
// 那條路繞過了本檔上半部所有的庫過濾,等於「誰登入都看到同一格、而且是全部」。
//
// 修法=MCP 改帶**登入者的 portal session token** 打本段端點。所以本段的每一支:
// ① 一律 requirePortalUsersession → 回讀 user record → 停用即時生效),
// ② owner_id / library 由 server 注入,**呼叫端傳什麼都不看**(與上半部同一條紅線:
// 呼叫端自己帶租戶字串=繞過庫過濾),
// ③ 越權與不存在同回 404(不洩存在性)。
//
// 薄殼(rule 07):這裡沒有新能力——template/record/map 的真身都在 KBDB 基本盤,
// 本段只做「權限注入+轉發」,與上半部 search/entries 一模一樣的做法。
/**
* record entry ** `library` slot record **
*
* entry 'general' fallbackentry
* general record contact / workflow_metadata / triplet
* triplet library slot general fallback
* ["kb"] contact
* owner_id server /
*/
function recordLibrary(values: Record<string, unknown> | undefined): string | null {
const lib = values?.library;
return typeof lib === 'string' && lib.trim() ? lib.trim() : null;
}
/** record 可讀?租戶要對;有標 library 的還要在用戶庫集合內。 */
function canReadRecord(
rec: { values?: Record<string, unknown>; owner_id?: string | null },
tenant: string,
libraries: string[],
): boolean {
if ((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 同一個帳號看得更多。
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: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
}
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)) as { libraries?: { library?: string }[] } | null;
if (!body || !Array.isArray(body.libraries)) {
return c.json({ error: '藏書地圖讀取失敗:KBDB 回應不是預期的 libraries 清單' }, 502);
}
const allowed = body.libraries.filter(
(l) => typeof l?.library === 'string' && canReadLibrary(libraries, l.library),
);
return c.json({ success: true, libraries: allowed, count: allowed.length });
}),
);
// GET /portal/data/map/:library — 單庫詳圖。無權該庫 → 與不存在同回 404(不洩存在性)。
portalDataRouter.get('/portal/data/map/:library', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
const library = c.req.param('library');
if (!canReadLibrary(libraries, library)) return notFound(c);
const res = await kbdbFetch(
c.env,
`/map/${encodeURIComponent(library)}?owner_id=${encodeURIComponent(portalTenant(c.env))}`,
);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/templates — template 清單。
// template=虛擬表定義(schema),**全域共享不分租戶**kbdb-proxy 同一裁定,leo 2026-06-14):
// 它描述「資料長什麼形狀」,不含任何人的內容。內容的隔離在 records/entries 那層。
portalDataRouter.get('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const res = await kbdbFetch(c.env, '/templates');
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
return new Response(res.body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}),
);
// POST /portal/data/templates — 建 templatename + slots)。
// 鐵律:這是「虛擬表定義」,不是建真的資料表;KBDB 不提供建表/SQL。
// created_by 記租戶(溯源),template 本身全域可見可用。
portalDataRouter.post('/portal/data/templates', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const body = (await c.req.json().catch(() => null)) as
| { name?: unknown; slots?: unknown; description?: unknown }
| null;
if (!body || typeof body.name !== 'string' || !body.name.trim() || !Array.isArray(body.slots)) {
return c.json({ error: 'name 與 slots[] 必填' }, 400);
}
const res = await kbdbFetch(c.env, '/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: body.name,
slots: body.slots,
description: typeof body.description === 'string' ? body.description : undefined,
created_by: portalTenant(c.env),
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/records/by-template/:template — 某 template 底下的 record。
// server 注入 owner_id(呼叫端傳的一律忽略);有標 library 的再逐筆過濾。
portalDataRouter.get('/portal/data/records/by-template/:template', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
const tenant = portalTenant(c.env);
const res = await kbdbFetch(
c.env,
`/records/by-template/${encodeURIComponent(c.req.param('template'))}?owner_id=${encodeURIComponent(tenant)}`,
);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown>; owner_id?: string | null }[] }
| null;
if (!body || !Array.isArray(body.records)) {
return c.json({ error: 'record 讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
}
// KBDB 已按 owner_id 過濾;這裡再守一次庫(縱深防禦,且舊部署若回多了不會外洩)。
const records = body.records.filter((r) => canReadRecord(r, tenant, libraries));
return c.json({ success: true, records, count: records.length });
}),
);
// GET /portal/data/records/:recordId — 單筆 record。
// 逐筆驗歸屬(owner_id 必須是本實例租戶)+ 驗庫;兩者不符與不存在同回 404。
portalDataRouter.get('/portal/data/records/:recordId', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) return notFound(c);
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param('recordId'))}`);
if (res.status === 404) return notFound(c);
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status}` }, 502);
const body = (await res.json().catch(() => null)) as
| { record?: { values?: Record<string, unknown>; owner_id?: string | null } }
| null;
const record = body?.record;
if (!record) return notFound(c);
if (!canReadRecord(record, portalTenant(c.env), libraries)) return notFound(c);
return c.json({ success: true, record });
}),
);
// POST /portal/data/records — 依 template 填一筆 record。
// owner_id **一律由 server 定死成本實例租戶**(呼叫端傳的忽略)——寫入端若讓呼叫端挑歸屬,
// 等於開一扇「把資料寫進別人格子」的門。要寫進某個庫(values.library)必須有該庫權限。
portalDataRouter.post('/portal/data/records', (c) =>
run(c, async () => {
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const libraries = parseLibraries(auth.user.values.libraries);
if (libraries.length === 0) {
return c.json({ error: '此帳號尚未被授權任何知識庫,無法寫入' }, 403);
}
const body = (await c.req.json().catch(() => null)) as
| { template?: unknown; values?: unknown }
| null;
if (!body || typeof body.template !== 'string' || !body.template.trim() || !body.values || typeof body.values !== 'object') {
return c.json({ error: 'template 與 values 必填' }, 400);
}
const values = body.values as Record<string, unknown>;
const targetLib = recordLibrary(values);
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
// 寫入越庫是**明確拒絕**(403),不套讀取那條 404 不洩存在性的規則:
// 庫名是呼叫端自己指定的,這裡沒有「洩漏某庫存在」的問題,講清楚才可修正。
return c.json({ error: `無「${targetLib}」庫的權限,不能寫入該庫` }, 403);
}
const res = await kbdbFetch(c.env, '/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: body.template, values, owner_id: portalTenant(c.env) }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}),
);
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
//
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
+5
View File
@@ -677,6 +677,11 @@ portalRouter.post('/portal/login', (c) =>
display_name: rec.values.display_name ?? '',
role: rec.values.role ?? 'user',
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/*
});
}),
+9 -3
View File
@@ -75,6 +75,13 @@ export type Bindings = {
* dev undefined/health
*/
ARCRUN_BUNDLE_VERSION?: string;
/**
* Arcrun#106 commit40 sha
* `ARCRUN_BUNDLE_VERSION` ****semverPortal/daemon
* ****
* `acr init/update`cli/src/lib/deploy.ts var /health
*/
ARCRUN_BUNDLE_COMMIT?: string;
// Platform telemetry api_key(可選,wrangler secret
// 對應 SDD .agents/specs/llm-interface/ M1.2
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
@@ -103,9 +110,8 @@ export type Bindings = {
GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token
GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo
GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch
// 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。
// daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。
ARCRUN_BUNDLE_VERSION?: string;
// ARCRUN_BUNDLE_VERSION 原本在這裡重複宣告了一次——TS2300 重複識別字,
// #106 順手併回上面那一處,說明同源,行為零變化。)
// MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。
// 真相住在 mcp worker 的同名 envmcp/src/types.ts,預設 259200030 天);cypher 這份
// 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。
+29
View File
@@ -26,4 +26,33 @@ describe('GET /health — bundle_version 欄位', () => {
expect(data.ok).toBe(true);
expect(data.bundle_version).toBe('2026-07-28/6d06162');
});
// Arcrun#106CLI 更新那條路會多烙一個 commit(版號=發行頻道編號,commit=真的部了哪份碼)。
it('有 ARCRUN_BUNDLE_COMMIT 時一起回(acr update 注入情境)', async () => {
const fakeEnv = {
ARCRUN_BUNDLE_VERSION: '1.4.41',
ARCRUN_BUNDLE_COMMIT: 'f87d0e92f49690253e7c89c5badc82a08eb5d21b',
} as unknown as Bindings;
const res = await healthRouter.fetch(
new Request('http://localhost/health'),
fakeEnv,
{} as ExecutionContext,
);
const data = await res.json() as { bundle_version: string; bundle_commit: string };
expect(data.bundle_version).toBe('1.4.41');
expect(data.bundle_commit).toBe('f87d0e92f49690253e7c89c5badc82a08eb5d21b');
});
// 安裝器那條路沒有這個 var(回歸:不能因為多了新欄位就讓舊路徑多吐一個空字串出來)。
it('沒 ARCRUN_BUNDLE_COMMIT 就省略該欄(安裝器路徑不受影響)', async () => {
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '1.4.41' } as unknown as Bindings;
const res = await healthRouter.fetch(
new Request('http://localhost/health'),
fakeEnv,
{} as ExecutionContext,
);
const data = await res.json() as { bundle_version: string; bundle_commit?: string };
expect(data.bundle_version).toBe('1.4.41');
expect(data.bundle_commit).toBeUndefined();
});
});
@@ -1,79 +0,0 @@
/**
* Arcrun#92
*
* main.go host function http_request 64KB
* claude_api 1MB host
* panic writeOut memory 1
* "HTTP request failed"使URL
*
* ****
* 1.
* 2. + + +
*/
import { describe, it, expect } from 'vitest';
import {
outFitsCapacity,
formatBytes,
oversizeResponseEnvelope,
HOST_TOO_LARGE,
} from '../src/lib/wasi-shim';
describe('容量握手的判定規則', () => {
it('宣告 64KB、資料 200KB → 塞不下', () => {
expect(outFitsCapacity(65536, 200_000)).toBe(false);
});
it('剛好等於容量 → 塞得下(不可 off-by-one 誤殺)', () => {
expect(outFitsCapacity(65536, 65536)).toBe(true);
});
it('舊零件沒宣告容量(0)→ 一律視為塞得下,維持舊行為', () => {
// 這條是防「換一種說謊」:不能因為新規則就把 claude_api 那種 1MB 緩衝區的
// 大回應統統誤判成「太大」。沒宣告 = host 不知道上限 = 不准亂猜。
expect(outFitsCapacity(0, 900_000)).toBe(true);
});
it('HOST_TOO_LARGE 與零件端的 hostTooLarge 常數同值(registry/components/*/main.go', () => {
expect(HOST_TOO_LARGE).toBe(3);
});
});
describe('formatBytes', () => {
it('分別用 bytes / KB / MB', () => {
expect(formatBytes(512)).toBe('512 bytes');
expect(formatBytes(65536)).toBe('64 KB');
expect(formatBytes(3_355_443)).toBe('3.2 MB');
});
});
describe('「回應太大」的訊息本身', () => {
const env = oversizeResponseEnvelope(3_355_443, 65536);
it('講出實際大小與上限(不是只說「太大」)', () => {
expect(env.error).toContain('3.2 MB');
expect(env.error).toContain('64 KB');
expect(env.actual_bytes).toBe(3_355_443);
expect(env.limit_bytes).toBe(65536);
});
it('明講「不是連線失敗」,把使用者從錯誤方向拉回來', () => {
expect(env.error).toContain('不是連線失敗');
});
it('給得出下一步(分頁/篩選),不是叫人「稍後再試」', () => {
expect(env.error).toMatch(/分頁|篩選/);
expect(env.error).not.toMatch(/稍後再試|請重新操作/);
});
it('不准退回萬用句', () => {
expect(env.error).not.toMatch(/請求失敗|HTTP request failed|未知錯誤/);
});
it('帶機器可讀欄位,上層不必比對字串', () => {
expect(env.code).toBe('response_too_large');
});
it('status 不偽造上游狀態碼(對方沒回 413)', () => {
expect(env.status).toBe(0);
});
});
+208
View File
@@ -232,6 +232,214 @@ describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
});
});
// ═══════════════ 3b. 授權的 AI(arcrun-mcp)走的資料面 ═══════════════
//
// leo 2026-08-12:「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘:同一個 session、同一份庫權限、
// 同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。
describe('藏書地圖 /portal/data/mapMCP 走的那條)', () => {
it('只回這個帳號有權限的庫;全館其他庫不出現在回應裡', async () => {
await seedSession('tok-m1', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
.reply(200, {
success: true,
libraries: [
{ library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
{ library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
],
count: 2,
});
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { library: string }[]; count: number };
expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
expect(data.count).toBe(1);
});
it('["*"] 全庫 → 全部庫都回', async () => {
await seedSession('tok-m2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
.reply(200, {
success: true,
libraries: [
{ library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
{ library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
],
count: 2,
});
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
const data = (await res.json()) as { libraries: { library: string }[] };
expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
});
it('庫集合為空 → 誠實空結果+說明,不打 KBDB', async () => {
await seedSession('tok-m3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '[]' }));
const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
expect(res.status).toBe(200);
const data = (await res.json()) as { count: number; note?: string };
expect(data.count).toBe(0);
expect(data.note).toContain('尚未被授權');
});
it('單庫詳圖:無權該庫 → 404 同一句(不打 KBDB,不洩該庫存不存在)', async () => {
await seedSession('tok-m4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('單庫詳圖:有權該庫 → 200 轉發', async () => {
await seedSession('tok-m5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
.reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
expect(res.status).toBe(200);
});
it('未登入 → 401', async () => {
expect((await get('/portal/data/map')).status).toBe(401);
});
});
describe('結構化資料 /portal/data/records、/portal/data/templatesMCP 走的那條)', () => {
it('by-templateserver 注入 owner_idcaller 自帶的被靜默覆蓋(繞不過)', async () => {
await seedSession('tok-r1', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
let captured = '';
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/records/by-template/contact')) return false;
captured = p;
return true;
},
method: 'GET',
})
.reply(200, { success: true, records: [], count: 0 });
const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
Authorization: 'Bearer tok-r1',
});
expect(res.status).toBe(200);
expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
});
it('by-template:有標 library 的 record 越庫的被濾掉;沒標 library 的照回', async () => {
await seedSession('tok-r2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, {
success: true,
records: [
{ record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
{ record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
{ record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫=結構化資料列
],
count: 3,
});
const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
const data = (await res.json()) as { records: { record_id: string }[] };
expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
});
it('單筆:別的租戶的 record → 404 同一句(就算全庫權限也擋)', async () => {
await seedSession('tok-r3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_other', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('單筆:越庫的 record → 404 同一句;有權的 → 200', async () => {
await seedSession('tok-r4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_hr', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);
await seedSession('tok-r5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/records/r_fin', method: 'GET' })
.reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
});
it('寫入:owner_id 由 server 定死,呼叫端塞的不算', async () => {
await seedSession('tok-r6', 'rec_6');
mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
let body: Record<string, unknown> = {};
fetchMock
.get(KBDB)
.intercept({
path: '/records',
method: 'POST',
body: (b: string) => {
body = JSON.parse(b) as Record<string, unknown>;
return true;
},
})
.reply(200, { success: true, record: { record_id: 'r_new' } });
const res = await SELF.fetch('http://localhost/portal/data/records', {
method: 'POST',
headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
});
expect(res.status).toBe(200);
expect(body.owner_id).toBe(TENANT);
});
it('寫入越庫 → 403(明確拒絕,庫名是呼叫端自己指定的,沒有存在性可洩)', async () => {
await seedSession('tok-r7', 'rec_7');
mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
const res = await SELF.fetch('http://localhost/portal/data/records', {
method: 'POST',
headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
});
expect(res.status).toBe(403);
});
it('templates 全域共享(schema 非內容):登入即可列', async () => {
await seedSession('tok-t1', 'rec_t1');
mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
fetchMock
.get(KBDB)
.intercept({ path: '/templates', method: 'GET' })
.reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
expect(res.status).toBe(200);
expect(((await res.json()) as { count: number }).count).toBe(1);
});
it('未登入 → 401records / templates 都是)', async () => {
expect((await get('/portal/data/templates')).status).toBe(401);
expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
expect((await get('/portal/data/records/r1')).status).toBe(401);
});
});
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
describe('GET /portal/data/graph/neighbors/:nameD-4 粗閘)', () => {
@@ -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: 表退場施工步驟③讓舊表退場,非資料存取違規,理由見檔頭
+17 -7
View File
@@ -65,6 +65,13 @@ export interface RecordResult {
record_id: string;
template_id: string;
values: Record<string, string>;
/**
* record slot entries owner_idcreateRecord
* 2026-08-12 `GET /records/:id` ****
* id cypher portal /AI
* 404 null
*/
owner_id: string | null;
}
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
@@ -85,7 +92,7 @@ export async function createRecord(db: D1Database, input: CreateRecordInput): Pr
.bind(uid('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 };
}
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
@@ -147,17 +154,19 @@ export async function updateRecord(
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
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<{ slot: string; content: string; template_id: string }>();
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
const rows = res.results ?? [];
if (rows.length === 0) return null;
const values: Record<string, string> = {};
for (const r of rows) values[r.slot] = r.content;
return { record_id: recordId, template_id: rows[0].template_id, values };
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
}
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
@@ -192,19 +201,20 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i
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<{ record_id: string; slot: string; content: string; template_id: string }>();
.all<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
for (const r of evRes.results ?? []) {
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 is RecordResult => !!r);
+16 -3
View File
@@ -1,13 +1,25 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { Env } from "./types.js";
import { partnerAuthMiddleware } from "./middleware/partner-auth.js";
import { partnerAuthMiddleware, type AuthPath } from "./middleware/partner-auth.js";
import { handleMcpRequest } from "./mcp-handler.js";
import { resolveKnowledgeIdentity } from "./lib/portal-client.js";
import type { PortalIdentity } from "./oauth/store.js";
import { inspectorHtml } from "./pages/inspector.js";
import { kbdbFetch } from "./lib/kbdb-client.js";
import { registerOAuthRoutes } from "./oauth/routes.js";
const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>();
const _app = new Hono<{
Bindings: Env;
Variables: {
org_namespace: string;
partner_token: string;
// 登入者身分(以帳密走 OAuth 連進來時才有)+ 這條連線是哪種憑據。
// 知識面工具(kbdb_*)據此決定走 portal 資料面還是既有 KBDB 直連(見 lib/portal-client.ts)。
portal?: PortalIdentity;
auth_path: AuthPath;
};
}>();
// ── OAuth 2.1 server 路由(掛在 worker 根路徑,非 /mcp)──────────────────────────
// well-known / authorize / token / register 必須在 origin 根,claude.ai 遠端 connector 才發現得到。
@@ -261,7 +273,8 @@ app.options("/mcp", (c) => {
app.post("/", partnerAuthMiddleware, async (c) => {
const orgNamespace = c.get("org_namespace");
const partnerToken = c.get("partner_token");
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal"));
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity);
});
// 輸出根 app_app):與 basePath('/mcp') 的 app 共享同一份 router,故 OAuth 根路由與
+34 -6
View File
@@ -19,6 +19,7 @@
import type { Env } from "../types.js";
import { kbdbFetch } from "./kbdb-client.js";
import { portalFetch, type KnowledgeIdentity } from "./portal-client.js";
/** 全館視圖一行(kbdb GET /map 的 libraries[] 元素;top_entities 已是 top-3 名字)。 */
export interface LibraryMapRow {
@@ -86,25 +87,45 @@ const MAP_FETCH_TIMEOUT_MS = 1500;
const CACHE_TTL_OK_MS = 5 * 60 * 1000;
const CACHE_TTL_FAIL_MS = 60 * 1000;
let instructionsCache: { text: string | null; expiresAt: number } | null = null;
/**
* 2026-08-12
*
* entity
*
*
*/
const instructionsCache = new Map<string, { text: string | null; expiresAt: number }>();
/** 測試用:清掉 isolate 內快取(prod 不呼叫)。 */
export function __resetLibraryMapInstructionsCacheForTests(): void {
instructionsCache = null;
instructionsCache.clear();
}
/**
* MCP server instructions design §4 / §6session instructions
* push /HTTP // JSON nullcaller
*
* identity.kind === 'portal' cypher `/portal/data/map`
* KBDB `/map` tokenstale
*/
export async function buildLibraryMapInstructions(env: Env): Promise<string | null> {
export async function buildLibraryMapInstructions(
env: Env,
identity: KnowledgeIdentity,
): Promise<string | null> {
if (identity.kind === "stale") return null;
// 快取 keyportal 用 session(=這個人這次登入),service 用固定字串。
// session token 只當 Map 的 key 活在 isolate 記憶體內,不落地、不寫 log。
const cacheKey = identity.kind === "portal" ? `portal:${identity.portal.session}` : "service";
const now = Date.now();
if (instructionsCache && instructionsCache.expiresAt > now) return instructionsCache.text;
const hit = instructionsCache.get(cacheKey);
if (hit && hit.expiresAt > now) return hit.text;
let text: string | null = null;
try {
const res = await Promise.race([
kbdbFetch(env, "/map"),
identity.kind === "portal"
? portalFetch(env, identity.portal.session, "/portal/data/map")
: kbdbFetch(env, "/map"),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("library map fetch timeout")), MAP_FETCH_TIMEOUT_MS),
),
@@ -124,6 +145,13 @@ export async function buildLibraryMapInstructions(env: Env): Promise<string | nu
text = null;
}
instructionsCache = { text, expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS) };
instructionsCache.set(cacheKey, {
text,
expiresAt: now + (text ? CACHE_TTL_OK_MS : CACHE_TTL_FAIL_MS),
});
// isolate 內的快取,不做失效協議;但別讓不同帳號的格子無上限長大(isolate 可活很久)。
if (instructionsCache.size > 64) {
for (const [k, v] of instructionsCache) if (v.expiresAt <= now) instructionsCache.delete(k);
}
return text;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Portal client AI西
*
* leo 2026-08-12 Portal 西
* AI MCP AI西
* MCP ****
*
* **portal session token** cypher
* portal
* cypher `/portal/data/*` server
* rule 07 API
*
* CYPHER_EXECUTOR service binding binding
*/
import type { Env } from "../types.js";
import type { PortalIdentity } from "../oauth/store.js";
import { errorResponse } from "./cypher-client.js";
export interface PortalCallOpts {
method?: string;
body?: unknown;
query?: Record<string, string | number | undefined>;
}
/** 用登入者的 session 打 cypher 的 portal 資料面。 */
export async function portalFetch(
env: Env,
session: string,
path: string,
opts: PortalCallOpts = {},
): Promise<Response> {
if (!env.CYPHER_EXECUTOR) {
throw new Error("CYPHER_EXECUTOR service binding not configured");
}
const url = new URL(`https://cypher${path}`);
for (const [k, v] of Object.entries(opts.query ?? {})) {
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
}
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
method: opts.method ?? "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session}`,
},
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
}
/**
*
*
* leo
* - portal portal
* - service static token / partner key KBDB
* - stale OAuth token token ****
* 退
*/
export type KnowledgeIdentity =
| { kind: "portal"; portal: PortalIdentity }
| { kind: "service" }
| { kind: "stale" };
export function resolveKnowledgeIdentity(
authPath: "oauth" | "service",
portal: PortalIdentity | undefined,
): KnowledgeIdentity {
if (authPath !== "oauth") return { kind: "service" };
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
}
/** 舊 token(沒帶身分)時的統一回覆:講清楚怎麼修,不假裝查不到資料。 */
export function staleIdentityError() {
return errorResponse(
"identity_missing",
"這條 MCP 連線是舊版簽發的 token,裡面沒有登入者身分,因此查不到任何知識內容。" +
"重新連線一次(在 claude.ai 的 connector 設定裡重新授權、輸入你的 Portal 帳密)即可——" +
"不需要另外找任何 credential 或金鑰。",
[
"到 claude.ai → Settings → Connectors,把這個 connector 重新連線一次(會跳出輸入 Portal 帳密的頁面)",
"重連後 kbdb_* 全部工具都會用你這個帳號的權限查詢",
],
);
}
/**
* portal AI
* 401/403 ****
* AI 使
*/
export async function portalError(res: Response, what: string) {
const detail = await res.text().catch(() => "");
if (res.status === 401) {
return errorResponse(
"session_expired",
`${what}失敗:登入階段已過期(portal session 到期或已登出)。`,
[
"到 claude.ai → Settings → Connectors 重新連線這個 connector(重新輸入 Portal 帳密)",
"重連後權限與你在 portal 網頁上看到的一致",
],
detail,
);
}
if (res.status === 403) {
return errorResponse(
"forbidden",
`${what}失敗:這個帳號沒有這項權限(帳號可能已停用,或沒有被授權該知識庫)。`,
["請知識庫管理員在 portal 的帳號管理裡確認你的狀態與可用知識庫"],
detail,
);
}
return errorResponse(`portal_${res.status}`, `${what}失敗(HTTP ${res.status}`, ["稍後重試"], detail);
}
+8 -2
View File
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { registerAllTools } from "./tools/registry.js";
import { buildLibraryMapInstructions } from "./lib/library-map.js";
import type { KnowledgeIdentity } from "./lib/portal-client.js";
import { Env } from "./types.js";
export async function handleMcpRequest(
@@ -9,11 +10,16 @@ export async function handleMcpRequest(
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
): Promise<Response> {
// library-map SDD M4design §4/§6):連線時把全館藏書地圖嵌進 server instructions
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeoutisolate TTL 快取
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
const mapInstructions = await buildLibraryMapInstructions(env);
//
// 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把
// 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。
// 快取也因此改成 per-session key(見 lib/library-map.ts)。
const mapInstructions = await buildLibraryMapInstructions(env, identity);
// 2026-07-30leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
// 如果不會,要寫什麼在外面讓它一聽到就知道?」):
@@ -60,7 +66,7 @@ export async function handleMcpRequest(
{ instructions },
);
registerAllTools(server, env, orgNamespace, partnerToken);
registerAllTools(server, env, orgNamespace, partnerToken, identity);
await server.connect(transport);
return transport.handleRequest(request);
+28 -2
View File
@@ -1,9 +1,19 @@
import { Context, Next } from "hono";
import { Env } from "../types.js";
import { getAccessToken } from "../oauth/store.js";
import { getAccessToken, type PortalIdentity } from "../oauth/store.js";
import { constantTimeEqual } from "../oauth/crypto.js";
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
/**
* ****kbdb_*
* - "oauth" Portal portal session portal
* portal
* - "service"static token / partner key ****
* KBDB
* token
*/
export type AuthPath = "oauth" | "service";
/**
* MCP / GUI
*
@@ -19,7 +29,15 @@ import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.
* ALLOW_PLAINTEXT_NAMESPACE="true"
*/
export async function partnerAuthMiddleware(
c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>,
c: Context<{
Bindings: Env;
Variables: {
org_namespace: string;
partner_token: string;
portal?: PortalIdentity;
auth_path: AuthPath;
};
}>,
next: Next
) {
const origin = originOf(c.req.url);
@@ -50,6 +68,11 @@ export async function partnerAuthMiddleware(
}
c.set("org_namespace", at.namespace);
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
// 登入者的身分(2026-08-12):知識面工具(kbdb_*)帶著它打 cypher 的 portal 資料面,
// 權限與這個人在 portal 網頁上看到的完全一致。舊 token 沒有這欄 → undefined
// 知識面工具會要求重新連線(不偷偷退回服務金鑰那條老路)。
c.set("portal", at.portal);
c.set("auth_path", "oauth");
await next();
return;
}
@@ -60,6 +83,7 @@ export async function partnerAuthMiddleware(
const ns = c.env.MCP_OWNER_NAMESPACE || "leo";
c.set("org_namespace", ns);
c.set("partner_token", ns);
c.set("auth_path", "service");
await next();
return;
}
@@ -79,6 +103,7 @@ export async function partnerAuthMiddleware(
}
c.set("org_namespace", info.org_namespace);
c.set("partner_token", token);
c.set("auth_path", "service");
await next();
return;
}
@@ -89,6 +114,7 @@ export async function partnerAuthMiddleware(
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
c.set("org_namespace", token);
c.set("partner_token", token);
c.set("auth_path", "service");
await next();
return;
}
+56 -6
View File
@@ -14,6 +14,7 @@ import {
consumeAuthCode,
putAccessToken,
AUTH_CODE_TTL_SECONDS,
type PortalIdentity,
} from "./store.js";
import {
originOf,
@@ -34,10 +35,25 @@ const CORS_JSON = {
"Cache-Control": "no-store",
} as const;
function ownerNamespace(env: Env): string {
/**
* ****arcrun_* kbdb_*
* portal session store.ts PortalIdentity
*
* cypher workflow API opaque key
* X-Arcrun-API-Key portal session cypher
* portal session workflow
* arcrun_* ****
*
* "leo" ****
* namespace KBDB owner_id API key
*/
function workflowTenant(env: Env): string {
return env.MCP_OWNER_NAMESPACE || "leo";
}
/** portal session TTL 讀不到時的保守假設(秒):短的那邊贏,寧可早點要求重連。 */
const FALLBACK_PORTAL_SESSION_TTL = 604800; // 7 天(cypher portal.ts 的預設值)
function tokenTtl(env: Env): number {
const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL;
@@ -248,7 +264,13 @@ export function registerOAuthRoutes<
}
// 認證下沉到 cypher 的 /portal/login(唯一真相源;同樣吃它的節流與停用檢查)。
// 走 service bindingMCP 與 cypher 同帳號,屬 D28 允許的零件級組合)。
let loginOk = false;
//
// 🔴 2026-08-12leo:「用登入能做的 mcp 就應該能做,結果要你去打 MCP 時自己找
// credential 問題很大」):這裡**接住登入回來的身分**,不再只留 `res.ok`。
// 舊版把身分丟掉 ⇒ 查詢時無身分可帶 ⇒ 只好去撈服務內部金鑰(KBDB_INTERNAL_TOKEN
// 直打 KBDB ⇒ 繞過所有庫過濾、而且不管誰登入都看到同一格。根因就在這幾行。
let portal: PortalIdentity | null = null;
let portalTtl = FALLBACK_PORTAL_SESSION_TTL;
try {
const res = await c.env.CYPHER_EXECUTOR.fetch(
new Request("https://cypher/portal/login", {
@@ -257,11 +279,34 @@ export function registerOAuthRoutes<
body: JSON.stringify({ email, password }),
}),
);
loginOk = res.ok;
if (res.ok) {
const body = (await res.json().catch(() => null)) as {
session_token?: unknown;
display_name?: unknown;
role?: unknown;
libraries?: unknown;
session_expires_in?: unknown;
} | null;
const session = typeof body?.session_token === "string" ? body.session_token : "";
if (session) {
portal = {
session,
display_name: typeof body?.display_name === "string" ? body.display_name : "",
role: typeof body?.role === "string" ? body.role : "user",
libraries: Array.isArray(body?.libraries)
? body.libraries.filter((x): x is string => typeof x === "string")
: [],
};
const ttl = Number(body?.session_expires_in);
if (Number.isFinite(ttl) && ttl > 0) portalTtl = ttl;
}
}
} catch {
return c.html(consentPage(consent, "暫時無法驗證帳密,請稍後再試。"), 503);
}
if (!loginOk) {
if (!portal) {
// 帳密不對,或這台 cypher 舊到還不回 session_token。兩者都不可以發碼——
// 發了也是一張沒有身分的 token,查什麼都得再找一次 credential,正是要修的病。
return c.html(consentPage(consent, "帳號或密碼不正確,請重試。"), 401);
}
if (!c.env.OAUTH_KV) {
@@ -275,7 +320,9 @@ export function registerOAuthRoutes<
code_challenge_method: "S256",
scope: consent.scope,
resource: consent.resource,
namespace: ownerNamespace(c.env),
namespace: workflowTenant(c.env),
portal,
portal_session_expires_in: portalTtl,
});
const location = redirectWith(redirectUri, {
code,
@@ -318,7 +365,9 @@ export function registerOAuthRoutes<
return err("invalid_grant", "PKCE verification failed");
}
const ttl = tokenTtl(c.env);
// token 活不過它底下的 portal session:否則第 8 天會出現「MCP 還連著、卻什麼都查不到」
// ——使用者看到的是壞掉,實際是身分過期。兩者一起到期,重連就是重新輸入帳密,一次搞定。
const ttl = Math.min(tokenTtl(c.env), data.portal_session_expires_in || FALLBACK_PORTAL_SESSION_TTL);
const accessToken = randomToken(32);
await putAccessToken(
c.env.OAUTH_KV,
@@ -327,6 +376,7 @@ export function registerOAuthRoutes<
namespace: data.namespace,
client_id: data.client_id,
scope: data.scope,
portal: data.portal,
// RFC 8707aud 一律用「本 server canonical resource URI」(非 client 原樣值)。
// authorize 已只存 canonical,這裡再以當前 origin 重算一次確保與 partner-auth 嚴格比對一致。
aud: resourceUri(originOf(c.req.url)),
+31
View File
@@ -4,6 +4,28 @@
// KV key 一律用 SHA-256 hex(不把 raw code/token 當 key)→ 就算 KV list 也拿不到可用憑證。
import { sha256Hex } from "./crypto.js";
/**
* authorize token
*
* leo 2026-08-12 MCP
* ****
*
* `session` cypher `/portal/login` portal session token portal
* KV TTL
* access_token TTL routes.ts
* MCP session
*
* display_name / role / libraries ****arcrun_whoami
* cypher user record
* token
*/
export interface PortalIdentity {
session: string;
display_name: string;
role: string;
libraries: string[];
}
/** authorization code 綁定的資料(一次性;/token 驗證後即刪)。 */
export interface AuthCodeData {
client_id: string;
@@ -15,6 +37,10 @@ export interface AuthCodeData {
resource: string;
/** 換發後 token 綁定的資料分區(owner namespace)。 */
namespace: string;
/** 這張 code 是誰換的(帳密驗過的那個人)。 */
portal: PortalIdentity;
/** portal session 剩餘秒數(authorize 當下);access_token TTL 不得超過它。 */
portal_session_expires_in: number;
}
/** access token 綁定的資料。 */
@@ -26,6 +52,11 @@ export interface AccessTokenData {
aud: string;
/** 過期時間(epoch 秒),與 KV TTL 雙保險。 */
exp: number;
/**
* token ** token** undefined
* 退fail-closed
*/
portal?: PortalIdentity;
}
const CODE_PREFIX = "oauth:code:";
+57 -13
View File
@@ -5,31 +5,75 @@
* AI CLI acr whoamiMCP rule 07 §5
* AI MCP curl
*
* MCP orgNamespace+ cypher binding
* 2026-08-12 ****display_name / role /
* account_namespace 西
* /kbdb/*portal-data.ts
* arcrun_* API key server
*
* MCP
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { toolName } from "../brand.js";
import { Env } from "../types.js";
import type { KnowledgeIdentity } from "../lib/portal-client.js";
export function registerWhoami(server: McpServer, env: Env, orgNamespace: string) {
export function registerWhoami(
server: McpServer,
env: Env,
orgNamespace: string,
identity: KnowledgeIdentity,
) {
server.tool(
toolName("whoami"),
"回報這個 MCP 連線目前生效的身份:綁哪個帳號 / namespace、cypher 連向哪。" +
"部署 / 觸發 / 查 workflow 前先 call 此 tool 確認帳號,**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
"回報這個 MCP 連線目前生效的身份:以帳密連線時回「登入的是誰、能看哪些知識庫」;" +
"服務級 token 連線時回綁定的帳號 namespace。部署 / 觸發 / 查 workflow 前先 call 此 tool 確認身份," +
"**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
{},
async () => {
// 薄殼:MCP 透過 service bindingCYPHER_EXECUTOR)連 cypherbinding 本身決定連哪台;
// 身份來自啟動時解析的 orgNamespace(綁哪個帳號的資料分區)。這裡只如實回報,不做推斷。
const identity = {
account_namespace: orgNamespace || "(未設)",
const base = {
cypher: "service-binding:CYPHER_EXECUTOR",
kbdb: "service-binding:KBDB",
note:
"此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
};
return {
content: [{ type: "text" as const, text: JSON.stringify(identity, null, 2) }],
};
if (identity.kind === "portal") {
const { display_name, role, libraries } = identity.portal;
return json({
...base,
auth: "portal-login(這條連線是有人輸入 Portal 帳密授權的)",
logged_in_as: display_name || "(未設顯示名稱)",
role,
libraries: libraries.length ? libraries : ["(尚未被授權任何知識庫)"],
knowledge_scope:
libraries.includes("*")
? "全部知識庫(此帳號有全庫權限)"
: `僅限上列知識庫——kbdb_* 查得到的東西與這個帳號在 portal 網頁上看得到的完全一致`,
note:
"你是「主人授權的 AI」:主人查得到的你查得到,主人查不到的你也查不到。" +
"kbdb_* 不需要任何額外的 credential / 金鑰 / kbdb_base——已經登入過了,不會再問第二次。",
});
}
if (identity.kind === "stale") {
return json({
...base,
auth: "舊版 token(沒有登入者身分)",
knowledge_scope: "查不到任何知識內容",
note:
"這條連線是本次改版前簽發的 token。到 claude.ai → Settings → Connectors " +
"重新連線一次(輸入 Portal 帳密)即可恢復,不需要找任何 credential。",
});
}
return json({
...base,
auth: "service tokenstatic token / partner key,代表整個實例或租戶,不是某個人)",
account_namespace: orgNamespace || "(未設)",
note: "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
});
},
);
}
function json(obj: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(obj, null, 2) }] };
}
+148 -59
View File
@@ -1,23 +1,27 @@
/**
* KBDB MCP kbdb-base Phase 9.1HANDOFF §2
*
* rule 07 §5 APIMCP +
* kbdbFetchKBDB service binding HTTP APIkbdb/src/routes/*
* rule 07 §5 APIMCP +
*
* 2026-08-12
* leo Portal 西AI
* MCP AI西
* MCP
*
* MCP ****
* KBDB_INTERNAL_TOKEN KBDB
*
* identity.kind === 'portal' portal session cypher
* `/portal/data/*` server portal
* ****MCP
*
* static token / partner keyidentity.kind === 'service' KBDB
*
*
* KBDB leo 2026-06-14 DECISION-kbdb-v3-baseplane.md
* - ** / SQL tool**
* - AI templatename+slots+ recordslotcontent
* Supabase schema template/slot CREATE TABLE
* - 調 HTTP API D1 SQL
*
* API kbdb/src/routes
* POST /templates { name, slots[], description?, created_by? } { template }
* GET /templates { templates[], count }
* GET /templates/:idOrName { template }
* POST /records { template, values:{slot:content}, owner_id? } { record }
* GET /records/by-template/:t ?owner_id= { records[], count }
* GET /records/:recordId { record }
* GET /entries/search ?q=&owner_id= { entries[], count, mode:'keyword' }
* - AI templatename+slots+ recordslotcontent
* - 調 HTTP API D1 SQL
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -25,22 +29,32 @@ import { z } from "zod";
import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
import {
portalFetch,
portalError,
staleIdentityError,
type KnowledgeIdentity,
} from "../lib/portal-client.js";
/** 走 portal 資料面時,呼叫端傳的 owner_id 一律無效(server 用登入者的歸屬)——如實告訴 AI。 */
const OWNER_IGNORED_HINT =
"owner_id 在登入身分下不生效:查詢範圍由你的帳號權限決定(與你在 portal 網頁看到的一致)";
/** 註冊全部 KBDB 資料層工具(kbdb-base Phase 9.1)。不含建表/SQL tool(鐵律)。 */
export function registerAllKbdbDataTools(server: McpServer, env: Env) {
registerCreateTemplate(server, env);
registerListTemplates(server, env);
registerCreateRecord(server, env);
registerGetRecord(server, env);
registerQuery(server, env);
registerSearch(server, env);
export function registerAllKbdbDataTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
registerCreateTemplate(server, env, identity);
registerListTemplates(server, env, identity);
registerCreateRecord(server, env, identity);
registerGetRecord(server, env, identity);
registerQuery(server, env, identity);
registerSearch(server, env, identity);
}
/**
* kbdb_create_template template= /
* AI API template + slots
*/
export function registerCreateTemplate(server: McpServer, env: Env) {
export function registerCreateTemplate(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_create_template",
"建一個 KBDB template(萬用表裡的一種資料形狀,類 Supabase 的虛擬表)。KBDB 不能建真的資料表——" +
@@ -50,16 +64,24 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
name: z.string().min(1).describe("template 名稱(唯一識別,之後填 record 用這個名字),如 'contact' / 'note'"),
slots: z.array(z.string().min(1)).min(1).describe("欄位名清單,如 ['name','email','phone']"),
description: z.string().optional().describe("這個 template 用途的簡述(選填)"),
created_by: z.string().optional().describe("建立者標記(選填)"),
created_by: z.string().optional().describe("建立者標記(選填;登入身分下由 server 記錄,不吃此值"),
},
async ({ name, slots, description, created_by }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, "/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, slots, description, created_by }),
});
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, "/portal/data/templates", {
method: "POST",
body: { name, slots, description },
})
: await kbdbFetch(env, "/templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, slots, description, created_by }),
});
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, `建 template「${name}`);
return errorResponse("create_template_failed", `建 template 失敗`, ["檢查 name 是否重複", "確認 slots 是非空字串陣列"], await res.text().catch(() => ""));
}
const data = await res.json();
@@ -74,17 +96,28 @@ export function registerCreateTemplate(server: McpServer, env: Env) {
}
/** kbdb_list_templates — 列出所有已建的 template(看有哪些資料形狀可用)。 */
export function registerListTemplates(server: McpServer, env: Env) {
export function registerListTemplates(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_list_templates",
"列出 KBDB 裡所有 template(已定義的資料形狀)。要存資料前先看有沒有現成 template 可用,沒有再 kbdb_create_template。",
{},
async () => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, "/templates");
if (!res.ok) return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, "/portal/data/templates")
: await kbdbFetch(env, "/templates");
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, "列 template");
return errorResponse("list_templates_failed", `列 template 失敗`, ["稍後重試"], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data, ["每個 template 的 slots_json 是它的欄位清單", "填資料用 kbdb_create_record"]);
return successResponse(data, [
"每個 template 的 slots_json 是它的欄位清單",
"填資料用 kbdb_create_record",
"template 是全域共享的「資料形狀」定義(schema),不含任何人的內容——內容的權限在 record/entry 那層",
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
@@ -93,7 +126,7 @@ export function registerListTemplates(server: McpServer, env: Env) {
}
/** kbdb_create_record — 依某 template 填一筆 recordslot → 內容)。 */
export function registerCreateRecord(server: McpServer, env: Env) {
export function registerCreateRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_create_record",
"依某 template 填一筆 record(一列資料)。values 是 {slot名: 內容}slot 名要對得上 template 的 slots。" +
@@ -101,23 +134,34 @@ export function registerCreateRecord(server: McpServer, env: Env) {
{
template: z.string().min(1).describe("template 的 name 或 id"),
values: z.record(z.string()).describe("欄位內容 {slot名: 字串內容},如 {name:'Leo', email:'leo@x.com'}"),
owner_id: z.string().optional().describe("資料歸屬標記(選填,如專案 id / 用戶 id"),
owner_id: z.string().optional().describe("資料歸屬標記(選填;登入身分下一律由 server 定成你的歸屬,不吃此值"),
},
async ({ template, values, owner_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, values, owner_id }),
});
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, "/portal/data/records", {
method: "POST",
body: { template, values },
})
: await kbdbFetch(env, "/records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, values, owner_id }),
});
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, `填 recordtemplate「${template}」)`);
return errorResponse("create_record_failed", `填 record 失敗`, [
`確認 template「${template}」存在(kbdb_list_templates`,
"values 的 slot 名要對得上 template 的 slots",
], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data, [`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`]);
return successResponse(data, [
`已存入。用 kbdb_query(template='${template}') 列出此 template 的所有 record`,
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
@@ -126,7 +170,7 @@ export function registerCreateRecord(server: McpServer, env: Env) {
}
/** kbdb_get_record — 用 record_id 取單筆 record。 */
export function registerGetRecord(server: McpServer, env: Env) {
export function registerGetRecord(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_get_record",
"用 record_id 取一筆 record 的所有欄位內容。record_id 從 kbdb_create_record 回傳或 kbdb_query 列出取得。",
@@ -134,10 +178,23 @@ export function registerGetRecord(server: McpServer, env: Env) {
record_id: z.string().min(1).describe("record 的 idrec_xxx"),
},
async ({ record_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const res = await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
if (res.status === 404) return errorResponse("not_found", `record「${record_id}」不存在`, ["確認 record_id 正確", "用 kbdb_query 列出某 template 的 record 取 id"]);
if (!res.ok) return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
const res =
identity.kind === "portal"
? await portalFetch(env, identity.portal.session, `/portal/data/records/${encodeURIComponent(record_id)}`)
: await kbdbFetch(env, `/records/${encodeURIComponent(record_id)}`);
if (res.status === 404) {
// 登入身分下,「不是你的」與「不存在」刻意同回 404(不洩存在性,portal 同一條紅線)。
return errorResponse("not_found", `查無 record「${record_id}」(不存在,或不在你的權限範圍內)`, [
"確認 record_id 正確",
"用 kbdb_query 列出某 template 的 record 取 id",
]);
}
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, "取 record");
return errorResponse("get_record_failed", `取 record 失敗`, ["稍後重試"], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data);
} catch (e) {
@@ -148,21 +205,39 @@ export function registerGetRecord(server: McpServer, env: Env) {
}
/** kbdb_query — 列出某 template 底下的所有 record(結構化查詢)。 */
export function registerQuery(server: McpServer, env: Env) {
export function registerQuery(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_query",
"列出某 template 底下的所有 record(結構化查詢,按 template 取整批資料)。要按關鍵字找內容用 kbdb_search。",
{
template: z.string().min(1).describe("template 的 name 或 id"),
owner_id: z.string().optional().describe("只取某歸屬的 record(選填)"),
owner_id: z.string().optional().describe("只取某歸屬的 record(選填;登入身分下不生效,範圍由你的權限決定"),
},
async ({ template, owner_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const path = `/records/by-template/${encodeURIComponent(template)}` + (owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "");
const res = await kbdbFetch(env, path);
if (!res.ok) return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
const res =
identity.kind === "portal"
? await portalFetch(
env,
identity.portal.session,
`/portal/data/records/by-template/${encodeURIComponent(template)}`,
)
: await kbdbFetch(
env,
`/records/by-template/${encodeURIComponent(template)}` +
(owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : ""),
);
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, `查詢 template「${template}」的 record`);
return errorResponse("query_failed", `查詢 record 失敗`, [`確認 template「${template}」存在`], await res.text().catch(() => ""));
}
const data = await res.json();
return successResponse(data, ["用 kbdb_get_record(record_id) 取單筆全文", "按關鍵字找內容改用 kbdb_search"]);
return successResponse(data, [
"用 kbdb_get_record(record_id) 取單筆全文",
"按關鍵字找內容改用 kbdb_search",
...(identity.kind === "portal" ? [OWNER_IGNORED_HINT] : []),
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
@@ -175,26 +250,38 @@ export function registerQuery(server: McpServer, env: Env) {
* / KBDB MCP RAGissue #7 / D17
* mode=semantic vectorize base keyword + capability_hint CC
*/
export function registerSearch(server: McpServer, env: Env) {
export function registerSearch(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_search",
"搜尋 KBDB 內容。mode='keyword'(預設,D1 LIKE 關鍵字,基本盤永遠可用)或 'semantic'AI 向量語義搜尋," +
"需先開 embed 模組)。語義沒開時會自動降級關鍵字並告訴你怎麼開。要按 template 取整批結構化資料用 kbdb_query。",
{
q: z.string().min(1).describe("搜尋關鍵字 / 語義查詢句"),
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填)"),
owner_id: z.string().optional().describe("限定某歸屬範圍內搜(選填;登入身分下不生效,範圍由你的權限決定"),
source: z.string().optional().describe("只搜某來源(ingest source.uri,選填)"),
mode: z.enum(["keyword", "semantic"]).optional().describe("keyword(預設)或 semantic(需開 vectorize"),
},
async ({ q, owner_id, source, mode }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const qs = new URLSearchParams({ q });
if (owner_id) qs.set("owner_id", owner_id);
if (source) qs.set("source", source);
if (mode) qs.set("mode", mode);
const res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
if (!res.ok) return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
const data = (await res.json()) as { mode?: string; capability_hint?: string };
let res: Response;
if (identity.kind === "portal") {
// /portal/data/search 只吃在權限範圍內「再收窄」的 filterowner_id/library 由 server 定死。
res = await portalFetch(env, identity.portal.session, "/portal/data/search", {
query: { q, mode },
});
} else {
const qs = new URLSearchParams({ q });
if (owner_id) qs.set("owner_id", owner_id);
if (source) qs.set("source", source);
if (mode) qs.set("mode", mode);
res = await kbdbFetch(env, `/entries/search?${qs.toString()}`);
}
if (!res.ok) {
if (identity.kind === "portal") return portalError(res, "搜尋");
return errorResponse("search_failed", `搜尋失敗`, ["稍後重試"], await res.text().catch(() => ""));
}
const data = (await res.json()) as { mode?: string; capability_hint?: string; note?: string };
// base 回 capability_hint → 語義沒開、已降級 keyword。把它當 next-step 傳給 AI(發現閉環)。
const hints =
data.capability_hint
@@ -202,6 +289,8 @@ export function registerSearch(server: McpServer, env: Env) {
: data.mode === "semantic"
? ["mode:semantic = AI 向量語義搜尋"]
: ["mode:keyword = D1 LIKE(基本盤)", "想要語義搜尋:mode='semantic'(需先開 vectorize"];
if (identity.kind === "portal") hints.push(OWNER_IGNORED_HINT);
if (data.note) hints.push(data.note);
return successResponse(data, hints);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
+60 -7
View File
@@ -23,6 +23,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Env } from "../types.js";
import { cypherFetch, errorResponse, successResponse } from "../lib/cypher-client.js";
import {
portalFetch,
portalError,
staleIdentityError,
type KnowledgeIdentity,
} from "../lib/portal-client.js";
/** graph 查詢 workflow 名(與 registry/examples/graph-neighbors/workflow.yaml 的 name 一致)。 */
export const GRAPH_NEIGHBORS_WORKFLOW = "graph_neighbors";
@@ -35,8 +41,13 @@ const INSTALL_HINTS = [
];
/** 註冊全部 KBDB graph 查詢工具(issue #68)。 */
export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamespace: string) {
registerGraphNeighbors(server, env, orgNamespace);
export function registerAllKbdbGraphTools(
server: McpServer,
env: Env,
orgNamespace: string,
identity: KnowledgeIdentity,
) {
registerGraphNeighbors(server, env, orgNamespace, identity);
// graph_traverserepo 內目前只有 graph-neighbors 有 workflow 定義(registry/examples/),
// traverse 尚無可對齊的 input 形狀 → 不猜、不過度工程;等 workflow 進 registry 再加薄殼。
}
@@ -45,7 +56,12 @@ export function registerAllKbdbGraphTools(server: McpServer, env: Env, orgNamesp
* kbdb_graph_neighbors knowledge graph 1-hop/N-hop
* 調 GET /q/{ns}/graph_neighbors MCP client
*/
export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace: string) {
export function registerGraphNeighbors(
server: McpServer,
env: Env,
orgNamespace: string,
identity: KnowledgeIdentity,
) {
server.tool(
"kbdb_graph_neighbors",
"knowledge graph 鄰居查詢(1-hop/N-hop 關係遍歷):給一個節點名,沿 KBDB triplet" +
@@ -60,10 +76,10 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
depth: z.number().int().min(1).max(10).optional().describe(
"最大跳數(N-hop),預設 1(只看直接鄰居)",
),
kbdb_base: z.string().min(1).describe(
"你自己部署的 KBDB 對外 base URL(如 https://arcrun-kbdb.<你的subdomain>.workers.dev " +
"或 KBDB custom domain)。workflow 刻意不寫死任何一家的庫——" +
"帶錯(或照抄別人的值)=查詢打進別人的庫",
kbdb_base: z.string().min(1).optional().describe(
"【登入身分下不需要,留空即可】你自己部署的 KBDB 對外 base URL" +
"以帳密連線的 MCP 由 server 端自己知道要查哪個庫——不必、也不該由你指定" +
"(指定了也不會採用)。只有服務級 tokenstatic token / partner key)連線時才需要填。",
),
template: z.string().optional().describe(
"triplet 記錄的 template 名,預設 'graph_triplet'(以實際部署的 kbdb-graph-plugin " +
@@ -74,6 +90,43 @@ export function registerGraphNeighbors(server: McpServer, env: Env, orgNamespace
),
},
async ({ subject, depth, kbdb_base, template, directed }) => {
if (identity.kind === "stale") return staleIdentityError();
// ── 登入身分:走 cypher 的 portal 資料面(與人類在 portal 按「關聯」同一支端點)──
// 那支已經有 D-4 graph 粗閘(沒有 graph 來源庫權限 → 403),也已經處理好
// 「這台實例沒裝 graph plugin 就改用 tenant 的 graph_neighbors workflow」的兩條路。
// ⇒ MCP 不必要 kbdb_base、不必知道租戶、不必再認證一次。
if (identity.kind === "portal") {
try {
const res = await portalFetch(
env,
identity.portal.session,
`/portal/data/graph/neighbors/${encodeURIComponent(subject)}`,
{ query: { depth: depth ?? 1 } },
);
if (!res.ok) return portalError(res, `查「${subject}」的鄰居`);
const out = (await res.json().catch(() => null)) as
| { neighbors?: unknown[]; edges?: unknown[]; count?: number }
| null;
return successResponse(out, [
`${out?.count ?? 0} 個鄰居(depth 上限 ${depth ?? 1}`,
"count=0 且不確定資料有沒有進圖:kbdb_query(template='triplet') 看三元組記錄",
"找關鍵字內容改用 kbdb_search;取單筆全文用 kbdb_get_record",
"查詢範圍=你這個帳號被授權的知識庫(與 portal 網頁上的關聯檢視一致)",
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
}
}
// ── 服務級憑據:既有路徑(打 /q/:ns/graph_neighbors workflow),行為零變更 ──
if (!kbdb_base) {
return errorResponse(
"kbdb_base_required",
"以服務級 token 連線時,graph 查詢需要 kbdb_base(你自己 KBDB 的對外 URL",
["改用帳密連線(OAuth)則不需要此參數", "或帶上 kbdb_base 再試一次"],
);
}
if (!orgNamespace) {
return errorResponse(
"no_namespace",
+36 -11
View File
@@ -22,6 +22,12 @@ import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
import { entityNames, parseSlotArray, type LibraryMapRow } from "../lib/library-map.js";
import {
portalFetch,
portalError,
staleIdentityError,
type KnowledgeIdentity,
} from "../lib/portal-client.js";
/**
* /404
@@ -39,8 +45,8 @@ const RECOMPUTE_HINTS = [
];
/** 註冊全部藏書地圖工具(library-map M4)。 */
export function registerAllKbdbMapTools(server: McpServer, env: Env) {
registerGetMap(server, env);
export function registerAllKbdbMapTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
registerGetMap(server, env, identity);
}
/** 單庫詳圖回傳形狀(GET /map/:library 的 mapslot 陣列已 parse 成物件)。 */
@@ -62,7 +68,7 @@ interface LibraryMapDetail {
* kbdb_get_map library
* design §6 retrieval get_map(library) graph/search
*/
export function registerGetMap(server: McpServer, env: Env) {
export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool(
"kbdb_get_map",
"藏書地圖:KBDB 全館導覽。不帶參數=全館地圖(每庫一行:庫名+narrative+核心 top 3 entities" +
@@ -73,15 +79,26 @@ export function registerGetMap(server: McpServer, env: Env) {
library: z.string().min(1).optional().describe(
"庫名(如 'kb''notes')。帶了回該庫詳圖;不帶回全館地圖(先看全館再挑庫)",
),
owner_id: z.string().optional().describe("限定某資料歸屬範圍(選填,與其他 kbdb_* 工具同義)"),
owner_id: z.string().optional().describe(
"限定某資料歸屬範圍(選填;登入身分下不生效,看得到哪些庫由你的帳號權限決定)",
),
},
async ({ library, owner_id }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const qs = owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
// 登入身分:走 cypher 的 portal 資料面 —— 只會回這個帳號有權限的庫
//KBDB 的 /map 對權限無知,會回全館;過濾在 cypher 那邊 server 側做)。
const isPortal = identity.kind === "portal";
const qs = !isPortal && owner_id ? `?owner_id=${encodeURIComponent(owner_id)}` : "";
const mapFetch = (path: string) =>
identity.kind === "portal"
? portalFetch(env, identity.portal.session, `/portal/data${path}`)
: kbdbFetch(env, path);
if (!library) {
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count)。
const res = await kbdbFetch(env, `/map${qs}`);
const res = await mapFetch(`/map${qs}`);
if (!res.ok && isPortal) return portalError(res, "取全館地圖");
if (!res.ok) {
return errorResponse(
"map_fetch_failed",
@@ -90,7 +107,7 @@ export function registerGetMap(server: McpServer, env: Env) {
await res.text().catch(() => ""),
);
}
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number };
const data = (await res.json()) as { libraries?: LibraryMapRow[]; count?: number; note?: string };
const libraries = (Array.isArray(data.libraries) ? data.libraries : []).map((l) => ({
...l,
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
@@ -101,8 +118,13 @@ export function registerGetMap(server: McpServer, env: Env) {
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
// 註解),所以「地圖是空的」現在真的等於「這個租戶目前沒有任何三元組資料」,
// 不再是「沒人跑過 recompute」那種曖昧狀態。
// 登入身分下還有第二種可能:這個帳號一個庫都沒被授權——「沒權限看」與「沒有資料」
// 不可以長得一樣,所以分開講(cypher 端會附 note 說明)。
return successResponse({ libraries: [], count: 0 }, [
"全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
isPortal
? "看不到任何庫:可能是這個知識庫真的還沒有三元組資料,也可能是你的帳號還沒被授權任何庫——請向管理員確認你的可用知識庫"
: "全館地圖是空的:這個租戶目前沒有任何三元組資料(不是地圖沒算,是真的還沒有資料)",
...(data.note ? [data.note] : []),
...RECOMPUTE_HINTS,
]);
}
@@ -113,7 +135,7 @@ export function registerGetMap(server: McpServer, env: Env) {
}
// 單庫詳圖:完整 slotsslot 陣列 parse 成物件再回)。
const res = await kbdbFetch(env, `/map/${encodeURIComponent(library)}${qs}`);
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
if (res.status === 404) {
// 地圖是讀時即時核對重算的:只要這個庫「已知」(有三元組、entries 蓋過章、或登記過),
// 上一步就會自動把它補成一筆 triplet_count:0 的地圖,走不到這個分支。真的落到 404,
@@ -121,10 +143,13 @@ export function registerGetMap(server: McpServer, env: Env) {
// (可能打錯字,或這個庫在別的租戶/別的 owner_id 底下)。
return errorResponse(
"map_not_found",
`查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名)", ...RECOMPUTE_HINTS],
isPortal
? `查無庫「${library}」——這個名字不存在,或不在你被授權的知識庫範圍內(兩者刻意同一句話,不洩漏某個庫存不存在)`
: `查無庫「${library}」——這個名字在這個租戶的資料裡從沒出現過(不是「這庫是空的」,是根本沒有這個庫;地圖是即時核對重算的,不是忘了 recompute)`,
["kbdb_get_map 不帶參數看全館有哪些庫(確認庫名/確認你有權限的庫)", ...RECOMPUTE_HINTS],
);
}
if (!res.ok && isPortal) return portalError(res, `取庫「${library}」詳圖`);
if (!res.ok) {
return errorResponse(
"map_fetch_failed",
+14 -5
View File
@@ -20,8 +20,15 @@ import { registerAllKbdbDataTools } from "./kbdb_data.js";
import { registerAllKbdbGraphTools } from "./kbdb_graph.js";
import { registerAllKbdbMapTools } from "./kbdb_map.js";
import { registerWhoami } from "./arcrun_whoami.js";
import type { KnowledgeIdentity } from "../lib/portal-client.js";
export function registerAllTools(server: McpServer, env: Env, orgNamespace: string, partnerToken: string) {
export function registerAllTools(
server: McpServer,
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
) {
registerSearchComponents(server, env, orgNamespace);
// 🔴 2026-07-21 leo 拍板停用:零件走 PR、專業等級;recipe/workflow/app 誰都可以做。
// 零件貢獻**只有一條路=PR 人審**leo 2026-08-01:「已經沒有 publish 了,
@@ -53,13 +60,15 @@ export function registerAllTools(server: McpServer, env: Env, orgNamespace: stri
registerAllRecipeTools(server, env);
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/searchHANDOFF §2
// 鐵律:不提供建表/SQL toolAI 只有 template+slot 可用(類 Supabase 萬用表)
registerAllKbdbDataTools(server, env);
// 2026-08-12:知識面(kbdb_*)全部改吃 identity——以帳密連線者走 portal 資料面
// (權限=那個人的權限),服務級憑據維持既有 KBDB 直連。見 lib/portal-client.ts。
registerAllKbdbDataTools(server, env, identity);
// issue #68: KBDB graph 查詢薄殼(kbdb_graph_neighbors,調 /q/:ns/graph_neighbors 同步查詢端點)
// 補齊 D17「KBDB MCP=RAG 套餐」第三模式:關鍵字/語義之外的圖(關係遍歷)
registerAllKbdbGraphTools(server, env, orgNamespace);
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
// library-map SDD M4Arcrun#39: 藏書地圖薄殼(kbdb_get_map,調 kbdb GET /map/map/:library
// retrieval 第一站:先看地圖定位庫,再 search/graph 進庫(design §6
registerAllKbdbMapTools(server, env);
registerAllKbdbMapTools(server, env, identity);
// §7.8 P1 D2: whoami(與 CLI acr whoami 對齊,AI 不繞 CLI 自己 curl 猜帳號)
registerWhoami(server, env, orgNamespace);
registerWhoami(server, env, orgNamespace, identity);
}
+18 -4
View File
@@ -2,6 +2,15 @@ export interface Env {
COMPONENT_REGISTRY: Fetcher;
CYPHER_EXECUTOR: Fetcher;
KBDB: Fetcher;
/**
* KBDB
*
* 2026-08-12 **kbdb_*** cypher
* `/portal/data/*` portal session
* SaaS partner-key middleware/partner-auth.ts 3
* tokenstatic token KBDB
* binding MCP
*/
KBDB_INTERNAL_TOKEN: string;
API_KEY?: string;
// Platform telemetry / feedback aggregation key (optional)
@@ -20,11 +29,16 @@ export interface Env {
// 短效認證儲存:authorization codeTTL ~600s+ access tokenTTL = MCP_TOKEN_TTL)。
// 只放「取得的暫時性認證」,key 用 SHA-256 hashKV list 不外洩可用 token)。長效機密不進 KV。
OAUTH_KV?: KVNamespace;
// Owner 祕密(CF Secret,非 KV、非明碼 var):/authorize 同意頁的把關密碼。
// 只有 owner 知道 → 「只知 URL + 明碼 namespace」的人走不完 OAuth,拿不到 token。
// 未設 → OAuth /authorize 回 503(拒絕在無把關下發碼,不留不安全預設)
// 【已停用,2026-07-30】舊的 owner 祕密。把關改成「使用者自己的 Portal 帳密」——
// 沒人給得了封測者這把祕密(安裝器產生後從不顯示、CF secret 又讀不回),
// 而且全實例共用一把、分不出是誰連上來的。程式已不再讀它;欄位留著只為不讓舊 toml 炸掉
MCP_OWNER_SECRET?: string;
// OAuth 換發出的 access_token 綁定的 namespaceowner 的資料分區)。預設 "leo"。
// **工作流面**(arcrun_* 工具)的租戶代號,當 cypher 的 X-Arcrun-API-Key 用。預設 "leo"。
//
// ⚠️ 2026-08-12 起**知識面(kbdb_*)不再讀這個欄位**:那邊改成跟著登入者的 portal session
// 走(oauth/store.ts PortalIdentity)。此欄位曾被當成 KBDB 的 owner_id ⇒ 不管誰登入
// 都看到同一格、而且是全部——那個用法已經消滅。
// 要連工作流面也拆掉它,得先在 cypher 開一組吃 portal session 的 workflow 端點(下一步)。
MCP_OWNER_NAMESPACE?: string;
// access_token 存活秒數(同時是 KV TTL)。字串(toml var)。預設 259200030 天)。
// 過期後 claude.ai 重走 OAuthowner 重輸祕密)——刻意不做 refresh token 以免長效機密落地。
+202 -12
View File
@@ -59,14 +59,55 @@ async function pkcePair() {
return { verifier, challenge };
}
/**
* cypher `/portal/login` 2026-08-12 MCP 使
* Portal owner secret session_token 401
*/
const GOOD_EMAIL = "leo@example.com";
const GOOD_PASSWORD = "correct horse";
function cypherMock(
over: {
/** null 登入成功但**不回** session_token(舊版 cypher);預設回 "sess-abc" */
sessionToken?: string | null;
displayName?: string;
role?: string;
libraries?: string[];
sessionExpiresIn?: number;
} = {},
): { fetcher: Fetcher; calls: Array<{ email: string; password: string }> } {
const calls: Array<{ email: string; password: string }> = [];
const fetcher = {
async fetch(req: Request) {
const body = (await req.json()) as { email: string; password: string };
calls.push(body);
if (body.email !== GOOD_EMAIL || body.password !== GOOD_PASSWORD) {
return new Response(JSON.stringify({ error: "email 或密碼錯誤" }), { status: 401 });
}
const sessionToken = over.sessionToken === undefined ? "sess-abc" : over.sessionToken;
return new Response(
JSON.stringify({
success: true,
...(sessionToken ? { session_token: sessionToken } : {}),
display_name: over.displayName ?? "Leo",
role: over.role ?? "admin",
libraries: over.libraries ?? ["*"],
session_expires_in: over.sessionExpiresIn ?? 604800,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
} as unknown as Fetcher;
return { fetcher, calls };
}
function baseEnv(over: Partial<Env> = {}): Env {
return {
COMPONENT_REGISTRY: {} as Fetcher,
CYPHER_EXECUTOR: {} as Fetcher,
CYPHER_EXECUTOR: cypherMock().fetcher,
KBDB: {} as Fetcher,
KBDB_INTERNAL_TOKEN: "internal",
OAUTH_KV: makeKV(),
MCP_OWNER_SECRET: "s3cr3t-owner",
MCP_OWNER_NAMESPACE: "leo",
...over,
} as Env;
@@ -121,6 +162,8 @@ describe("oauth/store", () => {
scope: "mcp",
resource: "https://mcp/mcp",
namespace: "leo",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
portal_session_expires_in: 604800,
});
const first = await consumeAuthCode(kv, "code-1");
expect(first?.namespace).toBe("leo");
@@ -271,7 +314,11 @@ describe("oauth flow (整合)", () => {
)}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz&scope=mcp`,
);
expect(ok.status).toBe(200);
expect(await ok.text()).toContain("Owner 祕密");
const consentHtml = await ok.text();
// 同意頁問的是 Portal 帳密(不是另一把 owner secret
expect(consentHtml).toContain("Portal");
expect(consentHtml).toContain('name="email"');
expect(consentHtml).toContain('name="password"');
// 缺 PKCE → 400
const bad = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
@@ -281,18 +328,19 @@ describe("oauth flow (整合)", () => {
expect(bad.status).toBe(400);
});
it("GET /authorizeMCP_OWNER_SECRET 未設 → 503(不留不安全預設", async () => {
const app = buildApp(baseEnv({ MCP_OWNER_SECRET: undefined }));
it("GET /authorize不需要任何 owner 祕密就看得到同意頁(封測者接自己的 AI 不會死在這頁", async () => {
// 舊行為:未設 MCP_OWNER_SECRET → 503 ⇒ 每個封測者都卡住。現在把關是 Portal 帳密。
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
const r = await app.req(
`/authorize?response_type=code&client_id=c1&redirect_uri=${encodeURIComponent(
"https://claude.ai/cb",
)}&code_challenge=${challenge}&code_challenge_method=S256`,
);
expect(r.status).toBe(503);
expect(r.status).toBe(200);
});
it("完整 code→token:正確 owner 祕密 + 正確 verifier → access_token", async () => {
it("完整 code→token:正確 Portal 帳密 + 正確 verifier → access_token", async () => {
const env = baseEnv();
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
@@ -310,7 +358,8 @@ describe("oauth flow (整合)", () => {
code_challenge_method: "S256",
scope: "mcp",
resource: "https://mcp.arcrun.dev/mcp",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -344,6 +393,143 @@ describe("oauth flow (整合)", () => {
expect(at?.aud).toBe("https://mcp.arcrun.dev/mcp");
});
// ── 2026-08-12:身分要接住並攜帶(本次修的病根)─────────────────────────────
describe("登入者身分跟著 token 走(leo:掛上 MCP 並輸入帳密=授權,下游不得再問一次)", () => {
it("驗完帳密不是只留布林值:token 帶得出 portal session 與該帳號的可用知識庫", async () => {
const env = baseEnv({ CYPHER_EXECUTOR: cypherMock({ libraries: ["kb"], displayName: "小明", role: "user" }).fetcher });
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const authRes = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
const tokRes = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
const at = await getAccessToken(env.OAUTH_KV!, (await tokRes.json()).access_token);
expect(at?.portal?.session).toBe("sess-abc");
expect(at?.portal?.display_name).toBe("小明");
expect(at?.portal?.role).toBe("user");
expect(at?.portal?.libraries).toEqual(["kb"]);
});
it("**不同帳號登入 → token 帶的身分跟著換**(不是不管誰登入都同一格)", async () => {
// 兩個帳號權限不同:一個全庫、一個只有 kb。token 裡的身分必須各自不同。
const envA = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-A", displayName: "Leo", libraries: ["*"] }).fetcher });
const envB = baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: "sess-B", displayName: "小明", libraries: ["kb"] }).fetcher });
async function tokenFor(env: Env) {
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const a = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
const t = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
return getAccessToken(env.OAUTH_KV!, (await t.json()).access_token);
}
const a = await tokenFor(envA);
const b = await tokenFor(envB);
expect(a?.portal?.session).not.toBe(b?.portal?.session);
expect(a?.portal?.libraries).toEqual(["*"]);
expect(b?.portal?.libraries).toEqual(["kb"]);
});
it("access_token 活不過它底下的 portal sessionTTL 取兩者較小)", async () => {
const env = baseEnv({
MCP_TOKEN_TTL: "2592000", // 30 天
CYPHER_EXECUTOR: cypherMock({ sessionExpiresIn: 3600 }).fetcher, // session 只有 1 小時
});
const app = buildApp(env);
const { verifier, challenge } = await pkcePair();
const redirect = "https://claude.ai/cb";
const a = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
const code = new URL(a.headers.get("location")!).searchParams.get("code")!;
const t = await app.req("/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirect,
}).toString(),
});
expect((await t.json()).expires_in).toBe(3600);
});
it("cypher 回 200 但沒給 session_token(舊版 cypher)→ 不發碼(不發一張沒有身分的 token)", async () => {
const app = buildApp(baseEnv({ CYPHER_EXECUTOR: cypherMock({ sessionToken: null }).fetcher }));
const { challenge } = await pkcePair();
const r = await app.req("/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: "c1",
redirect_uri: "https://claude.ai/cb",
code_challenge: challenge,
code_challenge_method: "S256",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
expect(r.status).toBe(401);
expect(r.headers.get("location")).toBeNull();
});
});
it("錯誤 owner 祕密 → 401、不發 code", async () => {
const app = buildApp(baseEnv());
const { challenge } = await pkcePair();
@@ -355,7 +541,8 @@ describe("oauth flow (整合)", () => {
redirect_uri: "https://claude.ai/cb",
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "WRONG",
email: GOOD_EMAIL,
password: "WRONG",
}).toString(),
redirect: "manual",
});
@@ -377,7 +564,8 @@ describe("oauth flow (整合)", () => {
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -445,7 +633,8 @@ describe("oauth resourceRFC 8707)簽發端把關", () => {
code_challenge: challenge,
code_challenge_method: "S256",
resource,
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -570,7 +759,8 @@ describe("oauth store drift guardOAUTH_KV 的 put 一律帶 TTL", () => {
redirect_uri: redirect,
code_challenge: challenge,
code_challenge_method: "S256",
owner_secret: "s3cr3t-owner",
email: GOOD_EMAIL,
password: GOOD_PASSWORD,
}).toString(),
redirect: "manual",
});
@@ -0,0 +1,208 @@
/**
* kbdb_* ****2026-08-12
*
* leo Portal 西AI
* MCP AI西
* MCP
*
*
* ** portal session** cypher `/portal/data/*`
* KBDB KBDB
* owner_id ****
* token**fail-closed**退
* static token / partner key KBDB
*/
import { describe, it, expect } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import { registerAllKbdbDataTools } from "../../../src/tools/kbdb_data.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function makeServer() {
const tools = new Map<string, { description: string; handler: ToolHandler }>();
const server = {
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
tools.set(name, { description, handler });
},
};
return { server: server as unknown as McpServer, tools };
}
/** 兩個 binding 都掛上,才驗得出「該走哪一條」——走錯的那條會被記錄下來。 */
function makeEnv(respond: (which: "cypher" | "kbdb", url: URL, init?: RequestInit) => Response) {
const cypherCalls: { url: URL; init?: RequestInit }[] = [];
const kbdbCalls: { url: URL; init?: RequestInit }[] = [];
const env = {
CYPHER_EXECUTOR: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
cypherCalls.push({ url, init });
return respond("cypher", url, init);
},
},
KBDB: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
kbdbCalls.push({ url, init });
return respond("kbdb", url, init);
},
},
KBDB_INTERNAL_TOKEN: "service-key-should-not-be-used-on-portal-path",
} as unknown as Env;
return { env, cypherCalls, kbdbCalls };
}
function parseResult(r: { content: { text: string }[] }) {
return JSON.parse(r.content[0].text) as Record<string, unknown>;
}
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
const SERVICE: KnowledgeIdentity = { kind: "service" };
const STALE: KnowledgeIdentity = { kind: "stale" };
function tools(identity: KnowledgeIdentity, respond: Parameters<typeof makeEnv>[0]) {
const { server, tools } = makeServer();
const e = makeEnv(respond);
registerAllKbdbDataTools(server, e.env, identity);
return { tools, ...e };
}
const OK = () => new Response(JSON.stringify({ success: true, entries: [], records: [], count: 0 }));
describe("kbdb_* 以登入者身分查詢(portal 資料面)", () => {
const cases: Array<{ tool: string; args: Record<string, unknown>; path: string; method?: string }> = [
{ tool: "kbdb_search", args: { q: "火星座標" }, path: "/portal/data/search" },
{ tool: "kbdb_query", args: { template: "triplet" }, path: "/portal/data/records/by-template/triplet" },
{ tool: "kbdb_get_record", args: { record_id: "rec_1" }, path: "/portal/data/records/rec_1" },
{ tool: "kbdb_list_templates", args: {}, path: "/portal/data/templates" },
{ tool: "kbdb_create_template", args: { name: "contact", slots: ["name"] }, path: "/portal/data/templates", method: "POST" },
{ tool: "kbdb_create_record", args: { template: "contact", values: { name: "Leo" } }, path: "/portal/data/records", method: "POST" },
];
for (const c of cases) {
it(`${c.tool} → 打 ${c.path},帶登入者 session,完全不碰 KBDB 服務金鑰`, async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(PORTAL, OK);
const res = await t.get(c.tool)!.handler(c.args);
expect(res.isError).toBeUndefined();
// 走的是 cypher 的 portal 資料面,不是 KBDB 直連
expect(kbdbCalls, `${c.tool} 不該直打 KBDB`).toHaveLength(0);
expect(cypherCalls).toHaveLength(1);
expect(cypherCalls[0].url.pathname).toBe(c.path);
expect(cypherCalls[0].init?.method ?? "GET").toBe(c.method ?? "GET");
// 帶的是「那個人的 session」,不是任何服務金鑰
const auth = new Headers(cypherCalls[0].init!.headers as HeadersInit).get("Authorization");
expect(auth).toBe("Bearer sess-abc");
expect(auth).not.toContain("service-key");
});
}
it("呼叫端自帶 owner_id 一律不生效(不讓呼叫端自己挑租戶/歸屬)", async () => {
const { tools: t, cypherCalls } = tools(PORTAL, OK);
await t.get("kbdb_search")!.handler({ q: "x", owner_id: "someone-else" });
await t.get("kbdb_query")!.handler({ template: "triplet", owner_id: "someone-else" });
for (const call of cypherCalls) {
expect(call.url.searchParams.get("owner_id")).toBeNull();
}
});
it("寫入時 owner_id 不從呼叫端 body 走(server 定死成登入者的歸屬)", async () => {
const { tools: t, cypherCalls } = tools(PORTAL, OK);
await t.get("kbdb_create_record")!.handler({
template: "contact",
values: { name: "Leo" },
owner_id: "someone-else",
});
const body = JSON.parse(String(cypherCalls[0].init!.body)) as Record<string, unknown>;
expect(body).not.toHaveProperty("owner_id");
});
it("越庫寫入被擋(403)→ 誠實講是權限問題", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: '無「secret」庫的權限,不能寫入該庫' }), { status: 403 }),
);
const res = await t.get("kbdb_create_record")!.handler({
template: "note",
values: { library: "secret", body: "x" },
});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("forbidden");
});
it("查不是自己的 record(404)→ 與「不存在」同一句話(不洩存在性)", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }),
);
const res = await t.get("kbdb_get_record")!.handler({ record_id: "rec_someone_else" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("not_found");
expect(String(parseResult(res).human_message)).toContain("不在你的權限範圍內");
});
it("session 過期(401)→ session_expired,不謊稱資料是空的", async () => {
const { tools: t } = tools(PORTAL, () =>
new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
const res = await t.get("kbdb_search")!.handler({ q: "x" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
});
describe("fail-closed:舊 token 沒有身分就查不到東西(不退回服務金鑰)", () => {
for (const name of [
"kbdb_search",
"kbdb_query",
"kbdb_get_record",
"kbdb_list_templates",
"kbdb_create_template",
"kbdb_create_record",
]) {
it(`${name} → identity_missing,且一個查詢都不發`, async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(STALE, OK);
const res = await t.get(name)!.handler({
q: "x",
template: "t",
record_id: "r",
name: "n",
slots: ["a"],
values: { a: "b" },
});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(cypherCalls).toHaveLength(0);
expect(kbdbCalls).toHaveLength(0);
});
}
});
describe("回歸:服務級憑據維持既有 KBDB 直連", () => {
it("kbdb_search 仍直打 KBDB /entries/search,且照舊吃 owner_id", async () => {
const { tools: t, cypherCalls, kbdbCalls } = tools(SERVICE, OK);
const res = await t.get("kbdb_search")!.handler({ q: "x", owner_id: "leo" });
expect(res.isError).toBeUndefined();
expect(cypherCalls).toHaveLength(0);
expect(kbdbCalls).toHaveLength(1);
expect(kbdbCalls[0].url.pathname).toBe("/entries/search");
expect(kbdbCalls[0].url.searchParams.get("owner_id")).toBe("leo");
});
it("kbdb_query / kbdb_get_record 路徑不變", async () => {
const { tools: t, kbdbCalls } = tools(SERVICE, OK);
await t.get("kbdb_query")!.handler({ template: "triplet" });
await t.get("kbdb_get_record")!.handler({ record_id: "rec_1" });
expect(kbdbCalls.map((c) => c.url.pathname)).toEqual([
"/records/by-template/triplet",
"/records/rec_1",
]);
});
});
+92 -7
View File
@@ -5,6 +5,17 @@ import {
registerGraphNeighbors,
GRAPH_NEIGHBORS_WORKFLOW,
} from "../../../src/tools/kbdb_graph.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
/** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
};
/** 本次改版前簽發的舊 token(沒有身分)。 */
const STALE: KnowledgeIdentity = { kind: "stale" };
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ─────────────────────────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
@@ -45,7 +56,7 @@ describe("kbdb_graph_neighbors: registration", () => {
it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
expect(tools.has("kbdb_graph_neighbors")).toBe(true);
expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph");
});
@@ -61,7 +72,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
{ status: 200 },
),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "Arcrun",
depth: 2,
@@ -88,7 +99,7 @@ describe("kbdb_graph_neighbors: request shape", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -108,7 +119,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -124,7 +135,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -143,7 +154,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
status: 200,
}),
);
registerGraphNeighbors(server, env, "leo");
registerGraphNeighbors(server, env, "leo", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -156,7 +167,7 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
it("empty orgNamespace → no_namespace error, no fetch made", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "");
registerGraphNeighbors(server, env, "", SERVICE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({
subject: "A",
kbdb_base: "https://kbdb.example.com",
@@ -166,3 +177,77 @@ describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash
expect(calls).toHaveLength(0);
});
});
// ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)──
describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => {
it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() =>
new Response(
JSON.stringify({
neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }],
edges: [],
count: 1,
}),
{ status: 200 },
),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 });
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A");
expect(calls[0].url.searchParams.get("depth")).toBe("2");
const headers = new Headers(calls[0].init!.headers as HeadersInit);
expect(headers.get("Authorization")).toBe("Bearer sess-abc");
expect(res.isError).toBeUndefined();
expect((parseResult(res).data as { count: number }).count).toBe(1);
});
it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBeUndefined();
expect(calls).toHaveLength(1);
// 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫)
expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull();
});
it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }),
);
registerGraphNeighbors(server, env, "leo", PORTAL);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("forbidden");
});
it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(() => new Response("{}"));
registerGraphNeighbors(server, env, "leo", STALE);
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed
});
});
+135 -17
View File
@@ -7,6 +7,32 @@ import {
renderLibraryMapLines,
__resetLibraryMapInstructionsCacheForTests,
} from "../../../src/lib/library-map.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
/** 本次改版前簽發的舊 token(沒有身分)。 */
const STALE: KnowledgeIdentity = { kind: "stale" };
/** 假 CYPHER_EXECUTOR bindingportal 資料面用)。 */
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
const calls: { url: URL; init?: RequestInit }[] = [];
const env = {
CYPHER_EXECUTOR: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
calls.push({ url, init });
return respond(url, init);
},
},
} as unknown as Env;
return { env, calls };
}
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
@@ -56,7 +82,7 @@ describe("kbdb_get_map: registration", () => {
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
expect(tools.has("kbdb_get_map")).toBe(true);
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
@@ -69,7 +95,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(calls).toHaveLength(1);
@@ -90,7 +116,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
});
@@ -105,7 +131,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const data = parseResult(res).data as {
libraries: { top_entities: string[]; triplet_count: number }[];
@@ -119,7 +145,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
expect(body.ok).toBe(true);
@@ -135,7 +161,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const hintsText = JSON.stringify(body.hints);
@@ -149,7 +175,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
const body = parseResult(res);
@@ -178,7 +204,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(calls[0].url.pathname).toBe("/map/kb");
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
@@ -197,7 +223,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
triplet_count: "111",
};
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBeUndefined();
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
@@ -212,7 +238,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
);
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
expect(res.isError).toBe(true);
const body = parseResult(res);
@@ -233,7 +259,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
},
},
} as unknown as Env;
registerGetMap(server, env);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("internal_error");
@@ -258,7 +284,7 @@ describe("buildLibraryMapInstructions", () => {
}),
),
);
const text = await buildLibraryMapInstructions(env);
const text = await buildLibraryMapInstructions(env, SERVICE);
expect(text).not.toBeNull();
// design §4 格式:{library}{narrative}|核心:{top3}{triplet_count} triplets
expect(text!).toContain("kbleo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea111 triplets");
@@ -269,7 +295,7 @@ describe("buildLibraryMapInstructions", () => {
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("binding throws → null(靜默略過)", async () => {
@@ -280,22 +306,22 @@ describe("buildLibraryMapInstructions", () => {
},
},
} as unknown as Env;
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("caches within TTLsame isolate 第二次不再打 /map", async () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const first = await buildLibraryMapInstructions(env);
const second = await buildLibraryMapInstructions(env);
const first = await buildLibraryMapInstructions(env, SERVICE);
const second = await buildLibraryMapInstructions(env, SERVICE);
expect(second).toBe(first);
expect(calls).toHaveLength(1);
});
@@ -318,3 +344,95 @@ describe("renderLibraryMapLines", () => {
expect(renderLibraryMapLines([])).toBeNull();
});
});
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
// 一個只有部分權限的帳號。
describe("藏書地圖:登入身分(portal 資料面)", () => {
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBeUndefined();
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/portal/data/map");
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
});
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
});
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
expect(res.isError).toBe(true);
const body = parseResult(res);
expect(body.error_code).toBe("map_not_found");
expect(String(body.human_message)).toContain("不在你被授權");
});
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(() => new Response("{}"));
registerGetMap(server, env, STALE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(calls).toHaveLength(0);
});
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const text = await buildLibraryMapInstructions(env, PORTAL);
expect(text).toContain("kb");
expect(calls[0].url.pathname).toBe("/portal/data/map");
});
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const other: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
};
await buildLibraryMapInstructions(env, PORTAL);
await buildLibraryMapInstructions(env, other);
expect(calls).toHaveLength(2); // 兩次真的各打一次
await buildLibraryMapInstructions(env, PORTAL);
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
});
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
const { env, calls } = makePortalEnv(() => new Response("{}"));
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
expect(calls).toHaveLength(0);
});
});
+10 -29
View File
@@ -30,13 +30,6 @@ import (
"unsafe"
)
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。
const (
hostOK uint32 = 0
hostError uint32 = 1
hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
)
// ── host function 宣告 ───────────────────────────────────────────────────────
//go:wasmimport u6u kv_get
@@ -327,17 +320,9 @@ func doRefresh(input Input, recipe AuthRecipe) (string, int64, bool) {
formBody := form.Encode()
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
respStr, code := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
if code == hostTooLarge {
writeError("token endpoint 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" +
"這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" +
"多半表示 " + cfg.TokenEndpoint + " 回的不是正常的 token JSON(例如回了一整頁 HTML 錯誤頁);" +
"請確認 auth recipe 的 token_endpoint 指向正確的 token 端點。")
return "", 0, false
}
if code != hostOK {
writeError("token endpoint 沒有拿到回應:引擎的 host function 回傳錯誤碼 " +
strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。")
respStr, ok2 := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
if !ok2 {
writeError("token endpoint HTTP 請求失敗")
return "", 0, false
}
@@ -402,7 +387,7 @@ func writeError(msg string) {
func kvGet(key string) (string, uint32) {
keyBytes := []byte(key)
outBuf := make([]byte, 65536)
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut
var outLen uint32
status := hostKvGet(
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
@@ -434,7 +419,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
return "", false
}
outBuf := make([]byte, 65536)
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
var outLen uint32
status := hostCryptoDecrypt(
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
@@ -447,22 +432,18 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
return string(outBuf[:outLen]), true
}
// httpRequest 回傳 (回應原文, host function 回傳碼)。
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。
// 之所以不再只回 bool:bool 把「連不上」和「回應太大」混成同一句話,
// 使用者拿到 "token endpoint HTTP 請求失敗" 會往連線方向查,方向全錯(Arcrun#92)。
func httpRequest(reqURL, method, headersJSON, body string) (string, uint32) {
func httpRequest(reqURL, method, headersJSON, body string) (string, bool) {
urlBytes := []byte(reqURL)
methodBytes := []byte(method)
headersBytes := []byte(headersJSON)
bodyBytes := []byte(body)
if len(urlBytes) == 0 {
return "", hostError
return "", false
}
outBuf := make([]byte, 65536)
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
var outLen uint32
var bodyPtr uintptr
if len(bodyBytes) > 0 {
@@ -481,9 +462,9 @@ func httpRequest(reqURL, method, headersJSON, body string) (string, uint32) {
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", status
return "", false
}
return string(outBuf[:outLen]), hostOK
return string(outBuf[:outLen]), true
}
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
@@ -19,19 +19,11 @@ import (
"io"
"net/url"
"os"
"strconv"
"strings"
"time"
"unsafe"
)
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。
const (
hostOK uint32 = 0
hostError uint32 = 1
hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
)
// ── host function 宣告 ───────────────────────────────────────────────────────
//go:wasmimport u6u kv_get
@@ -275,17 +267,9 @@ func main() {
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
respStr, code := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
if code == hostTooLarge {
writeError("token exchange 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" +
"這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" +
"多半表示 " + recipe.TokenExchange.Endpoint + " 回的不是正常的 token JSON" +
"(例如回了一整頁 HTML 錯誤頁);請確認 auth recipe 的 token_exchange.endpoint 正確。")
return
}
if code != hostOK {
writeError("token exchange 沒有拿到回應:引擎的 host function 回傳錯誤碼 " +
strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。")
respStr, ok := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
if !ok {
writeError("token exchange HTTP 失敗")
return
}
@@ -360,12 +344,11 @@ func pemToPkcs8(pem string) ([]byte, error) {
return base64.StdEncoding.DecodeString(cleaned)
}
// kvGet 呼叫 host function,回傳 (value, status)。
// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBufArcrun#92
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
func kvGet(key string) (string, uint32) {
keyBytes := []byte(key)
outBuf := make([]byte, 65536)
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut
var outLen uint32
status := hostKvGet(
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
@@ -381,7 +364,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
encBytes := []byte(encB64)
ivBytes := []byte(ivB64)
outBuf := make([]byte, 65536)
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
var outLen uint32
if len(encBytes) == 0 || len(ivBytes) == 0 {
return "", false
@@ -404,7 +387,7 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
return nil, false
}
outBuf := make([]byte, 1024) // RSA-2048 簽章 = 256 bytes,1KB 綽綽有餘
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
var outLen uint32
status := hostCryptoSignRS256(
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
@@ -417,21 +400,19 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
return outBuf[:outLen], true
}
// httpRequest 呼叫 host,回傳 (response body 字串, host function 回傳碼)。
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。
// 不再只回 bool 的理由(Arcrun#92):bool 把「連不上」與「回應太大」講成同一句話。
func httpRequest(url, method, headersJSON, body string) (string, uint32) {
// httpRequest 呼叫 host,回傳 response body 字串(host 側把 status + body 串好)
func httpRequest(url, method, headersJSON, body string) (string, bool) {
urlBytes := []byte(url)
methodBytes := []byte(method)
headersBytes := []byte(headersJSON)
bodyBytes := []byte(body)
if len(urlBytes) == 0 {
return "", hostError
return "", false
}
outBuf := make([]byte, 65536)
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
var outLen uint32
// bodyBytes 可能為空(GET),host function 允許 len=0
var bodyPtr uintptr
@@ -451,9 +432,9 @@ func httpRequest(url, method, headersJSON, body string) (string, uint32) {
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", status
return "", false
}
return string(outBuf[:outLen]), hostOK
return string(outBuf[:outLen]), true
}
// interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串。
+3 -6
View File
@@ -287,14 +287,11 @@ func writeError(msg string) {
os.Stdout.Write(out)
}
// kvGet 呼叫 host function,回傳 (value, status)。
// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBufArcrun#92
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
func kvGet(key string) (string, uint32) {
keyBytes := []byte(key)
outBuf := make([]byte, 65536)
// 容量握手(Arcrun#92):先把緩衝區大小告訴 host,host 才能在值塞不下時
// 回 status=3(值太大)而不是硬寫爆這塊記憶體。見 wasi-shim.ts writeOut。
outLen := uint32(len(outBuf))
var outLen uint32
status := hostKvGet(
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
@@ -312,7 +309,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
encBytes := []byte(encB64)
ivBytes := []byte(ivB64)
outBuf := make([]byte, 65536)
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92
var outLen uint32
// 處理空字串的防呆(TinyGo 取 &[]byte{}[0] 會 panic)
if len(encBytes) == 0 || len(ivBytes) == 0 {
+2 -17
View File
@@ -15,14 +15,9 @@ import (
"encoding/json"
"io"
"os"
"strconv"
"unsafe"
)
// 與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組回傳碼。
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
const hostTooLarge uint32 = 3
//go:wasmimport u6u http_request
func hostHttpRequest(
urlPtr uintptr, urlLen uint32,
@@ -107,9 +102,7 @@ func main() {
methodBytes := []byte("POST")
outBuf := make([]byte, 1024*1024) // 1MB
// 容量握手(Arcrun#92):先把緩衝區大小告訴 host,塞不下時 host 會回一段
// 講明「回應太大 + 實際/上限大小 + 該怎麼辦」的 envelope,而不是硬寫爆記憶體。
outLen := uint32(len(outBuf))
var outLen uint32
urlPtr, urlLen := safePtr(urlBytes)
methodPtr, methodLen := safePtr(methodBytes)
@@ -124,16 +117,8 @@ func main() {
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區
if result == hostTooLarge {
writeError("Mira 的回應太大,裝不下:超過這個零件單次能接收的 1 MB 上限。" +
"這不是連線失敗,也不是 Mira 沒回應。" +
"做法:把 prompt 改成請 Mira 回短一點(或分段回),或改用 callback_url 走非同步取回。")
return
}
if result != 0 {
writeError("沒有拿到 Mira 的回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是 prompt 寫錯。")
writeError("Mira daemon request failed (host_http_request returned non-zero)")
return
}
+2 -21
View File
@@ -9,14 +9,9 @@ import (
"encoding/json"
"io"
"os"
"strconv"
"unsafe"
)
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組數字。
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92:以前這種情況會被說成 "HTTP request failed"
const hostTooLarge uint32 = 3
// host function 宣告(由 WASI shim 注入)
//
//go:wasmimport u6u http_request
@@ -91,11 +86,7 @@ func main() {
headersBytes := []byte(headersJSON)
bodyBytes := []byte(bodyStr)
outBuf := make([]byte, 65536) // 64KB output buffer
// 容量握手(Arcrun#92):呼叫前先把緩衝區大小告訴 host。
// hostcypher-executor/src/lib/wasi-shim.ts 的 writeOut)拿這個值當上限——
// 塞不下時不會硬寫爆這塊記憶體,而是改寫一段「回應太大 + 實際/上限大小 + 該怎麼辦」
// 的 error envelope 回來,由下面既有的 parsed["error"] 判定鏈原樣交給使用者。
outLen := uint32(len(outBuf))
var outLen uint32
urlPtr, urlLen := safePtr(urlBytes)
methodPtr, methodLen := safePtr(methodBytes)
@@ -110,18 +101,8 @@ func main() {
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
// host function 回傳碼(定義在 wasi-shim.ts):0=成功 1=host 端錯誤 3=回應塞不下緩衝區
if result == hostTooLarge {
// 走到這裡=連「回應太大」的說明本身都塞不進緩衝區(極端情況),
// 所以零件自己講。訊息一樣要講清楚真因,不能退回 "HTTP request failed"。
writeError("回應太大,裝不下:對方的回應超過這個零件單次能接收的 64 KB 上限。" +
"這不是連線失敗,資料也沒有被截掉一半。" +
"做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試。")
return
}
if result != 0 {
writeError("沒有拿到回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是你的 workflow 參數寫錯。")
writeError("HTTP request failed")
return
}
-91
View File
@@ -1,91 +0,0 @@
/**
* Arcrun#92 重現腳本 回應太大到底會讓使用者看到什麼訊息
*
* 為什麼要有這支http_request 零件的接收緩衝區是 64 KB回應超過這個大小時
* 舊版會硬把資料寫進零件記憶體寫爆或讓 host 丟例外回 1零件對外只講得出一句
* "HTTP request failed"使用者照那句去查連線防火牆URL方向全錯
* 這支腳本把那個情境真的做出來修之前 / 修之後的訊息可以並排比
*
* 用法本機不碰任何線上實例
*
* # 修之後工作區現在的 wasm
* node scripts/repro-oversize-response.mjs 200000
*
* # 修之前 main 上的舊 wasm 取出來當對照組 wasm 不做容量握手 走舊路徑
* git show origin/main:.component-builds/http_request/component.wasm > /tmp/old-http_request.wasm
* node scripts/repro-oversize-response.mjs 200000 /tmp/old-http_request.wasm
*
* # 對照沒超過上限時兩者都應該正常
* node scripts/repro-oversize-response.mjs 1024
*
* Node < 22.18 請加 --experimental-strip-types本檔會 import 一支 .ts
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..');
const { createWasiShim } = await import(
resolve(repoRoot, 'cypher-executor/src/lib/wasi-shim.ts')
);
const responseBytes = Number(process.argv[2] ?? 200_000);
const wasmPath = process.argv[3]
? resolve(process.argv[3])
: resolve(repoRoot, '.component-builds/http_request/component.wasm');
// 假裝遠端回了一包很大的 JSON2xxhost function 照原樣把 body 交給零件)
const filler = 'x'.repeat(Math.max(0, responseBytes - 14));
const remoteBody = JSON.stringify({ items: filler });
const shim = createWasiShim(
JSON.stringify({ url: 'https://example.com/big-list', method: 'GET' }),
{ http_request: async () => remoteBody },
);
const instance = await WebAssembly.instantiate(
await WebAssembly.compile(readFileSync(wasmPath)),
shim.imports,
);
shim.setMemory(instance.exports.memory);
let crashed = null;
try {
await shim.run(instance);
} catch (e) {
crashed = e instanceof Error ? e.message : String(e);
}
const stdout = shim.getStdout().trim();
const stderr = shim.getStderr().trim();
console.log(`wasm : ${wasmPath}`);
console.log(`模擬回應大小 : ${remoteBody.length} bytes(零件緩衝區上限 65536 bytes`);
console.log('');
// 以下複刻 .component-builds/http_request/src/index.ts 的收尾,
// 印出「使用者真的會拿到的那一包」
if (stderr) console.log(`零件 stderr : ${stderr.slice(0, 300)}`);
if (crashed) console.log(`WASM 執行中止 : ${crashed}`);
if (!stdout) {
console.log('使用者看到 : HTTP 500 {"success":false,"error":"WASM component produced no output"}');
process.exit(0);
}
let parsed;
try {
parsed = JSON.parse(stdout);
} catch (e) {
console.log(`使用者看到 : HTTP 500 {"success":false,"error":"${e.message}"}`);
process.exit(0);
}
console.log(`success : ${parsed.success}`);
console.log(`error : ${parsed.error ?? '(無)'}`);
if (parsed.success) {
const body = parsed.data?.body ?? '';
console.log(`data.body 長度 : ${String(body).length} bytes`);
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* sync-resource-rule.mjs 該用哪些資源這條規則的**唯一原稿**同步給需要打包的呼叫端
*
* 為什麼需要這支
* 規則的原稿在 `shared/resource-rule/rule.mjs`理由見該檔開頭
* 兩條路取用它的方式不同
*
* · **安裝器 / 任何 Worker**本來就會下載這個 repo archive 當部署來源
* 直接 import `shared/resource-rule/rule.mjs`**不需要副本本支不管它**
*
* · **`acr` CLI**`arcrun` 是獨立 npm 套件`npm pack` 打不進套件目錄外的檔案
* 套件裡必須有一份這支就是產生那一份的地方
*
* 這算不算第二份實作
* 不算而且是機械保證的產生物是**逐位元組副本**`--check` 一有差就 exit 1
* `npm run build` `npm test` 都會先跑 `--check`
* 也就是說有人手改了 CLI 那一份= build publish 擋下
* `cli/harness/`產生物進 repo `check:harness` 世代閘的既有慣例
* 不是為本票新發明的做法
*
* 用法
* node scripts/sync-resource-rule.mjs 產生更新副本
* node scripts/sync-resource-rule.mjs --check 只檢查有漂移就 exit 1不寫檔
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
// REPO 一律由本檔位置推導,不吃 cwd(比照 scripts/build-worker-artifacts.mjs)。
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const SOURCE_DIR = join(REPO, 'shared/resource-rule');
/**
* 需要套件內自帶一份的呼叫端**整個目錄原樣鏡射**不是挑檔案
* 檔名與相對位置保持一致`cf-resource-api.mjs` 裡的 `./rule.mjs` 才不用改寫
* 安裝器不在此列它直接讀 repo archive 裡的原稿連副本都不需要
*/
const MIRRORS = ['cli/src/lib/resource-rule'];
const CHECK_ONLY = process.argv.includes('--check');
/** @param {string|Buffer} b */
const sha256 = (b) => createHash('sha256').update(b).digest('hex');
if (!existsSync(SOURCE_DIR)) {
console.error(`❌ 找不到規則原稿目錄:${SOURCE_DIR}`);
process.exit(1);
}
/** 原稿目錄裡所有 .mjs(README / 測試不進副本)。 */
const FILES = readdirSync(SOURCE_DIR).filter((f) => f.endsWith('.mjs')).sort();
if (FILES.length === 0) {
console.error(`${SOURCE_DIR} 裡沒有任何 .mjs 原稿`);
process.exit(1);
}
let drifted = 0;
for (const mirror of MIRRORS) {
for (const file of FILES) {
const src = readFileSync(join(SOURCE_DIR, file));
const srcHash = sha256(src);
const rel = `${mirror}/${file}`;
const abs = join(REPO, mirror, file);
const had = existsSync(abs) ? readFileSync(abs) : null;
if (had !== null && sha256(had) === srcHash) {
console.log(`${rel} = 原稿(sha256 ${srcHash.slice(0, 12)}`);
continue;
}
if (CHECK_ONLY) {
drifted++;
console.error(
had === null
? `${rel} 不存在——跑 \`node scripts/sync-resource-rule.mjs\` 產生。`
: `${rel} 與原稿不一致(副本 ${sha256(had).slice(0, 12)} ≠ 原稿 ${srcHash.slice(0, 12)})。\n` +
` 這一份是**產生物**,不要手改:規則要改就改 shared/resource-rule/${file}` +
`然後跑 \`node scripts/sync-resource-rule.mjs\``,
);
continue;
}
mkdirSync(join(REPO, mirror), { recursive: true });
writeFileSync(abs, src);
console.log(`${rel} ← shared/resource-rule/${file}sha256 ${srcHash.slice(0, 12)}`);
}
// 副本目錄裡多出來的 .mjs = 有人在產生物旁邊自己加了一支(第二份實作的常見長法)。
const mirrorAbs = join(REPO, mirror);
const extra = existsSync(mirrorAbs)
? readdirSync(mirrorAbs).filter((f) => f.endsWith('.mjs') && !FILES.includes(f))
: [];
for (const f of extra) {
drifted++;
console.error(`${mirror}/${f} 在原稿目錄裡不存在——副本目錄不是放自己東西的地方。`);
}
}
if (drifted > 0) {
console.error(
`\n${drifted} 項與規則原稿脫節。` +
`\n「該用哪些資源」只能有一份實作(.claude/rules/07-thin-shell.md)——` +
`副本漂移就是第二份實作偷偷長出來的樣子。`,
);
process.exit(1);
}
+138
View File
@@ -0,0 +1,138 @@
# `shared/resource-rule` — 「這個實例該用哪些資源」的唯一一份規則
> leo 2026-08-12
> ①「如果你沒有裝,就是新的;**如果你已經有,原來叫什麼名字就繼續用下去**。」
> ②「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
① 是規則本身,② 是它該住哪裡。這個目錄就是 ②。
---
## 1. 規則(三句話)
判準是「**這顆 worker 現在綁著誰**」,**不是**「有沒有叫這個名字的資源」。
1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
3. **只要有一點說不準就整趟停手**——讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
該更新的 worker 一顆都不在 ⇒ **什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
`planResources()`(不寫入,只出計畫)與 `applyResourcePlan()`(有 blocker 就拒絕執行)分兩段,
所以「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,不是靠誰記得寫 early return。
---
## 2. 為什麼在這裡,不在 cypher-executor 的 API
`.claude/rules/07-thin-shell.md` 的標準答案是「能力放 API」。這一條**不走那條路**,理由是自舉:
| 問題 | 說明 |
|---|---|
| **cypher 可能還不存在** | 這條規則要在「決定怎麼裝」的當下就用得到,而安裝器的工作正是把 cypher 生出來。把規則放進 cypher = 要先有雞才能有蛋。 |
| **輸入是使用者自己的帳號狀態** | 判斷的依據是使用者 Cloudflare 帳號上的綁定。送去平台託管的 worker 換一個答案 ⇒ ①「能不能安裝」綁在平台是否活著,②使用者的帳號拓撲交給第三方。 |
| **它根本不需要是服務** | 這是**純函式**:唯一的 IO 由呼叫端注入(`ResourceApi`)。薄殼原則要求「能力只實作一次」,不是「能力一定要是 HTTP」。 |
所以形態是**一份零依賴的 ESM**——Node 18+ 與 Cloudflare Workers runtime 都能直接 import
不必編譯、不必連網、不必先有任何 arcrun 元件活著。
其他評估過的形態:**共用 npm 套件** → 要多發一個 package + token,且安裝器得先 `npm i` 才能判斷,
自舉問題只是換個位置;**做成一顆零件** → 得用 TinyGo/AssemblyScript 重寫一次,那正是「第二份實作」。
---
## 3. 檔案
| 檔案 | 內容 |
|---|---|
| `rule.mjs` | 規則本體:`planResources` / `applyResourcePlan` / `parseWranglerRequirements` 把 CF 回應讀成事實的 `normalizeLiveBindings` / `normalizeLiveVars` |
| `cf-resource-api.mjs` | `ResourceApi` 的 CF REST 實作(只用 global `fetch`)。**眼睛也要共用**——見下 §5 |
| `installer-entry.mjs` | 安裝器唯一該碰的入口:`resolveInstanceResources()` |
| `tests/fixture-account.mjs` | 假 Cloudflare 帳號(`fetch` 替身)+三種情境 |
| `tests/demo.mjs` | `node shared/resource-rule/tests/demo.mjs`——零依賴、零建置就能跑的示範 |
🔴 **零依賴是硬規則**:只准 import 同目錄的兄弟檔,不准碰 `node:*`
有外部依賴就會有某條路吃不到它。`cli/tests/single-implementation.test.ts` ③ 會擋。
---
## 4. 兩條路怎麼取用
### 安裝器 / 任何 Worker(不需要副本)
安裝器本來就會下載本 repo 的 archive 當部署來源(`.claude/rules/05-deploy-convention.md`
「WASM 來源」),`shared/resource-rule/` 就在那份 archive 裡:
```js
import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
const r = await resolveInstanceResources({
accountId, apiToken,
wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // toml 的「內容」,不是路徑
mode: isUpdate ? 'update' : 'init',
});
if (r.blocked) {
// 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
return showAndStop(r.blockers);
}
// r.bindings : { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
// r.origin : { 'kv_namespace:WEBHOOKS': 'adopted' | 'created', … }
// r.liveVars : { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } } ← #106
```
**安裝器不准自己判斷要不要建資源**,也不准自己解讀 CF 的 binding 回應。只呼叫這一支。
### `acr` CLI(需要一份鏡射)
`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒ 套件裡必須自帶一份。
`cli/src/lib/resource-rule/` 就是本目錄的**逐位元組鏡射**,由
`node scripts/sync-resource-rule.mjs` 產生。
**要改規則就改這個目錄,然後重跑 sync。** 手改鏡射會被擋下:
`npm run build``npm test` 都先跑 `sync-resource-rule.mjs --check`
差一個位元組就 exit 1(同 `cli/harness/` 的產生物+世代閘慣例)。
---
## 5. 為什麼連 CF client 也共用
判斷一致還不夠,**看到的東西**也要一致。
「已部署的 worker 綁著什麼」是從 `GET /workers/scripts/{script}/settings` 讀來的。
兩條路各自寫一份 client,只要有一邊把 404 當錯誤、漏了 `per_page`、少認一種欄位名
`namespace_id` vs `id`),那一邊就會「看不到既有綁定」——
而看不到既有綁定的下一步,依規則就是**新建**。
**Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。**
所以 `cli/src/lib/cf-api.ts``CfAccountClient``ResourceApi` 那七個方法**全部委派**
`cf-resource-api.mjs`,自己不留實作。
---
## 6. 驗收
```bash
cd cli && npm test # 58 項,含下列三組
node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑
```
| 測試 | 證的事 |
|---|---|
| `cli/tests/two-paths-agree.test.ts` | 同一個帳號狀態餵給 `acr` 那條與安裝器那條,**選出的 resource id 相同**、建的東西相同、停手的理由相同 |
| `cli/tests/single-implementation.test.ts` | ①規則的 7 支函式全 repo 只有這裡有實作 ②鏡射逐位元組相同 ③共用層零依賴 |
| `cli/tests/resource-adoption.test.ts` | #97 本身的迴歸(沿用/不多建/四種停手情境),改共用層後照樣全過 |
三種情境(`tests/fixture-account.mjs``SCENARIOS`):
- `fresh` — 沒裝過 → **正常建新的**(不能為了沿用而變成永遠不建)
- `installed` — 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在
- `renamed`**資源在但名字與預期完全不同** → 仍然沿用(#97 的病根,專門驗)
---
## 7. 相關
- `Arcrun#97` — 「我按了更新,工作流和登入全不見了」:CLI 那條已修,本目錄是把同一條規則交給所有路徑
- `Arcrun#106` — 重部署把 `plain_text` var(含版本標籤)洗掉:`liveVars` 就是那些標籤
- `Arcrun#80` / `arcrun-rag#39` — 同一個「重複做 Arcrun 的工作」家族;Arcrun 是唯一編譯點的既有慣例
- `.claude/rules/07-thin-shell.md` — 本目錄存在的依據
+202
View File
@@ -0,0 +1,202 @@
// @ts-check
/**
* cf-resource-api.mjs 規則的**眼睛與手** Cloudflare 帳號的那七個動作也只有一份
*
* `rule.mjs` 是純判斷IO 由呼叫端注入`ResourceApi`本檔就是那個注入物的正貨
* CF REST API 實作 `ResourceApi`零依賴只用 global `fetch`
* Node 18+ Cloudflare Workers runtime 都能直接跑
*
* 為什麼連這層也要共用
* 判斷一致還不夠**看到的東西**也要一致
* 已部署的 worker 綁著什麼是從 `GET /workers/scripts/{script}/settings` 讀來的
* 如果兩條路各自寫一份 client隨便一個差異打錯端點 404 當錯誤漏了 per_page
* 少認一種欄位名都會讓其中一條路看不到既有綁定而看不到既有綁定的下一步
* 依規則就是**新建**Arcrun#97 的災情不需要規則寫錯只要眼睛不一樣就會重演
*
* 這裡**故意只有 `ResourceApi` 那七個方法**verifyAccess / subdomain / KV 讀寫
* 這些跟該用哪些資源無關的帳號操作留在各自的呼叫端不往共用層堆
*
* 🔴 除了同目錄的 `./rule.mjs`這支不准 import 任何東西共用層的價值在於
* 整個目錄複製到哪個 runtime 都能直接跑多一個外部依賴就少一條路吃得到
*/
import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
* @typedef {import('./rule.mjs').RawWorkerBinding} RawWorkerBinding
*/
/**
* @typedef {object} CfResourceApiOptions
* @property {string} accountId
* @property {string} apiToken
* @property {typeof globalThis.fetch} [fetch]
* 注入用離線測試餵假帳號或宿主要用自己的 fetch預設 global fetch
*/
/**
* 建一個打真實 Cloudflare `ResourceApi`
*
* @param {CfResourceApiOptions} options
* @returns {ResourceApi & { cfRaw: (path: string, init?: RequestInit) => Promise<{ok: boolean, status: number, result?: any, error?: string}> }}
*/
export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchImpl }) {
const doFetch = fetchImpl ?? globalThis.fetch;
if (typeof doFetch !== 'function') {
throw new Error('createCloudflareResourceApi:這個執行環境沒有 fetch,請用 options.fetch 注入。');
}
const accountBase = `${CF_API_BASE}/accounts/${accountId}`;
const headers = {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
};
/**
* HTTP status 交回呼叫端自己判斷要區分404 不存在其他錯誤時用
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.success) {
return {
ok: false,
status: res.status,
error:
(data?.errors ?? []).map((/** @type {{message?: string}} */ e) => e.message).filter(Boolean).join('; ') ||
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
}
/**
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<any>}
*/
async function cf(path, init) {
const { ok, status, result, error } = await cfRaw(path, init);
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
return result;
}
return {
cfRaw,
/**
* 讀一顆已部署 worker 現在綁著哪些資源**使用者那側的事實**Arcrun#97 的唯一真相源
*
* - script 不存在404 `{ deployed: false }`這是還沒部署不是錯誤
* - 其他任何失敗 throw呼叫端必須把它當我不知道**不是**它沒有
* 把查不到當成不存在就是 #97 的根因
*
* @param {string} script
* @returns {Promise<ScriptBindings>}
*/
async getScriptBindings(script) {
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
const res = await cfRaw(path);
if (!res.ok) {
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
throw new Error(`${script} 綁定失敗:${res.error}`);
}
/** @type {RawWorkerBinding[]} */
const raw = res.result?.bindings ?? [];
return {
deployed: true,
bindings: normalizeLiveBindings(raw),
vars: normalizeLiveVars(raw),
};
},
/** @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');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
},
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
const result = await cf('/d1/database?per_page=100');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
},
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
},
/**
* 無條件新建一顆 KV namespace
*
* 🔴 Arcrun#97這裡**故意沒有**找不到同名就順手建一顆 ensure 版本
* 照名字找 找不到 新建 綁上去正是把使用者實例洗成空的那條路
* 安裝器取的名字跟 binding 名不一樣永遠對不上 每次更新都新建
* 要不要建一律先過 `planResources`
*
* @param {string} title
* @returns {Promise<string>}
*/
async createKvNamespace(title) {
const result = await cf('/storage/kv/namespaces', {
method: 'POST',
body: JSON.stringify({ title }),
});
return result.id;
},
/**
* 無條件新建 D1沒有 ensure 版本理由同 createKvNamespaceArcrun#97
* @param {string} name
* @returns {Promise<string>}
*/
async createD1Database(name) {
const result = await cf('/d1/database', {
method: 'POST',
body: JSON.stringify({ name }),
});
return result.uuid;
},
/**
* 新建 KBDB embed 用的 Vectorize index**bge-m3 = 1024 / cosine**
* 已存在409 / already exists視為成功並行或重跑不該炸
* 沒有 ensure 版本要不要建 planResources 判斷這裡只負責建Arcrun#97
*
* @param {string} name
* @returns {Promise<string>}
*/
async createVectorizeIndex(name) {
const res = await cfRaw('/vectorize/v2/indexes', {
method: 'POST',
body: JSON.stringify({
name,
config: { dimensions: 1024, metric: 'cosine' },
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
}),
});
if (res.ok) return name;
const detail = (res.error ?? '').toLowerCase();
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
},
};
}
+100
View File
@@ -0,0 +1,100 @@
// @ts-check
/**
* installer-entry.mjs 安裝器那條路的**唯一入口**
*
* 安裝器arcrun-rag `installer/oauth-prototype/worker.js`不必也不准自己判斷
* 該建哪些資源它只要呼叫這一支拿回每個 binding 該用哪顆資源
*
* ```js
* import { resolveInstanceResources } from './shared/resource-rule/installer-entry.mjs';
*
* const r = await resolveInstanceResources({
* accountId, apiToken,
* wranglerTomls: [cypherToml, registryToml, mcpToml, kbdbToml], // 字串陣列
* mode: isUpdate ? 'update' : 'init',
* });
* if (r.blocked) {
* // 🔴 一顆資源都沒被建。把 r.blockers 原文顯示給使用者,**不要自己「試著繼續」**。
* return showAndStop(r.blockers);
* }
* // r.bindings: { 'kv_namespace:WEBHOOKS': 'kvid-…', 'd1:DB': 'uuid-…', … }
* // r.liveVars: { 'arcrun-cypher-executor': { ARCRUN_BUNDLE_VERSION: '1.4.33', … } }
* ```
*
* 為什麼安裝器不需要副本安裝器本來就會下載本 repo archive 當部署來源
* `.claude/rules/05-deploy-convention.md`WASM 來源
* `shared/resource-rule/` 就在那份 archive 直接 import 即可
* **不必再編一次不必貼一份也就不會有第二種答案**
*/
import { planResources, applyResourcePlan, parseWranglerRequirements, ResourcePlanBlocked } from './rule.mjs';
import { createCloudflareResourceApi } from './cf-resource-api.mjs';
/**
* @typedef {object} ResolveOptions
* @property {string} accountId
* @property {string} apiToken
* @property {string[]} wranglerTomls worker wrangler.toml **內容**不是路徑
* @property {'update' | 'init'} mode 這台照定義裝過了沒
* @property {typeof globalThis.fetch} [fetch] 注入用測試宿主自帶 fetch
*/
/**
* @typedef {object} ResolveResult
* @property {boolean} blocked true = 什麼都沒建什麼都不該部署
* @property {string[]} blockers blocked 時的原因原文要原樣轉給使用者
* @property {Record<string, string>} bindings `${kind}:${binding}` 資源 idindex
* @property {Record<string, 'adopted'|'created'>} origin 同上 key 這顆是沿用還是新建
* @property {Record<string, Record<string, string>>} liveVars script 現有 plain_text var#106
*/
/**
* 決定這台實例每個 binding 該用哪顆資源照規則沿用既有只在確定沒人綁過時才新建
*
* @param {ResolveOptions} options
* @returns {Promise<ResolveResult>}
*/
export async function resolveInstanceResources({ accountId, apiToken, wranglerTomls, mode, fetch }) {
const api = createCloudflareResourceApi({ accountId, apiToken, fetch });
/** @type {import('./rule.mjs').BindingRequirement[]} */
const requirements = [];
for (const toml of wranglerTomls) {
const parsed = parseWranglerRequirements(toml);
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
}
/** @param {string[]} blockers @returns {ResolveResult} */
const stop = (blockers) => ({ blocked: true, blockers, bindings: {}, origin: {}, liveVars: {} });
if (requirements.length === 0) {
return stop(['這批 wrangler.toml 裡讀不到任何資源綁定需求——不確定要裝什麼,停手。']);
}
let plan;
try {
plan = await planResources(api, requirements, mode);
} catch (e) {
return stop([`資源解析失敗(${e instanceof Error ? e.message : String(e)})。沒有建立任何資源。`]);
}
if (plan.blockers.length > 0) return stop(plan.blockers);
/** @type {Map<string, import('./rule.mjs').ResolvedResource>} */
let resolved;
try {
resolved = await applyResourcePlan(api, plan);
} catch (e) {
return stop(e instanceof ResourcePlanBlocked ? e.blockers : [e instanceof Error ? e.message : String(e)]);
}
/** @type {Record<string, string>} */
const bindings = {};
/** @type {Record<string, 'adopted'|'created'>} */
const origin = {};
for (const [key, r] of resolved) {
bindings[key] = r.value;
origin[key] = r.origin;
}
return { blocked: false, blockers: [], bindings, origin, liveVars: Object.fromEntries(plan.liveVars) };
}
+570
View File
@@ -0,0 +1,570 @@
// @ts-check
/**
* rule.mjs 這個實例該用哪些資源**唯一一份**規則
*
*
* 這份檔案為什麼在這裡`shared/`不在 `cli/`
*
* leo 2026-08-12根本就不應該在 CLI我要的是一個大家都可以用到的規則
*
* `.claude/rules/07-thin-shell.md` 的判準口訣
* 這段邏輯換一個介面要不要重寫要重寫 它是能力該在共用層
*
* 該沿用哪幾顆資源換到安裝器就得重寫一次 它是**能力**不是薄殼的事
* 而它原本住在 `cli/src/lib/resource-resolver.ts` 那本身就是違規
* 後果也真的發生了`acr` 那條有這條規則安裝器那條沒有於是安裝器照名字找
* 找不到就建新的空的 Arcrun#97我按了更新工作流和登入全不見了
*
* 為什麼不是 cypher-executor API 端點薄殼原則的標準答案
* **自舉**這條規則要在決定怎麼裝怎麼更新的當下就用得到而那個當下
* cypher 可能還不存在安裝器的工作正是把它生出來或正要被覆蓋
* 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**
* 把它送去一顆平台託管的 worker 換一個答案等於①讓能不能安裝綁在平台是否活著
* ②把使用者的帳號拓撲交給第三方兩件都不該為了形式上的漂亮而做
*
* 薄殼原則要求的是能力只實作一次不是能力一定要是 HTTP
* 這條規則是**純函式**唯一的 IO 由呼叫端注入 `ResourceApi`
* 所以它用不著變成服務一份零依賴的 ESM 就能讓每條路吃到同一份判斷
*
* 怎麼讓兩條路吃到同一份而不是各留一份
* 本檔是**唯一被人手維護的實作**零依賴不吃任何 node 內建Workers runtime 可直接跑
* · `acr``cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**
* `scripts/sync-resource-rule.mjs` 產生CLI 要能單獨 npm publish
* 套件目錄外的檔案打不進 tarball故必須有這一份
* `npm run build` / `npm test` 都會跑 `--check`內容一漂就紅
* `cli/harness/`產生物世代閘的既有慣例
* · 安裝器 / 任何 Worker安裝器本來就會下載本 repo archive部署來源
* `.claude/rules/05-deploy-convention.md`WASM 來源
* 直接 import 這一份 `shared/resource-rule/rule.mjs` 即可**不需要再編一次也不留副本**
* 用法見同目錄 README.md
*
*
* 規則本身leo 的兩句話
*
* 如果你沒有裝就是新的如果你已經有原來叫什麼名字就繼續用下去
*
* 判準是**這顆 worker 現在綁著誰**不是有沒有叫這個名字的資源
* 1. **已部署的 worker 上綁著什麼那就是事實** 原封不動沿用不管那顆資源叫什麼名字
* 2. **只有確定沒有任何人綁過它才准新建**新版本新增的 binding或真的全新帳號
* 3. **只要有一點說不準就整趟停手**讀不到綁定綁著的資源不見了同一個 binding 指向兩顆
* 該更新的 worker 一顆都不在**什麼都不建什麼都不部署**把話說清楚讓人來判斷
*
* 為什麼拆成 plan / apply 兩段
* `planResources()` **完全不寫入**只回一份要沿用什麼要新建什麼有什麼不敢動的
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行
* 被擋下的時候一顆資源都不會被建出來**結構上的保證**
* 不是靠某個人記得在對的地方寫 early return#97 正是死在先動手後判斷
*
* 🔴 這份檔案沒有 import也不准有任何依賴都會讓某一條路吃不到它
*/
/**
* 這支負責的資源種類要加新種類R2/Queue/Hyperdrive就加在這裡
* 一律走同一道門不准任何呼叫端自己照名字 ensure繞過去
* @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
*/
/**
* 從已部署 worker 上讀回來的一條綁定`value`KV/D1 是資源 idVectorize index
* @typedef {object} LiveBinding
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
*/
/**
* @typedef {object} ScriptBindings
* @property {boolean} deployed
* false = 這顆 worker 在帳號上還不存在全新部署不是讀取失敗讀取失敗要 throw
* @property {LiveBinding[]} bindings
* @property {Record<string, string>} [vars]
* 這顆 worker 現在掛著的 `plain_text` var
*
* 🔴 Arcrun#106#97 只把資源類綁定當成事實沿用KV/D1/Vectorize
* plain_text var 整批沒人管 重部署把它們洗成 repo toml 的預設值
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`安裝器注入的版本標籤
* 更新完就消失Portal 設定頁變成無法讀取目前版本
* **保留了櫃子沒保留櫃子上的標籤**這個欄位就是那些標籤
*/
/**
* 規則需要的 CF 能力收窄成介面方便離線測試餵假帳號也讓安裝器用自己的 fetch 實作
* @typedef {object} ResourceApi
* @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
* @property {() => Promise<Map<string, string>>} listKvNamespaces title id
* @property {() => Promise<Map<string, string>>} listD1Databases name uuid
* @property {() => Promise<string[]>} listVectorizeIndexes
* @property {(title: string) => Promise<string>} createKvNamespace
* @property {(name: string) => Promise<string>} createD1Database
* @property {(name: string) => Promise<string>} createVectorizeIndex
*/
/**
* 這顆 worker 需要這個 bindingcreateName 只在**真的要新建**時才會被拿來當名字用
* @typedef {object} BindingRequirement
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} worker 需要它的 worker script = wrangler.toml `name`
* @property {string} createName
*/
/**
* @typedef {object} PlannedAdopt
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的
*/
/**
* @typedef {object} PlannedCreate
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} createName
* @property {string[]} wantedBy
* @property {string[]} alsoBind 其他也指向同一顆資源的 binding shareSameResource建一顆大家共用
*/
/**
* @typedef {object} ResourcePlan
* @property {PlannedAdopt[]} adopt
* @property {PlannedCreate[]} create
* @property {string[]} blockers 非空 = 整趟停手applyResourcePlan 會拒絕執行
* @property {Map<string, Record<string, string>>} liveVars
* 每顆**已部署** worker 現在掛著的 plain_text varscript /未部署的不在裡面
*
* Arcrun#106讀綁定的時候本來就把整份 `bindings[]` 拿回來了var 就在同一份回應裡
* 順手帶出來**不另外打一次 API**也不新增一種查不到的失敗模式
* 讀不到綁定這件事已經在上面 blockers 那一關擋掉了
*/
/**
* @typedef {object} ResolvedResource
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* @property {string} [from]
*/
/**
* @typedef {object} WranglerRequirements
* @property {string} script worker script toml 頂層 `name`空字串 = 這份 toml 沒宣告 name不該發生
* @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
*/
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
/** @param {string[]} blockers */
constructor(blockers) {
super(`資源解析被擋下(${blockers.length} 項)`);
this.name = 'ResourcePlanBlocked';
/** @type {string[]} */
this.blockers = blockers;
}
}
/**
* @param {ResourceKind} kind
* @param {string} binding
* @returns {string}
*/
export function bindingKey(kind, binding) {
return `${kind}:${binding}`;
}
/** @type {Record<ResourceKind, string>} */
export const KIND_LABEL = {
kv_namespace: 'KV namespace',
d1: 'D1 資料庫',
vectorize: 'Vectorize index',
};
/**
* @param {unknown} e
* @returns {string}
*/
function msg(e) {
return e instanceof Error ? e.message : String(e);
}
/**
* 決定每個 binding 要沿用哪顆資源要不要新建**不寫入任何東西**
*
* @param {ResourceApi} api
* @param {readonly BindingRequirement[]} requirements
* @param {'update' | 'init'} mode
* 'update' = 這台照定義已經裝過了見下方一顆都不在規則'init' = 全新安裝允許從零建
* @returns {Promise<ResourcePlan>}
*/
export async function planResources(api, requirements, mode) {
/** @type {string[]} */
const blockers = [];
/** @type {PlannedAdopt[]} */
const adopt = [];
/** @type {PlannedCreate[]} */
const create = [];
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
/** @type {Map<string, LiveBinding[]>} */
const live = new Map();
/** @type {Map<string, Record<string, string>>} */
const liveVars = new Map();
let readFailed = false;
for (const script of scripts) {
try {
const res = await api.getScriptBindings(script);
if (res.deployed) {
live.set(script, res.bindings);
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
liveVars.set(script, res.vars ?? {});
}
} catch (e) {
readFailed = true;
blockers.push(
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
);
}
}
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
blockers.push(
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
`已停手,沒有新建任何資源。`,
);
}
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
/** @type {Map<string, BindingRequirement[]>} */
const byKey = new Map();
for (const req of requirements) {
const key = bindingKey(req.kind, req.binding);
const list = byKey.get(key);
if (list) list.push(req);
else byKey.set(key, [req]);
}
/** @type {Map<ResourceKind, Set<string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = 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;
};
for (const [, reqs] of byKey) {
const { kind, binding } = reqs[0];
/** @type {Array<{value: string, script: string}>} */
const found = [];
for (const [script, bindings] of live) {
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
if (hit) found.push({ value: hit.value, script });
}
const distinct = [...new Set(found.map((f) => f.value))];
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
if (distinct.length > 1) {
blockers.push(
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
`${found.map((f) => `${f.script}${f.value}`).join('、')})。` +
`分不出哪一顆才是你在用的,不猜——停手。`,
);
continue;
}
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
/** @type {Set<string>} */
let existing;
try {
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
`${msg(e)})。不確定就不動——停手。`,
);
continue;
}
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
`但這顆在你的 Cloudflare 帳號上找不到了。` +
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
);
continue;
}
adopt.push({ kind, binding, value, from: found[0].script });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
}
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
}
/**
* 收斂不同 binding 其實是同一顆資源的情況
*
* 判準是 **toml 自己宣告的名字**`database_name` / `index_name`不是使用者那側的資源名
* cypher `CREDENTIALS_DB` kbdb `DB` 都寫 `database_name = "arcrun-kbdb"`
* 那是**我們**在宣告這兩個綁定指向同一顆庫 #97 那種拿名字去猜使用者的資源是兩回事
*
* 沒有這一步會出兩種錯
* 全新安裝時建出兩顆同名 D1KBDB 的資料與 credential 目錄從此分家
* 一邊已部署沿用既有另一邊沒有新建一顆空的 半套資料比全壞更難查
*
* @param {PlannedAdopt[]} adopt
* @param {PlannedCreate[]} create
* @param {Map<string, BindingRequirement[]>} byKey
* @returns {PlannedCreate[]}
*/
function shareSameResource(adopt, create, byKey) {
/** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
const declaredName = (kind, binding) =>
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
/** @type {PlannedCreate[]} */
const out = [];
/** @type {Map<string, PlannedCreate>} */
const groups = new Map();
for (const c of create) {
const groupKey = `${c.kind} ${c.createName}`;
// ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。
const twin = adopt.find(
(a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
);
if (twin) {
adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
continue;
}
// ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。
const head = groups.get(groupKey);
if (head) {
head.alsoBind.push(c.binding);
head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
continue;
}
groups.set(groupKey, c);
out.push(c);
}
return out;
}
/**
* plan 動手沿用的原樣帶出來該建的才建
* 有任何 blocker 直接丟 ResourcePlanBlocked**一顆都不建**
*
* @param {ResourceApi} api
* @param {ResourcePlan} plan
* @returns {Promise<Map<string, ResolvedResource>>}
*/
export async function applyResourcePlan(api, plan) {
if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);
/** @type {Map<string, ResolvedResource>} */
const out = new Map();
for (const a of plan.adopt) {
out.set(bindingKey(a.kind, a.binding), {
kind: a.kind,
binding: a.binding,
value: a.value,
origin: 'adopted',
from: a.from,
});
}
/** @type {string[]} */
const madeSoFar = [];
for (const c of plan.create) {
/** @type {string} */
let value;
try {
if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
else value = await api.createVectorizeIndex(c.createName);
} catch (e) {
// 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
// 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。
const orphans = madeSoFar.length > 0
? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)`
: '';
throw new Error(`${KIND_LABEL[c.kind]}${c.createName}」失敗:${msg(e)}${orphans}`);
}
madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
for (const binding of [c.binding, ...c.alsoBind]) {
out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────
/**
* wrangler.toml table 資源種類需求解析與注入共用同一張表兩邊才不會對不上
* @type {Record<string, ResourceKind>}
*/
export const TABLE_KIND = {
kv_namespaces: 'kv_namespace',
d1_databases: 'd1',
vectorize: 'vectorize',
};
/**
* wrangler.toml 抽出這顆 worker 需要哪些資源綁定
*
* 刻意寫成行掃描而不引 TOML parser注入端injectWranglerConfig本來就是純文字操作
* 兩邊用同一種視角看這份檔案才不會對不上註解掉的區塊**不算需求**
* kbdb `[[vectorize]]` 預設是註解狀態要開語義查詢時才會被取消註解 那時才成為需求
*
* 也是零依賴的一部分不引 TOML parser 安裝器 import 這支不必多裝任何東西
*
* @param {string} toml
* @returns {WranglerRequirements}
*/
export function parseWranglerRequirements(toml) {
let script = '';
let seenTable = false;
/** @type {WranglerRequirements['bindings']} */
const bindings = [];
/** @type {ResourceKind | null} */
let kind = null;
let binding = '';
let createName = '';
const flush = () => {
if (kind && binding) {
bindings.push({ kind, binding, createName: createName || binding });
}
kind = null;
binding = '';
createName = '';
};
for (const raw of toml.split('\n')) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
if (table) {
flush();
seenTable = true;
kind = TABLE_KIND[table[1]] ?? null;
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
if (!kv) continue;
const [, key, value] = kv;
if (!seenTable && key === 'name') {
script = value;
continue;
}
if (!kind) continue;
if (key === 'binding') binding = value;
// 只有 D1Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。
else if (key === 'database_name' || key === 'index_name') createName = value;
}
flush();
return { script, bindings };
}
// ─────────────────────────────────────────────────────────────────────────────
// Cloudflare `/settings` 回應 → 事實(兩條路都要用同一種眼睛看)
// ─────────────────────────────────────────────────────────────────────────────
/**
* CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
* 同一種資源在不同 API 版本欄位名不一故全都收
*
* @typedef {object} RawWorkerBinding
* @property {string} [type]
* @property {string} [name]
* @property {string} [namespace_id]
* @property {string} [id]
* @property {string} [database_id]
* @property {string} [index_name]
* @property {string} [text] `plain_text` 綁定的值#106secret_text 不會回值本來就讀不到也不該讀
*/
/**
* CF binding 陣列收斂成規則認得的三種資源不認得的型別直接略過
*
* 🔴 這支**刻意放在規則裡**不留在各自的 CF client
* 什麼才算這顆 worker 綁著某顆資源是規則的一部分
* 兩條路各自解讀 CF 回應 = 漂移會從這裡長回來例如一邊認 `namespace_id`
* 另一邊只認 `id`於是一邊看得到綁定另一邊看不到 後者又去新建了
*
* @param {RawWorkerBinding[]} raw
* @returns {LiveBinding[]}
*/
export function normalizeLiveBindings(raw) {
/** @type {LiveBinding[]} */
const out = [];
for (const b of raw) {
if (!b?.name) continue;
if (b.type === 'kv_namespace') {
const value = b.namespace_id ?? b.id;
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
} else if (b.type === 'd1' || b.type === 'd1_database') {
const value = b.id ?? b.database_id;
if (value) out.push({ kind: 'd1', binding: b.name, value });
} else if (b.type === 'vectorize') {
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
}
}
return out;
}
/**
* 抽出已部署 worker 上的 `plain_text` var#106
*
* 只收 `plain_text`**`secret_text` 一律不碰**CF 本來就不回值也不該被搬來搬去
* wrangler deploy 不會動 secret它們自己會留著
*
* @param {RawWorkerBinding[]} raw
* @returns {Record<string, string>}
*/
export function normalizeLiveVars(raw) {
/** @type {Record<string, string>} */
const out = {};
for (const b of raw) {
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
}
return out;
}
+60
View File
@@ -0,0 +1,60 @@
// @ts-check
/**
* demo.mjs 安裝器那條路的**可獨立執行**證明
*
* node shared/resource-rule/tests/demo.mjs
*
* 這支只 import `shared/resource-rule/`**沒有 node_modules沒有建置步驟**
* 跑得起來本身就是安裝器把 repo archive 拉下來就能直接用這句話的證據
* 對照組`acr` 那條要先 npm ci + TS 轉譯才跑得動兩條路差在外殼判斷是同一份
*
* 三種情境各跑一次印出每個 binding 選到哪顆資源以及這一趟建了幾顆
*/
import { resolveInstanceResources } from '../installer-entry.mjs';
import { makeAccount, SCENARIOS, WORKER_NEEDS } from './fixture-account.mjs';
/** 用 fixture 的需求組出各 worker 的 wrangler.toml 內容。 */
function tomls() {
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
for (const d of need.d1) {
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
}
return t;
});
}
const order = /** @type {const} */ (['fresh', 'installed', 'renamed']);
console.log('安裝器那條路(只 import shared/resource-rule/,零依賴、零建置)\n');
for (const scenario of order) {
const mode = scenario === 'fresh' ? 'init' : 'update';
const account = makeAccount(scenario);
const r = await resolveInstanceResources({
accountId: 'acct-demo',
apiToken: 'tok-demo',
wranglerTomls: tomls(),
mode,
fetch: account.fetch,
});
console.log(`── ${scenario}mode=${mode}):${SCENARIOS[scenario].label}`);
if (r.blocked) {
console.log(' ⛔ 停手,一顆資源都沒建:');
for (const b of r.blockers) console.log(`${b}`);
console.log('');
continue;
}
for (const key of Object.keys(r.bindings).sort()) {
console.log(` ${key.padEnd(30)}${r.bindings[key].padEnd(26)} ${r.origin[key]}`);
}
console.log(
` 本趟新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆、` +
`Vectorize ${account.created.vectorize.length}` +
`|沿用既有版本標籤 ARCRUN_BUNDLE_VERSION=` +
`${r.liveVars['arcrun-cypher-executor']?.ARCRUN_BUNDLE_VERSION ?? '(無,全新安裝)'}\n`,
);
}
@@ -0,0 +1,202 @@
// @ts-check
/**
* fixture-account.mjs 一個假的 Cloudflare 帳號做成 **`fetch` 替身**
*
* 為什麼是 fetch 替身不是假的 ResourceApi 物件
* 本票要證的是`acr` 那條與安裝器那條跑出來的決定必須一致
* 如果兩條路各自餵一個假的 `ResourceApi`那就只測到了 `rule.mjs` 的判斷
* **完全跳過了怎麼把 CF 回應讀成事實** Arcrun#97 的重演只需要眼睛不一樣就夠了
* 一邊把 404 當錯誤一邊漏認 `namespace_id`
* `fetch` 這一層假起兩條路就是真的走完整條鏈HTTP 解析 判斷
*
* 零依賴 ESMNode Workers 都能跑
*/
/** arcrun 各 worker 在 wrangler.toml 裡宣告的 KV binding 名(= 需求,不是資源名)。 */
export const KV_BINDINGS = [
'WEBHOOKS', 'CREDENTIALS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV',
'ANALYTICS_KV', 'EXEC_CONTEXT', 'SUBMISSIONS_KV', 'OAUTH_KV',
];
/** 這台實例上有資源綁定的四顆 worker,以及各自需要的綁定。 */
export const WORKER_NEEDS = {
'arcrun-cypher-executor': {
kv: ['EXEC_CONTEXT', 'WEBHOOKS', 'CREDENTIALS_KV', 'ANALYTICS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV'],
d1: [{ binding: 'CREDENTIALS_DB', database_name: 'arcrun-kbdb' }],
},
'arcrun-registry': { kv: ['SUBMISSIONS_KV', 'ANALYTICS_KV'], d1: [] },
'arcrun-mcp': { kv: ['OAUTH_KV'], d1: [] },
'arcrun-kbdb': { kv: [], d1: [{ binding: 'DB', database_name: 'arcrun-kbdb' }] },
};
/**
* WORKER_NEEDS 攤成 `BindingRequirement[]`兩條路都用**同一份需求**進去
* 才能證明差異如果有來自實作而不是輸入
* @returns {Array<{kind: 'kv_namespace'|'d1', binding: string, worker: string, createName: string}>}
*/
export function requirements() {
const out = [];
for (const [worker, need] of Object.entries(WORKER_NEEDS)) {
for (const b of need.kv) out.push({ kind: 'kv_namespace', binding: b, worker, createName: b });
for (const d of need.d1) {
out.push({ kind: 'd1', binding: d.binding, worker, createName: d.database_name });
}
}
return out;
}
/**
* 三種情境`titleFor` 決定使用者帳號上那顆資源實際叫什麼名字
* 這正是 #97 的病根所在規則**不准**拿名字當識別
*
* @typedef {'fresh' | 'installed' | 'renamed'} Scenario
*/
/** @type {Record<Scenario, {label: string, deployed: boolean, titleFor: (binding: string) => string}>} */
export const SCENARIOS = {
fresh: {
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
deployed: false,
titleFor: (b) => b,
},
installed: {
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>',
deployed: true,
titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`,
},
renamed: {
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
deployed: true,
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
},
};
/**
* 建一個假帳號 + 對應的 `fetch` 替身
*
* @param {Scenario} scenario
* @returns {{
* fetch: typeof globalThis.fetch,
* created: {kv: string[], d1: string[], vectorize: string[]},
* userData: {workflows: string[], sessions: string[], libraries: string[]},
* kvIdFor: (binding: string) => string | undefined,
* d1Id: string,
* requestLog: string[],
* }}
*/
export function makeAccount(scenario) {
const spec = SCENARIOS[scenario];
/** title → id */
const kv = new Map();
/** name → uuid */
const d1 = new Map();
/** @type {string[]} */
const vectorize = [];
/** script → CF `/settings` 回應裡的 bindings[] 原始形狀 */
const scripts = new Map();
const created = { kv: [], d1: [], vectorize: [] };
const requestLog = [];
// 使用者的東西——驗「更新完還在不在」用。掛在資源 id 上,不是掛在名字上。
const userData = {
workflows: ['webhook:leo:daily-digest', 'webhook:leo:inbox-sync', 'webhook:leo:rag-ingest'],
sessions: ['session:leo-abc123'],
libraries: ['general', '課程', '客戶', '研究'],
};
const kvIdByBinding = new Map();
const D1_ID = 'd1id-kbdb-REAL';
if (spec.deployed) {
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
for (const b of KV_BINDINGS) {
const id = `kvid-${b.toLowerCase()}-REAL`;
kv.set(spec.titleFor(b), id);
kvIdByBinding.set(b, id);
}
d1.set('arcrun-rag-yuga3bse-kbdb', D1_ID);
// 已部署的 worker 上綁著它們——**這才是規則要看的事實**
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
const bindings = [];
for (const b of need.kv) {
bindings.push({ type: 'kv_namespace', name: b, namespace_id: kvIdByBinding.get(b) });
}
for (const d of need.d1) bindings.push({ type: 'd1', name: d.binding, id: D1_ID });
// #106plain_text var 也在同一份回應裡
bindings.push({ type: 'plain_text', name: 'ARCRUN_BUNDLE_VERSION', text: '1.4.33' });
scripts.set(script, bindings);
}
}
/** @param {unknown} result @param {number} [status] */
const ok = (result, status = 200) =>
new Response(JSON.stringify({ success: true, result, errors: [] }), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** @param {string} message @param {number} status */
const fail = (message, status) =>
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
status,
headers: { 'Content-Type': 'application/json' },
});
/** @type {typeof globalThis.fetch} */
// @ts-expect-error — 測試替身只實作用得到的那幾條路徑
const fakeFetch = async (input, init) => {
const url = new URL(typeof input === 'string' ? input : String(input));
const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, '');
const method = (init?.method ?? 'GET').toUpperCase();
requestLog.push(`${method} ${path}${url.search}`);
const body = init?.body ? JSON.parse(String(init.body)) : null;
// 已部署 worker 的綁定
const m = path.match(/^\/workers\/scripts\/([^/]+)\/settings$/);
if (m && method === 'GET') {
const script = decodeURIComponent(m[1]);
if (!scripts.has(script)) return fail('workers.api.error.script_not_found', 404);
return ok({ bindings: scripts.get(script) });
}
if (path === '/storage/kv/namespaces' && method === 'GET') {
return ok([...kv].map(([title, id]) => ({ id, title })));
}
if (path === '/storage/kv/namespaces' && method === 'POST') {
const id = `kvid-NEW-${created.kv.length + 1}`;
kv.set(body.title, id);
created.kv.push(body.title);
return ok({ id, title: body.title });
}
if (path === '/d1/database' && method === 'GET') {
return ok([...d1].map(([name, uuid]) => ({ uuid, name })));
}
if (path === '/d1/database' && method === 'POST') {
const uuid = `d1id-NEW-${created.d1.length + 1}`;
d1.set(body.name, uuid);
created.d1.push(body.name);
return ok({ uuid, name: body.name });
}
if (path === '/vectorize/v2/indexes' && method === 'GET') {
return ok(vectorize.map((name) => ({ name })));
}
if (path === '/vectorize/v2/indexes' && method === 'POST') {
vectorize.push(body.name);
created.vectorize.push(body.name);
return ok({ name: body.name });
}
return fail(`fixture 沒有實作這條路徑:${method} ${path}`, 501);
};
return {
fetch: fakeFetch,
created,
userData,
kvIdFor: (binding) => kvIdByBinding.get(binding),
d1Id: D1_ID,
requestLog,
};
}
-1
View File
@@ -341,7 +341,6 @@ arcrun 不自管加密金鑰,`crypto_decrypt` host function 已成永遠回失
|------|--------|------|------|
| ~~**credential 注入 401**~~ | ✅ 已解 | **8.1-8.5 全完成(2026-06-25 確認)** | 機制(auth_static_key `resolve_credentials` + graph-executor `resolveCredentialRefs`)已端到端實證:2026-06-13 Notion `{{credential.notion_token}}` 真讀到資料(同等於 8.5 OpenAI 驗收,機制與服務無關)。tasks.md 8.5 已補 `[x]` |
| §8 P1/P2 recipe/workflow list 遷 D1 | 🔴 高 | 架構已拍板未動 code | 走 kbdb /entries HTTP 雙寫不加 binding;依賴 D1(現已可建)。另開 session 做 |
| 零件接收緩衝區有硬上限(http_request 64KBclaude_api 1MB | 🟡 中 | 訊息已誠實(Arcrun#92),**上限本身還在** | 回應超過上限=真的抓不回來。修的是「以前說成 HTTP request failed」,現在改說「回應太大+實際/上限大小+改用分頁」。host↔零件走**容量握手**(零件先把 outBuf 長度寫進 `*outLenPtr`host 塞不下回 `HOST_TOO_LARGE=3`,見 `wasi-shim.ts`)。要真的支援大回應得另外設計(分頁/串流),不是調大 buffer 就好 |
| 4 份 inline http_request host fn 抽共用 helper | 🟡 中 | 待 dedup | http_request/claude_api/kbdb_upsert_block/km_writer 各自複製貼上同段(這次假綠修也是逐份改) |
| `arcrun.dev/llms.txt` 404 | 🟡 中 | 未 serve | landing/public 缺檔;GitHub repo 內正常(test/5 走 GitHub 不阻擋) |
| MCP account-source | 🟡 中 | 記錄中 | self-hosted MCP 指官方不指自己(§5.2 已知) |