From c24edbbdb6875ae3c1748742eb1ba414cd1e2419 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 23:04:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(credential-store-migration):=20T5=20?= =?UTF-8?q?=E5=AF=AB=E5=85=A5=E8=B7=AF=E5=BE=91=E6=94=B9=E8=B5=B0=20Worker?= =?UTF-8?q?s=20Secrets=20+=20D1=20ref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST/PUT /credentials 改寫:密文值 PUT 進 CF Workers per-script Secrets (唯寫,D19 不持有內容物),D1 credentials 表只存目錄(不含密文)。 secret_ref = CRED__,避免跨租戶撞名。 DELETE/GET /credentials 本次不動(仍走舊 KV,T9 範圍),已誠實標注。 新增 CREDENTIALS_DB D1 binding(與 KBDB base 共用同一顆 arcrun-kbdb, 沿用既有 database_id 注入機制)+ CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID env vars(CF_ACCOUNT_ID 由 deploy.ts 自動注入,同 WORKER_SUBDOMAIN 模式)。 §2.4 選項甲落地:client 不再加密,明文值經 TLS 傳輸,cypher 短暫經手 明文不落地不持金鑰——與舊版 01-tech-stack.md 傳輸格式不同,是本 SDD 對舊格式的刻意取代(§6 Q-b 仍需 leo 明確接受)。 驗證:tsc --noEmit exit 0;vitest 26/27(1 pre-existing 無關失敗)。 端到端:真實部署 leo21c arcrun-cypher-executor,POST/PUT /credentials 成功寫入 Workers Secrets + D1 row(CF API + D1 query 雙重確認), 清理測試資料後 restore wrangler.toml(無帳號專屬 id 殘留 git)。 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018D6QoC5waFkcjc2N7csJBB --- cli/src/lib/deploy.ts | 10 + cypher-executor/src/lib/hash.ts | 3 + cypher-executor/src/routes/credentials.ts | 203 +++++++++++++++--- cypher-executor/src/types.ts | 15 +- cypher-executor/wrangler.toml | 15 ++ .../credential-store-migration.md | 30 ++- 6 files changed, 243 insertions(+), 33 deletions(-) diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index e771878..da4dbba 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -462,6 +462,16 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void { ); } + // credential-store-migration T3:CF_ACCOUNT_ID(vars,非機密識別碼)換成用戶自己的帳號 id, + // 比照 WORKER_SUBDOMAIN 注入同一套機制。cypher 寫入 /credentials 時要用它組 + // CF Workers Scripts secrets 管理 API URL(見 routes/credentials.ts)。 + if (ctx.accountId && /CF_ACCOUNT_ID/.test(toml)) { + toml = toml.replace( + /(CF_ACCOUNT_ID\s*=\s*")[^"]*(")/, + `$1${ctx.accountId}$2`, + ); + } + // KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml) if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) { toml = toml.replace( diff --git a/cypher-executor/src/lib/hash.ts b/cypher-executor/src/lib/hash.ts index f13b0b3..43cd65a 100644 --- a/cypher-executor/src/lib/hash.ts +++ b/cypher-executor/src/lib/hash.ts @@ -24,6 +24,9 @@ export function isRecipeHash(id: string): boolean { return /^rec_[0-9a-f]{8}$/.test(id); } +/** 匯出給 credential-store-migration T5 用(secret_ref 命名衍生租戶隔離 hash,非解密/簽章邏輯)。 */ +export { sha256Prefix }; + async function sha256Prefix(input: string): Promise { const data = new TextEncoder().encode(input); const buf = await crypto.subtle.digest('SHA-256', data); diff --git a/cypher-executor/src/routes/credentials.ts b/cypher-executor/src/routes/credentials.ts index e796c34..4219b6c 100644 --- a/cypher-executor/src/routes/credentials.ts +++ b/cypher-executor/src/routes/credentials.ts @@ -1,56 +1,197 @@ /** * Credentials API — 多租戶 credential 管理 * - * POST /credentials - * Body: { name: string, encrypted: string, iv: string } - * Header: X-Arcrun-API-Key - * → 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV + * credential-store-migration T5(D19「擁有目錄,不擁有內容物」, + * system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md §2.3-2.4/§3): * - * DELETE /credentials/:name - * Header: X-Arcrun-API-Key - * → 刪除 {api_key}:cred:{name} + * 新寫入(POST 建立 / PUT 覆寫)一律走新家: + * 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫, + * arcrun 自己也讀不回值——D19「不持有內容物」)。 + * 2. D1 `credentials` 表只寫「目錄」(api_key/name/service/sensitivity/secret_ref/ + * created_at),**不含密文**。 + * 不再寫 KV / 不再寫明文密文到 D1。 * - * GET /credentials - * Header: X-Arcrun-API-Key - * → 列出當前 api_key 下所有 credential 名稱(不含加密值) + * §2.4 選項甲(傾向):client 不再 AES-GCM 加密,明文值經 TLS 送到 cypher,cypher 短暫在記憶體 + * 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。這與舊版 + * `.claude/rules/01-tech-stack.md` 記載的「傳輸格式 {name, encrypted, iv}」不同——是本 SDD + * (2026-07-03 T1.5 spike 定案)對舊格式的刻意取代,SDD §6 Q-b 仍列為需 leo 明確接受的 + * 誠實 trade-off(本次實作先落地,若 leo 不接受選項甲需回頭改)。 + * + * DELETE /credentials/:name 與 GET /credentials 本次**不動**(T9 治理端點的範圍), + * 仍讀寫舊 KV 路徑——這代表新寫入的 credential 目前查不到舊 GET /credentials 列表裡 + * (誠實缺口,見 credential-store-migration.md T5 完成註記;驗證改用直接查 D1)。 */ import { Hono } from 'hono'; import type { Bindings } from '../types'; +import { sha256Prefix } from '../lib/hash'; export const credentialsRouter = new Hono<{ Bindings: Bindings }>(); -// POST /credentials — 上傳加密 credential +/** 本 worker 的 script name(wrangler.toml `name`),官方與 self-hosted 都用同一個名字, + * 只有帳號(CF_ACCOUNT_ID)不同——CF Workers Scripts secrets API 是 accountId+scriptName 定位。*/ +const CYPHER_SCRIPT_NAME = 'arcrun-cypher-executor'; + +/** + * secret_ref 命名規則(credential-store-migration §2.3「以 CRED_ 前綴隔離命名空間」): + * CRED__ + * 加 api_key 的 hash 是為了避免跨租戶同名 credential(如兩個用戶都存 telegram_bot_token) + * 撞名覆蓋彼此的 Workers Secret(secret 是掛在同一個 worker 上、全域命名空間,沒有租戶 + * 隔離機制,必須自己用命名衍生隔離)。name 先前已被 validateName() 限制為 \w+, + * 大寫後仍是合法的 env var 名(CF secret name 只接受 [A-Za-z0-9_])。 + */ +async function deriveSecretRef(apiKey: string, name: string): Promise { + const hash8 = await sha256Prefix(apiKey); + return `CRED_${name.toUpperCase()}_${hash8.toUpperCase()}`; +} + +function validateName(name: unknown): name is string { + return typeof name === 'string' && /^\w+$/.test(name); +} + +function validSensitivity(s: unknown): s is 'standard' | 'high' { + return s === 'standard' || s === 'high'; +} + +/** + * 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。 + * 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。 + */ +async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise { + if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) { + throw new Error( + '此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' + + 'credential-store-migration.md T3:acr init/update 應確保這兩項就緒)', + ); + } + const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets`; + const res = await fetch(url, { + method: 'PUT', + headers: { + Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: secretRef, text: value, type: 'secret_text' }), + }); + const body = (await res.json().catch(() => null)) as + | { success?: boolean; errors?: Array<{ message?: string }> } + | null; + if (!res.ok || !body?.success) { + const detail = body?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`; + throw new Error(`CF Workers Secrets 寫入失敗:${detail}`); + } +} + +/** + * D1 upsert credential 目錄 row(不含密文)。 + * created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新 + * service/sensitivity/secret_ref(secret_ref 是純函式衍生自 api_key+name,理論上覆寫時 + * 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。 + */ +async function upsertCredentialRow( + db: D1Database, + apiKey: string, + name: string, + service: string | null, + sensitivity: 'standard' | 'high', + secretRef: string, +): Promise { + const now = Math.floor(Date.now() / 1000); + await db + .prepare( + `INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, NULL) + ON CONFLICT(api_key, name) DO UPDATE SET + service = excluded.service, + sensitivity = excluded.sensitivity, + secret_ref = excluded.secret_ref`, + ) + .bind(apiKey, name, service, sensitivity, secretRef, now) + .run(); +} + +interface CredentialWriteBody { + name?: string; + value?: string; + service?: string; + sensitivity?: string; +} + +/** POST 建立 / PUT 覆寫共用的寫入邏輯。回傳 { secretRef } 供 route handler 組回應。 */ +async function writeCredential( + env: Bindings, + apiKey: string, + name: string, + value: string, + service: string | undefined, + sensitivityRaw: string | undefined, +): Promise<{ secretRef: string; sensitivity: 'standard' | 'high' }> { + const sensitivity = validSensitivity(sensitivityRaw) ? sensitivityRaw : 'standard'; + const secretRef = await deriveSecretRef(apiKey, name); + + // 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回) + await putWorkerSecret(env, secretRef, value); + + // 2. D1 目錄(不含密文) + await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef); + + return { secretRef, sensitivity }; +} + +// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄) credentialsRouter.post('/credentials', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } - const body = await c.req.json().catch(() => null) as { - name?: string; - encrypted?: string; - iv?: string; - } | null; - - if (!body?.name || !body.encrypted || !body.iv) { - return c.json({ error: '缺少必要欄位:name, encrypted, iv' }, 400); + const body = (await c.req.json().catch(() => null)) as CredentialWriteBody | null; + if (!validateName(body?.name)) { + return c.json({ error: 'name 必填,只能包含英文字母、數字和底線' }, 400); + } + if (!body?.value || typeof body.value !== 'string') { + return c.json({ error: 'value 必填(credential 明文值,經 TLS 傳輸)' }, 400); } - const name = body.name.trim(); - if (!/^\w+$/.test(name)) { - return c.json({ error: 'credential name 只能包含英文字母、數字和底線' }, 400); + try { + const { secretRef, sensitivity } = await writeCredential( + c.env, apiKey, body.name, body.value, body.service, body.sensitivity, + ); + return c.json({ success: true, name: body.name, service: body.service ?? null, sensitivity, secret_ref: secretRef }); + } catch (e) { + // 誠實回報:寫入失敗(缺 token 設定 / CF API 錯誤)不假綠(mindset §7) + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); } - - const kvKey = `${apiKey}:cred:${name}`; - const record = JSON.stringify({ encrypted: body.encrypted, iv: body.iv }); - - await c.env.CREDENTIALS_KV.put(kvKey, record); - - return c.json({ success: true, name }); }); -// DELETE /credentials/:name — 刪除 credential +// PUT /credentials/:name — 整筆覆寫(credential-store-migration §3:只能 replace,不能 edit 局部) +credentialsRouter.put('/credentials/:name', async (c) => { + const apiKey = c.req.header('X-Arcrun-API-Key'); + if (!apiKey) { + return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); + } + + const name = c.req.param('name'); + if (!validateName(name)) { + return c.json({ error: 'name 只能包含英文字母、數字和底線' }, 400); + } + + const body = (await c.req.json().catch(() => null)) as CredentialWriteBody | null; + if (!body?.value || typeof body.value !== 'string') { + return c.json({ error: 'value 必填(credential 明文值,經 TLS 傳輸)' }, 400); + } + + try { + const { secretRef, sensitivity } = await writeCredential( + c.env, apiKey, name, body.value, body.service, body.sensitivity, + ); + return c.json({ success: true, name, service: body.service ?? null, sensitivity, secret_ref: secretRef }); + } catch (e) { + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); + } +}); + +// DELETE /credentials/:name — 刪除 credential(未動:仍是舊 KV 路徑,T9 範圍) credentialsRouter.delete('/credentials/:name', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { @@ -64,7 +205,7 @@ credentialsRouter.delete('/credentials/:name', async (c) => { return c.json({ success: true, name }); }); -// GET /credentials — 列出 credential 名稱(不含值) +// GET /credentials — 列出 credential 名稱(不含值)(未動:仍是舊 KV 路徑,T9 範圍) credentialsRouter.get('/credentials', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 0b5ee7c..057b627 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -27,8 +27,15 @@ export type Bindings = { RECIPES: KVNamespace; // Webhook Store:key = workflow name,value = Workflow JSON WEBHOOKS: KVNamespace; - // Credential Store:AES-GCM 加密存放用戶 API token + // Credential Store:AES-GCM 加密存放用戶 API token(舊家;credential-store-migration T7 + // 雙讀過渡期間仍是 fallback 讀路徑,本次 T5 只改「新寫入」,不動這裡) CREDENTIALS_KV: KVNamespace; + // credential-store-migration T2/T5(D19「擁有目錄,不擁有內容物」):credential 目錄表 + // (api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,不含密文)。 + // 與 KBDB base 共用同一顆 arcrun-kbdb D1(self-hosted 由 deploy.ts 注入用戶自己的 + // database_id,比照 kbdb/wrangler.toml 同一套 database_id 注入機制)。密文本體不在這裡, + // 住在 Workers per-script Secrets(見 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。 + CREDENTIALS_DB: D1Database; // Analytics:執行統計(fire-and-forget,key = stats:{workflowId}:{timestamp}) ANALYTICS_KV: KVNamespace; // Users:OAuth 登入用戶帳號(key = user:{provider}:{provider_id}) @@ -49,6 +56,12 @@ export type Bindings = { SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID) // KBDB 整合 KBDB_INTERNAL_TOKEN?: string; + // credential-store-migration T3/T5:能打 CF Workers Scripts secrets 管理 API 的 token + // (wrangler secret;PUT/DELETE .../workers/scripts/{script}/secrets,唯寫讀不回值, + // D19 語意仍成立)+ 對應帳號 id(非機密,比照 WORKER_SUBDOMAIN 由 deploy.ts 自動注入)。 + // 缺其一 → /credentials 寫入路徑誠實回報未就緒(不假綠),見 T3。 + CF_SECRETS_API_TOKEN?: string; + CF_ACCOUNT_ID?: string; // KBDB Base worker URL(recipe 成功記錄 /recipe-stats/record、fragment 抓取)。 // 未設 fallback 見各使用點(recipe-expander 預設 kbdb.finally.click)。kbdb-base SDD §7.1。 KBDB_BASE_URL?: string; diff --git a/cypher-executor/wrangler.toml b/cypher-executor/wrangler.toml index 649b69a..98aadc3 100644 --- a/cypher-executor/wrangler.toml +++ b/cypher-executor/wrangler.toml @@ -39,6 +39,15 @@ id = "25bef01d079148919578894434d58c4d" binding = "SESSIONS_KV" id = "455d0505c7534883a4d4985ab8295857" +# credential-store-migration T2/T5(D19):credential 目錄表(不含密文)。 +# 與 KBDB base 共用同一顆 arcrun-kbdb D1(比照 kbdb/wrangler.toml 同一個 database id)。 +# self-hosted:deploy.ts injectWranglerConfig 對任何 toml 內的 database_id 賦值行一律注入 +# ctx.d1DatabaseId(既有機制,見 kbdb/wrangler.toml 同款註解),本檔沿用不需額外改 deploy.ts。 +[[d1_databases]] +binding = "CREDENTIALS_DB" +database_name = "arcrun-kbdb" +database_id = "0c580910-e00b-4f8e-9c57-ac54ea52242f" # 官方 prod D1(arcrun-kbdb);self-hosted 由上述機制注入用戶自己的 id + # 2026-06-04:移除 WASM_BUCKET R2 binding。R2 wasm 路徑早已 dead(平台零件 = 獨立 Worker, # 不從 R2 動態讀),保留只會誤導且 R2 需綁信用卡,與 open source 零費用核心衝突。 # SDD: .agents/specs/component-registry-canon/tasks.md Phase 1.5(registry 已於 2026-05-07 移除,此為 cypher-executor 補清) @@ -107,6 +116,12 @@ ENVIRONMENT = "production" # MULTI_TENANT = "true" # ENCRYPTION_KEY 透過 wrangler secret set 設定 +# credential-store-migration T3(§2.3 寫入路徑需要的 token+account id): +# CF_SECRETS_API_TOKEN 是機密,透過 `wrangler secret put CF_SECRETS_API_TOKEN` 設定(不進 toml)。 +# CF_ACCOUNT_ID 非機密(帳號識別碼,不是憑證),比照 WORKER_SUBDOMAIN 由 deploy.ts 自動注入 +# (injectWranglerConfig 新增 CF_ACCOUNT_ID 注入,ctx.accountId 是 init/update 早就有的值)。 +CF_ACCOUNT_ID = "" + # Component worker subdomain(workers.dev 帳號 subdomain) # cypher-executor fetch component worker 一律走 arcrun-{name}.{WORKER_SUBDOMAIN}.workers.dev # 避開同 zone (*.arcrun.dev) 自循環死鎖,見 arcrun.md P0 #9(2026-05-13) diff --git a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md index 29edf19..b381533 100644 --- a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md +++ b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md @@ -197,7 +197,35 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla `fn(...)` 呼叫——用 probe 測試證實 `kv_get` 的 import 同樣不可直接呼叫,確認是既有架構的 環境限制非 secret_get 缺陷,已移除該 2 案例並在測試檔內註記;pointer 機制的真實驗證改由 T5 wrangler 部署端到端涵蓋。 -- [ ] T5 寫入路徑:`POST/PUT /credentials` 改寫 Workers Secrets(API `PUT`)+ D1 ref(§2.4)。 +- [x] T5 寫入路徑:`POST/PUT /credentials` 改寫 Workers Secrets(API `PUT`)+ D1 ref(§2.4)。2026-07-03 完成: + `cypher-executor/src/routes/credentials.ts` 整檔改寫 POST(建立)+ 新增 PUT `/credentials/:name` + (覆寫):兩者共用 `writeCredential()` → 1) `putWorkerSecret()` PUT 進 CF Workers Scripts secrets + 管理 API(唯寫)2) `upsertCredentialRow()` D1 upsert(不含密文,覆寫時保留原 `created_at`)。 + `secret_ref` 命名=`CRED__`(`cypher-executor/src/lib/hash.ts` + 匯出既有 `sha256Prefix`,避免跨租戶同名 credential 撞名覆蓋彼此的 secret;純函式衍生非解密/簽章邏輯, + 不違 rule 02 §2.2)。DELETE/GET `/credentials` 本次刻意不動(仍讀寫舊 KV,T9 治理端點範圍), + 已在檔頭與 §3 comment 誠實標注此缺口。`types.ts` Bindings 加 `CREDENTIALS_DB: D1Database` + (與 KBDB base 共用同一顆 `arcrun-kbdb` D1)+ `CF_SECRETS_API_TOKEN?` / `CF_ACCOUNT_ID?`。 + `wrangler.toml` 加 `[[d1_databases]] binding="CREDENTIALS_DB"`(沿用 kbdb 同款 database_id + 注入機制,deploy.ts 不用改)+ `[vars] CF_ACCOUNT_ID=""` 佔位(T3 由 deploy.ts 自動注入非機密 + 帳號 id,同 WORKER_SUBDOMAIN 模式)。**§2.4 選項甲落地**(client 不再 AES-GCM 加密,明文值經 + TLS 傳輸,cypher 短暫在記憶體經手明文不落地不持金鑰)——與舊版 `01-tech-stack.md` 記載的 + `{name,encrypted,iv}` 傳輸格式不同,此為本 SDD 對舊格式的刻意取代,§6 Q-b 仍列需 leo 明確 + 接受的誠實 trade-off(先落地,若不接受選項甲需回頭改,見任務完成報告「撞牆」段)。 + 驗證:`tsc --noEmit` exit 0(cli + cypher-executor 皆是);`vitest run` 26/27 通過(1 個 + pre-existing 無關失敗,見 T4 記錄,non-regression 已用 git stash 驗證)。**端到端證據**(真實 + leo21c `arcrun-cypher-executor` worker,非測試替身——寫入端點本身就是要驗的東西,無替代): + 比照 mistakes #23 手法本地 patch `wrangler.toml`(KV/D1 id、CF_ACCOUNT_ID、WORKER_SUBDOMAIN、 + KBDB_BASE_URL 換 leo21c 真實值,strip `[ai]`/`[[routes]]`)→ `wrangler deploy --dry-run` 核對 + binding 表(`env.CREDENTIALS_DB (arcrun-kbdb) D1 Database` 等全對)→ 真部署 → `wrangler secret put + CF_SECRETS_API_TOKEN`(沿用 `CLOUDFLARE_API_TOKEN`)→ `curl POST /credentials` 回 + `{"success":true,"secret_ref":"CRED_TELEGRAM_BOT_TOKEN_9BB9FB82",...}` → CF API `GET + .../workers/scripts/arcrun-cypher-executor/secrets` 確認該 secret 真的存在 → D1 query 確認 + 對應 row(api_key/name/service/sensitivity/secret_ref/created_at 全對)→ `curl PUT + /credentials/telegram_bot_token` 覆寫值,確認 `secret_ref` 不變、`created_at` 不變(覆寫語意 + 正確)→ 清理:CF API `DELETE .../secrets/{ref}` + D1 `DELETE FROM credentials`,二次查詢確認 + 皆已清空。部署完成後**立刻 restore `wrangler.toml` 備份**,git 追蹤版本無 leo21c 帳號專屬 id + 殘留(已 grep 確認)。 - [ ] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5)+ 更新 last_used。 - [ ] T7 雙讀 fallback(§4.1)。 - [ ] T8 回填端點 `POST /credentials/migrate-to-workers-secrets`(§4.2,冪等可審)。