D38:credential 目錄改走 KBDB API(零原生 SQL)+舊表退場;測試刻意留紅燈
存取層:credentials.ts / auth-dispatcher.ts / portal.ts 全改走 kbdbBase()+fetch 到 /entries(照 execution-logger.ts 既有慣例),.prepare/.exec/.batch 命中 0。 資料層:0005 seed credential template;0006 把舊表資料搬進 entries 後拆表; 0002 標退役、deploy.ts 不再套用(加 kbdb-sql-ok 留痕,純歷史對照)。 總管親驗四項(不聽 agent 自評): 1 三個檔 .prepare/.exec/.batch 命中 0;六個檔全部通過 kbdb-api-wall-guard 2 0006 的 INSERT 欄位(id/entry_type/owner_id/page_name/metadata_json/ created_at/updated_at)與 0001_base 的 entries 表逐一對得上 3 不可逆風險查官方:D1 batch 是 transaction、任一句失敗整批 rollback; exec 出錯則「執行停止、後續不執行」=> 兩種語意下 INSERT 失敗都不會跑到 DROP TABLE,用戶 credential 目錄不會遺失 4 0006 搬在拆之前、冪等(NOT EXISTS 防重複)、豁免標記有留痕且理由正當 (拆表是牆內施工,API 不提供也不該提供拆表) 🔴 抓到一個假綠並修正:agent 中途被中斷,把 111 行的 credentials.test.ts 砍成一行「// placeholder — see edit below」,那個 edit 從來沒發生, 且 setup.ts 被刪。vitest 對這種檔案回報「Tests: no tests」, 很容易被讀成「沒失敗=通過」——正是 CP 記過的 「這條 route 曾整條消失過沒人發現」同型。 處置:還原 setup.ts/vitest.config.ts,credentials.test.ts 改成 **刻意會失敗的紅燈**並在檔頭列出要補的五項。空檔會被誤認為綠,紅燈不會。 未部署。本批要先上 stage 驗過才進 prod(leo 08-07 定)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,24 +7,41 @@
|
||||
* 寫入(POST 建立 / PUT 覆寫):
|
||||
* 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫,
|
||||
* arcrun 自己也讀不回值——D19「不持有內容物」)。
|
||||
* 2. D1 `credentials` 表只寫「目錄」(api_key/name/service/sensitivity/secret_ref/
|
||||
* created_at),**不含密文**。
|
||||
* 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`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留
|
||||
* `/catalog` 別名,Console 既有呼叫不受影響)。
|
||||
* - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row;
|
||||
* 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。
|
||||
* - `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 }>();
|
||||
|
||||
@@ -61,7 +78,7 @@ export async function storeCredential(
|
||||
): Promise<void> {
|
||||
const secretRef = await deriveSecretRef(apiKey, name);
|
||||
await putWorkerSecret(env, secretRef, value);
|
||||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service, 'standard', secretRef);
|
||||
await upsertCredentialEntry(env, apiKey, name, service, 'standard', secretRef);
|
||||
}
|
||||
|
||||
function validateName(name: unknown): name is string {
|
||||
@@ -124,32 +141,197 @@ async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* D1 upsert credential 目錄 row(不含密文)。
|
||||
* created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新
|
||||
* service/sensitivity/secret_ref(secret_ref 是純函式衍生自 api_key+name,理論上覆寫時
|
||||
* 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。
|
||||
* 給熱路徑(auth-dispatcher.ts)用:回這個租戶所有 credential 的 name→secret_ref 對照。
|
||||
* 快取命中=零網路呼叫;未命中打一次 KBDB list(見 getCredentialDirectory)。
|
||||
*/
|
||||
async function upsertCredentialRow(
|
||||
db: D1Database,
|
||||
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 now = Math.floor(Date.now() / 1000);
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(api_key, name) DO UPDATE SET
|
||||
service = excluded.service,
|
||||
sensitivity = excluded.sensitivity,
|
||||
secret_ref = excluded.secret_ref`,
|
||||
)
|
||||
.bind(apiKey, name, service, sensitivity, secretRef, now)
|
||||
.run();
|
||||
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 {
|
||||
@@ -160,25 +342,26 @@ interface CredentialRow {
|
||||
last_used_at: number | null;
|
||||
}
|
||||
|
||||
/** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
|
||||
async function listCredentialRows(db: D1Database, apiKey: string): Promise<CredentialRow[]> {
|
||||
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<CredentialRow>();
|
||||
return rows.results ?? [];
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 查單一 credential 的 secret_ref(治理端點刪除用;不對外回傳 secret_ref 本身,只內部使用)。 */
|
||||
async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise<string | null> {
|
||||
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;
|
||||
/** 給 `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 {
|
||||
@@ -203,13 +386,13 @@ async function writeCredential(
|
||||
// 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回)
|
||||
await putWorkerSecret(env, secretRef, value);
|
||||
|
||||
// 2. D1 目錄(不含密文)
|
||||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||
// 2. KBDB 目錄(不含密文)
|
||||
await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef);
|
||||
|
||||
return { secretRef, sensitivity };
|
||||
}
|
||||
|
||||
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄)
|
||||
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + KBDB entries 目錄)
|
||||
credentialsRouter.post('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
@@ -272,17 +455,17 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
|
||||
const name = c.req.param('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();
|
||||
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' });
|
||||
}
|
||||
// D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
|
||||
// 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。
|
||||
// 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) {
|
||||
@@ -290,8 +473,8 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials/catalog — D1 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。
|
||||
// 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑;
|
||||
// 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');
|
||||
@@ -299,22 +482,22 @@ credentialsRouter.get('/credentials/catalog', async (c) => {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
try {
|
||||
const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey);
|
||||
const rows = await listCredentialRows(c.env, apiKey);
|
||||
return c.json({ success: true, credentials: rows, total: rows.length });
|
||||
} catch (e) {
|
||||
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事)
|
||||
// 誠實回報:KBDB 不可達 / 回錯(不假綠回空陣列裝沒事)
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /credentials — 列出 credential 目錄(T9:改讀 D1,只回 metadata,絕不含值/secret_ref)
|
||||
// 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.CREDENTIALS_DB, apiKey);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user