c4cee35adb
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>
506 lines
23 KiB
TypeScript
506 lines
23 KiB
TypeScript
/**
|
||
* 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_<NAME 大寫>_<sha256(api_key) 前 8 碼大寫>
|
||
* 加 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<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 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<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 發 request(server 端直連,不經 /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=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<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 本函式的內部 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<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_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<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 — 刪除 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);
|
||
}
|
||
});
|