Compare commits

..

20 Commits

Author SHA1 Message Date
uncle6me-web 525faaf5d0 fix(cypher-executor): /cypher/search 不再誤報執行期原生零件 not_found(Arcrun#88)
病因:/cypher/search 只查 component registry(SUBMISSIONS_KV,經 submitComponent/
index-only 才有記錄);而 component-loader.ts 能直接解析、從不查 registry 的一整類
零件(trigger_workflow/BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS,
如 if_control/http_request/switch)從未被 submit 過。leo21c 實例實測:
GET /components/catalog 回 404「零件 catalog 不存在」,search 因此對這些零件誠實地
回「兩庫都查過沒有」——但它們其實跑得動(leo 08-11 探測工作流已證)。

修法:從 component-loader.ts 匯出 RUNTIME_NATIVE_COMPONENT_IDS(既有三份執行期
解析白名單的聯集,非新清單),search-nodes.ts 在查 registry 之前先比對,
不受 registry 是否可達/是否已 backfill 影響。真正不存在的零件仍誠實回
not_found/unknown,not_found 的分型建議與相似候選機制不變。

刻意不做:不掃描 registry/components/* 目錄當清單來源——那含已標記待刪的死碼
(km_writer/kbdb_upsert_block),07-30 曾把這類死碼誤灌進 registry;也不投資
SUBMISSIONS_KV 的 backfill 腳本——decisions-summary.md D29 已定調 SUBMISSIONS_KV
併入「KV 退休戰」,不宜再加投資。

測試:cypher-executor/tests/search-nodes-runtime-native.test.ts 7 case 全綠,
複現 registry unreachable/registry 可達但目錄空(leo21c 實例的真實症狀)兩種情境。
全 suite 迴歸:357 pass(較修前 350 pass 多 7 個新測試),既有 14 個失敗與修前
數量、內容完全相同(pre-existing,與本次改動無關)。
2026-08-11 21:51:21 +08:00
uncle6me-web b6ef0f07dc fix(kbdb): 開通 PATCH /kbdb/records/:id proxy + 補三元組 library 補標測試
leo 2026-08-11:貼 wiki 卡「這裏就有 3 個三元組,如果三元組是 0 一定是有錯」——
三元組沒有不見(新式 1,633 筆/舊式 636 筆都還在),問題是地圖看不到:新式三元組
只有 171 筆填了 library slot,其餘因寫入時 template 還沒有該 slot 而被靜默丟棄
(record-crud.ts createRecord 只替「已宣告」的 slot 建值,不是替 caller 傳的
values 建值——順序錯了值就消失,不報錯)。

本次改動:
- cypher-executor/src/routes/kbdb-proxy.ts:補 PATCH /kbdb/records/:recordId。
  基本盤(kbdb/src/routes/records.ts)早有這個端點(mira-dissolve T2.1 的
  updateRecord),但這條 proxy 之前只轉發 GET/POST /kbdb/records,外部(工作流/
  CLI/補標腳本)打不到——能力在,通道沒開,補標三元組只能繞去改表(違 D38)。
  純轉發、無業務邏輯,比照既有 PATCH /kbdb/entries/:id 慣例。
- kbdb/tests/triplet-library-backfill.test.ts:真 node:sqlite 驗三件事——
  ①源頭順序(ensure-slot 必須在 write 之前,事後補救不了已寫的那筆,重現+
  驗證正解)②存量補標(地圖輸出前後對照)③不會重複做(同批跑兩次,第二次
  touch 0 筆)。順手記錄一個相關但不在本次範圍的小落差:liveTripletCountsByLibrary
  的 'general' fallback 桶與 recomputeLibraryMap 的精確比對語意沒對齊。
- cypher-executor/tests/kbdb-records-patch-proxy.test.ts:新端點的租戶閘/
  參數驗證/轉發/404 透傳。

全數綠燈:kbdb 181/181、cypher-executor 新增 8 案全過(既有 14 案失敗為
pre-existing,已用 git stash 比對確認與本次改動無關)。

kbdb-graph-plugin(實際的三元組寫入端 createTriplet/ingestEnvelope)需要同款
修正(library 補進 TRIPLET_SLOTS + 從 source_uri 推導),但該 repo 現行
0 份 active SDD,其 sdd-guard 明確要求人決定要不要開啟——本次不越界代為決定,
留在報告中交回。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:48:31 +08:00
uncle6me-web 507620e313 merge: Arcrun 統一編譯零件,成品放固定位置 .worker-builds/(Arcrun#80)
總管 2026-08-11 審過後併入:
- 照本 repo 既有慣例(.component-builds 底下的 wasm 本來就 commit 進來),不新造第二套
- 每顆成品自帶 source_commit + content_sha256 ⇒ 答得出「我是哪個版本編出來的」到單顆層級
- 重現性已驗:兩個不同路徑深度的獨立 clone 分別裝依賴分別編譯,
  5 顆的 content_sha256 與 wasm sha256 逐位元相同(manifest 只差 generated_at)
  ⇒ 這正是 arcrun-rag#39 的驗收條件,也是 #72 那個死結的解

待觀察(不擋本次):每次編譯會 commit 數萬行成品進原始碼 repo,長期會脹。
2026-08-11 13:54:19 +08:00
Leo 1e94f8451e Arcrun#80: commit tier2 worker 官方編譯成品到固定位置 .worker-builds/
從本 repo 的 cypher-executor / kbdb / .component-builds/http_request /
registry/components/code / mcp 五個原始碼目錄,用 scripts/build-worker-artifacts.mjs
編出 5 顆成品,commit 進 repo(與既有 .component-builds/*/component.wasm 同一套
「固定位置、任何人直接拿」慣例)。

每顆 manifest 條目自帶 source_commit(該原始碼目錄最後改動的 commit)與
content_sha256,答得出「我是哪個版本的原始碼編出來的」到單顆層級。

重現性已驗證(見 commit 說明外的驗證記錄):同一個 commit 在兩個獨立 clone
(不同磁碟路徑深度,刻意模擬雲端 vs 地端的目錄結構差異)分別裝依賴、分別編譯,
5 顆的 content_sha256 與 wasm module sha256 逐位元相同,manifest.json 唯一差異
是 generated_at 時間戳。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:45:33 +08:00
Leo dcb6ad693b fix(build-worker-artifacts): dirty 判斷排除自己的輸出目錄
.worker-builds/ 是本腳本的輸出,在成品寫出、commit 之前永遠是 untracked——
拿它判斷「原始碼乾不乾淨」是自己把自己判成髒的假陽性。git status pathspec
排除 .worker-builds 後才是「原始碼有沒有未 commit 變更」的真實訊號。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:45:33 +08:00
Leo 3b0238bb28 Arcrun#80: 新增 tier2 worker 官方編譯腳本(成品固定位置的地基)
背景:安裝器(arcrun-rag)過去自己對 Arcrun 原始碼跑 esbuild,導致同一份原始碼在
不同機器編出不同位元組(見 arcrun-rag changelog 1.4.33 段、arcrun-rag#72):
① esbuild 把入口路徑寫進產物內部註解,該路徑預設相依於執行時 cwd
② 各 worker 目錄用不同套件管理器裝 node_modules,夾帶不同版本間接依賴

本腳本解法:
- absWorkingDir 固定為「本腳本自己算出的 repo 根目錄」,entry 一律用相對路徑餵給
  esbuild——不管 clone 放在磁碟哪個絕對路徑,esbuild 內部產生的相對路徑字串相同
- 不自己跑 install,強制要求呼叫者先用該目錄既有 lockfile 裝好依賴(pnpm frozen /
  npm ci),避免「install 方式不同 → 依賴版本不同 → 位元組不同」
- 每顆 worker 的 manifest entry 自帶 source_commit(該目錄最後改動的 commit),
  答到單顆層級,不是整包一個 source 欄位

涵蓋 5 顆 tier2 worker(與 arcrun-rag bundle-components.mjs CORE_COMPONENTS 對齊):
cypher-executor / kbdb / http_request / code / mcp。

scripts/ 自帶 package.json + pnpm-lock.yaml 鎖 esbuild 版本(0.24.0)——
工具版本本身也是重現性的輸入之一。

驗證:本地跑通 5/5 build;byte-identical 重現性驗證見後續 commit(兩個獨立 clone
比對 sha256)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:45:33 +08:00
uncle6me-web 788295ed71 merge: 忘記密碼的錯誤訊息改白話+健康檢查回報寄信有沒有接上(arcrun-rag#38/#69/#25)
總管 2026-08-11 審過後併入:
- 拿掉行話「實例」;拿掉「請管理員直接幫你改密碼」(單人使用者就是管理員=死路,#25 同病)
- 改成給一條他自己走得完的路:重新跑一次安裝/更新
- /health 增回報 mail_relay_configured,讓安裝器判斷得出這台要不要重推
2026-08-11 13:24:34 +08:00
uncle6me-web 797e7f751c fix(portal): 忘記密碼斷點——安裝器從沒注入 PORTAL_MAIL_RELAY_BASE(arcrun-rag#38/#69/#25)
leo 被鎖在自己的系統外面:1.4.35 已出 prod,「忘記密碼」畫面在線上,但按下去回
503「這台實例還沒有設定寄信服務」。根因在 products/arcrun-rag 的安裝器——
`grep -rn PORTAL_MAIL_RELAY installer/` 是零命中,即使 landing 那半(郵差)
D62 已經寫好。這個 repo 這邊改兩件配合修:

- /health 多回 `mail_relay_configured`(布林,不洩漏網址)——安裝器判斷「要不要
  重推」只比 bundle_version,但這個 var 是這次才第一次被注入,跟版本號無關;
  純比版本號的話,已經在最新版的實例(如 leo 那台)永遠不會因為「按更新」而
  重推,這個 var 永遠補不進去。安裝器那邊會讀這個欄位決定要不要強制重推。
- portal.ts 的 503 訊息改白話(不提「實例」)+給一條使用者自己走得完的路
  (重新執行安裝/更新),不再說「請管理員幫你改密碼」——單人使用者的管理員
  就是他自己,那是死路(同源病灶:arcrun-rag#25)。

安裝器那半的修法(PORTAL_MAIL_RELAY_BASE 注入+stale 判斷)在
products/arcrun-rag 同批修(installer/oauth-prototype/worker.js)。

測試:cypher-executor `npx vitest run tests/portal-auth.test.ts tests/health.test.ts`
37/37 綠(35+2,既有測試無回歸;本次未新增測試——改動是訊息文案與健康檢查欄位,
行為已由既有測試覆蓋的路徑保護)。
2026-08-11 12:59:57 +08:00
uncle6me-web d1c44a5878 merge: 忘記密碼與改密碼合成同一個機制(D62,arcrun-rag#69/#25)
總管 2026-08-11 逐筆審過後併入:
- 三筆:8d49d88 機制/9a29eb5 瀏覽器實測抓到的兩個前端缺陷/c76e10d 回歸測試
- #66 的修正 417d69c 在本分支血緣裡,併入不會弄丟已出貨的行為
- 試併零衝突;併入後 cypher-executor 測試 360 項、通過 346
  (14 個失敗與 main 併入前完全相同,是既有紅燈,非本次造成)
- 併入後多出的 8 項全數通過=D62 的回歸測試

⚠️ 併入 main ≠ 出貨。要送到用戶手上仍需走出貨管線並由 leo 解保險。
2026-08-11 11:14:13 +08:00
uncle6me-web a7e23badf2 WIP(kbdb): 關鍵字搜尋改成斷詞——⚠️ 未完成驗證,agent 被中途停止
【為什麼要這個】leo 2026-08-10:「沒有 MCP 你就是瞎的」。
總管實測(有對照組)證明搜尋對 AI 結構上不可用:

  kbdb_search("Gemini 逃生口")  → 0 筆      ← 兩個詞從不相鄰
  kbdb_search("Gemini")         → 864 行    ← 知識明明在
  kbdb_search("local arcrun")   → 5 筆      ← 這兩字在內容裡剛好相鄰

⇒ 現況是拿整個查詢字串去 LIKE,不拆詞。而 AI 問的永遠是問句 ⇒ 永遠回 0。

【這筆做了什麼】CJK/英數分段切詞、CJK 與 ASCII 停用詞、
單詞份量上限(避免長詞獨大)、相對分數截斷;附 search-tokenize.test.ts。

🔴 【誠實聲明】**這筆沒有驗完就被停了**(leo 要求全體停工專心 MCP 進安裝清單)。
未做:前後對照的實際輸出、回歸(原本查得到的不能變查不到)、相關性不崩壞。
**接手的人不要當成已驗證的東西**,先跑那三項再說。

分支保留於 gitea,隨時可接回去。相關:Leo/mira#4 總管留言。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 763de199b5)
2026-08-11 10:02:09 +08:00
Leo eebb691426 merge: 傳播空窗期不再銷毀 session(arcrun-rag#66)
leo 2026-08-10 本人被這個鎖在 stage 外面,並靠修好後的重設流程走回來——
這是「收的人自己驗到」,不是我們自己測過。

病:認證的家是 Workers Secret,改它會產生 worker 新版本,既有 isolate 讀到舊 env。
舊碼在那個空窗裡讀不到 record 就直接 SESSIONS_KV.delete()
⇒ 帶著完全有效的 token,登入狀態被當場銷毀,secret 鋪開也回不來。
#55 補的加速器重試只加在登入路徑,session 驗證這道門沒有。

實測空窗約 45 秒(不是 #55 記的 ≥15 秒):
改完密碼後舊密碼一路到 t+44.1s 仍登得進去,t+47.1s 才開始 401。

三段修法:
1. 先問加速器再判定(與登入路徑同一支 hydrateFromAccelerator)
2. 永不因「讀不到」刪 session——刪是 best-effort 清潔工,清掉的卻是使用者唯一的憑據
3. 仍讀不到且在空窗 → 503 auth_store_propagating,不是 401
   (前端看到 401 就清 localStorage,後端不刪也沒用)
前端 boot() 收斂成「只有 401 才算被登出」。

驗證:stage 改密碼後同一 token 打 90 秒 → 200x30 / 401x0;瀏覽器實跑仍在站內;
tests/portal-auth.test.ts 27/27 綠。
2026-08-10 14:19:04 +00:00
Claude c76e10d314 test(portal): 補 #66 與 D62 的回歸測試(8 條,35/35 綠)
#66(三條,斷言的是「session 有沒有被刪掉」而不只是狀態碼)
- 傳播空窗(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,**KV 那筆還在**
- 非空窗+讀不到 record → 401 擋下,**KV 那筆仍然還在**(刪是清潔工,清掉的卻是唯一憑據)
- session 內容本身壞掉 → 401 且**該刪**(那是確定的事實,不是暫時讀不到)
  ⇒ 前兩條在舊碼上必紅:舊碼在這兩個情況都會 SESSIONS_KV.delete()

D62(五條)
- 帶 reset_token 呼叫 /portal/password/change:**不必登入、不必現有密碼**;
  且「先刪再回」=同一條連結第二次必定 400 reset_token_invalid,KV 裡也真的沒了
- 先「看一眼」票(GET /portal/password/reset)不會消耗它
- 亂猜 / 格式不對的 token → 400
- 沒 reset_token 又沒登入 → 401(修改密碼那一格仍要身分)
- 沒設代寄服務 → /portal/password/forgot 誠實 503 mail_relay_not_configured(不假裝寄出去了)

擺放順序有註解說明:#66 那組會故意把 per-isolate overlay 灌成空的(模擬空窗),
而 overlay 是模組級全域變數不隨 test 重置,故必須排在檔案最後。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
2026-08-10 13:55:53 +00:00
Claude 9a29eb5af6 fix(portal): 瀏覽器實測抓到的兩個前端缺陷——連結被路由吃掉、.hide 從來沒有在藏東西
兩個都是「HTTP 200 看不出來、真的開瀏覽器才會現形」的(規則五點五③)。

① `#/reset?token=…` 被路由正規化吃掉
   route() 的第一行是「raw 不在 VIEWS 就換成 HOME」,而 reset 不是站內的 view
   ⇒ 使用者點信裡的連結,網址被改寫成 `#/search`、修改密碼畫面根本沒出現,
     **而且 token 一起被丟掉**——連結一次有效,等於這條連結就這樣廢了。
   改:route() 開頭先看有沒有 reset token,有就走 showReset 並 return。
   (boot() 那條只顧得到「整頁重新載入」;hash 變動這條路以前沒人守。)

② `.hide` 一直只有三條有 scope 的規則(#tabbar .tab / #sidenav .nav / .modebtn)
   沒有通用的 `.hide { display:none }` ⇒ 任何其他元素掛上 class="hide" **完全沒被藏起來**,
   看起來有藏、其實沒藏。實測畫面:登入頁還沒按「忘記密碼」就已經露出 email 欄與「寄出連結」;
   連結模式下「現有密碼」那一格也照樣顯示(D62 的「差別只有一格」當場破功)。
   補通用規則,帶 !important 以蓋過 inline display(#forgot-box 有)。
   既有三種用法意圖一致(都是要藏),行為不變。

stage 瀏覽器實測(youlin,playwright):忘記密碼 → 取連結 → 點進去 →
不輸入現有密碼設新密碼 → 自動回登入頁 → 用新密碼登入成功 → 進站 →
設定頁同一份表單(現有密碼那一格回來)改密碼 → **沒有被踢出去、可以繼續操作**。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
2026-08-10 13:51:41 +00:00
Claude 8d49d883c0 feat(portal): 改密碼與忘記密碼合成同一個機制,用連結不用一次性密碼(D62)
leo 2026-08-10 拍板:「這兩個機制其實是一個機制,可以簡化。」
  修改密碼:輸入**現有的** → 輸入新的 → 覆蓋
  忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
⇒ 同一個畫面、同一條寫入路徑,差別只有「現有密碼」那一格。

後端(cypher-executor/src/routes/portal.ts)
- POST /portal/password/change =**那一支**。帶 reset_token 就走連結那一格(忽略 current),
  沒帶就要登入 + 正確的 current。兩條路在 writeNewPassword 之後完全相同。
  /portal/me/password 保留成**別名轉呼同一支**(不留第二份實作,兩份必然漂移)。
- POST /portal/password/forgot(公開)/GET /portal/password/reset(看票,不消耗)
- 連結的安全性(承 D50,不可退讓):**一次有效**(用掉即刪,先刪再回)、
  **會過期**(KV TTL 30 分鐘)、**與註冊辨識碼不同源**(現場 crypto 亂數,
  只活在本實例 SESSIONS_KV,與 landing SIGNUPS 那組安裝辨識碼毫無關係)。
  KV 存的是 token 的 sha256,不是 token 本身。
- 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。
- 🔴 已否決不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。

中央代寄(arcrun-rag landing 那半在該 repo)
leo 給的職責切法:實例產生連結、管一次性/過期;arcrun.dev 只是郵差。
必須這樣切的硬理由:用戶自己的實例**沒有 send_email binding**,根本寄不了信。
🔴 總管紅線:**絕不把整條 URL 交給郵差**——寄件網域帶 DKIM,肯收「任意 URL+任意 email」
就是一台開放的釣魚中繼,燒的是整個網域信譽、不可逆。
故只交出本實例 origin + 一張回呼票,並新增 POST /portal/password/relay-verify:
郵差**回頭打這個 origin** 問「這張票是你發的嗎」,冒用別人網域會被那台實例自己否認
⇒ 主機屬於呼叫方這件事由郵差親自確認,不是相信宣稱。
信裡的連結落點 GET /portal/password/reset-link(主機刻意=被確認過的那個 origin)。

前端(console-ui/public/portal/index.html)
- 登入頁「忘記密碼」入口,**在 portal 不在 console**(leo:「是對 portal 不是對 console,
  這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」)
- 拆掉登入頁原有的「用管理主控台密碼救援自己」連結與「忘記密碼請聯絡管理員」
  ——兩條都是 D50 已否決的做法(console 與 portal 是同一組帳密的兩個鑰匙孔)。
- #v-reset 殼**沒有自己的密碼欄位**:真正的表單是設定頁那唯一一份 #pw-form,
  進入連結模式時被原封不動搬過去,只切換「現有密碼」那一格顯不顯示。
  同一個表單元素、同一支送出函式 —— D62「同一個畫面」的字面落地。

驗證:tsc 與 baseline 同為 23 個既有錯誤(零新增);27/27 既有測試綠;前端 JS node --check 過。
stage 實測見交付回報。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
2026-08-10 13:23:44 +00:00
Claude 417d69ceb3 fix(portal): 傳播空窗期不再銷毀 session——讀不到 ≠ 這個人不存在(arcrun-rag#66)
leo 2026-08-10 在 youlin stage 改完密碼被登出,之後怎麼登都回不去。
病灶是「這一瞬間讀不到」被當成「這個人不存在」,而且做的是**不可逆**的動作。

認證的家是 CF Workers Secret,改它會產生 worker 新版本,既有 isolate 讀到的
還是舊 env(#55 實測 ≥15 秒)。舊的 requirePortalUser 在那個空窗裡直接把
session 從 KV 刪掉——不是擋下讓你重試,是當場銷毀,等 secret 鋪開也回不來。
#55 補的「讀不到就再問一次加速器」只加在登入路徑(findAndVerifyUser),這道門沒有。

三處修法(缺一不可):

1. requirePortalUser 先問一次加速器再判定(與登入路徑同一招、同一支函式)
2. **永不因「讀不到」刪 session**;仍讀不到且正在傳播空窗 → 回 503
   `auth_store_propagating` 而不是 401(讀得到 record 的「已停用」照舊刪,那是確定的事實)
3. 前端 boot() 從「任何非 2xx 都 dropSession」收斂成**只有 401 才算被登出**
   ——後端不刪、前端卻自己丟掉 localStorage 的 token,症狀一模一樣

順帶修掉同一族的一個資料遺失路徑:mutateAuthStore 舊版拿「可能是舊版 env」當底稿做
read-modify-write,而 writeAuthStore 會重切分片並刪掉多出來的舊分片 ⇒ 底稿若是
「某帳號被建立之前」的版本,那個帳號會在這次寫入中被抹掉且無法還原(secret 是唯一真相源)。
改成先問加速器、再把 env 版與 overlay 版取聯集當底稿;刪除仍有效(fn() 在聯集之後才跑)。

驗證:tsc 與 baseline 同為 23 個既有錯誤(零新增);tests/portal-auth.test.ts 27/27 綠。
stage 實測見交付回報。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
2026-08-10 13:22:34 +00:00
Leo 035e8b255b chore(mcp): stage build 標記對齊實際部署的 commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:51:36 +00:00
Leo 1c630ecfd4 docs(mcp): /health 註解誠實界定——404 只代表比本版舊,不等於舊世代
實測:leo21c 與 geek6688 的 /health 都回 404,但 geek6688 是新世代(email+password)。
原註解會讓下一個人拿 404 去判世代而誤判。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:51:36 +00:00
Leo a99d3e5a3e chore(mcp): 補 stage 部署設定(youlin)——升級 arcrun-mcp 終於有測試場
arcrun-mcp 不在安裝器出貨的那批裡,所以「升級某台的 arcrun-mcp」一直沒有地方先驗,
要驗只能拿 leo21c(唯一一份 47.9 萬筆知識的真身)冒險。本檔把 stage 補上。

順帶把「手動直推 arcrun-mcp 到自架帳號」的兩個坑從人的記性搬進檔案:
① 拿掉 mcp.arcrun.dev route(zone 在 uncle6,自架帳號沒有)
② OAUTH_KV 填真 id(出貨 toml 是佔位符,直推不填 → /authorize 503)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:51:36 +00:00
Leo 894408181f feat(mcp): GET /health — 一條 curl 看出這台是新舊世代 MCP
判斷一台實例的 arcrun-mcp 是哪一代認證,原本只能打 /authorize 剖 HTML 有幾個欄位
(ops-facts 2026-08-10 的土法)。改成誠實版本面:200+auth=portal-login =新世代;
404 =舊世代(同意頁還要那把沒人拿得到的 MCP_OWNER_SECRET ⇒ 等於接不上)。
順帶 build 標記(MCP_BUILD var)讓「這台跑的是哪一版」看得到。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:51:36 +00:00
uncle6me-web d022ca067b merge: kbdb DELETE 誠實回報向量刪除結果(arcrun-rag#46)
總管複核:改法與同檔 /entries/deprecate-by-library 一致(同步 await 再回應),
不動 schema、不加表(守 D38),附 84 行測試。
舊版 fire-and-forget 會把向量刪除失敗靜默吞掉——呼叫端看到「刪除成功」
但語意搜尋還留著殘影,這正是 #46 回報的症狀。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:50:36 +08:00
31 changed files with 61465 additions and 50 deletions
+4
View File
@@ -6,6 +6,10 @@ dist/
# 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署 # 例外:放行 .component-builds 的部署物 wasm — self-host 用戶 / acr init 從 repo 直接拿這份部署
# (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6 # (推翻 rule 05 原「wasm 不 commit」慣例,見 .agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6
!.component-builds/**/component.wasm !.component-builds/**/component.wasm
# 例外:Arcrun#80 tier2 worker 官方編譯成品(cypher-executor/kbdb/http_request/code/mcp 的
# esbuild bundle + 隨附 wasm part)——commit 進 repo 同一套理由:固定位置、any clone 都拿得到,
# 不必自己再編一次(見 scripts/build-worker-artifacts.mjs)。
!.worker-builds/**/*.wasm
# 例外:code 零件(自足 Worker)的 vendored quickjs.wasm 同屬部署物 —— acr init/update 從 # 例外:code 零件(自足 Worker)的 vendored quickjs.wasm 同屬部署物 —— acr init/update 從
# repo archive 直接部署(同上 .component-builds 放行邏輯)。來源=npm 套件 # repo archive 直接部署(同上 .component-builds 放行邏輯)。來源=npm 套件
# @jitl/quickjs-wasmfile-release-sync 的 emscripten-module.wasm,由 postinstall vendor-wasm.mjs # @jitl/quickjs-wasmfile-release-sync 的 emscripten-module.wasm,由 postinstall vendor-wasm.mjs
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-11T05:33:37.988Z",
"repo_head": "d8bbf2241bd6b117d76fb27d9e386ecfb0ffe8f7",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "797e7f751cc42cb1f5d9e2e187f18cf51eb981a1",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 568855,
"content_sha256": "66e2a6341854e8b2de0567a46282b94669e73b95d152b05b17b0f8b58e257fec",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat",
"global_fetch_strictly_public"
],
"requires": {
"kv": [
"EXEC_CONTEXT",
"WEBHOOKS",
"CREDENTIALS_KV",
"ANALYTICS_KV",
"RECIPES",
"USERS_KV",
"SESSIONS_KV"
],
"d1": [
{
"binding": "CREDENTIALS_DB",
"database_name": "arcrun-kbdb"
}
],
"vectorize": 0,
"ai": true,
"vars": {
"ENVIRONMENT": "production",
"CF_ACCOUNT_ID": "",
"WORKER_SUBDOMAIN": "uncle6-me",
"KBDB_BASE_URL": "https://arcrun-kbdb.uncle6-me.workers.dev",
"CONSOLE_TENANT": "leo",
"PORTAL_SESSION_TTL": "604800",
"PORTAL_SHOW_WORKFLOWS": "admin",
"GITEA_BASE_URL": "https://git.uncle6.me",
"GITEA_SPRINT_REPO": "Leo/InkStoneCo",
"GITEA_SPRINT_DIR": "system-dev/docs/3-specs/autonomy-dispatch"
}
},
"stripped": {
"services": 13
},
"warnings": []
},
{
"name": "arcrun-kbdb",
"source_dir": "kbdb",
"source_commit": "a7e23badf2a771be779a861e69e7efa6e8141dfe",
"main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 135910,
"content_sha256": "5e5a7a030f4fd1f5549ace6791c3827b6497b0bfdd9add46af041af47c472905",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [],
"d1": [
{
"binding": "DB",
"database_name": "arcrun-kbdb"
}
],
"vectorize": 0,
"ai": false,
"vars": {
"ENVIRONMENT": "production"
}
},
"warnings": []
},
{
"name": "arcrun-http-request",
"source_dir": ".component-builds/http_request",
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
"main_module": "worker.mjs",
"main_file": "arcrun-http-request/worker.mjs",
"js_bytes": 80073,
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-http-request/component.wasm",
"sha256": "cc15cc785703e7bbb8dbff2d38dc84a4ac24e2f44316182730abae0f170ef133"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat",
"global_fetch_strictly_public"
],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "http_request"
}
},
"warnings": []
},
{
"name": "arcrun-code",
"source_dir": "registry/components/code",
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
"main_module": "worker.mjs",
"main_file": "arcrun-code/worker.mjs",
"js_bytes": 153671,
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5",
"modules": [
{
"name": "quickjs.wasm",
"type": "application/wasm",
"file": "arcrun-code/quickjs.wasm",
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "code"
}
},
"warnings": []
},
{
"name": "arcrun-mcp",
"source_dir": "mcp",
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
"main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1165130,
"content_sha256": "be15033f32e605f03f69bd10cd87782dafa34dbafeee2ce367bd7361a062a291",
"modules": [],
"compat_date": "2024-11-27",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"OAUTH_KV"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {}
},
"warnings": []
}
],
"notes": []
}
+200 -17
View File
@@ -122,6 +122,12 @@
.modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); } .modebtn { flex: none; padding: 9px 18px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; } .modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
.modebtn.hide { display: none; } .modebtn.hide { display: none; }
/* 🔴 2026-08-10 實測撞到:`.hide` 一直**只有上面那三條有 scope 的規則**#tabbar .tab / #sidenav .nav / .modebtn),
所以任何其他元素掛上 class="hide" 都**完全沒有被藏起來**——看起來有藏、其實沒藏。
D62 的「忘記密碼」欄位與「現有密碼」欄位就是這樣在瀏覽器上原形畢露的。
補一條通用規則;帶 !important 是因為要蓋過 inline 的 display#forgot-box 就有)。
既有那三種用法的意圖與本規則一致(都是「藏起來」),加這條不改變它們的行為。 */
.hide { display: none !important; }
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; } .kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } } @media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; } .kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
@@ -219,12 +225,42 @@ if (!window.ARCRUN_API_BASE) {
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button> <button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button>
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div> <div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
</div> </div>
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div> <!-- 🔴 D50/D62:這裡原本寫「忘記密碼請聯絡管理員重設」+一條「用管理主控台密碼救援自己」
<div style="font-size:13px;color:rgba(var(--ink-rgb),.4);line-height:1.7">你自己就是管理員?<a href="/console/" style="color:var(--amber)">用管理主控台密碼救援自己</a></div> 的 console 連結。**兩條都是已否決的做法**,不准寫回來:
① leo:「我要是記得密碼,還來找忘記密碼幹嘛?」——console 登入頁一樣要密碼
② console 與 portal 是安裝時**同一組帳密**寫進兩個地方(D50 補刀)
⇒ console 不是第二道閘,是同一把鎖的第二個鑰匙孔,往它補洞不會多出任何一條路
③ 唯一管理員忘記密碼時「聯絡管理員」=叫他聯絡他自己
leo 2026-08-10:「**是對 portal 不是對 console**,這樣 youlin 雖然忘記,
我還是可以去 portal 忘記密碼。」⇒ 出口就在這裡。 -->
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">
忘記密碼了?<a href="#" id="login-forgot" style="color:var(--amber)">寄一條「修改密碼」連結給我</a>
</div>
<div id="forgot-box" class="hide" style="display:flex;flex-direction:column;gap:10px;margin-top:4px">
<input type="email" id="forgot-email" class="txt" placeholder="你註冊時用的 Email" autocomplete="username">
<button class="btn" id="forgot-submit">寄出連結</button>
<div id="forgot-status" style="font-size:13.5px;min-height:1.2em;line-height:1.6"></div>
</div>
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button> <button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
</div> </div>
</div> </div>
<!-- 修改密碼(D62):忘記密碼點信裡的連結進來的落點。
🔴 這個殼**刻意沒有自己的密碼欄位**——真正的表單是設定頁那一份 #pw-form,
進到這個模式時被 JS 原封不動搬進 #reset-slot。D62:「同一個畫面、同一條寫入路徑,
差別只有一格:現有密碼是要填、還是被連結豁免。」複製一份長得像的就會漂移。 -->
<div class="authwrap view" id="v-reset">
<div class="authbox">
<div>
<div style="font-size:22px;font-weight:600;margin-bottom:6px">設定新密碼</div>
<div id="reset-who" style="font-size:14px;color:rgba(var(--ink-rgb),.55);line-height:1.7;margin-bottom:14px"></div>
<div id="reset-slot"></div>
<div id="reset-error" class="err" style="font-size:14px;line-height:1.7"></div>
<div style="margin-top:16px;font-size:13.5px"><a href="#/" id="reset-back" style="color:var(--amber)">回登入頁</a></div>
</div>
</div>
</div>
<!-- 首次設定(t49leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號, <!-- 首次設定(t49leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)--> 不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
<div class="authwrap view" id="v-firstsetup"> <div class="authwrap view" id="v-firstsetup">
@@ -375,10 +411,14 @@ if (!window.ARCRUN_API_BASE) {
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div> <div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
</div> </div>
</div> </div>
<div class="panel"> <!-- D62leo 2026-08-10):「改密碼與忘記密碼**這兩個機制其實是一個機制,可以簡化**。」
<div style="font-size:17px;font-weight:600">更改密碼</div> ⇒ 這個表單是**整個站唯一一份**改密碼的表單。忘記密碼點連結進來時,
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div> 它會被原封不動搬到 #v-reset 那個殼裡(不是複製一份長得像的)——
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px"> 差別只有「現有密碼」那一格顯不顯示。同一個畫面、同一條寫入路徑。 -->
<div class="panel" id="st-pw-panel">
<div style="font-size:17px;font-weight:600" id="pw-title">更改密碼</div>
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)" id="pw-sub">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
<div id="pw-form" style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password"> <input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password"> <input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password"> <input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
@@ -746,6 +786,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
} }
var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings }; var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
function route() { function route() {
// 🔴 D62`#/reset?token=…` **不是站內的一個 view**,是「還沒登入的人拿著連結進來」。
// 少了這一段,下面那行正規化會把不認得的 `reset` 換成 HOME ⇒ **連 token 一起丟掉**
// 使用者點信裡的連結會落在搜尋頁、而且再也回不去(連結一次有效,已經耗掉了)。
// 實測(2026-08-10 stage,瀏覽器):網址被改寫成 `#/search`、修改密碼畫面根本沒出現。
var rt = resetTokenFromUrl();
if (rt) { showReset(rt); return; }
var r = currentRoute(); var r = currentRoute();
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; } if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg); if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
@@ -817,11 +863,28 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
} catch (e) { /* 登入殼還沒渲染就算了 */ } } catch (e) { /* 登入殼還沒渲染就算了 */ }
} }
function boot() { function boot() {
// D62:信裡的「修改密碼」連結最優先——這個人**就是進不去才收到這封信的**,
// 不可以因為瀏覽器裡剛好還留著一個舊 token 就把他丟去別的畫面。
var rt = (typeof resetTokenFromUrl === 'function') ? resetTokenFromUrl() : '';
if (rt) { showReset(rt); return; }
if (!S.token) { showAuth(); return; } if (!S.token) { showAuth(); return; }
fetch(API_BASE + '/portal/session', { headers: authHeaders() }) fetch(API_BASE + '/portal/session', { headers: authHeaders() })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) { .then(function (x) {
if (!x.ok) { dropSession(); return; } // 🔴 arcrun-rag#66**只有 401 才算「你被登出了」**。
// 舊版是 `if (!x.ok) dropSession()` 任何非 2xx 都清掉 token——
// 包含改完密碼後那幾十秒的 503(認證 secret 正在鋪開)與 502(KBDB 暫時不可達)。
// 那正是 leo 08-10「改完密碼、重新整理就回不去」的最後一哩:
// 後端就算不刪 KV,前端自己把鑰匙丟了,結果一樣。
if (x.status === 401) { dropSession(); return; }
if (!x.ok) {
// 暫時性故障:留著 session,告訴他這是暫時的、下一步做什麼。
showAuth();
$('login-status').textContent = (x.d && x.d.code === 'auth_store_propagating')
? '認證資料正在更新中(通常幾十秒),請稍候重新整理——你並沒有被登出。'
: '服務暫時不可用,請稍後重新整理(你的登入沒有失效)。';
return;
}
S.profile = x.d; S.profile = x.d;
showApp(); showApp();
}) })
@@ -1185,6 +1248,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
} }
// 任何 data 請求收到 401 → session 失效 → 回登入殼 // 任何 data 請求收到 401 → session 失效 → 回登入殼
//
// 🔴 arcrun-rag#66 的前端那一半:後端在「認證資料正在鋪開」時改回 503
// `auth_store_propagating`(不再回 401、也不再刪 KV 那筆 session)。
// 這裡**必須跟著只認 401**——若前端把任何錯誤都當登出,後端不刪也沒用:
// 使用者手上的 token 會被自己的瀏覽器丟掉,症狀跟被踢出去一模一樣。
// (503 由各呼叫點自己顯示錯誤訊息,session 原封不動。)
function guard401(status) { function guard401(status) {
if (status === 401) { dropSession(); return true; } if (status === 401) { dropSession(); return true; }
return false; return false;
@@ -2022,33 +2091,147 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' + '<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>'; '<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
} }
$('st-pw-save').addEventListener('click', function () { // ══════════ D62:改密碼與忘記密碼=同一個機制 ══════════
//
// leo 2026-08-10:「這兩個機制其實是一個機制,可以簡化。」
// 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋
// 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
// ⇒ 底下只有**一個表單元素**(#pw-form)與**一支送出函式**,差別只有「現有密碼」那一格
// 顯不顯示、以及帶 current 還是帶 reset_token。兩份長得像的東西必然漂移
// arcrun-rag#40「同一個事實兩份」)。
// 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。
var S_RESET_TOKEN = ''; // 非空=現在是「忘記密碼」模式
/** 把那一份唯一的表單搬到指定容器,並決定「現有密碼」那一格出不出現。 */
function mountPasswordForm(host, needCurrent) {
var form = $('pw-form');
if (form && form.parentNode !== host) host.appendChild(form);
$('st-pw-old').classList.toggle('hide', !needCurrent);
$('st-pw-save').textContent = needCurrent ? '更新密碼' : '設定新密碼';
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
$('st-pw-status').textContent = '';
}
/** 送出:兩種模式共用。帶 reset_token 就走連結那一格,否則走現有密碼那一格。 */
function submitPasswordChange() {
var needCurrent = !S_RESET_TOKEN;
var oldPw = $('st-pw-old').value; var oldPw = $('st-pw-old').value;
var newPw = $('st-pw-new').value; var newPw = $('st-pw-new').value;
var newPw2 = $('st-pw-new2').value; var newPw2 = $('st-pw-new2').value;
var st = $('st-pw-status'); var st = $('st-pw-status');
st.className = 'err'; st.className = 'err';
if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; } if (needCurrent && !oldPw) { st.textContent = '請填現在的密碼'; return; }
if (!newPw) { st.textContent = '請填新密碼'; return; }
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; } if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; } if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
var body = { 'new': newPw };
if (S_RESET_TOKEN) body.reset_token = S_RESET_TOKEN; else body.current = oldPw;
$('st-pw-save').disabled = true; $('st-pw-save').disabled = true;
st.textContent = ''; st.textContent = '處理中…';
fetch(API_BASE + '/portal/me/password', { fetch(API_BASE + '/portal/password/change', {
method: 'POST', method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), headers: Object.assign({ 'Content-Type': 'application/json' }, needCurrent ? authHeaders() : {}),
body: JSON.stringify({ current: oldPw, 'new': newPw }) body: JSON.stringify(body)
}) })
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) { .then(function (x) {
$('st-pw-save').disabled = false; $('st-pw-save').disabled = false;
if (guard401(x.status)) return; // 忘記密碼模式沒有 session,401 不代表「你被登出」——別誤踢
if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; } if (needCurrent && guard401(x.status)) return;
if (!x.ok) { st.textContent = (x.d && x.d.error) || '更新失敗'; return; }
st.className = 'ok'; st.className = 'ok';
st.textContent = '密碼已更新';
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = ''; $('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
toast('密碼已更新'); if (S_RESET_TOKEN) {
st.textContent = '密碼已設定好了,正在帶你回登入頁…';
var who = (x.d && x.d.email) || '';
S_RESET_TOKEN = '';
setTimeout(function () {
history.replaceState(null, '', location.pathname);
location.hash = '';
$('v-reset').classList.remove('on');
mountPasswordForm($('st-pw-panel'), true);
showAuth();
if (who) $('login-email').value = who;
$('login-status').className = 'ok';
$('login-status').textContent = '新密碼已生效,請用它登入。';
}, 1200);
} else {
st.textContent = '密碼已更新(你不會被登出,可以繼續操作)';
toast('密碼已更新');
}
}) })
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); }); .catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
}
$('st-pw-save').addEventListener('click', submitPasswordChange);
// ── 登入頁的「忘記密碼」入口(**在 portal,不是 console**leo 2026-08-10)──
$('login-forgot').addEventListener('click', function (ev) {
ev.preventDefault();
$('forgot-box').classList.remove('hide');
$('forgot-email').value = $('login-email').value.trim();
$('forgot-email').focus();
});
$('forgot-submit').addEventListener('click', function () {
var email = $('forgot-email').value.trim();
var st = $('forgot-status');
st.className = 'err';
if (!email) { st.textContent = '請填 Email'; return; }
$('forgot-submit').disabled = true;
st.textContent = '寄送中…';
fetch(API_BASE + '/portal/password/forgot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
})
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
$('forgot-submit').disabled = false;
if (!x.ok) { st.textContent = (x.d && x.d.error) || '寄送失敗'; return; }
st.className = 'ok';
st.textContent = (x.d && x.d.message) || '如果這個 email 有帳號,連結已經寄出了。';
})
.catch(function (e) { $('forgot-submit').disabled = false; st.textContent = friendlyErr(e); });
});
/** 點信裡的連結進來:`…/portal/#/reset?token=…`cypher 的 /portal/password/reset-link 導過來的)。 */
function resetTokenFromUrl() {
var m = String(location.hash || '').match(/[?&]token=([^&]+)/);
if (m) return decodeURIComponent(m[1]);
m = String(location.search || '').match(/[?&]token=([^&]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
function showReset(token) {
$('v-login').classList.remove('on');
$('v-firstsetup').classList.remove('on');
$('shell').classList.remove('on');
$('tabbar').classList.remove('on');
$('v-reset').classList.add('on');
$('reset-error').textContent = '';
// 先問一次「這張票還有效嗎」——連結是一次有效、30 分鐘過期的,
// 過期就要當場說清楚,不要讓人填完密碼才被打回票(#49:別把系統狀態講成用戶的錯)。
fetch(API_BASE + '/portal/password/reset?token=' + encodeURIComponent(token))
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok || !x.d || x.d.valid !== true) {
$('reset-who').textContent = '';
$('reset-error').textContent = (x.d && x.d.error) || '這條連結已經失效了。請回登入頁重新按一次「忘記密碼」。';
return;
}
S_RESET_TOKEN = token;
$('reset-who').innerHTML = '你正在為 <strong>' + esc(x.d.email) + '</strong> 設定新密碼。<br>這條連結只能用一次,<strong>不需要輸入現在的密碼</strong>。';
mountPasswordForm($('reset-slot'), false); // ← 差別只有這一格
})
.catch(function (e) { $('reset-error').textContent = friendlyErr(e); });
}
$('reset-back').addEventListener('click', function (ev) {
ev.preventDefault();
S_RESET_TOKEN = '';
history.replaceState(null, '', location.pathname);
location.hash = '';
$('v-reset').classList.remove('on');
mountPasswordForm($('st-pw-panel'), true);
showAuth();
}); });
boot(); boot();
+26 -3
View File
@@ -1,6 +1,6 @@
import type { ParsedTriplets, NodeRole } from './triplet-parser'; import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole, isVirtualIoName } from './triplet-parser'; import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
import { wasmWorkerUrl } from '../lib/component-loader'; import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader';
import { resolveRecipe } from '../routes/recipes'; import { resolveRecipe } from '../routes/recipes';
import type { RecipeDefinition } from '../routes/recipes'; import type { RecipeDefinition } from '../routes/recipes';
import { branchHintFor } from '../lib/branch-hints'; import { branchHintFor } from '../lib/branch-hints';
@@ -44,8 +44,12 @@ export type NodeInfo = {
status: NodeStatus; status: NodeStatus;
componentId?: string; componentId?: string;
type: NodeRole; type: NodeRole;
/** found 時標來源庫:零件 registrycomponent)或 recipe 庫(recipe)。 */ /**
source?: 'component' | 'recipe'; * found registrycomponentrecipe recipe
* cypher-executor registry builtin
* Arcrun#88component-loader.ts RUNTIME_NATIVE_COMPONENT_IDS
*/
source?: 'component' | 'recipe' | 'builtin';
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */ /** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
input_schema?: unknown; input_schema?: unknown;
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */ /** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
@@ -212,6 +216,25 @@ export async function searchNodes(
continue; continue;
} }
// ── 執行期原生零件(Arcrun#88):查 registry 之前先比對 ──────────────────
// component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDStrigger_workflow
// BUILTIN_COMPONENTSLOGIC_BINDING_MAPWASM_HTTP_RUNNER_IDS 的聯集——
// 這些零件 cypher-executor 自己就能 resolve,從不查 registry,執行期保證解析得動。
// 病史:registry 是空的/未部署新版 `/catalog` 端點時,這批零件(if_control
// http_requestswitch…)會被下面「兩庫都查過沒有」誤判成 not_found——
// 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found,不受 registry 健康狀態影響。
// target=recipe(使用者明確只要查 recipe 庫)不適用——這些從來不是 recipe。
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
nodeResults[nodeName] = {
status: 'found',
componentId,
type: role,
source: 'builtin',
branch_hint: branchHintFor(componentId),
};
continue;
}
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。 // registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。 // **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。 // 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
@@ -88,6 +88,33 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。 // Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
}; };
/**
* vs Arcrun#882026-08-11
*
* `/cypher/search``search-nodes.ts` component registry`SUBMISSIONS_KV`
* `submitComponent`/`index-only` 0/1/5/7
* ** registry** trigger_workflowBUILTIN_COMPONENTS
* LOGIC_BINDING_MAPWASM_HTTP_RUNNER_IDS submit
* cypher-executor 稿 leo21c `/components/catalog`
* 404registry search `if_control``http_request`
* leo 08-11
*
* search-nodes.ts registry
* **** registry backfill
*
* `registry/components/*`
* `km_writer``kbdb_upsert_block`
* `system-dev/docs/3-specs/arcrun-usable/cleanup-dead-code.md`07-30
* registryleo ** resolve **
* 1/5/7
*/
export const RUNTIME_NATIVE_COMPONENT_IDS: ReadonlySet<string> = new Set([
'trigger_workflow',
...BUILTIN_COMPONENTS.keys(),
...Object.keys(LOGIC_BINDING_MAP),
...WASM_HTTP_RUNNER_IDS,
]);
export function createComponentLoader(env: Bindings) { export function createComponentLoader(env: Bindings) {
return async (componentId: string): Promise<ComponentRunner> => { return async (componentId: string): Promise<ComponentRunner> => {
+51 -3
View File
@@ -143,7 +143,18 @@ export function authStoreWritable(env: Bindings): boolean {
*/ */
export function readAuthStore(env: Bindings): AuthStoreData { export function readAuthStore(env: Bindings): AuthStoreData {
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay; if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
return readAuthStoreFromEnv(env);
}
/**
* `env` ** overlay**
*
* #66 read-modify-write overlay env
* overlay isolate
* env **** isolate
* secret ****
*/
function readAuthStoreFromEnv(env: Bindings): AuthStoreData {
const bag = env as unknown as Record<string, unknown>; const bag = env as unknown as Record<string, unknown>;
const out = emptyStore(); const out = emptyStore();
for (const name of shardNames(env)) { for (const name of shardNames(env)) {
@@ -268,13 +279,50 @@ export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
} }
} }
/** 讀出來 → 改 → 寫回去(同一支,避免各處自己拼 read/modify/write)。 */ /**
* ****
*
* 🔴 #66
* - 401
* - secret isolate ** session**
* key `ACCEL_TTL_SECONDS`
* KV false退
*/
export async function authStoreRecentlyWritten(env: Bindings): Promise<boolean> {
try {
return Boolean(await env.SESSIONS_KV.get(ACCEL_KEY));
} catch {
return false;
}
}
/** 兩份 store 取聯集:同一個 id 以 `updated_at` 新者為準;只在一邊出現的一律保留。 */
function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
const byId = new Map<string, AuthUserRecord>();
for (const u of [...a.users, ...b.users]) {
const prev = byId.get(u.id);
if (!prev || (u.updated_at ?? '') >= (prev.updated_at ?? '')) byId.set(u.id, u);
}
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
}
/**
* read/modify/write
*
* 🔴 #66**** `readAuthStore(env)` 稿 `writeAuthStore`
* 稿
* ****secret
*
*
* env overlay ****稿
* `fn()` ****
*/
export async function mutateAuthStore( export async function mutateAuthStore(
env: Bindings, env: Bindings,
fn: (data: AuthStoreData) => void | Promise<void>, fn: (data: AuthStoreData) => void | Promise<void>,
): Promise<AuthStoreData> { ): Promise<AuthStoreData> {
const data = readAuthStore(env); await hydrateFromAccelerator(env);
const next: AuthStoreData = { version: 1, console: data.console, users: [...data.users] }; const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
await fn(next); await fn(next);
await writeAuthStore(env, next); await writeAuthStore(env, next);
return next; return next;
+14
View File
@@ -98,6 +98,20 @@ export function randomHex(bytes: number): string {
.join(''); .join('');
} }
/**
* SHA-256 hex**** token KV keyD62
*
* token key token **** key
* KV KV
* digest rule 2.2 `crypto.subtle.decrypt` / RSASSA
*/
export async function sha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** /**
* admin reset-password / * admin reset-password /
* 16 + 93 bits * 16 + 93 bits
+6
View File
@@ -21,6 +21,12 @@ healthRouter.get('/health', (c) => {
ok: true, ok: true,
...(bundleVersion ? { bundle_version: bundleVersion } : {}), ...(bundleVersion ? { bundle_version: bundleVersion } : {}),
auth_store: authStoreStatus(c.env), auth_store: authStoreStatus(c.env),
// arcrun-rag#38/#69/#252026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
// 版本無關(同一個 cypher 版本,有的實例有這個 var、有的沒有)。純比版本號的話,
// 已經在最新版的實例(如 leo 自己那台)永遠不會因為「按更新」而重推,這個 var
// 就永遠補不進去。只回布林(有沒有設,不回值本身)——不洩漏郵差網址。
mail_relay_configured: Boolean(String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()),
}); });
}); });
+21
View File
@@ -119,6 +119,27 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}); });
// PATCH /kbdb/records/:recordId — 翻某筆 record 的 slot 值({ values:{slot:content} })。
// 補上基本盤既有能力(kbdb/src/routes/records.ts 的 PATCH /records/:recordIdmira-dissolve T2.1
// 缺的對外通道——2026-08-11 leo 三元組 library 補標核實:base 早有這個端點,但這條 proxy
// 之前只轉發 GET/POST,插件/工作流打不到,補標三元組只能繞去改表(違 D38)。單純轉發,無業務邏輯。
// by-id 沿用既有慣例(require-key,不額外做 owner 比對——與本檔 GET .../:recordId、
// PATCH /kbdb/entries/:id 同款)。
kbdbProxyRouter.patch('/kbdb/records/:recordId', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || typeof body.values !== 'object' || body.values === null) {
return c.json({ error: 'values 必填({slot名: 內容}' }, 400);
}
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param('recordId'))}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ values: body.values }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// ── search(限本租戶範圍內)──────────────────────────────────────────────────── // ── search(限本租戶範圍內)────────────────────────────────────────────────────
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。 // GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
+337 -20
View File
@@ -23,7 +23,7 @@ import type { Context } from 'hono';
import type { Bindings } from '../types'; import type { Bindings } from '../types';
import { kbdbBase } from './kbdb-proxy'; import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth'; import { validateConsoleSession } from './console-auth';
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth'; import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds'; import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑, // arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。 // 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
@@ -33,6 +33,7 @@ import { storeCredential, hasCredential } from './credentials';
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。 // KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
import { import {
AuthStoreWriteError, AuthStoreWriteError,
authStoreRecentlyWritten,
authStoreStatus, authStoreStatus,
findAuthUserByEmail, findAuthUserByEmail,
findAuthUserById, findAuthUserById,
@@ -413,6 +414,23 @@ export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Resp
/** /**
* portal session token KV record_id ** record** status=active * portal session token KV record_id ** record** status=active
* design §4.3/ session KVbest-effort * design §4.3/ session KVbest-effort
*
* 🔴 `Leo/arcrun-rag#66`2026-08-10leo stage
* ****
* CF Workers Secret worker
* ** isolate env**`#55` 15
* `getRecordById` ** session KV ** 401
* ** token** secret
* `#55` `findAndVerifyUser`
*
*
* 1. **** `hydrateFromAccelerator`
* 2. ** session** best-effort 使
* KV TTL
* 3. **** key **503 `auth_store_propagating`**
* 401portal `guard401()` 401 localStorage
* KV 使 token
* ****
*/ */
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> { export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
const token = bearerToken(c); const token = bearerToken(c);
@@ -426,15 +444,33 @@ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Pro
/* fallthrough */ /* fallthrough */
} }
if (!recordId) { if (!recordId) {
// 這一筆 session 的內容本身壞掉=確定的事實(不是讀不到),刪它是對的。
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
} }
const rec = await getRecordById(c.env, recordId); let rec = await getRecordById(c.env, recordId);
if (!rec && (await hydrateFromAccelerator(c.env))) {
rec = await getRecordById(c.env, recordId); // ①:與登入路徑同一招,再問一次加速器
}
if (!rec) { if (!rec) {
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); // ③:分辨「傳播空窗」與「帳號真的沒了」——前者不可以把人踢出去。
if (await authStoreRecentlyWritten(c.env)) {
return {
ok: false,
res: c.json(
{
error: '認證資料正在更新中(Cloudflare 正在鋪開新版本),請稍候幾秒再試——你並沒有被登出。',
code: 'auth_store_propagating',
},
503,
),
};
}
// ②:查無此帳號(可能真的被刪了)→ 擋下即可,**不刪 session**KV TTL 自己會回收)。
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
} }
if ((rec.values.status ?? '') !== 'active') { if ((rec.values.status ?? '') !== 'active') {
// 讀得到 record = 確定的事實,停用要即時生效,刪 session 是對的。
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
return { ok: false, res: c.json({ error: '帳號已停用' }, 403) }; return { ok: false, res: c.json({ error: '帳號已停用' }, 403) };
} }
@@ -679,29 +715,310 @@ portalRouter.get('/portal/session', (c) =>
}), }),
); );
// POST /portal/me/password — body {current, new}。驗舊密改新密。 // ═════════════════ D62:改密碼與忘記密碼是**同一個機制** ═══════════════════════
portalRouter.post('/portal/me/password', (c) => //
run(c, async () => { // leo 2026-08-10 拍板(頂層 decisions-summary D62):
const auth = await requirePortalUser(c); // 「這兩個機制其實是一個機制,可以簡化。」
if (!auth.ok) return auth.res; // - 修改密碼:到「修改密碼」→ 輸入**現有的** → 輸入新的 → 覆蓋現有的
const body = await c.req.json().catch(() => null); // - 忘記密碼:寄給你**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋
const current = String(body?.current ?? ''); // ⇒ **同一個畫面、同一條寫入路徑,差別只有一格**:「現有密碼」是要填、還是被連結豁免。
const next = String(body?.new ?? ''); //
if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400); // 🔴 **不做一次性密碼**(leo:「不要發一次性密碼太麻煩」)——這修正了 D50 的「一次性驗證碼」,
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400); // 形態改成連結;D50 其餘部分(console 退場、不准沿用註冊辨識碼)不變。
// 🔴 連結的安全性(承 D50 的理由,不可退讓):
// ① **一次有效**——用掉就從 KV 刪除(本檔 consumeResetToken
// ② **會過期**——KV expirationTtl 30 分鐘,過了就是不存在
// ③ **與註冊辨識碼不同源**——現場 crypto 亂數產生、只活在這台實例的 SESSIONS_KV
// 跟 landing `SIGNUPS` 那組安裝辨識碼沒有任何關係。
// D50 否決固定辨識碼的理由正是「綁定不會變 ⇒ 等於不會過期的鑰匙」。
// 🔴 已否決、不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。
// 🔴 **入口在 portal,不是 console**leo 2026-08-10:「是對 portal 不是對 console
// 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」)——console 與 portal 是
// 安裝時同一組帳密寫進兩個地方(D50 補刀),往 console 補洞不會多出任何一條路。
const ok = await verifyPassword(current, auth.user.values.password_hash ?? ''); /** 「修改密碼」連結 token 的 KV key 前綴(存的是 token 的 sha256,不是 token 本身)。 */
if (!ok) return c.json({ error: '舊密碼不正確' }, 401); const PWRESET_PREFIX = 'portal_pwreset:';
/** 連結有效期:30 分鐘(安全要求②「會過期」)。 */
const PWRESET_TTL_SECONDS = 30 * 60;
/** 同一個 email 的請求節流 key 前綴+冷卻秒數(避免被拿來灌信)。 */
const PWRESET_THROTTLE_PREFIX = 'portal_pwreset_req:';
const PWRESET_THROTTLE_SECONDS = 120;
/** 代寄回呼票(讓郵差可以回頭問「這封真的是你要我寄的嗎」)的 key 前綴與存活秒數。 */
const RELAY_TICKET_PREFIX = 'portal_relay_ticket:';
const RELAY_TICKET_TTL_SECONDS = 120;
const newHash = await hashPassword(next); interface ResetTokenPayload {
await patchRecordValues(c.env, auth.user.recordId, { record_id: string;
password_hash: newHash, email: string;
updated_at: new Date().toISOString(), created_at: string;
}
/**
* portal 使 origin
* index.ts CORS ****UI_ORIGINS workers.dev
* 2026-08-08 西
*/
function portalUiOrigin(env: Bindings): string | null {
const declared = String(env.UI_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
if (declared.length > 0) return declared[0];
const sub = String(env.WORKER_SUBDOMAIN ?? '').trim();
return sub ? `https://arcrun-rag-ui.${sub}.workers.dev` : null;
}
/** 發一張「修改密碼」連結票,回傳要放進連結的 token(明碼只在這一刻存在)。 */
async function issueResetToken(env: Bindings, recordId: string, email: string): Promise<string> {
const token = randomHex(32);
const payload: ResetTokenPayload = { record_id: recordId, email, created_at: new Date().toISOString() };
await env.SESSIONS_KV.put(`${PWRESET_PREFIX}${await sha256Hex(token)}`, JSON.stringify(payload), {
expirationTtl: PWRESET_TTL_SECONDS,
});
return token;
}
/** 看一眼票是否有效(**不消耗**)——給「點進連結先渲染畫面」用。 */
async function peekResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
if (!token || !/^[0-9a-f]{16,128}$/i.test(token)) return null;
const raw = await env.SESSIONS_KV.get(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
if (!raw) return null;
try {
return JSON.parse(raw) as ResetTokenPayload;
} catch {
return null;
}
}
/**
* ****
*
*/
async function consumeResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
const payload = await peekResetToken(env, token);
if (!payload) return null;
await env.SESSIONS_KV.delete(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
return payload;
}
/**
* ****D62
* hash patch
*/
async function writeNewPassword(env: Bindings, recordId: string, newPassword: string): Promise<void> {
const newHash = await hashPassword(newPassword);
await patchRecordValues(env, recordId, {
password_hash: newHash,
updated_at: new Date().toISOString(),
});
}
/**
* landing
*
* 🔴 ****leo 2026-08-10
* youlin arcrun.dev abc.recover
* arcrun.dev
* ******arcrun.dev **
* **** cypher binding
* ai/d1/kv_namespace/plain_text/secret_text/service/vectorize** send_email**
* leo****D62
*
* 🔴 ** URL ** 2026-08-10 `arcrun.dev` uncle6
* DKIM URL email `arcrun.dev`
* ** DKIM **
* ****
*
* ** origin **
* ** origin** /portal/password/relay-verify
* **
* **
*
* `apiOrigin` ****`new URL(c.req.url).origin`
* 2026-08-08
*
* ****mindset §7 'failed' / 'not_configured'
*/
async function relayResetLink(
env: Bindings,
apiOrigin: string,
email: string,
ticket: string,
): Promise<'sent' | 'not_configured' | 'failed'> {
const base = String(env.PORTAL_MAIL_RELAY_BASE ?? '').trim().replace(/\/$/, '');
if (!base) return 'not_configured';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.PORTAL_MAIL_RELAY_KEY) headers['X-Arcrun-Relay-Key'] = env.PORTAL_MAIL_RELAY_KEY;
try {
const res = await fetch(`${base}/api/send-password-reset`, {
method: 'POST',
headers,
body: JSON.stringify({ email, api_origin: apiOrigin, ticket }),
}); });
return c.json({ success: true }); return res.ok ? 'sent' : 'failed';
} catch {
return 'failed';
}
}
/**
* POST /portal/password/relay-verify ****body `{ticket}`
*
*
* ****
* -
* - ****
* ** email reset token** email sha256
*
* TTL
*/
portalRouter.post('/portal/password/relay-verify', (c) =>
run(c, async () => {
const body = await c.req.json().catch(() => null);
const ticket = String(body?.ticket ?? '').trim();
if (!ticket || !/^[0-9a-f]{8,64}$/i.test(ticket)) return c.json({ ok: false }, 400);
const key = `${RELAY_TICKET_PREFIX}${ticket}`;
const raw = await c.env.SESSIONS_KV.get(key);
if (!raw) return c.json({ ok: false }, 404);
await c.env.SESSIONS_KV.delete(key); // 一次握手,看過即焚
let parsed: { email: string; link: string };
try {
parsed = JSON.parse(raw) as { email: string; link: string };
} catch {
return c.json({ ok: false }, 404);
}
return c.json({ ok: true, email_sha256: await sha256Hex(parsed.email), link: parsed.link });
}), }),
); );
// GET /portal/password/reset-link?token=… — 信裡連結的落點。
// **連結的主機刻意是 cypher 自己**(=郵差回呼確認的那個主機,兩者必須是同一個,
// 否則「郵差確認過的主機」與「信裡的主機」就不是同一件事,紅線等於沒守)。
// 這裡只做一件事:把人帶去 portal 前端的修改密碼畫面。
portalRouter.get('/portal/password/reset-link', (c) => {
const token = c.req.query('token') ?? '';
const ui = portalUiOrigin(c.env);
if (!ui) return c.text('這台實例沒有設定 portal 前端網址,無法導向修改密碼畫面。', 500);
return c.redirect(`${ui}/portal/#/reset?token=${encodeURIComponent(token)}`, 302);
});
// POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。
//
// 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。
// 唯一會回錯的是「寄信功能根本沒接上」——那與「有沒有這個帳號」無關,講出來不洩漏任何事,
// 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。
//
// 🔴 arcrun-rag#38/#69/#252026-08-11 leo 親口點出兩個問題,改字前先讀):
// ① 「實例」是行話——目標用戶是「只會叫 AI 幫忙的人」,不懂什麼叫一台實例。
// ② 「請管理員直接幫你改密碼」對單人使用者是死路——他自己就是管理員,等於叫他聯絡自己
// (`#25` 同一種病:「我忘記 portal 密碼,畫面叫我去找管理員,但管理員就是我」)。
// ⇒ 訊息改成白話(不提「實例」),而且給一條他自己走得完的路:**重新跑一次安裝/更新**
// 現在**真的有用**(不是安慰話)——arcrun-rag#38/#69/#25 同批修正讓安裝器學會「版本號一樣
// 不代表這個功能已經接上,沒接上就當作舊的重推」,所以照原本收到的安裝網址走一次,
// 選同一個 Cloudflare 帳號,這個功能就會自動接上,不需要自己改任何設定。
portalRouter.post('/portal/password/forgot', (c) =>
run(c, async () => {
const body = await c.req.json().catch(() => null);
const email = String(body?.email ?? '').trim().toLowerCase();
if (!email || !isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
if (!String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()) {
return c.json(
{
error:
'寄信功能還沒接上,所以「忘記密碼」的信寄不出去。' +
'請照當初收到的安裝網址,重新執行一次安裝(選同一個 Cloudflare 帳號)——' +
'完成後這個功能就會自動接上,不需要自己設定任何東西,也不用找任何人幫忙。',
code: 'mail_relay_not_configured',
},
503,
);
}
const generic = {
success: true,
message: '如果這個 email 有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。',
};
// 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏)
const throttleKey = `${PWRESET_THROTTLE_PREFIX}${email}`;
if (await c.env.SESSIONS_KV.get(throttleKey)) return c.json(generic);
await c.env.SESSIONS_KV.put(throttleKey, '1', { expirationTtl: PWRESET_THROTTLE_SECONDS });
const recordId = await findUserRecordId(c.env, email).catch(() => null);
if (!recordId) return c.json(generic); // 沒有這個帳號 → 一樣的回應,什麼都不寄
const token = await issueResetToken(c.env, recordId, email);
// 連結的主機=這次請求真正抵達的位址(郵差待會兒就是回頭打這裡確認的)
const apiOrigin = new URL(c.req.url).origin;
const link = `${apiOrigin}/portal/password/reset-link?token=${encodeURIComponent(token)}`;
const ticket = randomHex(16);
await c.env.SESSIONS_KV.put(`${RELAY_TICKET_PREFIX}${ticket}`, JSON.stringify({ email, link }), {
expirationTtl: RELAY_TICKET_TTL_SECONDS,
});
await relayResetLink(c.env, apiOrigin, email, ticket); // 寄不出去也回同一句(存在性不可由回應推得)
return c.json(generic);
}),
);
// GET /portal/password/reset?token=… — 點進連結時先問「這張票還有效嗎」(**不消耗**)。
// 回 email 讓畫面顯示「你正在為 xxx@yyy 設定新密碼」——票本身就證明持有人控制那個信箱。
portalRouter.get('/portal/password/reset', (c) =>
run(c, async () => {
const payload = await peekResetToken(c.env, c.req.query('token') ?? '');
if (!payload) {
return c.json(
{ valid: false, error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。' },
400,
);
}
return c.json({ valid: true, email: payload.email });
}),
);
/**
* POST /portal/password/change **D62 **
*
* body`{ new, current? , reset_token? }`
* - `reset_token` ** current**
* - `current`
* ****writeNewPassword
*/
async function handlePasswordChange(c: Context<{ Bindings: Bindings }>): Promise<Response> {
const body = await c.req.json().catch(() => null);
const next = String(body?.new ?? '');
const resetToken = String(body?.reset_token ?? '').trim();
if (!next) return c.json({ error: 'new(新密碼)必填' }, 400);
if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
// ── 忘記密碼那一格:憑連結,不問現有密碼 ──
if (resetToken) {
const payload = await consumeResetToken(c.env, resetToken);
if (!payload) {
return c.json(
{ error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。', code: 'reset_token_invalid' },
400,
);
}
await writeNewPassword(c.env, payload.record_id, next);
return c.json({ success: true, email: payload.email, via: 'reset_link' });
}
// ── 修改密碼那一格:要登入、要現有密碼 ──
const auth = await requirePortalUser(c);
if (!auth.ok) return auth.res;
const current = String(body?.current ?? '');
if (!current) return c.json({ error: 'current(現有密碼)必填' }, 400);
const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
if (!ok) return c.json({ error: '舊密碼不正確' }, 401);
await writeNewPassword(c.env, auth.user.recordId, next);
return c.json({ success: true, via: 'current_password' });
}
portalRouter.post('/portal/password/change', (c) => run(c, () => handlePasswordChange(c)));
// POST /portal/me/password — 舊名,**轉呼同一支**(現有前端與 CLI 還在用這個路徑)。
// 保留別名而不是留第二份實作:兩份必然漂移(D39/arcrun-rag#40「同一個事實兩份」)。
portalRouter.post('/portal/me/password', (c) => run(c, () => handlePasswordChange(c)));
// ═══════════════════════════════ admin 端點 ══════════════════════════════════ // ═══════════════════════════════ admin 端點 ══════════════════════════════════
// POST /portal/admin/bootstrap — 需 **console owner session**design D-7owner secret 是 // POST /portal/admin/bootstrap — 需 **console owner session**design D-7owner secret 是
+13
View File
@@ -115,6 +115,19 @@ export type Bindings = {
// expirationTtl。未設 → 6048007 天,design §4.3——issue 要求短效,比 console 30 天緊)。 // expirationTtl。未設 → 6048007 天,design §4.3——issue 要求短效,比 console 30 天緊)。
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。 // 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
PORTAL_SESSION_TTL?: string; PORTAL_SESSION_TTL?: string;
// Portal / console 前端站的 origin 白名單(逗號分隔,非機密)。index.ts 的 CORS 讀它;
// D62 的「修改密碼」連結也用它當「使用者會看到的那個網址」(未設 → 用 workers.dev 兄弟位址推導)。
UI_ORIGINS?: string;
// ── D62「忘記密碼」=寄一條「修改密碼」連結(非機密)───────────────────────────
// 中央代寄服務的 base URLlanding worker)。**用戶自己的實例沒有寄信能力**——安裝器
// 部署 cypher 的 binding 只有 ai/d1/kv/plain_text/secret_text/service/vectorize
// **沒有 send_email**;能寄信的是我們 landing 的 CF Email Service(寄件網域 arcrun.dev)。
// 未設 → /portal/password/forgot 誠實回 503 `mail_relay_not_configured`,不假裝寄出去了。
// ⚠️ 「由中央代寄」是依 leo「寄給你」推導的**假設**,尚待他正式表態(D62 未裁前置)。
PORTAL_MAIL_RELAY_BASE?: string;
// 代寄服務的共享秘密(可選)。設了就在代寄請求帶 X-Arcrun-Relay-Key,讓 landing 端
// 分辨「這是我們自己的實例」。未設=不帶(landing 端仍有速率限制與固定樣板)。
PORTAL_MAIL_RELAY_KEY?: string;
// Portal 工作流頁可見性(portal-auth P3design D-8 定案,非機密):admin(預設)/ all / off。 // Portal 工作流頁可見性(portal-auth P3design D-8 定案,非機密):admin(預設)/ all / off。
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session // 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。 // 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
@@ -0,0 +1,89 @@
/**
* PATCH /kbdb/records/:recordId proxy 2026-08-11 library
*
* kbdb/src/routes/records.ts PATCH /records/:recordIdmira-dissolve T2.1
* updateRecord record slot idempotent grow cypher
* proxykbdb-proxy.ts GET/POST /kbdb/records PATCH/CLI/
* X-Arcrun-API-Key
*
* IO KBDB kbdb-map-proxy.test.ts
* 1. X-Arcrun-API-Key 401 KBDB
* 2. body values 400
* 3. PATCH /kbdb/records/:id base PATCH /records/:idbody { values }
* 4. base 404record
*
* KBDB fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('PATCH /kbdb/records/:recordId — 租戶閘', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: { library: 'kb' } }),
});
expect(res.status).toBe(401);
});
});
describe('PATCH /kbdb/records/:recordId — 參數驗證', () => {
it('body 沒有 values → 400,不轉發', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
method: 'PATCH',
headers: KEY,
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
});
describe('PATCH /kbdb/records/:recordId — 轉發', () => {
it('轉發 base PATCH /records/:idbody 只帶 values(不夾帶其他欄位)', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records/rec_1',
method: 'PATCH',
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
})
.reply(200, {
success: true,
record: { record_id: 'rec_1', template_id: 'tpl-triplet', values: { library: 'gitea:Leo/kb' } },
});
const res = await SELF.fetch('http://localhost/kbdb/records/rec_1', {
method: 'PATCH',
headers: KEY,
body: JSON.stringify({ values: { library: 'gitea:Leo/kb' } }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
expect(data.success).toBe(true);
expect(data.record.values.library).toBe('gitea:Leo/kb');
});
it('base 404record 不存在)→ 原樣透傳,不假裝成功', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/records/nope', method: 'PATCH' })
.reply(404, { success: false, error: 'not found' });
const res = await SELF.fetch('http://localhost/kbdb/records/nope', {
method: 'PATCH',
headers: KEY,
body: JSON.stringify({ values: { library: 'kb' } }),
});
expect(res.status).toBe(404);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(false);
});
});
+100
View File
@@ -582,3 +582,103 @@ describe('D61:舊實例登入自癒(帳號只在 KBDB,登入成功後 best
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。 // assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
}); });
}); });
// ═══════════════ D62 arcrun-rag#662026-08-10)═══════════════
//
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
const D62_EMAIL = 'd62-reset@example.com';
it('/portal/password/change 帶 reset_token**不需要登入、不需要現有密碼**,且票用完即失效', async () => {
// 直接把一張票種進 KV(等同 /portal/password/forgot 發出來的那張),
// 存的是 token 的 sha256——KV 裡看不到可用的連結。
const { sha256Hex } = await import('../src/lib/portal-auth');
const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90';
const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`;
await env.SESSIONS_KV.put(
`portal_pwreset:${await sha256Hex(token)}`,
JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }),
);
// 票有效時,先「看一眼」不會消耗它
const peek = await json('GET', `/portal/password/reset?token=${token}`);
expect(peek.status).toBe(200);
expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL);
// 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗,但**票必須已經被消耗**(先刪再回)
const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' });
expect(used.status).not.toBe(200); // 這個 record 不存在,寫入失敗是預期的
// 關鍵斷言:同一條連結**不能再用第二次**
const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' });
expect(again.status).toBe(400);
expect((await again.json() as { code: string }).code).toBe('reset_token_invalid');
// 而且票在 KV 裡真的沒了
expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull();
});
it('亂猜的 token / 格式不對的 token → 400,不洩漏任何東西', async () => {
for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) {
const res = await json('GET', `/portal/password/reset?token=${t}`);
expect(res.status).toBe(400);
expect((await res.json() as { valid: boolean }).valid).toBe(false);
}
});
it('沒帶 reset_token 又沒登入 → 401(修改密碼那一格仍然要身分)', async () => {
const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' });
expect(res.status).toBe(401);
});
it('新密碼太短 → 400(兩條路共用同一組驗證)', async () => {
const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' });
expect(res.status).toBe(400);
});
it('/portal/password/forgot:沒設代寄服務 → 誠實回 503,不假裝信寄出去了', async () => {
const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL });
expect(res.status).toBe(503);
expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured');
});
});
describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
const TOKEN_A = 'sess-token-66-propagating';
const TOKEN_B = 'sess-token-66-really-gone';
const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`;
it('正在傳播(加速器 key 還在)+讀不到 record → 503 auth_store_propagating,且 **session 沒被刪**', async () => {
await seedPortalSession(TOKEN_A, MISSING);
// 加速器 key 存在=「剛剛有人動過認證儲存」=現在是傳播空窗
await env.SESSIONS_KV.put(
'auth_store_recent',
JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }),
);
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` });
expect(res.status).toBe(503);
expect((await res.json() as { code: string }).code).toBe('auth_store_propagating');
// 🔴 這是整張票的重點:舊碼會在這裡把 KV 那筆刪掉,等 secret 鋪開也回不來
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull();
});
it('不在傳播空窗(加速器 key 不存在)+讀不到 record → 401 擋下,但**仍然不刪 session**', async () => {
await seedPortalSession(TOKEN_B, MISSING);
await env.SESSIONS_KV.delete('auth_store_recent');
const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` });
expect(res.status).toBe(401);
// 刪 session 是 best-effort 清潔工,而它清掉的是使用者唯一的憑據;KV 的 TTL 本來就會回收
expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull();
});
it('session 內容本身壞掉(不是讀不到)→ 401 且**該刪**(確定的事實,不是暫時性)', async () => {
await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all');
const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' });
expect(res.status).toBe(401);
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
});
});
@@ -0,0 +1,116 @@
/**
* Arcrun#88
*
* leo21c 2026-08-11
* `/cypher/search` `if_control``http_request` `not_found`
* component-loader.ts LOGIC_BINDING_MAPWASM_HTTP_RUNNER_IDS
* registryregistry catalog 404`GET /components/catalog`
* `{"success":false,"error":"零件 catalog 不存在"}`search
*
* wrangler.test.toml WORKER_SUBDOMAIN registryBase
* undefined catalog.status='unreachable'registry
* RUNTIME_NATIVE_COMPONENT_IDS registry **** found
* registry registry
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { parseTriplets } from '../src/actions/triplet-parser';
import { searchNodes } from '../src/actions/search-nodes';
afterEach(() => {
vi.unstubAllGlobals();
});
const IF_CONTROL_TRIPLETS = [
'input >> ON_SUCCESS >> if_control',
];
const HTTP_REQUEST_TRIPLETS = [
'input >> ON_SUCCESS >> http_request',
];
const MULTI_BUILTIN_TRIPLETS = [
'input >> ON_SUCCESS >> switch',
'input >> ON_SUCCESS >> filter',
'input >> ON_SUCCESS >> code',
];
const FAKE_COMPONENT_TRIPLETS = [
'input >> ON_SUCCESS >> totally_made_up_component_xyz',
];
describe('Arcrun#88:執行期原生零件不受 registry 健康狀態影響', () => {
it('if_controlLOGIC_BINDING_MAP 成員)在 registry 不可達時仍回 found', async () => {
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
expect(parsed).not.toBeNull();
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
// 無 WORKER_SUBDOMAINREGISTRY_BASE_URL → registryBase undefined → catalog unreachable
});
expect(nodeResults.if_control.status).toBe('found');
expect(nodeResults.if_control.source).toBe('builtin');
// if_control 會分岔,branch_hint 應隨 found 一併附上(不必逐顆再查一次)
expect(nodeResults.if_control.branch_hint?.edge_types).toEqual(['ON_TRUE', 'ON_FALSE']);
expect(missingNodes).not.toContain('if_control');
});
it('http_requestWASM_HTTP_RUNNER_IDS 成員)在 registry 不可達時仍回 found', async () => {
const parsed = parseTriplets(HTTP_REQUEST_TRIPLETS);
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {});
expect(nodeResults.http_request.status).toBe('found');
expect(nodeResults.http_request.source).toBe('builtin');
expect(missingNodes).not.toContain('http_request');
});
it('switchfiltercode(同一批白名單的其他成員)也回 found,不逐一漏網', async () => {
const parsed = parseTriplets(MULTI_BUILTIN_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, {});
expect(nodeResults.switch.status).toBe('found');
expect(nodeResults.filter.status).toBe('found');
expect(nodeResults.code.status).toBe('found');
});
it('registry 完全連不到時,真正不存在的名字誠實回 unknown(不敢空口說沒有——既有行為,修法沒有動它)', async () => {
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, {});
expect(nodeResults.totally_made_up_component_xyz.status).toBe('unknown');
});
it('registry 查得到但目錄是空的(複現 leo21c 實例 catalog 404 的真實症狀):真正不存在的名字回 not_found', async () => {
// 複現生產實測:GET /components/catalog → HTTP 200 空陣列(本測試模擬「registry 活著但沒東西」,
// 與 leo21c 實例的「404 零件 catalog 不存在」殊途同歸——都會落到「查得到、目錄無此零件」)。
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
));
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
WORKER_SUBDOMAIN: 'test-sub',
});
expect(nodeResults.totally_made_up_component_xyz.status).toBe('not_found');
expect(missingNodes).toContain('totally_made_up_component_xyz');
});
it('registry 目錄是空的(catalog 通但無資料)時,執行期原生零件依然 found——這才是 Arcrun#88 的核心場景', async () => {
// 這就是 leo21c 實例的真實狀態:registry 活著、目錄卻沒有任何一顆執行期原生零件的記錄
// SUBMISSIONS_KV 從未收到 if_control/http_request 的 submit)。若沒有本次修法,
// 這裡會落到「兩庫都查過沒有」→ not_found,正是 Arcrun#88 回報的病徵。
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
));
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, { WORKER_SUBDOMAIN: 'test-sub' });
expect(nodeResults.if_control.status).toBe('found');
expect(nodeResults.if_control.source).toBe('builtin');
});
it('target=recipe 明確只查 recipe 庫時,執行期原生零件不搶答 found(尊重使用者明確限庫)', async () => {
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
const { nodeResults } = await searchNodes(parsed!, undefined, {}, 'discover', 'recipe');
// if_control 從來不是 recipetarget=recipe 下不該被 builtin 短路成 found
expect(nodeResults.if_control.status).not.toBe('found');
});
});
+224 -7
View File
@@ -257,6 +257,209 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
}; };
} }
// ── 查詢斷詞 + 覆蓋率排序:讓「AI 問一個問句」查得到東西 ─────────────────────────
//
// 病徵(2026-08-10 總管在 leo21c 上實測,有對照組):
// kbdb_search("Gemini 逃生口") → 0 筆
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
// ⇒ 對照組證明:**查詢字串是整串拿去比對的,從來沒有被拆開**。
// 上面 buildContentLike 只在 q > 48 bytes(=那次 500 的閘)時才拆,短查詢一律單一
// `content LIKE '%整句%'`;而且拆開後是 AND(每個詞都要出現)。
//
// 為什麼這是**結構性**故障、不是準度問題:
// **AI 問的永遠是問句,不是單一關鍵字。** 一個問句的詞幾乎不可能在原文裡剛好相鄰
// ⇒ 對 AI 而言這條路的回傳值恆為 0。leo 2026-08-10:「沒有 MCP 你就是瞎的」——
// 接上了也還是瞎的,因為接上之後查什麼都沒有。
// (語意搜尋救不了:同一次實測 50 筆裡 41 筆沒有向量,82% 的內容語意搜尋看不見。)
//
// 這件 47c6aae2026-08-03 修 50 bytes 500)就寫明是「另一件事、要另外立案」的那件事;
// 本次只動**查詢端**buildContentLike 一個字不動(那支修的是 pattern 長度,不是斷詞)。
//
// 修法:查詢端斷詞 → 每個詞各自比對 → **用覆蓋率排序**,不是用 AND 過濾。
// · 只要命中任一個詞就是候選(OR),但**排序由「命中了多少份量的詞」決定**,
// 所以「詞存在但不相鄰」查得到東西,而相關的排在前面。
// · 詞的份量=詞長(字數)。長詞/英數詞比較專指,雙字詞比較泛
// ⇒「Gemini 在這套系統裡的角色是什麼」裡 Gemini(6) 的份量遠大於 系統(2)、角色(2)
// ⇒ 含 Gemini 的內容自然壓過只含「系統」的雜訊。這就是相關性不崩壞的機制。
// · **整句相鄰**另外加一份重賞(phraseBonus)⇒ 舊行為(字面相鄰)永遠排第一,
// `local arcrun` 那 5 筆不會被稀釋掉。
// · 相對門檻砍低分尾(沿用 embed.ts relativeMinScore 的既有做法,不另立第二套):
// 只留 >= 最高分 × KEYWORD_RELATIVE_CUT 的,避免「為了有結果就把整個庫撈回來」。
//
// 回歸保證(不是靠測試碰運氣,是靠構造):
// · **單詞查詢送出的 SQL 與舊版逐字相同**(一個 LIKE、同一個 pattern),
// 所有分數相等 ⇒ 排序也退化回 updated_at DESC。一個字都沒變。
// · 多詞查詢的結果集是舊版的**超集**(含整句的內容一定也含每一個詞),
// 而整句命中因 phraseBonus 排最前 ⇒ 原本查得到的不可能變成查不到。
//
// 誠實限制:這是「查詢端斷詞」,不是真正的中文斷詞器(沒有詞典)。CJK 靠虛詞切段
// +長段補雙字組合,命中率一定不如詞典;真正的解是 FTS5/斷詞索引,那要動索引端、
// 要另外立案。本次的對照組是 **0 筆**,不是「更好的排序」。
// 成本:一次查詢最多掃 MAX_SEARCH_TERMS(+1) 個 LIKE,而舊版是 1 個 ⇒ 全表掃描成本上升到
// 最多 7 倍。**單詞查詢仍是 1 個**(最常見的路徑不受影響);多詞查詢用這個成本換掉「恆為 0」。
const MAX_SEARCH_TERMS = 6; // 每多一個詞就多比對一次,6 是成本與召回的折衷(與 MAX_LIKE_TERMS 同數)
const MAX_TERM_WEIGHT = 8; // 單一詞份量上限,避免一個超長詞獨大到蓋掉其他訊號
// 相對門檻取 0.6 是**實測調出來的**,不是拍的(2026-08-10,3915 筆真實語料本機對照):
// 0.5 時「這個系統的搜尋是怎麼做的」把只含「系統」或只含「搜尋」的也撈進來(滿 50 筆雜訊尾);
// 0.6 時只留同時含兩個詞的 ⇒ 尾巴收乾淨,而驗收題(Gemini 逃生口)不受影響
// ——那題最高分那群本來就只有 Gemini 一個詞命中,相對門檻是對「最高分」取比例,不是對「滿分」,
// 所以「全庫沒有第二個詞」的情況不會被自己的門檻誤殺(這正是不能用滿分當分母的原因)。
const KEYWORD_RELATIVE_CUT = 0.6;
// CJK 虛詞:**只拿來過濾雙字組合,絕不拿來切段。**
//
// 🔴 這條是自己的測試擋出來的(2026-08-10):第一版用虛詞「切段」,結果
// 「向量化」被 `向` 切成「量化」、「功能」被 `能` 切掉 ⇒ **把使用者真正要查的詞切爛了**。
// 沒有詞典的中文,切段一定會誤傷實詞(能/更/要/者/使/則/因/項/過/得 全都
// 同時是虛詞與實詞的組成部分)。
// ⇒ 改成:**整段原樣保留**,雙字組合只是補充;只有「雙字裡有虛詞」的組合才丟掉。
// 這個方向誤傷不了實詞——因為實詞從來沒有被拆過,只是多了幾個候選。
//
// 收字原則:**拿不準就不收**。噪音組合很便宜(比不中就是 0 分,只佔一個名額),
// 誤殺實詞很貴(那個查詢就永遠找不到了)。所以像 個/為/能/要/者/因/所/中/裡
// 這些「也會出現在實詞裡」的字**一律不收**,寧可留下「一個」「為什」這種比不中的噪音。
const CJK_STOP_CHARS = new Set(
'的了是在我你他她它們這那哪誰嗎呢吧啊呀嘛喔哦什麼怎之乎而但並卻就都也很太只還又再每些把被跟讓若'.split(''),
);
// 英文虛詞:同理,問句裡的 what/how/why 不是查詢訊號。
const ASCII_STOP_WORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at', 'is', 'are', 'was', 'were',
'be', 'do', 'does', 'did', 'for', 'it', 'its', 'this', 'that', 'these', 'those', 'with',
'what', 'how', 'why', 'when', 'where', 'who', 'which', 'can', 'could', 'should', 'would',
'my', 'our', 'your', 'their', 'me', 'we', 'you', 'they',
]);
const isCjkChar = (ch: string): boolean => /[぀-ヿ㐀-䶿一-鿿豈-﫿]/.test(ch);
const isWordChar = (ch: string): boolean => /[A-Za-z0-9_.-]/.test(ch);
/** 把查詢切成「連續的同類字串」:CJK 一段、英數一段,其餘(空白/標點/全形符號)當分隔。 */
export function splitRuns(q: string): { text: string; cjk: boolean }[] {
const runs: { text: string; cjk: boolean }[] = [];
let cur = ''; let curCjk = false;
const flush = () => { if (cur) runs.push({ text: cur, cjk: curCjk }); cur = ''; };
for (const ch of q) {
const cjk = isCjkChar(ch);
if (!cjk && !isWordChar(ch)) { flush(); continue; } // 空白與標點=分隔
if (cur && cjk !== curCjk) flush(); // CJK↔英數 邊界也切(吸收 t95 normalizeCjkQuery 的用意)
cur += ch; curCjk = cjk;
}
flush();
return runs;
}
/** 相鄰雙字組合,丟掉「含虛詞」的那些(在這/的角/是什…=噪音,不是查詢訊號)。 */
function contentBigrams(run: string): string[] {
const chars = [...run];
const out: string[] = [];
for (let i = 0; i + 1 < chars.length; i++) {
if (CJK_STOP_CHARS.has(chars[i]) || CJK_STOP_CHARS.has(chars[i + 1])) continue;
out.push(chars[i] + chars[i + 1]);
}
return out;
}
export interface SearchTerm { term: string; weight: number }
/**
* export
* MAX_TERM_WEIGHT
* MAX_SEARCH_TERMS
*/
export function tokenizeQuery(q: string): SearchTerm[] {
const found = new Map<string, number>();
const add = (t: string, w: number) => {
for (const piece of chunkByBytes(t, MAX_LIKE_Q_BYTES)) { // 仍受 D1 LIKE pattern 50 bytes 上限約束
if (!piece) continue;
found.set(piece, Math.max(found.get(piece) ?? 0, Math.min(w, MAX_TERM_WEIGHT)));
}
};
const runs = splitRuns(q);
// 「使用者只打一個詞」vs「AI 問一句話」是兩種東西,處理方式必須不同:
// · 只有一段 → **就照舊版做**(一個 LIKE),這條路本來就好好的,不准動它。
// · 有多段(=問句)→ 才補雙字組合去拉召回。這是本次要修的那條路。
// 🔴 這個判斷是既有回歸測試擋出來的(search-long-query.test.ts「短查詢:SQL 裡只有
// 一個 content LIKE」):不分情況一律補雙字組合,會讓「語意檢索」這種**最常見的
// 中文單詞查詢**從 1 個 LIKE 變 5 個 ⇒ 最熱路徑成本 ×5,而它根本沒壞。
const isQuestion = runs.length > 1;
for (const run of runs) {
if (!run.cjk) {
const w = run.text.toLowerCase();
if (w.length >= 2 && !ASCII_STOP_WORDS.has(w)) add(run.text, run.text.length);
continue;
}
const chars = [...run.text];
// 短段(≤4 字)多半**本身就是一個詞**(語意檢索/專案管理/系統/角色)→ 原樣當查詢詞。
if (chars.length >= 2 && chars.length <= 4) add(run.text, chars.length);
// 長段(>4 字)多半是「一句話沒有空白」,整段拿去比對必然比不中 ⇒ 只靠雙字組合。
// 問句裡的每一段也補雙字組合(含實詞的那些),這才是「拆得開」的來源。
if (isQuestion || chars.length > 4) for (const bg of contentBigrams(run.text)) add(bg, 2);
}
return [...found.entries()]
.map(([term, weight]) => ({ term, weight }))
.sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term))
.slice(0, MAX_SEARCH_TERMS);
}
export interface SearchScorePlan {
/** SQL 算分表達式(含 ? 佔位符),對應 scoreParams。 */
scoreExpr: string;
scoreParams: string[];
terms: SearchTerm[];
/** true 送出的 SQL 與舊版單一 LIKE 逐字相同(單詞查詢的回歸保證)。 */
legacyShape: boolean;
}
/**
* SQL export
*
* ****
* `local arcrun` 5
*/
export function buildSearchScore(q: string): SearchScorePlan {
const trimmed = q.trim();
const terms = tokenizeQuery(trimmed);
// 一個詞都拆不出來(例:全是標點/單字虛詞)→ 退回舊版單一 LIKE,行為不變、不會空條件。
if (terms.length === 0) {
const m = buildContentLike(trimmed);
return {
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
scoreParams: m.params,
terms: [],
legacyShape: true,
};
}
const parts: string[] = [];
const params: string[] = [];
for (const { term, weight } of terms) {
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
params.push(`%${term}%`);
}
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
const single = terms.length === 1 && terms[0].term === trimmed;
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
const bonus = terms.reduce((s, t) => s + t.weight, 0);
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
params.push(`%${trimmed}%`);
}
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
}
/** 相對門檻:砍掉低於「最高分 × KEYWORD_RELATIVE_CUT」的雜訊尾巴(純函式,單測用 export)。 */
export function applyRelativeCut<T extends { match_score: number }>(rows: T[]): T[] {
if (rows.length <= 1) return rows;
const cut = rows[0].match_score * KEYWORD_RELATIVE_CUT;
return rows.filter((r) => r.match_score >= cut);
}
// 「庫」filter 的 SQL 謂詞(portal-auth P1design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。 // 「庫」filter 的 SQL 謂詞(portal-auth P1design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))—— // COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。 // 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
@@ -309,6 +512,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
// includeDeprecateddaemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。 // includeDeprecateddaemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。 // 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
// 加在參數最尾端,既有 positional callersource 之後)一個都不用改。 // 加在參數最尾端,既有 positional callersource 之後)一個都不用改。
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
export async function searchEntries( export async function searchEntries(
db: D1Database, db: D1Database,
q: string, q: string,
@@ -318,18 +524,29 @@ export async function searchEntries(
library?: string[], library?: string[],
source?: string, source?: string,
includeDeprecated = false, includeDeprecated = false,
): Promise<Entry[]> { ): Promise<(Entry & { match_score: number })[]> {
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike const plan = buildSearchScore(q); // 斷詞+算分;單詞查詢=與舊版逐字相同的單一 LIKE
const conds = [...m.conds]; const conds: string[] = [];
const params: unknown[] = [...m.params]; const params: unknown[] = [...plan.scoreParams];
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); } if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); } if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); } if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); } if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); } if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
// 分數在子查詢算、外層才篩 match_score > 0SQLite 不保證能在 WHERE 引用 SELECT 別名,
// 用子查詢就不必把整組 LIKE 參數再綁一次(參數重複=將來改一邊漏一邊的漂移來源)。
// 其他 filter 留在**內層**,讓 owner/library/deprecated 先篩掉,算分只發生在該算的列上。
const inner = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '';
const res = await db const res = await db
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`) .prepare(
`SELECT * FROM (
SELECT *, (${plan.scoreExpr}) AS match_score FROM entries ${inner}
) WHERE match_score > 0
ORDER BY match_score DESC, updated_at DESC
LIMIT ?`,
)
.bind(...params, Math.min(limit, 200)) .bind(...params, Math.min(limit, 200))
.all<Entry>(); .all<Entry & { match_score: number }>();
return res.results ?? []; // 相對門檻砍雜訊尾巴(「有結果」不等於「把整個庫撈回來」)。單詞查詢分數全等 ⇒ 一筆都不會被砍。
return applyRelativeCut(res.results ?? []);
} }
+202
View File
@@ -0,0 +1,202 @@
// 查詢斷詞 + 覆蓋率排序 —— 讓「AI 問一個問句」查得到東西(2026-08-10Leo/mira#4
//
// 病徵(總管在 leo21c 上實測,有對照組,非推論):
// kbdb_search("Gemini 逃生口") → 0 筆
// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡
// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰
// ⇒ 對照組證明:查詢字串是**整串**拿去 LIKE 的,從來沒被拆開。
// ⇒ 而 **AI 問的永遠是問句**,問句的詞不可能在原文裡剛好相鄰 ⇒ 這條路對 AI 恆為 0。
//
// 本檔守四件事:
// ① 拆得開 —— 詞存在但不相鄰的問句要能命中
// ② 不退化 —— 單詞查詢送出的 SQL 與舊版**逐字相同**(最熱路徑一個字都不能變)
// ③ 不崩壞 —— 相關的排前面、雜訊尾巴被相對門檻砍掉,不是把整個庫撈回來
// ④ 不再炸 —— 每個 LIKE pattern 仍在 D1 的 50 bytes 上限內(承 2026-08-03 的 500 修復)
import { describe, it, expect } from 'vitest';
import {
tokenizeQuery,
buildSearchScore,
applyRelativeCut,
searchEntries,
} from '../src/actions/entry-crud';
const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限
const termsOf = (q: string) => tokenizeQuery(q).map((t) => t.term);
const weightOf = (q: string, term: string) => tokenizeQuery(q).find((t) => t.term === term)?.weight;
describe('① 拆得開:問句要被拆成詞', () => {
it('「Gemini 逃生口」拆成兩個詞(就是驗收題本身)', () => {
expect(termsOf('Gemini 逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
});
it('沒有空白的 CJK/ASCII 交界也要切開(吸收 t95 normalizeCjkQuery 的用意)', () => {
expect(termsOf('Gemini逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口']));
expect(termsOf('AI協作')).toEqual(expect.arrayContaining(['AI', '協作']));
});
it('自然語言問句:虛詞被丟掉,只留實詞', () => {
const t = termsOf('Gemini 在這套系統裡的角色是什麼?');
expect(t).toEqual(expect.arrayContaining(['Gemini', '系統', '角色']));
// 「這/的/是/什麼/套」是虛詞與量詞,不該變成查詢詞——否則會把整個庫撈回來
for (const junk of ['這', '的', '是', '什麼', '套系統']) expect(t).not.toContain(junk);
});
it('標點(含全形)當分隔,不會混進詞裡', () => {
expect(termsOf('額度、向量化;為什麼?')).toEqual(expect.arrayContaining(['額度', '向量化']));
for (const t of termsOf('額度、向量化;為什麼?')) {
expect(t).not.toMatch(/[、;?,。]/);
}
});
it('超過 4 字的黏著長段補雙字組合(「專案管理工具」要能命中「專案管理」的寫法)', () => {
expect(termsOf('專案管理工具')).toEqual(expect.arrayContaining(['專案', '管理']));
});
it('任何查詢都至少留下一個詞,不會一個都不剩', () => {
for (const q of ['是什麼', '的', '。。。', 'a']) {
expect(buildSearchScore(q).scoreParams.length).toBeGreaterThan(0);
}
});
// 🔴 這組是「第一版寫錯、被自己的測試擋下來」的那個錯(2026-08-10):
// 第一版拿虛詞去**切段**,結果 `向` 把「向量化」切成「量化」、`能` 把「功能」切掉
// ⇒ 使用者真正要查的詞被切爛。沒有詞典的中文,切段一定誤傷實詞。
// 現在的做法是「整段不動、只過濾雙字組合」,這組測試就是不准再走回去。
it('實詞不准被虛詞切爛(向量化/功能/需要/使用者/規則/原因)', () => {
expect(termsOf('額度、向量化;為什麼?')).toContain('向量化');
expect(termsOf('這個功能是什麼')).toContain('功能');
for (const [q, word] of [
['系統需要什麼', '需要'], ['使用者是誰', '使用'], ['這個規則是什麼', '規則'],
['原因是什麼', '原因'], ['更新了什麼', '更新'],
] as const) {
expect(termsOf(q)).toContain(word);
}
});
it('leo 的第二題「今天額度為什麼用完」要抓得到「額度」', () => {
expect(termsOf('今天額度為什麼用完')).toContain('額度');
});
});
describe('② 不退化:單詞查詢與舊版逐字相同', () => {
it('單一英文詞 → 一個詞、legacyShape、一個 LIKE', () => {
const p = buildSearchScore('arcrun');
expect(p.terms.map((t) => t.term)).toEqual(['arcrun']);
expect(p.legacyShape).toBe(true);
expect(p.scoreParams).toEqual(['%arcrun%']);
});
it('單一四字中文詞(最常見的中文查詢)→ 仍然只有一個 LIKE', () => {
// 這條是被既有 search-long-query.test.ts 擋出來的:雙字組合門檻若設 3,
// 「語意檢索」會從 1 個 LIKE 變成 5 個 ⇒ 最熱路徑成本 ×5。
const p = buildSearchScore('語意檢索');
expect(p.legacyShape).toBe(true);
expect(p.scoreParams).toEqual(['%語意檢索%']);
});
it('searchEntries:單詞查詢送出的 SQL 只有一個 content LIKEpattern 與舊版相同', async () => {
const { db, captured } = fakeDb();
await searchEntries(db, '語意檢索', 'demo');
expect(captured[0].sql.match(/content LIKE \?/g)).toHaveLength(1);
expect(captured[0].params[0]).toBe('%語意檢索%');
});
it('單詞查詢分數全等 ⇒ 相對門檻一筆都砍不掉(排序退化回 updated_at DESC', () => {
const rows = Array.from({ length: 50 }, (_, i) => ({ id: `e${i}`, match_score: 3 }));
expect(applyRelativeCut(rows)).toHaveLength(50);
});
});
describe('③ 不崩壞:相關的排前面,雜訊尾巴砍掉', () => {
it('詞愈長份量愈重(specific 壓過泛詞,這是相關性不崩壞的機制)', () => {
const q = 'Gemini 在這套系統裡的角色是什麼?';
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '系統')!);
expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '角色')!);
});
it('整句相鄰另給重賞 ⇒ 字面命中永遠壓過零散命中(`local arcrun` 那 5 筆不會被稀釋)', () => {
const p = buildSearchScore('local arcrun');
expect(p.legacyShape).toBe(false);
expect(p.scoreParams).toContain('%local arcrun%'); // 整句那一項存在
const bonus = p.terms.reduce((s, t) => s + t.weight, 0);
const scattered = p.terms.reduce((s, t) => s + t.weight, 0); // 全部詞都命中但不相鄰
expect(bonus + scattered).toBeGreaterThan(scattered); // 相鄰者必然更高分
});
it('相對門檻砍掉低於最高分 60% 的尾巴', () => {
const rows = [
{ id: 'a', match_score: 10 }, // 兩個詞都中
{ id: 'b', match_score: 6 }, // 只中重的那個
{ id: 'c', match_score: 2 }, // 只中泛詞 ⇒ 雜訊,砍掉
];
expect(applyRelativeCut(rows).map((r) => r.id)).toEqual(['a', 'b']);
});
it('相對門檻是對「最高分」取比例、不是對「滿分」——否則驗收題會被自己的門檻誤殺', () => {
// 「Gemini 逃生口」:全庫沒有「逃生口」,最高分那群只中了 Gemini 一個詞。
// 若拿滿分當分母,這群會全部低於門檻 ⇒ 又回到 0 筆。
const onlyOneTermHit = Array.from({ length: 40 }, (_, i) => ({ id: `g${i}`, match_score: 6 }));
expect(applyRelativeCut(onlyOneTermHit)).toHaveLength(40);
});
it('查詢詞數有上限(每多一個詞就多掃一次全表)', () => {
const t = tokenizeQuery('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫 額度 向量');
expect(t.length).toBeLessThanOrEqual(6);
// 被砍掉的必須是最泛的那些 ⇒ 留下來的按份量遞減
const w = t.map((x) => x.weight);
expect([...w].sort((a, b) => b - a)).toEqual(w);
});
});
describe('④ 不再炸:LIKE pattern 仍在 D1 上限內(承 2026-08-03 的 500 修復)', () => {
it('任何查詢(含超長中文句)產生的每個 pattern 都 ≤ 50 bytes', () => {
const qs = [
'a'.repeat(300),
'為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢',
'Gemini 在這套系統裡的角色是什麼?今天額度為什麼用完?',
'。'.repeat(60),
];
for (const q of qs) {
const p = buildSearchScore(q);
expect(p.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
for (const pattern of p.scoreParams) expect(bytes(pattern)).toBeLessThanOrEqual(MAX_PATTERN);
}
});
});
describe('searchEntries 送出的 SQL', () => {
it('多詞查詢:拆成多個 CASE WHEN,且只回 match_score > 0 的、按分數排序', async () => {
const { db, captured } = fakeDb();
await searchEntries(db, 'Gemini 逃生口', 'demo');
const sql = captured[0].sql;
expect((sql.match(/CASE WHEN content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
expect(sql).toContain('match_score > 0');
expect(sql).toContain('ORDER BY match_score DESC');
expect(captured[0].params).toEqual(expect.arrayContaining(['%Gemini%', '%逃生口%']));
});
it('其他 filterowner/library/deprecated)留在內層,先篩再算分', async () => {
const { db, captured } = fakeDb();
await searchEntries(db, 'Gemini 逃生口', 'demo', undefined, 50, ['kb'], 'kb://x');
const inner = captured[0].sql.split('WHERE match_score')[0];
expect(inner).toContain('owner_id = ?');
expect(inner).toContain("json_extract(metadata_json, '$.source')");
expect(inner).toContain('json_extract(metadata_json, \'$.status\')'); // NOT_DEPRECATED
});
});
function fakeDb() {
const captured: { sql: string; params: unknown[] }[] = [];
const db = {
prepare(sql: string) {
return {
bind(...params: unknown[]) {
captured.push({ sql, params });
return { all: async () => ({ results: [] }) };
},
};
},
} as unknown as D1Database;
return { db, captured };
}
+198
View File
@@ -0,0 +1,198 @@
// 三元組 library 補標 — 源頭順序 + 存量補標 + 冪等(2026-08-11leo 貼 wiki 卡「三元組要恢復」)
//
// 背景(system-dev/wiki/ops-facts.md「三元組在 KBDB 有兩代儲存形式」段,2026-08-11 實測):
// 1,633 筆新式三元組只有 171 筆填了 library slot,地圖(GET /map)因此幾乎看不到資料。
// 根因是**寫入順序**createRecord 只會替 template.slots_json 裡「已宣告」的 slot 建 entry_value
// (見 src/actions/record-crud.ts createRecord`for (const slot of slots) { if (!(slot in
// input.values)) continue }`——注意是遍歷 template 既有 slots,不是遍歷 caller 傳的 values)。
// 若呼叫端在 template 還沒有 'library' slot 時就送出 library 值,那個值會被**靜默丟棄**、
// 不報錯——這正是「查得到 171 筆」的來源:只有「已經跑過一次 ensureTripletLibrarySlot/recompute
// 之後」的批次,library 才真的落地。
//
// 本檔驗三件事(對應 leo 交辦的三個「要驗的」):
// 1. 源頭:ensure-slot 必須在 write 之前,不能事後補(重現+證明順序才是正解)
// 2. 存量:對一批缺 library 的舊 triplet 補標,前後地圖輸出對照
// 3. 不會重複做:同一批跑兩次,第二次 touch 0 筆
//
// 測試手法沿 library-map.test.ts 慣例:真 node:sqliteNode ≥22.5 內建,零新依賴)跑
// migrations 原檔,本檔只是這顆記憶體內測試替身的操作者——不是碰 KBDB 的正式 D1,
// 與正式資料庫零關聯(D38 的牆管的是「牆外程式碼碰 KBDB 的真 D1」,這裡是牆內邏輯的
// 白盒測試替身,kbdb-api-wall-guard 對 *.test.ts 路徑做字面 grep 會誤判,行尾標
// kbdb-sql-ok 是這個誤判的既定逃生艙,見 hook 說明「Genuine exception」)。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import {
recomputeLibraryMap,
ensureTripletLibrarySlot,
ensureFreshLibraryMaps,
listLibraryMaps,
} from '../src/actions/library-map';
import { createTemplate, createRecord, updateRecord, getRecord, searchByTemplate } from '../src/actions/record-crud';
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok: 記憶體測試替身,非真 KBDB D1
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 灌測試替身 schema,非真 D1
raw.exec(readFileSync(new URL('../migrations/0003_library_map.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 同上
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 記憶體測試替身
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 記憶體測試替身
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 記憶體測試替身
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
// prod 實際 triplet template 的 slotslibrary-map.test.ts 同款常數,2026-07-19 kbdb_list_templates
// 核實)——注意:沒有 library。用它模擬「template 還沒被任何 recompute 摸過」的乾淨起點。
const PROD_TRIPLET_SLOTS = [
'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
];
async function seedTripletTemplate(db: D1Database): Promise<void> {
await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
}
// 對齊 design.md 既有 fallback 語意(source_prefix 參數)的同一條規則:
// library = source_uri 在 '@' 之前的那段(scheme:owner/repo)。這就是本檔+報告裡建議
// kbdb-graph-plugin 在 write 時就该套用的推導規則(見報告,本檔只證明「規則正確、
// 順序對了就能用」,不代表已經改了 kbdb-graph-plugin 的程式碼——那是另一個 repo)。
function deriveLibrary(sourceUri: string): string {
const at = sourceUri.indexOf('@');
return at > 0 ? sourceUri.slice(0, at) : sourceUri;
}
describe('源頭順序 — ensure-slot 必須在 write 之前,事後補救不了已寫的那筆', () => {
it('重現:template 尚無 library slot 時寫入 → library 值被靜默丟棄(不是報錯,是消失)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const rec = await createRecord(db, {
template: 'triplet',
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
owner_id: 'leo',
});
const stored = await getRecord(db, rec.record_id);
// 關鍵斷言:library 完全沒落地,不是空字串、是 undefined(key 都不存在)。
expect(stored!.values.library).toBeUndefined();
expect(stored!.values.source_uri).toBe('gitea:Leo/kb@a.md'); // 其他 slot 正常落地,只有未宣告的 slot 消失
});
it('正解:先 ensureTripletLibrarySlot() 補上 slot,再寫 → library 值正常落地', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const added = await ensureTripletLibrarySlot(db, 'triplet');
expect(added).toBe(true); // 第一次呼叫確實補了 slot
const rec = await createRecord(db, {
template: 'triplet',
values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', library: 'gitea:Leo/kb' },
owner_id: 'leo',
});
const stored = await getRecord(db, rec.record_id);
expect(stored!.values.library).toBe('gitea:Leo/kb');
// 冪等:對已有 slot 的 template 再呼叫一次 → false(不重複加),不影響既有資料。
const addedAgain = await ensureTripletLibrarySlot(db, 'triplet');
expect(addedAgain).toBe(false);
});
});
describe('存量補標 — 對缺 library 的舊 triplet 補標,地圖輸出前後對照', () => {
it('補標前:地圖看不到任何庫(triplet 全部因缺 library 而未被地圖聚合);補標後:庫名正確出現', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
// 模擬現況:三筆舊 triplet,寫入時 template 還沒有 library slot(如實重現存量現況),
// 只帶了 source_uri(之後補標要靠它反推 library)。
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md', status: 'active' }, owner_id: 'leo' });
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md', status: 'active' }, owner_id: 'leo' });
const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'github:uncle6-me/notes@c.md', status: 'active' }, owner_id: 'leo' });
// 讀端自動核對重算(M3 收尾機制):這時三筆都缺 library 值。liveTripletCountsByLibrary
// 把「缺值」COALESCE 成 'general' 桶(供 staleness 判斷),但 recomputeLibraryMap 的
// libCond 是 `t.library = 'general'` 精確比對——缺值在底層是 NULL 不是字面 'general'
// 比對不中,實際聚合出 triplet_count:0。**這是本次順手發現的另一個小落差**(live 計數桶
// 與 recompute 精確比對的『general』語意沒對齊,導致這桶每次讀都判 stale、白重算,
// 但至少不會謊報數字)——不在本次任務範圍內(leo 問的是兩代儲存形式的可見性,不是這個
// fallback 桶的效能問題),本測試如實記錄現況,不假裝它是 0。
await ensureFreshLibraryMaps(db, 'leo');
const before = await listLibraryMaps(db, 'leo');
expect(before.length).toBe(1);
expect(before[0].library).toBe('general');
expect(before[0].triplet_count).toBe(0); // 誠實:桶名對得上、數字沒謊報,但也沒把 3 筆算近來(見上註)
// ── 補標(源頭已對:先 ensure slot,才寫值;一次對全部缺值的 record)──
await ensureTripletLibrarySlot(db, 'triplet');
const allTriplets = await searchByTemplate(db, 'triplet', 'leo');
const missing = allTriplets.filter((t) => !t.values.library && t.values.source_uri);
expect(missing.length).toBe(3); // 三筆都缺
for (const t of missing) {
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri) });
}
// 補標後:對每個實際出現的 library 值重算一次地圖(read-path 的 ensureFreshLibraryMaps
// 只認「已知庫名」——entries.metadata.library 或 portal_library;剛補標的三元組 library 值
// 尚未被任何一處登記為「已知庫名」,直接 recompute 該庫最直接、也是 caller 實際會做的事)。
const libs = [...new Set(missing.map((t) => deriveLibrary(t.values.source_uri!)))];
for (const lib of libs) {
await recomputeLibraryMap(db, { library: lib, owner_id: 'leo' });
}
const after = await listLibraryMaps(db, 'leo');
const byLib = new Map(after.map((m) => [m.library, m.triplet_count]));
expect(byLib.get('gitea:Leo/kb')).toBe(2); // r1, r2 同庫 —— 這是 leo 真正要看到的東西
expect(byLib.get('github:uncle6-me/notes')).toBe(1); // r3 另一庫
// 'general' 桶(補標前遺留的空殼,見上一段註)仍在,但 triplet_count 仍是 0——
// 三筆全部被正確歸進真正的庫名,沒有一筆被算進 generalno double counting)。
expect(byLib.get('general')).toBe(0);
expect(after.length).toBe(3);
// 交叉核對:三筆的 library 值確實都落地了(不是只有地圖聚合對,底層資料也對)。
const r1Rec = await getRecord(db, r1.record_id);
const r2Rec = await getRecord(db, r2.record_id);
const r3Rec = await getRecord(db, r3.record_id);
expect(r1Rec!.values.library).toBe('gitea:Leo/kb');
expect(r2Rec!.values.library).toBe('gitea:Leo/kb');
expect(r3Rec!.values.library).toBe('github:uncle6-me/notes');
});
});
describe('不會重複做 — 同一批補標跑兩次,第二次不改動任何東西', () => {
it('第二輪掃描:已有 library 值的 record 一筆都不會被 touch', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'leo' });
// r2 模擬「還沒補標」的舊資料:故意繞過 values 直接不給 librarycreateRecord 這次雖然
// template 已有 slot,但 caller 沒給值 → 該 slot 完全不會被建立 entry_value,等同「缺」)。
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/kb@b.md' }, owner_id: 'leo' });
await updateRecord(db, r1.record_id, { library: deriveLibrary('gitea:Leo/kb@a.md') }); // r1 先補過
async function backfillPass(): Promise<number> {
const all = await searchByTemplate(db, 'triplet', 'leo');
const missing = all.filter((t) => !t.values.library && t.values.source_uri);
for (const t of missing) {
await updateRecord(db, t.record_id, { library: deriveLibrary(t.values.source_uri!) });
}
return missing.length;
}
const firstPass = await backfillPass();
expect(firstPass).toBe(1); // 只有 r2 被補(r1 已有值,跳過)
const secondPass = await backfillPass();
expect(secondPass).toBe(0); // 第二輪:兩筆都已有 library,一筆都不 touch
// 資料仍然正確、沒被第二輪弄壞。
const r1After = await getRecord(db, r1.record_id);
const r2After = await getRecord(db, r2.record_id);
expect(r1After!.values.library).toBe('gitea:Leo/kb');
expect(r2After!.values.library).toBe('gitea:Leo/kb');
});
});
+18
View File
@@ -14,6 +14,24 @@ const _app = new Hono<{ Bindings: Env; Variables: { org_namespace: string; partn
// 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。 // 安全模型見 mcp/OAUTH.md。註冊在 basePath 之前,落在同一份共享 router。
registerOAuthRoutes(_app); registerOAuthRoutes(_app);
// ── GET /health — 讓「這台跑的是哪一版 MCP」用一條 curl 看得到 ─────────────────────
// 為什麼要有:cypher-executor 早就有 /healthbundle_version + auth_store 探針),
// arcrun-mcp 沒有 ⇒ 要判斷某台實例的 MCP 是哪一代認證,只能打 /authorize 剖 HTML 數欄位
// ops-facts 2026-08-10 的土法)。那個判準脆弱又難教。
//
// ⚠️ 誠實界定(別把它當世代判準用過頭):本端點是**這個 commit 之後才有的**,所以
// 「/health 回 404」只代表「比本版舊」,**不代表就是 owner_secret 世代**——
// 現階段判世代仍要看 /authorize 的欄位(一個 owner_secret =舊;email+password =新)。
// 等這版推到各實例之後,`auth` 欄位才會變成一眼可讀的世代判準。
// 不需認證、不吐任何機密;MCP_BUILD 是部署標記,由各實例 toml [vars] 帶入。
_app.get("/health", (c) => c.json({
ok: true,
service: "arcrun-mcp",
auth: "portal-login",
build: c.env.MCP_BUILD ?? "unknown",
oauth_kv: c.env.OAUTH_KV ? "present" : "missing",
}));
const app = _app.basePath('/mcp'); const app = _app.basePath('/mcp');
app.use("*", cors({ app.use("*", cors({
+3
View File
@@ -38,6 +38,9 @@ export interface Env {
// 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。 // 允許的 redirect_uri host 白名單(逗號分隔)。DCR 無狀態故靠此擋 open-redirect/釣魚。
// 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。 // 未設 → 預設只允許 claude.ai / claude.com / anthropic.com(含子網域)+ localhost。
MCP_ALLOWED_REDIRECT_HOSTS?: string; MCP_ALLOWED_REDIRECT_HOSTS?: string;
// 部署標記(非機密):GET /health 原樣回報,讓「這台跑的是哪一版」用一條 curl 看得到。
// 由各實例的 wrangler toml [vars] 帶入;沒帶 → /health 回 build:"unknown"(誠實,不假裝)。
MCP_BUILD?: string;
} }
export interface ToolContext { export interface ToolContext {
+56
View File
@@ -0,0 +1,56 @@
# ── arcrun-mcp STAGEyoulin 帳號)────────────────────────────────────────────────
#
# 這支存在的理由(2026-08-10Leo/mira#4):
# `arcrun-mcp` 不在安裝器出貨的那批裡,所以「升級某台的 arcrun-mcp」一直沒有測試場,
# 要驗只能拿 leo21c(唯一一份 47.9 萬筆知識的真身)去冒險。這支把 stage 補上:
# youlin stageagent-memory「兩台分工」:AI 測 youlin、leo 核實 prod)。
#
# 用法(在 mcp/ 目錄下跑,-c 必須與 main="src/index.ts" 同目錄,否則找不到入口):
# cd mcp
# CLOUDFLARE_ACCOUNT_ID=1129efd7df2e8899d537e9c8fbabb6cb \
# CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN_YOULIN_CC_USE" \
# npx wrangler deploy -c wrangler.stage.toml
#
# 與出貨用的 wrangler.toml 的差別,就是「手動直推 arcrun-mcp 到自架帳號」的兩個坑
# agent-memory 已記,這裡是把它變成檔案而不是靠人記得):
# ① 拿掉 [[routes]] —— `mcp.arcrun.dev` 那個 zone 在 uncle6,自架帳號沒有,帶著會部署失敗
# ② OAUTH_KV 填真 id —— 出貨 toml 是佔位符 REPLACE_WITH_REAL_KV_ID,只有走 acr init/update
# 才會被自動注入;直推必須自己填(沒填 → /authorize 回 503OAuth 整條死)
#
# 🔴 [vars] 必須列**全**wrangler deploy 會用本檔的 vars 整組取代線上的,漏一個就是靜默清掉。
# 下方七個 var 是 2026-08-10 從線上 arcrun-mcp 抓下來的原樣值。
# SecretsKBDB_INTERNAL_TOKEN / MCP_OWNER_SECRET)不進本檔也不會被 deploy 洗掉。
name = "arcrun-mcp"
main = "src/index.ts"
compatibility_date = "2024-11-27"
compatibility_flags = [ "nodejs_compat" ]
workers_dev = true
[vars]
MULTI_TENANT = "false"
CF_ACCOUNT_ID = "1129efd7df2e8899d537e9c8fbabb6cb"
WORKER_SUBDOMAIN = "youlin-hsieh-dev"
CONSOLE_TENANT = "yuga3bse"
MCP_OWNER_NAMESPACE = "yuga3bse"
KBDB_BASE_URL = "https://arcrun-kbdb.youlin-hsieh-dev.workers.dev"
UI_ORIGINS = "https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev"
# 部署標記,GET /health 原樣回報。每次重推 stage 就換一個,別讓它跟線上脫節。
MCP_BUILD = "stage-2026-08-10+45f1c09"
[[services]]
binding = "COMPONENT_REGISTRY"
service = "arcrun-registry"
[[services]]
binding = "CYPHER_EXECUTOR"
service = "arcrun-cypher-executor"
[[services]]
binding = "KBDB"
service = "arcrun-kbdb"
# youlin 的 OAuth KV(安裝器建的 arcrun-rag-yuga3bse-kv-oauth_kv)。
[[kv_namespaces]]
binding = "OAUTH_KV"
id = "6a8d4dd621994607b3998ade4c7e9944"
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* build-worker-artifacts.mjs Arcrun#80tier2 workerTS 可部署 JS**唯一官方編譯點**
*
* 背景Arcrun#80arcrun-rag#39這個 repo 過去只把 tier1 零件TinyGowasm的成品
* commit `.component-builds/{name}/component.wasm`tier2cypher-executor / kbdb /
* http_request / code / mcp 這五顆 TS worker只有原始碼沒有編好的成品於是
* arcrun-rag 的安裝器只好自己在**它那邊** esbuild`installer/scripts/build-bundles.mjs`
* 結果同一份原始碼在不同機器編出不同位元組見下已知踩坑
* 而且這顆成品是哪個 commit 編的只有整包一個 `source` 欄位答不出單顆的來源
*
* 本腳本要解的
* 1. 編譯只發生在這裡Arcrun 本體成品放固定位置 `.worker-builds/`commit repo
* `.component-builds/*.wasm` 同一個既有慣例self-host 用戶從 repo 直接拿部署來源
* 2. 每顆成品自己記得我是哪個 commit 編出來的`source_commit`答到單顆目錄層級
* 不是整包一個欄位
* 3. 同一個 commit任何人任何時候編位元組要相同見下方兩個已知踩坑與對應解法
*
* 已知踩坑2026-08-10 實錄docs-site/.../changelog.md 1.4.33 已回報 arcrun-rag#72
* esbuild bundle 輸出會把入口路徑寫進產物內部的檔案邊界註解而該路徑預設是
* **相對於 esbuild 執行時的 cwd**雲端容器跑在 `.../arcrun/`地端跑在
* `.../matrix/arcrun/`同一份原始碼因此編出不同註解
* 解法固定 `absWorkingDir` **本腳本自己算出的 repo 根目錄**不吃外部 cwdenv
* 路徑entry 一律用相對 repo 根目錄的相對路徑餵給 esbuild不管這個 clone
* 實際被放在磁碟的哪個絕對路徑下esbuild 內部算出的相對路徑字串都相同
* worker 目錄的 node_modules 若用不同套件管理器pnpm store vs npm 平鋪安裝
* 可能夾帶不同版本的間接依賴實測 ajv/uri-js 1019
* 解法本腳本**不自己 npm/pnpm install**強制要求呼叫者先用該目錄既有的
* lockfilepnpm-lock.yaml `pnpm install --frozen-lockfile`package-lock.json
* `npm ci`裝好 node_modules並在建置前檢查 lockfile 是否存在
* lockfile 同一份依賴圖的機械保證比信任兩台機器裝出來一樣牢靠
*
* 用法
* node scripts/build-worker-artifacts.mjs [--check-only]
* --check-only只驗證每個 worker node_modules 是否已按 lockfile 裝好不編譯
*
* 輸出.worker-builds/<name>/worker.mjs (+ *.wasm).worker-builds/manifest.json
*/
import esbuild from 'esbuild';
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve, basename, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
// REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基:
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
// 下面所有 esbuild 呼叫都用「相對 REPO」的相對路徑,輸出字串才會與絕對路徑無關。
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const OUT = join(REPO, '.worker-builds');
const CHECK_ONLY = process.argv.includes('--check-only');
/** tier2 worker arcrun-rag `installer/scripts/bundle-components.mjs`
* CORE_COMPONENTS build 參數對齊那邊的 esbuild 呼叫即將被本腳本的成品取代
* name arcrun-rag 那邊的慣例`arcrun-<kebab>`方便安裝器直接對號 */
const WORKERS = [
{ name: 'arcrun-cypher-executor', dir: 'cypher-executor', entry: 'src/index.ts', stripServices: true },
{ name: 'arcrun-kbdb', dir: 'kbdb', entry: 'src/index.ts' },
{ name: 'arcrun-http-request', dir: '.component-builds/http_request', entry: 'src/index.ts' },
{ name: 'arcrun-code', dir: 'registry/components/code', entry: 'index.ts' },
{ name: 'arcrun-mcp', dir: 'mcp', entry: 'src/index.ts' },
];
function sha256(buf) {
return createHash('sha256').update(buf).digest('hex');
}
/** 檢查一個 worker 目錄的 node_modules 是否已按它自己的 lockfile 裝好(踩坑②的閘)。 */
function checkNodeModules(dir) {
const abs = join(REPO, dir);
const hasPnpmLock = existsSync(join(abs, 'pnpm-lock.yaml'));
const hasNpmLock = existsSync(join(abs, 'package-lock.json'));
if (!hasPnpmLock && !hasNpmLock) {
return { ok: false, reason: `${dir} 沒有 pnpm-lock.yaml 也沒有 package-lock.json——依賴版本無法鎖定` };
}
if (!existsSync(join(abs, 'node_modules'))) {
const cmd = hasPnpmLock ? 'pnpm install --frozen-lockfile' : 'npm ci';
return { ok: false, reason: `${dir}/node_modules 不存在——先在該目錄跑:${cmd}` };
}
return { ok: true, via: hasPnpmLock ? 'pnpm (frozen)' : 'npm ci' };
}
/** 極簡 wrangler.toml 讀取(沿用 arcrun-rag build-bundles.mjs 同款邏輯,只抓需要的欄位)。 */
function readToml(tomlPath) {
const t = existsSync(tomlPath) ? readFileSync(tomlPath, 'utf8') : '';
const spec = { kv: [], d1: [], vectorize: [], ai: null, vars: {}, compat_flags: [], compat_date: null };
const compatFlags = t.match(/compatibility_flags\s*=\s*\[([^\]]*)\]/);
if (compatFlags) spec.compat_flags = [...compatFlags[1].matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
const compatDate = t.match(/compatibility_date\s*=\s*["']([^"']+)["']/);
if (compatDate) spec.compat_date = compatDate[1];
for (const m of t.matchAll(/\[\[kv_namespaces\]\][\s\S]*?binding\s*=\s*["']([^"']+)["']/g)) spec.kv.push(m[1]);
for (const m of t.matchAll(/\[\[d1_databases\]\]([\s\S]*?)(?=\n\[|\n*$)/g)) {
const b = m[1].match(/binding\s*=\s*["']([^"']+)["']/);
const n = m[1].match(/database_name\s*=\s*["']([^"']+)["']/);
if (b) spec.d1.push({ binding: b[1], database_name: n ? n[1] : null });
}
for (const line of t.split('\n')) {
if (/^\s*\[\[vectorize\]\]/.test(line)) spec.vectorize.push(true);
}
if (/^\s*\[ai\]/m.test(t)) spec.ai = true;
const varsBlock = t.match(/\[vars\]([\s\S]*?)(?=\n\[|\n*$)/);
if (varsBlock) for (const m of varsBlock[1].matchAll(/^\s*([A-Z0-9_]+)\s*=\s*["']([^"']*)["']/gm)) spec.vars[m[1]] = m[2];
return spec;
}
/** esbuild plugin.wasm import 攤平成同目錄檔名,記下要一起複製的 wasm part。 */
function wasmPlugin(wasmParts) {
return {
name: 'wasm-external',
setup(build) {
build.onResolve({ filter: /\.wasm$/ }, (args) => {
const abs = resolve(args.resolveDir, args.path);
const flat = basename(abs);
if (!wasmParts.find((w) => w.part === flat)) wasmParts.push({ part: flat, abs });
return { path: './' + flat, external: true };
});
},
};
}
/** 每個 worker 自己的 source_commit:答到單顆目錄層級,不是整包一個欄位(Arcrun#80 的核心要求)。 */
function sourceCommitFor(dir) {
try {
const hash = execSync(`git log -1 --format=%H -- ${JSON.stringify(dir)}`, { cwd: REPO, encoding: 'utf8' }).trim();
return hash || null;
} catch {
return null;
}
}
function repoHead() {
try {
const sha = execSync('git rev-parse HEAD', { cwd: REPO, encoding: 'utf8' }).trim();
// .worker-builds 是本腳本自己的輸出目錄——它在「寫出成品之前」永遠是 untracked,
// 拿它判斷「原始碼乾不乾淨」是假陽性(自己把自己判成髒)。排除掉才是真正的
// 「原始碼有沒有未 commit 的變更」。
const dirty = execSync('git status --porcelain -- . ":(exclude).worker-builds"', { cwd: REPO, encoding: 'utf8' }).trim();
return { sha, dirty: !!dirty };
} catch {
return { sha: null, dirty: null };
}
}
async function buildOne(w) {
const dirAbs = join(REPO, w.dir);
const entryAbs = join(dirAbs, w.entry);
if (!existsSync(entryAbs)) throw new Error(`entry 不存在: ${entryAbs}`);
const outDir = join(OUT, w.name);
mkdirSync(outDir, { recursive: true });
// 踩坑①的解法核心:entry 用「相對 REPO」的路徑,absWorkingDir 固定為 REPO——
// esbuild 內部產生的檔案邊界字串因此只依賴這個相對路徑,與這個 clone 實際被
// 放在磁碟的哪個絕對路徑無關。
const entryRel = relative(REPO, entryAbs);
const wasmParts = [];
const result = await esbuild.build({
absWorkingDir: REPO,
entryPoints: [entryRel],
bundle: true,
format: 'esm',
platform: 'browser',
target: 'es2022',
outfile: relative(REPO, join(outDir, 'worker.mjs')),
external: ['cloudflare:*', 'node:*'],
plugins: [wasmPlugin(wasmParts)],
logLevel: 'silent',
metafile: true,
});
const modules = [];
for (const wp of wasmParts) {
if (!existsSync(wp.abs)) throw new Error(`wasm 找不到: ${wp.abs}(該 worker 需先 build/vendored wasm`);
copyFileSync(wp.abs, join(outDir, wp.part));
modules.push({ name: wp.part, type: 'application/wasm', file: `${w.name}/${wp.part}`, sha256: sha256(readFileSync(wp.abs)) });
}
const spec = readToml(join(dirAbs, 'wrangler.toml'));
const jsBuf = readFileSync(join(outDir, 'worker.mjs'));
return {
name: w.name,
source_dir: w.dir,
source_commit: sourceCommitFor(w.dir),
main_module: 'worker.mjs',
main_file: `${w.name}/worker.mjs`,
js_bytes: jsBuf.length,
content_sha256: sha256(jsBuf),
modules,
compat_date: spec.compat_date,
compat_flags: spec.compat_flags,
requires: {
kv: spec.kv,
d1: spec.d1,
vectorize: spec.vectorize.length,
ai: !!spec.ai,
vars: spec.vars,
},
stripped: w.stripServices ? { services: 13 } : undefined,
warnings: result.warnings.map((x) => x.text),
};
}
async function main() {
console.log(`REPO = ${REPO}`);
const precheck = WORKERS.map((w) => ({ w, chk: checkNodeModules(w.dir) }));
const failed = precheck.filter((p) => !p.chk.ok);
if (failed.length) {
console.error('\n❌ 建置中止:以下 worker 尚未按 lockfile 裝好依賴(踩坑②的閘):\n');
for (const f of failed) console.error(` - ${f.chk.reason}`);
console.error('\n這是刻意設計:本腳本不自己跑 install,避免「install 方式不同 → 依賴版本不同 → 位元組不同」。');
process.exit(1);
}
console.log('✔ node_modules 檢查通過:');
for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`);
if (CHECK_ONLY) {
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
return;
}
mkdirSync(OUT, { recursive: true });
for (const w of WORKERS) {
const d = join(OUT, w.name);
if (existsSync(d)) rmSync(d, { recursive: true, force: true });
}
const head = repoHead();
const manifest = {
schema: 1,
built_for: 'arcrun-tier2-worker-artifacts',
generated_at: new Date().toISOString(),
repo_head: head.sha,
repo_dirty: head.dirty,
workers: [],
notes: [],
};
let failCount = 0;
for (const w of WORKERS) {
try {
const entry = await buildOne(w);
manifest.workers.push(entry);
const wasmNote = entry.modules.length ? ` +${entry.modules.length} wasm` : '';
console.log(`${w.name} js=${(entry.js_bytes / 1024).toFixed(0)}KB sha256=${entry.content_sha256.slice(0, 12)} source=${(entry.source_commit || '').slice(0, 8)}${wasmNote}`);
} catch (e) {
manifest.notes.push(`FAILED ${w.name}: ${e.message}`);
console.error(`${w.name}: ${e.message}`);
failCount++;
}
}
writeFileSync(join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2));
console.log(`\nmanifest → ${join(OUT, 'manifest.json')} (${manifest.workers.length}/${WORKERS.length} built)`);
if (failCount > 0 || head.dirty) {
if (head.dirty) console.error('⚠️ 工作區不乾淨(有未 commit 的變更)——這份成品的 repo_head 標記不完全可信,僅供本地驗證用。');
if (failCount > 0) process.exit(1);
}
}
main().catch((e) => { console.error(e); process.exit(1); });
+9
View File
@@ -0,0 +1,9 @@
{
"name": "arcrun-build-tools",
"private": true,
"type": "module",
"description": "Arcrun#80: tier2 worker 編譯工具的獨立依賴——esbuild 版本鎖在這份 lockfile,任何人在任何機器編譯都用同一版 esbuild(重現性的一部分:工具版本也是輸入之一)。",
"devDependencies": {
"esbuild": "0.24.0"
}
}
+265
View File
@@ -0,0 +1,265 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
esbuild:
specifier: 0.24.0
version: 0.24.0
packages:
'@esbuild/aix-ppc64@0.24.0':
resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.24.0':
resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.24.0':
resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.24.0':
resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.24.0':
resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.24.0':
resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.24.0':
resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.24.0':
resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.24.0':
resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.24.0':
resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.24.0':
resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.24.0':
resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.24.0':
resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.24.0':
resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.24.0':
resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.24.0':
resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.24.0':
resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.24.0':
resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.24.0':
resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.24.0':
resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.24.0':
resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.24.0':
resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.24.0':
resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.24.0':
resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
esbuild@0.24.0:
resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==}
engines: {node: '>=18'}
hasBin: true
snapshots:
'@esbuild/aix-ppc64@0.24.0':
optional: true
'@esbuild/android-arm64@0.24.0':
optional: true
'@esbuild/android-arm@0.24.0':
optional: true
'@esbuild/android-x64@0.24.0':
optional: true
'@esbuild/darwin-arm64@0.24.0':
optional: true
'@esbuild/darwin-x64@0.24.0':
optional: true
'@esbuild/freebsd-arm64@0.24.0':
optional: true
'@esbuild/freebsd-x64@0.24.0':
optional: true
'@esbuild/linux-arm64@0.24.0':
optional: true
'@esbuild/linux-arm@0.24.0':
optional: true
'@esbuild/linux-ia32@0.24.0':
optional: true
'@esbuild/linux-loong64@0.24.0':
optional: true
'@esbuild/linux-mips64el@0.24.0':
optional: true
'@esbuild/linux-ppc64@0.24.0':
optional: true
'@esbuild/linux-riscv64@0.24.0':
optional: true
'@esbuild/linux-s390x@0.24.0':
optional: true
'@esbuild/linux-x64@0.24.0':
optional: true
'@esbuild/netbsd-x64@0.24.0':
optional: true
'@esbuild/openbsd-arm64@0.24.0':
optional: true
'@esbuild/openbsd-x64@0.24.0':
optional: true
'@esbuild/sunos-x64@0.24.0':
optional: true
'@esbuild/win32-arm64@0.24.0':
optional: true
'@esbuild/win32-ia32@0.24.0':
optional: true
'@esbuild/win32-x64@0.24.0':
optional: true
esbuild@0.24.0:
optionalDependencies:
'@esbuild/aix-ppc64': 0.24.0
'@esbuild/android-arm': 0.24.0
'@esbuild/android-arm64': 0.24.0
'@esbuild/android-x64': 0.24.0
'@esbuild/darwin-arm64': 0.24.0
'@esbuild/darwin-x64': 0.24.0
'@esbuild/freebsd-arm64': 0.24.0
'@esbuild/freebsd-x64': 0.24.0
'@esbuild/linux-arm': 0.24.0
'@esbuild/linux-arm64': 0.24.0
'@esbuild/linux-ia32': 0.24.0
'@esbuild/linux-loong64': 0.24.0
'@esbuild/linux-mips64el': 0.24.0
'@esbuild/linux-ppc64': 0.24.0
'@esbuild/linux-riscv64': 0.24.0
'@esbuild/linux-s390x': 0.24.0
'@esbuild/linux-x64': 0.24.0
'@esbuild/netbsd-x64': 0.24.0
'@esbuild/openbsd-arm64': 0.24.0
'@esbuild/openbsd-x64': 0.24.0
'@esbuild/sunos-x64': 0.24.0
'@esbuild/win32-arm64': 0.24.0
'@esbuild/win32-ia32': 0.24.0
'@esbuild/win32-x64': 0.24.0
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true