chore: D22 落地——docs/SDD/wiki/CLAUDE.md 進 repo(Gitea private 預設全 push)

頂層 D22 決策(leo 2026-07-03 拍板):推什麼由開發環境歸屬決定,
Gitea private=除機敏值/build 產物/.github 外全 push。
解 T1.5 卡點:雲端工人 clone 拿得到 credential-store-migration.md,可就地改寫 SDD。
機敏掃描兩輪通過(新增 189 檔約 2.1MB,node_modules/dist/wasm 照舊排除)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-03 07:13:15 +08:00
parent c830150da1
commit 5d00e71275
190 changed files with 39486 additions and 14 deletions
+381
View File
@@ -0,0 +1,381 @@
# arcrun — 進度與待辦
> 設計細節見 `arcrun/README.md`(產品說明)和 `arcrun/BETA_TEST.md`(封測指南)。
> 這份文件只記錄:目前狀態、還差什麼、封測能不能啟動。
---
## 一、封測目標場景
封測者是工程師朋友,有自己的網頁,需要後端自動化。目標是他能在 AI 協助下,一次或很少次完成以下完整流程:
1. `acr init` 取得 api_key
2. `acr parts scaffold` 查零件格式,AI 幫寫 workflow YAML
3. 若內建零件不足,`acr recipe push` 增加打外部 API 的 recipe
4. `acr creds push` 上傳 OAuth tokengmail / google_sheets 等)
5. `acr push` 部署 workflow,取得 Webhook URL
6. 網頁 POST /webhooks/named/{name}/trigger,結果存 Google Sheets
---
## 二、場景各步驟驗證狀態
### Step 1acr init → api_key
- [x] `acr init` Standard 模式完成,api_key 存入 `~/.arcrun/config.yaml`
- [x] 已驗證:`mode: standard, api_key: ak_...` 正確
### Step 2acr parts scaffold → AI 看到零件格式
- [x] `acr parts` 列出 21 個零件,完全內建,不依賴 registry.arcrun.dev
- [x] `acr parts scaffold google_sheets` 輸出 spreadsheet_id / range / operation / values 格式與 credentials.yaml 範本
- [x] 已驗證:輸出可直接貼入 YAML
### Step 3acr recipe push → 打外部 API
- [x] `acr recipe push` 上傳成功,回傳 rec_hash
- [x] workflow 使用 `component: rec_xxxxxxxx`acr push 後 trigger 能正確呼叫外部 API
- [x] 已驗證(2026-04-18):httpbin_post recipe → trigger → httpbin.org/post 回傳正確 ✅
### Step 4acr creds push → 自動注入 token
- [x] `POST /credentials` API 完成,以 `{api_key}:cred:{name}` 存入 KV
- [x] Webhook trigger 時 injectCredentials 從 KV 取得 token 自動注入
- [x] `/register` 現在回傳 `encryption_key``acr init` 自動存入 config
- [x] `acr creds push` 從 config 讀 encryption_key,不再需要手動設定環境變數
- [x] 已驗證(2026-04-18):beta@arcrun.dev 帳號完整流程:init → creds push → trigger → credential 注入成功 ✅
### Step 5acr push → Webhook URL
- [x] `acr push workflow.yaml` 部署成功,顯示 Webhook URL 和完整 curl 範例
- [x] config 中的 `component` / 參數在 push 時套入 graph 節點
- [x] 已驗證(2026-04-18):sheet-test workflow push 成功 ✅
### Step 6:網頁 POST → 執行 → 結果到 Google Sheets
- [x] `POST /webhooks/named/{name}/trigger -H 'X-Arcrun-API-Key: ...'` 觸發執行正常
- [x] google_sheets 零件有實作(append row 到 Sheets API
- [x] 已驗證(2026-04-18):trigger sheet-test → 報「缺少 credential」(符合預期,credential 未上傳)✅
- [ ] **未驗證**:真實 google_oauth token + acr creds push → trigger → Google Sheets 實際寫入
- 需要真實 OAuth token 才能完整驗證
---
## 三、封測啟動阻擋項
P0 全部清除才啟動封測。
| # | 項目 | 狀態 | 說明 |
|---|------|------|------|
| 1 | acr parts scaffold 正確輸出 | ✅ 完成 | 21 個零件內建清單 |
| 2 | acr recipe push 端對端 | ✅ 完成 | httpbin_post 驗證通過 |
| 3 | acr creds push 代碼 | ✅ 完成 | 需 ARCRUN_ENCRYPTION_KEY |
| 4 | credential 注入端對端 | ✅ 完成 | 無 token 時錯誤訊息正確 |
| 5 | acr push + webhook trigger | ✅ 完成 | 端對端驗證通過 |
| 6 | acr creds push 實測 | ✅ 完成 | /register 回傳 encryption_keyacr init 自動存入 configCLI 1.0.9|
| 7 | Google Sheets 真實寫入 | ⚠️ 部分驗證 | credential 注入已驗證;實際 Sheets 寫入需真實 OAuth token |
| 8 | 第三方服務認證 recipe | ✅ 完成 | 20 個服務(Notion/Slack/GitHub/OpenAI 等),CLI 1.1.0 |
| **9** | **cypher-executor outbound HTTP fetch 全失效** | ✅ **已解決 2026-05-13**(CF 同 zone 自循環死鎖,改走 workers.dev)| 詳見下方專段 |
| **10** | **multi-node chain context propagation 漏失** | ✅ **已解決 2026-05-13**ON_SUCCESS/ON_FAIL/IF/ON_CLICK 沒 spread baseCtx| 詳見下方專段 |
**目前狀況**P0 全部解決。
- #9 修復方式:component worker URL 從 `*.arcrun.dev`(同 cypher zone)改走 `arcrun-{name}.{WORKER_SUBDOMAIN}.workers.dev`(避開同 zone 自循環)
- #10 修復方式:4 個 edge type 補 `{...baseCtx, ...result}`,跟 PIPE/FOREACH 一致
兩個 P0 解完 mira 7 節點 workflow 端對端通(含真 Claude 16 秒呼叫)。
---
### ✅ P0 #92026-05-13 已解決):cypher-executor outbound fetch 全失效
**完整事件報告(含誤判路徑)**[docs/incidents/2026-05-13-cypher-outbound-522.md](../../../docs/incidents/2026-05-13-cypher-outbound-522.md)
**修復方式**cypher-executor fetch component worker 從 `*.arcrun.dev`(同 zone)改走 `arcrun-{name}.{WORKER_SUBDOMAIN}.workers.dev`。對外 `cypher.arcrun.dev` 不變,用戶 0 感知。
**改動檔案**2026-05-13):
- `cypher-executor/src/lib/component-loader.ts``wasmWorkerUrl(canonicalId, subdomain)` 簽名加 subdomain 參數 + URL pattern 改 workers.dev
- `cypher-executor/src/actions/auth-dispatcher.ts`:同步新簽名
- `cypher-executor/src/types.ts``Bindings``WORKER_SUBDOMAIN: string`
- `cypher-executor/wrangler.toml``[vars]``WORKER_SUBDOMAIN = "uncle6-me"`
- 5 個 component worker 在 dashboard 啟用 workers.dev URLkbdb-get / kbdb-ingest / kbdb-create-block / kbdb-patch-block / claude-api**未來新 component 也都要開**
**驗證**cypher-executor → kbdb-get / claude-api 從 522 → 200。mira `acr run wiki_synthesis` 5 節點 workflow 跑通前 3 節點(kbdb_get chain)。
**Self-hosted fork 注意**:必須改 `wrangler.toml [vars] WORKER_SUBDOMAIN` 為自己的 CF 帳號 subdomain,並把所有 component worker 在 dashboard 啟用 workers.dev URL。
---
### ✅ P0 #102026-05-13 已解決):multi-node chain context propagation 漏失
**現象**cypher binding workflow 從第 2 個節點開始,原始 input contexttop-level `api_key` / `mira_token` 等)丟失,下游節點 `{{api_key}}` 模板原文未替換傳給零件 → 401 Unauthorized 或類似錯。
**測試重現**
```yaml
flow:
- "input >> ON_SUCCESS >> n1"
- "n1 >> ON_SUCCESS >> n2"
config:
n1: { component: kbdb_get, api_key: "{{api_key}}", block_id: "{{b1}}" }
n2: { component: kbdb_get, api_key: "{{api_key}}", block_id: "{{b2}}" }
context: { api_key: "ak_xxx", b1: "...", b2: "..." }
```
n1 收到 ctx 含 `api_key / b1 / b2` ✓ → 跑通。
n2 收到的 ctx 只有 `n1.output spread`blocks/count/success/block_id),**`api_key / b1 / b2` 不見**`{{api_key}}` 原文傳到零件回 401。
**根因**`graph-executor.ts` 在 PIPE / FOREACH 邊類型已修「baseCtx result」,但 **ON_SUCCESS / ON_FAIL / IF / ON_CLICK 四個 edge type 沒套同模式**,直接把 `result` 當下游 ctx 傳,丟掉原始 context。
**修法**`cypher-executor/src/graph-executor.ts` line 407 / 415 / 423 / 472):
```typescript
// 改前
result = await this.executeNode(nextNode, graph, result, ...);
// 改後(同 PIPE/FOREACH 模式)
const baseCtx = (typeof context === 'object' && context !== null) ? context as Record<string, unknown> : {};
const baseResult = (typeof result === 'object' && result !== null) ? result as Record<string, unknown> : {};
const mergedCtx = { ...baseCtx, ...baseResult };
result = await this.executeNode(nextNode, graph, mergedCtx, ...);
```
**驗證**mira `acr run wiki_synthesis` 7 節點 workflow 端對端跑通(16 秒,含真 Claude 呼叫)。每個節點都拿到正確 `api_key` 不再 401。
**歷史脈絡**:類似問題 2026-05-07 commit e8fca33 在 FOREACH edge 已修一次("FOREACH preserves outer context"),但當時沒同步處理另外 4 個 edge type。本次補完。
---
### ✅ P0 #10 補完三個衍生問題(2026-05-13 晚 ~ 2026-05-14
P0 #10 修完後 mira 嘗試做 wiki 多段結構,又踩出三個 cypher binding 設計缺陷。**都是同一天解掉**。
#### A. interpolateData() 不遞迴 nested object
**現象**`set` / `kbdb_create_block``values: { text: "{{classify.data.text}}" }``tags_json: ["facet:{{paragraph.facet}}"]` 等 nested config 內的 `{{x}}` 不被替換,原文傳給零件。
**根因**`interpolateData()` 只 iterate top-level,對非 string 值(object / array)直接 pass-through 不下沉。
**修法**:拆 `interpolateString` + `interpolateValue`(遞迴 object / array),`interpolateData` 改 call `interpolateValue`
**測試**`set values: { text: "hello {{name}}", arr: ["item {{name}}"] }``name=world` → 全展開。
#### B. ctx 沒存上游 output 的 node id namespace
**現象**`{{classify.data.text}}` 找不到上游 classify 的 output;只能用 `{{data.text}}`(直接 spread 取),但會被下個節點覆蓋,多節點 chain 用不了。
**根因**`propagateCtx` 只把上游 result spread 進 ctx,沒額外存 `[node.id]: result`
**修法**`propagateCtx` 改回傳 `{ ...baseCtx, ...baseResult, [upstreamNodeId]: upstreamResult }`。讓下游能用 `{{node_id.data.text}}` 從 namespace 取,永不被覆蓋。
**測試**5 節點 chain 用 `{{load_schema.blocks.0.content}}` / `{{classify.data.text}}` 全展開。
#### C. FOREACH 找 iterable 只看 result,不看 ctx + 不看 nested
**現象**mira wiki_synthesis 雙重 FOREACH(外層 `對每個 paragraph`、內層 `對每個 triplet`),外層 OK,內層跑 0 次。
**根因 (C1)**`getIterableFromContext(result, key)` 只看當前節點 output。`result``create_paragraph` output`{data, success}`),不含 paragraphs。但 `paragraphs` 早就在 ctx 從 classify spread 來。
**根因 (C2)**:當外層 FOREACH 把 `paragraph` item 注入 ctx,內層 FOREACH 要找 `paragraph.triplets``getIterableFromContext` 只看 top-level,看不到 `paragraph` 物件裡的 `triplets`
**修法**
- (C1) FOREACH `result` 找不到 iterable → fallback 找 `context`
- (C2) `getIterableFromContext` 加一輪「掃 ctx 內每個 object 找 nested key」
**測試**mira wiki_synthesis 3 層樹(wiki-page → paragraphs → triplets)端對端跑通,KBDB 內驗證 `物理 AI` wiki 有 2 段 paragraph + 4 個 tripletparent_id 正確接到對應 paragraph。
#### Edge type 一致化
`propagateCtx(context, result, upstreamNodeId)` helper5 個 edge typePIPE / ON_SUCCESS / ON_FAIL / IF / ON_CLICK / FOREACH)全部用同一 function 組下游 ctx。**未來新 edge type 必須用這 helper**,避免再漏。
#### CLI validator 同步
`cli/src/lib/yaml-parser.ts` validateRelations 加 regex 支援 `對每個 X` / `FOREACH X` 迭代器命名(之前 validator 字串完全比對擋住,但 graph-builder 執行端早已支援)。
---
### 三-A、P1 待改進(不擋封測,但 mira 已踩到)
#### ✅ P1 #3cypher-executor `scheduled()` handler2026-05-14 完成)
**原痛點**cron 零件只做 expression validationcypher-executor 沒 `scheduled()` handler。寫了 cron 首節點的 workflow 不會真的跑。
**之前的 workaround**(已撤):mira 寫了個 `/mira/wiki-from-raw` route 從前端 fire-and-forget 觸發 wiki_synthesis。但這違反「一律 arcrun-native」原則,也讓 arcrun 永遠補不齊缺失。**已刪 route,回 arcrun-native 路線**。
**落地**
1. `wrangler.toml``[triggers] crons = ["* * * * *"]`(每分鐘 tick
2. `src/lib/cron-match.ts`5 欄位 cron expression matcher(支援 `*` / `N` / `*/N` / `1-5` / `5,10` 組合)
3. `src/scheduled.ts`scheduled handler 掃 KV `cron-idx:` prefix,比對 controller.scheduledTime,匹配就 `executeWebhookGraph` 背景跑
4. `routes/webhooks-named.ts`acr push 偵測首節點是 cron 零件 → 抽 `cron_expr` 存進 record + 額外寫 `cron-idx:{api_key}:{name}` 輕量 index entry。DELETE 一併清理
5. `src/index.ts`export default 改 `{ fetch, scheduled }`
6. cypher-executor 自己加 `workers_dev = true` 給未來 self-trigger 用(fork 用 path-based 子 trigger 也走 workers.dev 避同 zone
**workflow YAML 慣例**
```yaml
flow:
- "my_cron >> ON_SUCCESS >> downstream_node"
config:
my_cron:
component: cron
cron_expr: "*/5 * * * *" # 每 5 分鐘
```
acr push 就會自動建立 cron-idx 並開始定時觸發。
**測試**`tests/arcrun-test/cron_heartbeat.yaml` — 每分鐘 fire 一次 + set 節點 log。
`wrangler tail arcrun-cypher-executor` 應看 `[scheduled] trigger cron_heartbeat ...`
**對應 use case**mira `mira_feed_watcher`(7B.3h,下一輪做)/ RSS 每日抓 / voice-stt 每小時掃 / 等所有 cron-driven source。
---
#### P1 #1workflow 缺 IF/branch 能力(2026-05-14 mira 7B.3f 提出)
**現象**mira 想做「找有則 PATCH 沒則 CREATE」(index-entry upsert),arcrun 目前只有 `ON_SUCCESS` + `對每個 X`FOREACH+ 已存在但壞掉的 `if_control`(見已知限制 #1),沒有 `>> ON_TRUE >>` / `>> ON_FALSE >>` 條件路由。
**短期 workaround**(已採用,2026-05-14):建 `kbdb_upsert_block` 零件,把分支邏輯封進零件內部(GET by page_name → 找到 PATCH 沒找到 POST)。caller 看到的是單純的 upsert 介面。
**長期解**:升 `if_control` false branch 路由 / 加 `>> ON_TRUE >>` edge type,讓 workflow 層可表達分支。對未來所有「找則改否則建」/「條件分流」場景都會撞到,不只 mira。
**位置**cypher-executor/src/graph-executor.ts edge type 處理(5 個 edge type 抽出 `propagateCtx` 後新增 IF 應該不難)+ cli/src/lib/yaml-parser.ts validator。
---
#### P0 #112026-05-14 已解決):interpolateString stringify array 撐爆下游
**現象**mira_feed_watcher 用 `items: "{{list_raws.blocks}}"` 把 kbdb_get 拿到的 blocks 陣列傳給 filter 零件。watcher 跑 264ms 完成、0 raw 處理。
**根因**`interpolateString` 看到模板就用 `String.replace`,非 string 值(陣列)一律 `JSON.stringify`。filter 零件收到字串 `"[{...},{...}]"` 不是 arrayitems 被忽略 → 0 matches → FOREACH 跑 0 次。
**修法**`interpolateString` 加 single-ref pass-through 規則:若整個值是純單一 `{{x}}` 引用,回 raw value(保留 array / object 型別)。多 ref / 混合文字仍 stringify 拼接字串。
**測試**mira_feed_watcher 推到 prod 後下一個 cron tick 觀察 wiki-processed tag 是否在 raws 上出現。
---
### 三-B、新零件加入紀錄
| 日期 | 零件 | 動機 | 對應 SDD |
|---|---|---|---|
| 2026-05-14 | `kbdb_upsert_block` | mira 7B.3f index-entry per-entity upsert,繞過 workflow 缺 IF/branch 能力(P1 #1)。內部 GET by page_name → 找到 PATCH 沒找到 POST。page_name 當 idempotency key。 | polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4.1 |
**新零件 checklist(避免 P1 #1 重蹈 kbdb_upsert_block 漏白名單覆轍)**
每加一個 API/data 零件(不是 logic primitive),都要:
- [ ] `registry/components/{name}/main.go` + `component.contract.yaml` + `go.mod`
- [ ] `tinygo build -target=wasi`
- [ ] `.component-builds/{name}/` 完整 4 檔(`wrangler.toml``workers_dev = true` + `pnpm-lock.yaml` + `tsconfig.json` + `src/index.ts`
- [ ] **`cypher-executor/src/lib/component-loader.ts``WASM_HTTP_RUNNER_IDS` 加 canonical_id**(漏這條 cypher-executor 永遠拋「找不到零件」,端對端會死靜悄悄)
- [ ] `acr validate workflow.yaml`
- [ ] 直接 curl `https://{kebab}.arcrun.dev` + `https://arcrun-{kebab}.{WORKER_SUBDOMAIN}.workers.dev` 都 200
---
### 三-C、P1 #2workers_dev = true 全 component 自動化(2026-05-14 已收)
**原痛點**:每新部署一個 component worker,要去 CF Dashboard 手動 Enable workers.dev URL,否則 cypher-executor fetch 該 worker 會 404。
**解**32 個 `.component-builds/*/wrangler.toml` 全部加 `workers_dev = true`。CI 每次 deploy 自動啟用對應 workers.dev URL,零手動。
**未來新 component**:模板 (`component-worker-template/`) 應該預設帶 `workers_dev = true`,新人 fork 不會踩。已列入「新零件 checklist」第 3 條。
**為何不走 `*.acr-comp.uncle6.me` 自訂 zone**CF Universal SSL 只發一層子域,sub-sub `*.acr-comp.uncle6.me` 不蓋;要 ACM ($10/月) 才能簽。違反 arcrun「fork 後 self-host 用 free tier 跑得起來」核心目標。workers_dev=true 走 CF 默認的 workers.dev certfree tier OK,更乾淨。
---
### 原 P0 #9 調查紀錄(保留作歷史參考)
**現象**cypher-executor 的 `makeHttpRunner` (`cypher-executor/src/lib/component-loader.ts:142`) 對任何 outbound URL fetch 都回 CF **522 (origin timeout, ~1000ms)**
**測試矩陣**mira repo `polaris/mira/arcrun/wiki_synthesis.yaml` 端對端壓測時發現):
| 路徑 | 結果 | 證明 |
|---|---|---|
| 本機 curl → kbdb-get.arcrun.dev | 200 (22ms) | KBDB worker 本身健康 |
| cypher-executor → kbdb-get.arcrun.dev (HTTP) | **522** (1002ms) | outbound HTTP fetch 壞 |
| cypher-executor → claude-api.arcrun.dev (HTTP) | **522** | 同 zone 也壞 |
| cypher-executor → httpbin.org (外部) | **522** | 不只是 same-account loop |
| cypher-executor → string_ops (Service Binding) | 200 ✅ | SVC_* 路徑正常 |
| acr run hello (built-in via SB) | ✅ | hello.yaml 仍跑得通 |
**衍生小 bug**`set` 零件 input schema 變了(要 `assignments` 陣列或 `values` 物件,不是 `value`)。tests/arcrun-test/hello.yaml 跑 string_ops 沒踩到。
**影響範圍(封測啟動阻擋)**
- 任何用戶 workflow 含 outbound HTTP 都壞:
- 用戶 `acr recipe push` 後 trigger → 打外部 API 全 522(推翻 P0 #2 4/18 紀錄)
- 用戶要存資料進 arcrun 內建 KBDB → 522mira 7B.3c 卡這裡)
- 任何 auth primitive 走獨立 Worker URL 路徑 → 522
- Google Sheets 寫入 → 522(推翻 P0 #7「待真實 OAuth 驗證」評估,根本還沒到 OAuth 步驟就壞)
- 只有「全內建邏輯零件 + 純 service binding」workflow 還能跑
**根因(2026-05-13 確認):5/8-5/9 9 次 manual `wrangler deploy` 把含 WIP bug 的 cypher-executor 推上 prod**
調查路徑(按時序):
1. 一開始懷疑 free tier CPU cap10ms / invocation)→ 對照測試「5 節點 SB chain」跑了 2.2 秒卻通過,**推翻**
2. 換懷疑 CF zone 規則 / Bot Fight Mode → dashboard bindings 乾淨無攔截,**推翻**
3. 用戶補繳費恢復 Workers Paid → 重測仍全 522**徹底排除付費假設**
4. 看 dashboard Version History5/8 4 次 + 5/9 5 次 = **9 次 manual `wrangler deploy by uncle6.me`**
5. 對照 GitHub Actions4/24 後完全沒 deploy(最後是 commit e222116 `fix(wasi-shim)`
6. 對照 git:本機 main **領先 origin/main 3 commits 未 push**,含:
- `497f92a feat(arcrun): recipe system + resumable workflow + component registry canon`
- `e8fca33 feat(cypher): 3-node wiki workflow end-to-end (FOREACH + nested interp + unified parsing)`
- `519423c feat(arcrun): mira wiki page with tag filter + accumulated WIP`(自描含 `cypher-executor: auth-dispatcher / wasi-shim adjustments (WIP)`
**結論**:那 9 次 manual deploy 把上面 3 個 unpushed commit 的 cypher-executor 改動推上 prod,其中至少一個改動破壞了 outbound fetch(最可能是 519423c WIP 內的 wasi-shim / auth-dispatcher 改動)。GitHub Actions 因為沒 push 沒跑,CI 沒 catch4/18-4/24 那段 SDD「驗證通過」的紀錄是 truth,現在 prod 是壞的版本。
**為何 SB 路徑沒事 / HTTP 路徑全死**SB 走 cypher-executor 內部 service binding API`env.SVC_X.fetch()`),不經過 outbound HTTP code path。HTTP 路徑走 `makeHttpRunner` (component-loader.ts:142) 的 `fetch(url, ...)`,這條路被 WIP code 弄壞。具體壞在哪要 diff 那 3 個 commit 的 cypher-executor 改動才知道。
**驗證 wrangler tail 證據**trigger 任何 outbound HTTP 的 graphcypher-executor 自己 `wallTime: 497ms, cpuTime: 2ms, outcome: ok`、無 logs、無 exceptions。代表 cypher-executor 把「fetch 失敗的 522 response」當作 component 正常輸出包回 client,自己沒撞任何錯。
**解法(三選一)**
- **A. Rollback prod 到 4/24 的 e222116** — CF dashboard → arcrun-cypher-executor → Deployments → 找 4/24 那筆 → Rollback。5 分鐘恢復 outbound fetch,丟失 wiki workflow / recipe / resumable 等 cypher 端 WIP 改動(但前端、registry components、KBDB blocks 都不丟,因為它們是別的 worker / 別的儲存)。**richblack 操作。**
- **B. Diff 3 個 unpushed commit 找出壞掉的改動修掉** — 不丟功能,但要動 src code 走 SDD 協議,30min - 數小時。
- **C. 架構切換**mira 老闆 2026-05-13 提的):sub-workflow 自殺交棒模式,cypher-executor 不再做集中 graph executor。從根本繞開「cypher-executor 一個 invocation 跑長 graph」這條脆弱路徑。一勞永逸但是大改。
**衍生小 bug 仍要修**(跟付費無關):`set` 零件 input schema 變了(要 `assignments` 陣列或 `values` 物件,不是 `value`)。要嘛 update set 零件 contract 容錯,要嘛文件化新 schema。
**為什麼這直接擋封測**
封測場景 Step 6「網頁 POST → 結果存 Google Sheets」走 google_sheets 零件 (HTTP outbound to googleapis.com)。如果 cypher-executor outbound 全壞,**封測者跑任何含外部 API 的 workflow 都會 522**,不是「Google Sheets 實際寫入未驗證」級別的小事。
**也直接擋 mira**[polaris/mira/.agents/specs/mira-app/tasks.md] 7B.3c-fwiki 合成 workflow)卡這裡。
---
## 四、封測前 P3(啟動當天)
- [ ] 用封測者 email 呼叫 `/register`,取得 api_key
- [ ] 將 ARCRUN_ENCRYPTION_KEY 以安全方式提供給封測者
- [ ] 確認聯絡管道
---
## 五、已知限制(封測期間不修)
1. `if_control` false branch 不路由(條件 false 時後續節點不執行)→ 升級計畫見 P1 #12026-05-14 mira 用 `kbdb_upsert_block` workaround
2. 多節點 context 不自動解包(上游輸出 flat merge,下游需從 `data.result` 取值)
3. 用戶自製邏輯零件(Phase 5)封測後才實作
---
## 六、實作進度
| Phase | 內容 | 狀態 |
|-------|------|------|
| 0 | Workers 部署、CI/CD、DNS | ✅ |
| 1 | CLI 基礎(init / validate / run / parts | ✅ |
| 2 | /register、/cypher/execute、21 個零件 | ✅ |
| 3 | Service Binding 架構、{{variable}} 插值、ON_FAIL 修正 | ✅ |
| 4 | 動態 Recipe KVCRUD)、acr recipe 指令 | ✅ |
| 5 | 用戶自製邏輯零件(WASM push) | ⏸ 封測後 |
| 6 | Credential 多租戶({api_key}:cred:{name})、acr creds push | ✅ |
| 7 | acr parts 內建清單、acr parts scaffold | ✅ |
| 8 | /webhooks/named、acr push 改版、config 套入 graph | ✅ |
### CLI 版本
| 版本 | 變更 |
|------|------|
| 1.1.0 | auth recipe 系統:20 個服務預建(Notion/Slack/GitHub/OpenAI/Google SA 等);acr auth-recipe 指令 |
| 1.0.9 | /register 回傳 encryption_keyacr init 自動儲存;creds push 不需手動設環境變數 |
| 1.0.8 | acr push → webhooks/namedconfig 套入 graphacr parts 內建清單 |
| 1.0.7 | acr creds push → POST /credentials |
| 1.0.6 | acr recipe push / list / delete |
| 1.0.5 | hello.yaml 改 string_ops--version 修正 |
| 1.0.4 | config/context 分離 |
| 1.0.3 | 初始發布 |
@@ -0,0 +1,295 @@
# Auth Recipe System — SDD
> 文件類型:SDDSoftware Design Document
> 建立:2026-04-19
> 狀態:實作中
---
## 一、目標
封測前完成,讓封測者碰到「我要連 X 服務」都有辦法,而不是「還沒做」。
**精神**`http_request` 是容器零件,auth recipe 是「如何對這個服務認證」的設定層,兩者分離。新增一個服務 = 寫一份 YAML,不需要改程式碼、不需要重新部署 Worker。
---
## 二、三層模型
```
Layer 3: Auth Recipe (YAML/JSON in RECIPES KV)
公共,描述「如何對某服務認證」
key: auth_recipe:{service}
例: auth_recipe:notion, auth_recipe:slack
↓ 引用
Layer 2: Auth Primitive (TypeScript in Worker)
四個通用認證邏輯:static_key | oauth2 | service_account | mtls
封測只做 static_key 和 service_account (Google JWT)
↑ 使用
Layer 1: Tenant Secret (CREDENTIALS_KV)
每個 tenant 自己的加密 credential
key: {api_key}:cred:{name}
```
---
## 三、Auth Recipe Schema
```typescript
interface AuthRecipeDefinition {
kind: 'auth_recipe'; // 區別 RecipeDefinition 用
service: string; // canonical_id, e.g. "notion"
version: number;
primitive: 'static_key' | 'oauth2' | 'service_account' | 'mtls';
base_url: string;
display_name?: string;
description?: string;
// service_account 用
service_account_kind?: 'google_jwt';
token_exchange?: {
endpoint: string; // e.g. https://oauth2.googleapis.com/token
scopes: string[];
};
required_secrets: Array<{
key: string; // CREDENTIALS_KV 的名稱
label: string; // UI/CLI 顯示
type?: 'string' | 'json_blob'; // default: string
help?: string;
help_url: string; // **必填**(官方文件連結,http(s)://)。見下「help_url 一律必填」。
}>;
inject: {
header?: Record<string, string>; // "Authorization": "Bearer {{secret.token}}"
query?: Record<string, string>;
body?: Record<string, string>;
// path:注入 endpoint URL path 的 secret2026-05-29 加)。
// 解 telegram 類「token 在 URL path」(/bot{token}/)—— header/query/body 都不適用。
// key = 模板變數名,API recipe 的 endpoint 用 {{auth.K}} 引用。
// 例:auth_recipe:telegram inject.path = { bot_token: "{{secret.telegram_bot_token}}" }
// recipe:telegram_send endpoint = "https://api.telegram.org/bot{{auth.bot_token}}/sendMessage"
path?: Record<string, string>;
};
created_at: number;
updated_at: number;
}
```
**Template 語法**
- `{{secret.KEY}}` → 從 tenant 的 CREDENTIALS_KV 解密取值
- `{{runtime.access_token}}` → service_account JWT exchange 後取得的短期 token
---
## 四、KV 儲存
沿用現有 `RECIPES` KV namespace,不新增 binding。
```
auth_recipe:{service} → AuthRecipeDefinition JSON
```
與現有 `recipe:{id}` / `idx:{hash}` 的 key 不衝突。
---
## 五、執行流程
### 5.1 static_key(涵蓋 ~80% 服務)
```
trigger → graph-executor
→ injectCredentials(componentId, input, env, apiKey)
→ resolveAuthRecipe("notion", RECIPES KV)
→ 取得 required_secrets: [{key: "notion_token", ...}]
→ 從 CREDENTIALS_KV 讀 "{api_key}:cred:notion_token"
→ AES-GCM 解密
→ 展開 inject.header templates ({{secret.notion_token}} → 實際值)
→ 注入 _auth_headers, _auth_query, _auth_body 到 input
→ makeAuthRecipeRunner(recipe)
→ 合併 _auth_headers 到 fetch headers
→ 呼叫 recipe.base_url + input._path
→ 回傳結果
```
### 5.2 service_accountGoogle 家族)
```
injectCredentials
→ resolveAuthRecipe("google_sheets_sa", RECIPES KV)
→ 解密 service_account_json (JSON blob)
→ signGoogleJwt(serviceAccountJson, scopes) via crypto.subtle (RSASSA-PKCS1-v1_5 + SHA-256)
→ POST token_exchange.endpoint → 取得 access_token
→ 展開 inject.header: { Authorization: "Bearer {{runtime.access_token}}" }
→ 注入 _auth_headers
```
---
## 六、Context key 慣例
注入後的認證資訊以 `_auth_` 前綴攜帶,不污染業務欄位:
| Key | 說明 |
|---|---|
| `_auth_headers` | `Record<string, string>` — 要合併進 fetch headers |
| `_auth_query` | `Record<string, string>` — 要附加到 URL query string |
| `_auth_body` | `Record<string, string>` — 要合併進 request body |
| `_auth_path` | `Record<string, string>` — endpoint URL path 用(2026-05-29 加)。`makeRecipeRunner` 的 endpoint interpolate 用 `{{auth.K}}` 從這裡取值 |
`makeAuthRecipeRunner` / `makeRecipeRunner` 在發出 fetch 前讀取這些 `_auth_*` 欄位,
之後從 auto-body 中剔除所有 `_` 前綴欄位(不洩漏給下游)。
## 七、API recipe 的 auth_service(多 recipe 共用一把 auth2026-05-29 加)
`RecipeDefinition``auth_service?: string` 欄位:API recipe **自報它屬於哪個服務**
auth-dispatcher 用它查 `auth_recipe:{auth_service}`,而非假設 componentId == service name。
- 讓多個 recipe 共用同一把 auth`recipe:kbdb_get` / `kbdb_create_block` 都設 `auth_service: "kbdb"`
→ 共用唯一的 `auth_recipe:kbdb`,加新 action 不必複製 auth recipe。
- auth-dispatcher 解析順序:先查 `recipe:{componentId}``auth_service`,有就用它;
沒有則 fallback 把 componentId 當 service name(向後相容舊行為)。
- 這是「服務身分標籤」非「許可清單」:auth_recipe 只定義「怎麼認證」,不含「誰准用」。
授權由發 API key 的服務裁決,arcrun 不做內部授權判斷(見 DECISIONS.md「arcrun 不做授權判斷」)。
---
## 七、向後相容
- 現有 `BUILTIN_API_RECIPES`gmail, google_sheets, telegram, line_notify**不動**
- 現有 `BUILTIN_CREDENTIALS_MAP` **不動**
- auth recipe 解析在 component-loader step 5.5(新增),在 step 6 KV recipe 和 step 7 builtin 之前
-`auth_recipe:{service}` 不存在 → 繼續往下走,行為與現在完全相同
---
## 八、新增/修改的檔案
| 檔案 | 類型 | 說明 |
|---|---|---|
| `cypher-executor/src/routes/recipes.ts` | 修改 | 加 `AuthRecipeDefinition` 型別、`resolveAuthRecipe``/auth-recipes` CRUD routes |
| `cypher-executor/src/actions/credential-injector.ts` | 修改 | 加 auth recipe 分支:static_key + service_account |
| `cypher-executor/src/lib/jwt-signer.ts` | 新增 | Google JWT signing via crypto.subtle |
| `cypher-executor/src/lib/component-loader.ts` | 修改 | step 5.5 auth recipe lookup + `makeAuthRecipeRunner` |
| `cypher-executor/src/lib/auth-recipe-seeds.ts` | 新增 | 20 個常用服務的 auth recipe 定義 |
| `cli/src/commands/auth-recipe.ts` | 新增 | `acr auth-recipe list/info/scaffold` |
| `cli/src/commands/parts.ts` | 修改 | `cmdPartsScaffold` fallback 到 auth recipe |
| `cli/src/index.ts` | 修改 | 註冊 auth-recipe 指令 |
---
## 九、封測前預計的 Auth Recipe 清單(20 個)
### static_key 類(~80% 服務)
| service | 認證方式 | credential key |
|---|---|---|
| `notion` | Bearer token (header) | `notion_token` |
| `slack` | Bot Token (Bearer) | `slack_bot_token` |
| `github` | PAT (Bearer) | `github_token` |
| `openai` | API key (Bearer) | `openai_api_key` |
| `anthropic` | API key (x-api-key) | `anthropic_api_key` |
| `airtable` | PAT (Bearer) | `airtable_token` |
| `discord` | Bot token ("Bot TOKEN") | `discord_bot_token` |
| `stripe` | Secret key (Bearer) | `stripe_secret_key` |
| `twilio` | AccountSid + AuthToken (Basic Auth) | `twilio_account_sid`, `twilio_auth_token` |
| `sendgrid` | API key (Bearer) | `sendgrid_api_key` |
| `hubspot` | Private App token (Bearer) | `hubspot_token` |
| `linear` | API key (Bearer) | `linear_api_key` |
| `shopify` | Admin API token (X-Shopify-Access-Token) | `shopify_access_token` |
| `resend` | API key (Bearer) | `resend_api_key` |
| `supabase` | Service role key (Bearer + apikey) | `supabase_service_key` |
| `typeform` | PAT (Bearer) | `typeform_token` |
| `jira` | API token + email (Basic Auth) | `jira_api_token`, `jira_email` |
### service_account 類(Google 家族,JWT signing
| service | scopes | credential key |
|---|---|---|
| `google_sheets_sa` | spreadsheets | `google_service_account` |
| `google_gmail_sa` | gmail.send | `google_service_account` |
| `google_drive_sa` | drive | `google_service_account` |
> 注意:三個 Google 服務可共用同一個 `google_service_account` credential,只是 scope 不同。
---
## 十、實作進度
### Server (cypher-executor)
- [x] `AuthRecipeDefinition` 型別 + `resolveAuthRecipe`
- [x] `/auth-recipes` CRUD routes
- [x] `injectFromAuthRecipe` — static_key primitive
- [x] `lib/jwt-signer.ts` — Google JWT via crypto.subtle
- [x] `injectFromAuthRecipe` — service_account primitive
- [x] `makeAuthRecipeRunner` in component-loader
- [x] step 5.5 in createComponentLoader
- [x] auth-recipe-seeds.ts (20 services)
- [x] seed script / deploy seeds to KV2026-04-19 全部 ✅)
### CLI (arcrun)
- [x] `commands/auth-recipe.ts` — list / info / scaffold
- [x] 更新 `commands/parts.ts` — scaffold fallback
- [x] 更新 `index.ts` — 註冊指令
- [x] 版本升 1.1.0
- [x] npm publisharcrun@1.1.0
### 驗證
- [ ] notion (static_key) 端對端
- [ ] google_sheets_sa (service_account) 端對端
- [ ] 舊有 google_sheets builtin 向後相容確認
---
## 十一、長期演進:TinyGo WASM Primitive(封測後)
> 參考:`docs/user_requirements/arcrun/credential_parts.md`
**目前封測版**Layer 2 primitive 邏輯在 `cypher-executor` TypeScript 中實作(`credential-injector.ts`)。
**長期目標**:四個 primitive 各自編譯為獨立 TinyGo WASM,取代現有 TS 實作:
```
arcrun/registry/components/auth_static_key/ ← TinyGo WASM
arcrun/registry/components/auth_oauth2/ ← TinyGo WASM
arcrun/registry/components/auth_service_account/ ← TinyGo WASM
arcrun/registry/components/auth_mtls/ ← TinyGo WASM
```
每個 primitive 實作統一 interface`Authenticate` / `NeedsRefresh` / `Refresh` / `Test`)。
切換時 `cypher-executor``injectFromAuthRecipe` 改為呼叫對應 WASM,邏輯不變。
**何時做**:封測驗證完成、TinyGo crypto 支援確認後(特別是 RS256/ES256 JWT signing)。
在此之前,**不建立任何 TypeScript SDK 或 Python SDK 來包裝 credential 邏輯**。
### 禁止的做法
- ❌ 建立 `js-sdk/``python-sdk/` 包裝 credential 加解密
- ❌ 在 client 端重實作 AES-GCM encrypt/decrypt
- ❌ 用 TypeScript 重寫已計劃用 TinyGo 實作的 primitive 邏輯
---
## help_url 一律必填(2026-06-06richblack
每個 `required_secrets[].help_url` **必填**(官方文件連結,`http(s)://`),**不分冷門/熱門服務**。
**為什麼**
- 「冷門/熱門」沒有客觀界線——不要讓系統判斷。統一「都要填」最簡單、可執行。
- AI/使用者設定 credential 時要去 provider 設定(建 token / Service Account / 分享資源給 client_email…)。
有官方連結 → AI 直接讀權威來源,不必花時間搜、也不會搜到過時/釣魚/非官方頁。
- 投稿者不一定會寫說明(mindset §7:不靠自律)→ 用機制硬性要求。
**enforcement(薄殼原則 rule 07:能力/驗證在 API,介面繼承)**
- `POST /auth-recipes``cypher-executor/src/routes/recipes.ts`)驗證:每個 secret 缺 `help_url`
或非 `http(s)://` → 400 拒絕。CLI/MCP/任何介面 push auth recipe 都經此 API → 自動受約束,不需各介面各自檢查。
- 種子(`auth-recipe-seeds.ts`)全部已含 help_url23/23 實測通過)。
- API recipe`POST /recipes`)不帶 secretssecrets 在 auth recipe),故 help_url 要求在 auth recipe 這層。
**誠實限制**:help_url 只保證「有指向官方文件」,不保證連結永遠有效(provider 可能改網址)。
這是「降低 AI 搜錯」的保險,非絕對正確;連結失效時 AI 仍可 fallback 去搜(但有起點)。
@@ -0,0 +1,184 @@
# Credential Store 遷移 SDD — KV → D1(目錄)+ Cloudflare Secrets Store(密文)
> 建立:2026-06-29 by arcrun CC|對應 issueArcrun#13(優先序 3)|決策:leo 2026-06-29 拍板(D19
> 範圍宣告:本檔是既有 SDD `credential-primitives-wasm/` 的補充設計(rule 02 §4.3 例外:現有 SDD 目錄內新增單檔)。**不施工,先 SDD,總管審對齊後放行。**
> 取代關係:本檔的儲存決策**取代** `credential-store-redesign.md` §4.5 的「維持 KV」推薦——leo 看完該推薦後**拍板走 D1+Secrets Store**D19),故 Phase B 翻回「搬」。redesign.md 的 Atelegram 一致性,已做)/ C(友善前門)/ Q1(acr parts)不受影響。
> 詞彙:component=TinyGo WASMrecipe=http_request+固定設定|workflow=多步 YAMLauth-recipe=credential 怎麼注入。
---
## 0. leo 的最終決策(D19,一句話)
**「擁有目錄,不擁有內容物」**arcrun 持有 credential 的**目錄**(看得到存了哪些、最後何時用),但**不持有、也讀不回密文明文**——連 owner 自己的界面都讀不到值。
動機(leo 親述):用戶 API key 外泄被盜刷的經濟損失 + 商譽風險(「就算不是我們過失也會怪在我們身上」)。**我們不持有明文、不持有金鑰 = 不背這個鍋。**
```
密文值(token / SA JSON / private key → Cloudflare Secrets StoreCF 託管金鑰,arcrun 拿不到明文)
credential 目錄(name/service/metadata → 使用者自己的 D1(不含密文,只含指向 Secrets Store 的 secret_ref
舊自管 ENCRYPTION_KEY + 密文存 KV → 廢掉(回填完成後);KV 降為暫存/熱讀快取,非真相源
界面 acr creds list → 讀 D1(顯示清單 + last_used),看得到但讀不回值
```
### n8n credentials UXD19 定案)
- 界面顯示**很多 credentials**(名字 / 服務 / metadata / last_used)。
- **不能 read 既有值、不能 edit 既有值**(連 owner 都不行)。只能:
- **整筆 replace**(重貼新值覆蓋舊的)
- **delete**
- read 不回值「不是限制、是設計」——值在 Secrets Storearcrun 拿不到。
---
## 1. 現狀(已核實 code2026-06-29
| 項目 | 現狀 | 檔案 |
|---|---|---|
| 密文儲存 | `CREDENTIALS_KV`key=`{api_key}:cred:{name}`,值=`{encrypted, iv}`AES-GCM | `cypher-executor/src/routes/credentials.ts:48` |
| 加密金鑰 | 自管全域 `ENCRYPTION_KEY`client 端加密、WASM `crypto_decrypt` 解密) | rule 01 加解密;`wasi-shim.ts` |
| 寫 | `POST /credentials` { name, encrypted, iv } | `credentials.ts:24-53` |
| 讀+解密 | `auth_static_key` WASM `kv_get``crypto_decrypt` | `registry/components/auth_static_key/main.go:146` |
| 列表 | `GET /credentials``KV.list({prefix})` 列名(不含值) | `credentials.ts:68-78` |
| D1 | 全 repo 無 credential D1D1 只有 KBDB graph | `kbdb/wrangler.toml:10` |
**風險點(leo 要廢的)**:自管單一 `ENCRYPTION_KEY` → 漏一把,全部 credential 裸。
---
## 2. 目標架構(D19
### 2.1 三方職責
| 元件 | 存什麼 | 誰拿得到明文 |
|---|---|---|
| **Cloudflare Secrets Store** | 密文值本體(token/SA JSON/private key | **只有 CF runtime 在執行期注入時**。arcrun TS/WASM/owner 界面**都拿不回** |
| **使用者的 D1**(與 KBDB 共用那顆 `arcrun-kbdb` | credential **目錄**`api_key / name / service / sensitivity / created_at / last_used_at / secret_ref`,**無密文** | 全員可讀目錄(非機密),**無人從這拿到值** |
| **CREDENTIALS_KV** | 過渡期:舊自管密文;遷移後:降為可選熱讀快取或廢用 | (遷移後不再是真相源) |
### 2.2 D1 schema(明確不含密文欄)
```sql
-- migrations/0002_credentials.sqlkbdb 同一顆 D1IF NOT EXISTS 冪等)
CREATE TABLE IF NOT EXISTS credentials (
api_key TEXT NOT NULL, -- 租戶
name TEXT NOT NULL, -- credential 名(= auth-recipe required_secrets[].key,如 telegram_bot_token
service TEXT, -- 對應 servicetelegram / notion …),可空
sensitivity TEXT NOT NULL DEFAULT 'standard', -- 'standard' | 'high'high = SA JSON/private key
secret_ref TEXT NOT NULL, -- 指向 Secrets Store 的 secret 名(不是密文本體)
created_at INTEGER NOT NULL,
last_used_at INTEGER, -- 執行注入時更新(治理面 last_used 顯示用)
PRIMARY KEY (api_key, name)
);
CREATE INDEX IF NOT EXISTS idx_cred_apikey ON credentials(api_key);
```
> ⚠️ **「name 是名字標籤不是密文」**leo 防誤解):`name`/`secret_ref` 都是字串標籤,**密文值永不進 D1**。
### 2.3 Secrets Store 整合(密文存 store、D1 存 ref
**已核實 CF Secrets Store 形態(wrangler 文件):**
- 管理 CLI`wrangler secrets-store store create/list/delete``wrangler secrets-store secret put/get/list/delete <STORE_ID> <name>`
- Worker bindingwrangler.jsonc):
```jsonc
"secrets_store_secrets": [
{ "binding": "CRED_STORE", "store_id": "<STORE_ID>", "secret_name": "<name>" }
]
```
- 執行期:`await env.CRED_STORE.get()` 回密文值。
**⚠️ 設計約束(必須在施工前對齊,本 SDD 標為待決)**:
上述 binding 形態要求 `secret_name` **在 wrangler config 部署時靜態宣告**——這不適合「每個用戶任意數量的動態 credential」(不可能為每個 token 改 toml 重部署)。三條候選路徑,**請 leo/總管拍板選一**:
1. **Secrets Store Account REST API(動態 by ref,推薦)**worker 執行期用 CF API`accounts/{id}/secrets_store/stores/{store_id}/secrets/{secret_ref}/value`,或官方提供的等價端點)依 `secret_ref` 動態取值。需一把「能讀 Secrets Store 的 CF API token」給 cypher worker(本身是高敏感、走 `wrangler secret put` 注入該 worker,非進 git)。**好處**:完全動態、不用為每 cred 改 toml。**代價**worker 持有一把能讀 store 的 token(但仍比「自管全域 ENCRYPTION_KEY」好——CF 託管、可輪替、範圍限 store)。
2. **靜態 binding + 預宣告**:只適合固定少量 credential,動態場景不可行 → 排除。
3. **過渡折衷**:高敏感(SA JSON/private key)走 Secrets Store;低敏感 token 暫留 KV(AES-GCM) → 但這不滿足 D19「廢自管 ENCRYPTION_KEY、owner 也讀不回」→ 僅作為回填未完成前的中繼。
> **本 SDD 預設走路徑 1REST by ref**,並標「待 leo 確認 self-hosted leo21c CF 帳號的 Secrets Store API 路徑與 token 範圍」。施工前需一次 spike 驗證 leo21c 帳號可建 store + worker 可 by-ref 取值。
**self-hostedleo21c**Secrets Store 是 CF 原生能力,leo21c 自己的 CF 帳號支援。`acr init/update` 需新增「ensure Secrets Store store 存在」步驟(類比現有 `ensureD1Database` / `ensureKvNamespace`),把 `store_id` 注入 worker。
> 註:這與「Anthropic Routine env-var 型 self-hosted 不支援」是**不同層**(那是 Anthropic 雲端;這是 CF 帳號能力)——別混(leo 提醒)。
### 2.4 寫入流程(client 加密 → Secrets Store
> ⚠️ 鐵律對齊:現行是 **client 端加密 + 自管 ENCRYPTION_KEY**。走 Secrets Store 後,**密文交給 CF 託管**arcrun 不再自管金鑰。client 端是否仍加密一層 = 待決(見 §6 開放問題 Q-b):
> - 選項甲:client 不再加密,明文值經 TLS 送到 cypher → cypher 用「能寫 store 的 token」寫進 Secrets StoreCF 端加密託管)。**arcrun TS 短暫經手明文**(記憶體中,不落地)。
> - 選項乙:保留 client 加密當「傳輸期保護」,但這需 arcrun 持有解密金鑰才能寫進 store 明文槽 → 又回到自管金鑰,違 D19。
> → 傾向**選項甲**(符合「不持有金鑰」),但「TS 短暫經手明文」需 leo 接受(mindset §7 誠實標:不是零接觸,是不落地、不持久、不持金鑰)。
### 2.5 讀取/注入流程(執行期 by ref)
1. cypher 執行 workflow,遇 recipe `auth_service='telegram'`。
2. 查 D1 `credentials` where api_key+name=`telegram_bot_token` → 取 `secret_ref` + `sensitivity`。
3. 用 `secret_ref` 向 Secrets Store 取密文值(路徑 1REST by ref)。
4. 依 `auth_recipe:telegram` 的 `inject.path/header` 注入(與現行 auth-dispatcher 同型,只是值來源從「KV 密文+WASM 解密」換成「Secrets Store 取值」)。
5. 更新 D1 `last_used_at`。
> ⚠️ rule 02 §2.2/§2.3 對齊:解密/注入仍不在 cypher TS 實作業務邏輯。Secrets Store 取值是「host 能力」(類比 `kv_get`/`crypto_decrypt`),應落在 wasi-shim host function(新增 `secret_get(ref)`)或等價的 host 邊界,**WASM 零件仍是注入邏輯的所在**。施工時須守此界線(見 §5)。
---
## 3. 治理 / UX 端點(n8n 模式)
| 端點 | 行為 | 對齊 D19 |
|---|---|---|
| `GET /credentials` | 讀 **D1**,回 `[{name, service, sensitivity, created_at, last_used_at}]`**不含 secret_ref 對外、不含值** | 顯示清單 + last_used |
| `PUT /credentials/:name`(replace | 整筆覆寫:寫新值進 Secrets Store(同 ref 覆蓋或新 ref+ 更新 D1 metadata | 只能 replace |
| `DELETE /credentials/:name` | 刪 D1 row + 刪 Secrets Store secret | 可 delete |
| ~~`GET /credentials/:name/value`~~ | **不存在 / 移除任何讀回值的路徑** | 讀不回值 = 設計 |
CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds replace <name>`(覆寫)、`acr creds delete <name>`。**移除任何「印出 credential 值」的路徑**。
---
## 4. 遷移:雙讀過渡 + 回填 + 回滾
### 4.1 雙讀過渡(不停機)
執行期取 credential 值的順序:
1. 先查 D1 有無 `secret_ref` → 有 → 走 Secrets Store 取值(新家)。
2. 找不到 → fallback 舊路徑:`CREDENTIALS_KV {api_key}:cred:{name}` + WASM `crypto_decrypt`(舊自管)。
→ 新寫入一律走新家;舊資料未回填前仍可讀。
### 4.2 回填(一次性、冪等、可審)
`POST /credentials/migrate-to-secrets-store`(類比現有 `migrate-cron-index` 冪等端點):
- 對每個 `{api_key}:cred:{name}` KV rowWASM 解密取明文 → 寫進 Secrets Store(領 `secret_ref`)→ 在 D1 建 row(含 ref + metadata)。
- 冪等:D1 已有該 (api_key,name) row 且 ref 可解析 → 跳過。
- **誠實回報**逐筆 ok/fail(不假綠,mindset §7)。
- **回填完成且驗證通過後**,才執行 §4.4 廢 ENCRYPTION_KEY。
### 4.3 回滾錨點
- 回填**不刪 KV 舊密文**(保留為回滾錨點),只新增 D1+Secrets Store。
- 出問題 → 雙讀 fallback 自動回到 KV 路徑;D1 row 可刪、Secrets Store secret 可刪 → 回到純 KV 狀態。
- 只有在「全租戶回填驗證綠 + 觀察期無 fallback 命中」後,才進 §4.4。
### 4.4 廢自管 ENCRYPTION_KEY(最後一步)
- 移除 `crypto_decrypt` 對 credential 的依賴路徑(KV 密文路徑停用)。
- `wrangler secret delete ENCRYPTION_KEY`cypher / auth_static_key / auth_service_account)。
- 清理 KV 舊密文(確認 Secrets Store 是唯一真相源後)。
- ⚠️ **此步不可逆**,須 leo 明示放行 + 回填觀察期通過。
---
## 5. 與鐵律 / 既有架構對齊(施工硬約束)
- **rule 02 §2.2/§2.3**cypher TS 不實作 credential 解密/注入業務邏輯。Secrets Store 取值走 host function 邊界(新增 `secret_get(ref)` 於 wasi-shim),注入仍在 WASM auth primitive。
- **rule 01 加解密**:自管 AES-GCM 路徑在回填後廢除;新家由 CF Secrets Store 託管金鑰(arcrun 不持金鑰)。
- **不新增 component**mindset §1);不新增 service bindingrule 03 §3.1)。
- **薄殼(rule 07**CLI/MCP 只做「list 顯示 / replace 覆寫 / delete」介面轉換,store 能力在 API。
- **部署繞開 GitHub Actions**D1 migration + Secrets Store ensure 走 `acr init/update`wrangler 直推),不掛 Actions。
- **D1 同源**:用使用者既有 `arcrun-kbdb` D1(不新建第二顆),migration `0002_credentials.sql` 走現有 `deploy.ts` migration 注入路徑(與 `0001_base.sql` 同模式)。
---
## 6. 開放問題(施工前須 leo / 總管拍板)
- **Q-a(核心)**Secrets Store 動態 by-ref 取值路徑(§2.3 路徑 1 REST API)—— leo21c 自己的 CF 帳號是否支援、API 路徑與 token 範圍?需一次 spike 驗證。**這是整個遷移的硬前置**(取值不通則 D19 走不了)。
- **Q-b**:寫入時 client 是否仍加密一層(§2.4 甲/乙)。傾向甲(不持金鑰,TS 短暫經手明文不落地)——需 leo 接受此誠實 trade-off。
- **Q-c**`sensitivity` 分級的判定(誰標 high/standard)——auth-recipe 可加 `sensitivity` 欄宣告該 service 的 credential 等級,或一律 high。
- **Q-d**:與 redesign.md C(友善前門:.env → 一次填)整合——C 的「值來源 .env」在新架構下是「.env 明文 → cypher 寫進 Secrets Store」,與 §2.4 甲一致。確認 C 依附本 SDDstore 是 Secrets Store 不是 D1)。
---
## 7. 任務分解(待放行後施工;本批僅 SDD)
- [ ] T1 spikeleo21c CF 帳號 Secrets Store create store + worker by-ref 取值驗證(解 Q-a)。**硬前置。**
- [ ] T2 D1 migration `0002_credentials.sql`(§2.2+ deploy.ts 注入。
- [ ] T3 `acr init/update` 新增 ensure Secrets Store store + 注入 store_id(類比 ensureD1Database)。
- [ ] T4 wasi-shim 新增 `secret_get(ref)` host function(守 rule 02 邊界)。
- [ ] T5 寫入路徑:`POST/PUT /credentials` 改寫 Secrets Store + D1 ref(§2.4)。
- [ ] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → Secrets Store 取值(§2.5+ 更新 last_used。
- [ ] T7 雙讀 fallback(§4.1)。
- [ ] T8 回填端點 `POST /credentials/migrate-to-secrets-store`(§4.2,冪等可審)。
- [ ] T9 治理端點 list/replace/delete + 移除任何 read-value 路徑(§3+ CLI 薄殼。
- [ ] T10 回填驗證 + 觀察期 → 廢 ENCRYPTION_KEY(§4.4leo 明示放行)。
> **每個 cred 操作跨 TS / WASM / host-function / 兩個 store,必端到端實測**(防再假綠,mindset §7)。T1 不通則整案停,先解 Q-a。
@@ -0,0 +1,211 @@
# Credential Store 重設計提案(A telegram 一致性 + B KV→D1 + C 友善前門)
> 建立:2026-06-29 by arcrun CC|更新:2026-06-29(依 leo 最終精確 spec 改寫)|對應 issueArcrun#13
> 範圍宣告:本檔是既有 SDD `credential-primitives-wasm/` 的補充設計筆記(不是新 SDD 子系統,rule 02 §4.3 例外)。
> 詞彙(leo 堅持精確):**零件/component=TinyGo WASM**、**recipe=http_request+固定設定(打最終 API**、
> **workflow=多步 YAML(在這層「按名指定要哪個 credential」)**、**auth-recipe=credential 怎麼注入**。
---
## 0. leo 的最終 spec(一句話):**不是拆除,是把前門做友善 + 把 D1 收尾**
**保留全部既有零件,只改「credential 怎麼餵進去」,不改 recipe 本身。**
```
人填 .env / 環境變數 ← 友善前門,任何人都會(本機=.env;雲端=code-on-web 的 Environment 設定,對稱)
↓ acr creds push 保留,但「值的來源」= .env/env-var(不再強迫手寫 credentials.yaml
長久存「我的 D1」 ← 存一次,存進使用者自己的 D1;之後自動,永不重推
↓ workflow YAML「按名」指定要哪個 credential"我要 google 那把")→ 自動從 D1 取
recipe 打最終 API ← telegram_send / notion / gsheets,保留,不刪
```
**對稱性**:本機讀 `.env`;雲端讀 code-on-web 的 Environment 設定。**同機制,只是來源位置不同。**
### 明確「保留、不可刪」
1. **`acr creds push` 保留** —— 手動推單一 credential 仍合法(只是不再是唯一/被迫的路徑)。
2. **最終 API recipe 保留** —— telegram_send / notion / gsheetshttp_request + 固定設定)是打 API 的端點,**不刪**。改的是「credential 怎麼餵」,不是 recipe。
---
## 0.5 Q1 — list 是動態讀 store 還是 hardcoded?(已核實 code2026-06-29
**結論:recipe/auth-recipe 的「正規清單」是動態的;但 `acr parts` 是 hardcoded 且把 5 個 recipe 混進去 → 那 5 個之外的 pushed recipe 在 `acr parts` 看不到。**
| 指令 | 讀哪裡 | 動態? | pushed recipe 會出現? |
|---|---|---|---|
| `acr recipe push` | `POST /recipes` → KV storerecipe.ts:87 | — | 寫入 store |
| `acr recipe list` | `GET /recipes``RECIPES.list({prefix:'recipe:'})`recipe.ts:182 / recipes.ts:260 | ✅ 動態 | ✅ 會 |
| `acr auth-recipe list` | `GET /auth-recipes``RECIPES.list({prefix:'auth_recipe:'})`auth-recipe.ts:42 / recipes.ts:547 | ✅ 動態 | ✅ 會 |
| **`acr parts`** | **hardcoded `BUILTIN_COMPONENTS` 陣列(parts.ts:28-262),完全不 fetch store** | **❌ 靜態** | **❌ 不會** |
- **component(零件=WASMhardcoded 是對的**:零件只能 PR merge 新增(mindset §4 人類閘門),固定、慢增,靜態清單反映真實。
- **但 `acr parts` 把 5 個 recipe 也 hardcode 進去**parts.ts:197-261gmail_send / google_sheets_append / telegram_send / line_notify_send / notion)——**這是 component 與 recipe 的 conflation**。recipe 是動態投稿的(任何人 `acr recipe push`),卻被釘進 `acr parts` 的靜態清單。
- **後果(leo 的擔憂成立,但範圍精確)**:用戶 `acr recipe push my_recipe` → 進 store → `acr recipe list` / `acr recipe search` **看得到**;但 `acr parts` **永遠看不到**(除非改 source 重發 CLI)。「submitted = invisible」**只發生在 `acr parts` 這個面**,不是整個生態壞掉。
- **與 telegram bug 的關係**telegram bug 是另一條軸(seed source vs live KV 漂移,A 已修),但與此**同病**=「hardcoded 清單 ≠ live store」。`acr parts` 列退役/釘選 recipe#13)也是這病的症狀。
### Q1 修法 scope(建議,未在本批改 code)
1. **`acr parts` 移除 5 個 recipe 的 hardcode**parts.ts:197-261),只留**真零件**logic/data/ai/http_request/cron…=WASMPR-only,靜態正確)。
2. **`acr parts` 末尾改成「指路」**:列完真零件後,動態提示「整合類服務 → `acr auth-recipe list`(即時讀 store);API recipe → `acr recipe list`(即時讀 store)」。讓 AI/人從 `acr parts` 被導到動態清單,不在靜態清單找 recipe。
- 進階(可選):`acr parts` 末段直接 fetch `GET /recipes` + `GET /auth-recipes` 把 store 的 recipe 也列出來(標明「動態,來自 store」),徹底消除「submitted = invisible」。
3. **明確分流原則寫進 parts.ts 註解**:零件=靜態(PR-only);recipe/auth-recipe=動態(從 store 讀)。不再把 recipe 釘進零件清單。
4. 這 scope #13「acr parts 殘留」的根治版(#13 只把 recipe 描述改對,沒解決「recipe 不該 hardcode 在 parts」的結構問題)。**屬薄殼修正(rule 07)=把「列 recipe」這能力從介面靜態資料改成讀 API store,不違禁令。**
---
## 1. 現狀(已核實 code + 實測 live2026-06-29
### 1.1 儲存:**KV,不是 D1**(B 的根因=未完成的遷移,非「設計即如此」)
- credential 只存 `CREDENTIALS_KV`key=`{api_key}:cred:{name}`AES-GCM。
- 寫:`acr creds push``POST /credentials``cypher-executor/src/routes/credentials.ts:48`)。
- 讀+解密:`auth_static_key` WASM `kv_get``registry/components/auth_static_key/main.go:146`)。
- binding`cypher-executor/wrangler.toml:23`;型別 `src/types.ts:31`
- **全 repo 無任何 credential 的 D1**D1 引用全是 KBDB graph)。
- leo 確認:採用 KBDB 後使用者已有自己的 D1,當時就要求 credential 也搬進「我的 D1」長久存 → **此遷移從未完成**
→ BKV→D1)**確定要做**,是 leo 明確要的「長久存我的 D1」層。
### 1.2 .env → credential**值不會自動進**(C 的痛點,但前門不友善 ≠ 拆掉)
- `.env` 目前只供「設定」:`ENCRYPTION_KEY`/`NAMESPACE`/`ARCRUN_*`/`CLOUDFLARE_*``cli/src/lib/config.ts:125`)。
- credential 的**值**只來自手寫 `credentials.yaml``acr creds push``cli/src/commands/creds.ts`)。
- leo 記憶「notion/gsheets 只靠 .env 就通」與 code 不符——實際是早先 push 過、值已在 KV。
- 痛點(北極星「拿 credential ≠ 攻打用戶」):每次要手寫 yaml;不像 n8n Credentials tab 填一次長存。
**C 不是發明「自動偵測魔法」**,而是把 `acr creds push` 的**值來源**從「手寫 yaml」改成「讀 .env/env-var」,存進 D1,之後 workflow 按名取。
### 1.3 telegram 漂移(A)——已實測 smoking gun,已修 source
- recipe `telegram_send``api-recipe-seeds.ts:108``auth_service:'telegram'`endpoint `.../bot{{auth.bot_token}}/sendMessage`
-`auth-recipe-seeds.ts` source 只有 **23** 個 service,缺 `telegram`/`line_notify`/`kbdb`/`google_user`
- 實測:prod = 27(手動 seed 過 telegram);self-host **leo21c = 23,無 telegram** → telegram_send 找不到 auth recipe → `{{auth.bot_token}}` 注入空 → URL 變 `.../bot/sendMessage` → 壞。notion/gsheets 通是因它們在那 23 裡。
- 這是「source vs live 漂移=假綠」。telegram **不是**走 legacy `BUILTIN_CREDENTIALS_MAP`,正確形態一直是 recipe+auth-recipe,只是 auth-recipe 沒進 source 種子。
---
## 2. 目標狀態(target,=leo 的 3 層流,全部保留只連得更友善)
1. **唯一打外部 API 的方式** recipe(保留)。telegram 與 notion/gsheets **同一條**「workflow 按名指定 → 從 D1 取 → auth-recipe 注入 → recipe 打 API」鏈,無例外(A,本次已修 source)。
2. **credential 長久存使用者自己的 D1**(如 n8n Credentials tab):填一次、長存、workflow 按名引用、永不重推(B)。
3. **友善前門**:使用者把 token 填進 `.env`(本機)/ code-on-web Environment(雲端)**一次**`acr creds push`(保留)以此為值來源,client 端加密後存進 D1。不再被迫手寫 `credentials.yaml`C)。
---
## 3. 遷移步驟(A 已做;B/C 是設計變更,誠實標工作量)
### Phase A — telegram/line_notify/kbdb 一致性(本次已做,commit 90777e3,未 push/merge
- [x] `auth-recipe-seeds.ts` 補 telegraminject.path bot_token/ line_notifyheader Bearer/ kbdbheader Bearer),形態取自 prod。
- [x] `recipes.ts` `AuthInjectSpec``path?`WASM/SDD §六早有、source 介面漏 → tsc 擋)。
- [ ] google_useroauth2)暫不回灌:內嵌 client_secret 不可進 git + 介面無 oauth2 欄 → 留 Phase D。
- 部署後 leo21c 跑 `acr update`(/init/seed) → 23→26telegram 即與 notion 同鏈可用。
- **不刪任何 recipe;不碰 `acr creds push`。** 純補種子 + 補型別欄位。
### Phase B —(已翻案,見 §4.5)儲存:**KV 維持,不搬 D1**;高敏感可選補 Secrets Store
> 早先版本寫「KV→D1 遷移」。§4.5 核實史實 + leo 自己 2026-06-07 拍板(credential 留 KV)後**翻案**
> credentialRedis-class(按 key 直接點查、密文不可 SQL 查),搬 D1 是淨負債。**B 改為:**
1. **低敏感 credential 維持 KV**(現狀 CREDENTIALS_KVAES-GCM)。`acr creds list` 已可用(`GET /credentials` KV.list)。
2. **(可選、後續)高敏感(service account JSON/private key)→ Cloudflare Secrets Store**credential_parts.md §8.2KV 只存 ref)。唯一值得的儲存升級,與拍板不衝突。
3. **不新增 D1 credentials 表、不新增 `cred_get` host function、不做 KV→D1 migration**——這些原計畫取消(好處 KV 已有)。
4. 若 leo 仍要 D1:屬新需求(須更新 2026-06-07 拍板)。即便如此,密文本體仍留 KV/Secrets StoreD1 只放非機密 metadata。
5. 風險:跨 TS Worker / WASM / host function 三層,**必端到端實測**(防再假綠,mindset §7)。**非小工程。**
### Phase C — 友善前門:`acr creds push` 值來源=.env/env-var → D1(小~中工程,依附 B)
> **精確(leo 修正)**:不是「自動 pickup 魔法」,是「`acr creds push` 保留,但值的來源改成 .env/env-var,存進 D1,之後 workflow 按名取」。
1. `acr creds push`(保留)新增「從 .env/env-var 讀值」模式:掃 .env 中「已知 credential 名」
(由 auth-recipe 的 `required_secrets[].key` 反推,例 `notion_token`/`telegram_bot_token`)→ client 端加密 → 存進 D1Phase B 的 store)。
- 仍保留現有「從 credentials.yaml 讀值」模式(向後相容,不刪)。yaml 變成可選輸入源之一,不再是被迫。
2. **對稱**:本機讀 `.env``config.ts loadDotEnvOnce` 已會載入 .env 進 `process.env`);
雲端=code-on-web 的 Environment 設定(同樣落到 env-var)。同一套讀法、不同來源位置。
3. 名稱規約:.env 用大寫(`NOTION_TOKEN`)→ normalize 成 credential 名 `notion_token`(=auth-recipe required_secrets.key)。
4. **薄殼界線**rule 07):這是「擴 `acr creds push` 的輸入源」=介面慣例的輸入解析(§2 允許第 1 項),
**不是**在 CLI 拼裝 API 缺的能力(值來源是 .env/yaml 都只是「轉成 API 期望的加密 payload」)。加密仍 client 端(§2 第 4 項唯一例外)。store 仍是 API(D1)。
5. 結果:填一次 .env → `acr creds push`(或 deploy 末尾自動觸發一次)→ 長存 D1 → workflow 按名引用、零重複推。
### Phase D(後續,非本批)— google_user(oauth2) 回灌 + oauth2 secret 部署期注入
- AuthRecipeDefinition 介面補 oauth2 欄位;client_secret 走 `wrangler secret`/env 注入(不進 git),再回灌 source。
---
## 4. 正解:telegram 的 workflow / recipe yaml(給 mira 抄)
telegram 發訊=**recipe `telegram_send`**(不是 component、不是自建零件);**credential 在 workflow 層按名指定**
```yaml
name: notify_done
flow:
- "notify >> ON_SUCCESS >> end" # 單節點時 flow 可省
config:
notify:
component: telegram_send # ← 內建 recipe canonical_id(不是 component:telegram
chat_id: "123456789"
text: "✅ 任務完成"
# bot_token 不寫這裡。recipe 的 auth_service='telegram' → 按名從 D1 取 telegram_bot_token
# → auth-recipe:telegram 的 inject.path 注入進 URL path。一致鏈,與 notion/gsheets 同型。
```
**credential 解析(target 設計):**
1. 使用者把 `TELEGRAM_BOT_TOKEN=123456:ABC...` 填進 `.env`(本機)/ code-on-web Environment(雲端)**一次**。
2. `acr creds push`(保留;值來源=.env)→ client 加密 → 存進「我的 D1」credential store。**之後永不重推。**
3. 執行時:cypher-executor 見 `telegram_send.auth_service='telegram'``auth_static_key` WASM 用 `cred_get` 從 D1 取
`telegram_bot_token` 密文 → `crypto_decrypt` → 依 `auth_recipe:telegram``inject.path.bot_token` 輸出 `auth_path`
`makeRecipeRunner` 把 endpoint `{{auth.bot_token}}` 換成真 token → POST `https://api.telegram.org/bot<token>/sendMessage`body=`{chat_id,text}`
這條鏈與 notionheader 注入)/gsheetsservice_account runtime token 注入)**完全同型**,只注入位置不同(path vs header)。
> **過渡期(B/C 未完成前,今天就能用的方式)**:在 `credentials.yaml` 放 `telegram_bot_token` + `acr creds push`(值進 KV)。
> 上面 yaml 的 workflow 寫法不變——只是 store 暫時是 KV、值來源暫時是手寫 yaml。B/C 完成後 store→D1、來源→.env**workflow yaml 一字不改**(按名引用穩定)。
---
## 4.5 Q2 — KV vs D1:史實核實 + 三方調和 + CC 推薦(leo 授權我決定)
### 史實(git 核實,2026-06-29):**本 repo 的 credential 從來沒在 D1**
- 最早 MVP commit `2707fca`credential 即「AES-GCM decrypt from **CREDENTIALS_KV**」。
- 更早的 u6u-credentials ancestor worker`credentials/` 已刪):`CREDENTIALS_KV.put('cred:${id}')`——**也是 KV**。
(它的 `CredentialRecord` 已有 name/type/created_at 結構欄位,但以 JSON 存進 KV。)
- 全 history 的 `CREATE TABLE` 只有 KBDBentries/templates/entry_values),**從無 credentials 表**。
-**找不到任何「credential D1→KV 撤退」的 commit**。leo 記憶的「原本 D1 有 credentials 表、後來退 KV」
**在本 repo 史實裡不存在**(可能是 pre-repo 設計或別處,沒落地過 code)。
### 三方說法(全部攤開,none 寫 D1 存 credential
| 來源 | 對 credential 儲存的說法 |
|---|---|
| leo 口頭(現在) | 存 **D1**(「長久存我的 D1」) |
| 現行 code | **KV**CREDENTIALS_KVMVP 至今未變) |
| **kbdb-base SDD 拍板 2026-06-07leo 親簽「Leo:同意」)** | **credential 留 KV**(「session、verdict、credential 留 KV=短期高頻純取用,類比 Redisworkflow/recipe/成功記錄才進 D1=要層級/列舉/排序」)。`kbdb-base/design.md:318``tasks.md:11``tasks.md:83` 三處一致。 |
| credential_parts.md §8.2(長期需求源) | **tenant KV + Cloudflare Secrets Store**(低敏感→KV AES-GCM;高敏感如 service account JSON/private key→Secrets StoreKV 只存 ref)。**不是 D1。** |
**leo 口頭「D1」與他自己 2026-06-07 的書面拍板(credential 留 KV)直接衝突**,且兩份書面需求源都沒說 D1。
SDD 協議(00-sdd-protocol「規範互相矛盾→引原文、不自行猜」)要求攤開矛盾;但 leo 此次**明確授權「你決定並推薦」**,故下方給推薦(grounded in 上述證據),同時把矛盾標清楚讓 leo 有機會推翻。
### 「退 D1→KV 的理由還成立嗎?」(leo 的核心提問)
- 本 repo 沒發生過該退守,故無「當初理由」可考。但 leo 假設的理由(「self-hosted 少依賴、免 D1」)**今天確實已 moot**——
self-hosted 用戶現在本來就有一顆 KBDB D1(`kbdb/wrangler.toml:10` `[[d1_databases]]`deploy.ts 注入用戶自己的 id)。
- **但「能用 D1」不等於「credential 該用 D1」。** 2026-06-07 拍板把 KV 留給 credential 的理由不是「沒有 D1」,
而是**「credential =短期高頻純取用(by api_key+name 直接點查),是 Redis-class 工作負載,不需要 D1 的層級/列舉/排序」**。
這個理由**與有沒有 D1 無關,今天仍成立**。
### ✅ CC 推薦(leo 授權我決定):**credential 主存維持 KV;不為「列表/按名引用」而搬 D1**
理由(誠實 trade-offmindset §7):
1. **守 leo 自己的拍板**2026-06-07「credential 留 KV」是有理由的設計(Redis vs PostgreSQL 分工),非偷懶。推翻它要有新理由,目前沒有。
2. **D1 宣稱的好處在 KV 上已可達或不需要**
-`acr creds list`」KV 已能做(`GET /credentials``KV.list({prefix})` 列名,credentials.ts:68-78 已實作,**今天就有**)。
- 「按名引用(workflow 指定要哪把 cred)」是 **key 設計**`{api_key}:cred:{name}`)就給的,與 KV/D1 無關——telegram_send 的 `auth_service``required_secrets.key` 已是按名取。**A 修好後就具備,不需 D1。**
- 「結構化欄位/sensitivity 分級」credential 是不可查詢的密文 blob(查詢明文=洩漏),D1 的 SQL 查詢優勢對 credential **用不上**;分級需求由 credential_parts.md 的 **Secrets Store**(高敏感)解,不是 D1。
3. **搬 D1 成本高且踩鐵律邊界**:要新 host function `cred_get`WASM 改)、一次性 migration、跨 TS/WASM/host-function 三層 e2e——換來的好處 KV 已有。**淨負債。**
4. **真正缺的不是 D1,是「友善前門 C」**:leo 真痛點是「每 cred 手寫 yaml + 重推」,那是 **C(.env 當值來源 + 一次存)** 解的,**與 store 是 KV 還 D1 無關**。
**推薦的目標儲存(取代本檔早先「搬 D1」設計):**
- **低敏感 credential(多數 token/api_key)→ 維持 KV**(現狀,AES-GCM)。
- **高敏感(service account JSON / private key)→ Cloudflare Secrets Store**(依 credential_parts.md §8.2KV 只存 ref)——這是**唯一值得做的儲存升級**,且與 leo 拍板不衝突(拍板說 credential 留 KV,未禁高敏感走 Secrets Store;兩者互補)。
- **C(友善前門)照做**`acr creds push` 值來源=.env/env-var、存一次、workflow 按名取。**store 仍 KV(+Secrets Store)**。
-**B 從「KV→D1 遷移」改為「KV 維持 + 高敏感補 Secrets Store(可選、後續)」**。telegram/notion/gsheets 的一致鏈(A)與一次填(C)**完全不依賴 D1**。
> 若 leo 看完仍要 D1(例如想未來在同一顆 DB 做 cred 審計/輪替記錄):那是**新需求**,需更新 2026-06-07 拍板。
> 屆時 credential **本體**仍建議 KV/Secrets Store(密文不需 SQL),D1 只放**非機密的 metadata**cred 名稱/服務/建立時間/last_verified),與 workflow/recipe 進 D1 同模式。**不要把密文搬進 D1 當主存。**
---
## 5. 與既有鐵律/SDD 的對齊
- **不刪 recipe、不禁 `acr creds push`**leo spec)。A 純補種子 + 型別欄位;C 是擴 `acr creds push` 輸入源,非拆除。
- 不新建 componentmindset §1 / rule 02)。
- credential 解密仍在 WASMrule 02 §2.2);KV→D1 只是密文來源從 `kv_get` 換成新 host function `cred_get`(屬 wasi-shimrule 02 §2.3 合法)。
- 薄殼(rule 07):`acr creds push` 讀 .env 仍只做「輸入解析 + client 加密 + 呼叫 API」,能力(store)在 API/D1,不在介面層。
- 設計權威:`auth-recipe.md` §六(telegram path 注入 line 70-71)、§七(kbdb 共用 line 150-151);
需求源 `docs/user_requirements/credential_parts.md`credential 長期規格)。
@@ -0,0 +1,245 @@
# Design Document: Credential Primitives TS → WASM 改寫
## Overview
`cypher-executor` 中以 TypeScript 實作的 credential 注入邏輯,改寫為 4 個獨立的 WASM 零件。這是 `credential_parts.md` 長期規格的實現,不再是「未來 Phase」。
**動機**:TS 實作無法在地端(workerd)和邊緣端(Wazero)執行。WASM 零件跨 runtime 可攜,符合 u6u 三層部署架構。
**嚴格規範(richblack 2026-04-19 確認)**cypher-executor TS **完全不實作**任何 credential / auth / template / JWT / 解密邏輯。所有業務邏輯必須在 TinyGo WASM 零件內。TS 僅負責 HTTP routing + 呼叫 WASM + host function 提供 runtime primitivecrypto.subtle / KV / fetch)。
---
## 現有 TS 實作(要刪除的)
| 檔案 | 功能 | 對應 WASM Primitive |
|------|------|---------------------|
| `credential-injector.ts``injectFromAuthRecipe()` | static_key template 展開 | `auth_static_key` |
| `credential-injector.ts` — service_account 分支 | JWT signing + token exchange | `auth_service_account` |
| `credential-injector.ts``decryptCredential()` | AES-GCM 解密 | host function(所有 primitive 共用) |
| `credential-injector.ts``interpolateTemplate()` | `{{secret.KEY}}` 替換 | 內建在各 primitive |
| `jwt-signer.ts``exchangeGoogleJwt()` | PEM→PKCS8→RS256→token | `auth_service_account` |
| `component-loader.ts` — BUILTIN_API_RECIPES | gmail/telegram/line/gsheets 寫死邏輯 | 刪除,改用 auth recipe + `http_request` 零件 |
| `credential-injector.ts` — BUILTIN_CREDENTIALS_MAP | 舊路徑 flat injection | 刪除,統一走 auth recipe |
| `arcrun/credentials/` | 重複的 credentials Worker | 刪除,路由已在 cypher-executor |
---
## 4 個 WASM Primitive 設計
### 統一 I/O 介面(stdin/stdout JSON
```
stdinWorker → WASM):
{
"action": "authenticate" | "needs_refresh" | "refresh" | "test",
"api_key": "ak_xxx", // 租戶識別,用來組 KV key
"service": "openai", // 對應 auth_recipe:{service}
"request": { "method": "GET", "url": "/path", "headers": {}, "body": null }
}
WASM 內部流程:
1. recipeJSON = kv_get("auth_recipe:" + service)
2. 依 recipe.required_secrets 逐一 kv_get("{api_key}:cred:{name}") → {encrypted, iv}
3. secrets[name] = crypto_decrypt(encrypted, iv)
4. service_accountcrypto_sign_rs256(jwt, pkcs8) + http_request 換 token
5. 展開 recipe.inject 的 {{secret.X}} / {{runtime.X}} 模板
stdoutWASM → Worker):
{
"success": true,
"auth_headers": { "Authorization": "Bearer xxx" },
"auth_query": {},
"auth_body": {},
"runtime": { ... updated runtime state,供下次 refresh 用 }
}
```
### auth_static_key
**位置**`arcrun/registry/components/auth_static_key/`
**語言**TinyGo 或 AssemblyScript
功能:
1. 讀取 `recipe.inject.header/query/body` 模板
2.`secrets` 展開 `{{secret.KEY}}` 模板
3. 回傳 `auth_headers` / `auth_query` / `auth_body`
涵蓋:~80% 服務(Bearer token, API Key, Basic Auth, custom header
### auth_service_account
**位置**`arcrun/registry/components/auth_service_account/`
**語言**TinyGo 或 AssemblyScript
功能:
1.`secrets.service_account_json` 解析 private key
2. JWT signingRS256PEM→PKCS8→sign
3. POST token exchange endpoint → 取得 access_token
4. 展開 `{{runtime.access_token}}` 模板
**crypto 考量**
- TinyGo 的 `crypto/rsa` + `crypto/x509` 支援有限
- 若 TinyGo 不支援 RS256:使用 host function 讓 Worker 的 `crypto.subtle` 代簽
- 或改用 AssemblyScript(有 as-crypto 套件)
### auth_oauth2(新建)
**位置**`arcrun/registry/components/auth_oauth2/`
功能:
1. `needs_refresh`:檢查 `runtime.expires_at` 是否過期
2. `refresh`:用 `runtime.refresh_token` + `secrets.client_secret` 換新 token
3. `authenticate`:展開 `{{runtime.access_token}}` 到 headers
### auth_mtls(新建)
**位置**`arcrun/registry/components/auth_mtls/`
功能:
1.`secrets` 讀取 client cert + key
2. 回傳 TLS 設定(由 Worker runtime 執行實際 mTLS handshake
---
## cypher-executor 改動
### 保留(TS routing 層)
- `routes/credentials.ts` — HTTP CRUD for credentials(接收加密的 payload
- `routes/recipes.ts` — HTTP CRUD for auth recipes
- `routes/auth.ts` — OAuth flow routing
- `graph-executor.ts` — workflow 執行排程
- `lib/wasi-shim.ts` — WASM runtime + host functions(加解密 / KV / 簽章 / HTTP 實際由 `crypto.subtle` / env binding / fetch 執行,但**呼叫時機由 WASM 決定**)
### 修改
- `actions/credential-injector.ts`**整檔刪除**,改為新檔 `actions/auth-dispatcher.ts`(約 30 行):
1.`resolveAuthRecipe(componentId)` 取得 `primitive` 名稱(static_key / service_account / oauth2 / mtls
2. 呼叫對應的 auth primitive Worker(見下「呼叫方式」)
3.`{ action, api_key, service, request }`**不送 secrets、不送 recipe plaintext**
4. WASM 透過 host function 自行 `kv_get` 讀 recipe + 加密 secret`crypto_decrypt` 解密
5. 讀回傳 → 合併 `_auth_headers` / `_auth_query` / `_auth_body` 進 ctx
**呼叫方式(Phase 72026-06-06 演化)**:原設計走 `fetch(wasmWorkerUrl(auth_{primitive}, WORKER_SUBDOMAIN))`workers.dev 公網 URL)。但壓測階段 11 證實:**self-hosted** cypher`arcrun-cypher-executor.{sub}.workers.dev`)用 `fetch()` 打**同 `{sub}.workers.dev` zone** 的 auth workerCF 回 **1042**(官方 docs:「fetch from another Worker on the **same zone**」)。官方不踩是因官方 cypher 在自訂域 `cypher.arcrun.dev`、打 `*.workers.dev` 屬**跨 zone**(非官方有 flag)。token/解密鏈本身正常,唯一卡點是同 zone fetch。
- **解法 = `global_fetch_strictly_public` compatibility flag**cypher wrangler.toml),auth-dispatcher **維持原本 `fetch(workers.dev)` 不改**。此 flag 讓 `fetch()` 走公網「前門」→ same-zone fetch 也通(官方 docs:「Worker-to-Worker fetch 可用 service binding **或** `global_fetch_strictly_public` flag」)。
- **為何選 flag 不選 service binding**service binding 靜態、加/改要重 deploy cypher(官方 docs);flag 一行解、用戶無感、不動 service binding 禁令。先做了 service bindingA)後 richblack 拍板廢、改 flagB)。
- **flag 安全(查證官方 docs**:唯一副作用「Worker fetch 自己 hostname → self-loop」;cypher 只打外部 API + sibling auth worker(皆非自己 hostname)→ 不 self-loop。
- **官方/self-host 共用同一份 toml**:官方 cypher 本就跨 zonecypher.arcrun.dev → workers.dev),加 flag 行為不變;self-host 同 zone 被修好。`stripOfficialOnlyBindings()` 不碰 `compatibility_flags`self-hosted 部署自動帶上。
- `lib/component-loader.ts`**刪除 `BUILTIN_API_RECIPES`**(含 http_request / gmail / telegram / line_notify / google_sheets 的 TS 實作),全部改走 WASM runner。每個 `.wasm` 零件都已編譯並以獨立 Worker 部署(`{canonical-id-kebab}.arcrun.dev`)。loader 新增的「WASM runner」路徑就是「canonical_id → HTTP URL 查表後 fetch」,**不做** WASM instantiate。
- **R2 動態注入 WASM 路徑作廢**richblack 2026-04-19 確認:CF workerd 無法以 R2 物件臨時 instantiate WASM)。用戶自製零件(Phase 5)同樣走「產生獨立 Worker」流程,不從 R2 讀。
### 刪除
- `lib/jwt-signer.ts` — 整檔刪除,RS256 簽章移入 `auth_service_account` WASM(透過 host function `crypto_sign_rs256`
- `credential-injector.ts` 整檔刪除(見上)
- `component-loader.ts``BUILTIN_API_RECIPES` 整段刪除
- `BUILTIN_CREDENTIALS_MAP` 已在 `credential-injector.ts` 內,隨檔一併刪
---
## Host FunctionsWASM ↔ Worker 的橋接)
auth primitive WASM 需要呼叫外部能力時,透過 host function。全部放 `u6u` namespace。**錯誤回傳非零 uint32;成功 = 0 且把結果寫入 `outPtr` 指向的 buffer**。
| Host Function | TinyGo 簽章 | 用途 |
|---|---|---|
| `http_request` | `(urlPtr/Len, methodPtr/Len, headersPtr/Len, bodyPtr/Len, outPtr, outLenPtr) uint32` | HTTP 請求(已實作) |
| `kv_get` | `(keyPtr, keyLen, outPtr, outLenPtr) uint32` | 讀 KV。Worker 依 key 前綴路由到 `CREDENTIALS_KV` / `RECIPES` |
| `crypto_decrypt` | `(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) uint32` | AES-GCM 解密。encryption key 由 Worker 從 `env.ENCRYPTION_KEY` 內部讀取,**永遠不暴露給 WASM** |
| `crypto_sign_rs256` | `(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) uint32` | Worker 用 `crypto.subtle.sign('RSASSA-PKCS1-v1_5' + SHA-256)`private key 以 PKCS8 bytes 傳入 |
這些 host function 在 `lib/wasi-shim.ts` 中以 WASI import 提供。
### 安全邊界
- `ENCRYPTION_KEY` 只在 `crypto_decrypt` host function 內部使用,**絕不**經 stdin / 回傳值 / 任何路徑傳給 WASM
- `api_key` 經 stdin 傳入 WASM(讓 WASM 自己組 `{api_key}:cred:{name}` KV key
- `kv_get` 在 Worker 側檢查 key 前綴:
- `auth_recipe:*` → 讀 `RECIPES`
- `{api_key}:cred:*` → 讀 `CREDENTIALS_KV`,且 `{api_key}` 必須等於 stdin 傳入的 api_key(防越權)
- 其他前綴 → 回傳錯誤
---
## 關於解密位置
採用**方案 B(唯一方案)**WASM 透過 host function `crypto_decrypt()` 自行解密。
- cypher-executor TS 完全不解密、不知道 plaintext
- `ENCRYPTION_KEY` 永遠留在 Worker host function 內
- WASM 知道要解哪份 ciphertext(經 `kv_get` 讀到的 `{encrypted, iv}`),但拿不到 encryption key
- 這樣 TS 層完全沒有零件業務邏輯,符合 CLAUDE.md §禁止行為 1/6
(歷史註記:曾規劃方案 A「TS 先解密再送 stdin」,已廢棄 — 違反「TS 不得實作零件邏輯」。)
---
## §8 `{{credential.X}}` 用戶面注入語法(2026-06-10richblack 確認 change
### 問題(壓測實證的 401 根因)
Haiku 自主壓測時自然地在 workflow node.data 寫 `{{credential.openai_key}}` 引用已存的 credential
結果打目標 API 回 **401**;只有把 token **明文硬編碼**進 workflow 才 200。
**根因(系統客觀證據,三條 template 展開路徑都不認 `credential.` namespace**
1. `graph-executor.ts` `interpolateString()`node.data 展開)只認「context 內已存在的 key path」
`{{input.X}}` / `{{data.Y}}` / 任意 dot path),**無 `credential` namespace** → `{{credential.X}}`
展不開 → header 帶字面值 `Bearer {{credential.X}}` 或空 → 401。
2. auth recipe `inject` + auth primitive WASM 只認 `{{secret.X}}` / `{{runtime.X}}`,那條是 auth recipe
**自動注入**路徑(recipe 宣告 `auth_service` 才觸發),不是用戶在 node.data 直接引用的入口。
**正解(auth recipe 自動注入)其實存在,但用戶不知道、直覺會寫 `{{credential.X}}`** → 這是
**設計缺口**(缺一個直覺的用戶面 credential 引用入口),不是單純 bug。
### 決策:讓 `{{credential.X}}` 真的能用(richblack 2026-06-10
新增「用戶在 workflow node.data 直接寫 `{{credential.NAME}}` → 自動解密回填」這條入口。
**硬約束(rule 02 §2.2**:解密**絕不能**在 graph-executor TS 做(hook 擋 `crypto.subtle.decrypt`
且違反「TS 不實作 credential 邏輯」)。故 `{{credential.X}}` 的解密**復用既有 auth_static_key WASM**。
### 設計:auth_static_key 新增 `resolve_credentials` action
| 角色 | 職責 |
|------|------|
| graph-executor.ts(薄 routing | 偵測 node.data 內 `{{credential.NAME}}` → 收集 names → 呼叫 auth_static_key WASM `resolve_credentials` → 拿回明文 → 回填 node.data。**不解密、不碰 ENCRYPTION_KEY**。 |
| auth_static_key WASM(新 action | 收 `{action:"resolve_credentials", api_key, names:[...]}` → 對每個 name `kv_get("{api_key}:cred:{name}")` + `crypto_decrypt` → 回 `{success, credentials:{name: plaintext}}`。**不查 recipe**(與 authenticate 分流)。 |
**stdin/stdoutresolve_credentials**
```
stdin: { "action": "resolve_credentials", "api_key": "ak_xxx", "names": ["openai_key", "..."] }
stdout: { "success": true, "credentials": { "openai_key": "sk-...", ... } }
缺某 name → success:false + error 指明缺哪個(誠實,不假綠)
```
**展開時機(graph-executor**:在 node.data interpolate(現有 `interpolateData`**之後**、
node 執行**之前**插一步:掃描展開結果裡殘留的 `{{credential.NAME}}` → 走 WASM resolve → 回填。
放在 auth recipe 自動注入(`tryAuthDispatch`)**之外**,兩條入口並存但不重疊(一個是用戶顯式引用、
一個是 recipe 宣告 `auth_service` 隱式注入)。
**安全邊界(同 authenticate**
- `crypto_decrypt` host function 內部用 `ENCRYPTION_KEY`,**永不暴露給 WASM / 不經 graph-executor**。
- `kv_get` host 側檢查 `{api_key}:cred:*` 的 api_key 必須等於 stdin api_key(防越權,現有機制)。
- graph-executor 只傳 `names`(明文 credential 名)+ api_key**不傳、不見** ciphertext / plaintext key。
### 為何不選「新建 credential_resolve 零件」
auth_static_key 已具備 `kvGet` + `cryptoDecrypt` 全部能力,新建零件 = 功能重疊 + 多一個 Worker
部署成本。加一個 action 最貼近現有架構(richblack 2026-06-10 拍板)。
### 不做
- ❌ 不在 graph-executor 解密(rule 02 §2.2
- ❌ 不改 credential KV key 格式(仍 `{api_key}:cred:{name}`
- ❌ 不碰 auth recipe 自動注入路徑(`tryAuthDispatch` / `{{secret.X}}` 不變)
---
## 不做的事
- ❌ 不改 recipe YAML schema — 沿用現有格式
- ❌ 不改 KV 儲存結構 — `auth_recipe:{service}` / `{api_key}:cred:{name}` 不變
- ❌ 不改 SDK API — SDK 仍是 HTTP thin wrapper
- ❌ 不建新的 Worker — 在 cypher-executor 內完成(§8 復用 auth_static_key,不新建零件)
@@ -0,0 +1,237 @@
# Implementation Tasks: Credential Primitives TS → WASM
**嚴格規範(richblack 2026-04-19**cypher-executor TS 不得實作任何 credential / auth / template / JWT / 解密邏輯。全部走 TinyGo WASM + host functions(方案 B)。
**封測狀態**:推遲(richblack 2026-04-19 決定)。先完成 Phase 1-3 清除違規 TS,再啟動封測。
---
## Phase 0:核心合併(u6u-core → arcrun
- [x] 0.1 把 `u6u-core/builtins/` 搬到 `arcrun/builtins/`
- [x] 0.2 確認 `arcrun/registry/components/` 21 個零件的 contract.yaml 完整(21/21
- [x] 0.3 刪除 `arcrun/credentials/` 整個目錄(重複,credential route 已在 cypher-executor
- [x] 0.4 更新 `arcrun/cypher-executor/wrangler.toml`:確認 CREDENTIALS_KV binding 存在
- [x] 0.5 刪除 `matrix/u6u-core/` 整個目錄(2026-04-19 完成,只剩 credentials/ 已被 cypher-executor 取代)
- [x] 0.6 在 `cypher-executor/src/lib/wasi-shim.ts` 新增 host functions
- `u6u.kv_get(keyPtr, keyLen, outPtr, outLenPtr) uint32` — 依 key 前綴路由到 `CREDENTIALS_KV` / `RECIPES`,越權檢查 api_key
- `u6u.crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) uint32` — 用 `env.ENCRYPTION_KEY` + `crypto.subtle` AES-GCM 解密;key 不暴露給 WASM
- `u6u.crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) uint32``crypto.subtle.sign('RSASSA-PKCS1-v1_5' + SHA-256)`
- 2026-04-19 完成:wasi-shim.ts 新增 `createArcrunHostFunctions(env, apiKey)` factory,集中 AES-GCM 解密 + RSA sign + KV 前綴路由越權檢查。WASI imports 的 u6u namespace wiring 本來就已接好(只是當時沒有實作 factory)。typecheck 通過。
- [x] 0.7 在 `cypher-executor/src/lib/component-loader.ts` 新增 WASM runner 路徑:
- 所有 WASM 零件(含 auth primitive、API 零件、未來用戶自製)一律走 HTTP URL(`{canonical-id-kebab}.arcrun.dev`)到獨立 Worker
- **R2 動態注入路徑作廢**richblack 2026-04-19 確認:CF workerd 不支援以 R2 物件臨時 instantiate WASM;用戶自製零件同樣走「產生獨立 Worker」流程,不走 R2)
- cypher-executor 本身**不做** WASM instantiate,也不直接呼叫 `createArcrunHostFunctions`;那個 factory 是**零件 Worker 側**`.component-builds/{name}/src/index.ts`)用的,在 Phase 1 建立 auth_static_key Worker 時接上
- 2026-04-19 完成:`component-loader.ts` 新增 `WASM_HTTP_RUNNER_IDS`10 個 canonical_id6 個 API 零件 + 4 個 auth primitive)+ `wasmWorkerUrl()` URL 慣例輔助函數;解析鏈新增為第 8 層(放在 `BUILTIN_API_RECIPES` fallback 之後,避免 Phase 3 尚未完成時 API 零件 Worker 未部署造成 404Phase 3 刪除 `BUILTIN_API_RECIPES` 後,API 零件會自然落到此層)。auth primitive 從此層進入。`tsc --noEmit` 通過。
---
## Phase 1auth_static_key WASM(優先,涵蓋 80% 服務)
方案 BWASM 自行讀 KV + 解密,TS 不碰 plaintext。
- [x] 1.1 建立 `arcrun/registry/components/auth_static_key/` 目錄
- [x] 1.2 寫 `component.contract.yaml`input: `{action, api_key, service, request}` → output: `{success, auth_headers, auth_query, auth_body, runtime}`
- [x] 1.3 實作 `main.go`TinyGo):
- 宣告 host imports`kv_get` / `crypto_decrypt`static_key 不需要 http_request
- 從 stdin 讀 `{action, api_key, service}`
- `kv_get("auth_recipe:" + service)` → recipe JSON → 驗證 `primitive == "static_key"`
- 對每個 non-optional `recipe.required_secrets``kv_get("{api_key}:cred:{name}")``{encrypted, iv}``crypto_decrypt` → plaintext
- 展開 `{{secret.X}}` / `{{runtime.X}}` 模板於 `inject.header/query/body`;未知 key 展空字串(與 TS parity);其他 namespace 的 `{{...}}` 原樣保留
- 輸出 stdout JSON `{success, auth_headers, auth_query, auth_body, runtime}`
- [x] 1.4 `tinygo build -o auth_static_key.wasm -target=wasi main.go` — 2026-04-19 編譯通過(1.1MB,在 contract 限制 2MB 內)
- [🔄] 1.5 建立 `.component-builds/auth_static_key/`(用 `component-worker-template`)並部署到 `auth-static-key.arcrun.dev`
- 2026-04-20 完成**建置**部分:`.component-builds/auth_static_key/{wrangler.toml, package.json, tsconfig.json, src/index.ts, component.wasm}` 全數到位
- 方案 A:`src/index.ts` 直接 import `../../../cypher-executor/src/lib/wasi-shim``createWasiShim` + `createArcrunHostFunctions`(以 `ArcrunHostEnv` 結構型別相容);AES 解密邏輯仍只存在於 wasi-shim.ts 一處(rule 02 §2.2)
- 綁同組 KV:CREDENTIALS_KV (e7f4320f88d343f187e35e3543dd74c9) / RECIPES (9cf9db905c6241f78503199e58b2ffe0);ENCRYPTION_KEY 走 `wrangler secret put`
- `wrangler deploy --dry-run` 通過(1192 KiB, 419 KiB gzip);實際 `wrangler deploy` + `secret put ENCRYPTION_KEY` 留給 richblack 執行
- [x] 1.6 建立 `auth-dispatcher.ts`(取代 `credential-injector.ts`):查 auth recipe → HTTP POST 到對應 auth primitive URL → 合併 `_auth_headers` 進 ctx
- 2026-04-20 完成:`cypher-executor/src/actions/auth-dispatcher.ts` 新建,export `tryAuthDispatch(componentId, input, env, apiKey)`
- 流程:查 `resolveAuthRecipe` → primitive 在 `SUPPORTED_PRIMITIVES`(目前只有 `static_key`)→ fetch `wasmWorkerUrl('auth_static_key')` → 合併 `_auth_headers/_auth_query/_auth_body`
- 自引用防護:`AUTH_PRIMITIVE_IDS` set 排除 4 個 `auth_*` componentId
- `wasmWorkerUrl``component-loader.ts` export 出來共用
- `graph-executor.ts` 改為:先試 `tryAuthDispatch`(新路徑),沒命中 fallback 到舊 `injectCredentials`(Phase 1.9 刪)
- 檢查過 auth-dispatcher.ts 無 `crypto.subtle` / `interpolate` / `{{secret.` / hard-code API URL,符合 rule 02 §2.2
- `tsc --noEmit` 通過
- [ ] 1.7 端對端測試:openai recipe → 成功注入 `Authorization: Bearer <openai_key>`
- [ ] 1.8 端對端測試:twilio recipeBasic Auth)→ 成功注入
- [ ] 1.9 **刪除 `credential-injector.ts` 整檔**`decryptCredential` / `decryptSecrets` / `interpolateTemplate` / `BUILTIN_CREDENTIALS_MAP` 全刪)
---
## Phase 2auth_service_account WASM
- [🔄] 2.1 建立 `arcrun/registry/components/auth_service_account/` 目錄
- [🔄] 2.2 寫 `component.contract.yaml`
- [🔄] 2.3 實作 `main.go`
- 從 stdin 讀 `{api_key, service}` + `kv_get` 拿 recipe + 解密 SA JSON
- 解析 SA JSON 取 `client_email` / `private_key`PEM
- PEM → PKCS8 bytes(純 Gobase64 decode + 去 header/footer
- 組 JWT header + payloadbase64url),呼叫 `crypto_sign_rs256(signingInput, pkcs8)` 拿 signature
- 組完整 JWT → `http_request` POST `token_uri` → 拿 `access_token`
- 展開 `{{runtime.access_token}}` 模板
- [x] 2.4 `tinygo build -o auth_service_account.wasm -target=wasi main.go` — 2026-04-20 編譯通過(1.1MB,在 contract 限制 2MB 內)
- [x] 2.5 建立 `.component-builds/auth_service_account/` 並部署到 `auth-service-account.arcrun.dev`
- 2026-04-20 完成**建置**部分:`.component-builds/auth_service_account/{wrangler.toml, package.json, tsconfig.json, src/index.ts, component.wasm}` 全數到位
- 方案 A:`src/index.ts` 重用 `createArcrunHostFunctions` 提供 kv_get/crypto_decrypt/crypto_sign_rs256,**額外加 `http_request` host function**(token exchange 用,非 crypto 不受 §2.2 約束)。http_request 直接回 response body 原文(WASM 端 json.Unmarshal 找 access_token)
- 綁同組 KV:CREDENTIALS_KV / RECIPES;ENCRYPTION_KEY 走 `wrangler secret put`
- `wrangler deploy --dry-run` 通過(1248 KiB, 440 KiB gzip);實際 `wrangler deploy` + `secret put ENCRYPTION_KEY` 留給 richblack 執行
- `auth-dispatcher.ts``SUPPORTED_PRIMITIVES` 加入 `'service_account'`,workflow 用 google SA recipe 會自動走新 WASM 路徑
- [ ] 2.6 端對端測試:google_sheets_sa recipe → 成功取得 access_token → 注入 header
- [x] 2.7 **刪除 `lib/jwt-signer.ts` 整檔** — 2026-04-20 完成
- `cypher-executor/src/lib/jwt-signer.ts` 已刪除(RS256 JWT 邏輯移入 `auth_service_account.wasm`)
- `credential-injector.ts` 原 line 23 `import { exchangeGoogleJwt }` 移除
- `credential-injector.ts` 原 line 140-150 service_account 分支改為 throw(任何 service_account recipe 已被 auth-dispatcher 攔截;這條 TS fallback 若被觸發即表架構錯亂,直接爆錯比沈默解密更安全)
- `cypher-executor` tsc --noEmit 通過
---
## Phase 3:清理 component-loader 的 TS 實作(全刪)
目標:`BUILTIN_API_RECIPES` 整段刪除,所有服務走 WASM runnerHTTP URL 路徑)。
- [x] 3.1 確認 `http_request.wasm` / `gmail.wasm` / `telegram.wasm` / `line_notify.wasm` / `google_sheets.wasm` 都在 `registry/components/` 且可執行 — 2026-04-20 驗證 6 個(含 cron)全數存在,main.go + .wasm 齊備
- [x] 3.2 確認上述零件 Worker 都已部署(`{name}.arcrun.dev` 可用) — 2026-04-20 完成**建置**部分
- 6 個 Worker 建置到位:`.component-builds/{http_request, gmail, telegram, line_notify, google_sheets, cron}/{wrangler.toml, package.json, tsconfig.json, src/index.ts, component.wasm}`
- 方案 A:5 個需 http_request 的零件(http_request/gmail/telegram/line_notify/google_sheets)`src/index.ts` 共用模板;cron 是純計算不註冊 host function
- 全部透過 `createWasiShim` 複用 cypher-executor/src/lib/wasi-shim.ts(rule 02 §2.2 邊界)
- 6 個 `wrangler deploy --dry-run` 全通過(~1.17 MB / ~413 KB gzip 每個);實際 `wrangler deploy` 留給 richblack 執行
- [x] 3.3 `component-loader.ts` 的內建路徑改為查對應 Worker URL → HTTP POST — 2026-04-20 完成
- 原本第 7 層是 `BUILTIN_API_RECIPES` fallback、第 8 層是 `WASM_HTTP_RUNNER_IDS` (HTTP URL);兩層合併為第 7 層 `WASM_HTTP_RUNNER_IDS` 直接走 `makeHttpRunner(wasmWorkerUrl(id))`
- 解析鏈新編號 1-8,順序不變(外部 URL → recipe hash → component hash → R2 → Service Binding → auth recipe runner → WASM HTTP runner → 找不到)
- [x] 3.4 **刪除 `BUILTIN_API_RECIPES` 整個 Record**`http_request` / `gmail` / `telegram` / `line_notify` / `google_sheets` / `cron` 的 TS 實作全刪) — 2026-04-20 完成
- `cypher-executor/src/lib/component-loader.ts` 原 line 253-326 `BUILTIN_API_RECIPES` 常數 + fallback lookup 全刪(約 80 行)
- 全域搜尋確認:`gmail.googleapis.com/...messages/send` / `api.telegram.org/bot.*sendMessage` / `sheets.googleapis.com/v4/spreadsheets` / `notify-api.line.me/api/notify` 在 cypher-executor TS 中已不存在(auth-recipe-seeds.ts 的 `base_url` 是 recipe 資料欄位,不是 hard-coded API call)
- `cypher-executor` tsc --noEmit 通過
- [ ] 3.5 端對端測試:workflow 用 gmail auth recipe + gmail.wasm Worker → 成功發信
- [ ] 3.6 端對端測試:workflow 用 http_request.wasm Worker + auth_static_key 注入 → 成功呼叫任意 API
---
## Phase 4auth_oauth2 + auth_mtls WASM(封測後)
- [ ] 4.1 建立 `arcrun/registry/components/auth_oauth2/`
- [ ] 4.2 實作:`needs_refresh` / `refresh` / `authenticate` 三個 action
- [ ] 4.3 建立 `arcrun/registry/components/auth_mtls/`
- [ ] 4.4 實作:輸出 TLS cert/key(實際 mTLS handshake 由 Worker runtime 執行,WASM 無法做 socket
---
## Phase 5:封測啟動門檻 — 核心穩定驗證
**全部通過才能啟動封測**
- [ ] 5.1 所有 20 個 auth recipe seed 可正常運作(static_key 17 個 + service_account 3 個)
- [ ] 5.2 `cypher-executor/src/actions/credential-injector.ts` **不存在**
- [ ] 5.3 `cypher-executor/src/lib/jwt-signer.ts` **不存在**
- [ ] 5.4 `cypher-executor/src/lib/component-loader.ts``BUILTIN_API_RECIPES` / `BUILTIN_CREDENTIALS_MAP`
- [ ] 5.5 `cypher-executor/src/` 全域搜尋 `crypto.subtle.decrypt` 只出現在 `wasi-shim.ts``crypto_decrypt` host function
- [ ] 5.6 `cypher-executor/src/` 全域搜尋 `crypto.subtle.sign` 只出現在 `wasi-shim.ts``crypto_sign_rs256` host function
- [ ] 5.7 `cypher-executor/src/` 全域搜尋 `interpolate` 回傳 0 筆(template 展開全在 WASM
- [ ] 5.8 全域搜尋 `{{secret\.` / `{{runtime\.` 在 TS 檔案中回傳 0 筆
---
## Phase 6:通用 CI/CD deploy workflow
**背景**(2026-04-20 richblack 決定):現 `.github/workflows/deploy.yml` 只部署 cypher-executor + registry + 已刪除的 credentials,漏掉 Phase 1-3 產出的 8 個 Worker,且硬編碼每個 job 導致未來新增 Worker 都要改 CI。改為**通用掃描式 workflow**:任何含 `wrangler.toml` 的目錄 = 部署單位,改到該目錄下任何檔案 = 觸發重新 deploy。
**關鍵決策**:
- 零件 `.wasm` 由 CI build(不 commit):`registry/components/{name}/main.go` 改動時才重 build,用 timestamp / content hash 判斷
- `.component-builds/{name}/component.wasm` 由 CI 從 `registry/components/{name}/{name}.wasm` 複製產生(deploy 前一步)
- 統一用 pnpm(`.component-builds/*` 本來就是;順勢把 cypher-executor 的 `package-lock.json` 砍了)
- runtime secret(`ENCRYPTION_KEY`)不進 CI,由 richblack 一次性 `wrangler secret put`
- registry Worker 的 `wrangler.toml` 現階段不改(職責是合約管理,與封測無關;`sandboxAcceptance.ts` 的 rule 02 §2.2 審查留到 Phase 5 用戶自製零件啟動時)
### Tasks
- [x] 6.1 改寫 `.github/workflows/deploy.yml`:動態掃描所有含 `wrangler.toml` 的目錄(排除 `node_modules/` + Pages 專案),用 matrix job fanout 部署;分兩層(tier1=`.component-builds/*`,tier2=其他),tier1 全綠後才 tier2(避免 service binding target 未存在)
- [x] 6.2 加上 TinyGo build 步驟:tier1 matrix 一律 setup-tinygo + 從 `registry/components/{name}/main.go` rebuild `.wasm` → copy 到 `.component-builds/{name}/component.wasm`
- [x] 6.3 diff-aware:push 到 main 時比對 `github.event.before..github.sha`,只 deploy 有 diff 的 Worker(含 `registry/components/{name}/` 連動 `.component-builds/{name}/`);`workflow_dispatch` 提供 `force_all` + `only` 選項
- [x] 6.4 統一 pnpm:刪除 `cypher-executor/package-lock.json` + `registry/package-lock.json`;workflow 優先 `pnpm install --frozen-lockfile`,若該目錄無 `pnpm-lock.yaml` 則 fallback 到 `--no-frozen-lockfile`(混合期容錯)
- [x] 6.5 加 `max-parallel: 5` 控制 Workers API rate limit(tier1 和 tier2 各自)
- [x] 6.6 驗證:`workflow_dispatch` + `force_all=true` 手動跑一次,24 個 Worker 全綠 — 2026-04-20 完成
- 最終綠色 run 24668903627(28/28 jobs,含 discover + summary):tier1 24 個零件 Worker + tier2 2 個 orchestration Worker(cypher-executor / registry)全 success
- 過程中修兩輪:先修 `setup-node``cache: 'pnpm'` 對 legacy `package-lock.json` 目錄失效(改為不用 cache);再修 tier2 三個 package.json(cypher-executor/registry/builtins)遺漏 `wrangler` devDependency + regen pnpm-lock.yaml
- ENCRYPTION_KEY secret 已由 richblack 授權、CC 從 .env pipe 到三個 Worker:`arcrun-auth-static-key``arcrun-auth-service-account``arcrun-cypher-executor`(不顯示內容)
- [x] 6.7 文件:在 `.claude/rules/` 加一份 `05-deploy-convention.md`(「新增 Worker = 新目錄 + wrangler.toml,不用改 CI」)
---
## Phase 7auth primitive 改走 service binding(解 self-hosted CF 1042
> 2026-06-06 richblack 拍板。來源:壓測報告階段 11`test_arcrun/docs/壓測報告.md §11`)。
**背景(架構演化)**
- 當初設想「用戶對每個服務建**零件**」→ 零件用 service binding 要 redeploy → 用戶建 workflow 要 redeploy(不可接受)→ 故禁 service binding、改 cypher bindingHTTP URL)。
- 後來架構演化:用戶要的是 **recipe(資料,存 KV+ 固定 primitive**,不是新零件。`acr recipe push` 進 KV **不 deploy**。用戶用「http_request primitive + 不同 recipe」打各種服務,永不新增 primitive。
- 結論:「用戶建 workflow 要 deploy」這個禁令前提**在 recipe 模型下不成立**。primitivehttp_request + 4 個 auth)是**平台固定基礎設施**,和 13 個邏輯零件同類,給它 service binding 的 deploy 成本只在平台部署時付一次,**不落用戶身上**。
**根因(壓測階段 11,唯讀證據)**
- `auth-dispatcher.ts:67-68``fetch(wasmWorkerUrl('auth_service_account', WORKER_SUBDOMAIN))` 打 workers.dev → CF 回 **404 + "error code: 1042"**CF 邊緣頁,非 auth worker 回應)。
- **1042 精確定義(官方 docs2026-06-06 查證)**:「Worker tried to fetch from another Worker **on the same zone**, only supported when `global_fetch_strictly_public` flag is used」。關鍵是 **same zone**
- **self-hosted 踩**cypher`arcrun-cypher-executor.{sub}.workers.dev`)與 auth worker`arcrun-auth-*.{sub}.workers.dev`**同在 `{sub}.workers.dev` zone** → 同 zone fetch → 1042。
- **官方不踩**:官方 cypher 在自訂域 `cypher.arcrun.dev`,打 auth 的 `*.uncle6-me.workers.dev` 是**跨 zone**,不觸發 same-zone 限制(非「官方有 flag」——官方 wrangler.toml 只有 `nodejs_compat`,無 `global_fetch_strictly_public`)。
- 這是 P0#9「同 zone 522」的**另一形態**:之前解的是 `*.arcrun.dev` 同 zone,沒解到 `*.workers.dev` 同 zone。service binding 是內部 RPC 不經 zone → 徹底免疫(13 邏輯零件正是走 binding 才不踩)。
- 直接從外部打 `arcrun-auth-service-account.leo21c.workers.dev` → 200 + 真 `ya29...` token → **JWT/解密/token 換取鏈本身全正常**,唯一卡點是 cypher 打不到自己帳號的 auth worker。
- service binding 是 CF 內部 RPC,不經公網 → 同時繞開**同 zone 522P0#9**與**同帳號 workers.dev 子請求 1042**,比 workers.dev fetch 乾淨(13 邏輯零件正是用 binding 才不踩)。
**技術前提(已驗證 ✅)**
- CF API 查 leo21c(全新自架帳號)的 `arcrun-cypher-executor` settings → **實綁 13 個 service binding**SVC_SET→arcrun-set …)。證明 `deploy.ts` 的 binding 注入在 self-hosted 生效,auth binding 走同路綁得上。
### 修法演進:Aservice binding)→ 廢 → Bglobal_fetch_strictly_public flag
**先做了 Aservice binding)後評估廢棄,改用 Bflag)**——richblack 2026-06-06 拍板。
- **Aservice binding)為何廢**service binding 靜態,加/改要重 deploy cypher(官方 docs 證實)。richblack 判定不夠乾淨,且有更簡單的 B。
- **B`global_fetch_strictly_public` flag)為何對**:官方 docs——此 flag 讓 `fetch()` 走公網「前門」,**same-zone fetch 也能通**。cypher wrangler.toml 加一行即解,**用戶無感、不用域名、不用重 deploy、不動 service binding 禁令**。
- **B 安全(查證官方 docs**:唯一副作用是「Worker fetch 自己 hostname 會 self-loop」;cypher 只打外部 API + sibling auth worker(皆非自己 hostname)→ 不 self-loop。
- **B 官方/self-host 共用**:官方 cypher 本就跨 zone,加 flag 行為不變;self-host 同 zone 被修好。同一份 toml 兩邊通用。
- **評估但廢的他案**arcrun.dev 子域給 self-host cypher——查證後子域同 zonezone=註冊域名)照踩 1042,且跨帳號 route 落地等於 PaaS 轉向,廢。
### Tasks
- [x] 7.1 規範:`02-forbidden §3.1` / `03` / `CLAUDE.md` 鐵律4 / `pre-bash-guard 3.1`——**禁令維持原狀(不解禁 service binding**,僅加註「same-zone 1042 用 flag 解」。(先前一度改成「平台 primitive 例外允許 binding」,因改用 B 已**全部還原**)
- [x] 7.2 ~~hook 解禁~~**還原**`pre-bash-guard.sh` 規則 3.1 回原本「禁止新增 service binding」(B 不需解禁)
- [x] 7.3 `cypher-executor/wrangler.toml``compatibility_flags``global_fetch_strictly_public`(+ 註解病因/安全)。**移除先前加的 SVC_AUTH_***grep SVC_AUTH=0
- [x] 7.4 `auth-dispatcher.ts` / `types.ts`:**還原**到 A 之前(移除 binding 優先邏輯與 SVC_AUTH_* 型別,回單純 `fetch(workers.dev)`——flag 讓它同 zone 也通);tsc exit 0
- [x] 7.5 `cli/src/lib/deploy.ts`**無需改**——flag 在 cypher wrangler.toml`stripOfficialOnlyBindings()` 不碰 `compatibility_flags`self-hosted 部署自動帶上
- [ ] 7.6 驗收(客觀證據):全新自架帳號 `POST /webhooks/named/{ns}/{wf}/trigger``append_row` 的 auth 注入回 200、真的寫進 Google Sheets(第一個端到端驗 self-hosted auth 鏈的測試);`tsc --noEmit` exit 0
- [x] 7.7 design.md 同步(§修改 auth-dispatcher 補「binding 優先、fetch fallback」+ 架構演化 + 1042 根因):§修改 的 auth-dispatcher 補「binding 優先、fetch fallback」;記架構演化(recipe 取代零件 → primitive 可 binding
---
## Phase 8`{{credential.X}}` 用戶面注入語法(2026-06-10 richblack 確認 changedesign §8
> 根因:壓測 401 = workflow 寫 `{{credential.X}}` 但三條 template 展開路徑都不認此 namespace。
> 修法:auth_static_key 加 `resolve_credentials` actionWASM 解密),graph-executor 偵測+回填(不解密)。
- [x] 8.1 `registry/components/auth_static_key/main.go`input 加 `Names []string`main 在 `service`
必填檢查**之前**分流 `action == "resolve_credentials"` → 走新 `handleResolveCredentials`(不查 recipe、
不要求 service),每個 name `kvGet("{api_key}:cred:{name}")` + `cryptoDecrypt` → 回
`{success, credentials:{name: plaintext}}`;缺 name → success:false + error 指明(不假綠)
- [x] 8.2 `tinygo build -target=wasi -o auth_static_key.wasm main.go` 編譯通過(1.1MB+ copy 到
`.component-builds/auth_static_key/component.wasm`
- [x] 8.3 `cypher-executor/src/graph-executor.ts`node.data interpolate 後、node 執行前,掃描殘留
`{{credential.NAME}}` → 有則 POST `wasmWorkerUrl('auth_static_key')`
`{action:"resolve_credentials", api_key, names}` → 回填 node.data。**不解密、不碰 ENCRYPTION_KEY**rule 02 §2.2
實作放 `auth-dispatcher.ts``resolveCredentialRefs`(遞迴掃描+回填,無 {{credential.}} 則零開銷不打 WASM),graph-executor 只呼叫
- [x] 8.4 `tsc --noEmit` exit 0 + grep 確認 graph-executor / auth-dispatcher 無 crypto.subtle / {{secret. / ENCRYPTION_KEY 業務邏輯
- [x] 8.5 端對端驗收:機制已實證打通(2026-06-13 leo21c 壓測)。實際走 **Notion** 而非 OpenAI 路徑——
node.data `{{credential.notion_token}}` → 注入解密後 token → 真讀到 Notion Recipes 資料
(「蕃茄蘑菇燉雞」+ iCook 連結),同時 401 假綠根治全鏈驗證(host fn error envelope →
零件 parsed["error"] → cypher isFailure()401 回 success:false)。客觀證據:真服務 2xx + 真 body。
8.5 原訂 OpenAI 為驗收服務,實際以 Notion 達成同等證據;機制與服務無關,故視為驗收完成)
- [x] 8.6 design.md / status.md / BACKLOG.md 同步(§8 設計 + 8.1-8.4 done 標記)
---
## Notes
- 方案 B 是唯一方案(方案 A 已廢棄,違反 CLAUDE.md §禁止行為)
- Phase 8 的 `resolve_credentials` 是用戶面入口,與 authenticaterecipe 自動注入)並存不重疊
- Phase 0.6host functions+ 0.7WASM runner)是 Phase 1-3 的硬前置,必須先做
- 若 TinyGo `encoding/base64` 可用就直接用;若不可用則自行實作(見 gmail/main.go 的 `base64URLEncode`
- `auth_mtls` 的 TLS handshake 無法在 WASM 內做(WASI preview1 沒 socket),只能輸出 cert/key 讓 Worker 在 fetch 時用
- **每個 auth primitive WASM 都是獨立部署的 Worker**(透過 `component-worker-template/`),不是從 R2 動態載入
- Cypher binding = workflow YAML 裡的 URL 清單,不是 Cloudflare service binding
@@ -0,0 +1,25 @@
# CODING AGENTS: READ THIS FIRST
This is a **handoff bundle** from Claude Design (claude.ai/design).
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
## What you should do — IMPORTANT
**Read the chat transcripts first.** There are 1 chat transcript(s) in `arcrun/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
**Find the primary design file under `arcrun/project/` and read it top to bottom.** The chat transcripts will tell you which file the user was last iterating on. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
## About the design files
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
## Bundle contents
- `arcrun/README.md` — this file
- `arcrun/chats/` — conversation transcripts (read these!)
- `arcrun/project/` — the `arcrun` project files (HTML prototypes, assets, components)
@@ -0,0 +1,56 @@
// App root — screen switcher with persistent route
const { useState, useEffect } = React;
const SCREENS = [
{ id: 'landing', label: 'Landing' },
{ id: 'auth', label: 'Auth' },
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'keys', label: 'API Keys' },
{ id: 'workflow', label: 'Workflow' },
];
// Synonyms from sidebar ids
const aliases = { apps: 'dashboard', workflows: 'dashboard', docs: 'landing', settings: 'keys' };
function App() {
const [screen, setScreen] = useState(() => {
const saved = localStorage.getItem('arcrun:screen');
return saved && SCREENS.some(s => s.id === saved) ? saved : 'landing';
});
useEffect(() => {
localStorage.setItem('arcrun:screen', screen);
window.scrollTo(0, 0);
}, [screen]);
const nav = (id) => {
const resolved = aliases[id] || id;
if (SCREENS.some(s => s.id === resolved)) setScreen(resolved);
};
const Current = {
landing: Landing,
auth: Auth,
dashboard: Dashboard,
keys: ApiKeys,
workflow: WorkflowViewer,
}[screen];
return (
<div className="app">
<Current onNav={nav} />
<div className="proto-switch" role="tablist" aria-label="Screen switcher">
{SCREENS.map(s => (
<button key={s.id}
className={screen === s.id ? 'active' : ''}
onClick={() => nav(s.id)}>
{s.label}
</button>
))}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@@ -0,0 +1,92 @@
// Top nav and sidebar
const TopNav = ({ onNav, current }) => {
const [scrolled, setScrolled] = React.useState(false);
React.useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<nav className={`topnav ${scrolled ? 'scrolled' : ''}`}>
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo onClick={() => onNav('landing')} />
<div className="nav-links" style={{marginLeft: 20}}>
<a>Product</a>
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
</div>
</div>
<div className="flex gap-8" style={{alignItems: 'center'}}>
<button className="btn btn-ghost" onClick={() => onNav('auth')}>Log in</button>
<button className="btn btn-primary" onClick={() => onNav('auth')}>
Get started <Icon name="arrow_right" size={14} />
</button>
</div>
</nav>
);
};
const Footer = ({ onNav }) => (
<footer className="footer">
<div className="flex gap-12" style={{alignItems: 'center'}}>
<Logo size="sm" />
<span>© 2026 Arcrun Labs</span>
</div>
<div className="footer-links">
<a>Docs</a>
<a>Pricing</a>
<a>Changelog</a>
<a>Status</a>
<a>Privacy</a>
</div>
</footer>
);
// App shell with sidebar for logged-in screens
const Sidebar = ({ current, onNav }) => {
const items = [
{ id: 'dashboard', label: 'Dashboard', icon: 'home' },
{ id: 'apps', label: 'Apps', icon: 'grid', count: 6 },
{ id: 'workflows', label: 'Workflows', icon: 'workflow', count: 12 },
{ id: 'keys', label: 'API Keys', icon: 'key' },
{ id: 'docs', label: 'Docs', icon: 'book' },
];
const bottom = [
{ id: 'settings', label: 'Settings', icon: 'settings' },
];
return (
<aside className="sidebar">
<div className="sidebar-head">
<Logo size="md" onClick={() => onNav('landing')} />
</div>
<div className="sidebar-section">Workspace</div>
{items.map(it => (
<div key={it.id}
className={`sidebar-item ${current === it.id ? 'active' : ''}`}
onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
{it.count != null && <span className="sb-count">{it.count}</span>}
</div>
))}
<div style={{flex: 1}} />
{bottom.map(it => (
<div key={it.id} className="sidebar-item" onClick={() => onNav(it.id)}>
<span className="sb-ico"><Icon name={it.icon} size={15} /></span>
<span>{it.label}</span>
</div>
))}
<div className="sidebar-foot">
<div className="avatar-circ">MR</div>
<div className="meta">
<div className="name">Maya Rivera</div>
<div className="email">maya@northwind.co</div>
</div>
</div>
</aside>
);
};
Object.assign(window, { TopNav, Footer, Sidebar });
@@ -0,0 +1,86 @@
// Shared primitives: icons, logo, etc.
const Icon = ({ name, size = 16, stroke = 1.7 }) => {
const paths = {
arrow_right: <path d="M5 12h14M13 6l6 6-6 6" />,
arrow_left: <path d="M19 12H5M11 6l-6 6 6 6" />,
plus: <path d="M12 5v14M5 12h14" />,
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></>,
check: <path d="M20 6L9 17l-5-5" />,
close: <path d="M18 6L6 18M6 6l12 12" />,
eye: <><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" /></>,
warn: <><path d="M10.3 3.86L1.82 18a2 2 0 001.72 3h16.92a2 2 0 001.72-3L13.7 3.86a2 2 0 00-3.4 0z" /><line x1="12" y1="9" x2="12" y2="13" /><circle cx="12" cy="17" r="0.5" fill="currentColor" /></>,
home: <><path d="M3 10l9-7 9 7v10a2 2 0 01-2 2h-4a2 2 0 01-2-2v-5h-2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V10z" /></>,
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
workflow: <><circle cx="5" cy="6" r="2" /><circle cx="19" cy="12" r="2" /><circle cx="5" cy="18" r="2" /><path d="M7 6h4a4 4 0 014 4v0m0 4a4 4 0 01-4 4H7" /></>,
key: <><circle cx="7.5" cy="15.5" r="4.5" /><path d="M10.68 12.32L21 2M17 6l3 3M15 8l3 3" /></>,
book: <><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2zM22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /></>,
chevron_right: <path d="M9 6l6 6-6 6" />,
chevron_down: <path d="M6 9l6 6 6-6" />,
external: <><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" /><path d="M15 3h6v6M10 14L21 3" /></>,
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" /></>,
spark: <path d="M12 3l2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z" />,
bolt: <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />,
github: <path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.22.66-.48v-1.7c-2.78.6-3.36-1.34-3.36-1.34-.46-1.15-1.12-1.46-1.12-1.46-.92-.62.07-.6.07-.6 1.01.07 1.55 1.04 1.55 1.04.9 1.54 2.36 1.1 2.94.84.09-.65.35-1.1.64-1.35-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.5 9.5 0 0112 6.8c.85 0 1.7.11 2.5.33 1.9-1.3 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.6 1.03 2.68 0 3.84-2.34 4.69-4.57 4.93.36.31.68.92.68 1.85V21c0 .27.16.57.67.48A10 10 0 0022 12c0-5.52-4.48-10-10-10z" fill="currentColor" stroke="none" />,
google: <><path d="M21.35 11.1h-9.17v2.73h5.24c-.23 1.41-1.69 4.13-5.24 4.13-3.15 0-5.73-2.62-5.73-5.86 0-3.24 2.58-5.86 5.73-5.86 1.8 0 3 .77 3.69 1.43l2.5-2.4C16.95 3.74 14.8 2.8 12.18 2.8c-5.26 0-9.53 4.25-9.53 9.5s4.27 9.5 9.53 9.5c5.51 0 9.15-3.87 9.15-9.32 0-.63-.07-1.1-.15-1.38z" fill="currentColor" stroke="none" /></>,
share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98" /></>,
download: <><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></>,
zoom_in: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="11" y1="8" x2="11" y2="14" /><line x1="8" y1="11" x2="14" y2="11" /></>,
zoom_out: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /><line x1="8" y1="11" x2="14" y2="11" /></>,
maximize: <><path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3" /></>,
slack: <><rect x="13" y="2" width="3" height="8" rx="1.5" /><rect x="2" y="13" width="8" height="3" rx="1.5" /><rect x="14" y="14" width="8" height="3" rx="1.5" /><rect x="8" y="8" width="3" height="8" rx="1.5" /></>,
database: <><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v7c0 1.66 4.03 3 9 3s9-1.34 9-3V5M3 12v7c0 1.66 4.03 3 9 3s9-1.34 9-3v-7" /></>,
mail: <><rect x="2" y="4" width="20" height="16" rx="2" /><path d="M2 6l10 7 10-7" /></>,
filter: <path d="M3 4h18l-7 9v6l-4-2v-4L3 4z" />,
star: <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />,
linear: <><rect x="3" y="3" width="18" height="18" rx="4" /><path d="M7 11l5 5M7 15l3 3M7 7l10 10M11 7l6 6M15 7l2 2" /></>,
clock: <><circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 16 14" /></>,
send: <><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></>,
terminal: <><path d="M4 17l6-6-6-6M12 19h8" /></>,
logout: <><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></>,
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{display: 'block', flexShrink: 0}}>
{paths[name]}
</svg>
);
};
// Arcrun wordmark — custom "arc" glyph made of an arc stroke + ascending dot/node
const Logo = ({ size = 'md', onClick }) => {
const dims = size === 'sm' ? { w: 18, h: 18, f: 10 } : size === 'lg' ? { w: 28, h: 28, f: 14 } : { w: 22, h: 22, f: 12 };
return (
<div className="logo" onClick={onClick}>
<span className="logo-mark" style={{width: dims.w, height: dims.h}}>
<svg width={dims.w} height={dims.h} viewBox="0 0 24 24" fill="none">
<path d="M5 17 Q 12 4, 19 17" stroke="white" strokeWidth="2.4" strokeLinecap="round" fill="none" opacity="0.95" />
<circle cx="19" cy="17" r="2.2" fill="white" />
</svg>
</span>
<span>Arcrun</span>
</div>
);
};
// App icon with gradient background
const AppIcon = ({ tone = 'indigo', children, size = 38 }) => {
const tones = {
indigo: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
orange: 'linear-gradient(135deg, #F59E0B, #EF4444)',
green: 'linear-gradient(135deg, #10B981, #22C55E)',
pink: 'linear-gradient(135deg, #EC4899, #8B5CF6)',
blue: 'linear-gradient(135deg, #3B82F6, #06B6D4)',
slate: 'linear-gradient(135deg, #475569, #334155)',
amber: 'linear-gradient(135deg, #F59E0B, #D97706)',
};
return (
<div className="app-icon" style={{ background: tones[tone], width: size, height: size, color: 'white' }}>
{children}
</div>
);
};
Object.assign(window, { Icon, Logo, AppIcon });
@@ -0,0 +1,95 @@
# Chat
_Started 2026-04-23 04:37 UTC_
---
## User
## Assistant
I'll design Arcrun as a cohesive 5-screen system. Let me plan and build this as an interactive prototype with all screens navigable.
_[tool: update_todos]_
Let me plan the architecture. I'll build a single HTML file with React, using multiple JSX component files, with screen navigation via state.
Design system note: I'll use Inter, electric indigo #6366F1 primary, #0F0F0F bg, #1A1A1A cards, with subtle indigo→violet gradients on CTAs. Dark, minimal, developer-focused. "Arcrun" wordmark will be original — a stylized "↯" or arc mark paired with the name.
Let me start building.
_[tool: write_file]_
_[tool: write_file]_
Now the primitives and chrome components:
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
Now the 5 screens. Let me write them concisely.
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
_[tool: write_file]_
_[tool: write_file]_
_[tool: snip]_
_[tool: update_todos]_
_[tool: register_assets]_
_[tool: done]_
_[tool: snip]_
_[tool: list_files]_
The file exists. The error may have been transient. Let me verify by checking the page loads.
_[tool: show_html]_
_[tool: snip]_
_[tool: done]_
The CSS exists but isn't loading through the preview path. Let me inline it to eliminate the dependency.
_[tool: read_file]_
_[tool: run_script]_
_[tool: snip]_
_[tool: done]_
_[tool: fork_verifier_agent]_
Built **Arcrun** as a cohesive 5-screen dark-mode prototype — Landing, Auth, API Keys, Dashboard, and Workflow Viewer — all navigable via the floating screen switcher at the bottom.
**Highlights**
- **Landing** — hero with grid/glow bg, two-path split (developer terminal w/ npm·pip·bun tabs + Claude-style chat preview showing an Arcrun tool call)
- **Auth** — centered card, Sign in / Sign up tabs, GitHub + Google OAuth, email + password
- **API Keys** — first-time key callout with copy + hide-again warning, full keys table with live status toggles
- **Dashboard** — sidebar shell, 3-col apps grid (+ empty-state card), workflows table below
- **Workflow Viewer** — full-bleed dark canvas, 8 connected nodes with left-to-right bezier arrows, selectable with detail panel (I/O schema, editable triplet config for the AI node), minimap + zoom controls, prominent "Edit in Claude" CTA
Screen state persists in localStorage so reloads land you where you were.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
const ApiKeys = ({ onNav }) => {
const [newKeyCopied, setNewKeyCopied] = React.useState(false);
const [keys, setKeys] = React.useState([
{ id: 'k_dev', name: 'Local Development', prefix: 'ar_dev_', created: 'Mar 12, 2026', lastUsed: '2 min ago', active: true },
{ id: 'k_prod', name: 'Production — Northwind API', prefix: 'ar_live_', created: 'Feb 3, 2026', lastUsed: '12 sec ago', active: true },
{ id: 'k_staging', name: 'Staging — Vercel', prefix: 'ar_test_', created: 'Jan 28, 2026', lastUsed: '4 hours ago', active: true },
{ id: 'k_ci', name: 'CI/CD (GitHub Actions)', prefix: 'ar_live_', created: 'Jan 10, 2026', lastUsed: 'Yesterday', active: false },
{ id: 'k_old', name: 'Legacy — Zapier import', prefix: 'ar_live_', created: 'Nov 4, 2025', lastUsed: '3 weeks ago', active: false, revoked: true },
]);
const newKey = 'ar_live_sk_7x9Qf2vLm8nR4TpW6ZjKc3bEhN1aSyU5oP0dI';
const copyKey = () => {
setNewKeyCopied(true);
setTimeout(() => setNewKeyCopied(false), 1800);
};
const toggleKey = (id) => {
setKeys(keys.map(k => k.id === id ? { ...k, active: !k.active } : k));
};
return (
<div className="shell">
<Sidebar current="keys" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Workspace</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Settings</span>
</div>
<h1>API Keys</h1>
<div className="sub">Scoped credentials for calling the Arcrun API from your code and CI.</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> API docs</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> Create new key</button>
</div>
</div>
<div className="main-body" style={{maxWidth: 1080}}>
<div className="new-key-box">
<div className="warn-row">
<span className="warn-icon"><Icon name="warn" size={12} /></span>
<span><strong style={{color: '#FBBF24'}}>Save this key now.</strong> For security, we won't show it again — if you lose it, you'll need to create a new one.</span>
</div>
<h3>Your new API key</h3>
<p className="desc">Key named <strong style={{color: 'var(--text)'}}>"Production — Northwind API"</strong> · created just now · all scopes</p>
<div className="key-display">
<span className="key-val">{newKey}</span>
<button className={`copy-btn ${newKeyCopied ? 'copied' : ''}`} onClick={copyKey}>
<Icon name={newKeyCopied ? 'check' : 'copy'} size={12} />
{newKeyCopied ? 'Copied' : 'Copy'}
</button>
</div>
<div style={{marginTop: 14, display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-mute)', alignItems: 'center'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Full workspace access</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="clock" size={12} /> Never expires</span>
<span style={{marginLeft: 'auto'}}><span className="link">Add expiry or restrict scopes </span></span>
</div>
</div>
<div className="section-head">
<div>
<h2>All keys</h2>
<div className="subtle" style={{marginTop: 2}}>{keys.filter(k => !k.revoked).length} active · {keys.filter(k => k.revoked).length} revoked</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> Filter</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '32%'}}>Name</th>
<th>Key</th>
<th>Created</th>
<th>Last used</th>
<th>Status</th>
<th style={{width: 60, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td>
<div style={{fontWeight: 500, fontSize: 13.5}}>{k.name}</div>
</td>
<td className="mono">{k.prefix}{k.id.slice(-4)}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.created}</td>
<td className="dim" style={{fontSize: 12.5}}>{k.lastUsed}</td>
<td>
{k.revoked ? (
<span className="pill revoked"><span className="pdot" /> Revoked</span>
) : (
<div className="flex gap-8" style={{alignItems: 'center'}}>
<span className={`toggle ${k.active ? 'on' : ''}`} onClick={() => toggleKey(k.id)} />
<span className={`pill ${k.active ? 'active' : 'idle'}`}>
<span className="pdot" /> {k.active ? 'Active' : 'Paused'}
</span>
</div>
)}
</td>
<td style={{textAlign: 'right'}}>
{!k.revoked && (
<button className="btn btn-danger-ghost btn-sm"><Icon name="trash" size={12} /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{marginTop: 18, fontSize: 12, color: 'var(--text-mute)', display: 'flex', alignItems: 'center', gap: 8}}>
<Icon name="warn" size={12} />
<span>Revoking a key stops all in-flight requests within 60 seconds. This cannot be undone.</span>
</div>
</div>
</div>
</div>
);
};
window.ApiKeys = ApiKeys;
@@ -0,0 +1,90 @@
const Auth = ({ onNav }) => {
const [mode, setMode] = React.useState('signin');
const [email, setEmail] = React.useState('');
const [pw, setPw] = React.useState('');
const [remember, setRemember] = React.useState(true);
const submit = (e) => { e.preventDefault(); onNav('dashboard'); };
return (
<div className="auth-wrap">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div style={{position: 'absolute', top: 24, left: 24, zIndex: 2}}>
<Logo onClick={() => onNav('landing')} />
</div>
<div className="auth-card">
<h2 className="auth-h1">{mode === 'signin' ? 'Welcome back' : 'Create your account'}</h2>
<p className="auth-sub">{mode === 'signin' ? 'Sign in to your Arcrun workspace.' : 'Start building AI workflows in minutes.'}</p>
<div className="tabs">
<button className={mode === 'signin' ? 'active' : ''} onClick={() => setMode('signin')}>Sign in</button>
<button className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign up</button>
</div>
<div className="oauth-row">
<button className="oauth-btn github" onClick={() => onNav('dashboard')}>
<Icon name="github" size={17} stroke={0} /> Continue with GitHub
</button>
<button className="oauth-btn google" onClick={() => onNav('dashboard')}>
<Icon name="google" size={15} stroke={0} /> Continue with Google
</button>
</div>
<div className="divider">or continue with email</div>
<form onSubmit={submit}>
{mode === 'signup' && (
<div className="field">
<label>Full name</label>
<input className="input" type="text" placeholder="Maya Rivera" />
</div>
)}
<div className="field">
<label>Work email</label>
<input className="input" type="email" placeholder="you@company.com" value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="field">
<div className="field-row">
<label>Password</label>
{mode === 'signin' && <span className="link">Forgot password?</span>}
</div>
<input className="input" type="password" placeholder="••••••••••" value={pw} onChange={e => setPw(e.target.value)} />
</div>
{mode === 'signin' && (
<div style={{display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--text-dim)', marginBottom: 14}}>
<div onClick={() => setRemember(!remember)}
style={{width: 15, height: 15, borderRadius: 4, border: '1px solid var(--line-2)',
background: remember ? 'var(--primary)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'}}>
{remember && <Icon name="check" size={11} />}
</div>
<span onClick={() => setRemember(!remember)} style={{cursor: 'pointer'}}>Keep me signed in for 30 days</span>
</div>
)}
<button className="btn btn-primary auth-submit btn-lg" type="submit">
{mode === 'signin' ? 'Sign in' : 'Create account'} <Icon name="arrow_right" size={14} />
</button>
</form>
{mode === 'signup' && (
<p style={{fontSize: 11.5, color: 'var(--text-mute)', textAlign: 'center', marginTop: 14, lineHeight: 1.5}}>
By signing up, you agree to our <span className="link" style={{fontSize: 11.5}}>Terms</span> and <span className="link" style={{fontSize: 11.5}}>Privacy Policy</span>.
</p>
)}
<div className="auth-foot">
{mode === 'signin'
? <>New to Arcrun? <span className="link" onClick={() => setMode('signup')}>Create an account</span></>
: <>Already have an account? <span className="link" onClick={() => setMode('signin')}>Sign in</span></>}
</div>
</div>
</div>
);
};
window.Auth = Auth;
@@ -0,0 +1,126 @@
const Dashboard = ({ onNav }) => {
const apps = [
{ id: 'digest', name: 'Weekly Digest', desc: 'Summarize customer activity into a Monday email for the revenue team.', icon: 'mail', tone: 'indigo' },
{ id: 'triage', name: 'Support Triage', desc: 'Classify inbound tickets, attach context from the CRM, and route.', icon: 'filter', tone: 'orange' },
{ id: 'seo', name: 'SEO Brief Generator', desc: 'Turn a keyword into a draft brief with outline, FAQs, and SERP notes.', icon: 'search', tone: 'green' },
{ id: 'slack', name: 'Standup Bot', desc: 'Collect Linear updates and post a tidy engineering standup to Slack.', icon: 'slack', tone: 'pink' },
{ id: 'doc', name: 'Docs Sync', desc: 'Keep Notion runbooks in sync with the production API surface.', icon: 'book', tone: 'blue' },
];
const workflows = [
{ id: 'digest_weekly', name: 'digest/weekly', nodes: 9, modified: '2 hours ago', runs: '147 runs', status: 'healthy' },
{ id: 'triage_inbound', name: 'triage/inbound-email', nodes: 14, modified: 'Yesterday', runs: '2,318 runs', status: 'healthy' },
{ id: 'seo_brief', name: 'seo/brief-from-keyword', nodes: 7, modified: '3 days ago', runs: '42 runs', status: 'healthy' },
{ id: 'standup', name: 'slack/standup-collector', nodes: 6, modified: '1 week ago', runs: '24 runs', status: 'idle' },
{ id: 'docs_sync', name: 'docs/sync-notion', nodes: 11, modified: '2 weeks ago', runs: '8 runs', status: 'failed' },
];
return (
<div className="shell">
<Sidebar current="dashboard" onNav={onNav} />
<div className="main">
<div className="main-head">
<div>
<div className="crumb">
<span>Northwind</span>
<span className="sep"><Icon name="chevron_right" size={11} /></span>
<span>Dashboard</span>
</div>
<h1>Welcome back, Maya</h1>
<div className="sub">5 apps running · 12 workflows · 2,538 runs this week</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary"><Icon name="book" size={14} /> Templates</button>
<button className="btn btn-primary"><Icon name="plus" size={14} /> New app</button>
</div>
</div>
<div className="main-body">
{/* Apps grid */}
<div className="section-head">
<div>
<h2>My Apps</h2>
<div className="subtle" style={{marginTop: 2}}>Packaged workflows your team can run from chat or code</div>
</div>
<span className="subtle">{apps.length} apps</span>
</div>
<div className="apps-grid">
{apps.map(a => (
<div key={a.id} className="app-card">
<AppIcon tone={a.tone}><Icon name={a.icon} size={17} /></AppIcon>
<h4>{a.name}</h4>
<p className="dsc">{a.desc}</p>
<div className="row">
<a className="open" onClick={() => onNav('workflow')}>Open app <Icon name="arrow_right" size={12} /></a>
<button className="chip-btn">
<Icon name="spark" size={11} /> Edit in Claude
</button>
</div>
</div>
))}
<div className="app-card app-empty">
<div className="plus"><Icon name="plus" size={16} /></div>
<div style={{fontSize: 13, fontWeight: 500}}>Create new app</div>
<div style={{fontSize: 12, opacity: 0.75}}>Start from scratch or template</div>
</div>
</div>
{/* Workflows */}
<div className="wf-table">
<div className="section-head">
<div>
<h2>My Workflows</h2>
<div className="subtle" style={{marginTop: 2}}>The graphs that power your apps</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm"><Icon name="filter" size={12} /> All workflows</button>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}><Icon name="plus" size={12} /> New</button>
</div>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th style={{width: '34%'}}>Workflow</th>
<th>Nodes</th>
<th>Last modified</th>
<th>Activity</th>
<th>Status</th>
<th style={{width: 100, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{workflows.map(w => (
<tr key={w.id}>
<td>
<div className="wf-row-name">
<span className="dot" />
<span className="mono" style={{fontSize: 13}}>{w.name}</span>
</div>
</td>
<td className="dim">{w.nodes}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.modified}</td>
<td className="dim" style={{fontSize: 12.5}}>{w.runs}</td>
<td>
<span className={`pill ${w.status === 'healthy' ? 'active' : w.status === 'failed' ? 'revoked' : 'idle'}`}>
<span className="pdot" /> {w.status}
</span>
</td>
<td style={{textAlign: 'right'}}>
<button className="btn btn-secondary btn-sm" onClick={() => onNav('workflow')}>View</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
window.Dashboard = Dashboard;
@@ -0,0 +1,168 @@
const Landing = ({ onNav }) => {
const [installer, setInstaller] = React.useState('npm');
const installCmds = {
npm: '$ npm install arcrun',
pip: '$ pip install arcrun',
bun: '$ bun add arcrun',
};
return (
<div>
<TopNav onNav={onNav} current="landing" />
<div className="container">
<section className="hero">
<div className="hero-bg" />
<div className="hero-bg-grid" />
<div className="hero-eyebrow">
<span className="dot" />
<span>Now in public beta MCP-native</span>
</div>
<h1>Build AI workflows<br/><span className="grad">without the glue code.</span></h1>
<p className="sub">Connect your tools, automate your work. Orchestrate workflows from Claude.ai, your IDE, or a few lines of code Arcrun handles auth, retries, and state.</p>
<div className="hero-ctas">
<button className="btn btn-primary btn-lg" onClick={() => onNav('auth')}>
Start free <Icon name="arrow_right" size={15} />
</button>
<button className="btn btn-secondary btn-lg">
<Icon name="book" size={14} /> Read the docs
</button>
</div>
</section>
<section className="paths">
{/* Developer path */}
<div className="path-card">
<div className="path-label">
<Icon name="terminal" size={13} /> For Developers
</div>
<h3>Three lines, any runtime.</h3>
<p className="lede">Install once, call Arcrun from Node, Python, or your edge runtime. OAuth, rate limits, and retries are handled.</p>
<div className="install-tabs">
{Object.keys(installCmds).map(k => (
<button key={k} className={installer === k ? 'active' : ''} onClick={() => setInstaller(k)}>{k}</button>
))}
</div>
<div className="terminal" style={{marginBottom: 12}}>
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">terminal</div>
</div>
<div className="terminal-body">
<div><span className="dim">{installCmds[installer]}</span></div>
</div>
</div>
<div className="terminal">
<div className="terminal-head">
<div className="dots"><span/><span/><span/></div>
<div className="title">{installer === 'pip' ? 'app.py' : 'app.ts'}</div>
</div>
<div className="terminal-body">
{installer === 'pip' ? (
<>
<div><span className="c1">from</span> <span className="c2">arcrun</span> <span className="c1">import</span> <span className="c2">Arcrun</span></div>
<div className="sp-4"/>
<div><span className="c2">client</span> = <span className="c4">Arcrun</span>(<span className="c2">token</span>=<span className="c2">os</span>.<span className="c4">getenv</span>(<span className="c3">"ARCRUN_KEY"</span>))</div>
<div><span className="c2">run</span> = <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, <span className="c2">inputs</span>={'{'}<span className="c3">"user"</span>: <span className="c3">"u_219"</span>{'}'})</div>
</>
) : (
<>
<div><span className="c1">import</span> {'{'} <span className="c2">Arcrun</span> {'}'} <span className="c1">from</span> <span className="c3">"arcrun"</span>;</div>
<div className="sp-4"/>
<div><span className="c1">const</span> <span className="c2">client</span> = <span className="c1">new</span> <span className="c4">Arcrun</span>({'{'} <span className="c2">token</span>: <span className="c2">process</span>.<span className="c2">env</span>.<span className="c2">ARCRUN_KEY</span> {'}'});</div>
<div><span className="c1">const</span> <span className="c2">run</span> = <span className="c1">await</span> <span className="c2">client</span>.<span className="c4">run</span>(<span className="c3">"digest/weekly"</span>, {'{'} <span className="c2">user</span>: <span className="c3">"u_219"</span> {'}'});</div>
</>
)}
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Typed SDKs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Idempotent runs</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Self-host ready</span>
</div>
</div>
{/* Everyone path */}
<div className="path-card">
<div className="path-label">
<Icon name="spark" size={13} /> For Everyone
</div>
<h3>Talk to your workflows.</h3>
<p className="lede">Install Arcrun inside your AI assistant and run your apps by asking. Trigger workflows, fetch data, or draft messages in plain English.</p>
<div className="chat-preview">
<div className="chat-head">
<span className="brand-dot">AI</span>
<span>Your assistant Arcrun connected</span>
<span style={{marginLeft: 'auto'}} className="pill active"><span className="pdot" />2 apps</span>
</div>
<div className="chat-body">
<div className="chat-msg user">
<div className="avatar">M</div>
<div className="bubble">Send this week's customer digest to the revenue team.</div>
</div>
<div className="chat-msg ai">
<div className="avatar">A</div>
<div className="bubble">
Running <span style={{color: 'var(--primary)', fontWeight: 500}}>digest/weekly</span> for 147 accounts, then posting to #revenue.
<div className="tool-card">
<div className="tool-icon">AR</div>
<div className="tool-meta">
<div className="tool-name">arcrun · digest/weekly</div>
<div className="tool-sub">4 of 5 steps complete · 00:12 elapsed</div>
</div>
<span className="pill active"><span className="pdot" />running</span>
</div>
</div>
</div>
</div>
<div className="chat-input">
<span>Reply to your assistant</span>
<span className="caret" />
</div>
</div>
<div className="sp-16" />
<div className="flex gap-12" style={{fontSize: 12.5, color: 'var(--text-mute)'}}>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> One-click connect</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Works in your IDE</span>
<span className="flex gap-6" style={{alignItems: 'center'}}><Icon name="check" size={12} /> Audit trail</span>
</div>
</div>
</section>
<section className="strip">
<div className="cell">
<div className="ico"><Icon name="bolt" size={15} /></div>
<h4>Run anywhere</h4>
<p>Node, Python, Deno, Bun, Cloudflare Workers. One API, same semantics.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="workflow" size={15} /></div>
<h4>Composable steps</h4>
<p>Model calls, HTTP, database, branching wire them visually or in code.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="key" size={15} /></div>
<h4>Scoped keys</h4>
<p>Per-workflow API keys with fine-grained scopes and live revocation.</p>
</div>
<div className="cell">
<div className="ico"><Icon name="eye" size={15} /></div>
<h4>Observable</h4>
<p>Every run is replayable. Inspect inputs, outputs, and token usage.</p>
</div>
</section>
</div>
<Footer onNav={onNav} />
</div>
);
};
window.Landing = Landing;
@@ -0,0 +1,255 @@
const WorkflowViewer = ({ onNav }) => {
const nodes = [
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
];
const edges = [
['trigger', 'fetch'],
['trigger', 'events'],
['fetch', 'summarize'],
['events', 'summarize'],
['summarize', 'filter'],
['summarize', 'log'],
['filter', 'slack'],
['filter', 'mail'],
];
const [selectedId, setSelectedId] = React.useState('summarize');
const [title, setTitle] = React.useState('digest/weekly');
const [zoom, setZoom] = React.useState(100);
const selected = nodes.find(n => n.id === selectedId);
// Edit triplet inline (for the summarize node's prompt config)
const [triplet, setTriplet] = React.useState({
model: 'claude-haiku-4-5',
temperature: '0.3',
prompt: 'Summarize this week\'s account activity for the revenue team.',
});
// Measure node widths for edge endpoint accuracy
const nodeRefs = React.useRef({});
const [sizes, setSizes] = React.useState({});
React.useEffect(() => {
const ns = {};
for (const n of nodes) {
const el = nodeRefs.current[n.id];
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
}
setSizes(ns);
}, []);
const getPort = (id, side) => {
const n = nodes.find(x => x.id === id);
const sz = sizes[id] || { w: 200, h: 60 };
return {
x: side === 'out' ? n.x + sz.w : n.x,
y: n.y + sz.h / 2,
};
};
return (
<div className="wf-viewer">
<div className="wf-topbar">
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
<Icon name="arrow_left" size={16} />
</div>
<Logo size="sm" onClick={() => onNav('landing')} />
<div className="sep" />
<div className="wf-breadcrumb">
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
<Icon name="chevron_right" size={11} />
<input
className="wf-title mono"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<span className="wf-saved">
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
Saved · 2m ago
</span>
<div className="spacer" />
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
<button className="wf-edit-in-claude">
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
</button>
</div>
<div className="wf-canvas">
<svg className="wf-edges" width="100%" height="100%">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
</marker>
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
</marker>
</defs>
{edges.map(([a, b], i) => {
const p1 = getPort(a, 'out');
const p2 = getPort(b, 'in');
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
const highlight = a === selectedId || b === selectedId;
return (
<path key={i} d={d}
stroke={highlight ? '#6366F1' : '#3a3a3a'}
strokeWidth={highlight ? 2 : 1.5}
fill="none"
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
opacity={highlight ? 0.95 : 0.6} />
);
})}
</svg>
<div className="wf-nodes">
{nodes.map(n => (
<div key={n.id}
ref={el => (nodeRefs.current[n.id] = el)}
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
style={{left: n.x, top: n.y}}
onClick={() => setSelectedId(n.id)}>
{n.inputs.length > 0 && <span className="port in" />}
{n.outputs.length > 0 && <span className="port out" />}
<div className="node-row-top">
<span className="node-icon" style={{background: n.tone}}>
<Icon name={n.icon} size={12} />
</span>
<span className="node-title">{n.title}</span>
<span className="node-badge">{n.badge}</span>
</div>
<div className="node-sub">{n.type}</div>
</div>
))}
</div>
{/* Detail panel */}
{selected && (
<div className="wf-detail">
<div className="dt-head">
<span className="dt-icon" style={{background: selected.tone}}>
<Icon name={selected.icon} size={15} />
</span>
<div className="dt-meta">
<h3>{selected.title}</h3>
<div className="dt-type">{selected.type}</div>
</div>
<button className="close-btn" onClick={() => setSelectedId(null)}>
<Icon name="close" size={14} />
</button>
</div>
<div className="dt-body">
<div className="dt-section">
<h4>Input schema</h4>
{selected.inputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs this is a trigger.</div>
) : selected.inputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
<div className="dt-section">
<h4>Output schema</h4>
{selected.outputs.length === 0 ? (
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs terminal node.</div>
) : selected.outputs.map(f => (
<div key={f.k} className="schema-field">
<span className="k">{f.k}</span>
<span className="t">{f.t}</span>
</div>
))}
</div>
{selected.id === 'summarize' && (
<div className="dt-section">
<h4>Configuration</h4>
<div className="triplet">
<div className="trow">
<div className="tkey">model</div>
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">temp</div>
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
</div>
<div className="trow">
<div className="tkey">prompt</div>
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
</div>
</div>
</div>
)}
<div className="dt-section">
<h4>Last run</h4>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
</div>
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
</div>
</div>
</div>
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
<Icon name="spark" size={13} /> Edit this node in Claude
</button>
</div>
</div>
)}
{/* Minimap */}
<div className="wf-minimap">
<div className="mini-label">Overview</div>
{nodes.map(n => {
const sz = sizes[n.id] || {w: 180, h: 60};
return (
<div key={n.id} className="mini-box" style={{
left: 8 + (n.x / 1400) * 164,
top: 18 + (n.y / 500) * 80,
width: Math.max(6, (sz.w / 1400) * 164),
height: Math.max(4, (sz.h / 500) * 80),
opacity: selectedId === n.id ? 1 : 0.5,
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
}} />
);
})}
</div>
{/* Zoom controls */}
<div className="wf-controls">
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
<div className="zoom-val">{zoom}%</div>
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
<button><Icon name="maximize" size={13} /></button>
</div>
</div>
</div>
);
};
window.WorkflowViewer = WorkflowViewer;
@@ -0,0 +1,300 @@
# Frontend Redesign — Design
> 讀此檔前請先讀 `requirements.md``design-source/index.html`
> 視覺 spec 的 single source of truth 是 `design-source/`Claude Design 匯出的 HTML/JSX prototype)。
---
## 1. 架構總覽
```
landing/ (Next.js 15 App Router)
├── app/
│ ├── layout.tsx ← 全站 layoutnext/font + design tokens + 全域 CSS 匯入
│ ├── globals.css ← 匯入 design-tokens.cssTailwind @import
│ ├── design-tokens.css ← 新增:從 design-source 抽出的 CSS variables:root {...}
│ ├── page.tsx ← LandingRSC
│ ├── auth/
│ │ └── page.tsx ← Auth"use client"
│ ├── dashboard/
│ │ └── page.tsx ← Dashboard"use client",仍靠 middleware 保護)
│ ├── keys/
│ │ └── page.tsx ← API Keys"use client"
│ ├── workflows/
│ │ ├── page.tsx ← Workflows 清單(redirect 到 dashboard 的 table,本身極簡)
│ │ └── [name]/page.tsx ← Workflow Viewer"use client"
│ ├── integrations/page.tsx ← 保留現有
│ ├── api-docs/page.tsx ← 保留現有
│ └── login/page.tsx ← 保留現有(redirect /auth 同義;見 §9 遷移策略)
├── components/
│ ├── shell/
│ │ ├── Logo.tsx
│ │ ├── Icon.tsx
│ │ ├── TopNav.tsx
│ │ ├── Footer.tsx
│ │ └── Sidebar.tsx
│ ├── primitives/
│ │ ├── Button.tsx ← btn / btn-primary / btn-secondary / btn-ghost 對應 class
│ │ ├── Pill.tsx
│ │ ├── Toggle.tsx
│ │ ├── Terminal.tsx ← landing hero 右卡用
│ │ └── ChatPreview.tsx ← landing hero 右卡用
│ └── workflow/
│ ├── Canvas.tsx ← wf-viewer 本體(節點 + SVG edges
│ ├── NodeCard.tsx
│ ├── DetailPanel.tsx
│ ├── Minimap.tsx
│ └── ZoomControls.tsx
├── lib/
│ ├── api.ts ← typed fetch wrapperfetch ${API_BASE}${path}, credentials: 'include'
│ ├── workflows.ts ← listWorkflows / getWorkflow / getWorkflowYaml
│ ├── apiKeys.ts ← listKeys / createKey / patchKey / deleteKey
│ └── me.ts ← 已存在邏輯,集中到此
├── middleware.ts ← 擴展 matcher(加 /keys, /workflows/*
└── ...(既有 package.json / wrangler.toml 不變)
```
**路由對照設計稿的 5 screen**
| Screen | Route |
|---|---|
| Landing | `/` |
| Auth | `/auth`(新增;`/login` 保留並內部 `redirect('/auth')` |
| Dashboard | `/dashboard` |
| API Keys | `/keys` |
| Workflow Viewer | `/workflows/[name]` |
---
## 2. Design tokens 對應
設計稿所有 CSS 變數抄進 `app/design-tokens.css`**不解析、不改名**
```css
:root {
--bg: #0F0F0F;
--bg-1: #141414;
--card: #1A1A1A;
--card-2: #222222;
--line: #262626;
--line-2: #303030;
--text: #EDEDED;
--text-dim: #A0A0A0;
--text-mute: #6B6B6B;
--primary: #6366F1;
--primary-2: #8B5CF6;
--primary-soft: rgba(99, 102, 241, 0.12);
--primary-ring: rgba(99, 102, 241, 0.32);
--success: #22C55E;
--warn: #F59E0B;
--danger: #EF4444;
--gradient: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
--gradient-soft: linear-gradient(135deg, rgba(99,102,241,0.16) 0%, rgba(139,92,246,0.16) 100%);
}
```
並在 Tailwind v4 的 `@theme inline` block 內對應出:
```css
@theme inline {
--color-bg: var(--bg);
--color-card: var(--card);
--color-card-2: var(--card-2);
--color-line: var(--line);
--color-line-2: var(--line-2);
--color-text: var(--text);
--color-text-dim: var(--text-dim);
--color-text-mute: var(--text-mute);
--color-primary: var(--primary);
--color-primary-2: var(--primary-2);
}
```
這樣 JSX 裡可用 `bg-bg / text-text-dim / border-line`,又保留 CSS 變數語義。
**現有的 `--background: #0a0a0a` 要換成 `#0F0F0F`**(視覺 breaking change;受影響:所有沿用 `bg-[#0a0a0a]` 的 inline 值)。
---
## 3. 字型
```tsx
// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
weight: ['300', '400', '500', '600', '700', '800'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
weight: ['400', '500', '600'],
});
// body class = `${inter.variable} ${mono.variable}`
```
`globals.css` 中的 `body { font-family: var(--font-inter), -apple-system, sans-serif; }``.mono` class 用 `font-family: var(--font-mono)`
**移除**
- `design-source/index.html` 第 7-9 行的 `<link rel="preconnect"> / <link href="fonts.googleapis.com">`(不寫入 production)。
- React / Babel standalone script 標籤(prototype 專用,不進 production)。
---
## 4. 元件 porting 規則
Claude Design 用了 `window.Icon / window.Logo / window.AppIcon / window.TopNav ...` 的 globals 風格 — 那是 prototype 專用。Port 到 Next.js 時:
1. 每個元件拆單檔、具名 export。
2. 用 Tailwind + `className` 模板字串;共用 variant(如 btn)用 `cva`-style helper 即可(自己寫 5 行的 `clsx`-alike 函式),**不引入 class-variance-authority / clsx 套件**(避免新依賴)。
3. Icon 的 `paths` 直接搬,但每個 icon 拆成自己的 functional component 或集中在一個 `<Icon name="..." />`(沿用 design source 的 pattern)。
4. SVG arc wordmark 的 logo 直接 port。
---
## 5. 各 screen 實作細節
### 5.1 Landing — `app/page.tsx`
- 結構:`<TopNav />` + `<Hero />` + `<Paths />` + `<Strip />` + `<Footer />`
- Heroheading、eyebrow、CTA、radial grid bg(純 CSS)。
- Paths 左卡(Developer):install tabs (`npm` / `pip` / `bun`) + 兩個 terminal block**code 範例用 dogfooding 範例**`acr` CLI),不留 `Arcrun` SDK 假 API。
- Paths 右卡(Everyone):chat preview 結構保留;assistant 對話中的 tool call 用「arcrun · digest/weekly」不動。
- Strip4 cell。
- `LandingClientTabs` 因為有 tabs state,需標 `"use client"`;外層保持 RSC。
### 5.2 Auth — `app/auth/page.tsx`
- `"use client"`。state`mode: 'signin' | 'signup'`, `email`, `pw`, `remember`
- Submit`fetch(${API_BASE}/auth/password-login, { method: 'POST', credentials: 'include' })`**若 cypher-executor 尚未支援 password auth,先顯示「Password 登入尚未開放,請用 OAuth」警示,不偽造成功流程**)。
- OAuth 按鈕:直接 `<a href={API_BASE}/auth/google/start?redirect=/dashboard>`,和現行 `/login` 同樣機制。
- 下標提示「By signing up, you agree to our Terms ...」保留 static 字串。
- 保留 `/login` 路由向後相容(RSC 裡 `redirect('/auth')`)。
### 5.3 Dashboard — `app/dashboard/page.tsx`
- `"use client"` 或 split(外層 RSC 抓 /me,內層 Client)。
- 由 `<Sidebar current="dashboard" />` + main。
- 主要區塊:
- Main headbreadcrumb「{email 的 domain} Dashboard」、heading「Welcome back, {display_name}」、subtitle 顯示 app/workflow 總數(從 `/apps` + `/workflows` 計算;若 endpoint 404 → 顯示 `—`)。
- Apps Grid`/apps` 的結果渲染;每列永遠有一個 `app-empty` 卡(新建 CTA)。
- Workflows Table`/workflows` 的結果渲染;空時改為全寬「No workflows yet. Run `acr push` to add one.」內嵌指令框。
- 「Open app」「View」按鈕導向 `/workflows/[name]`
- 「Edit in Claude」按鈕本次不做動作,僅保 UIdisabled + tooltip「Coming soon」)。
### 5.4 API Keys — `app/keys/page.tsx`
- `"use client"`
- Fetch `/api-keys`:若回傳為空陣列但 `/me` 有 api_keyfallback 顯示 `/me.api_key` 為唯一一列(單 key 相容模式)。
- 頂部 new-key-box:只在「剛剛建立新 key」的一次性狀態顯示(`useState` + `sessionStorage` flagreload 後消失)。
- 表格、toggle、trash:對應 PATCH / DELETE。
- 「Create new key」按鈕:呼叫 `POST /api-keys`,拿到後打 highlight box。
- Revoke 警告文字維持設計稿「within 60 seconds」。
### 5.5 Workflow Viewer — `app/workflows/[name]/page.tsx`
- `"use client"`param `name` 來自動態路由。
- Mount 後呼叫 `GET /workflows/:name`:後端回傳 `{ name, nodes: Node[], edges: Edge[], yaml, last_run: {...} }`(若 endpoint 未實作 → 顯示「Workflow viewer 尚未啟用」empty state,不用假資料)。
- `<Canvas>` 內:
- SVG 的 `<marker>`, `<path>` 定義抄設計稿。
- Node 用絕對定位(x/y 直接用 API 資料;資料沒有 coord 時做自動 layout — 階段性做簡單 dagre-free 的「column by depth」排版,避免新依賴)。
- 點選節點 → 右側 detail panel 顯示 input/output schema;若 type 含 `ai.*`,顯示 triplet 編輯器(model / temp / prompt)— 編輯本次 **read-only**disabled input + 「Edit via acr CLI」提示)。
- 「Export YAML」按 `GET /workflows/:name/yaml``download` blob。
- 「Edit in Claude」:本次只開新 tab 到 `https://claude.ai/new?q=...`(文案「coming soon」按鈕),避免偽裝已整合。
- Zoom controls、minimap:純 UI`zoom` state 實際不套 transform(或簡單 `style={{ transform: scale(zoom/100) }}` 套在 `.wf-nodes` + svg)。
---
## 6. API wrapper`lib/api.ts`
```ts
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
export async function arcrunFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
headers: { 'Accept': 'application/json', ...(init.headers ?? {}) },
...init,
});
if (res.status === 401 && typeof window !== 'undefined') {
window.location.href = `/auth?redirect=${encodeURIComponent(location.pathname)}`;
throw new Error('unauthenticated');
}
if (!res.ok) throw new Error(`arcrun ${path}: ${res.status}`);
return res.json() as Promise<T>;
}
```
所有頁面透過這個 wrapper。**禁止在 page.tsx 裡 hard-code `fetch('https://...')`**(測試可以 grep)。
---
## 7. Middleware
```ts
export const config = {
matcher: ['/dashboard/:path*', '/keys/:path*', '/workflows/:path*'],
};
```
現有邏輯(讀 `arcrun_session` cookie,沒有就 redirect `/login?redirect=...`)保留,`/login` 改為內部 redirect `/auth`
---
## 8. 不做的設計稿功能
| 設計元素 | 取捨 |
|---|---|
| 底部的 `proto-switch`5 個 screen 切換 pill | **刪**。那是 prototype 用的 demo 切換器,不進 production。 |
| Sidebar 的 `count` badge | 先保留;數字從 `/workflows` / `/apps` 的長度派生;無資料時藏起來。 |
| Sidebar bottom 的 avatar + "Maya Rivera / maya@northwind.co" | 換成 `{display_name} / {email}`(真資料)。 |
| Workflow Viewer 的 triplet 可編輯 | 本次 disabled,僅顯示。 |
| 「Edit in Claude」整合 | 按鈕保留,點擊開新 tab 到 claude.ai,不串 MCP/API。 |
| 多 workspace breadcrumb | 固定顯示用戶 email domain 或「Personal」。 |
---
## 9. 既有頁面遷移
| 既有 | 處理 |
|---|---|
| `/page.tsx` | **rewrite**:沿用設計稿結構,code demo 字串改為 `acr` 實際指令(現有的 `auth.bind(...)` 寫法可保留在 Python tab |
| `/login` | 改為 `redirect('/auth')`Next.js RSC redirect),保留舊連結相容 |
| `/dashboard` | **rewrite**:舊 dashboard 變成 API Keys 獨立頁 + 新 Dashboard 總覽。原本 dashboard 裡的 Key 卡片搬到 `/keys`。 |
| `/api-docs` | 不動 |
| `/integrations` | 不動;在 Dashboard Apps Grid 旁提供 link |
---
## 10. 開發順序(高度相依)
`tasks.md`。總則:
1. 先做 design tokens + shellLogo / Icon / Button / Sidebar / TopNav / Footer — 其他頁面都吃這些。
2. 然後 Landing(可直接驗證視覺基準)。
3. 然後 Auth(獨立)。
4. 然後 API Keys(後端依賴少)。
5. 然後 Dashboard(依賴 `/workflows` + `/apps`,若未實作先 empty state)。
6. 最後 Workflow Viewer(依賴最重,多 endpoint)。
---
## 11. 風險與未解
| 風險 | 緩解 |
|---|---|
| cypher-executor 尚未有 `/workflows`, `/apps`, `/api-keys` CRUD | 前端先做,統一走 404 → empty state;另開 task 去 cypher-executor SDD 增補。本次 SDD 不負責後端實作。 |
| Password auth 沒實作 | Auth 頁 email/password form 在 submit 時顯示「OAuth only」提示 |
| `acr push` 未記錄 node 座標 | Canvas 自動排版(by topological depth),不強制 YAML 載入 layout |
| `next-on-pages``"use client"` 大量頁面的 edge runtime 支援 | 本來就用 `next-on-pages`,問題不大;必要時 per-page `export const runtime = 'edge'` |
| 舊 `/dashboard` 的 bookmark 使用者 | 現行 `/dashboard` 的 Key 管理被搬走;保留 Key 區塊 + 顯示提示「New page: /keys」引導 |
---
## 12. 與封測的關係
此 SDD 的實作**不解除封測阻擋**(封測阻擋在 Credential Primitives WASM)。此重設計與 Phase 0.6 / 0.7 / 1-3 是並行軌道。richblack 可決定先後順序,但本 SDD 獨立可 ship。
@@ -0,0 +1,159 @@
# Frontend Redesign — Requirements
> 來源:Claude Design bundle `JAdpACs3cSyw_vN6Ketj1Q`(已歸檔於 `design-source/`)。
> 此 SDD 擴展 `../landing-page.md` 的範圍:landing 從「單頁 + OAuth + Dashboard」升級為「5 screen app shell」。
---
## 1. 背景
`arcrun/landing/` 目前只提供:
- `/` — Landing hero + code demo
- `/login` — Google / GitHub OAuth
- `/dashboard` — API Key 管理
- `/api-docs` — Swagger UI(外部)
- `/integrations` — 20 個 recipe 靜態清單
Claude Design 交付一套完整 5 screen 設計(Landing / Auth / API Keys / Dashboard / Workflow Viewer),與現有前端相比多了:
- **Dashboard 的 Apps Grid 與 Workflows Table**(現在沒有應用/workflow 清單 UI
- **API Keys 獨立頁**(現在和 dashboard 混在一起)
- **Workflow Viewer**node-based canvas,對應 YAML workflow 的視覺化(目前 acr push YAML 後只有 CLI 輸出)
---
## 2. User Stories
### US-1:新訪客認識 arcrun
- 作為沒用過 arcrun 的工程師,我要在 landing 看到兩條路(For Developers / For Everyone),5 秒內判斷這是否符合我要的用法。
- 驗收:hero + 雙 path card + feature strip 在首屏呈現;CTA「Start free」可點擊到 `/auth`
### US-2:取得 / 登入會員
- 作為訪客,我要用 GitHub / Google / Email 登入建立帳號。
- 驗收:`/auth` 支援 Sign in / Sign up tabs、GitHub + Google OAuth、Email + Password 表單。OAuth 成功後導向 `/dashboard`
### US-3:看見應用全貌
- 作為已登入用戶,我要在 Dashboard 看到我已部署的 Apppackaged workflow)和 Workflow 清單。
- 驗收:
- Apps Grid:每個 app 卡片顯示名稱、描述、「Open app」「Edit in Claude」。
- Workflows Table:顯示 workflow 名稱、節點數、最後修改、執行次數、狀態。
- 若 app / workflow 為空,顯示新建 CTA(非硬編 mock)。
### US-4:管理 API Keys
- 作為已登入用戶,我要建立、檢視、停用、刪除 API Key。
- 驗收:
- 剛產生的 Key 顯示在頂部高亮卡片(含警示文字「不會再顯示」)。
- 全部 Keys 在下方表格顯示(名稱、遮蔽後 key、建立時間、最後使用、狀態、toggle、trash)。
- Rotate / Revoke 立即生效(呼叫 cypher-executor)。
### US-5:檢視 Workflow graph
- 作為已登入用戶,我要點 workflow 進到 viewer,看到節點 DAG 與每個節點的 I/O schema 與參數。
- 驗收:
- Canvas 顯示節點(含 icon / type badge),節點以 bezier 曲線相連。
- 選中節點後右側面板顯示 input / output schema、configuration(針對 ai.completion 等節點顯示 triplet 編輯器)。
- 工具列含 Share / Export YAML / Edit in Claude。
- Minimap + zoom 控制顯示可用。
- 「Export YAML」呼叫 cypher-executor 取得該 workflow 的原始 YAML。
### US-6Dogfooding
- 作為 arcrun 核心維護者,我要前端所有與服務打交道的動作都透過 arcrun 自家 APIcypher-executor)完成,不依賴第三方 OAuth / workflow / backend 服務。
- 驗收(見 §6)。
---
## 3. 非功能需求
| 項目 | 規範 |
|---|---|
| 技術棧 | Next.js 15 App Router、React 19、Tailwind v4、TypeScript(沿用 `landing/` 現有堆疊) |
| 部署 | Cloudflare Pages`@cloudflare/next-on-pages`),沿用 `landing/wrangler.toml` |
| 字型 | Inter、JetBrains Mono — 用 `next/font/google`,不拉 unpkg / fonts.googleapis.com `<link>` |
| 依賴 | 僅 Next / React / Tailwind;禁止 tRPC、React Query、SWR、Auth.js/Clerk、ReactFlow/XYFlow、Radix、shadcn CLI install、animation libraryframer-motion)等第三方 |
| 狀態管理 | React 內建(useState / useReducer / Context);持久化用 `localStorage` 或 server session cookie |
| 國際化 | 延後;本次一律英文(與設計稿一致)。現有 `?lang=zh` 不擴展 |
| 無障礙 | 按鈕 `aria-label`、Form 控件有 `<label>`;鍵盤可完成登入 / 複製 key 流程 |
| 效能 | Landing 首屏無阻塞 JSRSC);Dashboard / Workflow Viewer 可為 Client Component |
---
## 4. 視覺基準
| 項目 | 值(source of truth`design-source/index.html` 的 CSS 變數) |
|---|---|
| 主背景 | `#0F0F0F`(現有是 `#0a0a0a` — 本次改為 `#0F0F0F` |
| Card | `#1A1A1A` |
| Line | `#262626` / `#303030` |
| Primary | `#6366F1`indigo`#8B5CF6`violet)漸層 |
| Text | `#EDEDED` / dim `#A0A0A0` / mute `#6B6B6B` |
| 字型 | Inter 300/400/500/600/700/800Mono 400/500/600 |
---
## 5. 範圍界線
### 納入
- 5 screenLanding、Auth、Dashboard、API Keys、Workflow Viewer
- LogoSVG arc wordmark)、Icon setinline SVG,從 primitives.jsx port
- TopNav / Footer / Sidebar 三個 shell 元件
### 不納入(本次 SDD
- **Multi-tenant workspace 切換**(設計稿有 "Northwind" breadcrumb,本次純顯示用戶 email
- **Multi-API-key CRUD 後端**cypher-executor 現只支援每帳號一把 key,多 key table 先以「目前只支援一把」狀態呈現 — 見 §6.2)
- **Workflow 編輯**(只做 read-only viewer;編輯仍走 acr CLI / YAML
- **即時執行狀態 stream**minimap / zoom 僅 UI,不做真實 pan-zoom transform
- **i18n 中英切換**、**Hall of Fame**、**Donate**
- **Swagger UI 頁(/api-docs** — 保留現狀
---
## 6. API 依賴(全部打 `cypher.arcrun.dev`
### 6.1 既有(已實作於 cypher-executor
- `GET /me` — 取得登入用戶 + api_keydriverdashboard 側欄 avatar / API Keys 頁的單把 key
- `PUT /me/api-key/rotate``DELETE /me/api-key` — Rotate / Revoke
- `GET /auth/google/start``GET /auth/github/start` — OAuth 起點
- `POST /auth/logout`
- `POST /webhooks/named/{name}/trigger` — 觸發(給 Landing 文案展示)
### 6.2 需新增的 endpoint(阻擋項;**本 SDD 只定義契約,cypher-executor 實作歸屬於另一個 task**
| Method | Path | 說明 | 用途 |
|---|---|---|---|
| GET | `/workflows` | 列出當前 api_key 名下的 workflow(名稱、nodes、modified、run_count、status | Dashboard / Workflows Table |
| GET | `/workflows/:name` | 取得 workflow 詳細(含 graph 節點 + edges + YAML | Workflow Viewer |
| GET | `/workflows/:name/yaml` | 下載 raw YAML | Workflow Viewer Export |
| GET | `/apps` | 列出 app= workflow 標上 icon/tone/description metadata | Dashboard / Apps Grid |
| GET | `/api-keys` | 列出多把 key(若後端仍是單把,回傳單元素 array) | API Keys 頁 |
| POST | `/api-keys` | 建立新 key | API Keys 頁「Create new key」 |
| PATCH | `/api-keys/:id` | `{ active: boolean }` 切換 | API Keys 頁 toggle |
| DELETE | `/api-keys/:id` | 刪除 | API Keys 頁 trash |
**在後端尚未實作前**:前端用型別化 fetch wrapper 封裝呼叫;遇到 404 顯示 empty state(而非假資料)。本 SDD 明確禁止 hard-code mock fixture。
### 6.3 登入前後可用的公開資訊
- Integrations 清單(20 個 recipe):現有 `/integrations` 頁已有靜態清單,繼續複用。
---
## 7. Dogfooding 紅線
| 禁止 | 用哪個 arcrun 內部替代 |
|---|---|
| Auth0 / Clerk / Supabase Auth | cypher-executor `/auth/*` + session cookie(現行 `arcrun_session` |
| Segment / PostHog | 不加,或用後續 arcrun `analytics` 零件 |
| Vercel KV / Upstash | Cloudflare KV(經 cypher-executor |
| 直接寫第三方 APISlack / Notion 等)作 dashboard demo | 透過 arcrun workflow + trigger 模擬 |
| ReactFlow / XYFlow | 純 SVG 手刻(設計稿本來就是手刻) |
---
## 8. 驗收總清單
- [ ] 5 個 screen 全部在 `/`, `/auth`, `/dashboard`, `/keys`, `/workflows/[id]` 可達。
- [ ] 設計稿的 spacing / color / border-radius 100% 對得上(以 `design-source/index.html` CSS 變數為準)。
- [ ] middleware 保護 `/dashboard`, `/keys`, `/workflows/*`(未登入 → `/auth?redirect=...`)。
- [ ] 只打 `cypher.arcrun.dev`(可透過 `NEXT_PUBLIC_API_BASE` override),grep 結果不含其他外部 API host。
- [ ] `package.json` 新增依賴 = 0(本次不引入新 npm 套件)。
- [ ] 無 mock 資料:若後端未實作,顯示 loading 或 empty state,不編假陣列給 UI。
- [ ] `pnpm build` 通過,`next-on-pages` 輸出無 edge-runtime 錯誤。
@@ -0,0 +1,140 @@
# Frontend Redesign — Tasks
> 進度來源:本檔。完成一項立刻 `[x]`,不批次。
> 本 SDD 建立於 2026-04-23richblack 尚未下令動工,**所有 task 預設 `[ ]`**。
---
## Phase 0 — SDD 建立(本次)
- [x] 取得 Claude Design bundle,歸檔至 `design-source/`
- [x] 撰寫 `requirements.md`
- [x] 撰寫 `design.md`
- [x] 撰寫 `tasks.md`(本檔)
- [ ] richblack review + 認可 → 開 Phase 1
**等 richblack 明確說「開始動工」之前,不觸 `landing/` 任何檔案。**
---
## Phase 1 — Tokens + Shell(無外部 API 依賴)
- [ ] 1.1 建 `landing/app/design-tokens.css`,抄 design-source CSS :root 變數
- [ ] 1.2 更新 `landing/app/globals.css``@import './design-tokens.css'``@theme inline` 對應 token → Tailwind color
- [ ] 1.3 `layout.tsx` 引入 `next/font/google` 的 Inter + JetBrains Monobody bg 改 `var(--bg)`
- [ ] 1.4 建 `landing/components/shell/Icon.tsx`(港設計稿 primitives.jsx 的所有 icon
- [ ] 1.5 建 `landing/components/shell/Logo.tsx`SVG arc wordmark
- [ ] 1.6 建 `landing/components/shell/TopNav.tsx`
- [ ] 1.7 建 `landing/components/shell/Footer.tsx`
- [ ] 1.8 建 `landing/components/shell/Sidebar.tsx`(含頭像、項目清單、登出按鈕)
- [ ] 1.9 建 `landing/components/primitives/Button.tsx`variants: primary / secondary / ghost / danger-ghost / sm / lg
- [ ] 1.10 建 `landing/components/primitives/Pill.tsx``Toggle.tsx`
- [ ] 1.11 Lint + build pass
---
## Phase 2 — Landing`/`
- [ ] 2.1 Rewrite `app/page.tsx`,結構照 design-source/screens/Landing.jsx
- [ ] 2.2 Heroheading / eyebrow / CTA / radial-grid bgCSS only
- [ ] 2.3 Paths 左卡:install tabs 元件(Client Component+ terminal code blocks(字串改 `acr` 實際指令)
- [ ] 2.4 Paths 右卡:chat preview(靜態)
- [ ] 2.5 Feature strip4 cell
- [ ] 2.6 TopNav / Footer 接上
- [ ] 2.7 Responsive(≤ 768px: paths 單欄、hero h1 縮 1 級)
- [ ] 2.8 視覺比對 design-source/index.html(截圖對比 / DOM spec 檢查)
---
## Phase 3 — Auth`/auth``/login` redirect
- [ ] 3.1 建 `app/auth/page.tsx`Client
- [ ] 3.2 Sign in / Sign up tabs + OAuth buttonsGoogle / GitHub)接既有 `/auth/google/start` / `/auth/github/start`
- [ ] 3.3 Email / Password form — submit 顯示「OAuth only」提示(待後端支援)
- [ ] 3.4 `/login` 頁改為 `redirect('/auth')`
- [ ] 3.5 視覺比對 design-source/screens/Auth.jsx
---
## Phase 4 — API Keys`/keys`
- [ ] 4.1 建 `lib/api.ts` fetch wrapper
- [ ] 4.2 建 `lib/apiKeys.ts`listKeys / createKey / patchKey / deleteKey)— 後端未實作時回 `[{ ...from /me }]` 的 fallback
- [ ] 4.3 建 `app/keys/page.tsx`:頂部 new-key-boxsessionStorage flag)、全表格
- [ ] 4.4 「Create new key」呼叫 `POST /api-keys`(後端未實作 → 顯示「coming soon」toast
- [ ] 4.5 Toggle active / trash 接 PATCH / DELETE
- [ ] 4.6 middleware.ts matcher 加 `/keys/:path*`
- [ ] 4.7 原 `/dashboard` 頁的 Key 卡區塊移除,放提示「API Keys 已搬到 /keys」
---
## Phase 5 — Dashboard`/dashboard`
- [ ] 5.1 建 `lib/workflows.ts``lib/apps.ts`
- [ ] 5.2 Rewrite `app/dashboard/page.tsx`(保留 /me session 檢查)
- [ ] 5.3 Main headbreadcrumb / greeting / summary counters
- [ ] 5.4 Apps Grid + empty-state 卡(`/apps` 404 → 只顯示 empty-state
- [ ] 5.5 Workflows Table`/workflows` 404 → 顯示「no workflows — acr push」CTA
- [ ] 5.6 Sidebar 接真實 useremail / display_name)、登出按鈕接 `/auth/logout`
- [ ] 5.7 視覺比對 design-source/screens/Dashboard.jsx
---
## Phase 6 — Workflow Viewer`/workflows/[name]`
- [ ] 6.1 建 `app/workflows/[name]/page.tsx`Client
- [ ] 6.2 Topbarback / logo / breadcrumb / title / saved indicator / share / export / edit-in-claude
- [ ] 6.3 `<Canvas>`SVG defs + 節點定位 + bezier edges;資料從 `GET /workflows/:name`
- [ ] 6.4 Auto-layouttopological depth → columns,同 depth 平均分配 y
- [ ] 6.5 NodeCard 點擊 → DetailPanel
- [ ] 6.6 DetailPanelinput / output schema、tripletdisabled)、last run stats(可選)
- [ ] 6.7 Export YAML`GET /workflows/:name/yaml` → blob download
- [ ] 6.8 Minimap(純顯示)、ZoomControls(簡單 scale
- [ ] 6.9 middleware.ts matcher 加 `/workflows/:path*`
---
## Phase 7 — 清理 + 收尾
- [ ] 7.1 刪除舊 `/dashboard` 不再用的 coderotate/revoke 若全搬到 /keys
- [ ] 7.2 Grep 檢查:除 `cypher.arcrun.dev` 外無任何第三方 API host
- [ ] 7.3 Grep 檢查:無 `mock` / `fixture` / 硬編的假資料陣列(`app-empty` 的字串常數除外)
- [ ] 7.4 Grep 檢查:無新增 npm 依賴(`git diff landing/package.json` 應只改版本,不加條目)
- [ ] 7.5 `pnpm build` 通過;`next-on-pages` 通過
- [ ] 7.6 local `pnpm dev` 手工巡覽 5 screen,每個截圖比對 design-source
- [ ] 7.7 更新 `.agents/specs/arcrun/arcrun.md`,加一段「CLI 1.2.0 搭配新 landing」之類的進度註記
- [ ] 7.8 richblack 認可 → 合併 / deploy
---
## 需要 cypher-executor 搭配的 endpoint(不屬於本 SDD
若 richblack 決定新 endpoint 要和前端同 PR 做:
- [ ] 後端:`GET /workflows`
- [ ] 後端:`GET /workflows/:name` + `/yaml`
- [ ] 後端:`GET /apps`
- [ ] 後端:`GET /api-keys``POST /api-keys``PATCH /api-keys/:id``DELETE /api-keys/:id`
否則:以 empty state 呈現,封測也能運作。
---
## KBDB 整合(配合 matrix/kbdb/.agents/specs/arcrun-key-auth/
Arcrun 用戶的 `ak_xxx` Key 同時可用於 KBDB(捆綁服務,自動開通)。
cypher-executor 需在以下時機呼叫 KBDB
- [ ] 後端:OAuth callback 成功 → `POST /partners`(建立 KBDB partner 記錄)
- [ ] 後端:`PUT /me/api-key/rotate` → 舊 partner revoke + 建新 partner 記錄
- [ ] 後端:`DELETE /me/api-key` → KBDB partner revoke
詳細設計見 `matrix/kbdb/.agents/specs/arcrun-key-auth/design.md`
---
## 目前狀態
- **進度**:Phase 0 已完成(4/5;最後一項等 richblack 認可)。
- **阻擋**richblack 認可 + 「開始動工」指令。
- **未啟動**Phase 1-7 全部 `[ ]`
@@ -0,0 +1,323 @@
# Design: KBDB Base —— 原子化萬用表(self-hosted 資料底座 + 官方核心)
> 2026-06-07 richblack 拍板。來源:壓測報告(`test_arcrun/docs/壓測報告.md`)暴露 self-hosted 無資料層 →
> richblack 決定把 KBDB 的「基礎儲存」開源進 arcrun,向量/三元組走插件模式(如 PostgreSQL 的 PGVector / Apache AGE)。
> **架構同時影響官方版**:官方也改成「基礎核心 + 可選模組」,故開源與官方共用同一基礎,不維護兩套。
---
## 0. 為什麼(壓測暴露的缺口)
壓測(第一次完整壓測,6 輪回歸)跑通了「表單 → workflow → Google Sheets」,但暴露 **self-hosted 沒有資料層**
| 缺口 | 現況 | KBDB Base 如何解 |
|---|---|---|
| **無「專案」維度** | KV key 只有 `{namespace}:{type}:{name}`,無 projectrecipe 甚至全局共享 | entries 樹狀(entry_type=project/workflowparent_id)→ 可列「某專案的所有工作流」 |
| **D1 完全空置** | self-hosted init 只建 8 KV,無 D1`grep d1_databases` 全 repo 零結果 | KBDB Base 用 D1 → 填上閒置的 D1 |
| **recipe 成功無記錄** | push 時 probe 2xx 但不持久化 | 成功記錄存 entries/records → 可監控、可當投稿依據 |
| **資料碎(像 n8n** | 散落 KV,無結構 | templates 做虛擬表 → 結構化資料形態 |
> recipe 成功記錄 + 投稿(帶記錄免驗證)本次一起做(見 7)。recipe 信譽/市場/排序是後續,不在本次。
---
## 1. 核心設計:原子化萬用表(萬年不動)
> richblack 設計原則:「基礎表是 truth,萬年不動。新技術(embed/triplet/未來)一直增加,
> 它們把衍生結果記在**各自的地方**,不回頭改基礎表。」這正是 PostgreSQL 核心 + PGVector/AGE 插件的模型。
### 三張表(沿用 KBDB v3 `0005_universal_table`,已驗證可用)
| 表 | 作用 |
|---|---|
| `entries` | 原子資料。`entry_type``block`/`value`/`template`/`slot`+ 本 SDD 擴充 `project`/`workflow`)。樹狀 `parent_id`、多租戶 `owner_id` |
| `templates` | 定義虛擬表的 slots`slots_json`,如 `["display_name","gender"]`)→ 同一份 entries 用不同 template 呈現不同資料形態 |
| `entry_values` | 把多個 entries 按 template 組成一筆結構化記錄(`record_id` + `slot_name` + `entry_id` |
### 萬年不動原則(本 SDD 要修正 KBDB 現況的地方)
KBDB 現況**大致已符合**,但有違反處要修:
- ✅ embed`block-embed.ts` 把向量 upsert 到 **Vectorize**(獨立),不動 blocks 表。
- ✅ triplet 主體:`triplet-crud.ts` 主要 `INSERT INTO entry_values`(獨立記錄)。
- 🔴 **違反處**`triplet-crud.ts:106/110` `UPDATE blocks SET entity_type = ?` —— 抽 triplet 後回頭改基礎表。
**修法**:衍生屬性(entity_type 等)移出基礎表,記到衍生層(entry_values 或獨立 template)。基礎表只存 truth。
---
## 2. 插件模式:一套 code,三層 binding 開關(不維護兩套)
> richblack:「像 PGVector/AGE 是插件。基礎模組提供原子化萬用表(這也是賣點);要向量、要圖資料庫另外裝。
> 我還是希望只維持一套。」
| 層 | 能力 | 依賴 | 開源 / 費用 |
|---|---|---|---|
| **基礎 KBDB Base** | entries/templates/entry_values CRUD + **D1 LIKE 關鍵字搜尋** | D1 only | ✅ 開源,免費,**不綁卡** |
| **+ embed 模組** | 語義搜尋(search 升級) | CF **Vectorize** binding + AI | embed 是 **CF 內建**(程式薄)→ **不拆 repo,給「開/關」**:有 Vectorize binding 就啟用,沒有就降級 LIKE。Vectorize 需用戶自開(自付費) |
| **+ triplet 模組** | 三元組抽取 / 知識圖譜 / 圖查詢 | AI + triplet-*.ts8 個 action,較重) | triplet 是 **richblack 的 IP、程式量大****獨立 repo / 獨立 worker**,基礎不依賴它,要才裝 |
### Q1 拍板(richblack 2026-06-07):embed 不拆、triplet 拆
- **embed → 不拆 repobinding 開/關**:因為 embed 靠 CF 內建 Vectorize,程式薄(呼叫 `env.VECTORIZE.upsert`),沒 binding 就降級。一套 code、開關即可。
- **triplet → 獨立 repo/worker**:非 CF 內建、是 richblack IP、action 多(triplet-extract/normalize/embed/crud/stats/syntax/entities/update)。基礎 KBDB **不 import triplet**,要才裝。
### search 分層(壓測者問「search 還是要有,只是沒語義」→ 對)
KBDB `search.ts` 已有兩層骨架:
- 基礎版:D1 `LIKE` 全文搜尋(KBDB 現有 fallback 邏輯,不需 Vectorize)。
- + embed:語義搜尋(Vectorize + AI)。
**API 不變,能力分層**:基礎用 LIKE,裝 embed 升級語義。
---
## 3. 解耦工作(把寫死的觸發改成可選)
KBDB 現況:`blocks.ts``ingestText`/`createTriplet`/`deleteBlockVector` **寫死 import 進寫入流程**(寫 block 順手 embed+抽 triplet)。
解耦目標:
- 寫 block = 只寫 block(純基礎)。
- embed = 可選 hookVectorize binding 在才掛)。
- triplet = 可選(triplet 模組裝了才掛)。
- 基礎 `blocks.ts` / `templates.ts` / `records.ts` **不 import** vectorize/triplet。
> ✅ 已查證 `templates.ts` / `records.ts` 本就乾淨(只 import record-crud);主要工作在 `blocks.ts`
---
## 4. 落地形態:import 進 arcrun(如 MCP 的 pattern
> richblack:「像先前把 MCP import 進本專案一樣,也把 KBDB import 進來。」對,同一 pattern。
- **基礎 KBDB Base 搬進 `arcrun/kbdb/`**(如 `arcrun/mcp/`)。形態:獨立 Worker + D1。納入 deploy 掃描(rule 05:新目錄 + wrangler.toml 自動部署)。
- **init 建 D1**`cli/src/lib/cf-api.ts` 加 D1 建立(如現有 KV 建立),`deploy.ts` 注入 D1 id。**self-hosted 從只建 KV → 建 KV + D1**。
- **cypher 改用 KBDB Base 存專案/工作流**webhook/credential/recipe 的「專案歸屬」走 KBDB entries(漸進,不一次搬完)。
- **embed**:隨基礎進 arcrunVectorize binding 開關。
- **triplet**:獨立 repo,self-host 用戶要才另裝(不進 arcrun 基礎)。
### 共用同一基礎(官方也改)
richblack 決定官方版也改成「基礎核心 + 可選模組」→ 官方與 self-hosted **共用同一份基礎 KBDB code**,差別只在 binding(官方全開、self-host 基礎免費可選開)。**不維護兩套。**
---
## 5. 邊界(本 SDD 不做)
- **recipe 投稿入口 + 帶成功記錄免驗證**:✅ **本次要做**,見新增 §7(與 KBDB 成功記錄嵌在一起)。
- **recipe 信任 = 市場機制(靠量/星數模型)**:拍板用市場優先、暫不做強防偽(見 §7.3)。本次(5.2)做投稿端點(submit 私庫 / submit-p 公共庫覆蓋同 canonical_id+ stat 存證;**跨環境「靠量同步回市場」聚合層另排 task**;強防偽(官方重跑/多方回報)只在市場失靈才做。
- **triplet 模組本身**:獨立 repo,不在本 SDD(本 SDD 只確保基礎不依賴它、可選掛上)。
- **vectorize 的進階用法**suggest/entities-graph-embed):隨 triplet/進階模組,不在基礎。
- **官方版的搬遷細節**:本 SDD 聚焦「基礎 KBDB 進 arcrun self-hosted」,官方共用基礎是方向,搬遷另排。
## 7. recipe 公庫/私庫機制 + 帶 KBDB 成功記錄(市場信任)—— 本次要做
> §7.1-7.4 原只想「投稿」單向(本次最小已做:成功記錄 5.1 + submit-p 雛形 5.2)。
> §7.5 是 richblack 2026-06-07 指示補的 **CHANGE**:把公庫/私庫的所有互動情境想全(pull/搜尋/投稿/市場同步),
> 待 review。下方 7.1-7.4 是已成立的基礎,7.5 是擴充全貌。
> richblack 2026-06-07:本次只做「可投稿 + 帶成功記錄免驗證」,這兩件嵌在一起。
> 背景:官方初期那批 recipe 是一次性建立、未驗證(只為「Arcrun 推出不能空空的」)。
> 壓測者打通的修正版(如 google_sheets_append method/body)回不了官方 → 官方仍錯,下個用戶再撞。
> 不該由 AI 手改種子 recipe(治標);正解是投稿機制 + 用真實成功記錄當免驗證依據(治本)。
### 7.1 兩件嵌在一起
1. 成功記錄(KBDB):判定單位是「**工作流執行**」(對標 n8n execution,非逐節點)。一條 workflow 跑完,
整體成功(到 Output、無 error)→ 把這次**用到的每個 recipe 節點**各記成功 +1;整體失敗 → 已跑到的 recipe 節點各記失敗 +1。
歸屬對象是 recipe(因為投稿的是 recipe),**canonical_id 取 auth recipe 的 `service` 欄位**(與投稿身份一致)。
單節點工作流(cron→gdrive,一個節點一個 credential)是最簡情形。存 D1entry/record),可累積、可查。
> 註(術語別混):**cypher-executor = 工作流引擎**,用 cypher **語法**(節點+邊的圖遍歷語意)把零件串成工作流,
> 本就在開源核心內,**不是** graph DB。「可選/不在核心」的是兩個插件:① **triplet**(知識三元組的圖**儲存**
> 對標 Apache AGE)② **vectorize**(CF 內建但可能多花錢)。recipe 成功記錄是純 D1 KBDB 核心能力,不依賴這兩個插件。
2. 投稿(registry/官方):投稿 recipe 時帶上它在投稿者環境的成功記錄。官方端看記錄即信,免人工驗證——真實打通比投稿者自寫測試可信(呼應 gherkin-not-safety-sandbox-is)。
### 7.2 本次範圍(最小)
- recipe 成功/失敗計數落地 D1(KBDB 成功記錄)。
- 一個投稿端點:收 recipe + 其成功記錄 → 進官方 recipe 池,**新增一個作者版本**app-store 模型 §7.5.5**非**覆蓋同 canonical_id;同 canonical 多作者並存)。
- 投稿者目前就是 richblack,不需誘因;重點是有入口可投 + 帶記錄免驗證。
### 7.3 信任機制:市場優先(靠量,非人力/強防偽)—— richblack 2026-06-07 拍板
> 這個決策之前討論過、那時選「先不做」,導致今天又重講一次 → 故此次**寫進 SDD**,不再丟。
**核心:recipe 信任靠「量」決定,像 GitHub 星數,不靠人力檢核、也不先做強防偽。**
不可能全世界這麼多 recipe 都靠人力檢核。改用市場機制:
- recipe 的成功/失敗由**全體使用者的真實執行**累積(投稿者 A 的 recipe 通過 2000 次;投稿者 B 的失敗 500 次 = 像被按爛)。
- 上傳爛 recipe → 別人用會失敗 → 累積失敗數 → 投稿者信用下降 → 有動機更新自己發佈的 recipe,避免持續被按爛。
- **數據來源 = §7.1 的成功記錄**(每次工作流執行把用到的 recipe 成功/失敗記到 KBDB)。市場機制 = 把這些**跨使用者**的記錄**同步回公共市場**匯總。
**為什麼先市場、暫不做強防偽(誠實,mindset §7)**:
self-hosted 投稿者 = 自己環境 root,能寫任意數字進自己 KBDB → 自報 stat 可造假。
但**先信市場**:實驗一段時間,若市場反應爛(造假猖獗)再考慮強防偽(官方重跑 / 多方獨立回報——證據由投稿者控制不了的一方產生)。
故**本次不拿投稿者自報的數字當自動放行門檻**(那會造債:未來門檻是空的)。stat 先當存證 + 法律歸責軌跡。
**分期**
- **本次(5.2**:投稿端點 `POST /recipes/submit`。submit(私庫)vs submit-p(公共庫,需 exposure_consent = 暴露同意,mindset §6)。
公共庫**新增作者版本**app-store 模型 §7.5.5,非覆蓋)。投稿者帶的 stat 寫進官方 KBDB 當存證(不當門檻)。
- **後續(市場同步,§7.3 主體)**:跨環境「靠量同步回市場」——self-hosted 各用戶的真實成功/失敗匯總回公共市場,
形成 recipe 的星數/信用。是 §7.1 數據的跨環境聚合層。本次先留接口(5.1 已在各環境記錄,市場聚合另排 task)。
- **更後續(強防偽,只在市場失靈才做)**:官方 in-process 重跑投稿者工作流拿官方自己的 2xx / 第二投稿者獨立回報。
(註:app-store 模型下「版本並存」是常態、不刪舊版 §7.5.5;舊版靠市場淘汰,不做下架引導。)
### 7.4 與零件投稿的差異
零件投稿走 GitHub PR(人 merge + CI runtime 驗 wasm,記憶 component-submission-via-pr)。
recipe 是純文字資料(非 wasm),不需 CI 跑沙箱;它的驗證改用真實成功記錄。故 recipe 投稿機制與零件不同,不混用 component-registry-canon SDD。
### 7.5 公庫 / 私庫雙向機制(CHANGErichblack 2026-06-07 指示「把兩庫所有情境想好」)—— 待 review
> 背景修正:原 §7.1-7.4 只想了「投稿」單向,太窄。richblack 指出:
> **SaaS 版(standard mode)本來就只有公庫;self-hosted 版本來就只有私庫**
> 兩庫的互動是 overall 架構(不只為投稿):私庫是公庫的**按需子集**——self-hosted 也許只用 10 個,
> 公庫也許有 10000 個,**不需每次全部拉下來,用到的再複製**。投稿(submit-p)只是其中一條反向流。
#### 7.5.1 兩個庫的物理本質(已確認現況)
| 庫 | 是誰 | 物理 | 規模 | 角色 |
|----|------|------|------|------|
| **公庫** | 官方 SaaS`cypher.arcrun.dev`)的 `RECIPES` KV | 官方 CF 帳號 | 大(~10000,含社群投稿) | 唯一公共真相、可瀏覽/搜尋/pull |
| **私庫** | 每個 self-hosted(用戶自己帳號)的 `RECIPES` KV | 用戶 CF 帳號 | 小(~10,按需子集) | 自己實際用到的 + 自己改的 |
- recipe key 都是 `recipe:{canonical_id}`(無 owner 前綴)。同一套部署的 KV = 那套的庫。**物理隔離**(不同帳號不同 KV)。
- `mode`cli config):`standard` = SaaS 用戶(只連公庫,無私庫);`self-hosted` = 自己一套(有私庫,可選連公庫 pull/submit);`local` = 純本地。
#### 7.5.2 三種使用者 × 互動矩陣
| 使用者 | 有哪些庫 | 取 recipe | 改 / 投稿 | 成功記錄 |
|--------|----------|-----------|-----------|----------|
| **SaaS 用戶**standard) | 只公庫 | 直接用公庫(公庫即他的庫) | 改了直接寫公庫(=投稿,需 consent) | 記在官方 KBDB(即市場數據本身) |
| **self-hosted 用戶** | 私庫(主)+ 公庫(唯讀來源) | **公→私 pull**:搜公庫→複製到私庫 | 私庫改 →(可選)**submit-p** 推回公庫 | 記在自己 KBDB;市場同步另排(§7.3 5.5 |
| **local** | 無遠端庫 | 本地 recipe 檔 | 無 | 本地 |
#### 7.5.3 四條互動流(窮舉)
1. **公→私 pull(核心,之前漏)**self-hosted `acr recipe pull <canonical_id>` → 從公庫只讀端點抓該 recipe → 寫進自己私庫 KV。
按需,**不全量同步**(10000 不必拉)。pull 也可帶公庫的成功記錄(市場星數)供用戶判斷要不要用。
2. **公庫瀏覽/搜尋**`acr recipe search <q>` / `list` → 打公庫只讀端點(`GET /public-recipes?q=`)。
self-hosted 沒搜尋能力就不知道公庫有什麼可 pull。SaaS 用戶同一端點即用。
3. **私→公 submit-p(已做雛形)**self-hosted 把私庫某 recipe 推回公庫(`POST /recipes/submit`,需 exposure_consent)。**新增作者版本**app-store 模型 §7.5.5,非覆蓋;同 canonical 多作者並存)。
4. **成功記錄 → 市場同步(§7.3 5.5,另排)**self-hosted 各環境真實成功/失敗匯總回公庫的市場星數。pull(流1) 帶的星數就來自這裡。
#### 7.5.4 公庫只讀端點(公→私 + 瀏覽的基礎,待實作)
官方 cypher 開**公開只讀**端點(無需 api_key,因公庫本就公共):
- `GET /public-recipes?q=&limit=&offset=` → 搜尋/列出公庫 recipe。**同 canonical_id 回多筆(多作者)**,各附作者 + 市場星數 success/failure,供 CC/AI 依數據選(§7.5.5)。
- `GET /public-recipes/:canonical_id?author=` → 取單一 recipe 全文(pull 用)。不指定 author → 回市場最佳版本(成功率最高)。
- **搜尋/取用落空 → 回 `{ found: false, canonical_id, hint }` 創作引導**(§7.5.6),不回空陣列乾等。讓 CC 知道下一步是「自己做一個成為作者」。
> 與現有 `GET /recipes`(已存在,列當前部署 KV)的差別:`/public-recipes` 語意是「**這是公庫**,給外部 self-hosted pull/瀏覽」,且**含作者維度 + 市場數據**。
> 在官方部署上讀同一 KV;命名分開是為了語意清楚 + 公庫的多作者/市場排序不污染內部 `/recipes`
> **市場星數 per-uuid(§7.5.h 已實作)**5.1 改收集 API recipe 的 **uuid**(非 auth service),KBDB 市場星數記 per-uuid。
> 故 `GET /public-recipes/:canonical_id` 的「選市場最佳作者版本」**真正能區分 Leo 版/John 版**(各自 uuid 各自累積)。
> 舊資料無 uuid → fallback canonical_idmigration 後自然帶 uuid)。
#### 7.5.5 recipe = 具名作者作品(app-store 模型)+ UUID 身份 —— richblack 2026-06-07 拍板
> 這條推翻了我原本「覆蓋同 canonical_id」的錯誤前提。正確模型如下。
**每個投稿的 recipe 是一個「具名作者作品」,像 app store 上的一個 app,只有作者(+admin)能改。**
- Leo 投稿 `gsheets_upsert` = 在 app store 放了一個 app。**只有 Leo + admin 能動它**。
- John 覺得 Leo 的很爛 → John **另推一個** `gsheets_upsert`John 的作品)。**只有 John + admin 能動**。
- 公庫裡因此**同 canonical_id 並存多份**Leo 版、John 版),各帶作者、各帶獨立市場數據。
**所以:**
1. **submit-p ≠ 覆蓋**。投稿是**新增一個作者版本**,不是覆蓋同 canonical_id。(修正原 §7.2/§7.3 的「覆蓋」字眼。)
2. **沒有「公庫改了 Leo 要不要跟」的問題**:Leo 的作品只有 Leo 改;別人不滿意是自己推新作品,不是改 Leo 的。版本關係問題消失。
3. **向後相容、不刪舊 recipe**:Leo 的 5/10 不被刪,只是市場上沒人選 → 自然淘汰(長尾留著,無破壞性刪除)。
4. **選擇由市場數據驅動(CC/AI 操盤手選)**CC 搜 `gsheets_upsert` → 找到 2 個 → 看數據(Leo 5成功/10失敗、John 100成功/0失敗)→ **選 John 的**。Leo 的逐漸沒人用。
**身份模型(落地)= UUIDrichblack 2026-06-07 拍板,比 canonical_id+author 複合鍵更乾淨)**
**每個 recipe 一誕生就領一個 UUID = 唯一身份。`canonical_id` / `author` / `公私` 全是屬性,不是身份。**
- 身份(UUID) 與 歸屬(author 屬性) 分離 → 沒有「同 canonical_id 撞 key」問題(key 是 UUID,本就唯一)。
- **KV key 設計**`recipe:{uuid}` 存 recipe 本體;`idx:canonical:{canonical_id}` → UUID 清單(同 canonical 多 UUID);
私庫執行:`idx:installed:{canonical_id}` → 該 canonical 在本庫安裝的**唯一** UUIDpull/author 時定一個)→ 執行查找仍由 canonical_id 解析到唯一 recipe**不破執行**。
- **Leo 改 John 的**Leo 拿 John 版(UUID-J)改 → 發出去領**新 UUID-L** = Leo 作品。UUID-J/UUID-L 是兩個東西,不冒名、天然不衝突。
- **author 屬性 = 該 UUID 誕生時的投稿者**(誰投誰負責那版市場數據);可選帶 `derived_from: {uuid}` 溯源(致謝/fork upstream)。
**「Leo 跟 John 一模一樣」怎麼辦**(你問的):兩個不同 UUID 都通過、各跑各的市場數據。
**重複 = 市場冗餘,靠數據淘汰不靠技術阻止**(先有數據的勝,複製品沒人選自然沉底,§7.3 靠量不靠人力)。
**檢舉功能**:本次**不做**。檢舉 = 人力檢核,與「市場優先、暫不強防偽」(§7.3)矛盾,提前做還用不到 = 造債。
市場失靈才考慮(§7.3 更後續)。**CC 不檢舉**mindset §7:CC 是操盤手不是審查者、不代人裁決;CC 對爛 recipe 的「投票」= 不選它 = 市場數據)。
> 影響現有 code5.2 的 `POST /recipes/submit` 改「覆蓋」→「領新 UUID 新增」;recipe key 從 `recipe:{canonical_id}`
> `recipe:{uuid}` + canonical 索引;執行查找改經 `idx:installed:{canonical_id}` 拿唯一 UUID(保證不破 component-loader/auth-dispatcher/credential-injector)。列入 7.5.f task。
#### 7.5.6 搜尋落空 = 創作入口(richblack 2026-06-07 拍板)
> app-store 模型的閉環關鍵:公庫沒有的 recipe,由 CC 現做成為作者,再投回公庫補上。
**CC 搜「gsheets_delete_sheet」公庫沒有 → 端點不能只回空陣列乾等,要回「可創作」訊號 → CC 說「那我做一個」→ CC 成為作者。**
- 公庫只讀端點(§7.5.4)搜尋**落空時的回應 = 創作引導**,不是空結果:
明確回 `{ found: false, canonical_id, hint: "公庫無此 recipe,可自行建立並 submit-p 投稿成為作者" }`
讓 CC(AI 操盤手)知道下一步是「自己做一個」而非卡住。
- CC 現做 → 跑成功累積市場數據(§7.1)→ submit-p 推回公庫 → 成為公庫**第一個** `gsheets_delete_sheet`CC 是作者)。
- **閉環**
- 公庫有 → pull 用(市場數據選最佳作者版本,§7.5.5)。
- 公庫沒有 → CC 現做成為作者 → 用 → 投稿 → 公庫從此有了。
- 呼應 mindset**CC 是大腦**arcrun 是 AI 呼叫的工具),缺能力時 CC 自己補(做 recipe / 工作流),
不停在「找不到」。工作流是 default、零件是例外(缺能力先想工作流/recipe,不是停手)。
---
## 8. KV list 上限威脅免費承諾 → 高頻 list 遷 D1CHANGE2026-06-08 richblack CF 收到 list 超標信)
> 事件:richblack 的 CF 帳號收到「Workers KV 免費等級每日 1000 次 list 上限已超」信,
> list API 回 429 直到隔日重置。**這直接威脅 arcrun「免費可用」核心承諾**[[deploy-via-local-deploy-not-ci]] 免費優先精神)。
> 方向拍板(richblack 2026-06-08):**高頻 list 改走 D1**(對齊 §6 Q2 的 KV vs D1 分工——要列舉/搜尋的進 D1)。
### 8.1 根因:哪些 KV list 是高頻(grep cypher-executor/src
| 位置 | list 操作 | 頻率 | 嚴重度 |
|------|----------|------|--------|
| `scheduled.ts:34` | `WEBHOOKS.list({prefix:'cron-idx:'})` | **每分鐘一次 = 1440/日** | 🔴 **常駐單獨就爆**>1000 上限) |
| `recipes.ts` public-recipes(§7.5 新加) | `RECIPES.list({prefix:'recipe:'})` | 每次 recipe 搜尋 | 🟠 搜尋頻繁就疊加爆 |
| `recipes.ts` GET /recipes / listAllRecipes | `RECIPES.list` | 每次列 recipe | 🟠 |
| `webhooks-list.ts` / `webhooks-named.ts` | `WEBHOOKS.list` | 每次列 workflow | 🟠 |
> **D1 額度遠寬**:免費 5M 讀/日、100K 寫/日(§6 Q4),對比 KV list 僅 1000/日。要「列舉/搜尋/排序」的天生該 D1。
### 8.2 緊急止血(cron,今天在爆)
`scheduled.ts` 每分鐘 list `cron-idx:` → 改成**不靠 list**
- 方案:維護**單一固定 key**(如 `cron-idx:_all` 存所有 cron workflow 的 {apiKey,name,cron_expr} JSON 陣列),
scheduled 只 `get` 一次(取代 list)。acr push 時 upsert 進這個 key。
- 1440 次/日 list → **0 次 list**(改 1 次 get/分鐘 = 1440 get/日,KV get 免費額度 100K/日,遠夠)。
### 8.3 治本(recipe / workflow 查詢遷 D1,分期)
對齊 §6 Q2recipe + workflow 進 D1):
- **recipe**recipe 本體從 RECIPES KV 遷進 KBDB D1entry_type='recipe' 或專表)。
public-recipes 搜尋、list 改 D1 queryLIKE/WHERE,含 §7.5 多作者/市場排序天生適合 SQL)。
執行時 resolveRecipe 也改 D1(但執行是 get-by-key 級,KV get 不爆——可漸進)。
- **workflow**workflow record 從 WEBHOOKS KV 遷 D1entry_type='workflow' 掛 project parent,§6 Q1)。
list workflow 改 D1 query。
- **保留 KV 的**session / credential / exec context(§6 Q2:短期高頻純 get,不 list)。
### 8.4 分期建議(待 richblack 拍板細節)
1. **P0 止血**(§8.2):cron 改單 key get。最小改動、今天可止血。
2. **P1**public-recipes / recipe list 改 D1(我 §7.5 新加的 list 是疊加元兇)。
3. **P2**workflow list 改 D1。
4. **P3**recipe/workflow 本體完整遷 D1resolveRecipe / KV→D1 讀寫)。
> **誠實 trade-off**:遷 D1 要寫 migration + 雙寫過渡(KV 舊資料相容),工程量不小。
> 但不遷 = 免費承諾破功(用戶用一用就 429)。**這是核心承諾必修項,不是優化。**
> 待 review §8 + 拍板分期顆粒度後動 code。**勿在 review 前動。**
---
## 6. 開放問題(待 review
1. **專案維度怎麼進 entries**`entry_type='project'` + workflow 用 `parent_id` 掛專案?還是 templates 定義「專案」虛擬表?
1. Leo:由你決定,我記得先前 KBDB 已經有實作,參考原有做法。
2. [DECIDED, CC 2026-06-07] use entry_type=project + parent_id tree (NOT templates virtual table). Matches existing KBDB: entry_type is an extensible string, entry-crud.ts already supports WHERE parent_id; no schema change (honors table-never-changes). "list workflows under project" = WHERE parent_id=PROJECT_ID AND entry_type=workflow. templates/slot reserved for structured records like recipe success stats.
2. KV vs D1 分工[拍板 2026-06-07]KV 不全廢,按特性分。workflow YAML + recipe + 成功記錄進 D1(要層級/列舉/排序);session、verdict、credential 留 KV(短期高頻純取用)。類比 PostgreSQL + Redis。workflow 變 entryentry_type=workflow 掛 project parent)。
1. Leo:同意
3. **triplet 獨立 repo 的接點**:基礎 KBDB 怎麼讓 triplet 模組「掛上」——HTTP hook?還是 triplet worker 自己讀同一個 D1
1. Leotriplet 其實也就是 template 加上一些函式,最簡單就是你給他一段文字抽取出三元組,但如果要自動化,就會提供 API,背後是工作流,例如 block 存入時就叫起 triplet 功能,這個功能如果自動化就是在安裝時同意,但應該也可以有 mcp 讓 AI 易於操作,如同前述,交貨時有 MCP,因為是 AI Friendly 的系統。
4. **init 建 D1 的冪等 + self-hosted 免綁卡確認**:D1 是否如 KV 一樣免費不綁卡(需查證 CF D1 免費額度條件)。
1. Leo:一樣不綁卡,除非他用量很大,屆時不是我來提醒而是 CF 會提醒他
@@ -0,0 +1,221 @@
# Tasks: KBDB Base — atomic universal table
> 對應 design.md。design 待 richblack review 後才動 code。每完成一個 task 立刻標 [x],不批次。
> 來源:壓測報告暴露 self-hosted 無資料層;richblack 2026-06-07 拍板 KBDB 基礎開源 + 插件模式。
---
## 狀態:SDD 草稿,待 review design + 拍板開放問題後才動 code
- [x] Q1 專案維度[拍板 2026-06-07]richblack 授權 CC 決定,參考 KBDB 既有實作(已有 project/parent 做法)
- [x] Q2 KV vs D1 分工[拍板 2026-06-07]KV 不全廢,按特性分——workflow YAML+recipe+成功記錄進 D1session/verdict/credential 留 KV
- [x] Q3 triplet 接點[拍板 2026-06-07]triplet = template + 函式。手動(給文字回三元組)或自動(block 存入時叫起,安裝時同意);提供 MCP(AI-friendly,交貨帶 MCP
- [x] Q4 D1 免費不綁卡[查證 2026-06-07 CF docs]Workers Free 可用、不需信用卡(不像 R2)。額度 5M 讀/日、100K 寫/日、5GB。用量大時 CF 自會提醒用戶。
## Phase 0:基礎抽取與解耦
- [x] 0.1 三表 migration 抽出 arcrun/kbdb/migrations/0001_base.sqlentries/templates/entry_values + recipe_stat template seed),無 triplet/entity/vectorize 表
- [x] 0.2 萬年不動:新基礎 schema 本就不含 entity_type(那是 triplet 衍生欄位)→ 基礎 entries 無此欄,衍生屬性天然在基礎表外。原 kbdb 的 UPDATE blocks SET entity_type 屬 triplet 模組(獨立 repo),不影響 arcrun 基礎
- [x] 0.3 解耦:arcrun/kbdb 從零寫乾淨基礎(entry/template/record CRUD),不 import 任何 embed/tripletgrep 證);不去動原 kbdb repo(官方版仍用,動它有風險)
- [x] 0.4 templates/records route 純淨:只依賴 record-crudD1),無 vectorize/triplet
- [x] 0.5 基礎 search = D1 LIKEGET /entries/search?q=mode:keyword);語義層留給 embed 模組(未在基礎)
## Phase 1embed 模組(CF 內建,binding 開/關,不拆 repo
> 狀態:基礎已與 embed 完全解耦(不 import)。embed 模組本身(Vectorize upsert + 語義 search)尚未實作——基礎不依賴它即可,embed 待要用時再加。以下 1.x 未做。
- [ ] 1.1 embed 改可選 hook:有 env.VECTORIZE 才掛
- [ ] 1.2 search 語義層:有 Vectorize 啟用,否則降級 LIKEAPI 不變
- [ ] 1.3 wrangler.tomlVectorize/AI binding 可選
## Phase 2import 進 arcrun(如 MCP pattern
- [x] 2.1 基礎 KBDB Base 搬進 arcrun/kbdb/Worker + D1):kbdb/ 已建(三表 CRUD + recipe-stats + migration),納入 find wrangler.toml deploy 掃描
- [x] 2.2 cf-api.ts 加 D1 建立(listD1Databases/ensureD1Database);deploy.ts 注入 d1DatabaseId 到 kbdb wrangler.toml
- [x] 2.3 self-hosted init 建 KV + D1Q4 已確認免綁卡):init ensureD1Database 建空 D1 → deploy 部署完對 D1 套 migrations/0001_base.sqlCF /d1/query APIidempotent)建三表 + recipe_stat seed + 注入 wrangler.toml database_id
- [ ] 2.4 cypher 改用 KBDB 存專案/工作流歸屬(依 Q1/Q2,漸進)— 未做(後續)
## Phase 3triplet 模組(獨立 repo,本 SDD 只定接點)
- [ ] 3.1 確認基礎不依賴 tripletPhase 0.3 後驗證)
- [ ] 3.2 定義 triplet 掛上基礎的接點(依 Q3)
- [ ] 3.3 triplet 本身 → 獨立 repo,不在本 SDD
## Phase 4:官方共用基礎(方向,搬遷另排)
- [ ] 4.1 官方版改用同一份基礎 KBDB(binding 全開)→ 不維護兩套
## Phase 5recipe 投稿 + 帶 KBDB 成功記錄(免官方驗證)—— 本次要做(design 7)
- [x] 5.1 recipe 成功記錄落地 D1:判定單位=工作流執行(n8n execution)。GraphExecutor 收集本次用到的 recipe keyusedRecipeKeys=API recipe uuid,§7.5.h 改 per-uuid;舊資料 fallback canonical_id);executeWebhookGraph 執行結束後一次性 POST KBDB /recipe-stats/record(整體成功→各+1成功、真錯非paused→各+1失敗,fire-and-forget via waitUntil)。cypher tsc exit 0
- [x] 5.2 投稿端點(cypher recipes.ts):POST /recipes/submit(需 exposure_consent、stat 存證不當門檻)。**已修正**(7.5.f):原「覆蓋同 canonical_id」→ app-store/UUID 模型「領新 uuid 新增作者版本」,同 canonical 多作者並存。cypher tsc exit 0
- [x] 5.3 CLI 接點(= 7.5.b/c/e):acr recipe push(私庫)/ pull(公→私)/ submit-p(公共庫帶暴露同意)。cli tsc exit 0
- [x] 5.4 市場機制(靠量/星數模型,design §7.3):stat 存證已做(5.2 recipe_submission entry+ per-uuid 市場數據(5.1+7.5.h)。先市場優先、暫不強防偽(5.6 後續)
- [ ] 5.5 市場同步(跨環境聚合層,§7.3 主體):self-hosted 各用戶真實成功/失敗匯總回公共市場 → recipe 星數/信用。5.1 已在各環境記錄,聚合另排
- [ ] 5.6 強防偽(只在市場失靈才做):官方 in-process 重跑投稿者工作流拿官方自己 2xx / 第二投稿者獨立回報;版本並存/舊版下架
## Phase 7.5:公庫/私庫雙向機制(CHANGEdesign §7.5)—— richblack 2026-06-07 review 通過,可執行
> richblack 指示「把兩庫所有情境想好」+「這些細節想好是可以執行」→ §7.5 review 通過,開始動 code。
> **7.5.5 拍板:recipe = 具名作者作品(app-store 模型)**——同 canonical_id 多作者並存、只有作者+admin 能改、
> 投稿=新增作者版本(非覆蓋)、向後相容不刪舊、選擇由市場數據驅動、無「公庫改私庫要不要跟」問題。
> **7.5.6 拍板:搜尋落空=創作入口**——公庫沒有→回 found:false 創作引導→CC 現做成為作者→投稿補上(閉環)。
- [x] 7.5.g 落空創作引導:GET /public-recipes(?q=) 與 GET /public-recipes/:canonical_id 落空回 { found:false, hint } 創作引導,不回空陣列。cypher tsc exit 0
- [x] 7.5.a 公庫只讀端點(cypher recipes.ts):GET /public-recipes?q=&limit=&offset=list/search 多作者+per-uuid 市場星數)、GET /public-recipes/:canonical_id?author=pull 取全文,多作者選市場最佳)。cypher tsc exit 0
- [x] 7.5.h **市場星數 per-uuid(§7.5.h**5.1 改收集 API recipe uuidresolveRecipe,非 resolveAuthRecipe);usedRecipeServices→usedRecipeKeysKBDB 星數記 per-uuidpublic-recipes 選最佳 per-uuid 查 → 真正區分 Leo/John 版。cypher tsc exit 0
- [x] 7.5.f **recipe UUID 身份模型(app-store 核心,§7.5.5 拍板)**recipes.ts RecipeDefinition 加 uuid/author/derived_frominstallRecipeRecord helperrecipe:{uuid} + idx:canonical 清單 + idx:installed + idx:hash);POST /recipes=私庫沿用 installed uuid 就地更新;POST /recipes/submit=領新 uuid 新增作者版本(非覆蓋);resolveRecipe 向後相容(uuid→installed→fallback 舊 key,不破執行鏈);DELETE 清 uuid+索引;GET dedupinit-seed 用 UUIDauthor=system);POST /recipes/migrate-uuid 一次性轉舊 key(增量寫不刪舊、冪等)。cypher tsc exit 0。重複靠市場淘汰、不做檢舉、CC 不檢舉(§7.3+mindset §7
- [x] 7.5.b 公→私 pullCLI 薄殼):acr recipe pull <canonical_id> [--author] → GET 公庫 /public-recipes/:id → POST 自己私庫 /recipes(帶 derived_from 溯源)。cli tsc exit 0
- [x] 7.5.c 公庫搜尋(CLI 薄殼):acr recipe search <q> → GET 公庫 /public-recipes?q=,印多作者+市場數據,落空印創作引導。cli tsc exit 0
- [x] 7.5.e submit-pCLI 薄殼):acr recipe submit-p <canonical_id> [--author] → GET 私庫取全文 → 暴露同意 → POST 公庫 /recipes/submit(新增作者版本)。config 加 DEFAULT_PUBLIC_LIBRARY_URL(公庫=官方 cypherARCRUN_PUBLIC_LIBRARY_URL 可覆蓋)。cli tsc exit 0
- [x] 7.5.i **MCP 薄殼補齊 recipe 工具(rule 07 §5**:新增 mcp/src/tools/arcrun_recipe.ts 六工具 arcrun_recipe_search/pull/submit_p/push/list/deleteregisterAllRecipeTools 註冊進 registry.ts),全用 cypherFetch 薄殼模式(無業務邏輯)。與 CLI 六能力對齊,MCP 不再落後。submit_p 帶 exposure_consent 把關。mcp tsc exit 0。註:MCP 連平台 cypher(§5.2 account-source 已知違反 pre-existing,沿用既有模式不一併修)
## Phase 8KV list 上限威脅免費承諾 → 高頻 list 遷 D1CHANGEdesign §8)—— 待 richblack review 分期顆粒度,勿動 code
> 2026-06-08 richblack CF 帳號收到 KV list 超標信(每日 1000 上限,429)。威脅免費核心承諾。
> 方向拍板:高頻 list 遷 D1(對齊 §6 Q2)。根因①cron 每分鐘 list(1440/日單獨就爆)②§7.5 新加的 recipe 搜尋 list。
> 待 review §8 分期顆粒度後才動 code。
- [x] 8.P0 **緊急止血 cron**(§8.2):scheduled.ts 每分鐘 WEBHOOKS.list('cron-idx:') → 改單一固定 keycron-idx:_all 存 {apiKey}:{name}→cron_expr map)只 get 一次。新增 lib/cron-index.tsreadCronIndex/updateCronIndexEntry,單 key read-modify-write);webhooks-named POST/DELETE 改維護單 key;新增一次性 POST /webhooks/named/migrate-cron-index 把舊 per-key 折進集中 key(冪等、不刪舊);acr update 部署後自動呼叫 migrate(接在 seed 後,冪等、失敗不致命)→ 既有 cron 不必手動重 push。1440 list/日 → 0 list。cypher+cli tsc exit 0。**已完成 2026-06-09**
- [ ] 8.P1 recipe 查詢遷 D1(§8.3):public-recipes 搜尋 + GET /recipes list(§7.5 新加的元兇)改 D1 queryLIKE/WHERE,多作者/市場排序天生適合 SQL)
- [ ] 8.P2 workflow list 遷 D1webhooks-list/webhooks-named 的 list 改 D1 queryentry_type=workflow
- [ ] 8.P3 recipe/workflow 本體完整遷 D1resolveRecipe / KV→D1 讀寫 + migration + 雙寫過渡(KV 舊資料相容)。session/credential/exec-context 留 KV(§6 Q2
## Phase 9KBDB 資料層薄殼補 MCP/CLIHANDOFF §2design §4「交貨帶 MCP」)—— 本次做
> 核實:CLI/MCP 現在完全沒 KBDB 資料層能力(既有 arcrun_skills_examples 打的是舊 /blocks /search
> v3 schema,非本基本盤三表)。基本盤 API 已完整(templates/records/entries/search)。
> 本 Phase = **薄殼暴露**,不重寫能力(rule 07)。
> **KBDB 鐵律(leo 2026-06-14**:不提供建表/SQL toolAI 只有「建 template(name+slots) + 填 record(slot→content)」
> 可用(類 Supabase 萬用表);薄殼只經 KBDB service binding 調基本盤 HTTP API,不直連 D1、不寫 SQL。
- [x] 9.1 **MCP 薄殼**(AI 用,插件也走這條):mcp/src/tools/kbdb_data.ts6 工具
`kbdb_create_template`(name+slots)、`kbdb_list_templates``kbdb_create_record`(template+values)、
`kbdb_get_record`(record_id)、`kbdb_query`(by-template 列 records)、`kbdb_search`(entries LIKE q)。
全走既有 kbdbFetchKBDB binding)薄殼模式,無業務邏輯。registerAllKbdbDataTools 註冊進 registry.ts。
**不含建表/SQL tool**(鐵律,grep 證 code 無 CREATE TABLE/.prepare/env.DB/SQL)。**mcp tsc exit 0**2026-06-14)。
- [x] 9.5 **cypher KBDB proxy9.2 的前置,2026-06-14**CLI 是 client 只認證到 cypher,達不到獨立
KBDB workerMCP 走內部 service binding 可達,CLI 不行)。故在 cypher 開 `cypher-executor/src/routes/kbdb-proxy.ts`
純轉發 `/kbdb/templates|records|search` → KBDB 基本盤(沿用 KBDB_BASE_URL HTTP fetch + KBDB_INTERNAL_TOKEN
**不新增 service binding** rule02 §3.1)。**租戶隔離(leo 拍板選項①)**X-Arcrun-API-Key 自動當 owner_id 注入
records/entries(強制覆寫 caller 自帶 owner_id 防跨租戶寫);**templates 全域共享**(虛擬表定義是 schema 非資料)。
無 SQL/建表/業務邏輯(純 proxy)。掛進 index.ts。cypher tsc exit 0。
- [x] 9.2 **CLI 薄殼**(人用,2026-06-14):`cli/src/commands/kbdb.ts` — acr kbdb template create/list、
record create/get、query、search,透過 9.5 的 cypher proxy 打基本盤(與 MCP kbdb_* 同能力,差異只來自介面慣例
rule07 §3.4)。註冊進 index.ts`acr kbdb`)。無業務邏輯(薄殼)。cli tsc exit 0。
**未驗收**:端到端需 cypher 部署 + KBDB_BASE_URL 可達後實測(acr kbdb template create → query 回得到)。
- [x] 9.3 **基本盤 entries 加 page_name 讀過濾**2026-06-14):listEntries 加 `page_name` 過濾 +
GET /entries 接 `?page_name=`(既有欄位的便利查詢,不動表結構、不違反「表不變」鐵律)。
用途:skills/examples 用 page_name 當 idempotency key 做 get-by-key。kbdb tsc exit 0。
- [x] 9.4 **修復 LI M3 斷鏈**2026-06-14,連動 llm-interface M3.2/M3.4):skills/examples 整條從
舊 v3 `/blocks` `/search` 改打基本盤 `/entries`entry_type 對應)。5 個已上線的 MCP 工具原本
對死 route 回 404(假綠),現修正;sync-registry-to-kbdb.py 改打 /entries idempotent upsert。
誠實降級:基本盤無語義 search → search_examples 改 LIKE 關鍵字(embed 模組 Phase 1 上線再換回語義)。
mcp + kbdb tsc exit 0。
- [x] 9.6 **cypher proxy 補 `/kbdb/entries` CRUDHANDOFF §2 缺口①,2026-06-15**9.5 proxy 只轉發
templates/records/search**漏了基本盤的 `/entries` CRUD**——這正是 mira `_kbdb_client.py` 主線遷移
ingest/create_block/get_by_id/get_by_page_name/patch_block)要打的端點。補 POST/GET(list)/GET(:id)/PATCH(:id)
`/kbdb/entries` 純轉發到 KBDB 基本盤 `/entries`。**租戶隔離同 9.5 選項①**:寫入強制注入 owner_id、list 強制
以本租戶 owner_id 過濾(防跨租戶讀)、PATCH 剝除 caller 自帶 owner_id(防認領/踢走);by-id GET 沿用既有
records by-id 慣例(require-key)。**刻意不開 DELETE**(基本盤 delete-by-id 無 owner 檢查,經 proxy 暴露 =
跨租戶刪除風險;mira 也不需要)。無 SQL/業務邏輯(純 proxy)。**cypher tsc exit 0** + **端到端 prod 驗收綠**2026-06-15):
無 key→401A POST→owner_id 自動=AGET by id 回得到;A list count=1、B list 同 type **count=0**(跨租戶隔離);
PATCH 改 content 成功且 owner_id hijack→B 被剝除(仍=A、B 看不到);page_name lookup count=1mira idempotency 路徑);
POST 帶 caller owner_id=B→覆寫成 A。已部署 arcrun-cypher-executor(官方 58309bb9)。smoke 資料已清。
- [x] 9.7 **修 `arcrun_report_feedback` 死 routeHANDOFF §3b 連帶,9.4 漏網,2026-06-15**9.4 把
skills/examples 從舊 v3 `/blocks` 改打基本盤 `/entries`,但 `arcrun_report_feedback` 仍 POST 死掉的 `/blocks`
KBDB 基本盤只 mount entries/templates/records/recipe-stats,無 /blocks → 404 假紅)。改打 `/entries`
entry_type=agent-feedback、owner_id=用戶 namespace、source/api_key 併入 metadata_json、tags_json 不變)。
回傳 id 從基本盤 `{entry:{id}}` 取(兼容舊 `{id}`)。薄殼模式不變(kbdbFetch)。**mcp tsc exit 0** +
**端到端 prod 契約驗收綠**2026-06-15):確認舊 `/blocks`→**404**(正是原 bug、report_feedback 假紅根因);
用 9.7 實際送的 payloadentry_type=agent-feedback + metadata_json/tags_jsonPOST `/entries`→success、欄位保留;
經 cypher proxy 讀回 count=1。已部署 arcrun-mcp(官方 58309bb9)。注:MCP service-binding hop 由既有 kbdb_* 工具
9.1,同 kbdbFetch 路徑,2026-06-14 已驗)佐證 binding 活;本次只修死 URL。smoke 資料已清。
## Phase 10base 補 record PATCHmira-dissolve T2 `[→arcrun]`issue #6)—— 本次做
> 來源:頂層 SDD `docs/3-specs/mira-dissolve/`T2.1/T2.2),總管經本 repo GitHub issue #6 交辦。
> 用途:graph 插件的精耕「取代」語意(同來源檔重萃 → 舊版 deprecate)需要「改既有 record 的 slot 值」。
> **三表 append-only 不破**deprecate = 翻 record 的 slot 值(改底層 `entries.content`),不動表結構、不加欄、不刪 row。
> **base 不知 triplet(解耦鐵律 0.3grep-proven**:故 base 端 **不建 `/templates/triplet` 專屬端點**
> issue 描述的「PUT /templates/triplet 加 status/superseded_by」拆兩半(與頂層 tasks T2.2/T3.2 一致):
> base 只提供**通用** template 增改 slots 能力(既有 `PATCH /templates/:id` 已替換 slots_json,本就支援);
> triplet 專屬的 `status`/`superseded_by` 兩 slot 由 **T3.2 在 kbdb-graph-plugin 的 `TRIPLET_SLOTS` 補**(另一 repo,非本次)。
- [x] 10.1 **`PATCH /records/:id`(T2.1,前置硬依賴)**:改既有 record 的 slot→content 值。底層更新對應
`entries.content`(既有 slot 找 entry_value→entry 改 content);slot 不存在則補建 entry + entry_value(用 record 的
template 推算 slot 是否合法)。**三表結構一個欄位都不加**。`updateRecord` 在 record-crud.ts、route 在 records.ts。
- [x] 10.2 **template 增改 slots = 既有 `PATCH /templates/:id`T2.2base 端)**:核實 `updateTemplate` 已支援
`slots` 替換(record-crud.ts:44 既有)→ base 端通用能力已就緒,**無需新 code、不建 triplet 端點**。triplet 兩 slot
歸 T3.2plugin repo)。本條=核實 + 文檔釐清,非改 code。
- [ ] 10.3 **(選)`DELETE /records/:id`(T2.3)**:暫不做。依賴頂層 T8.3「死資料自動刪除原則」(design 待觀察未定);
且 deprecated record 查詢 `where status=active` 本就濾掉 → 無此端點不阻擋 deprecate 落地。待頂層拍板再補。
## Phase 11self-hosted KBDB 查詢能力補缺(issue #5,普世框架視角)—— 本次做
> 來源:issue #5(原 Mira dogfood 開,Mira 已蒸發 → 當「未來任何 self-hosted 用戶都會撞的框架缺口」處理)。
> 四點分流依 leo 2026-06-26 修正指示 + 頂層 mira-dissolve 重審:
> 規則判準見 07-thin-shell §3.5issue #4 自力救濟階梯):source/DELETE 屬**自家 API 缺能力 → 補 API**。
- [x] 11.1 **source 過濾(#5 第1點,✅ 做,普世成立)**`listEntries``source` filter
GET /entries 接 `?source=`。**零建表/零 migration**:用 SQLite `json_extract(metadata_json,'$.source')`
查既有 metadata_json TEXT 欄(**不加欄、表不變鐵律**)。cypher proxy `GET /kbdb/entries` 白名單加
`source`(隨租戶 owner_id 一起篩)。按來源篩 + 語義/關鍵字查詢都會用,ingest envelope 帶 source.uri。
kbdb+cypher tsc exit 0。**端到端待 leo21c 部署驗**(需寫一筆帶 metadata.source 的 entry → ?source= 篩回)。
- [ ] 11.2 ~~documents 聚合 GROUP BY page_name#5 第2點)~~ **不做**(舊河道頁特例):新架構「跨 vault 的圖」
走 graph MCP traverse/neighbors,不靠 KBDB 出 SQL 聚合端點。普世用戶也不需要。頂層 R6 已否決。
- [ ] 11.3 **cypher proxy DELETE#5 第3點)⏸ 暫擱置**:依賴頂層「死資料自動刪除原則」(mira-dissolve T8
待觀察未定)。等 T8 拍板再補。**註**:#6 已對 records proxy 同理擱置 DELETE(裸 delete-by-id 無 owner 檢查,
經 proxy 暴露=跨租戶刪除風險,補時要先讀 entry 驗 owner_id==本租戶才放行)。
- [→#7] 11.4 **embed-on-write#5 第4點)併入 #7**:開 embed module + `kbdb_embed:true` 後寫入是否自動
embed,由 #7 embed 模組定義(module 職責,前端不該手動戳 process-page)。不在 #5 單獨處理。
- [x] 11.5 **能力對照文件(#5 第二部分)**`docs/4-guides/kbdb-capabilities.md`
self-hosted arcrun-kbdb 現有查詢能力清單 + 端點對照。**不寫「documents/process-page 待移植」**
(舊河道視角,新架構不移植)。
## Phase 12optional embed 模組 + vectorize 開關 + 語義查詢(issue #7 / mira-dissolve T2.4)—— 本次做
> 來源:issue #7(總管,全包 4 件)+ 頂層 mira-dissolve T2.4 系列。普世框架能力(任何 self-host 用戶可選開語義查詢)。
> 鐵律:embedding 屬 **base optional 模組**(非 graph/ingest);CF Vectorize+AI binding 開/關,不拆 repo
> **不裝保持輕**(free-tier 友善);不對每個 block 地毯式 embed(精耕:只 embed 標 embed:true 的 entry)。
> 解鎖下游:ingest embed(第二階段)+ graph 詞+gloss 語義 normalizeT3.2c)等此。
- [x] 12.1 **base embed 模組(T2.4,從零做)**`kbdb/src/embed.ts`——`embedEnabled()`VECTORIZE+AI binding 都在才算開)、
`embedOnWrite()`(寫入時對標 `metadata_json.embed:true` 的 entry 做 Workers AI `@cf/baai/bge-base-en-v1.5`
768 維 → `VECTORIZE.upsert`,標 is_embedded=1**不動表結構**)、`semanticSearch()`query 向量 + metadata filter
owner_id/source)。**base 對內容語意無知**:用通用 `embed:true` flag 而非寫死 entry_type 白名單(不破解耦)。
wrangler.toml 加註解版 `[[vectorize]]+[ai]`deploy 開時取消註解)。
- [x] 12.2 **entries route 接 embed(含 #5 第4點 embed-on-write**POST/PATCH 用 `executionCtx.waitUntil`
fire-and-forget embed(模組開 + entry embeddable 才做,失敗不致命);DELETE 連帶刪向量(避孤兒);
**GET /entries/search 加 `mode=keyword|semantic`**semantic 需模組開,未開→**誠實降級 keyword + `capability_hint`
告知「叫 CC 幫開 vectorize」**(發現閉環,#7 第4點 + 不假綠)。kbdb tsc exit 0。
- [x] 12.3 **vectorize 開關從零做(T2.4c**`.arcrun.yaml`/config 加 `kbdb_embed`config.ts interface + env override
ARCRUN_KBDB_EMBED 布林);`deploy.ts``DeployContext.kbdbEmbed`:開時 `ensureVectorizeIndex`REST
`POST /vectorize/v2/indexes` dims=768/cosine,冪等)+ `injectWranglerConfig` 取消 kbdb toml 的 vectorize/ai 註解
**置於 stripOfficialOnlyBindings 之後**,否則 [ai] 被 strip 清掉——已驗);`acr init` 互動加問「要不要開語義查詢」
(預設關);存進 config 讓 acr update 維持一致。cli tsc exit 0。
- [x] 12.4 **KBDB MCP 加語義查詢(T2.4b**`kbdb_search``mode`/`source` 參數透傳 + 把 base 的 `capability_hint`
當 next-step 回給 AI(語義/關鍵字同一 KBDB MCP,D17 邊界)。薄殼模式不變(kbdbFetch)。mcp tsc exit 0。
- [x] 12.5 **CC 幫開 vectorizeT2.4d,第一版)**:路徑=CC 寫 config `kbdb_embed:true` + `acr update`(已接 kbdbEmbed
→ 建 index + 注入 binding redeploy)。base 查詢回應的 `capability_hint` 是發現入口。Pages 設定頁不做(leo 排未來)。
- [ ] 12.V **端到端驗收 ⏳ 待 leo21c 部署驗**(需官方/leo21c 帳號開 Vectorize index):開 kbdb_embed → acr update →
寫一筆帶 `metadata.embed:true` 的 entry → `?mode=semantic` 搜回;未開時 `?mode=semantic` 回 keyword+capability_hint。
本次只到 **tsc exit 0kbdb/cypher/cli/mcp 全綠)+ toml 注入 dry-run 驗證**,不假裝端到端綠(mindset §7)。
## 驗收
- [ ] V1 純 D1(無 Vectorize/AI)能 CRUD entries/templates/records + LIKE search
- [ ] V2 開 Vectorize 語義啟用、關掉降級 LIKE,API 不變
- [ ] V3 基礎 blocks/templates/records grep 無 vectorize/triplet import
- [ ] V4 self-hosted init 建 D1 成功、不綁卡
- [ ] V5 列出某專案下所有工作流可行
- [ ] V6 各 worker tsc exit 0
## Notes
- 插件模型對標 PostgreSQL:基礎核心=psql coreembed=PGVectortriplet=Apache AGE。
- embed 不拆 repoCF 內建、binding 開關);triplet 拆獨立 repoIP、action 多)。
- 官方與 self-hosted 共用同一基礎,差別只在 binding → 只維護一套。
- recipe 投稿入口是另一條線(registry),不在本 SDD。
@@ -0,0 +1,325 @@
# arcrun.dev Landing Page — SDD
> **目標**:給工程師一個門面,可以取得 API Key、管理 Key、探索 APISwagger),同時藉此獲得會員 Email。
> **原則**:先快速可用,不追求功能完整。榮譽牆、Python Lib 是後期。
---
## 0. 範圍(這份 SDD 涵蓋)
| 功能 | 說明 |
|---|---|
| 首頁 Hero | 說明 arcrun 是什麼,CTA 取得 API Key |
| OAuth 登入 | Google / GitHub(用自己的 auth recipe — dogfooding |
| API Key 管理 | 查看、Rotate、Revoke |
| Swagger UI | 嵌入 `/api`,讓工程師直接試打 |
| 榮譽牆 `/integrations` | 靜態骨架,先列 20 個 recipe,無動態數字 |
| 中英切換 | `?lang=zh` |
**不在本次範圍**Python lib、Donate 整合、Social Proof 即時數字、貢獻者排行。
---
## 1. 技術選型
### 1.1 框架:Next.jsApp Router
**選 Next.js 而非 Astro 的原因**
- `finally-click` 已有完整 Next.js + OAuth 回調實作,可直接複用模式
- API Key 管理頁有登入態保護需求,Next.js 的 middleware 最直接
- Cloudflare Pages 支援 Next.js`@cloudflare/next-on-pages`
- Astro 在動態路由保護上摩擦較多
### 1.2 部署:Cloudflare Pages
```
arcrun.dev → Cloudflare PagesNext.js
API calls → cypher.arcrun.dev(現有 Worker
```
### 1.3 儲存:現有 cypher-executor CREDENTIALS_KV + 新增 USERS_KV
現有 cypher-executor Worker 已有:
- `CREDENTIALS_KV``{api_key}:cred:{name}` 存 tenant credentials
- `RECIPES`auth recipes
新增需求:
- **USERS_KV**:存 user 帳號,key = `user:{provider}:{provider_user_id}`
- value: `{ email, display_name, api_key, created_at, provider }`
- **SESSIONS_KV**:存 login sessionkey = `sess:{session_id}`
- value: `{ api_key, email, expires_at }`
- TTL = 7 天
兩個 KV 都加到 cypher-executor `wrangler.toml`
### 1.4 OAuth — Dogfooding 自己的 Auth Recipe
登入用 arcrun 自己的 auth recipe
- 不是用 arcrun auth recipe 的 `http_request` runner 去打第三方
- 而是 **直接複用 recipe YAML 裡定義的 OAuth App 設定(client_id/secret**
- Worker 端實作 standard OAuth2 authorization_code flow
**支援提供商(MVP**
- Googlegoogle_drive recipe 的 OAuth App,或另建 arcrun-login Google App
- GitHubgithub recipe 的 OAuth App
登入 OAuth App 與 auth recipe 的 OAuth App **可以是同一個**(只要 scopes 包含 `openid profile email`),但更乾淨的做法是登入用獨立的 Google/GitHub App(只要 email scope),auth recipe 用的是資源存取 App。
**決策:登入用獨立 OAuth App**
- `GOOGLE_CLIENT_ID``GOOGLE_CLIENT_SECRET` — 只申請 `openid profile email`
- `GITHUB_CLIENT_ID``GITHUB_CLIENT_SECRET` — 只申請 `read:user` + `user:email`
- 以 Worker Secret 方式存入 cypher-executor
---
## 2. 頁面結構
```
arcrun.dev/
├── / 首頁(Hero + Code snippet + CTA
├── /login 登入頁(Google / GitHub 按鈕)
├── /auth/callback OAuth callbackPages Function
├── /dashboard API Key 管理(需登入)
├── /api Swagger UI(嵌入 swagger.json
└── /integrations 服務目錄(靜態,20 個 recipe)
```
---
## 3. 登入 / OAuth 流程
### 3.1 流程圖
```
用戶點「Google 登入」
→ GET /auth/google/startWorker 端)
→ redirect 到 Google OAuthstate = random, 存 SESSIONS_KV sess:state:{state} = {provider, redirect_back}
→ 用戶同意
→ GET /auth/callback?code=...&state=...Worker 端)
→ 驗 state
→ 用 code 換 access_tokenPOST google token endpoint
→ 用 token 取 userinfoGET google userinfo
→ upsert USERS_KV user:{provider}:{provider_id} = {email, display_name, api_key, ...}
→ 若新用戶:呼叫現有 /register?email=... 取得 arcrun API Key
→ 建立 sessionSESSIONS_KV sess:{session_id} = {api_key, email, ...}TTL=7d
→ Set-Cookie: arcrun_session={session_id}; HttpOnly; Secure; SameSite=Lax
→ redirect 到 /dashboard
```
### 3.2 「若新用戶取得 API Key」的邏輯
現有 `/register` endpoint 接受 `email` 回傳 `api_key`HMAC 確定性)。
但 landing page 需要的是**真正綁定到用戶帳號的 key**,且用戶可以 rotate/revoke。
**方案:延伸現有 register endpoint**
`/register` 目前:`HMAC(secret, email)` → 確定性 api_key,存 CREDENTIALS_KV
新增邏輯:
1. 若 USERS_KV 已有此 user → 直接用記錄裡的 api_key
2. 若新 user → 呼叫現有 `/register`(保持 HMAC 確定性邏輯)→ 拿到 api_key → 存入 USERS_KV
**好處**:不破壞現有 register 邏輯;登入後的 dashboard 顯示的 key = 現有 key = 封測用的 key。
### 3.3 Rotate / Revoke
- **Rotate**:產生新 UUID v4 key → 更新 USERS_KV 記錄 → 舊 key 失效(透過把新 key 加到 CREDENTIALS_KV,舊 key 的資料都跟著 API Key 命名空間走,所以 credentials 會留在舊 namespace
- 簡化版:Rotate 後顯示提示「您的 workflow credentials 已和舊 Key 分離,請重新設定」
- **Revoke**USERS_KV 記錄 `revoked: true` → Worker middleware 拒絕此 key
---
## 4. API 端點(新增到 cypher-executor
```
GET /auth/google/start → redirect 到 Google OAuth
GET /auth/github/start → redirect 到 GitHub OAuth
GET /auth/callback?code=&state= → 換 token、建立 session
POST /auth/logout → 清 session cookie
GET /me → 回傳當前登入用戶資訊(需 session cookie 或 API Key
PUT /me/api-key/rotate → 產生新 key
DELETE /me/api-key → Revoke(標記撤銷)
```
---
## 5. 前端頁面設計
### 5.1 首頁(/
```
Hero:
Stop fighting OAuth.
One API key. Every service. Works anywhere.
[Get API Key — Free] [View on GitHub]
Before/After:
40 行 OAuth 程式碼 → auth.bind("google_drive")
Code Demo(三個 tab):
Python / JavaScript / HTTPn8n 用戶)
[Get Free API Key] 按鈕
```
### 5.2 登入頁(/login
```
arcrun
登入或建立帳號
[Continue with Google]
[Continue with GitHub]
不需要信用卡。API Key 立即可用。
```
### 5.3 Dashboard/dashboard
```
歡迎,{display_name}
您的 API Key
┌────────────────────────────────┐
│ ak_xxxxxxxxxxxxxxxxxxxx [複製] │
└────────────────────────────────┘
[Rotate Key] [Revoke Key]
使用說明:
Authorization: Bearer {key}
或 X-Arcrun-API-Key: {key}
[登出]
```
### 5.4 Swagger UI/api
- 嵌入 `<SwaggerUIBundle>` JSCDN
- `url: 'https://cypher.arcrun.dev/swagger.json'`(現有 Worker 已有 `/docs` openapi endpoint
- 頂部說明:「這是 arcrun 的原始 API。Python / JS lib 是它的包裝,任何能發 HTTP request 的工具都能直接用。」
### 5.5 服務目錄(/integrations
- 靜態列出 20 個 auth recipe(從 seed data 產生)
- 每個 recipe:名稱、認證方式(static_key / service_account)、所需 credentials
- 「找不到你要的服務?開 PR 貢獻 Recipe」CTA
---
## 6. 檔案結構
```
arcrun/landing/ ← 新 Next.js 專案
├── app/
│ ├── layout.tsx
│ ├── page.tsx 首頁
│ ├── login/
│ │ └── page.tsx
│ ├── dashboard/
│ │ ├── page.tsx
│ │ └── middleware.ts (或 root middleware
│ ├── api-docs/
│ │ └── page.tsx Swagger UI
│ └── integrations/
│ └── page.tsx
├── middleware.ts 保護 /dashboard(讀 cookie
├── lib/
│ └── auth.ts session helpers
├── public/
├── next.config.ts
├── package.json
└── wrangler.toml CF Pages 設定
```
cypher-executor 新增:
```
arcrun/cypher-executor/src/routes/
├── auth.ts ← 新增(OAuth start/callback/logout/me
```
cypher-executor wrangler.toml 新增:
```toml
[[kv_namespaces]]
binding = "USERS_KV"
id = "<to be created>"
[[kv_namespaces]]
binding = "SESSIONS_KV"
id = "455d0505c7534883a4d4985ab8295857"
```
---
## 7. 環境變數 / Secrets
### cypher-executorWorker Secrets
```
GOOGLE_CLIENT_ID Google OAuth App client_id(僅 openid profile email scope
GOOGLE_CLIENT_SECRET Google OAuth App client_secret
GITHUB_CLIENT_ID GitHub OAuth App client_idread:user + user:email scope
GITHUB_CLIENT_SECRET GitHub OAuth App client_secret
SESSION_SECRET 隨機 32 bytes,用於 HMAC session ID(或直接用 UUID
```
### landingPages Environment Variables
```
NEXT_PUBLIC_API_BASE https://cypher.arcrun.dev
```
---
## 8. 實作步驟(Checklist
### Phase 1cypher-executor 後端擴充
- [x] `wrangler kv:namespace create USERS_KV` → 填入 wrangler.toml (id: 25bef01d079148919578894434d58c4d)
- [x] `wrangler kv:namespace create SESSIONS_KV` → 填入 wrangler.toml (id: 455d0505c7534883a4d4985ab8295857)
- [x] 建立 `arcrun/cypher-executor/src/routes/auth.ts`
- [x] GET `/auth/google/start`
- [x] GET `/auth/github/start`
- [x] GET `/auth/callback`(換 token → userinfo → upsert USERS_KV → 建 session → Set-Cookie → redirect
- [x] POST `/auth/logout`
- [x] GET `/me`(讀 session cookie 或 API Key header
- [x] PUT `/me/api-key/rotate`
- [x] DELETE `/me/api-key`revoke
- [x] 在 `src/index.ts` 掛載 `authRouter`
- [ ] `wrangler secret put GOOGLE_CLIENT_ID` 等 4 個 secrets ← **用戶需自建 Google/GitHub OAuth App**
- [x] `wrangler deploy` ← 已部署(Worker version 7877857b
### Phase 2Next.js Landing 專案
- [x] `npx create-next-app@latest arcrun/landing --typescript --tailwind --app`
- [x] 設定 `@cloudflare/next-on-pages`Next.js 15 + .npmrc legacy-peer-deps
- [ ] 建立 `middleware.ts`(保護 /dashboard,讀 `arcrun_session` cookie)← 待做
- [x] 首頁(`app/page.tsx`):Hero + Code Demo tab + CTA
- [x] 登入頁(`app/login/page.tsx`):Google / GitHub 按鈕(href 到 cypher.arcrun.dev/auth/google/start
- [x] Dashboard`app/dashboard/page.tsx`):顯示 API KeyRotate / Revoke 按鈕
- [x] Swagger UI`app/api-docs/page.tsx`):client component,動態 import swagger-ui CDN
- [x] 服務目錄(`app/integrations/page.tsx`):靜態,列 20 個 recipe
- [ ] 中英切換 ← 低優先,可延後
- [x] `wrangler pages deploy` → https://42a8d302.arcrun-landing.pages.dev
- [ ] Cloudflare dashboard 設定 arcrun.dev custom domain → arcrun-landing Pages project
### Phase 3:驗收(待 OAuth Secrets 填入後)
- [ ] Google / GitHub OAuth 完整流程(登入 → dashboard → 看到 key
- [ ] Rotate:新 key 出現
- [ ] Revoke:舊 key 的 API 呼叫回傳 401
- [ ] Swagger UI 正常載入,可試打 `/health`
- [x] `/integrations` 正確列出 20 個服務
---
## 9. 待決事項(開始實作前確認)
| 問題 | 預設決策 |
|---|---|
| Google OAuth App 是否要另建(只有 email scope? | 是,另建;auth recipe 的 App 不動 |
| Rotate 後舊 credentials 是否遷移? | 不遷移,顯示提示 |
| Domain arcrun.dev 是否已購入且在 Cloudflare | 假設是(wrangler.toml 有設 zone_name |
| 登入後 redirect 預設到 /dashboard | 是,可從 `?redirect=` 覆寫 |
@@ -0,0 +1,109 @@
# Design 補充:設定分層(env > 專案層 > 全域)+ init 非互動
> 2026-06-04 建立。`sdk-and-website/design.md` 的單檔補充(規則 02 §4.3 允許)。
> 來源:`docs/壓測報告.md` §1.2(方案 C+ §2.2(init 非互動)= 阻斷項 #7#8
> richblack 2026-06-04 授權:「#7#8 是同一問題,明顯發現的問題當然要解決,完成後就要推。」
---
## 1. 問題(壓測實測)
`acr` v1.1.0 把設定**寫死在全域唯一一份** `~/.arcrun/config.yaml``config.ts`
`CONFIG_DIR = join(homedir(), '.arcrun')`),且 `init` 只能 readline 互動。後果:
- **#8 多帳號**:同一台電腦只能一個 arcrun 身份 → 接案者 / 多公司 / 個人+公司混用做不到。
壓測者克難法是覆寫 `HOME`(需讀原始碼才找得到、不直覺)。
- **#7 非互動**init 只能 TTY 問答 → AI / CI 無法用 flag / env 完成。
兩者本質同一:**設定來源不該只有「全域單檔 + 強制 TTY」一條路**。
## 2. 設計(richblack 2026-06-04 拍板:三層全上 + init flag/env
### 2.1 設定分層優先序(仿 git config / Claude Code MCP
```
1. 環境變數 ARCRUN_* / CLOUDFLARE_* ← 最高,解 #7AI/CI 非互動)
2. 專案層設定 <就近往上找>/.arcrun.yaml ← 解 #8(接案多帳號),有 fallback
3. 全域設定 ~/.arcrun/config.yaml ← 平常自動用(自己的帳號)
```
- **就近往上找**:從 `process.cwd()` 往上層目錄逐層找 `.arcrun.yaml`,找到第一個即用(停在檔案系統根)。
→ 自己的專案不放檔 → 自動 fallback 全域;客戶資料夾放檔 → 只在該樹生效,離開自動切回。零心智負擔、不會忘記切換。
- **覆蓋是「欄位級 merge」**:高層只覆蓋它有提供的欄位,未提供的欄位 fallback 到低層。
(例:專案層只放 `cypher_executor_url`,其餘仍用全域。)
### 2.2 env 變數對應(最高層,欄位級覆蓋)
| env | 覆蓋 config 欄位 | 用途 |
|---|---|---|
| `ARCRUN_MODE` | `mode` | local/standard/self-hosted |
| `ARCRUN_API_KEY` | `api_key` | standard |
| `ARCRUN_ENCRYPTION_KEY` | `encryption_key` | standard/self-hosted |
| `ARCRUN_CYPHER_EXECUTOR_URL` | `cypher_executor_url` | self-hosted 指向自己的 cypher |
| `CLOUDFLARE_ACCOUNT_ID` | `cloudflare_account_id` | self-hosted(沿用 wrangler 慣用名)|
| `CLOUDFLARE_API_TOKEN` | `cf_api_token` | self-hosted(沿用 wrangler 慣用名)|
> CF 兩個用 `CLOUDFLARE_*` 而非 `ARCRUN_*`:與 wrangler / deploy.ts 既有慣例一致(deploy.ts 跑 wrangler 時就是設這兩個 env),CI 設一次兩邊通用。
### 2.3 init 非互動(flag > env > 互動問答)
`acr init --self-hosted` 取得 account-id / api-token 的順序:
1. **flag**`--account-id <id>` / `--api-token <token>`
2. **env**`CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN`
3. **互動**:前兩者缺才 readline 問(保留現有 UX
> mindset §7 判準:帳號設定**不是**「暴露資料 / 建零件」類風險確認,是單純設定值,flag/env 合法、不違反「非 TTY 拒絕代人類確認」。
> 風險確認(exposure_consent 等)仍維持需人類明示,不在本次放寬範圍。
## 3. 實作(只動 cli/
| 檔案 | 動作 |
|---|---|
| `cli/src/lib/config.ts` | `loadConfig()` 改三層解析:全域 → merge 專案層 `findProjectConfig()` → merge env`applyEnvOverrides()`)。新增 `acr config --where` 用的 `resolveConfigSources()` |
| `cli/src/commands/init.ts` | `cmdInit` / `initSelfHosted``accountId`/`apiToken` options,缺才走 env 再 fallback 互動 |
| `cli/src/index.ts` | init 加 `--account-id <id>` / `--api-token <token>` option |
**`loadConfig()` 是唯一設定入口**(12 個指令全走它)→ 改它一處,全指令自動受益分層。
### 3.1 `acr config --where`(壓測 §1.2 建議 #3,避免用錯帳號)
新增輕量 `acr config` 指令,印出「現在這個資料夾正用哪個帳號 / 設定來自哪一層」:
```
mode: self-hosted(來源:專案層 /path/客戶A/.arcrun.yaml
cloudflare_account_id: abc...(來源:env CLOUDFLARE_ACCOUNT_ID
cypher_executor_url: https://...(來源:全域 ~/.arcrun/config.yaml
```
> 安全價值:部署前一眼確認「沒用錯帳號」。本次一併做(成本低、直接回應壓測痛點)。
### 3.2 安全:專案層 .arcrun.yaml 含憑證 → 必須 gitignore
專案層 `.arcrun.yaml` 可能含 `cf_api_token``createCredentialsYamlIfMissing()` 既有 gitignore 邏輯
擴充為一併忽略 `.arcrun.yaml`(壓測 §1.2 安全附帶發現:憑證進版控 = 帳號外洩)。
## 4. 驗收標準(客觀證據,mindset §7)
1. 專案資料夾放 `.arcrun.yaml``acr config --where` 顯示來源為該專案層;離開該樹 → 顯示全域。
2. `CLOUDFLARE_ACCOUNT_ID=x CLOUDFLARE_API_TOKEN=y acr init --self-hosted` → 不問互動直接跑(#7)。
3. `acr init --self-hosted --account-id x --api-token y` → 同上(flag 優先於 env)。
4. 三者皆缺 → fallback 互動問答(既有 UX 不破壞)。
5. env > 專案層 > 全域 的欄位級覆蓋:單元測試覆蓋三層 merge。
6. `npx tsc --noEmit` 全綠。
## 4.1 實作完成記錄(2026-06-04
全部 task 完成,客觀證據如下(mindset §7):
- [x] `config.ts` 三層解析:`loadConfig()` = 全域 → 專案層 → env 欄位級 merge;新增 `findProjectConfig()`(就近往上找)/ `resolveConfigSources()` / `activeProjectConfigPath()`
- [x] `init.ts``initSelfHosted` 收 flag/env,缺才互動;gitignore 一併排除 `.arcrun.yaml`
- [x] `index.ts`init 加 `--account-id`/`--api-token`;新增 `acr config [--where]` 指令
- [x] 新增 `commands/config.ts`token 遮罩、來源層標示)
- **驗收證據**
- 端對端測試(真實 fs 臨時目錄樹)8/8 通過:深層就近找專案層、未提供欄位 fallback、env 最高層覆蓋、來源層標示、離開專案樹回全域
- `acr init --help` 顯示 flag`acr config --where` 正確標來源 + token 遮罩 `secret_t…`
- `npx tsc --noEmit` 全綠
## 5. 為何不違反鐵律
- 只動 `cli/`,不碰零件 / cypher-executor 執行路徑 / Service Binding。
- flag/env 是帳號設定值,非風險確認(mindset §7 風險確認仍須人類明示)。
- 不新增頂層 SDD 目錄(本檔是 sdk-and-website 子系統內單檔補充,rule 02 §4.3)。
@@ -0,0 +1,281 @@
# Design Document: arcrun SDK Libraries + Website
## Overview
本設計涵蓋 arcrun 的三個新增交付物:
1. Python SDK lib`pip install arcrun`
2. JS/TS SDK lib`npm install arcrun``@arcrun/sdk`
3. arcrun.dev 網站完善(零件列表、recipe 列表、登入管理)
**設計原則:修改不重建。** SDK 是 `cypher.arcrun.dev` HTTP API 的 thin wrapper。不在 client 端重新實作任何 server 端已有的邏輯(workflow 執行、credential 注入、auth recipe 解析)。唯一在 client 做的是 AES-GCM 加密(因為 server 的 POST /credentials 期望收到加密後的 payload)。
---
## Architecture
### 系統關係圖
```
使用者程式碼
├── CLIacr → cypher.arcrun.devHTTP API
├── Python SDKarcrun → cypher.arcrun.devHTTP API
└── JS SDKarcrun / @arcrun/sdk → cypher.arcrun.devHTTP API
arcrun.dev 網站(Next.js / Cloudflare Pages
├── /login → /auth/google/start, /auth/github/startcypher.arcrun.dev
├── /dashboard → /me, /me/api-key/rotatecypher.arcrun.dev
├── /integrations → /auth-recipescypher.arcrun.dev
└── /components → /recipes + 靜態零件清單(embedded
cypher.arcrun.devCloudflare Worker — cypher-executor,不改)
├── POST /credentials ← 接收 { name, encrypted, iv }
├── GET /credentials ← 列出 credential 名稱
├── DELETE /credentials/:name ← 刪除 credential
├── GET /auth-recipes ← 列出 20 個 auth recipe
├── GET /auth-recipes/:service ← 單一 recipe 詳情
├── POST /webhooks/named ← 部署 workflow
├── POST /webhooks/named/:name/trigger ← 觸發 workflow
├── GET /webhooks/named ← 列出 workflow
├── POST /register ← 註冊取得 API Key
├── GET /me ← 當前用戶資訊
└── /auth/* ← OAuth 流程
```
---
## Python SDK`arcrun/python-sdk/`
### 目錄結構
```
arcrun/python-sdk/
├── pyproject.toml ← hatchling build, name="arcrun", deps=[httpx>=0.27, cryptography>=42]
├── README.md
└── arcrun/
├── __init__.py ← from .client import Arcrun
├── client.py ← Arcrun class(主入口)
├── crypto.py ← AES-GCM 加密(client 端,用 cryptography 套件)
├── creds.py ← CredentialsClientpush/list/delete
├── auth.py ← AuthClientsetup/bind/get_token/list_services
└── workflows.py ← WorkflowClientrun/push/list/delete
```
### API 設計
```python
from arcrun import Arcrun
# 建構 — api_key 從參數 > 環境變數 > ~/.arcrun/config.yaml 自動取得
client = Arcrun()
# 或明確指定
client = Arcrun(api_key="ak_xxx", encryption_key="hexstring")
# Auth:設定並綁定服務
client.auth.setup("openai", api_key="sk-xxx") # 加密 + 上傳
openai_client = client.auth.bind("openai") # 取回 pre-auth client
response = openai_client.get("/models") # httpx.Client
token = client.auth.get_token("openai") # raw token string
services = client.auth.list_services() # [{ service, display_name, ... }]
# Credentials:低階操作
client.creds.push("my_token", "value123")
names = client.creds.list()
client.creds.delete("my_token")
# Workflows
result = client.workflows.run("my-flow", {"email": "user@example.com"})
url = client.workflows.push("my-flow", graph_dict)
workflows = client.workflows.list()
```
### Credential 加密流程
```
setup("openai", api_key="sk-xxx")
1. GET /auth-recipes/openai → recipe(含 required_secrets, inject
2. 對應 required_secrets[0].key = "openai_api_key"
3. crypto.py 用 encryption_key AES-GCM 加密 "sk-xxx"
4. POST /credentials → { name: "openai_api_key", encrypted, iv }
5. 本地 _cred_cache["openai_api_key"] = "sk-xxx"(供 bind() 用)
bind("openai")
1. GET /auth-recipes/openai → recipe.inject.header = { Authorization: "Bearer {{secret.openai_api_key}}" }
2. 用 _cred_cache["openai_api_key"] 替換 template → "Bearer sk-xxx"
3. 回傳 AuthenticatedClient(base_url="https://api.openai.com/v1", headers={"Authorization": "Bearer sk-xxx"})
```
**注意**`bind()` 依賴 `setup()` 在同一 session 建立的 `_cred_cache`。跨 session 使用時(credential 已上傳但 cache 不存在),`bind()` 無法解析 template — 此時 `get_token()` 也無法返回值。**這是已知限制,封測期間先接受。** 長期解法是 server 提供 `/credentials/:name/secret` 解密端點(u6u-core/credentials 已有)。
### 關鍵差異:crypto.py 的定位
`crypto.py` 只做 **加密**encrypt),不做解密。
功能等同 `u6u-core/credentials/src/actions/crypto.ts``encrypt()` 函數。
解密只在 server 端發生(cypher-executor 的 `credential-injector.ts``u6u-core/credentials/getCredentialSecret.ts`)。
---
## JS/TS SDK`arcrun/js-sdk/`
### 目錄結構
```
arcrun/js-sdk/
├── package.json ← name TBDarcrun vs @arcrun/sdk),tsup build
├── tsconfig.json ← ES2020, NodeNext
└── src/
├── index.ts ← export class Arcrun
├── crypto.ts ← Web Crypto API AES-GCM encryptclient 端)
├── creds.ts ← CredentialsClientpush/list/delete
├── auth.ts ← AuthClientsetup/bind/getToken/listServices
└── workflows.ts ← WorkflowClientrun/push/list/delete
```
### API 與 Python SDK 對等
```typescript
import { Arcrun } from 'arcrun' // or '@arcrun/sdk'
const client = new Arcrun() // reads ARCRUN_API_KEY from env
await client.auth.setup('openai', { api_key: 'sk-xxx' })
const oai = await client.auth.bind('openai')
const models = await (await oai.get('/models')).json()
const token = await client.auth.getToken('openai')
const services = await client.auth.listServices()
await client.creds.push('my_token', 'value')
const names = await client.creds.list()
const result = await client.workflows.run('my-flow', { email: 'user@example.com' })
```
### Build 產物
```
dist/
├── index.js ← ESM
├── index.cjs ← CJS
├── index.d.ts ← TypeScript 型別
└── index.d.cts
```
### Crypto 實作
使用 Web Crypto API`crypto.subtle`),相容 Node 18+ / browsers / CF Workers / Deno
```typescript
async function encrypt(plaintext: string, hexKey: string): Promise<{ encrypted: string; iv: string }> {
const key = await crypto.subtle.importKey('raw', hexToBytes(hexKey), { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext));
return { encrypted: toBase64(ciphertext), iv: toBase64(iv.buffer) };
}
```
---
## arcrun.dev 網站
### 現有狀態(`arcrun/landing/`
已完成:
- [x] `/` — Hero + Code DemoPython/JS/HTTP tabs
- [x] `/login` — Google + GitHub OAuth 按鈕(前端 OK,需設 OAuth secrets
- [x] `/dashboard` — API Key 查看/Copy/Rotate/Revoke(依賴 `/me` API
- [x] `/integrations` — 20 個 recipe 靜態卡片
- [x] `/api-docs` — Swagger UI CDN 嵌入
- [x] `middleware.ts` — 保護 `/dashboard`(未登入 → `/login`
- [x] Cloudflare Pages 部署
待完成:
- [ ] OAuth secrets 設定(`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`
- [ ] `/components` 頁面(零件列表 — 21 個 WASM 零件的 input/output/config_example
- [ ] 首頁 code demo 更新為三種使用方式(CLI / Python / JS
- [ ] 登入流程真實驗證
### 新增頁面:`/components`
```
/components
├── 零件卡片(21 個)
│ ├── canonical_id
│ ├── display_name
│ ├── description
│ ├── input_schemarequired / optional 欄位)
│ ├── output_schema
│ ├── credentials_requiredif any
│ └── config_exampleYAML code block
└── 分類篩選(邏輯 / API / 控制流)
```
資料來源:靜態嵌入(從 `registry/components/*/component.contract.yaml` 在 build 時讀取),不依賴 runtime API。
### OAuth 設定(待 richblack 操作)
需要在 Cloudflare Worker 設定以下 secrets
```bash
wrangler secret put GOOGLE_CLIENT_ID --name arcrun-cypher-executor
wrangler secret put GOOGLE_CLIENT_SECRET --name arcrun-cypher-executor
wrangler secret put GITHUB_CLIENT_ID --name arcrun-cypher-executor
wrangler secret put GITHUB_CLIENT_SECRET --name arcrun-cypher-executor
wrangler secret put SESSION_SIGNING_SECRET --name arcrun-cypher-executor
```
---
## server 端需要的修改
### cypher-executor 修改(最小化)
目前 `POST /credentials` 端點(`routes/credentials.ts`)接收 `{ name, encrypted, iv }` 後直接存 KV。
SDK 需要的改動:
1. **`GET /auth-recipes` 回應格式**:目前 list 端點回 `{ recipes: [...] }` 但 recipe 的 `service` 欄位是 key — SDK 已在 list_services() 正確處理 ✅
2. **`GET /auth-recipes/:service` 回應格式**:目前回 `{ success: true, recipe: {...} }` — SDK 需讀 `body.recipe` 而非 body 本身 ✅
3. **`POST /credentials` 不需改動** — SDK 自己做 AES-GCM 加密後送 `{ name, encrypted, iv }`
4. **未來**:新增 `GET /credentials/:name/secret` 端點(解密返回 plaintext),讓跨 session 的 `bind()` 能工作。但此端點在 `u6u-core/credentials/src/actions/getCredentialSecret.ts` 已有實作 — 需要在 cypher-executor 整合或 Service Binding 到 u6u-credentials Worker。**封測後再做。**
---
## 不做的事(明確排除)
- ❌ 不在 SDK 裡做 workflow 解析或 YAML 處理 — 那是 CLI 的職責
- ❌ 不在 SDK 裡做 server-side 解密 — 解密只在 server 端
- ❌ 不建新的 credentials Worker — 用現有的
- ❌ 不建新的 KV namespace — 用現有的 CREDENTIALS_KV
- ❌ 不改 cypher-executor 的 credential-injector.ts — 那已經完成且測試通過
---
## 實作順序
```
Phase 1Python SDK 重建 + 測試
1.1 重建 arcrun/python-sdk/(按本 SDD 的結構)
1.2 修正上次的 bugrecipe 回應 wrapper、inject key "header" vs "headers"、secret key mapping
1.3 對 cypher.arcrun.dev live 測試全部 API
1.4 本地安裝測試(pip install -e .
Phase 2JS SDK 重建 + 測試
2.1 重建 arcrun/js-sdk/(按本 SDD 的結構)
2.2 同步修正 Python SDK 發現的所有 recipe 格式問題
2.3 buildtsup+ 本地測試
Phase 3arcrun.dev 網站補完
3.1 新增 /components 頁面
3.2 更新首頁 code demo(三種使用方式)
3.3 OAuth secrets 設定(需 richblack 操作 GCP / GitHub
3.4 登入流程驗證
Phase 4GitHub README + 發布
4.1 更新 arcrun/README.md — 三種 Quick Start
4.2 pip publisharcrun
4.3 npm publishTBD 套件名)
4.4 最終驗證:從零開始 pip install / npm install / 打 API
```
@@ -0,0 +1,132 @@
# DesignMCP 統一帳號來源 — 單一 remote MCP + .env 切 MCP URL
> 2026-06-06 richblack 拍板(推翻本檔初版的「①工具帶參數 / ②自架 worker / ③平台 routing」三方案)。
> `sdk-and-website/design.md` 的單檔補充(規則 02 §4.3 允許)。
> 來源:壓測報告 §5.1/§5.2/§5.4(薄殼原則)+ richblack 對話釐清。
> 對應鐵律:`.claude/rules/07-thin-shell.md` §4(統一帳號來源)。
---
## 1. 問題(壓測 §5.2
CLI 已能讀三層 configenv > 專案 `.arcrun.yaml` > 全域)切帳號;**MCP 不能**——MCP 用 Cloudflare
service binding 焊死平台 `arcrun-cypher-executor`self-hosted 用戶用 MCP 連不到自己的 cypher。
**這違反薄殼鐵律**:「切換帳號」這能力做在了 CLI(介面層),MCP 沒跟上 → 證明它不在 API/共用層。
## 2. 關鍵洞見(richblack):不需要兩套 transport
> 「self-hosted 用戶也有 CF,把 MCP 放上網對他也沒困難。接案時進客戶專案讀 `.env` 連的是
> 客戶專案的雲端 MCP — 這比保留 stdio + 雲端兩套更單純。」
推論:**所有人都用 remote Worker MCP,差別只在「連哪台 MCP」**。
- **我自己**:全域 config 的 `mcp_url` 指向我自己的 MCP Worker。
- **接案幫客戶**:客戶資料夾 `.arcrun.yaml` / `.env` 放客戶的 `mcp_url`(那台 MCP 綁客戶 cypher)。
進客戶資料夾 → 自動連客戶那套。
- **SaaS 用戶**:不設 `mcp_url` → fallback 平台預設 MCPAI 幫他帶 api_key。
→ **不需要 stdio 本機 MCP、不需要 transport 抽象層、不需要把 Worker 改成讀本機檔。**
現有 remote HTTP Worker MCP 形態完全保留。
## 3. 薄殼原則怎麼落地
「身份解析(讀哪個帳號/哪台 MCP)」本質在 client(帳號設定是本機檔,cypher/MCP Worker 讀不到)。
正解不是「API 去讀 .env」(做不到),而是:
> **MCP URL 與 cypher URL 一樣,由同一份 config 解析模組(env > 專案 > 全域)決定。**
> CLI 讀 `cypher_url`Claude Code 的 MCP 連線讀 `mcp_url`**同一份 `.arcrun.yaml` / env、同一個解析邏輯**。
> 「切換帳號」這能力只實作一次(config 解析),不再綁死在 CLI。
### 接線:mcp_url → Claude Code 的 MCP 設定
Claude Code 看 `.mcp.json`(專案層)決定連哪台 MCP。所以需要一個東西把
「arcrun config 解析出的 `mcp_url`」寫進專案 `.mcp.json`
- **`acr mcp-setup`**(新指令):依三層 config 解析出 `mcp_url`,在 cwd 寫 / 更新 `.mcp.json`
- `acr init` 順帶呼叫(裝好就有)。
- 「切帳號」= 在客戶資料夾跑 `acr mcp-setup`(讀該資料夾的 `.arcrun.yaml`)→ 產對的 `.mcp.json`
## 4. 動到的檔案
| 檔案 | 動作 | 狀態 |
|---|---|---|
| `cli/src/lib/config.ts` | `ArcrunConfig``mcp_url``ENV_MAP``ARCRUN_MCP_URL``resolveConfigSources` 含 mcp_url;新增 `DEFAULT_MCP_URL` + `getMcpUrl()` | ✅ 完成 |
| `cli/src/commands/mcp-setup.ts`(新增) | `acr mcp-setup`:依 `getMcpUrl()` 寫專案 `.mcp.json` | 進行中 |
| `cli/src/index.ts` | 註冊 `acr mcp-setup` | 進行中 |
| `cli/src/commands/init.ts` | init 尾端順帶 `acr mcp-setup`(裝好即有) | 進行中 |
| **`arcrun/mcp/`(新目錄)** | MCP 從 sibling repo `matrix/arcrun-mcp` **搬進主庫**(與 cli/ 並列)。形態不變(remote Worker),只是進主庫 → 同 repo、同 deploy 掃描、與 cypher API 對齊 | 進行中 |
| `mcp/wrangler.toml` | name/route 對齊 arcrun 部署慣例(`workers_dev=true`deploy.yml/local-deploy.sh 自動掃到) | 進行中 |
## 5. MCP 搬進 arcrun/mcp/(不改形態,只進主庫)
- **搬 verbatim**`src/tools/*``src/lib/*``src/types.ts``src/mcp-handler.ts``src/index.ts``tests/`
- **形態不變**:仍是 Hono + `WebStandardStreamableHTTPServerTransport` 的 remote Worker(不加 stdio)。
- **進主庫的理由**(a) 與 cypher-executor 同 repo → 改 API 時 MCP 薄殼同步可見、一起 review;
(b) 被 deploy 掃描(wrangler.toml)自動部署,納入 release.feature「推送=全部到位」;
(c) self-host 用戶 codeload 主庫即得 MCP,能部署自己的 MCP Worker。
- **sibling repo `matrix/arcrun-mcp` 去留**:搬進來後,原 sibling 為歷史/過渡;新開發在 `arcrun/mcp/`
## 5.5 self-hosted MCP 認證對齊(2026-06-14HANDOFF §3b
> 症狀(mira CC 實測 `arcrun-mcp.leo21c.workers.dev`):self-hosted 用 namespace 明碼連 MCP 一律 401
> CLL 全通。根因:MCP `middleware/partner-auth.ts` 把 Bearer 拿去 KBDB `/partners/:token/info`
> **驗證**namespace 明碼非註冊 partner → 401。而 cypher-executor 的 `X-Arcrun-API-Key`
> **不驗證、直接當分區 key**webhooks-named.ts triggerNamed)→ 這就是「CLI 通、MCP 401」的分歧。
**決策:① MCP self-hosted 繞 partner 驗證 + ② mcp-setup 寫 headers(兩者缺一不可)。**
| | 修法 | 為何必須 |
|---|---|---|
| ① | `partner-auth.ts``MULTI_TENANT === 'false'` 時 Bearer = namespace 明碼直接當 `org_namespace`,不打 KBDB partner 驗證(對齊 cypher 的 opaque-key 模型)。官方 SaaS(不設/`"true"`)行為不變 → 官方與 self-host 共用同一份程式碼 | 只做②也沒用:partner 驗證仍擋明碼 |
| ② | `mcp-setup.ts`:把 `config.api_key`self-hosted 存 namespace 明碼)寫進 `.mcp.json``headers.Authorization: Bearer …`(與 CLI 同一份身份,rule 07 §4) | 只做①也沒用:裸 `.mcp.json` 不送任何 header |
判定旗標:worker `[vars] MULTI_TENANT`(與 cypher 同名)。
### 5.5.1 部署注入修補(2026-06-15HANDOFF §3b-2
> 症狀:①②code 正確、官方帳號測綠,但 mira 推 leo21c 端到端**仍 401**。
> **根因(非 code bug,是部署注入缺口)**partner-auth.ts `if (c.env.MULTI_TENANT === 'false')`
> 邏輯對,但 worker env 裡 `MULTI_TENANT === undefined`——因為:
> - mcp/wrangler.toml 的 `MULTI_TENANT` 原本是**註解掉的**
> - `cli/src/lib/deploy.ts``injectWranglerConfig` 部署時注入了 KV id / WORKER_SUBDOMAIN / D1 id
> **但沒注入 MULTI_TENANT** → 部署後 `c.env.MULTI_TENANT===undefined ≠ 'false'` → 走 partner-key → 401。
> - config 源頭早有(init.ts `multi_tenant:false` + `mode:'self-hosted'`),只是沒被注進 worker。
>
> **只取消註解 mcp/wrangler.toml 不夠**——那只修「手動 fork」,沒修「acr update 自動部署」(mira 走後者)。
> 根因要修在 deploy.ts 注入邏輯。
**修法(方案①:注 vars 非 secret,符合 self-hosted 零填寫契約)**
| 檔案 | 動作 | 狀態 |
|---|---|---|
| `cli/src/lib/deploy.ts` | `DeployContext``selfHosted?`;新增 export `injectMultiTenant(toml)`(處理 active/註解/無行三態,加進 `[vars]`);`injectWranglerConfig``selfHosted` 時呼叫——與 WORKER_SUBDOMAIN/KV 注入同層級 | ✅ 完成 |
| `cli/src/commands/init.ts` | deployCtx 帶 `selfHosted: true`init 本就是 --self-hosted 分支) | ✅ 完成 |
| `cli/src/commands/update.ts` | ctx 帶 `selfHosted: config.mode==='self-hosted' \|\| config.multi_tenant===false`mira 走這條) | ✅ 完成 |
| `mcp/wrangler.toml` | `# [vars]`/`# MULTI_TENANT` 改 active `[vars]`(官方不含 MULTI_TENANT=多租戶;注入走 case-3 加行,結構正確在 [vars] 下) | ✅ 完成 |
**本地驗注入(dry-run,真實 export 函式)**mcp / cypher-executor 注入後各恰 1 行 active `MULTI_TENANT = "false"` 且在 active `[vars]` 之下 → ✓ PASS。cli tsc exit 0。
**端到端(交棒回 mira**mira 在 leo21c 重跑 `acr update` 重部 MCP worker(這次帶 MULTI_TENANT=false)→ `curl -H "Authorization: Bearer leo" https://<mcp>.leo21c.workers.dev/mcp`**200 非 401**。官方帳號測不到(不設 MULTI_TENANT)。
### 5.5.0 原始 code 修法(2026-06-14,①②)
| 檔案 | 動作 | 狀態 |
|---|---|---|
| `mcp/src/types.ts` | `Env``MULTI_TENANT?` | ✅ 完成 |
| `mcp/src/middleware/partner-auth.ts` | self-hosted 分支:Bearer 明碼直接當 org_namespace | ✅ 完成 |
| `cli/src/commands/mcp-setup.ts` | `.mcp.json``headers.Authorization` | ✅ 完成 |
驗收:mira 用 namespace 明碼連 self-hosted MCP(待 leo21c 部署後實測,HANDOFF §3);官方 SaaS MCP partner-key 路徑回歸不變。
## 6. 不在範圍(明確排除)
- ❌ 不加 stdio transportrichblack §2:不需要)。
- ❌ 不把 init / config 搬進 MCPinit 是本機 CF 部署動作,MCP Worker 做不到)。
- ❌ 不在 MCP 重實作 credential 加密 / workflow 執行(server 職責,rule 02 §3.4)。
## 7. 驗收(客觀證據,mindset §7)
1. `cli/` + `mcp/` 各自 `tsc --noEmit` exit 0。
2. `acr mcp-setup` 在含 `.arcrun.yaml`(mcp_url=X)的資料夾 → 產出 `.mcp.json` 指向 X;無 mcp_url → 指向 DEFAULT_MCP_URL。
3. `acr config --where` 顯示 mcp_url 來源層。
4. `mcp/` 被 deploy 掃描掃到(`find wrangler.toml` 命中)。
5. self-hosted 用戶在客戶資料夾 `acr mcp-setup` → Claude Code 連客戶 MCP(端到端待 richblack 實測)。
@@ -0,0 +1,131 @@
# Requirements: arcrun SDK Libraries + Website
## Introduction
arcrun 目前有三個使用介面:
1. **CLI**`acr` 指令)— 已完成,用 YAML 定義 workflow 並推送執行
2. **Python / JS SDK lib**(本次新增)— `pip install arcrun` / `npm install arcrun`,讓開發者在寫程式時直接用 arcrun 功能
3. **arcrun.dev 網站**(本次完成)— 登入取得 API Key、管理 Key、瀏覽零件 / recipe 列表
**核心原則**SDK lib 是 `cypher.arcrun.dev` HTTP API 的 thin wrapper。所有業務邏輯(加解密、credential 注入、workflow 執行)都在 server 端完成。Client 端不重做 server 已有的邏輯。
**現有基礎設施**(不重建,直接使用):
- `cypher.arcrun.dev`cypher-executor Workerworkflow 執行、credential 管理、auth recipe、webhook
- `u6u-core/credentials`credential WorkerAES-GCM 加解密)— arcrun/credentials 是其 cherry-pick
- `arcrun/cli`CLI 工具(已發布 npm `arcrun@1.1.0`
- `arcrun/landing`Next.js 前端(已部署 Cloudflare Pages,有 hero/login/dashboard/integrations 骨架)
---
## Glossary
- **SDK lib**Python / JS 套件,wrapping `cypher.arcrun.dev` HTTP API,安裝後可在程式碼中直接使用
- **auth.setup()**:上傳一個服務的 credential(如 Notion token、OpenAI API Key)到 arcrun
- **auth.bind()**:取回已設定服務的 pre-authenticated HTTP client
- **auth.get_token()**:取回某服務的 raw tokenescape hatch,給官方 SDK 用)
- **workflows.run()**:觸發已部署的 workflow
- **workflows.push()**:上傳 workflow 定義
- **Recipe**:描述「如何對某服務認證」的 YAML 設定,存在 RECIPES KV
---
## Requirements
### Requirement 1Python SDK`pip install arcrun`
**User Story:** As a Python 開發者, I want `pip install arcrun` 後在程式碼中使用 arcrun, so that 不用離開寫程式環境就能串接 20+ 服務。
#### Acceptance Criteria
1. THE Python SDK SHALL 以 `arcrun` 套件名發布到 PyPI,支援 Python 3.10+。
2. THE SDK SHALL 提供以下 API
- `Arcrun(api_key=, base_url=)` — 建構 clientapi_key 支援從環境變數 `ARCRUN_API_KEY``~/.arcrun/config.yaml` 自動讀取
- `client.health()` — 健康檢查
- `client.auth.list_services()` — 列出可用 auth recipe 服務
- `client.auth.setup(service, **kwargs)` — 上傳 credential
- `client.auth.bind(service)` — 取得 pre-authenticated HTTP client
- `client.auth.get_token(service)` — 取得 raw token
- `client.creds.push(name, value)` — 上傳加密 credential
- `client.creds.list()` — 列出 credential 名稱
- `client.creds.delete(name)` — 刪除 credential
- `client.workflows.run(name, input)` — 觸發 workflow
- `client.workflows.push(name, graph)` — 上傳 workflow
- `client.workflows.list()` — 列出已部署 workflow
3. THE SDK 的 credential 加密 SHALL 在 client 端完成(使用 `cryptography` 套件 AES-GCM),然後以 `POST /credentials` 上傳加密後的 `{ name, encrypted, iv }` 到 server。
4. THE `auth.bind()` SHALL 從 server 取得 auth recipe 的 inject template,在 client 端用 cache 的 plaintext 值填入,回傳 pre-configured `httpx.Client`
5. THE SDK SHALL 使用 `httpx` 做 HTTP clientasync 版使用 `httpx.AsyncClient`)。
6. THE SDK 位置 SHALL 為 `arcrun/python-sdk/`build 系統用 `hatchling``pyproject.toml`)。
---
### Requirement 2JavaScript/TypeScript SDK`npm install arcrun`
**User Story:** As a JS/TS 開發者, I want `npm install arcrun` 後在程式碼中使用 arcrun, so that 可以嵌入現有 Node.js / Deno / Cloudflare Workers 專案。
#### Acceptance Criteria
1. THE JS SDK SHALL 以 `arcrun` 套件名發布到 npm,提供 ESM + CJS 雙格式 + TypeScript 型別定義。
2. THE SDK SHALL 提供與 Python SDK 對等的 APIcamelCase 版):
- `new Arcrun({ apiKey?, baseUrl? })` — 讀 `process.env.ARCRUN_API_KEY`
- `client.health()` — 回傳 `Promise<unknown>`
- `client.auth.listServices()` / `setup()` / `bind()` / `getToken()`
- `client.creds.push()` / `list()` / `delete()`
- `client.workflows.run()` / `push()` / `list()` / `delete()`
3. THE SDK 的 credential 加密 SHALL 使用 Web Crypto API`crypto.subtle` AES-GCM),相容 Node 18+、browsers、Cloudflare Workers、Deno。
4. THE `auth.bind()` SHALL 回傳一個有 `get/post/put/delete/patch` 方法的 `AuthenticatedClient`base URL + auth headers 已配置。
5. THE SDK SHALL 使用原生 `fetch()` API,不依賴外部 HTTP client 套件。
6. THE SDK 位置 SHALL 為 `arcrun/js-sdk/`build 用 `tsup`ESM + CJS + DTS),`tsconfig.json` target ES2020 + NodeNext module。
7. THE JS SDK 套件名與 CLI 套件名衝突(都叫 `arcrun`),SHALL 使用 `@arcrun/sdk` 或由 richblack 決定套件名。
---
### Requirement 3arcrun.dev 網站完成
**User Story:** As a 潛在用戶, I want 在 arcrun.dev 上登入取得 API Key、瀏覽零件和 recipe 列表, so that 我可以評估 arcrun 是否符合需求並立即開始使用。
#### Acceptance Criteria
1. THE 網站 SHALL 在 `arcrun.dev` 提供以下頁面:
- `/` — 首頁 Hero + 三種使用方式(CLI / Python / JS
- `/login` — Google + GitHub OAuth 登入
- `/dashboard` — 登入後顯示 API Key(查看/Copy/Rotate/Revoke
- `/integrations` — 列出 20 個 auth recipe 服務,可按分類篩選
- `/components` — 列出所有零件(21 個 WASM 零件),顯示 input/output schema、config_example
- `/api-docs` — Swagger UI,可直接試打 API
2. THE 登入 SHALL 使用 Google + GitHub OAuth,流程走 `cypher.arcrun.dev``/auth/*` 端點。
3. THE 登入後 SHALL 自動對該 email 呼叫 `/register` 取得 API Key(若已有則取回現有 key)。
4. THE `/dashboard` SHALL 允許 Rotate(生成新 key)、Revoke(標記失效)、Copy to clipboard。
5. THE 網站 SHALL 部署在 Cloudflare Pages(現有 `arcrun/landing`),使用 Next.js App Router。
6. THE 首頁 code demo 區 SHALL 包含三個 tabPython、JavaScript、HTTP/curl,展示三種使用方式。
---
### Requirement 4GitHub README 更新
**User Story:** As a GitHub 訪客, I want README 清楚說明三種使用方式, so that 我能選擇最適合的方式開始用 arcrun。
#### Acceptance Criteria
1. THE `arcrun/README.md` SHALL 包含三種 Quick Start
- **CLI**`npm i -g arcrun && acr init && acr push workflow.yaml && acr run`
- **Python**`pip install arcrun && from arcrun import Arcrun && ...`
- **JavaScript**`npm install arcrun && import { Arcrun } from 'arcrun' && ...`
2. THE README SHALL 包含完整零件列表(21 個)和 auth recipe 列表(20 個服務)。
3. THE README SHALL 連結到 `arcrun.dev`(取得 API Key)和 Swagger UIAPI 文件)。
---
### Requirement 5SDK 發布
**User Story:** As a SDK 使用者, I want 公開安裝並直接使用, so that 不需要從原始碼 build。
#### Acceptance Criteria
1. THE Python SDK SHALL 發布到 PyPI`pip install arcrun` 可安裝。
2. THE JS SDK SHALL 發布到 npm`npm install arcrun`(或 `@arcrun/sdk`)可安裝。
3. THE 發布前 SHALL 完成以下測試(對 `cypher.arcrun.dev` live API):
- `health()`
- `auth.list_services()`
- `auth.setup()` + `auth.bind()` ✅(至少一個 static_key 服務如 openai
- `creds.push()` + `creds.list()`
- `workflows.list()`
@@ -0,0 +1,375 @@
# Design 補充:`acr init --self-hosted` 一鍵自動化(installer 模式)
> 2026-06-01 初稿 → 2026-06-02 定案改寫(richblack 拍板 installer 形態)。
> 本檔是 `sdk-and-website/design.md` 的單檔補充(規則 02 §4.3 允許)。
> **狀態:design 已與 richblack 對齊;實作前讀 §6 前置依賴。**
> 背景:戰法從 SaaS 轉 self-hosted 開源(docs/HANDOFF-self-host-harness.md §0)。
---
## 1. 定案形態(richblack 2026-06-02
**arcrun CLI = installer / orchestrator**(類似 rustup / nvm:工具本身小,按需從遠端拉真正內容)。
### 用戶只做 4 件事,中間什麼都不用懂:
1. 申請 CF 帳號
2. 安裝 CF CLI`wrangler`
3. 安裝 arcrun CLI`npm i -g arcrun`
4. `acr init --self-hosted`(貼 CF Account ID + API Token)→ **完成,其餘看機器跑**
### CLI 自動做(用戶無感):
- 驗 CF token 權限
- 建 7 個 KV namespace + 1 個 R2 bucket(冪等)
- **從 GitHub release 下載預編譯部署物**(含 24 個 `.wasm` + 各 Worker 的 wrangler.toml + cypher-executor/registry
- 把建好的 KV namespace id 注入各 wrangler.toml + cypher-executor 的 `WORKER_SUBDOMAIN`
- **`wrangler deploy` 部署全部 Worker**(用戶已裝 wrangler
- seed auth recipe + API recipe 進 RECIPES KV
- 寫回 `~/.arcrun/config.yaml`
- 印出「手動 `wrangler secret put ENCRYPTION_KEY` ×3」提示(secret 不自動化,rule 05
### 關鍵技術決策(richblack 2026-06-02
| 決策 | 選擇 | 理由 |
|---|---|---|
| 零件部署物 | **預編譯 `.wasm`**(不在用戶端 build)| 用戶不懂 tinygo、也不該懂。下載即用。 |
| 部署工具 | **wrangler**shell out| 用戶已裝 CF CLIself-host 本來就有上傳能力。CLI 不自己重寫 CF Script Upload API。 |
| 源碼來源 | **GitHub release tarball**(含預編譯 wasm)| 版本明確、不需用戶有 git、`acr update` 拉新 release 同一條路。 |
| 為何不是 git clone | repo **沒 commit `.wasm`**rule 05 build 產物不 commit)→ clone 拿不到 wasm | 必須走含 wasm 的 release artifact。 |
---
## 2. 為什麼是 installer 而非「repo 內掃 wrangler.toml」(推翻初稿)
初稿假設「用戶在 repo 內跑、CLI 掃 wrangler.toml」。**推翻**,因為:
- npm 全域裝的 `acr` 手上**沒有** 24 個 Worker 源碼。
- repo 沒 commit `.wasm`(已查證 `git ls-files .component-builds | grep .wasm` = 0)→ 連 clone 都拿不到可部署的 wasm。
- 用戶不該需要懂 git / tinygo / repo 結構。
→ 正解:CLI 當 installer,從 **GitHub release(含預編譯 wasm** 拉部署物到暫存目錄,在暫存目錄注入 KV id 後 `wrangler deploy`
---
## 3. 流程設計(`initSelfHosted` 改寫)
```
acr init --self-hosted
├─ 1. 問 2 輸入:CF Account ID + CF API Token
wrangler 是否已裝?which wrangler;沒裝 → 提示先裝 CF CLI 再來)
│ 驗 tokenCF API GET /accounts/{id}/tokens/verify + GET /accounts/{id}
│ 缺權限(Workers Scripts Edit / KV Edit / R2 Edit)→ exit 1 指出缺哪個 scope
├─ 2. 建資源(冪等:先 list 已存在就重用)
│ 7 KVWEBHOOKS / CREDENTIALS_KV / RECIPES / USERS_KV /
│ SESSIONS_KV / ANALYTICS_KV / EXEC_CONTEXTrule 01 資料儲存表)
│ 1 R2WASM_BUCKET
├─ 3. 下載部署物:GitHub release tarball → 解壓到暫存目錄 (~/.arcrun/.deploy-<ver>/)
│ 內含:cypher-executor/ + registry/ + .component-builds/*(每個含預編譯 component.wasm + wrangler.toml
├─ 4. 注入設定到暫存目錄的 wrangler.toml(不改用戶 repo,改暫存副本)
│ - 各 Worker 的 KV binding id ← step 2 建立的
│ - cypher-executor [vars] WORKER_SUBDOMAIN ← CF API GET /accounts/{id}/workers/subdomain
├─ 5. 部署:對暫存目錄每個含 wrangler.toml 的 dirshell out
`wrangler deploy`env CLOUDFLARE_API_TOKEN=<token>, CLOUDFLARE_ACCOUNT_ID=<id>
│ 分兩層:tier1 = .component-builds/*(先)→ tier2 = cypher-executor / registry(後)
│ 每個 wrangler.toml 已含 workers_dev = true → workers.dev URL 自動啟用
├─ 6. seed recipe 進 RECIPES KV(部署後打新 cypher URL,或直接 CF KV API 寫)
│ - auth recipe:重用 AUTH_RECIPE_SEEDScypher-executor/src/lib/auth-recipe-seeds.ts
│ - API recipe:新增 seed-api-recipes.ts(見 §5
├─ 7. 寫回 configmode: self-hosted + 所有 id + cypher_executor_url = 部署後 workers.dev URL
└─ 8. 印手動 secret 提示:
wrangler secret put ENCRYPTION_KEY --name arcrun-cypher-executor
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-static-key
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-service-account
(三 Worker 共用同一把 key,見 memory: encryption-key-drift-trap
```
### `acr update`(同一條路,未來新零件)
- 拉新 GitHub release → 解壓 → 注入既有 config 的 KV id → wrangler deploy 變動的 Worker。
- 第一期至少做到「重跑等效 init 的部署步驟」;diff-only 部署可後續優化。
---
## 4. 動到的檔案
| 檔案 | 動作 |
|---|---|
| `cli/src/commands/init.ts` | 改寫 `initSelfHosted()`line 105-131)為 installer 流程 |
| `cli/src/lib/cf-api.ts` | 擴充:KV namespace 建立/list、R2 bucket 建立、subdomain 查詢、token verify |
| 新增 `cli/src/lib/deploy.ts`(暫定)| 下載 release tarball + 解壓 + 注入 wrangler.toml + shell out wrangler deploy |
| 新增 `cli/src/commands/update.ts`(暫定)| `acr update`:拉新 release 重部署 |
| 新增 `cli/src/lib/api-recipe-seeds.ts` | API recipe **種子資料**installer 用;放 CLI 端,**不放 cypher-executor/src**——rule 02 §2.2 hook 擋 cypher-executor TS hard-code endpoint,且 seed 資料本就屬 installer 職責)|
| 新增 `cypher-executor/scripts/seed-api-recipes.ts` | seed **腳本**(給 prod 補灌用,import CLI 的種子資料;`scripts/` 不受 §2.2 hook 管)|
| `cli/src/index.ts` | 註冊 `acr update` 指令 |
**不動**cypher-executor 執行路徑、既有零件 wasm 源碼、config 讀取端(config.ts:52 已支援 self-hosted)。
---
## 5. API recipe seed(新增 seed-api-recipes.tsrichblack 2026-06-02 定)
codebase 只有 auth recipe seed。新增 `seed-api-recipes.ts`,把現役 API recipe hard-code 成種子。
### 現役 API recipe(從 prod KV 查得,2026-06-01
- `kbdb_get`+ create_block / patch_block / delete / ingest)→ auth_service: kbdb
- `gmail_send` → google_gmail_sa
- `google_sheets_append` / `google_sheets_read` → google_sheets_sa
- `telegram_send` → telegram
- `line_notify_send` → line_notify
### KBDB recipe 採 Supabase 模式(richblack 2026-06-02
- **KBDB 是 richblack 提供的服務**(跟 arcrun 一樣),採「基礎免費、大量收費」。
- KBDB recipe **進 seed**(展示能力 = 引子,Supabase 模式)。使用者要用 → 去 **arcrun 取統一 API Key**(已有 /register 入口),把 key 設成 credential。
- ⚠️ **FOLLOW-UP(交 KBDB 端)**:現役 endpoint 是 `kbdb.finally.click{{_path}}`。richblack:這是 KBDB 端要改的問題——KBDB 該用統一對外網址提供大家用,不是 finally.click。**seed 先照現況進;KBDB 端改網址後同步更新 seed。** 此事不擋 init 實作。
---
## 6. 部署物產製:commit wasm 進 repo + codeload tarballrichblack 2026-06-02 定案)
> 此節**取代初稿的「GitHub release artifact」構想**。richblack 拍板更輕的做法:
> 直接把預編譯 wasm commit 進 repoCLI 從 GitHub codeload tarball 拿。不需 release.yml 機制。
### 6.1 策略
- **repo 自帶可部署的 wasm**:刪 `.gitignore``*.wasm` 排除,commit 預編譯 wasm 進 repo。
→ repo 本身就是部署來源,CLI 直接拿、用戶用自己的 CF token deploy。
- **CLI 走 codeload tarball**`https://codeload.github.com/richblack/arcrun/tar.gz/{ref}`
ref = main 或 tag)。用戶不需 git、版本可控(tag)。`acr update` 拉新 ref。
- **理由**richblack):「我在我的 CF 能用 = 我已擁有 wasm;用戶指向我的 GitHub 取得 wasm
用他自己的 CF credential deploy。開源,看不看源碼不重要,體驗好最重要。」
### 6.2 ⚠️ 推翻既有鐵律(rule 05)— 需同步改規則
`.claude/rules/05-deploy-convention.md` 明文「`.component-builds/{name}/component.wasm` **不 commit 進 repo**
build 產物)」「Phase 1-3 暫時 commit 過,**之後會加 .gitignore 清理**」。
**本決策反向**commit wasm 進 repoself-host 需 repo 自帶可部署 wasm)。
**實作時必須同步改 rule 05 + .gitignore**,否則 pre-write hook / 規則與實作打架。
→ deploy.yml 的 CI rebuild 步驟仍保留(CI 部署 prod 時用最新 source rebuild,與 commit 的 wasm 不衝突;
commit 的 wasm 是給「self-host 用戶 + acr init」用的部署來源)。
### 6.3 只 commit 部署所需的 wasm(省空間)
- 實況(2026-06-02 查):`registry/components/*.wasm` 23 個(build 中間產物)+
`.component-builds/*/component.wasm` 22 個(部署物),共 **~50MB**。
- **部署只需 `.component-builds/*/component.wasm`**wrangler deploy 認這個)。
**只 commit `.component-builds/*/component.wasm`22 個),不必 commit registry 那 23 個**(省一半)。
`.gitignore` 改成:保留排除 `registry/components/**/*.wasm`(中間產物),只放行 `.component-builds/**/component.wasm`
- ⚠️ **誠實 trade-off**mindset §7):commit wasm 進 repo → 每次 wasm rebuild 都在 git 歷史累積二進位,
**repo 長期會膨脹**。可接受(self-host 體驗優先),但記錄此代價;未來若膨脹過劇,再考慮 release artifact / git-lfs。
### 6.3.1 「錯做成零件」的 3 個不 commitrichblack 2026-06-02
實際 commit 的是 **19 個正當零件**,不是 22。排除的 3 個:`claude_api` / `km_writer` / `kbdb_upsert_block`
- **原因(richblack 修正「待刪」說法)**:它們**不是 endpoint 薄殼,是把工作流硬塞進零件**(違反 DECISIONS §1)。
例:`kbdb_upsert_block` 的 upsert 邏輯應在 KBDB API 那邊(API 提供 upsert endpoint),零件只該驅動它;
現在卻把「GET 找→有則 PATCH 無則 POST」整段工作流塞進零件。本質是工作流/recipe,被錯做成零件。
- **為何「現在就不 commit」而非「先 commit 之後刪」**commit 二進位進 git 歷史後,即使日後 `git rm`
歷史裡仍永久殘留(repo 體積已被佔),除非 rewrite history(很麻煩)。**錯誤的東西不灌進永久歷史。**
- **落地**`.gitignore` 放行 22 個後**再排除這 3 個**(後出現規則勝出);`deploy.ts discoverWorkerDirs`
只部署「同時有 wrangler.toml + component.wasm」的目錄 → self-host 用戶 codeload 拿到的目錄缺這 3 個 wasm → 自然跳過。
- **後續**:這 3 個的降級(變回工作流/recipe)是 BACKLOG 既有待辦,本次不處理,但確保它們不進 self-host 部署來源。
### 6.4 CLI deploy 流程(deploy.ts downloadAndDeploy 補實作)
```
1. 下載 codeload tarballref 預設 mainacr update 可帶 tag)→ 解壓 ~/.arcrun/.deploy-<ref>/
2. 讀解壓出的 .component-builds/* + cypher-executor/ + registry/
3. 各 wrangler.toml 注入 ctx.kvNamespaceIds + cypher-executor WORKER_SUBDOMAIN
4. tier1=.component-builds/*(先)→ tier2=cypher-executor/registry(後)
每個 dirpnpm install(若有 lock)→ CLOUDFLARE_API_TOKEN=<用戶> wrangler deploy
5. 回 cypherExecutorUrl = https://arcrun-cypher-executor.<subdomain>.workers.dev
```
注意:tier2cypher-executor/registry)是 TSwrangler deploy 會在用戶端用內建 esbuild bundle
(不需額外工具,richblack 確認源碼可見不重要、體驗優先 → artifact 含 TS 源碼即可)。
### 6.5 實作順序
1. 改 `.gitignore`(放行 `.component-builds/**/component.wasm`+ commit 22 個 wasm。
2. 同步改 rule 05(記錄此決策推翻原慣例)。
3. 補實 `deploy.ts downloadAndDeploy`codeload 下載 + 注入 + wrangler deploy)。
4. **在 1-2 完成前,downloadAndDeploy 維持誠實 unimplemented,不假裝(mindset §7)。**
### 6.6 未來方向:零件按需安裝(richblack 2026-06-02,現在不做)
- 現在 `acr init --self-hosted` **全裝基礎零件**22 個一次部署)。簡單、夠用。
- **未來若零件數量真的變很多**,再思考「按需安裝」(只裝 workflow 實際用到的零件 / 用戶選裝)。
- **現在不做的理由**(DECISIONS 附錄「會不會累積成債」):零件目前少且未來絕大多數是 recipe
(不需 deploy)→ 為「零件爆量」做按需安裝基建 = 為不存在的規模做自動化 = 過度工程。
零件真的爆量再回頭做,屆時是「未來一次性處理的設計點」,現在不必焦慮。
---
## 7. 驗收標準(客觀證據,mindset §7)
1. richblack 用**全新 CF 帳號** + wrangler 已裝 + 一個 CF API Token 跑 `acr init --self-hosted`
→ 全程無手動建 KV / 無手動 clone / 無 tinygo / 無手動填 namespace id。
2. 跑完印 secret 提示,richblack 手動 `wrangler secret put ENCRYPTION_KEY` ×3。
3. `acr push` 一個含 http_request + 自建 recipe 的 workflow → trigger → **HTTP 2xx + execution trace**
4. 冪等:重跑 init 不重建已存在 KV / 不報錯。
5. `acr update` 拉新 codeload tarballtag)→ 重部署成功。
---
## 7.5 壓測修正(2026-06-04):fork 帳號裝不起來的四個阻斷項
> 來源:`docs/壓測報告.md`(壓測者:一個「完全不懂程式」的人 + AI 自架)。
> §3/§6 的 installer 流程在**非官方 CF 帳號**上實測**無法完成部署**。根因與修法如下,全部在本 SDD 範圍內(installer 補洞 + R2 dead storage 清除),不是新架構。
### 根因
各 worker 的 `wrangler.toml`repo 內)寫死了**只有 arcrun 官方帳號才有的綁定**:
- `[[routes]] zone_name = "arcrun.dev"`(每個零件 + cypher-executor)→ fork 沒有此 zone → `wrangler deploy` 找不到 zone 失敗(阻斷 #1
- `[[r2_buckets]]` + `[ai]`cypher-executor)→ fork 未必有 R2/AI(阻斷 #2
- R2 是 dead storage,卻因 CF R2 首次啟用**強制綁信用卡** → 違背「開源免費自架」(阻斷 #3
- R2 bucket 名 `WASM_BUCKET`(大寫+底線)違反 R2 命名規則 → 證明該碼從未成功跑過(阻斷 #4
### 修法(只動 `cli/`**不刪 repo 內 worker toml**
**關鍵判準**repo 內 toml 的 `[[routes]]` 是**官方 prod CI 部署**需要的(對外開放零件),直接刪會破壞官方部署。
正解是 `deploy.ts` 在**注入暫存副本**時 strip(§3 step 4 本就「不改用戶 repo,改暫存副本」),因為 deploy.ts 只在 self-hosted 路徑跑。
| 檔案 | 改動 |
|---|---|
| `cli/src/lib/deploy.ts` | 新增 `stripOfficialOnlyBindings()`:注入時移除 `[[routes]]`/`zone_name`/`[[r2_buckets]]`/`[ai]`。worker 靠 `workers_dev=true` 對外。移除 `REQUIRED_R2_BUCKET` |
| `cli/src/commands/init.ts` | 移除 `ensureR2Bucket` 呼叫 + `wasm_bucket` config + token 提示去掉「R2 Edit」+ 結果文案改「7 KV,無需綁卡」 |
| `cli/src/lib/cf-api.ts` | 移除 `ensureR2Bucket()` 方法 |
| `cli/src/lib/config.ts` | 移除 `wasm_bucket?` 欄位 |
| `cli/src/commands/validate.ts` | 錯誤文案「WASM_BUCKET 中找不到」→「registry 中找不到零件」(去命名誤導) |
→ self-hosted 改為**只需 Workers + KV**(皆免費額度、不綁卡),回歸開源免費承諾。
### 客觀證據(mindset §7
- `stripOfficialOnlyBindings()` 對真實 `auth_static_key`/`cypher-executor` toml 實測:routes/R2/AI 全 stripname/workers_dev/kv_namespaces/vars 全保留。
- `npx tsc --noEmit` CLI 全綠;`grep R2/WASM_BUCKET src/` 零殘留。
### 報告其他項(本次未動,記錄待辦)
- **#7 init 只能互動式**readline,無 flag/env)→ AI/CI 不友善。建議支援 `--account-id`/`--api-token` 或讀 env。**待 richblack 決定**。
- **#8 無多帳號/專案 scope**config 寫死全域 `~/.arcrun/config.yaml`)→ 接案者痛點。報告建議專案層 `.arcrun.yaml` 覆蓋全域(git config 模式)。**屬新功能(change),需 richblack 確認後另開 SDD**。
- **#5/#6 權限前置驗證 + 錯誤訊息**:移除 R2 後 R2 權限問題自然消失;KV 前置一次驗證可後續優化。
## 7.6 壓測修正(2026-06-06):seed 下沉成 API 行為(推翻 §5「seed 資料放 CLI」)
> 來源:壓測報告 §4.1seed 機制對所有 self-host 用戶都壞)+ §5.5(薄殼原則)。richblack 點名「薄殼原則寫入鐵律」。
> **此節推翻 §5「種子資料放 CLI 端」+ §8「只動 cli/」的範圍**——seed 改為 API 行為(rule 07)。
### 推翻什麼
§5 原寫「種子資料放 `cli/src/lib/api-recipe-seeds.ts`(installer 職責)」。壓測證明這是 §4.1 bug 的結構根因:
seed 邏輯寫在 CLi `init.ts`(迴圈 POST + `deployFullyOk` gate),registry 一個無關 worker 失敗就連坐讓 seed 永遠被跳過;且 init 從不 seed auth recipe。
### 改法(seed = API 能力,薄殼只呼叫一次)
| 檔案 | 改動 |
|---|---|
| 新增 `cypher-executor/src/routes/init-seed.ts` | `POST /init/seed`:一次灌 API recipe + auth recipe(冪等、直寫 KV、誠實計數)|
| 新增 `cypher-executor/src/lib/api-recipe-seeds.ts` | 種子資料**唯一真相移到 server**rule 02 §2.2 hook 對 `*-seeds.ts` 整類加例外,richblack 2026-06-06 授權)|
| 刪 `cli/src/lib/api-recipe-seeds.ts` | CLI 重複檔刪除(避免漂移)|
| `cypher-executor/scripts/seed-api-recipes.ts` | import 改指 server 端;備援路徑 |
| `cli/src/commands/init.ts` | `seedApiRecipes` 迴圈 → `callSeedEndpoint` 一次呼叫;移除 deployFullyOk gate |
| `cli/src/commands/update.ts` | 重部署後也呼叫 `/init/seed`;改重解析全部 KV id |
| `cli/src/lib/deploy.ts` | `REQUIRED_KV_NAMESPACES``SUBMISSIONS_KV`(修 registry 20/21,§4.1.1|
### 對 §8「為何不違反鐵律」的修正
§8 原寫「只動 cli/ + scripts/」。現在**也動 cypher-executor/src/**(新增 `/init/seed` route + 種子資料),
但**不違反 rule 02 §2.2**:seed 端點只做「讀內嵌種子資料 → 寫 KV」的 routing/資料搬運,不解密/不簽章/不展開 template;
種子的 endpoint 字串是資料宣告(`*-seeds.ts` 類豁免),真正 API 呼叫仍走零件路徑。符合薄殼原則(rule 07)。
## 7.7 壓測修正(2026-06-06):self-hosted 身份 = 明碼 namespace(不發 api_key
> 來源:壓測報告 §7.2seed 通了,但 creds push/push/runtime 全卡「缺少 api_key」)。
> richblack 拍板:self-hosted 不需要「祕密 api_key」,因為它只需要 namespace(分區標籤),不需要認證(你的 cypher 在你自己的 CF,無「別人」會冒用)。
### 根因
credential/部署/執行三條路徑全建在多租戶 `{api_key}:cred:{name}` 模型,但 self-hosted(單租戶)init 從不發 api_key →
`creds push`/`push` 硬要 api_key 而退出;runtime webhook trigger 也要 `X-Arcrun-API-Key` header(公開表單帶不了)。
### 設計(richblack 2026-06-06
**self-hosted 的「api_key」= 明碼 namespace,用戶在 `.env` 自填。工具不生成、不 hash、不外傳。**
- 兩欄(像 username/password,但都由用戶持有):
- `NAMESPACE=leo`(明碼分區標籤,當 KV 前綴;**非密碼**)
- `ENCRYPTION_KEY=<64+ hex>`(creds 加密金鑰,用戶自保管;須與 worker secret 一致)
- **誠實限制**mindset §7):namespace 明碼不提供認證。要防外部呼叫 → 對 webhook 加保護(mindset §6);arcrun 不做授權判斷(mindset §3)。
- **零分叉**SaaS 仍走 register 發的真 api_keyself-hosted 走 .env 的 NAMESPACE。**同一條 `api_key` 路徑、同一份 config 解析**,只是值來源不同。
### 改法
| 檔案 | 改動 |
|---|---|
| `cli/src/lib/config.ts` | `ENV_MAP``NAMESPACE`/`ARCRUN_NAMESPACE`/`ENCRYPTION_KEY` → 對應 `api_key`/`encryption_key`;新增 `.env` 自動載入(就近往上找,shell > .env,無 dotenv 依賴)|
| `cli/src/commands/creds.ts``push.ts` | self-hosted 缺值時,引導設 `.env` 的 NAMESPACE/ENCRYPTION_KEY(不再叫去 register|
| `cli/src/commands/init.ts` | self-hosted 結尾改提示「在 .env 設 NAMESPACE + ENCRYPTION_KEY」+「把同把 key wrangler secret put 進 worker;不想自己跑可明示同意我代設」|
| `cypher-executor/src/routes/webhooks-named.ts` | 新增 `POST /webhooks/named/:ns/:name/trigger`namespace 走 path,公開表單免 header);與 header 路徑共用 `triggerNamed`,不分叉 |
| `cli/src/commands/push.ts` | self-hosted 輸出 path-based 公開 webhook URL(免 header|
### 為何 namespace 進 URL path 安全
namespace 是明碼標籤非密碼,放 path 不洩漏任何祕密。真正的祕密是 `ENCRYPTION_KEY`(在 worker secret + 用戶 .env,永不進 URL/path)。要防外部濫用 webhook 是「webhook 保護層」職責,非 namespace。
## 7.8 壓測修正(2026-06-08):onboarding 四缺陷 → pip 式自我修復安裝(CHANGE,待 review
> 來源:Haiku 冷啟動壓測(test_arcrun/2/test_record.md+ richblack 觀察。
> **核心判準([[haiku-capable-is-design-goal]]**:Haiku 撞牆 = 介面缺陷,不是換強模型。
> 暴露的四個缺陷都是 onboarding/AI-UX,非功能 bug。**richblack 要的是系統級正解,不是暫時解法/外掛 doctor。**
### 缺陷清單(壓測實證)
| # | 缺陷 | 根因 | Haiku 實際行為(撞牆) |
|---|------|------|------|
| D1 | **安裝不偵測環境**(缺 node/wrangler/CF登入/D1 任一就卡) | init 假設前置齊備,缺了不自己補也不引導 | Haiku 遇 D1 不存在 → **跑去讀原始碼自己想辦法建**(非技術用戶災難) |
| D2 | **帳號 scopeAI 繞過 CLI 自己 curl 全域** | config 優先序**已實作**(專案>全域,config.ts:169-193),但 AI 不用 CLI 讀帳號、自己 curl 猜 → 打到全域 | Haiku curl 全域帳號 URL,非當前 project scope |
| D3 | **MCP 掛不進不提示重啟** | 安裝完 project scope MCP 顯示無法用,沒告訴用戶「MCP 要重啟 client 才載入」 | 用戶以為壞了 |
| D4 | (壓測舊版 1.3.0 無 D1 自動建;1.3.2 已修,但 D1↑ 的「偵測+冪等」更根本) | — | — |
### 正解:安裝流程內建偵測 + 自我修復 + 冪等(pip 式)—— richblack 2026-06-08 拍板
**`acr init`(安裝流程本身)= 像 pip:先偵測環境有什麼、版本對不對,才動手;缺什麼 Arcrun 自己負責裝。
重跑會檢查後說「什麼也沒動」(冪等)。不另做 doctor——偵測是正規安裝流程的一部分。**
1. **偵測先於動作**init 先檢查各前置(node / wrangler / CF 登入 / D1 / KV / migration+ 版本 →
缺的才裝/建、有的跳過、版本不對才升。**不是假設齊備直接動手**。
2. **Arcrun 負責裝它需要的**:缺什麼是 Arcrun 的工作,不丟給 AI/用戶自己想辦法(D1 不存在 → init 自己建+套 migration
非讓 AI 讀原始碼)。需人類授權的(建 CF 資源、暴露)仍停下請示(mindset §7 非 TTY 不偽造同意)。
3. **冪等可重跑**:重跑檢查後「什麼也沒動」(像 pip install 已裝),不報錯不重建。
4. **AI 無腦入口 = 一個 GitHub 連結**:AI 讀 README/連結就照著走,**不繞、不讀原始碼、不自己 curl**。
安裝指示集中在一處,AI 跟著做即可。
### 配套介面(讓 AI 不需也不該猜)
- **`acr whoami` / `acr config show`**(D2 修法):印「當前生效帳號 + scope 來源(project/global/env)」。
AI 無腦問 CLI 拿正確帳號,**不自己 curl**。同步加 **MCP `arcrun_whoami`**(薄殼一致,rule 07 §5)。
→ 治本 D2:不是 config 沒實作(已對),是 AI 該用工具讀、不該繞 CLI 猜帳號。
- **MCP 掛載後提示重啟**D3 修法):`acr init` / `acr mcp-setup` 寫完 `.mcp.json` 後,
明確印「⚠️ MCP 已設定,**請重啟 IDE/client 才會載入** project scope MCP」。掛不進時引導重啟,不讓用戶以為壞了。
### 範圍 / 分期(richblack 2026-06-09 授權 P0-P2 執行)
- [x] **P0**`acr init` 偵測 + 驗收(pip 式)。新增 `cli/src/lib/preflight.ts`
- `detectEnvironment()` 安裝前偵測 node/wrangler(缺=fatal 停下給補救指令,不假設齊備直接動手);
- `verifyInstall()` 裝完實查 CFKV/D1 listKv/listD1+ 打 cypher `/health` 確認真就緒,缺哪項報哪項 + 一鍵補裝(acr update 冪等)。
- `initSelfHosted` 開頭接 detectfatal 即 exit 1)、結尾接 verify(未就緒印補裝指引)。冪等沿用 ensureKv/ensureD1。cli tsc exit 0。**2026-06-09 完成**
- [x] **P1**`acr whoami``cli/src/commands/whoami.ts`,人讀 + `--json`,印 mode/帳號/連哪台/來源層,薄殼讀 resolveConfigSources+ MCP `arcrun_whoami``mcp/src/tools/arcrun_whoami.ts`,回報 orgNamespace + binding,與 CLI 對齊)。AI 問工具拿身份,不自己 curl 猜。cli+mcp tsc exit 0。**2026-06-09 完成**
- [x] **P2**MCP 掛載重啟提示(D3)。`cli/src/commands/mcp-setup.ts` 寫完 `.mcp.json` 後印「⚠ 請重啟 IDE/client 才會載入 project scope MCP」+ 信任工作區提醒。經 init 也會流到(init 呼叫 cmdMcpSetup)。**2026-06-09 完成**
- [~] **P3**README/GitHub 入口整理成「AI 讀了就照著裝」的單一指引(D 無腦入口)。**部分完成 2026-06-09**
- 新增 repo 根 `.env.example` 範本(CF Account/Token 基礎兩格 + NAMESPACE/ENCRYPTION_KEY + 服務 token 區,
每格上面白話說明「去哪申請、怎麼拿」,值留空。`.gitignore``!.env.example` 放行進 repo)。
- `llms.txt` step 3 改成「**你(AI)先 `cp .env.example .env`**,帶用戶填值(用戶只填「=」右邊)」——
把「建 .env 結構」從用戶身上挪到 AI,用戶只做貼值。step 4 補 init「安裝驗收」說明。
- 仍待:`arcrun.dev/llms.txt` 沒 servelanding/public 缺檔,404GitHub repo 內 llms.txt 正常)。
test/5 prompt 給 GitHub URL 故不阻擋;arcrun.dev serve 另排。
> **誠實**:偵測各前置 + 版本比對 + 冪等重跑工程量不小(跨 CLI 多步)。但「裝很久 + AI 一直繞」對非技術用戶
> 是致命體驗 → 這是 onboarding 必修不是優化。
>
> **2026-06-09 補(test_arcrun/4 壓測根因)**:D1/D2/D3 之上還有更致命的一層——壓測時 AI **整個沒跑 `acr init`**
> settings.local.json 沒 allow init),導致 D1/harness/MCP/slash-command 全沒裝(全掛在 cmdInit 內)。
> P0 的「偵測+驗收」讓「跑了 init 但環境半殘」能被看見並自癒;但「**根本沒跑 init**」要靠 P3README/入口
> 引導 AI「第一件事就是 acr init」)+ harness 的 mindset/CLAUDE.md 提醒。P3 後續補。
---
## 8. 為何不違反鐵律
- 只動 `cli/` + 新增 `cypher-executor/scripts/`(seed 腳本,非執行路徑業務邏輯)。
- 不在 `registry/components/` 寫 TS;不在 cypher-executor TS 實作 credential/auth/JWT。
- 不新增 Service Binding。
- secret 不進自動化(§3 step 8 手動)。
- 不重寫部署輪子(用 wrangler,不自寫 CF Script Upload)。
@@ -0,0 +1,170 @@
# Implementation Plan: arcrun SDK Libraries + Website
## Overview
按 Design 的四個 Phase 實作。原則:修改不重建,SDK 是 HTTP API thin wrapper,加密只在 client 做 encrypt(不做 decrypt)。
**前置依賴**:必須先完成 `credential-primitives-wasm/tasks.md` 的 Phase 0-3(核心合併 + WASM primitives),確認核心穩定後才開始建三個介面。
---
## Phase 0(前置):核心合併 + WASM 改寫
> 詳見 `.agents/specs/arcrun/credential-primitives-wasm/tasks.md`
>
> 摘要:
> - 合併 u6u-core → arcrun(搬 builtins、刪重複 credentials
> - credential-injector TS → auth_static_key / auth_service_account WASM
> - 刪除 component-loader 內建 API recipes TS
> - 驗證 20 個 auth recipe 正常運作
---
## Phase 1Python SDK
- [ ] 1. 建立 `arcrun/python-sdk/` 目錄
- [ ] 1.1 `pyproject.toml`name=arcrun, deps=[httpx>=0.27, cryptography>=42], build-system=hatchling
- [ ] 1.2 `arcrun/__init__.py``from .client import Arcrun`
- [ ] 1.3 `arcrun/crypto.py`AES-GCM encrypt only(使用 `cryptography` 套件)
- [ ] 1.4 `arcrun/creds.py`CredentialsClient — push(加密 + POST /credentials)、listGET /credentials)、delete
- [ ] 1.5 `arcrun/auth.py`AuthClient — setupfetch recipe → match secrets → encrypt → push)、bindfetch recipe → resolve headers from cache → return AuthenticatedClient)、get_token、list_services
- [ ] 1.6 `arcrun/workflows.py`WorkflowClient — runPOST /webhooks/named/{name}/trigger)、pushPOST /webhooks/named)、listGET /webhooks/named)、delete
- [ ] 1.7 `arcrun/client.py`Arcrun class — 讀 api_key / encryption_key 從 param > env > config.yaml
- [ ] 2. 修正上次已知的 bug
- [ ] 2.1 `_fetch_recipe()` 回應是 `{ success: true, recipe: {...} }`,需讀 `.recipe` 欄位
- [ ] 2.2 `inject` 下的 key 是 `header`singular),不是 `headers`
- [ ] 2.3 `required_secrets[].key` 是 prefixed(如 `openai_api_key`),setup() 的 kwargs alias 要能對應
- [ ] 2.4 `list_services()` 回應的 recipe 用 `service` 欄位(不是 `service_id`
- [ ] 3. 測試(對 cypher.arcrun.dev live API
- [ ] 3.1 `health()``{"ok": true}`
- [ ] 3.2 `auth.list_services()` → 20 個服務
- [ ] 3.3 `auth.setup("openai", api_key="sk-test-dummy")` → 成功
- [ ] 3.4 `auth.bind("openai")` → AuthenticatedClient with Authorization header
- [ ] 3.5 `auth.get_token("openai")` → "sk-test-dummy"
- [ ] 3.6 `creds.push("test_token", "value123")` → 成功
- [ ] 3.7 `creds.list()` → 含 "test_token"(注意 KV eventual consistency
- [ ] 3.8 `workflows.list()` → []
- [ ] 3.9 cleanup: `creds.delete("test_token")`
---
## Phase 2JS/TS SDK
- [ ] 4. 建立 `arcrun/js-sdk/` 目錄
- [ ] 4.1 `package.json`name TBDarcrun vs @arcrun/sdk),deps=devDeps onlytsup, typescript, @types/node
- [ ] 4.2 `tsconfig.json`ES2020, NodeNext
- [ ] 4.3 `src/crypto.ts`Web Crypto API AES-GCM encrypt only
- [ ] 4.4 `src/creds.ts`CredentialsClient — push/list/delete via fetch
- [ ] 4.5 `src/auth.ts`AuthClient — setup/bind/getToken/listServices
- [ ] 4.6 `src/workflows.ts`WorkflowClient — run/push/list/delete
- [ ] 4.7 `src/index.ts`export class Arcrun + re-exports
- [ ] 5. 同步修正(與 Python SDK 同樣的 recipe 格式問題)
- [ ] 5.1 `_fetchRecipe()``body.recipe`
- [ ] 5.2 inject key: `header` not `headers`
- [ ] 5.3 setup() secret key alias matching
- [ ] 5.4 listServices() 用 `service` 欄位
- [ ] 6. Build + 測試
- [ ] 6.1 `tsup` build → dist/index.js + dist/index.cjs + dist/index.d.ts
- [ ] 6.2 Node.js 腳本對 live API 測試(同 Python 測試項目)
---
## Phase 3arcrun.dev 網站
- [ ] 7. 新增 `/components` 頁面
- [ ] 7.1 從 `registry/components/*/component.contract.yaml` 讀取 21 個零件資料
- [ ] 7.2 卡片顯示:canonical_id, display_name, description, input required/optional, credentials_required, config_example
- [ ] 7.3 分類篩選:邏輯類 / API 類 / 控制流類
- [ ] 8. 更新首頁
- [ ] 8.1 Code demo tabs 改為 CLI / Python / JS 三個
- [ ] 8.2 CLI tab 展示 `acr init → acr push → acr run`
- [ ] 8.3 Python tab 展示 `pip install arcrun → Arcrun() → auth.setup → auth.bind`
- [ ] 8.4 JS tab 展示 `npm install arcrun → new Arcrun() → auth.setup → auth.bind`
- [ ] 9. OAuth 流程補完
- [ ] 9.1 確認 cypher-executor 的 `/auth/google/start``/auth/github/start``/auth/callback` 路由正確
- [ ] 9.2 提供 richblack OAuth secrets 設定指令清單
- [ ] 9.3 richblack 設定 secrets 後驗證登入流程
- [ ] 10. 部署
- [ ] 10.1 Cloudflare Pages build + deploy
- [ ] 10.2 驗證所有頁面可存取
---
## Phase 4README + 發布
- [ ] 11. 更新 `arcrun/README.md`
- [ ] 11.1 三種 Quick StartCLI / Python / JS
- [ ] 11.2 零件列表(21 個)
- [ ] 11.3 Auth Recipe 列表(20 個服務)
- [ ] 11.4 連結到 arcrun.dev 和 Swagger UI
- [ ] 12. 發布
- [ ] 12.1 Python SDK`pip install build && python -m build && twine upload dist/*`
- [ ] 12.2 JS SDK`npm run build && npm publish`
- [ ] 12.3 驗證:從零開始 `pip install arcrun` / `npm install arcrun` + hello world
---
## Phase 5acr init --self-hosted installer2026-06-02 新增)
> 定稿 design`self-hosted-init.md`。CLI = installer:建 KV/R2 + 拉預編譯 wasm + wrangler deploy + seed。
> 用戶只做:申請 CF 帳號 → 裝 wrangler → 裝 acr → acr init --self-hosted。其餘自動。
> 背景:戰法轉 self-hosted 開源(docs/HANDOFF-self-host-harness.md)。
- [x] 13.1 API recipe 種子 — **位置修正**:種子資料放 `cli/src/lib/api-recipe-seeds.ts`installer 用,避開 cypher §2.2 hook),seed 腳本 `cypher-executor/scripts/seed-api-recipes.ts`import 種子,給 prod 補灌)。10 個現役 recipekbdb_*/gmail_send/google_sheets_*/telegram_send/line_notify_send)。KBDB Supabase 模式進 seedfinally.click 是 KBDB 端 follow-up,已註於 api-recipe-seeds.ts
- [x] 13.2 `cli/src/lib/cf-api.ts` 新增 `CfAccountClient`verifyAccess / listKvNamespaces / ensureKvNamespace(冪等)/ ensureR2Bucket(冪等)/ getWorkersSubdomain
- [x] 13.3 `cli/src/commands/init.ts` `initSelfHosted()` 改寫:驗 token → 建 7 KV + R2 → 查 subdomain → downloadAndDeploy → 寫 config → seed(部署完成時)→ 印 secret 提示。誠實:部署未自動化時明說,不假綠
- [x] 13.4 `cli/src/lib/deploy.ts`REQUIRED_KV/R2/SECRET 常數 + wranglerAvailable() + **downloadAndDeploy 已補實**codeload tarball 下載 + 解壓 + discoverWorkerDirs 分 tier + injectWranglerConfig 注入 KV id/subdomain + runWranglerDeploy;部分失敗誠實收集回報,不假綠)
- [x] 13.5 `cli/src/commands/update.ts` + index.ts 註冊 `acr update`self-hosted 重部署,同走 downloadAndDeploy
- [x] 13.6 部署物產製:**改用 commit wasm 進 repo + codeload**(取代 release artifactrichblack 2026-06-02,§6
- `.gitignore` 否定規則放行 `.component-builds/**/component.wasm`(registry 中間產物仍排除)→ 已驗 git check-ignore
- rule 05 同步改(記錄推翻「wasm 不 commit」+ trade-off
- commit 22 個 `.component-builds/*/component.wasm` 進 repo
- [ ] 13.7 驗收:全新 CF 帳號跑 acr init --self-hosted 全自動;acr push workflow → trigger 2xx + trace**待 richblack 用第二帳號實測** + push 含 wasm 的 commit 到 GitHub 後 codeload 才拿得到)
- [x] 13.8 typecheckcli `tsc --noEmit` exit 0
## Phase 6:壓測四橫向問題修正(2026-06-06richblack 點名)
> 來源:`/Users/youlinhsieh/Documents/tech_projects/test_arcrun/docs/壓測報告.md`
> 四個結構性問題(非個別 bug):薄殼原則未成鐵律、CLI/MCP 不同步打不同帳號、deploy 未全推、表達不清。
- [x] 6.1 **薄殼鐵律成文 + hook 強制**:新增 `.claude/rules/07-thin-shell.md`(能力長在 API,介面只暴露);
`02-forbidden.md` 第五類 + `CLAUDE.md` 鐵律 8 + 索引;`pre-write-guard.sh` 規則 7.x
(擋 cli/src + arcrun-mcp/src 的 seedApiRecipes/seedAuthRecipes、upsert 拼裝、deployFullyOk gate)。
- [x] 6.2 **seed 下沉成 API 行為**(薄殼正例 + 修 §4.1 seed bug):
- 新增 `cypher-executor/src/routes/init-seed.ts``POST /init/seed` 一次灌 API recipe + auth recipe(冪等、直寫 KV、誠實計數),mount 進 index.ts
- 種子資料移到 server`cypher-executor/src/lib/api-recipe-seeds.ts`(唯一真相),刪 CLI 重複 `cli/src/lib/api-recipe-seeds.ts``seed-api-recipes.ts` 改 import server 端
- CLI `init.ts``seedApiRecipes` 迴圈 → `callSeedEndpoint` 薄殼一次呼叫;**移除 deployFullyOk gate**registry 失敗不再連坐害 seed 被跳過,§4.1 根因);auth recipe 現在一併 seed(§4.1.2 修)
- CLI `update.ts`:重部署後也呼叫 `/init/seed`(修 §4.1.3「update 不 seed 卻提示說會 seed」矛盾)+ 改為重解析「全部」KV id(修「只注入 2 個」風險)
- [x] 6.3 **hook seed 例外通用化**`*-seeds.ts` 整類豁免 endpoint/template 檢查(richblack 原則:不為單一零件改全域規則),未來新種子檔自動適用,永不再動 hook。
- [x] 6.4 **registry SUBMISSIONS_KV**(修 §2.6/#11「20/21」):加進 `REQUIRED_KV_NAMESPACES` → init/update 會建 + 注入 → registry 部署回 21/21 → 連帶 seed 不再被連坐(§4.1.1)。
- [x] 6.5 **Deploy 一致性(Gherkin + 把 npm publish 補進現行機制)**:誠實前提——GH Actions 2026-05-16 停用、公開 repo `.github/` 已移除,現行 deploy 走 `scripts/local-deploy.sh`。故:
- `tests/release.feature`(每個 publish target 場景,描述「該到位什麼」)
- `scripts/check-release.sh`(一眼看版本/部署狀態,已驗證抓出 CLI 1.2.0≠npm 1.1.0 漂移)
- **`scripts/local-deploy.sh` 第 6 段新增 CLI npm publish**cli/ 變動且 version bump → npm publish;同版跳過、未登入誠實標)。這是真正讓「推送=全部到位」的修法,根因是舊腳本只 wrangler deploy worker 從不 publish CLI。
- `.github/workflows/publish-cli.yml` 為預備檔(gitignored、不跑),日後重啟 GH Actions 可啟用。
- [x] 6.6 **表達不清 → README 同步現況**:移除 R2 殘留文案(權限表去掉 R2、init 輸出去掉 WASM_BUCKET、「無需綁卡」)、補 `--account-id/--api-token`/env 非互動、補多帳號 `.arcrun.yaml` + `acr config --where`
- [x] 6.7 客觀驗證:cypher-executor + cli 兩端 `tsc --noEmit` exit 0hook 7.x + *-seeds 例外 + 非 seed 仍擋 全部回歸測試通過。
- [x] 6.8 **MCP 帳號架構**richblack 2026-06-06 拍板「單一 remote MCP + .env 切 MCP URL」,推翻初版三方案):
- SDD `mcp-account-source.md` 重寫為實作版。**不加 stdio**self-hosted 用戶也有 CFMCP 一律 remote Worker;差別只在連哪台)。
- 共用 config 解析:`config.ts``mcp_url` 三層(env `ARCRUN_MCP_URL` > 專案 `.arcrun.yaml` > 全域)+ `getMcpUrl()` + `DEFAULT_MCP_URL`。「切換帳號」能力收斂到 config 解析(薄殼正解),不再綁 CLI。
- **MCP 搬進 `arcrun/mcp/`**(從 sibling repo verbatim 搬;wrangler name→`arcrun-mcp`+`workers_dev=true`;形態不變仍 remote Worker)。deploy 掃描已掃到、tsc 綠。
- **新增 `acr mcp-setup`**:依 `getMcpUrl()` 寫專案 `.mcp.json`remote http MCP)。init 順帶呼叫。接案進客戶資料夾跑一次 → Claude Code 連客戶 MCP。**實測 3 情境(fallback/專案覆蓋/env+ merge 保留既有 server 全通過**。
- `acr --version` 改從 package.json 動態讀(不再 hardcode,根治版本漂移)。
- [x] 6.10 **CLI 版本自動化**richblackdeploy 時自動昇版 + 留記錄,避免忘了改):`local-deploy.sh` deploy CLI 時若版本未 bump → 自動 `npm version patch` + prepend `cli/CHANGELOG.md`(含 commit subject)。已 bump 到 1.3.0 + 建 CHANGELOG.md。
- [ ] 6.9 驗收:richblack 跑 `scripts/local-deploy.sh`worker + MCP + CLI npm 一起);壓測者重跑 `acr init` → recipe/auth-recipe 不再空 + `.mcp.json` 自動產 → 表單→Google Sheets 端到端。
- [ ] 6.11 待 richblack 確認:MCP 搬進主庫後**對外正式網址**(現役 `studio.finally.click/mcp``DEFAULT_MCP_URL` 暫用此);sibling repo `matrix/arcrun-mcp` 去留(建議標歷史、新開發在 arcrun/mcp/)。
## Notes
- JS SDK 套件名需 richblack 決定(`arcrun` 已被 CLI 佔用 → 可能用 `@arcrun/sdk`
- OAuth secrets 設定需 richblack 手動操作(GCP Console + GitHub Settings
- `bind()` 跨 session 限制是已知的,封測期間先接受
- credential 加密用的 `encryption_key` 目前由 `/register` 回傳,`acr init` 自動存入 config