feat(credential-store-migration): T5 寫入路徑改走 Workers Secrets + D1 ref

POST/PUT /credentials 改寫:密文值 PUT 進 CF Workers per-script Secrets
(唯寫,D19 不持有內容物),D1 credentials 表只存目錄(不含密文)。
secret_ref = CRED_<NAME>_<sha256(api_key)[:8]>,避免跨租戶撞名。
DELETE/GET /credentials 本次不動(仍走舊 KV,T9 範圍),已誠實標注。

新增 CREDENTIALS_DB D1 binding(與 KBDB base 共用同一顆 arcrun-kbdb,
沿用既有 database_id 注入機制)+ CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID
env vars(CF_ACCOUNT_ID 由 deploy.ts 自動注入,同 WORKER_SUBDOMAIN 模式)。

§2.4 選項甲落地:client 不再加密,明文值經 TLS 傳輸,cypher 短暫經手
明文不落地不持金鑰——與舊版 01-tech-stack.md 傳輸格式不同,是本 SDD
對舊格式的刻意取代(§6 Q-b 仍需 leo 明確接受)。

驗證:tsc --noEmit exit 0;vitest 26/27(1 pre-existing 無關失敗)。
端到端:真實部署 leo21c arcrun-cypher-executor,POST/PUT /credentials
成功寫入 Workers Secrets + D1 row(CF API + D1 query 雙重確認),
清理測試資料後 restore wrangler.toml(無帳號專屬 id 殘留 git)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018D6QoC5waFkcjc2N7csJBB
This commit is contained in:
Claude
2026-07-03 23:04:59 +00:00
parent c3c0a8b17d
commit c24edbbdb6
6 changed files with 243 additions and 33 deletions
+10
View File
@@ -462,6 +462,16 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
);
}
// credential-store-migration T3CF_ACCOUNT_IDvars,非機密識別碼)換成用戶自己的帳號 id,
// 比照 WORKER_SUBDOMAIN 注入同一套機制。cypher 寫入 /credentials 時要用它組
// CF Workers Scripts secrets 管理 API URL(見 routes/credentials.ts)。
if (ctx.accountId && /CF_ACCOUNT_ID/.test(toml)) {
toml = toml.replace(
/(CF_ACCOUNT_ID\s*=\s*")[^"]*(")/,
`$1${ctx.accountId}$2`,
);
}
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
toml = toml.replace(
+3
View File
@@ -24,6 +24,9 @@ export function isRecipeHash(id: string): boolean {
return /^rec_[0-9a-f]{8}$/.test(id);
}
/** 匯出給 credential-store-migration T5 用(secret_ref 命名衍生租戶隔離 hash,非解密/簽章邏輯)。 */
export { sha256Prefix };
async function sha256Prefix(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const buf = await crypto.subtle.digest('SHA-256', data);
+172 -31
View File
@@ -1,56 +1,197 @@
/**
* Credentials API — 多租戶 credential 管理
*
* POST /credentials
* Body: { name: string, encrypted: string, iv: string }
* Header: X-Arcrun-API-Key
* → 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV
* credential-store-migration T5D19「擁有目錄,不擁有內容物」,
* system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md §2.3-2.4/§3):
*
* DELETE /credentials/:name
* Header: X-Arcrun-API-Key
* → 刪除 {api_key}:cred:{name}
* 新寫入(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),**不含密文**。
* 不再寫 KV / 不再寫明文密文到 D1。
*
* GET /credentials
* Header: X-Arcrun-API-Key
* → 列出當前 api_key 下所有 credential 名稱(不含加密值)
* §2.4 選項甲(傾向):client 不再 AES-GCM 加密,明文值經 TLS 送到 cypher,cypher 短暫在記憶體
* 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。這與舊版
* `.claude/rules/01-tech-stack.md` 記載的「傳輸格式 {name, encrypted, iv}」不同——是本 SDD
* 2026-07-03 T1.5 spike 定案)對舊格式的刻意取代,SDD §6 Q-b 仍列為需 leo 明確接受的
* 誠實 trade-off(本次實作先落地,若 leo 不接受選項甲需回頭改)。
*
* DELETE /credentials/:name 與 GET /credentials 本次**不動**T9 治理端點的範圍),
* 仍讀寫舊 KV 路徑——這代表新寫入的 credential 目前查不到舊 GET /credentials 列表裡
* (誠實缺口,見 credential-store-migration.md T5 完成註記;驗證改用直接查 D1)。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { sha256Prefix } from '../lib/hash';
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
// POST /credentials — 上傳加密 credential
/** 本 worker 的 script namewrangler.toml `name`),官方與 self-hosted 都用同一個名字,
* 只有帳號(CF_ACCOUNT_ID)不同——CF Workers Scripts secrets API 是 accountId+scriptName 定位。*/
const CYPHER_SCRIPT_NAME = 'arcrun-cypher-executor';
/**
* secret_ref 命名規則(credential-store-migration §2.3「以 CRED_ 前綴隔離命名空間」):
* CRED_<NAME 大寫>_<sha256(api_key) 前 8 碼大寫>
* 加 api_key 的 hash 是為了避免跨租戶同名 credential(如兩個用戶都存 telegram_bot_token
* 撞名覆蓋彼此的 Workers Secretsecret 是掛在同一個 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()}`;
}
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 對齊)。
*/
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 T3acr 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}`);
}
}
/**
* D1 upsert credential 目錄 row(不含密文)。
* created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新
* service/sensitivity/secret_refsecret_ref 是純函式衍生自 api_key+name,理論上覆寫時
* 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。
*/
async function upsertCredentialRow(
db: D1Database,
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();
}
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. D1 目錄(不含密文)
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef);
return { secretRef, sensitivity };
}
// POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄)
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 {
name?: string;
encrypted?: string;
iv?: string;
} | null;
if (!body?.name || !body.encrypted || !body.iv) {
return c.json({ error: '缺少必要欄位:name, encrypted, iv' }, 400);
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);
}
const name = body.name.trim();
if (!/^\w+$/.test(name)) {
return c.json({ error: 'credential name 只能包含英文字母、數字和底線' }, 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);
}
const kvKey = `${apiKey}:cred:${name}`;
const record = JSON.stringify({ encrypted: body.encrypted, iv: body.iv });
await c.env.CREDENTIALS_KV.put(kvKey, record);
return c.json({ success: true, name });
});
// DELETE /credentials/:name — 刪除 credential
// 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(未動:仍是舊 KV 路徑,T9 範圍)
credentialsRouter.delete('/credentials/:name', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
@@ -64,7 +205,7 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
return c.json({ success: true, name });
});
// GET /credentials — 列出 credential 名稱(不含值)
// GET /credentials — 列出 credential 名稱(不含值)(未動:仍是舊 KV 路徑,T9 範圍)
credentialsRouter.get('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
+14 -1
View File
@@ -27,8 +27,15 @@ export type Bindings = {
RECIPES: KVNamespace;
// Webhook Storekey = workflow namevalue = Workflow JSON
WEBHOOKS: KVNamespace;
// Credential StoreAES-GCM 加密存放用戶 API token
// Credential StoreAES-GCM 加密存放用戶 API token(舊家;credential-store-migration T7
// 雙讀過渡期間仍是 fallback 讀路徑,本次 T5 只改「新寫入」,不動這裡)
CREDENTIALS_KV: KVNamespace;
// credential-store-migration T2/T5D19「擁有目錄,不擁有內容物」):credential 目錄表
// api_key/name/service/sensitivity/secret_ref/created_at/last_used_at,不含密文)。
// 與 KBDB base 共用同一顆 arcrun-kbdb D1self-hosted 由 deploy.ts 注入用戶自己的
// database_id,比照 kbdb/wrangler.toml 同一套 database_id 注入機制)。密文本體不在這裡,
// 住在 Workers per-script Secrets(見 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。
CREDENTIALS_DB: D1Database;
// Analytics:執行統計(fire-and-forgetkey = stats:{workflowId}:{timestamp}
ANALYTICS_KV: KVNamespace;
// UsersOAuth 登入用戶帳號(key = user:{provider}:{provider_id}
@@ -49,6 +56,12 @@ export type Bindings = {
SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID)
// KBDB 整合
KBDB_INTERNAL_TOKEN?: string;
// credential-store-migration T3/T5:能打 CF Workers Scripts secrets 管理 API 的 token
// wrangler secretPUT/DELETE .../workers/scripts/{script}/secrets,唯寫讀不回值,
// D19 語意仍成立)+ 對應帳號 id(非機密,比照 WORKER_SUBDOMAIN 由 deploy.ts 自動注入)。
// 缺其一 → /credentials 寫入路徑誠實回報未就緒(不假綠),見 T3。
CF_SECRETS_API_TOKEN?: string;
CF_ACCOUNT_ID?: string;
// KBDB Base worker URLrecipe 成功記錄 /recipe-stats/record、fragment 抓取)。
// 未設 fallback 見各使用點(recipe-expander 預設 kbdb.finally.click)。kbdb-base SDD §7.1。
KBDB_BASE_URL?: string;
+15
View File
@@ -39,6 +39,15 @@ id = "25bef01d079148919578894434d58c4d"
binding = "SESSIONS_KV"
id = "455d0505c7534883a4d4985ab8295857"
# credential-store-migration T2/T5D19):credential 目錄表(不含密文)。
# 與 KBDB base 共用同一顆 arcrun-kbdb D1(比照 kbdb/wrangler.toml 同一個 database id)。
# self-hosteddeploy.ts injectWranglerConfig 對任何 toml 內的 database_id 賦值行一律注入
# ctx.d1DatabaseId(既有機制,見 kbdb/wrangler.toml 同款註解),本檔沿用不需額外改 deploy.ts。
[[d1_databases]]
binding = "CREDENTIALS_DB"
database_name = "arcrun-kbdb"
database_id = "0c580910-e00b-4f8e-9c57-ac54ea52242f" # 官方 prod D1arcrun-kbdb);self-hosted 由上述機制注入用戶自己的 id
# 2026-06-04:移除 WASM_BUCKET R2 binding。R2 wasm 路徑早已 dead(平台零件 = 獨立 Worker
# 不從 R2 動態讀),保留只會誤導且 R2 需綁信用卡,與 open source 零費用核心衝突。
# SDD: .agents/specs/component-registry-canon/tasks.md Phase 1.5registry 已於 2026-05-07 移除,此為 cypher-executor 補清)
@@ -107,6 +116,12 @@ ENVIRONMENT = "production"
# MULTI_TENANT = "true"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# credential-store-migration T3(§2.3 寫入路徑需要的 token+account id):
# CF_SECRETS_API_TOKEN 是機密,透過 `wrangler secret put CF_SECRETS_API_TOKEN` 設定(不進 toml)。
# CF_ACCOUNT_ID 非機密(帳號識別碼,不是憑證),比照 WORKER_SUBDOMAIN 由 deploy.ts 自動注入
# injectWranglerConfig 新增 CF_ACCOUNT_ID 注入,ctx.accountId 是 init/update 早就有的值)。
CF_ACCOUNT_ID = ""
# Component worker subdomainworkers.dev 帳號 subdomain
# cypher-executor fetch component worker 一律走 arcrun-{name}.{WORKER_SUBDOMAIN}.workers.dev
# 避開同 zone (*.arcrun.dev) 自循環死鎖,見 arcrun.md P0 #92026-05-13
@@ -197,7 +197,35 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla
`fn(...)` 呼叫——用 probe 測試證實 `kv_get` 的 import 同樣不可直接呼叫,確認是既有架構的
環境限制非 secret_get 缺陷,已移除該 2 案例並在測試檔內註記;pointer 機制的真實驗證改由
T5 wrangler 部署端到端涵蓋。
- [ ] T5 寫入路徑:`POST/PUT /credentials` 改寫 Workers SecretsAPI `PUT`+ D1 ref(§2.4)。
- [x] T5 寫入路徑:`POST/PUT /credentials` 改寫 Workers SecretsAPI `PUT`+ D1 ref(§2.4)。2026-07-03 完成:
`cypher-executor/src/routes/credentials.ts` 整檔改寫 POST(建立)+ 新增 PUT `/credentials/:name`
(覆寫):兩者共用 `writeCredential()` → 1) `putWorkerSecret()` PUT 進 CF Workers Scripts secrets
管理 API(唯寫)2) `upsertCredentialRow()` D1 upsert(不含密文,覆寫時保留原 `created_at`)。
`secret_ref` 命名=`CRED_<NAME 大寫>_<sha256(api_key) 前 8 碼大寫>``cypher-executor/src/lib/hash.ts`
匯出既有 `sha256Prefix`,避免跨租戶同名 credential 撞名覆蓋彼此的 secret;純函式衍生非解密/簽章邏輯,
不違 rule 02 §2.2)。DELETE/GET `/credentials` 本次刻意不動(仍讀寫舊 KV,T9 治理端點範圍),
已在檔頭與 §3 comment 誠實標注此缺口。`types.ts` Bindings 加 `CREDENTIALS_DB: D1Database`
(與 KBDB base 共用同一顆 `arcrun-kbdb` D1+ `CF_SECRETS_API_TOKEN?` / `CF_ACCOUNT_ID?`
`wrangler.toml``[[d1_databases]] binding="CREDENTIALS_DB"`(沿用 kbdb 同款 database_id
注入機制,deploy.ts 不用改)+ `[vars] CF_ACCOUNT_ID=""` 佔位(T3 由 deploy.ts 自動注入非機密
帳號 id,同 WORKER_SUBDOMAIN 模式)。**§2.4 選項甲落地**client 不再 AES-GCM 加密,明文值經
TLS 傳輸,cypher 短暫在記憶體經手明文不落地不持金鑰)——與舊版 `01-tech-stack.md` 記載的
`{name,encrypted,iv}` 傳輸格式不同,此為本 SDD 對舊格式的刻意取代,§6 Q-b 仍列需 leo 明確
接受的誠實 trade-off(先落地,若不接受選項甲需回頭改,見任務完成報告「撞牆」段)。
驗證:`tsc --noEmit` exit 0cli + cypher-executor 皆是);`vitest run` 26/27 通過(1 個
pre-existing 無關失敗,見 T4 記錄,non-regression 已用 git stash 驗證)。**端到端證據**(真實
leo21c `arcrun-cypher-executor` worker,非測試替身——寫入端點本身就是要驗的東西,無替代):
比照 mistakes #23 手法本地 patch `wrangler.toml`KV/D1 id、CF_ACCOUNT_ID、WORKER_SUBDOMAIN、
KBDB_BASE_URL 換 leo21c 真實值,strip `[ai]`/`[[routes]]`)→ `wrangler deploy --dry-run` 核對
binding 表(`env.CREDENTIALS_DB (arcrun-kbdb) D1 Database` 等全對)→ 真部署 → `wrangler secret put
CF_SECRETS_API_TOKEN`(沿用 `CLOUDFLARE_API_TOKEN`)→ `curl POST /credentials`
`{"success":true,"secret_ref":"CRED_TELEGRAM_BOT_TOKEN_9BB9FB82",...}` → CF API `GET
.../workers/scripts/arcrun-cypher-executor/secrets` 確認該 secret 真的存在 → D1 query 確認
對應 rowapi_key/name/service/sensitivity/secret_ref/created_at 全對)→ `curl PUT
/credentials/telegram_bot_token` 覆寫值,確認 `secret_ref` 不變、`created_at` 不變(覆寫語意
正確)→ 清理:CF API `DELETE .../secrets/{ref}` + D1 `DELETE FROM credentials`,二次查詢確認
皆已清空。部署完成後**立刻 restore `wrangler.toml` 備份**git 追蹤版本無 leo21c 帳號專屬 id
殘留(已 grep 確認)。
- [ ] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5+ 更新 last_used。
- [ ] T7 雙讀 fallback(§4.1)。
- [ ] T8 回填端點 `POST /credentials/migrate-to-workers-secrets`(§4.2,冪等可審)。