/** * Cloudflare KV REST API wrapper * 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI */ 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、建 R2 bucket、查 workers.dev subdomain。 * 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。 * 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2 */ export class CfAccountClient { 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 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) { const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`; throw new Error(`CF API ${path} 失敗:${msg}`); } return 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(若同名已存在則回傳既有 id,冪等)。*/ async ensureKvNamespace(title: string, existing?: Map): Promise { const known = existing ?? (await this.listKvNamespaces()); const found = known.get(title); if (found) return found; const result = await this.cf<{ id: string; title: string }>( '/storage/kv/namespaces', { method: 'POST', body: JSON.stringify({ title }) }, ); return result.id; } /** 建立 R2 bucket(已存在則略過,冪等)。*/ async ensureR2Bucket(name: string): Promise { try { await this.cf<{ name: string }>('/r2/buckets', { method: 'POST', body: JSON.stringify({ name }), }); } catch (e) { // bucket 已存在 → CF 回 10004 之類;視為冪等成功 const msg = e instanceof Error ? e.message : String(e); if (/already exists|10004/i.test(msg)) return; throw e; } } /** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/ async getWorkersSubdomain(): Promise { const result = await this.cf<{ subdomain: string }>('/workers/subdomain'); return result.subdomain; } } /** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/ export async function encryptCredential(value: string, encryptionKey: string): Promise { if (!encryptionKey || encryptionKey.length < 64) { throw new Error( 'ARCRUN_ENCRYPTION_KEY 未設定或長度不足(需要 256-bit hex,即 64 個十六進位字元)\n' + '生成指令:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"' ); } const keyBytes = hexToUint8Array(encryptionKey); const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes.buffer as ArrayBuffer, { name: 'AES-GCM' }, false, ['encrypt'], ); const iv = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(value); const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, encoded); return JSON.stringify({ encrypted: uint8ArrayToBase64(new Uint8Array(cipherBuffer)), iv: uint8ArrayToBase64(iv), }); } function hexToUint8Array(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); } return bytes; } function uint8ArrayToBase64(arr: Uint8Array): string { return Buffer.from(arr).toString('base64'); }