/** * Cloudflare KV REST API wrapper * 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI */ import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js'; const CF_API_BASE = 'https://api.cloudflare.com/client/v4'; export interface CfKvClientOptions { accountId: string; namespaceId: string; apiToken: string; } export class CfKvClient { private base: string; private headers: Record; constructor({ accountId, namespaceId, apiToken }: CfKvClientOptions) { this.base = `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`; this.headers = { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json', }; } async put(key: string, value: string): Promise { const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, { method: 'PUT', headers: { ...this.headers, 'Content-Type': 'text/plain' }, body: value, }); if (!res.ok) { const err = await res.text(); throw new Error(`KV PUT 失敗(${res.status}):${err.slice(0, 200)}`); } } async get(key: string): Promise { const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, { headers: this.headers, }); if (res.status === 404) return null; if (!res.ok) { const err = await res.text(); throw new Error(`KV GET 失敗(${res.status}):${err.slice(0, 200)}`); } return res.text(); } async list(prefix?: string): Promise> { const url = new URL(`${this.base}/keys`); if (prefix) url.searchParams.set('prefix', prefix); url.searchParams.set('limit', '1000'); const res = await fetch(url.toString(), { headers: this.headers }); if (!res.ok) { const err = await res.text(); throw new Error(`KV LIST 失敗(${res.status}):${err.slice(0, 200)}`); } const data = await res.json() as { result: Array<{ name: string; expiration?: number; metadata?: unknown }>; }; return data.result ?? []; } async delete(key: string): Promise { const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, { method: 'DELETE', headers: this.headers, }); if (!res.ok) { const err = await res.text(); throw new Error(`KV DELETE 失敗(${res.status}):${err.slice(0, 200)}`); } } } /** * Cloudflare Account-level API wrapper(self-hosted installer 用)。 * * 負責 acr init --self-hosted 的資源建立:驗 token、建/列 KV namespace、查 workers.dev subdomain。 * (不建 R2:R2 是 dead storage 且綁卡違背開源免費;見 init.ts step 2 + registry-canon Phase 1.5。) * 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。 * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2 */ export class CfAccountClient implements ResourceApi { private accountBase: string; private headers: Record; constructor(accountId: string, apiToken: string) { this.accountBase = `${CF_API_BASE}/accounts/${accountId}`; this.headers = { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json', }; } private async cf(path: string, init?: RequestInit): Promise { const { ok, status, result, error } = await this.cfRaw(path, init); if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`); return result as T; } /** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */ private async cfRaw( path: string, init?: RequestInit, ): Promise<{ ok: boolean; status: number; result?: T; error?: string }> { const res = await fetch(`${this.accountBase}${path}`, { ...init, headers: { ...this.headers, ...(init?.headers ?? {}) }, }); const data = await res.json().catch(() => null) as | { success: boolean; result: T; errors?: Array<{ message: string }> } | null; if (!res.ok || !data?.success) { return { ok: false, status: res.status, error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`, }; } return { ok: true, status: res.status, result: data.result }; } /** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/ async verifyAccess(): Promise { // GET /accounts/{id} 能通 = token 有此 account 的基本讀權限 await this.cf<{ id: string; name: string }>(''); } /** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/ async listKvNamespaces(): Promise> { const result = await this.cf>( '/storage/kv/namespaces?per_page=100', ); const map = new Map(); for (const ns of result) map.set(ns.title, ns.id); return map; } /** * 無條件新建一顆 KV namespace。 * * 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。 * 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路 * (安裝器取的名字跟我們的 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。 * 要不要建,一律先經過 resource-resolver 的 planResources 判斷;那裡只有在 * 「確定沒有任何已部署的 worker 綁過這個 binding」時才會排進 create。 */ async createKvNamespace(title: string): Promise { const result = await this.cf<{ id: string; title: string }>( '/storage/kv/namespaces', { method: 'POST', body: JSON.stringify({ title }) }, ); return result.id; } /** * 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。 * CF:`GET /accounts/{id}/workers/scripts/{script}/settings` → `result.bindings[]`。 * * - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。 * - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」—— * 把查不到當成不存在,就是 #97 的根因。 */ async getScriptBindings(script: string): Promise { const path = `/workers/scripts/${encodeURIComponent(script)}/settings`; const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path); if (!res.ok) { if (res.status === 404) return { deployed: false, bindings: [], vars: {} }; throw new Error(`讀 ${script} 綁定失敗:${res.error}`); } const raw = res.result?.bindings ?? []; // #106:同一份回應裡也帶著 plain_text var(實測 CF `/settings` 會回 `text` 值)。 // 舊版只挑資源類、把 var 整批丟掉 → 重部署等於把它們洗掉。 return { deployed: true, bindings: normalizeBindings(raw), vars: normalizeVars(raw) }; } /** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/ async getWorkersSubdomain(): Promise { const result = await this.cf<{ subdomain: string }>('/workers/subdomain'); return result.subdomain; } // D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified). async listD1Databases(): Promise> { const result = await this.cf>('/d1/database?per_page=100'); const map = new Map(); for (const db of result) map.set(db.name, db.uuid); return map; } /** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 */ async createD1Database(name: string): Promise { const result = await this.cf<{ uuid: string; name: string }>( '/d1/database', { method: 'POST', body: JSON.stringify({ name }) }, ); return result.uuid; } /** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */ async listVectorizeIndexes(): Promise { const result = await this.cf>('/vectorize/v2/indexes'); return (result ?? []).map(i => i.name); } /** * 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**,見 deploy.ts 常數說明)。 * 已存在(409 / already exists)視為成功——並行或重跑不該炸。沒有 ensure 版本: * 「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。 */ async createVectorizeIndex(name: string): Promise { const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', { method: 'POST', body: JSON.stringify({ name, config: { dimensions: 1024, metric: 'cosine' }, description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)', }), }); if (res.ok) return name; const detail = (res.error ?? '').toLowerCase(); if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name; throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`); } } /** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */ interface RawWorkerBinding { type?: string; name?: string; namespace_id?: string; id?: string; database_id?: string; index_name?: string; /** `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。 */ text?: string; } /** * 抽出已部署 worker 上的 `plain_text` var(#106)。 * * 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被 CLI 搬來搬去; * wrangler deploy 不會動 secret,它們自己會留著)。 */ function normalizeVars(raw: RawWorkerBinding[]): Record { const out: Record = {}; for (const b of raw) { if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text; } return out; } /** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */ function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] { const out: LiveBinding[] = []; for (const b of raw) { if (!b?.name) continue; if (b.type === 'kv_namespace') { const value = b.namespace_id ?? b.id; if (value) out.push({ kind: 'kv_namespace', binding: b.name, value }); } else if (b.type === 'd1' || b.type === 'd1_database') { const value = b.id ?? b.database_id; if (value) out.push({ kind: 'd1', binding: b.name, value }); } else if (b.type === 'vectorize') { if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name }); } } return out; }