# Credential Store 重設計提案（A telegram 一致性 + B KV→D1 + C 友善前門）

> ⚠️ **歷史記錄**：本檔的儲存決策已被 `credential-store-migration.md` 取代並執行完畢
> （2026-07-20）。文中提及的舊自管金鑰機制**已不存在**，僅供考古，勿依此操作。

> 建立：2026-06-29 by arcrun CC｜更新：2026-06-29（依 leo 最終精確 spec 改寫）｜對應 issue：Arcrun#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 / gsheets（http_request + 固定設定）是打 API 的端點，**不刪**。改的是「credential 怎麼餵」，不是 recipe。

---

## 0.5 Q1 — list 是動態讀 store 還是 hardcoded？（已核實 code，2026-06-29）

**結論：recipe/auth-recipe 的「正規清單」是動態的；但 `acr parts` 是 hardcoded 且把 5 個 recipe 混進去 → 那 5 個之外的 pushed recipe 在 `acr parts` 看不到。**

| 指令 | 讀哪裡 | 動態? | pushed recipe 會出現? |
|---|---|---|---|
| `acr recipe push` | `POST /recipes` → KV store（recipe.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（零件＝WASM）hardcoded 是對的**：零件只能 PR merge 新增（mindset §4 人類閘門），固定、慢增，靜態清單反映真實。
- **但 `acr parts` 把 5 個 recipe 也 hardcode 進去**（parts.ts:197-261：gmail_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…＝WASM，PR-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 + 實測 live，2026-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」長久存 → **此遷移從未完成**。
  → B（KV→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` 補 telegram（inject.path bot_token）/ line_notify（header Bearer）/ kbdb（header Bearer），形態取自 prod。
- [x] `recipes.ts` `AuthInjectSpec` 補 `path?`（WASM/SDD §六早有、source 介面漏 → tsc 擋）。
- [ ] google_user（oauth2）暫不回灌：內嵌 client_secret 不可進 git + 介面無 oauth2 欄 → 留 Phase D。
- 部署後 leo21c 跑 `acr update`(/init/seed) → 23→26，telegram 即與 notion 同鏈可用。
- **不刪任何 recipe；不碰 `acr creds push`。** 純補種子 + 補型別欄位。

### Phase B —（已翻案，見 §4.5）儲存：**KV 維持，不搬 D1**；高敏感可選補 Secrets Store
> 早先版本寫「KV→D1 遷移」。§4.5 核實史實 + leo 自己 2026-06-07 拍板（credential 留 KV）後**翻案**：
> credential＝Redis-class（按 key 直接點查、密文不可 SQL 查），搬 D1 是淨負債。**B 改為：**
1. **低敏感 credential 維持 KV**（現狀 CREDENTIALS_KV，AES-GCM）。`acr creds list` 已可用（`GET /credentials` KV.list）。
2. **（可選、後續）高敏感（service account JSON/private key）→ Cloudflare Secrets Store**（credential_parts.md §8.2，KV 只存 ref）。唯一值得的儲存升級，與拍板不衝突。
3. **不新增 D1 credentials 表、不新增 `cred_get` host function、不做 KV→D1 migration**——這些原計畫取消（好處 KV 已有）。
4. 若 leo 仍要 D1：屬新需求（須更新 2026-06-07 拍板）。即便如此，密文本體仍留 KV/Secrets Store，D1 只放非機密 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 端加密 → 存進 D1（Phase 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}`。

這條鏈與 notion（header 注入）/gsheets（service_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` 只有 KBDB（entries/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_KV，MVP 至今未變） |
| **kbdb-base SDD 拍板 2026-06-07（leo 親簽「Leo：同意」）** | **credential 留 KV**（「session、verdict、credential 留 KV＝短期高頻純取用，類比 Redis；workflow/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 Store，KV 只存 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-off，mindset §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.2，KV 只存 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` 輸入源，非拆除。
- 不新建 component（mindset §1 / rule 02）。
- credential 解密仍在 WASM（rule 02 §2.2）；KV→D1 只是密文來源從 `kv_get` 換成新 host function `cred_get`（屬 wasi-shim，rule 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 長期規格）。
