/** * Credentials API — 多租戶 credential 管理 * * 現行做法(D19「擁有目錄,不擁有內容物」;規範見 .claude/rules/01-tech-stack.md * 「Credential 儲存規範」。arcrun 不自管任何加密金鑰): * * 寫入(POST 建立 / PUT 覆寫): * 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫, * arcrun 自己也讀不回值——D19「不持有內容物」)。 * 2. 目錄(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at, * **不含密文**)走 KBDB HTTP API 寫,不再直連任何 D1。 * 不再寫 KV / 不再寫明文密文到 D1。 * * 傳輸格式:client **不做** AES-GCM 加密,明文值經 TLS 送到 cypher,cypher 短暫在記憶體 * 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。(此為 2026-07-03 * 定案並已落地的做法,取代更早的 `{name, encrypted, iv}` 格式;rule 01 已同步。) * * D38 圍牆修復(總管交辦,2026-08-07;leo「任何東西禁止用 SQL 語句存取資料,一律 API」): * 目錄舊家是 KBDB 裡多開的一張獨立 credentials 表(0002_credentials.sql,違規),現改走 * KBDB 三張核心表——entries 表一列(entry_type='credential',page_name=name 當冪等鍵, * owner_id=api_key 隔離租戶,其餘欄位打包進 metadata_json),template 定義見 * kbdb/migrations/0005_credential_template.sql,舊表資料遷移+拆表見 0006。連法比照既有 * execution-logger.ts / portal.ts 慣例:kbdbBase(env) 組 base+headers,直接 fetch KBDB * HTTP API,不經自己的 /kbdb/* proxy route(那支是給 CLI 用的,server 端直連 base 更省一跳)。 * * 效能(D38 評估要求「帶數字」,見 system-dev/wiki/decisions-summary.md D38 段): * 熱路徑(auth-dispatcher.ts resolveSecretsFromNewHome,每次 workflow 執行都會查一次)原本 * 直連 D1、零快取;改走 HTTP 後若一樣「每次查一次」延遲只會變差(多一趟公網往返)。這份 * name→secret_ref 映射「幾乎不變」(D38 評估原話),故本檔加一個租戶級記憶體快取 * (dirCache,per-isolate,TTL 60 秒),寫入(POST/PUT/DELETE)時主動失效,讓熱路徑多數 * 命中零網路呼叫。見下方 getCredentialDirectory / invalidateCredentialCache。 * * 治理端點: * - `GET /credentials`:改讀 KBDB entries(與 `/credentials/catalog` 共用同一份查詢,同時 * 保留 `/catalog` 別名,Console 既有呼叫不受影響)。 * - `DELETE /credentials/:name`:先查 KBDB 拿 secret_ref → 有則刪 Workers Secret + entries * row;沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的 * 孤兒資料。 */ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { sha256Prefix } from '../lib/hash'; import { kbdbBase } from './kbdb-proxy'; export const credentialsRouter = new Hono<{ Bindings: Bindings }>(); /** 本 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 命名規則(以 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()}`; } /** * 存一筆 credential 進「新家」(CF Workers Secrets 明文 + D1 目錄)。 * * 給 OAuth callback 這類非 /credentials 端點的內部呼叫者用(存 provider token 供 * auth primitive 零件取用)。與 `POST /credentials` 共用同一條寫入路徑, * 確保只有一套儲存。 */ export async function storeCredential( env: Bindings, apiKey: string, name: string, value: string, service: string | null, ): Promise { const secretRef = await deriveSecretRef(apiKey, name); await putWorkerSecret(env, secretRef, value); await upsertCredentialEntry(env, apiKey, name, service, 'standard', secretRef); } 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 對齊)。 */ export 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}`); } } /** * 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。 * 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。 */ export 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}`); } } // ── KBDB 目錄存取(D38:零 SQL,一律走 entries HTTP API)────────────────────────── const CREDENTIAL_ENTRY_TYPE = 'credential'; /** entries 表回來的一列(本檔只取用得到的欄位,避免耦合 KBDB 內部型別)。 */ interface KbdbEntryRow { id: string; page_name: string | null; owner_id: string | null; metadata_json: string | null; created_at: number; } interface CredentialMeta { service: string | null; sensitivity: 'standard' | 'high'; secret_ref: string; last_used_at: number | null; } /** name → secret_ref 對照(給熱路徑用;獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */ type CredentialRefMap = Record; function parseMeta(row: KbdbEntryRow): CredentialMeta { try { const m = row.metadata_json ? (JSON.parse(row.metadata_json) as Record) : {}; return { service: typeof m.service === 'string' ? m.service : null, sensitivity: m.sensitivity === 'high' ? 'high' : 'standard', secret_ref: typeof m.secret_ref === 'string' ? m.secret_ref : '', last_used_at: typeof m.last_used_at === 'number' ? m.last_used_at : null, }; } catch { // 壞資料誠實視為空目錄列,不讓損毀的 metadata_json 炸整條路徑 return { service: null, sensitivity: 'standard', secret_ref: '', last_used_at: null }; } } /** 對 KBDB base 發 request(server 端直連,不經 /kbdb/* proxy——那支是給 CLI 用的)。 */ async function kbdbCredFetch(env: Bindings, path: string, init?: RequestInit): Promise { const { base, headers } = kbdbBase(env); return fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init?.headers as Record | undefined) }, }); } // ── 熱路徑快取(D38 效能要求:這份映射幾乎不變,帶快取才不會比舊版 D1 直查慢)───────── // // per-isolate 記憶體快取,key=apiKey,TTL 60 秒。auth-dispatcher.ts 的 // resolveSecretsFromNewHome() 每次 workflow 執行都會呼叫,命中快取=零網路呼叫; // 未命中才打一次 KBDB(一次列出該租戶全部 credential,通常個位數到十位數筆,遠比逐名查便宜)。 // 寫入路徑(upsert/delete)主動 invalidate,保證「剛存的 credential 立刻查得到」不受 TTL 拖延。 // 快取容器用 plain object——apiKey 皆為服務端衍生字串,非使用者可控鍵名。 interface CachedDirRow { id: string; name: string; secret_ref: string; service: string | null; sensitivity: 'standard' | 'high'; last_used_at: number | null; } interface CachedDir { rows: CachedDirRow[]; fetchedAt: number; } const DIR_CACHE_TTL_MS = 60_000; const dirCache: Record = {}; /** 寫入(建立/覆寫/刪除)後呼叫,讓下次熱路徑查詢重新打一次 KBDB(不吃到過期快取)。 */ export function invalidateCredentialCache(apiKey: string): void { delete dirCache[apiKey]; } /** 拉某租戶全部 credential 目錄列(快取層,60 秒 TTL)。給熱路徑(auth-dispatcher)與治理端點共用。 */ async function getCredentialDirectory(env: Bindings, apiKey: string): Promise { const now = Date.now(); const cached = dirCache[apiKey]; if (cached && now - cached.fetchedAt < DIR_CACHE_TTL_MS) return cached.rows; const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' }); const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`); if (!res.ok) { // KBDB 不可達 / 回錯:誠實回空(呼叫端各自決定 fallback,不快取失敗結果避免卡住恢復) return []; } const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null; const rows: CachedDirRow[] = (body?.entries ?? []) .filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name) .map((e) => { const meta = parseMeta(e); return { id: e.id, name: e.page_name, secret_ref: meta.secret_ref, service: meta.service, sensitivity: meta.sensitivity, last_used_at: meta.last_used_at, }; }); dirCache[apiKey] = { rows, fetchedAt: now }; return rows; } /** * 給熱路徑(auth-dispatcher.ts)用:回這個租戶所有 credential 的 name→secret_ref 對照。 * 快取命中=零網路呼叫;未命中打一次 KBDB list(見 getCredentialDirectory)。 */ export async function getCredentialSecretRefs(env: Bindings, apiKey: string): Promise { const rows = await getCredentialDirectory(env, apiKey); const out: CredentialRefMap = {}; for (const r of rows) { if (r.secret_ref) out[r.name] = r.secret_ref; } return out; } /** * 治理面 last_used_at 更新(非關鍵路徑,best-effort,不阻塞呼叫端)。 * 直接用快取裡已知的 id/其餘欄位組 PATCH,不額外多打一次查詢。找不到快取(代表這個租戶 * 本次請求根本沒查到目錄,不太可能發生——resolveSecretsFromNewHome 只在有 secret_ref 命中時 * 才會呼叫本函式)就跳過,不為了治理欄位額外多打一輪 KBDB。 * 呼叫端刻意不 await 本函式的內部 fetch(fire-and-forget,見 auth-dispatcher.ts),失敗吞掉。 */ export function touchLastUsed(env: Bindings, apiKey: string, names: string[]): void { const cached = dirCache[apiKey]; if (!cached || names.length === 0) return; const now = Math.floor(Date.now() / 1000); for (const r of cached.rows) { if (!names.includes(r.name)) continue; const meta: CredentialMeta = { service: r.service, sensitivity: r.sensitivity, secret_ref: r.secret_ref, last_used_at: now, }; kbdbCredFetch(env, `/entries/${encodeURIComponent(r.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata_json: JSON.stringify(meta) }), }).catch(() => { /* 治理面欄位,非關鍵路徑,失敗不影響任何主流程 */ }); r.last_used_at = now; // 快取內同步更新,避免同一 TTL 視窗內下一次讀到舊值 } } /** 找某租戶某 credential 的 entry(page_name=name 精確比對,entry_type=credential 隔離)。 */ async function findCredentialEntry(env: Bindings, apiKey: string, name: string): Promise { const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, page_name: name, limit: '1', }); const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`); if (!res.ok) throw new Error(`KBDB /entries 查詢失敗:HTTP ${res.status}`); const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null; return body?.entries?.[0] ?? null; } /** * upsert credential 目錄列(不含密文)。 * created_at 只在首次建立時寫入(entries 表自帶 created_at,PATCH 不會動它); * last_used_at 覆寫時保留原值——secret_ref 是純函式衍生自 api_key+name,理論上覆寫時值不會 * 變,這裡仍走同一條寫入路徑以求同時支援「首次建立」與「覆寫」兩種呼叫路徑(比照舊 D1 版本)。 */ async function upsertCredentialEntry( env: Bindings, apiKey: string, name: string, service: string | null, sensitivity: 'standard' | 'high', secretRef: string, ): Promise { const existing = await findCredentialEntry(env, apiKey, name); const meta: CredentialMeta = { service, sensitivity, secret_ref: secretRef, last_used_at: existing ? parseMeta(existing).last_used_at : null, }; if (existing) { const res = await kbdbCredFetch(env, `/entries/${encodeURIComponent(existing.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata_json: JSON.stringify(meta) }), }); if (!res.ok) throw new Error(`credential 目錄更新失敗:HTTP ${res.status}`); } else { const res = await kbdbCredFetch(env, `/entries`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entry_type: CREDENTIAL_ENTRY_TYPE, owner_id: apiKey, page_name: name, metadata_json: JSON.stringify(meta), }), }); if (!res.ok) throw new Error(`credential 目錄建立失敗:HTTP ${res.status}`); } invalidateCredentialCache(apiKey); } interface CredentialRow { name: string; service: string | null; sensitivity: string; created_at: number; last_used_at: number | null; } /** KBDB 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */ async function listCredentialRows(env: Bindings, apiKey: string): Promise { const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' }); const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`); if (!res.ok) throw new Error(`credential 目錄查詢失敗:HTTP ${res.status}`); const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null; const rows = (body?.entries ?? []) .filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name) .map((e) => { const meta = parseMeta(e); return { name: e.page_name, service: meta.service, sensitivity: meta.sensitivity, created_at: e.created_at, last_used_at: meta.last_used_at }; }); // entries API 已用 created_at DESC 排序,這裡不重排(保持與舊版 D1 query 相同排序語意) return rows; } /** 給 `GET /portal/admin/ai` 之類「只要知道有沒有存過、不要值」的呼叫端用。 */ export async function hasCredential(env: Bindings, apiKey: string, name: string): Promise { const entry = await findCredentialEntry(env, apiKey, name); return entry !== null; } 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. KBDB 目錄(不含密文) await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef); return { secretRef, sensitivity }; } // POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + KBDB entries 目錄) 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 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); } 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); } }); // 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(T9:新家優先,舊 KV 為回退) // D19 對齊:能刪的只有「目錄 row + Workers Secret 這個密文本體」,本端點從頭到尾不讀值。 credentialsRouter.delete('/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'); try { const entry = await findCredentialEntry(c.env, apiKey, name); if (entry) { const meta = parseMeta(entry); if (meta.secret_ref) await deleteWorkerSecret(c.env, meta.secret_ref); const res = await kbdbCredFetch(c.env, `/entries/${encodeURIComponent(entry.id)}`, { method: 'DELETE' }); if (!res.ok) throw new Error(`credential 目錄刪除失敗:HTTP ${res.status}`); invalidateCredentialCache(apiKey); return c.json({ success: true, name, source: 'workers-secrets' }); } // KBDB 沒有這筆 entry:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑, // 避免「GET 改讀新家看不到、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 — 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。 // 與 GET /credentials(下方,改讀同一份 KBDB 查詢)是同一份資料的兩個路徑; // /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 listCredentialRows(c.env, apiKey); return c.json({ success: true, credentials: rows, total: rows.length }); } catch (e) { // 誠實回報:KBDB 不可達 / 回錯(不假綠回空陣列裝沒事) return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); } }); // GET /credentials — 列出 credential 目錄(改讀 KBDB,只回 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, 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); } });