Files
Arcrun/cypher-executor/src/routes/credentials.ts
T
uncle6me-web c4cee35adb feat(auth): 認證與資料分離——搬動知識資料時登入不再跟著壞掉(D61 / arcrun-rag#55)
leo 2026-08-10 下令:「登入認證資料要分離⋯⋯就算只有我一個人存在單獨的 json 檔也好,
它不能被改資料庫的連結導致無法登入。」「昨天不能登入 portal,今天不能登入 mcp,
這根本就是一個問題。」

病:portal 帳號住 KBDB(owner_id = {CONSOLE_TENANT}::portal),console 管理員帳密住
SESSIONS_KV。兩者都靠 binding 指過去,重裝/遷移一定會被重新指一次 ⇒ 保險箱的鑰匙
放在保險箱裡。2026-08-09 leo 資料一個位元組都沒動,卻被鎖在門外。

修:認證搬到 CF Workers per-script Secrets(掛在 script 上,與 bindings 兩套資源,
重部不會洗掉;journeys/gemini-key-lost-on-reinstall.md 與 installer worker.js:1148 皆有實證)。
- 新增 lib/portal-auth-store.ts:自足的 JSON,讀取零網路呼叫,>4.6KB 自動溢位分片
- portal.ts 的帳號讀寫全部改走它;KBDB 只留為舊實例的回退讀路徑,登入成功順手搬過去
- console-auth.ts 的第二份認證資料同樣搬離 KV
- 不牴觸 D38:KBDB 三張核心表不增不減,本案是把東西搬出去
- 沿用 credentials.ts 既有的 putWorkerSecret/deleteWorkerSecret,不另造第二套寫入路徑(D36)

明顯失敗(把 #10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
- 「這台實例讀不到任何登入資料」回 503 + code=auth_store_empty,且**不計入 5 次鎖定**
  (08-09 leo 就是被系統自己的誤判鎖了 15 分鐘)
- /console/setup 遇既有帳號改說「你剛才輸入的密碼沒有被採用」,不再只說「已設定過」
- /health 與 /console/auth-status 吐 auth_store 狀態(住哪、寫不寫得進去)

stage 實測撞到並修掉的坑:改 secret 會產生 worker 新版本,既有 isolate 讀到的還是舊 env
⇒ 「建好帳號立刻登入」有 15 秒以上 401,還被算進鎖定。加一層短 TTL 的 KV 加速器
(非真相源,只在 secret 查不到/密碼對不上時問一次),換 KV 不影響不變量。

驗收:stage(youlin)把知識資料庫換成另一顆空的 + 換租戶代號 + SESSIONS_KV 換成空的,
三樣一起換之後 portal / MCP /authorize / console 三條登入路徑仍全綠(複跑 3 次)。
對照組(舊版程式碼同樣換庫):登入回「email 或密碼錯誤」,5 次後鎖 15 分鐘。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:13:37 +08:00

506 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 送到 cyphercypher 短暫在記憶體
* 經手明文(不落地、不持久、不持金鑰)後直接 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 評估原話),故本檔加一個租戶級記憶體快取
* dirCacheper-isolateTTL 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 namewrangler.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_<NAME 大寫>_<sha256(api_key) 前 8 碼大寫>
* 加 api_key 的 hash 是為了避免跨租戶同名 credential(如兩個用戶都存 telegram_bot_token
* 撞名覆蓋彼此的 Workers Secretsecret 是掛在同一個 worker 上、全域命名空間,沒有租戶
* 隔離機制,必須自己用命名衍生隔離)。name 先前已被 validateName() 限制為 \w+
* 大寫後仍是合法的 env var 名(CF secret name 只接受 [A-Za-z0-9_])。
*/
async function deriveSecretRef(apiKey: string, name: string): Promise<string> {
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<void> {
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<void> {
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 T3acr 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 secretT9 治理端點用)。
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
*/
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
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<string, string>;
function parseMeta(row: KbdbEntryRow): CredentialMeta {
try {
const m = row.metadata_json ? (JSON.parse(row.metadata_json) as Record<string, unknown>) : {};
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 發 requestserver 端直連,不經 /kbdb/* proxy——那支是給 CLI 用的)。 */
async function kbdbCredFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
const { base, headers } = kbdbBase(env);
return fetch(`${base}${path}`, {
...init,
headers: { ...headers, ...(init?.headers as Record<string, string> | undefined) },
});
}
// ── 熱路徑快取(D38 效能要求:這份映射幾乎不變,帶快取才不會比舊版 D1 直查慢)─────────
//
// per-isolate 記憶體快取,key=apiKeyTTL 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<string, CachedDir> = {};
/** 寫入(建立/覆寫/刪除)後呼叫,讓下次熱路徑查詢重新打一次 KBDB(不吃到過期快取)。 */
export function invalidateCredentialCache(apiKey: string): void {
delete dirCache[apiKey];
}
/** 拉某租戶全部 credential 目錄列(快取層,60 秒 TTL)。給熱路徑(auth-dispatcher)與治理端點共用。 */
async function getCredentialDirectory(env: Bindings, apiKey: string): Promise<CachedDirRow[]> {
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<CredentialRefMap> {
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 本函式的內部 fetchfire-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 的 entrypage_name=name 精確比對,entry_type=credential 隔離)。 */
async function findCredentialEntry(env: Bindings, apiKey: string, name: string): Promise<KbdbEntryRow | null> {
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_atPATCH 不會動它);
* 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<void> {
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<CredentialRow[]> {
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<boolean> {
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 — 刪除 credentialT9:新家優先,舊 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 — 目錄唯讀 listMira 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);
}
});