From 1d19d46161b2da49024ec659c84737565a38b8e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:00:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(credentials):=20T8=20=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E7=AB=AF=E9=BB=9E=20+=20T9=20=E6=B2=BB=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E9=BB=9E/CLI=20(credential-store-migration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /credentials/migrate-to-workers-secrets:舊 KV credential 逐筆解密回填 D1+Workers Secrets,冪等可審,重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點 - GET /credentials 改讀 D1(與既有 /credentials/catalog 共用查詢);DELETE 改為新家優先、 舊 KV fallback,避免孤兒資料 - acr creds list/replace/delete 三支 CLI 薄殼指令;順手修好過期的 acr creds push(舊 client 端加密格式已被 T5 取代) - 新增 cypher-executor/tests/credentials.test.ts + D1 test fixture T6/T7(讀取/注入路徑、雙讀 fallback)需要重新編譯 registry/components/auth_static_key 的 TinyGo WASM,本環境無 tinygo 且 proxy 擋 github.com 下載,卡在工具鏈缺口,詳細分析 記錄在 credential-store-migration.md。 端到端驗證:部署到 leo21c 帳號真實跑過 GET/POST/DELETE 三分支 + migrate 端點(對真實 既存的兩筆 credential 跑,發現 cypher-executor 自己的 ENCRYPTION_KEY secret 疑似為空, 誠實記錄為待 leo/總管裁決的不可逆風險項,未擅自重設)。 Co-Authored-By: Claude Sonnet 5 --- cli/src/commands/creds.ts | 180 ++++++++++------- cli/src/index.ts | 23 ++- cypher-executor/src/routes/credentials.ts | 176 ++++++++++++++--- cypher-executor/tests/credentials.test.ts | 181 ++++++++++++++++++ cypher-executor/tests/env.d.ts | 10 + cypher-executor/tests/setup.ts | 9 + cypher-executor/vitest.config.ts | 1 + cypher-executor/wrangler.test.toml | 17 ++ .../credential-store-migration.md | 90 ++++++++- 9 files changed, 588 insertions(+), 99 deletions(-) create mode 100644 cypher-executor/tests/credentials.test.ts create mode 100644 cypher-executor/tests/env.d.ts create mode 100644 cypher-executor/tests/setup.ts diff --git a/cli/src/commands/creds.ts b/cli/src/commands/creds.ts index 1bc6894..9bbdb04 100644 --- a/cli/src/commands/creds.ts +++ b/cli/src/commands/creds.ts @@ -1,66 +1,132 @@ /** - * acr creds push [credentials.yaml] + * acr creds push/list/replace/delete * - * 讀取 credentials.yaml,以 ENCRYPTION_KEY 加密後 POST 至 cypher.arcrun.dev/credentials。 - * Server 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV(多租戶隔離)。 + * credential-store-migration T5(2026-07-03,Arcrun#2)已把 server 端寫入路徑從 + * 「client AES-GCM 加密 + {name,encrypted,iv}」改為「明文值 + TLS 傳輸」(§2.4 選項甲: + * arcrun 不再自管 ENCRYPTION_KEY,密文改由 CF Workers Secrets 託管)。T9(§3 治理端點) + * 對應把 CLI 薄殼換成新的 list/replace/delete 三支指令:全部只做「讀 argv/yaml → 呼叫 + * cypher-executor API → 印結果」,不做任何加解密或業務邏輯(rule 07 薄殼原則)。 * - * 不再需要用戶提供 CF API Token 或 KV Namespace ID。 + * 移除任何「印出 credential 值」的路徑(D19:擁有目錄,不擁有內容物,連 owner 都讀不回)。 */ import { readFileSync } from 'node:fs'; import yaml from 'js-yaml'; import chalk from 'chalk'; import ora from 'ora'; -import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; - -async function encryptValue(value: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> { - const keyBytes = hexToUint8Array(encryptionKey); - const cryptoKey = await crypto.subtle.importKey( - 'raw', - keyBytes.buffer as ArrayBuffer, - { name: 'AES-GCM' }, - false, - ['encrypt'], - ); - - const ivBytes = crypto.getRandomValues(new Uint8Array(12)); - const encoded = new TextEncoder().encode(value); - const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBytes }, cryptoKey, encoded); - - return { - encrypted: Buffer.from(new Uint8Array(cipherBuffer)).toString('base64'), - iv: Buffer.from(ivBytes).toString('base64'), - }; -} - -function hexToUint8Array(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); - return bytes; -} - -export async function cmdCredsPush(filePath: string): Promise { - const config = loadConfig(); - - if (config.mode === 'local') { - console.error(chalk.red('Local 模式不支援 acr creds push。')); - console.log(chalk.gray('請先執行 acr init 設定 Standard 模式,取得 API Key。')); - process.exit(1); - } +import { loadConfig, getCypherExecutorUrl, type ArcrunConfig } from '../lib/config.js'; +function requireApiKey(config: ArcrunConfig): string { if (!config.api_key) { - // self-hosted 用「資料分區標籤」(明碼,用戶在 .env 設 NAMESPACE)當 KV 前綴,非平台發的 api_key。 if (config.mode === 'self-hosted') { console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。')); console.log(chalk.gray('在專案 .env 設一行(明碼即可,這是分區標籤不是密碼):')); console.log(chalk.cyan(' NAMESPACE=leo')); - console.log(chalk.gray('(要防外部呼叫請對 webhook 加保護;見 README「讓 AI 連到對的 arcrun」段)')); } else { console.error(chalk.red('缺少 api_key,請重新執行 acr init。')); } process.exit(1); } + return config.api_key; +} + +interface CredentialRow { + name: string; + service: string | null; + sensitivity: string; + created_at: number; + last_used_at: number | null; +} + +/** acr creds list — 讀 D1 目錄顯示(不含值,D19:讀不回內容物)。 */ +export async function cmdCredsList(): Promise { + const config = loadConfig(); + const apiKey = requireApiKey(config); + const baseUrl = getCypherExecutorUrl(config); + + const res = await fetch(`${baseUrl}/credentials`, { + headers: { 'X-Arcrun-API-Key': apiKey }, + }); + const body = (await res.json().catch(() => null)) as + | { success?: boolean; credentials?: CredentialRow[]; error?: string } + | null; + + if (!res.ok || !body?.success) { + console.error(chalk.red(`讀取失敗:${body?.error ?? `HTTP ${res.status}`}`)); + process.exit(1); + } + + const rows = body.credentials ?? []; + if (rows.length === 0) { + console.log(chalk.gray('(尚無 credential)')); + return; + } + console.log(chalk.bold(`\n ${rows.length} 個 credential(只顯示目錄,值不可讀回)\n`)); + for (const r of rows) { + const lastUsed = r.last_used_at ? new Date(r.last_used_at * 1000).toISOString() : '(從未使用)'; + console.log(` ${chalk.cyan(r.name)} service=${r.service ?? '-'} sensitivity=${r.sensitivity} last_used=${lastUsed}`); + } + console.log(); +} + +/** acr creds replace — 整筆覆寫(只能 replace,不能 edit 局部,D19/§3)。 */ +export async function cmdCredsReplace( + name: string, + value: string, + options: { service?: string; sensitivity?: string }, +): Promise { + const config = loadConfig(); + const apiKey = requireApiKey(config); + const baseUrl = getCypherExecutorUrl(config); + + const spinner = ora(` 覆寫 ${name}`).start(); + try { + const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey }, + body: JSON.stringify({ value, service: options.service, sensitivity: options.sensitivity }), + }); + const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null; + if (!res.ok || !body?.success) { + throw new Error(body?.error ?? `HTTP ${res.status}`); + } + spinner.succeed(chalk.green(` ✓ ${name} 已覆寫`)); + } catch (e) { + spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } +} + +/** acr creds delete — 刪除 credential(目錄 row + Workers Secret 本體)。 */ +export async function cmdCredsDelete(name: string): Promise { + const config = loadConfig(); + const apiKey = requireApiKey(config); + const baseUrl = getCypherExecutorUrl(config); + + const spinner = ora(` 刪除 ${name}`).start(); + try { + const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, { + method: 'DELETE', + headers: { 'X-Arcrun-API-Key': apiKey }, + }); + const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null; + if (!res.ok || !body?.success) { + throw new Error(body?.error ?? `HTTP ${res.status}`); + } + spinner.succeed(chalk.green(` ✓ ${name} 已刪除`)); + } catch (e) { + spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`)); + process.exit(1); + } +} + +/** + * acr creds push [credentials.yaml] —(保留 bulk 匯入的既有慣用法,內部改走新的明文 PUT + * 路徑,不再 client 端 AES-GCM 加密——舊實作對應的 server 格式已被 T5 取代,直接沿用會 400)。 + */ +export async function cmdCredsPush(filePath: string): Promise { + const config = loadConfig(); + const apiKey = requireApiKey(config); - // 讀取 credentials.yaml let creds: Record; try { const raw = readFileSync(filePath, 'utf8'); @@ -76,48 +142,26 @@ export async function cmdCredsPush(filePath: string): Promise { return; } - // 加密金鑰:優先從 config 讀(含 .env 的 ENCRYPTION_KEY / ARCRUN_ENCRYPTION_KEY,見 config.ts loadDotEnvOnce), - // 其次環境變數。self-hosted:你自己保管這把(工具不生成、不外傳),須與 worker 的 ENCRYPTION_KEY secret 一致。 - const encryptionKey = config.encryption_key ?? process.env.ARCRUN_ENCRYPTION_KEY ?? ''; - if (!encryptionKey || encryptionKey.length < 64) { - if (config.mode === 'self-hosted') { - console.error(chalk.red('缺少 encryption_key(或長度不足,需 ≥64 hex chars = 256-bit)。')); - console.log(chalk.gray('在專案 .env 設(你自己保管,忘了就解不開已上傳的 credential):')); - console.log(chalk.cyan(' ENCRYPTION_KEY=<64+ hex> # 產生:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"')); - console.log(chalk.gray('同一把也要設進 worker:wrangler secret put ENCRYPTION_KEY(見 acr init 提示)')); - } else { - console.error(chalk.red('缺少 encryption_key。請重新執行 acr init 取得設定。')); - } - process.exit(1); - } - const baseUrl = getCypherExecutorUrl(config); console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至 ${baseUrl}\n`)); for (const [name, value] of entries) { const spinner = ora(` ${name}`).start(); try { - const { encrypted, iv } = await encryptValue(String(value), encryptionKey); - const res = await fetch(`${baseUrl}/credentials`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Arcrun-API-Key': config.api_key!, - }, - body: JSON.stringify({ name, encrypted, iv }), + headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey }, + body: JSON.stringify({ name, value: String(value) }), }); - if (!res.ok) { const errBody = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 200)}`); } - spinner.succeed(chalk.green(` ✓ ${name}`)); } catch (e) { spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`)); } } - console.log(chalk.gray('\n Credential 已加密儲存。執行 workflow 時會自動注入,無需在 --input 手動帶 token。\n')); + console.log(chalk.gray('\n Credential 已存進 Workers Secrets(cypher 唯寫,讀不回值)。執行 workflow 時會自動注入。\n')); } diff --git a/cli/src/index.ts b/cli/src/index.ts index 6229957..e759f04 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,7 +13,7 @@ import { dirname, join } from 'node:path'; import { cmdInit } from './commands/init.js'; import { cmdConfig } from './commands/config.js'; import { cmdWhoami } from './commands/whoami.js'; -import { cmdCredsPush } from './commands/creds.js'; +import { cmdCredsPush, cmdCredsList, cmdCredsReplace, cmdCredsDelete } from './commands/creds.js'; import { cmdPush } from './commands/push.js'; import { cmdRun } from './commands/run.js'; import { cmdValidate } from './commands/validate.js'; @@ -79,12 +79,27 @@ program .option('--json', '結構化輸出(給 AI / 腳本讀取)') .action((options: { json?: boolean }) => cmdWhoami(options)); -// acr creds push [credentials.yaml] -const credsCmd = program.command('creds').description('Credential 管理'); +// acr creds push/list/replace/delete — credential-store-migration T9(治理端點 CLI 薄殼) +const credsCmd = program.command('creds').description('Credential 管理(D19:只能看目錄/replace/delete,讀不回值)'); credsCmd .command('push [file]') - .description('加密上傳 credentials.yaml 至你的 CF KV(不經過 arcrun.dev)') + .description('批次上傳 credentials.yaml(明文經 TLS,值存進你的 Workers Secrets,不經過 arcrun.dev)') .action((file: string) => cmdCredsPush(file ?? 'credentials.yaml')); +credsCmd + .command('list') + .description('列出已存的 credential 目錄(name/service/sensitivity/last_used,不含值)') + .action(() => cmdCredsList()); +credsCmd + .command('replace ') + .description('整筆覆寫一個 credential 的值(只能整筆換,不能局部編輯)') + .option('--service ', '對應 service 名(如 telegram)') + .option('--sensitivity ', 'standard | high') + .action((name: string, value: string, options: { service?: string; sensitivity?: string }) => + cmdCredsReplace(name, value, options)); +credsCmd + .command('delete ') + .description('刪除一個 credential(目錄 row + Workers Secret 本體)') + .action((name: string) => cmdCredsDelete(name)); // acr push program diff --git a/cypher-executor/src/routes/credentials.ts b/cypher-executor/src/routes/credentials.ts index 73ab77a..ab4fc07 100644 --- a/cypher-executor/src/routes/credentials.ts +++ b/cypher-executor/src/routes/credentials.ts @@ -17,14 +17,21 @@ * (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)。 + * credential-store-migration T8(§4.2 回填)+ T9(§3 治理端點): + * - `POST /credentials/migrate-to-workers-secrets`:把呼叫者(X-Arcrun-API-Key)名下的舊 + * `{api_key}:cred:{name}` KV row 逐一解密(重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點, + * 不在本檔重新實作解密)→ PUT 進 Workers Secrets → D1 upsert 目錄。冪等:D1 已有可解析 + * secret_ref 的 row 就跳過;逐筆誠實回報 ok/skipped/fail(mindset §7 不假綠)。 + * - `GET /credentials`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留 + * `/catalog` 別名,Console 既有呼叫不受影響)。 + * - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row; + * 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。 */ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { sha256Prefix } from '../lib/hash'; +import { createArcrunHostFunctions } from '../lib/wasi-shim'; export const credentialsRouter = new Hono<{ Bindings: Bindings }>(); @@ -82,6 +89,29 @@ async function putWorkerSecret(env: Bindings, secretRef: string, value: string): } } +/** + * 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。 + * 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。 + */ +async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise { + if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) { + throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒'); + } + const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`; + const res = await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` }, + }); + if (res.status === 404) return; + 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,只更新 @@ -110,6 +140,35 @@ async function upsertCredentialRow( .run(); } +interface CredentialRow { + name: string; + service: string | null; + sensitivity: string; + created_at: number; + last_used_at: number | null; +} + +/** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */ +async function listCredentialRows(db: D1Database, apiKey: string): Promise { + const rows = await db + .prepare( + `SELECT name, service, sensitivity, created_at, last_used_at + FROM credentials WHERE api_key = ? ORDER BY created_at DESC`, + ) + .bind(apiKey) + .all(); + return rows.results ?? []; +} + +/** 查單一 credential 的 secret_ref(治理端點刪除用;不對外回傳 secret_ref 本身,只內部使用)。 */ +async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise { + const row = await db + .prepare(`SELECT secret_ref FROM credentials WHERE api_key = ? AND name = ?`) + .bind(apiKey, name) + .first<{ secret_ref: string }>(); + return row?.secret_ref ?? null; +} + interface CredentialWriteBody { name?: string; value?: string; @@ -191,7 +250,8 @@ credentialsRouter.put('/credentials/:name', async (c) => { } }); -// DELETE /credentials/:name — 刪除 credential(未動:仍是舊 KV 路徑,T9 範圍) +// DELETE /credentials/:name — 刪除 credential(T9:新家優先,舊 KV 為回退) +// D19 對齊:能刪的只有「目錄 row + Workers Secret 這個密文本體」,本端點從頭到尾不讀值。 credentialsRouter.delete('/credentials/:name', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { @@ -199,47 +259,117 @@ credentialsRouter.delete('/credentials/:name', async (c) => { } const name = c.req.param('name'); - const kvKey = `${apiKey}:cred:${name}`; - await c.env.CREDENTIALS_KV.delete(kvKey); - - return c.json({ success: true, name }); + try { + const secretRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name); + if (secretRef) { + await deleteWorkerSecret(c.env, secretRef); + await c.env.CREDENTIALS_DB + .prepare(`DELETE FROM credentials WHERE api_key = ? AND name = ?`) + .bind(apiKey, name) + .run(); + return c.json({ success: true, name, source: 'workers-secrets' }); + } + // D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑, + // 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。 + await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`); + return c.json({ success: true, name, source: 'legacy-kv' }); + } catch (e) { + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); + } }); // GET /credentials/catalog — D1 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。 -// 只回 metadata(name/service/sensitivity/created_at/last_used_at),**絕不回密文值**—— -// 密文在 Workers Secrets,本 worker 自己也讀不回(D19「擁有目錄,不擁有內容物」)。 -// 與舊 GET /credentials(KV 名稱清單)並存:這裡是新制 D1 目錄;舊 KV 寫入的看不到(T5 已知誠實缺口)。 -// 註冊須在 GET /credentials/:name 類 route 之前?本檔無 :name GET route,無攔截問題。 +// 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑; +// /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。 credentialsRouter.get('/credentials/catalog', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } try { - const rows = await c.env.CREDENTIALS_DB - .prepare( - `SELECT name, service, sensitivity, created_at, last_used_at - FROM credentials WHERE api_key = ? ORDER BY created_at DESC`, - ) - .bind(apiKey) - .all<{ name: string; service: string | null; sensitivity: string; created_at: number; last_used_at: number | null }>(); - return c.json({ success: true, credentials: rows.results ?? [], total: (rows.results ?? []).length }); + const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey); + return c.json({ success: true, credentials: rows, total: rows.length }); } catch (e) { // 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事) return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); } }); -// GET /credentials — 列出 credential 名稱(不含值)(未動:仍是舊 KV 路徑,T9 範圍) +// GET /credentials — 列出 credential 目錄(T9:改讀 D1,只回 metadata,絕不含值/secret_ref) credentialsRouter.get('/credentials', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } + try { + const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey); + return c.json({ success: true, credentials: rows, total: rows.length }); + } catch (e) { + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); + } +}); + +interface MigrateResult { + name: string; + ok: boolean; + skipped?: boolean; + error?: string; +} + +// POST /credentials/migrate-to-workers-secrets — 回填(T8,§4.2):一次性、冪等、可審。 +// 把呼叫者名下舊 `{api_key}:cred:{name}` KV row({encrypted, iv} AES-GCM 密文)逐一解密 +// →(重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點,本檔不重新實作解密)→ PUT 進 Workers +// Secrets → D1 upsert 目錄。冪等:D1 已有該 (api_key,name) row 且 secret_ref 非空 → 跳過。 +// 不刪 KV 舊密文(§4.3 回滾錨點——雙讀 fallback、廢除 ENCRYPTION_KEY 前的安全網)。 +credentialsRouter.post('/credentials/migrate-to-workers-secrets', async (c) => { + const apiKey = c.req.header('X-Arcrun-API-Key'); + if (!apiKey) { + return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); + } + + const cryptoDecrypt = createArcrunHostFunctions(c.env, apiKey).crypto_decrypt; + if (!cryptoDecrypt) { + return c.json({ success: false, error: 'crypto_decrypt host function 未就緒' }, 500); + } const prefix = `${apiKey}:cred:`; const list = await c.env.CREDENTIALS_KV.list({ prefix }); - const names = list.keys.map(k => k.name.slice(prefix.length)); + const results: MigrateResult[] = []; - return c.json({ credentials: names }); + for (const key of list.keys) { + const name = key.name.slice(prefix.length); + try { + const existingRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name); + if (existingRef) { + results.push({ name, ok: true, skipped: true }); + continue; + } + + const raw = await c.env.CREDENTIALS_KV.get(key.name); + if (!raw) { + results.push({ name, ok: false, error: 'KV row 讀不到值(可能已被刪除)' }); + continue; + } + const { encrypted, iv } = JSON.parse(raw) as { encrypted: string; iv: string }; + const plaintext = await cryptoDecrypt(encrypted, iv); + + const secretRef = await deriveSecretRef(apiKey, name); + await putWorkerSecret(c.env, secretRef, plaintext); + await upsertCredentialRow(c.env.CREDENTIALS_DB, apiKey, name, null, 'standard', secretRef); + results.push({ name, ok: true }); + } catch (e) { + // 誠實回報逐筆 fail,不假綠(mindset §7) + results.push({ name, ok: false, error: e instanceof Error ? e.message : String(e) }); + } + } + + const failed = results.filter(r => !r.ok); + return c.json({ + success: failed.length === 0, + total: results.length, + migrated: results.filter(r => r.ok && !r.skipped).length, + skipped: results.filter(r => r.skipped).length, + failed: failed.length, + results, + }); }); diff --git a/cypher-executor/tests/credentials.test.ts b/cypher-executor/tests/credentials.test.ts new file mode 100644 index 0000000..76313ce --- /dev/null +++ b/cypher-executor/tests/credentials.test.ts @@ -0,0 +1,181 @@ +/** + * credential-store-migration T8(回填端點)+ T9(治理端點)測試。 + * + * 範圍限制(誠實記錄,非本檔缺陷):`putWorkerSecret` / `deleteWorkerSecret` 呼叫真實 + * Cloudflare API(`fetch` 到 api.cloudflare.com)。測試環境(wrangler.test.toml)刻意不設 + * CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID,所以本檔只覆蓋「不需要真的打 CF API」的路徑: + * - D1-only 的 GET /credentials、/credentials/catalog + * - migrate 端點的冪等 skip 分支(D1 已有 row 就不會走到 putWorkerSecret) + * - migrate 端點在缺 CF token 時對「真的需要新建」的 row 誠實回報 fail(不假綠) + * - DELETE 在 D1 無 row 時 fallback 刪舊 KV(不會走到 deleteWorkerSecret) + * 真正打 CF Workers Secrets API 成功寫入/刪除的路徑,由部署到 leo21c 帳號後的端到端 + * curl 驗證覆蓋(見 credential-store-migration.md T8/T9 完成記錄)。 + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { env, SELF } from 'cloudflare:test'; + +const API_KEY = 'test-tenant-t89'; + +async function insertCredentialRow( + name: string, + secretRef: string, + extra: Partial<{ service: string | null; sensitivity: string; last_used_at: number | null }> = {}, +): Promise { + await env.CREDENTIALS_DB + .prepare( + `INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + API_KEY, + name, + extra.service ?? null, + extra.sensitivity ?? 'standard', + secretRef, + Math.floor(Date.now() / 1000), + extra.last_used_at ?? null, + ) + .run(); +} + +async function clearTenantRows(): Promise { + await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run(); +} + +describe('GET /credentials (D1, T9)', () => { + beforeEach(clearTenantRows); + + it('缺 X-Arcrun-API-Key → 401', async () => { + const res = await SELF.fetch('https://cypher.test/credentials'); + expect(res.status).toBe(401); + }); + + it('無資料 → 空陣列(非拋錯)', async () => { + const res = await SELF.fetch('https://cypher.test/credentials', { + headers: { 'X-Arcrun-API-Key': API_KEY }, + }); + expect(res.status).toBe(200); + const body = await res.json() as { success: boolean; credentials: unknown[]; total: number }; + expect(body.success).toBe(true); + expect(body.credentials).toEqual([]); + expect(body.total).toBe(0); + }); + + it('回傳 metadata,絕不含 secret_ref 或值', async () => { + await insertCredentialRow('telegram_bot_token', 'CRED_TELEGRAM_BOT_TOKEN_ABCDEF01', { service: 'telegram' }); + const res = await SELF.fetch('https://cypher.test/credentials', { + headers: { 'X-Arcrun-API-Key': API_KEY }, + }); + const body = await res.json() as { success: boolean; credentials: Array> }; + expect(body.success).toBe(true); + expect(body.credentials).toHaveLength(1); + const row = body.credentials[0]; + expect(row.name).toBe('telegram_bot_token'); + expect(row.service).toBe('telegram'); + expect(row).not.toHaveProperty('secret_ref'); + expect(row).not.toHaveProperty('value'); + expect(JSON.stringify(row)).not.toMatch(/CRED_/); + }); + + it('/credentials/catalog 回同一份資料(Console 相容別名)', async () => { + await insertCredentialRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01'); + const [listRes, catalogRes] = await Promise.all([ + SELF.fetch('https://cypher.test/credentials', { headers: { 'X-Arcrun-API-Key': API_KEY } }), + SELF.fetch('https://cypher.test/credentials/catalog', { headers: { 'X-Arcrun-API-Key': API_KEY } }), + ]); + const [listBody, catalogBody] = await Promise.all([listRes.json(), catalogRes.json()]) as Array<{ + credentials: Array<{ name: string }>; + }>; + expect(listBody.credentials.map(r => r.name)).toEqual(catalogBody.credentials.map(r => r.name)); + }); +}); + +describe('POST /credentials/migrate-to-workers-secrets (T8)', () => { + beforeEach(async () => { + await clearTenantRows(); + await env.CREDENTIALS_KV.list({ prefix: `${API_KEY}:cred:` }).then(async (list) => { + for (const k of list.keys) await env.CREDENTIALS_KV.delete(k.name); + }); + }); + + it('缺 X-Arcrun-API-Key → 401', async () => { + const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { method: 'POST' }); + expect(res.status).toBe(401); + }); + + it('D1 已有 row(曾回填過)→ 跳過,不誤判為失敗', async () => { + await insertCredentialRow('already_migrated', 'CRED_ALREADY_MIGRATED_ABCDEF01'); + // 對應的舊 KV row 仍在(§4.3 回滾錨點:回填後不刪 KV),驗證「有 D1 row 就跳過」而非重打 CF API + await env.CREDENTIALS_KV.put( + `${API_KEY}:cred:already_migrated`, + JSON.stringify({ encrypted: 'irrelevant', iv: 'irrelevant' }), + ); + + const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { + method: 'POST', + headers: { 'X-Arcrun-API-Key': API_KEY }, + }); + const body = await res.json() as { + success: boolean; total: number; migrated: number; skipped: number; failed: number; + results: Array<{ name: string; ok: boolean; skipped?: boolean }>; + }; + expect(body.success).toBe(true); + expect(body.total).toBe(1); + expect(body.skipped).toBe(1); + expect(body.migrated).toBe(0); + expect(body.failed).toBe(0); + expect(body.results[0]).toMatchObject({ name: 'already_migrated', ok: true, skipped: true }); + }); + + it('無任何舊 KV row → 空結果,success:true(沒東西可回填不是失敗)', async () => { + const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { + method: 'POST', + headers: { 'X-Arcrun-API-Key': API_KEY }, + }); + const body = await res.json() as { success: boolean; total: number }; + expect(body.success).toBe(true); + expect(body.total).toBe(0); + }); + + it('真正需要回填的 row(D1 無資料)在測試環境缺 CF token 時誠實回報 fail,不假綠', async () => { + await env.CREDENTIALS_KV.put( + `${API_KEY}:cred:needs_migration`, + JSON.stringify({ encrypted: 'ZmFrZQ==', iv: 'ZmFrZQ==' }), + ); + const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { + method: 'POST', + headers: { 'X-Arcrun-API-Key': API_KEY }, + }); + const body = await res.json() as { + success: boolean; failed: number; results: Array<{ name: string; ok: boolean; error?: string }>; + }; + // 解密本身可能因假造的 base64 密文而失敗,或走到 putWorkerSecret 因缺 CF_SECRETS_API_TOKEN 失敗—— + // 兩者都應該落在「誠實回報 fail」而非靜默假裝成功 + expect(body.success).toBe(false); + expect(body.failed).toBe(1); + const row = body.results.find(r => r.name === 'needs_migration'); + expect(row?.ok).toBe(false); + expect(row?.error).toBeTruthy(); + }); +}); + +describe('DELETE /credentials/:name (T9)', () => { + beforeEach(clearTenantRows); + + it('D1 無 row(從未回填)→ fallback 刪舊 KV,不誤報找不到', async () => { + await env.CREDENTIALS_KV.put( + `${API_KEY}:cred:legacy_only`, + JSON.stringify({ encrypted: 'x', iv: 'y' }), + ); + const res = await SELF.fetch('https://cypher.test/credentials/legacy_only', { + method: 'DELETE', + headers: { 'X-Arcrun-API-Key': API_KEY }, + }); + const body = await res.json() as { success: boolean; source: string }; + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.source).toBe('legacy-kv'); + const raw = await env.CREDENTIALS_KV.get(`${API_KEY}:cred:legacy_only`); + expect(raw).toBeNull(); + }); +}); diff --git a/cypher-executor/tests/env.d.ts b/cypher-executor/tests/env.d.ts new file mode 100644 index 0000000..b5f9de2 --- /dev/null +++ b/cypher-executor/tests/env.d.ts @@ -0,0 +1,10 @@ +/** + * vitest-pool-workers 的 `cloudflare:test` `env`/`ProvidedEnv` 型別合併宣告。 + * 讓 tests/*.test.ts 裡 `import { env } from 'cloudflare:test'` 拿到跟正式 Bindings + * 一致的型別(credential-store-migration T8/T9 測試需要 env.CREDENTIALS_DB / CREDENTIALS_KV)。 + */ +import type { Bindings } from '../src/types'; + +declare module 'cloudflare:test' { + interface ProvidedEnv extends Bindings {} +} diff --git a/cypher-executor/tests/setup.ts b/cypher-executor/tests/setup.ts new file mode 100644 index 0000000..8b94454 --- /dev/null +++ b/cypher-executor/tests/setup.ts @@ -0,0 +1,9 @@ +/** + * vitest-pool-workers 測試環境初始化:credential-store-migration T8/T9 測試用 D1 建表。 + * 對齊 kbdb/migrations/0002_credentials.sql schema(不接 migrations_dir,單表直接 exec)。 + */ +import { env } from 'cloudflare:test'; + +await env.CREDENTIALS_DB.exec( + "CREATE TABLE IF NOT EXISTS credentials (api_key TEXT NOT NULL, name TEXT NOT NULL, service TEXT, sensitivity TEXT NOT NULL DEFAULT 'standard', secret_ref TEXT NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER, PRIMARY KEY (api_key, name))", +); diff --git a/cypher-executor/vitest.config.ts b/cypher-executor/vitest.config.ts index 2bea07c..0200dbe 100644 --- a/cypher-executor/vitest.config.ts +++ b/cypher-executor/vitest.config.ts @@ -2,6 +2,7 @@ import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; export default defineWorkersConfig({ test: { + setupFiles: ['./tests/setup.ts'], poolOptions: { workers: { wrangler: { configPath: './wrangler.test.toml' }, diff --git a/cypher-executor/wrangler.test.toml b/cypher-executor/wrangler.test.toml index 83e17aa..0542d54 100644 --- a/cypher-executor/wrangler.test.toml +++ b/cypher-executor/wrangler.test.toml @@ -16,5 +16,22 @@ id = "test-exec-context" binding = "WEBHOOKS" id = "test-webhooks" +[[kv_namespaces]] +binding = "CREDENTIALS_KV" +id = "test-credentials-kv" + +[[kv_namespaces]] +binding = "RECIPES" +id = "test-recipes" + +# credential-store-migration T8/T9 測試用 D1 mock(Miniflare 本地 SQLite,非真實 leo21c D1; +# schema 由 tests/setup.ts 在測試啟動時建表,不用 migrations_dir——0002_credentials.sql 就一張表, +# 直接 exec 比接 migrations 機制簡單) +[[d1_databases]] +binding = "CREDENTIALS_DB" +database_name = "test-credentials-db" +database_id = "test-credentials-db-id" + [vars] ENVIRONMENT = "test" +ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 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 4f5bd6e..e50b6da 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 @@ -250,10 +250,92 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla 正確)→ 清理: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,冪等可審)。 -- [ ] T9 治理端點 list/replace/delete + 移除任何 read-value 路徑(§3)+ CLI 薄殼。 +- [⛔] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5)+ 更新 last_used。 + 〔2026-07-04 [cloud-worker] 卡在框架級工具缺口,非設計問題〕**T6 需要修改 + `registry/components/auth_static_key/main.go`(與 `auth_service_account/main.go`)**: + 這兩個 auth primitive 是獨立部署的 TinyGo WASM Worker,密文值現在住在 + **cypher-executor 自己的 per-script secrets**(T5 `putWorkerSecret` 寫入的 script 是 + `arcrun-cypher-executor`,不是 auth_static_key worker)——`env[secret_ref]` 動態索引 + 只在「secret 所在的那個 worker 自己的 runtime」內可讀,auth_static_key worker 讀不到 + cypher-executor 的 env。故 T6 的正確落地方式是:cypher-executor(`auth-dispatcher.ts`) + 先查 D1 拿 secret_ref、呼叫 `secret_get(ref)`(T4 已備妥,讀的正是 cypher 自己的 + env)取得明文,再把已解析好的值放進送給 auth_static_key WASM 的 payload 一個新欄位 + (如 `resolved_secrets: {name: value}`),main.go 收到後優先用它、沒有才 fallback 舊 + `kv_get`+`crypto_decrypt`(這個 fallback 分支同時就是 T7 雙讀)。**這個 main.go 改動 + 需要 TinyGo 編譯**——本次雲端環境核實:`tinygo` 未安裝(`which tinygo` 找不到)、 + `apt-cache search tinygo` 查無套件、且 proxy 擋 `github.com`(`curl -sI + https://github.com/tinygo-org/tinygo/releases` → `HTTP/1.1 403 Forbidden`, + tinygo 官方無 apt/npm 分發管道,只能從 GitHub Releases 下載),**無法在本環境安裝 + TinyGo 工具鏈,因此無法重新編譯/部署 auth_static_key.wasm**。誠實結論:T6/T7 的 + TS 側前置(D1 查詢+`secret_get` 呼叫)在架構上可行且不衝突 rule 02(cypher 短暫經手 + 明文、只轉送給 WASM 做 template 注入,同 §2.4 選項甲的既有精神),但**卡在需要 + TinyGo 編譯環境這一步,非本次雲端工人能力範圍**,需總管本機(有 TinyGo)或 leo + 決定是否要在雲端環境補裝工具鏈(例如允許 proxy 放行 github.com release 下載,或 + 改用其他 TinyGo 取得管道)。整案不硬繞(不改用「TS 直接組 header」這種違反 rule 02 + 的捷徑),留給有 TinyGo 環境的一方接手 main.go 改動 + 重新編譯部署。 +- [⛔] T7 雙讀 fallback(§4.1)。〔同上,隨 T6 main.go 改動一併落地——D1 查得到 secret_ref + 走新路徑,查不到就是 WASM 原有的 `kv_get`+`crypto_decrypt` 分支,本來就會自然發生, + 不需要額外的「fallback 判斷」程式碼,只要 T6 的 `resolved_secrets` 是「有給才用、沒給 + 就照舊」的設計即可。同樣卡在 T6 的 TinyGo 前置。〕 +- [x] T8 回填端點 `POST /credentials/migrate-to-workers-secrets`(§4.2,冪等可審)。 + 2026-07-04 [cloud-worker] 完成:`cypher-executor/src/routes/credentials.ts` 新增 + 端點,重用 `createArcrunHostFunctions(env, apiKey).crypto_decrypt`(wasi-shim.ts + 唯一合法 `crypto.subtle.decrypt` 呼叫點,本檔不重新實作解密,grep 確認全 repo + `crypto.subtle` 呼叫點未增加)解出舊 KV 明文 → `putWorkerSecret` → D1 upsert。 + 冪等:D1 已有可解析 `secret_ref` 的 row 就跳過(不重打 CF API)。逐筆誠實回報 + ok/skipped/fail。**單元測試**(`tests/credentials.test.ts`,vitest-pool-workers + + D1 mock,`tests/setup.ts` 建表):401 檢查、冪等 skip、空結果、以及「假造密文在 + 測試環境缺 CF token 時誠實回報 fail 不假綠」4 案例全過。`tsc --noEmit` exit 0; + `vitest run` 35/36(1 個 pre-existing 無關失敗,見完成記錄)。 + **端到端真實驗證**(leo21c `arcrun-cypher-executor`,非測試替身):patch + `wrangler.toml` 真實 id(同 T5 手法)→ `wrangler deploy --dry-run` 核對 binding + 表全對 → 真部署 → 打 `POST /credentials/migrate-to-workers-secrets` 對真實租戶 + `leo` 名下**兩筆真實既存**的舊 KV credential(`google_service_account`、 + `notion_token`,非測試殘留,KV 內容確認是合法 `{encrypted, iv}` JSON 結構, + base64 長度正常)→ **端點誠實回報兩筆皆 fail**(`Imported AES key length must be + 128, 192, or 256 bits but provided 0`),**未寫入任何部分資料到 D1 或 Workers + Secrets(已查證 D1 `credentials` 表對這兩筆 name 完全無 row,非部分髒寫)**。 + **重大誠實發現(非本次程式碼缺陷,是既有基礎設施缺口)**:這個錯誤代表 + `env.ENCRYPTION_KEY` 在 **cypher-executor 這個 worker 自己的 runtime** 內讀到空字串 + (`hexToUint8Array('').length === 0`)——過去 `crypto_decrypt` 只在 auth_static_key/ + auth_service_account 這兩個獨立 worker 上被呼叫過(各自有自己設定好的 + `ENCRYPTION_KEY` secret),**cypher-executor 自己的 `ENCRYPTION_KEY` secret 從未被 + 任何程式路徑真正呼叫驗證過**(Phase 6.6 CI 記錄有把同一份 `.env` 值 pipe 給三個 + worker,理論上應該一致,但 CF Workers Secrets 管理 API 本質唯寫、無法讀回比對, + 無法排除某個環節曾經漏設或設成空值)。T8 是**史上第一個**在 cypher-executor 自己 + 的 runtime 呼叫 `crypto_decrypt` 的程式路徑,因此第一次揭露這個潛在缺口。 + **不可逆風險,本次不處理,等 leo**:重設 cypher-executor 的 `ENCRYPTION_KEY` 這件事 + 本身不可逆——如果現在的空值就是問題根源,重設一把新 key 沒問題;但如果問題其實 + 出在別處(例如 `env` binding 讀取路徑有 bug),錯誤地假設「重設 key 就會好」而 + 去 `wrangler secret put` 可能導致**這兩把真實 credential(Google Service Account + + Notion token)從此永久無法解密**(唯寫 API 讀不回舊值比對,猜錯就回不了頭)。 + 故本次**刻意不猜、不重設**,只誠實回報現象+兩種可能成因,交給有本機環境能 + 實際比對三個 worker `ENCRYPTION_KEY` 是否一致的一方(總管/leo)診斷後裁決。 + 測試資料已清理(探測用 `t89_probe_secret`/`t89_legacy_probe` 皆已刪除,D1 與 KV + 復原至只剩原本兩筆真實資料的乾淨狀態,已 curl 覆核);部署完成後 `wrangler.toml` + 已 `git checkout` 復原,grep 確認無 leo21c 帳號專屬 id 殘留。 +- [x] T9 治理端點 list/replace/delete + 移除任何 read-value 路徑(§3)+ CLI 薄殼。 + 2026-07-04 [cloud-worker] 完成:`GET /credentials` 改讀 D1(與既有 `/credentials/ + catalog` 共用同一份 query,`/catalog` 保留給 Console 既有呼叫不受影響); + `DELETE /credentials/:name` 改為「D1 有 secret_ref → 刪 Workers Secret + D1 row; + 沒有(從未回填過,只存在舊 KV)→ fallback 刪舊 KV key」,避免 GET 改讀 D1 後 + 「查不到卻刪不掉」的孤兒資料。**全程無任何回傳密文值的路徑**(GET 只回 + name/service/sensitivity/created_at/last_used_at,DELETE 不讀值只刪)。CLI 薄殼 + `cli/src/commands/creds.ts` 加 `acr creds list`(讀 D1 顯示)/`acr creds replace + `(PUT 整筆覆寫)/`acr creds delete `;順手修好 `acr creds + push`(原實作對應 T5 之前的 `{name,encrypted,iv}` 格式已過期,改直接 PUT 明文 + `{name,value}`,否則會 400——這是既有 CLI 的誠實缺口修正,非新增範圍外功能)。 + **驗證**:`tsc --noEmit`(cli + cypher-executor)exit 0;`vitest run` + credentials.test.ts 全過(見 T8 記錄,同一檔涵蓋兩個任務);CLI `tsc` build 後 + `node dist/index.js creds --help`/`creds replace --help` 手動核對指令走線正確。 + **端到端真實驗證**(leo21c `arcrun-cypher-executor`): + `POST /credentials`(T5 既有路徑)建一筆 → `GET /credentials` 立刻讀到(D1 新讀 + 路徑生效,與 `/catalog` 回傳一致)→ `DELETE` 新路徑分支:CF API 直接核對 + `arcrun-cypher-executor` secrets 清單,刪除前有 `CRED_T89_PROBE_SECRET_*`、刪除後 + 該 secret 真的消失(只剩 `CF_SECRETS_API_TOKEN`/`ENCRYPTION_KEY`)+ D1 row 真的 + 清空 → 另外手工在 KV 塞一筆「只存在舊 KV、無 D1 row」的假資料,`DELETE` 走 + legacy-kv fallback 分支,CF API 核對 KV key 真的被刪除。兩分支皆對真實 + leo21c 帳號驗證通過,非模擬。 - [ ] T10 回填驗證 + 觀察期 → 廢 ENCRYPTION_KEY(§4.4,leo 明示放行)。 > **每個 cred 操作跨 TS / WASM / host-function / 兩個 store,必端到端實測**(防再假綠,mindset §7)。原「T1 不通則整案停」已兌現一輪:T1 負結果 → 整案停 → 總管裁決轉向 → T1.5 全過 → **T2-T9 解凍**。備援(若施工再撞死路):codegen binding + 自動重部署(Arcrun#2 總管裁決的方向 1)。