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:
uncle6me-web
2026-08-07 17:27:18 +08:00
parent 60688c3108
commit 7ba78552a4
7 changed files with 343 additions and 244 deletions
+25 -10
View File
@@ -336,20 +336,35 @@ export async function downloadAndDeploy(
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql${migPath}`); failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql${migPath}`);
} }
// 3.6 credentials 目錄表(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at)。 // 3.6 credential template seedD38 圍牆修復,總管交辦,2026-08-07):credential 目錄改走
// 現行 credential 規範見 .claude/rules/01-tech-stack.md「Credential 儲存規範」。 // KBDB template 機制(entries 表 entry_type='credential',比照 recipe_stat/execution_log
// 同一顆 D1(與 KBDB base 共用),冪等 IF NOT EXISTS,套用機制與 0001_base.sql 完全相同 // 慣例),取代舊的獨立 credentials 表(0002,已退役,見該檔頭部說明)。冪等,套用機制
// (同一個 applyD1Migration helper,同一支 CF D1 query API)。D19:這張表不含密文, // 與 0001_base.sql 完全相同。密文本體仍住 Workers per-script Secrets(見
// 密文本體住在 Workers per-script Secrets(見 cypher-executor/src/routes/credentials.ts)。 // cypher-executor/src/routes/credentials.tsD19「擁有目錄不擁有內容物」不變
const credMigPath = join(root, 'kbdb', 'migrations', '0002_credentials.sql'); const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
if (existsSync(credMigPath)) { if (existsSync(credTplMigPath)) {
try { try {
await applyD1Migration(ctx, readFileSync(credMigPath, 'utf8')); await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
} catch (e) { } catch (e) {
failures.push(`D1 migration 0002_credentials (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`); failures.push(`D1 migration 0005_credential_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
} }
} else { } else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0002_credentials.sql${credMigPath}`); failures.push(`D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql${credTplMigPath}`);
}
// 3.6b 退役舊 credentials 表(D382026-08-07):把該表殘留資料(若有)搬進 entries 後
// 拆表,讓 KBDB 回到「只有三張核心表」的狀態。冪等且對「從未跑過 0002」的全新實例
// 無害(表不存在時本檔第一步先補空殼再立刻拆掉,詳見檔頭)。每次部署都會重跑,
// 但真資料只搬一次(NOT EXISTS 判斷防重複)。
const dropCredMigPath = join(root, 'kbdb', 'migrations', '0006_drop_credentials_table.sql');
if (existsSync(dropCredMigPath)) {
try {
await applyD1Migration(ctx, readFileSync(dropCredMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0006_drop_credentials_table (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql${dropCredMigPath}`);
} }
// 3.7 execution_log template seedKV 額度事故修復,2026-08-07):workflow 執行紀錄改走 // 3.7 execution_log template seedKV 額度事故修復,2026-08-07):workflow 執行紀錄改走
+33 -58
View File
@@ -21,98 +21,73 @@ import type { Bindings } from '../types';
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes'; import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
import { wasmWorkerUrl } from '../lib/component-loader'; import { wasmWorkerUrl } from '../lib/component-loader';
import { createArcrunHostFunctions } from '../lib/wasi-shim'; import { createArcrunHostFunctions } from '../lib/wasi-shim';
import { getCredentialSecretRefs, touchLastUsed } from '../routes/credentials';
// ── credential-store 遷移 T6/T7(方案 AD19──────────────────────────────── // ── credential-store 遷移 T6/T7(方案 AD19 D38 圍牆修復(2026-08-07───────────
// //
// 密文值住 cypher-executor 自己的 per-script secretsT5 寫入)。解密發生在獨立的 // 密文值住 cypher-executor 自己的 per-script secretsT5 寫入)。解密發生在獨立的
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。 // auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref]T4)取明文 // 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref→ 用 secret_get(ref)
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有 // (即 env[ref]T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。 // WASM 收到優先用它,沒有才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
// //
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。 // D38leo 2026-06-14 立、2026-08-07 擴大):目錄不再直連 D1,改走 KBDB HTTP API
// `credentials.ts` 的 `getCredentialSecretRefs`,內建 60 秒租戶級快取——這是熱路徑,
// 每次 workflow 執行都會呼叫,映射「幾乎不變」故快取後多數命中零網路呼叫,效能不因改走
// API 而變差,見 credentials.ts 檔頭「效能」段的實測數字)。
//
// 嚴格邊界(rule 02 §2.2):本檔只做「查目錄拿 ref → secret_get 取值 → 當字串塞 payload」。
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function // **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
// 內,解密/注入邏輯仍全在 WASM 零件。 // 內,解密/注入邏輯仍全在 WASM 零件。
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
interface CredentialRefRow {
name: string;
secret_ref: string;
}
/** /**
* 對一組 credential name,從新家(cypher per-script secrets)取明文。 * 對一組 credential name,從新家(cypher per-script secrets)取明文。
* *
* 流程:查 D1 `credentials`api_key + name)拿 `secret_ref` → 用 `secret_get(ref)` * 流程:查 KBDB credential 目錄api_key + name,快取命中零網路呼叫)拿 `secret_ref`
* host function,實作 = env[ref])取值。 * → 用 `secret_get(ref)`host function,實作 = env[ref])取值。
* *
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、 * ⚠️ 只把「目錄有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!), * 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。 * 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
* *
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。 * 取到值的 name 順手更新 last_used_at(§2.5 治理面 last_used,見 touchLastUsed——
* fire-and-forget、非同步、不阻塞本函式回傳,失敗吞掉)。
* *
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback), * KBDB 不可達 / 這個租戶還沒有任何 credential → 回空 map(整組走 fallback),
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。 * 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
*/ */
/** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
type ResolvedSecretMap = Record<string, string>;
export async function resolveSecretsFromNewHome( export async function resolveSecretsFromNewHome(
env: Bindings, env: Bindings,
apiKey: string, apiKey: string,
names: string[], names: string[],
): Promise<Record<string, string>> { ): Promise<ResolvedSecretMap> {
const resolved: Record<string, string> = {}; const resolved: ResolvedSecretMap = {};
if (names.length === 0) return resolved; if (names.length === 0) return resolved;
const db = env.CREDENTIALS_DB; // 1. 拿這個租戶的 credential 目錄(name → secret_ref,快取層見 credentials.ts
if (!db) return resolved; // 未綁 D1 → 整組走 fallback const refs = await getCredentialSecretRefs(env, apiKey);
if (Object.keys(refs).length === 0) return resolved; // 目錄空 / KBDB 不可達 → 整組走 fallback
// 1. 查 D1 拿每個 name 的 secret_ref
let rows: CredentialRefRow[];
try {
const placeholders = names.map(() => '?').join(', ');
const result = await db
.prepare(
`SELECT name, secret_ref FROM credentials
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(apiKey, ...names)
.all<CredentialRefRow>();
rows = result.results ?? [];
} catch {
// D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠
return resolved;
}
if (rows.length === 0) return resolved;
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref] // 2. 用 secret_ref 從新家取值(host function secret_get = env[ref]
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get; const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
const resolvedNames: string[] = []; const resolvedNames: string[] = [];
for (const row of rows) { for (const name of names) {
const value = await secretGet(row.secret_ref); const ref = refs[name];
if (!ref) continue; // 目錄沒這個 name → 缺席,走 fallback
const value = await secretGet(ref);
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV // null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
if (value === null) continue; if (value === null) continue;
resolved[row.name] = value; resolved[name] = value;
resolvedNames.push(row.name); resolvedNames.push(name);
} }
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name) // 3. 順手更新 last_used_at(只更新真的從新家取到值的 namefire-and-forget,非關鍵路徑
if (resolvedNames.length > 0) { if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
try {
const now = Math.floor(Date.now() / 1000);
const placeholders = resolvedNames.map(() => '?').join(', ');
await db
.prepare(
`UPDATE credentials SET last_used_at = ?
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(now, apiKey, ...resolvedNames)
.run();
} catch {
// last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
}
}
return resolved; return resolved;
} }
+243 -60
View File
@@ -7,24 +7,41 @@
* 寫入(POST 建立 / PUT 覆寫): * 寫入(POST 建立 / PUT 覆寫):
* 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫, * 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫,
* arcrun 自己也讀不回值——D19「不持有內容物」)。 * arcrun 自己也讀不回值——D19「不持有內容物」)。
* 2. D1 `credentials` 表只寫「目錄api_key/name/service/sensitivity/secret_ref/ * 2. 目錄(api_key/name/service/sensitivity/secret_ref/created_at/last_used_at
* created_at),**不含密文**。 * **不含密文**)走 KBDB HTTP API 寫,不再直連任何 D1
* 不再寫 KV / 不再寫明文密文到 D1。 * 不再寫 KV / 不再寫明文密文到 D1。
* *
* 傳輸格式:client **不做** AES-GCM 加密,明文值經 TLS 送到 cyphercypher 短暫在記憶體 * 傳輸格式:client **不做** AES-GCM 加密,明文值經 TLS 送到 cyphercypher 短暫在記憶體
* 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。(此為 2026-07-03 * 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。(此為 2026-07-03
* 定案並已落地的做法,取代更早的 `{name, encrypted, iv}` 格式;rule 01 已同步。) * 定案並已落地的做法,取代更早的 `{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`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留 * - `GET /credentials`:改讀 KBDB entries(與 `/credentials/catalog` 共用同一份查詢,同時
* `/catalog` 別名,Console 既有呼叫不受影響)。 * 保留 `/catalog` 別名,Console 既有呼叫不受影響)。
* - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row * - `DELETE /credentials/:name`:先查 KBDB 拿 secret_ref → 有則刪 Workers Secret + entries
* 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。 * row沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的
* 孤兒資料。
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Bindings } from '../types'; import type { Bindings } from '../types';
import { sha256Prefix } from '../lib/hash'; import { sha256Prefix } from '../lib/hash';
import { kbdbBase } from './kbdb-proxy';
export const credentialsRouter = new Hono<{ Bindings: Bindings }>(); export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
@@ -61,7 +78,7 @@ export async function storeCredential(
): Promise<void> { ): Promise<void> {
const secretRef = await deriveSecretRef(apiKey, name); const secretRef = await deriveSecretRef(apiKey, name);
await putWorkerSecret(env, secretRef, value); 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 { 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 發 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;
}
/** /**
* D1 upsert credential 目錄 row(不含密文) * 給熱路徑(auth-dispatcher.ts)用:回這個租戶所有 credential 的 name→secret_ref 對照
* created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新 * 快取命中=零網路呼叫;未命中打一次 KBDB list(見 getCredentialDirectory)。
* service/sensitivity/secret_refsecret_ref 是純函式衍生自 api_key+name,理論上覆寫時
* 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。
*/ */
async function upsertCredentialRow( export async function getCredentialSecretRefs(env: Bindings, apiKey: string): Promise<CredentialRefMap> {
db: D1Database, 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, apiKey: string,
name: string, name: string,
service: string | null, service: string | null,
sensitivity: 'standard' | 'high', sensitivity: 'standard' | 'high',
secretRef: string, secretRef: string,
): Promise<void> { ): Promise<void> {
const now = Math.floor(Date.now() / 1000); const existing = await findCredentialEntry(env, apiKey, name);
await db const meta: CredentialMeta = {
.prepare( service, sensitivity, secret_ref: secretRef,
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) last_used_at: existing ? parseMeta(existing).last_used_at : null,
VALUES (?, ?, ?, ?, ?, ?, NULL) };
ON CONFLICT(api_key, name) DO UPDATE SET if (existing) {
service = excluded.service, const res = await kbdbCredFetch(env, `/entries/${encodeURIComponent(existing.id)}`, {
sensitivity = excluded.sensitivity, method: 'PATCH',
secret_ref = excluded.secret_ref`, headers: { 'Content-Type': 'application/json' },
) body: JSON.stringify({ metadata_json: JSON.stringify(meta) }),
.bind(apiKey, name, service, sensitivity, secretRef, now) });
.run(); 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 { interface CredentialRow {
@@ -160,25 +342,26 @@ interface CredentialRow {
last_used_at: number | null; last_used_at: number | null;
} }
/** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */ /** KBDB 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */
async function listCredentialRows(db: D1Database, apiKey: string): Promise<CredentialRow[]> { async function listCredentialRows(env: Bindings, apiKey: string): Promise<CredentialRow[]> {
const rows = await db const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: '200' });
.prepare( const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`);
`SELECT name, service, sensitivity, created_at, last_used_at if (!res.ok) throw new Error(`credential 目錄查詢失敗:HTTP ${res.status}`);
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`, const body = (await res.json().catch(() => null)) as { entries?: KbdbEntryRow[] } | null;
) const rows = (body?.entries ?? [])
.bind(apiKey) .filter((e): e is KbdbEntryRow & { page_name: string } => !!e.page_name)
.all<CredentialRow>(); .map((e) => {
return rows.results ?? []; 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 本身,只內部使用)。 */ /** 給 `GET /portal/admin/ai` 之類「只要知道有沒有存過、不要值」的呼叫端用。 */
async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise<string | null> { export async function hasCredential(env: Bindings, apiKey: string, name: string): Promise<boolean> {
const row = await db const entry = await findCredentialEntry(env, apiKey, name);
.prepare(`SELECT secret_ref FROM credentials WHERE api_key = ? AND name = ?`) return entry !== null;
.bind(apiKey, name)
.first<{ secret_ref: string }>();
return row?.secret_ref ?? null;
} }
interface CredentialWriteBody { interface CredentialWriteBody {
@@ -203,13 +386,13 @@ async function writeCredential(
// 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回) // 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回)
await putWorkerSecret(env, secretRef, value); await putWorkerSecret(env, secretRef, value);
// 2. D1 目錄(不含密文) // 2. KBDB 目錄(不含密文)
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef); await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef);
return { secretRef, sensitivity }; return { secretRef, sensitivity };
} }
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄) // POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + KBDB entries 目錄)
credentialsRouter.post('/credentials', async (c) => { credentialsRouter.post('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key'); const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) { if (!apiKey) {
@@ -272,17 +455,17 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
const name = c.req.param('name'); const name = c.req.param('name');
try { try {
const secretRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name); const entry = await findCredentialEntry(c.env, apiKey, name);
if (secretRef) { if (entry) {
await deleteWorkerSecret(c.env, secretRef); const meta = parseMeta(entry);
await c.env.CREDENTIALS_DB if (meta.secret_ref) await deleteWorkerSecret(c.env, meta.secret_ref);
.prepare(`DELETE FROM credentials WHERE api_key = ? AND name = ?`) const res = await kbdbCredFetch(c.env, `/entries/${encodeURIComponent(entry.id)}`, { method: 'DELETE' });
.bind(apiKey, name) if (!res.ok) throw new Error(`credential 目錄刪除失敗:HTTP ${res.status}`);
.run(); invalidateCredentialCache(apiKey);
return c.json({ success: true, name, source: 'workers-secrets' }); return c.json({ success: true, name, source: 'workers-secrets' });
} }
// D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑, // KBDB 沒有這筆 entry:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑,
// 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。 // 避免「GET 改讀新家看不到、DELETE 卻刪不掉」的孤兒資料。
await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`); await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`);
return c.json({ success: true, name, source: 'legacy-kv' }); return c.json({ success: true, name, source: 'legacy-kv' });
} catch (e) { } catch (e) {
@@ -290,8 +473,8 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
} }
}); });
// GET /credentials/catalog — D1 目錄唯讀 listMira Console 完整版,Arcrun#3 console 系)。 // GET /credentials/catalog — 目錄唯讀 listMira Console 完整版,Arcrun#3 console 系)。
// 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑; // 與 GET /credentials(下方,改讀同一份 KBDB 查詢)是同一份資料的兩個路徑;
// /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。 // /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。
credentialsRouter.get('/credentials/catalog', async (c) => { credentialsRouter.get('/credentials/catalog', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key'); 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); return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
} }
try { 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 }); return c.json({ success: true, credentials: rows, total: rows.length });
} catch (e) { } catch (e) {
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事) // 誠實回報:KBDB 不可達 / 回錯(不假綠回空陣列裝沒事)
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); 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) => { credentialsRouter.get('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key'); const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) { if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
} }
try { 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 }); return c.json({ success: true, credentials: rows, total: rows.length });
} catch (e) { } catch (e) {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
+4 -7
View File
@@ -27,7 +27,7 @@ import { hashPassword, verifyPassword, randomHex, generatePassword } from '../li
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds'; import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑, // arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。 // 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
import { storeCredential } from './credentials'; import { storeCredential, hasCredential } from './credentials';
export const portalRouter = new Hono<{ Bindings: Bindings }>(); export const portalRouter = new Hono<{ Bindings: Bindings }>();
@@ -1170,13 +1170,10 @@ portalRouter.get('/portal/admin/ai', (c) =>
const tenantSlug = portalTenant(c.env); const tenantSlug = portalTenant(c.env);
let hasKey = false; let hasKey = false;
try { try {
const row = await c.env.CREDENTIALS_DB // D38 圍牆修復(2026-08-07):改走 credentials.ts 的 KBDB 目錄查詢,不直連 D1。
.prepare('SELECT 1 FROM credentials WHERE api_key = ? AND name = ? LIMIT 1') hasKey = await hasCredential(c.env, tenantSlug, 'gemini_api_key');
.bind(tenantSlug, 'gemini_api_key')
.first();
hasKey = !!row;
} catch { } catch {
// D1 未就緒 ⇒ 當作沒設定(不擋頁面),但也不假裝有 // KBDB 不可達 ⇒ 當作沒設定(不擋頁面),但也不假裝有
hasKey = false; hasKey = false;
} }
+5 -5
View File
@@ -30,11 +30,11 @@ export type Bindings = {
// Credential StoreAES-GCM 加密存放用戶 API token(舊家;credential-store-migration T7 // Credential StoreAES-GCM 加密存放用戶 API token(舊家;credential-store-migration T7
// 雙讀過渡期間仍是 fallback 讀路徑,本次 T5 只改「新寫入」,不動這裡) // 雙讀過渡期間仍是 fallback 讀路徑,本次 T5 只改「新寫入」,不動這裡)
CREDENTIALS_KV: KVNamespace; CREDENTIALS_KV: KVNamespace;
// credential-store-migration T2/T5D19「擁有目錄,不擁有內容物」):credential 目錄表 // ⚠️ D38 圍牆修復(2026-08-07)後零讀寫點:credential 目錄已改走 KBDB entries HTTP API
// api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,不含密文)。 // 見 cypher-executor/src/routes/credentials.ts),不再對這顆 D1 下任何 SQL。binding
// 與 KBDB base 共用同一顆 arcrun-kbdb D1self-hosted 由 deploy.ts 注入用戶自己的 // 因 wrangler.toml 被權限鎖住(D38 決策所述)暫留宣告,比照 ANALYTICS_KV 同一模式
// database_id,比照 kbdb/wrangler.toml 同一套 database_id 注入機制)。密文本體不在這裡, // commit 60688c3binding 留在 toml,程式碼零讀寫點)。舊表資料遷移路徑見
// 住在 Workers per-script Secrets(見 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID // kbdb/migrations/0006_drop_credentials_table.sql
CREDENTIALS_DB: D1Database; CREDENTIALS_DB: D1Database;
// Analytics:執行統計(fire-and-forgetkey = stats:{workflowId}:{timestamp} // Analytics:執行統計(fire-and-forgetkey = stats:{workflowId}:{timestamp}
ANALYTICS_KV: KVNamespace; ANALYTICS_KV: KVNamespace;
+26 -103
View File
@@ -1,110 +1,33 @@
/** /**
* credential * credentials 🔴 D38
* *
* `putWorkerSecret` / `deleteWorkerSecret` *
* Cloudflare API`fetch` api.cloudflare.comwrangler.test.toml * 2026-08-07 D38 credential credentials + SQL
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID CF API * KBDB entriesentry_type='credential'+ HTTP API 111
* - D1-only GET /credentials/credentials/catalog * SQL
* - DELETE D1 row fallback KV deleteWorkerSecret *
* CF Workers Secrets API / leo21c * agent `// placeholder — see edit below`
* curl credential-store-migration.md T8/T9 * "edit below" vitest `Tests: no tests`
* **** CP
* route
*
* ****
*
* routes/credentials.ts
* 1. KBDB HTTP API owner_id = api_key
* 2. secret_ref
* 3. deprecated
* 4. ** SQL** .prepare/.exec/.batch
* 5. KBDB secret_ref D19
*/ */
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect } from 'vitest';
import { env, SELF } from 'cloudflare:test';
const API_KEY = 'test-tenant-t89'; describe('credentials 路由(D38 改走 KBDB API 後)', () => {
it('🔴 測試待重寫 —— 見本檔頭部清單(刻意紅燈,別刪掉改成空檔)', () => {
async function insertCredentialRow( expect.fail(
name: string, 'D38 圍牆修復後 credential 改走 KBDB entries + HTTP API' +
secretRef: string, '舊的 SQL 版測試已作廢、新測試尚未寫。' +
extra: Partial<{ service: string | null; sensitivity: string; last_used_at: number | null }> = {}, '要補的五項見本檔頭部註解。',
): Promise<void> {
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<void> {
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<Record<string, unknown>> };
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('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();
}); });
}); });
+7 -1
View File
@@ -1,12 +1,18 @@
-- credential-primitives-wasm — credential-store-migration T2D19D1 只存目錄,不存密文) -- credential-primitives-wasm — credential-store-migration T2D19D1 只存目錄,不存密文)
-- SDD: system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md §2.2 -- SDD: system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md §2.2
-- --
-- ⚠️ 已退役(D38 圍牆修復,2026-08-07):本檔在 KBDB 裡多開了一張獨立表,違反「KBDB 只有
-- 三張核心表」的鐵律(見 kbdb-usage skill「反例」)。deploy.ts 已不再套用本檔——新裝置改跑
-- 0005_credential_template.sqltemplate 定義)+ 0006_drop_credentials_table.sql(把舊資料
-- 搬進 entries 後拆表)。本檔保留純供歷史對照(欄位定義與 0005 的 slots_json 一字對應),
-- 不要再照抄這個形狀;新資料類型請照 0003/0004/0005 的手法(template + entries)。
--
-- 密文本體不在這裡:值住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理 API 唯寫)。 -- 密文本體不在這裡:值住在 CF Workers per-script Secrets(掛在 cypher worker 上,管理 API 唯寫)。
-- 這張表只存「目錄」:租戶(api_key) / 名字 / 服務 / 敏感度 / 指向 Workers Secrets 的 env var 名(secret_ref)。 -- 這張表只存「目錄」:租戶(api_key) / 名字 / 服務 / 敏感度 / 指向 Workers Secrets 的 env var 名(secret_ref)。
-- 冪等(IF NOT EXISTS),與 0001_base.sql 同模式,套用機制走 cli/src/lib/deploy.ts applyD1Migration。 -- 冪等(IF NOT EXISTS),與 0001_base.sql 同模式,套用機制走 cli/src/lib/deploy.ts applyD1Migration。
-- 同一顆 D1(與 KBDB base 共用 arcrun-kbdb),不新建第二顆。 -- 同一顆 D1(與 KBDB base 共用 arcrun-kbdb),不新建第二顆。
CREATE TABLE IF NOT EXISTS credentials ( CREATE TABLE IF NOT EXISTS credentials ( -- kbdb-sql-ok: 已退役的歷史存底,deploy.ts 不再套用本檔(改跑 0005+0006),保留純供欄位對照
api_key TEXT NOT NULL, -- 租戶 api_key TEXT NOT NULL, -- 租戶
name TEXT NOT NULL, -- credential 名(= auth-recipe required_secrets[].key,如 telegram_bot_token name TEXT NOT NULL, -- credential 名(= auth-recipe required_secrets[].key,如 telegram_bot_token
service TEXT, -- 對應 servicetelegram / notion …),可空 service TEXT, -- 對應 servicetelegram / notion …),可空