---
status: paused
superseded_by: ""
---

# Auth Recipe System — SDD

> 文件類型：SDD（Software 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 的 secret（2026-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_account（Google 家族）

```
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 共用一把 auth，2026-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 KV（2026-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 publish（arcrun@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-06，richblack）

每個 `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_url（23/23 實測通過）。
- API recipe（`POST /recipes`）不帶 secrets（secrets 在 auth recipe），故 help_url 要求在 auth recipe 這層。

**誠實限制**：help_url 只保證「有指向官方文件」，不保證連結永遠有效（provider 可能改網址）。
這是「降低 AI 搜錯」的保險，非絕對正確；連結失效時 AI 仍可 fallback 去搜（但有起點）。
