feat(credentials): T6 讀取/注入接新家 + T7 雙讀 fallback (credential-store-migration 方案 A)

D19「擁有目錄不擁有內容物」遷移的讀取斷點。密文住 cypher per-script secrets、
D1 只存 secret_ref、auth worker 讀不到 cypher 的 secrets → cypher 先取值塞 payload。

WASM 端(T7 fallback 骨架,零行為改變可獨立部署驗):
- auth_static_key/main.go + auth_service_account/main.go:Input 加 resolved_secrets,
  解密處改「有 resolved 就用、沒有才 fallback 舊 kv_get+crypto_decrypt」。default 等於舊碼。
- tinygo build 兩支通過,copy 到 .component-builds/*/component.wasm。

TS 端(T6 主路徑):
- auth-dispatcher.ts 新增 resolveSecretsFromNewHome:查 D1 拿 secret_ref →
  secret_get(env[ref], T4) 取明文 → 組 map(取不到的 name 缺席,不放空字串) →
  更新 last_used_at。tryAuthDispatch + resolveCredentialRefs 都塞 resolved_secrets。
- rule 02 §2.2 對齊:只查 ref/取值/塞字串,不解密不展開模板不組 JWT。

驗證:cypher-executor + cli tsc exit 0;vitest 41/42(新增 auth-dispatcher.test.ts 6 案例全過,
剩 1 pre-existing 無關失敗)。待 leo21c 部署驗 WASM 端到端(不由本任務部署,acr update
硬綁 GitHub codeload=mistakes #23)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-05 13:44:26 +08:00
parent 55a47d7c18
commit ed2e42e007
7 changed files with 314 additions and 26 deletions
Binary file not shown.
+113 -1
View File
@@ -20,6 +20,102 @@
import type { Bindings } from '../types'; 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';
// ── credential-store 遷移 T6/T7(方案 A,D19)────────────────────────────────
//
// 密文值住 cypher-executor 自己的 per-script secretsT5 寫入)。解密發生在獨立的
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref]T4)取明文
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
//
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
// 內,解密/注入邏輯仍全在 WASM 零件。
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
interface CredentialRefRow {
name: string;
secret_ref: string;
}
/**
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
*
* 流程:查 D1 `credentials`api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
* host function,實作 = env[ref])取值。
*
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
*
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。
*
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback),
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
*/
export async function resolveSecretsFromNewHome(
env: Bindings,
apiKey: string,
names: string[],
): Promise<Record<string, string>> {
const resolved: Record<string, string> = {};
if (names.length === 0) return resolved;
const db = env.CREDENTIALS_DB;
if (!db) return resolved; // 未綁 D1 → 整組走 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]
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
const resolvedNames: string[] = [];
for (const row of rows) {
const value = await secretGet(row.secret_ref);
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
if (value === null) continue;
resolved[row.name] = value;
resolvedNames.push(row.name);
}
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
if (resolvedNames.length > 0) {
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;
}
/** 對應 Phase 1-4 會部署的 auth primitive Worker */ /** 對應 Phase 1-4 會部署的 auth primitive Worker */
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']); const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
@@ -62,6 +158,13 @@ export async function tryAuthDispatch(
if (!recipe) return null; if (!recipe) return null;
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null; if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
// T6:先從新家(cypher per-script secrets)取 recipe 需要的 credential 明文。
// 拿到的塞進 `resolved_secrets`WASM 優先用;沒拿到的 name 缺席 → WASM fallback 舊 KVT7)。
const secretNames = recipe.required_secrets
.filter(s => !s.optional)
.map(s => s.key);
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, secretNames);
// 走新路徑:HTTP POST 到對應 auth primitive Worker // 走新路徑:HTTP POST 到對應 auth primitive Worker
// 走 workers.dev 避開同 zone 死鎖(P0 #9 // 走 workers.dev 避開同 zone 死鎖(P0 #9
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN); const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
@@ -72,6 +175,8 @@ export async function tryAuthDispatch(
action: 'authenticate', action: 'authenticate',
api_key: apiKey, api_key: apiKey,
service, service,
// 只在有取到值時帶上(空物件也無妨,WASM 對 nil/空 map 同樣 fallback
resolved_secrets: resolvedSecrets,
}), }),
}); });
@@ -159,6 +264,12 @@ export async function resolveCredentialRefs(
collectCredentialNames(data, names); collectCredentialNames(data, names);
if (names.size === 0) return data; if (names.size === 0) return data;
const nameList = [...names];
// T6:先從新家取這些 credential 的明文,塞 `resolved_secrets`WASM 優先用它,
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KVT7)。
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN); const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
@@ -166,7 +277,8 @@ export async function resolveCredentialRefs(
body: JSON.stringify({ body: JSON.stringify({
action: 'resolve_credentials', action: 'resolve_credentials',
api_key: apiKey, api_key: apiKey,
names: [...names], names: nameList,
resolved_secrets: resolvedSecrets,
}), }),
}); });
@@ -0,0 +1,105 @@
/**
* credential-store-migration T6/T7(方案 AD19)測試:auth-dispatcher 的
* `resolveSecretsFromNewHome`——「查 D1 拿 secret_ref → secret_get(env[ref]) 取值 → 組 map」。
*
* 覆蓋 T6 主路徑 + T7 雙讀分流的判準:
* - D1 有 ref 且 secret 存在(新家有值)→ name 進 mappayload 會含 resolved_secrets[name]
* - D1 無 ref(從未回填)→ name 缺席 map(讓 WASM fallback 舊 KVT7
* - D1 有 ref 但 secret 不存在(新家還沒寫值)→ secret_get 回 null → name 缺席(不放空字串!)
* - 取到值的 name 更新 last_used_at
*
* secret_get 的實作是 host function `env[ref]`wasi-shim),故測試把 CRED_* 值放進傳入的
* env 物件模擬「per-script secret 已注入」,用真實 D1 binding 存目錄(同 credentials.test.ts)。
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { env } from 'cloudflare:test';
import type { Bindings } from '../src/types';
import { resolveSecretsFromNewHome } from '../src/actions/auth-dispatcher';
const API_KEY = 'test-tenant-t67';
async function insertRefRow(name: string, secretRef: string): Promise<void> {
await env.CREDENTIALS_DB
.prepare(
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
)
.bind(API_KEY, name, null, 'standard', secretRef, Math.floor(Date.now() / 1000))
.run();
}
async function clearRows(): Promise<void> {
await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run();
}
/** 把 CRED_* secret 值疊到真實 env 上(模擬 per-script secret 已注入 runtime)。 */
function envWithSecrets(secrets: Record<string, string>): Bindings {
return { ...(env as unknown as Record<string, unknown>), ...secrets } as unknown as Bindings;
}
describe('resolveSecretsFromNewHome (T6/T7 方案 A)', () => {
beforeEach(clearRows);
it('D1 有 ref 且新家有值 → name 進 mapT6 命中新家)', async () => {
await insertRefRow('openai_key', 'CRED_OPENAI_KEY_ABCDEF01');
const testEnv = envWithSecrets({ CRED_OPENAI_KEY_ABCDEF01: 'sk-live-123' });
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['openai_key']);
expect(resolved).toEqual({ openai_key: 'sk-live-123' });
});
it('D1 無 ref(從未回填)→ name 缺席 map(走 WASM fallback 舊 KVT7', async () => {
// D1 沒有這個 name 的 row
const testEnv = envWithSecrets({ CRED_ANYTHING: 'unused' });
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['never_migrated']);
expect(resolved).not.toHaveProperty('never_migrated');
expect(resolved).toEqual({});
});
it('D1 有 ref 但新家沒值 → secret_get 回 null → name 缺席(不放空字串)', async () => {
await insertRefRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01');
// env 裡沒有 CRED_NOTION_TOKEN_ABCDEF01 → env[ref] 為 undefined → secret_get 回 null
const testEnv = envWithSecrets({});
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['notion_token']);
// 關鍵:缺席,不是 { notion_token: '' }(空字串會讓 WASM 誤判命中用空值)
expect(resolved).not.toHaveProperty('notion_token');
expect(resolved).toEqual({});
});
it('混合:一把有值一把沒 ref → 只有有值的進 map', async () => {
await insertRefRow('has_value', 'CRED_HAS_VALUE_ABCDEF01');
const testEnv = envWithSecrets({ CRED_HAS_VALUE_ABCDEF01: 'the-value' });
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['has_value', 'no_ref']);
expect(resolved).toEqual({ has_value: 'the-value' });
});
it('空 names → 空 map(零開銷)', async () => {
const resolved = await resolveSecretsFromNewHome(envWithSecrets({}), API_KEY, []);
expect(resolved).toEqual({});
});
it('取到值後更新 last_used_at(治理面)', async () => {
await insertRefRow('used_key', 'CRED_USED_KEY_ABCDEF01');
const before = await env.CREDENTIALS_DB
.prepare(`SELECT last_used_at FROM credentials WHERE api_key = ? AND name = ?`)
.bind(API_KEY, 'used_key')
.first<{ last_used_at: number | null }>();
expect(before?.last_used_at).toBeNull();
const testEnv = envWithSecrets({ CRED_USED_KEY_ABCDEF01: 'v' });
await resolveSecretsFromNewHome(testEnv, API_KEY, ['used_key']);
const after = await env.CREDENTIALS_DB
.prepare(`SELECT last_used_at FROM credentials WHERE api_key = ? AND name = ?`)
.bind(API_KEY, 'used_key')
.first<{ last_used_at: number | null }>();
expect(typeof after?.last_used_at).toBe('number');
});
});
@@ -62,6 +62,11 @@ type Input struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Service string `json:"service"` Service string `json:"service"`
Request json.RawMessage `json:"request,omitempty"` Request json.RawMessage `json:"request,omitempty"`
// ResolvedSecretscredential-store 遷移 T6/T7 方案 AD19)——cypher-executor 從自己的
// per-script secrets(新家)用 secret_get(D1 ref) 取到的明文,key = credential name
// (對 SA 而言就是 required_secrets[0].key)。有值優先用,沒有才 fallback 舊 KV+crypto_decrypt。
// default(此欄空/nil)行為完全等於遷移前的舊碼。
ResolvedSecrets map[string]string `json:"resolved_secrets,omitempty"`
} }
type SecretRequirement struct { type SecretRequirement struct {
@@ -176,28 +181,38 @@ func main() {
return return
} }
// 2. 解密 service account JSON (慣例:required_secrets[0] 是 SA JSON) // 2. 取得 service account JSON 明文 (慣例:required_secrets[0] 是 SA JSON)
saReq := recipe.RequiredSecrets[0] saReq := recipe.RequiredSecrets[0]
kvKey := input.APIKey + ":cred:" + saReq.Key
encJSON, s := kvGet(kvKey)
if s == 2 {
writeError("缺少 credential: " + saReq.Key + " (" + saReq.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
return
}
if s != 0 {
writeError("kv_get 失敗(credential " + saReq.Key + ")")
return
}
var rec EncryptedRecord var saJSONStr string
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil { // T6 方案 Acypher 已從新家(per-script secrets)取到明文就直接用,不走 KV。
writeError("credential " + saReq.Key + " 格式錯誤: " + err.Error()) // input.ResolvedSecrets 為 nil 時此 map 讀取回 ("", false),行為與遷移前一致。
return if v, ok := input.ResolvedSecrets[saReq.Key]; ok {
} saJSONStr = v
saJSONStr, ok := cryptoDecrypt(rec.Encrypted, rec.IV) } else {
if !ok { // T7 雙讀 fallback:新家沒給 → 舊路徑 kv_get + crypto_decrypt。
writeError("credential " + saReq.Key + " 解密失敗") kvKey := input.APIKey + ":cred:" + saReq.Key
return encJSON, s := kvGet(kvKey)
if s == 2 {
writeError("缺少 credential: " + saReq.Key + " (" + saReq.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
return
}
if s != 0 {
writeError("kv_get 失敗(credential " + saReq.Key + ")")
return
}
var rec EncryptedRecord
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
writeError("credential " + saReq.Key + " 格式錯誤: " + err.Error())
return
}
decrypted, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
if !ok {
writeError("credential " + saReq.Key + " 解密失敗")
return
}
saJSONStr = decrypted
} }
// 3. 解析 service account JSON // 3. 解析 service account JSON
@@ -50,6 +50,11 @@ type Input struct {
// Namesresolve_credentials action 用——要解密的 credential 名稱清單 // Namesresolve_credentials action 用——要解密的 credential 名稱清單
// (用戶在 workflow node.data 寫 {{credential.NAME}} 時,graph-executor 收集後傳入)。 // (用戶在 workflow node.data 寫 {{credential.NAME}} 時,graph-executor 收集後傳入)。
Names []string `json:"names,omitempty"` Names []string `json:"names,omitempty"`
// ResolvedSecretscredential-store 遷移 T6/T7 方案 AD19)——cypher-executor 已從
// 自己的 per-script secrets(新家)用 secret_get(D1 ref) 取到的明文,key = credential name。
// 有值就優先用,沒有才 fallback 舊路徑(kv_get {api_key}:cred:{name} + crypto_decrypt),
// 這個 fallback 就是雙讀(T7)。default(此欄空/nil)行為完全等於遷移前的舊碼。
ResolvedSecrets map[string]string `json:"resolved_secrets,omitempty"`
} }
type SecretRequirement struct { type SecretRequirement struct {
@@ -143,6 +148,15 @@ func main() {
if req.Optional { if req.Optional {
continue continue
} }
// T6 方案 Acypher 已從新家(per-script secrets)取到明文就直接用,不走 KV。
// input.ResolvedSecrets 為 nil 時此 map 讀取回 ("", false),行為與遷移前一致。
if v, ok := input.ResolvedSecrets[req.Key]; ok {
secrets[req.Key] = v
continue
}
// T7 雙讀 fallback:新家沒給 → 走舊路徑 kv_get + crypto_decrypt。
kvKey := input.APIKey + ":cred:" + req.Key kvKey := input.APIKey + ":cred:" + req.Key
encJSON, s := kvGet(kvKey) encJSON, s := kvGet(kvKey)
if s == 2 { if s == 2 {
@@ -220,6 +234,14 @@ func handleResolveCredentials(input Input) {
if name == "" { if name == "" {
continue continue
} }
// T6 方案 A:cypher 已從新家取到明文就直接用(同 authenticate 分支)。
if v, ok := input.ResolvedSecrets[name]; ok {
credentials[name] = v
continue
}
// T7 雙讀 fallback:新家沒給 → 舊路徑 kv_get + crypto_decrypt。
kvKey := input.APIKey + ":cred:" + name kvKey := input.APIKey + ":cred:" + name
encJSON, s := kvGet(kvKey) encJSON, s := kvGet(kvKey)
if s == 2 { if s == 2 {
@@ -250,7 +250,34 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla
正確)→ 清理:CF API `DELETE .../secrets/{ref}` + D1 `DELETE FROM credentials`,二次查詢確認 正確)→ 清理:CF API `DELETE .../secrets/{ref}` + D1 `DELETE FROM credentials`,二次查詢確認
皆已清空。部署完成後**立刻 restore `wrangler.toml` 備份**git 追蹤版本無 leo21c 帳號專屬 id 皆已清空。部署完成後**立刻 restore `wrangler.toml` 備份**git 追蹤版本無 leo21c 帳號專屬 id
殘留(已 grep 確認)。 殘留(已 grep 確認)。
- [] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5+ 更新 last_used。 - [x] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5+ 更新 last_used。
2026-07-05 本機(有 TinyGo 0.40.1)接手,方案 Aleo 已拍板〕**接續 2026-07-04 [cloud-worker]
揭露的正確落地方式**cypher 先取值塞 payload,因 auth worker 讀不到 cypher 的 secrets):
**WASM 端(T7 fallback 骨架,可獨立部署驗)**:
- `registry/components/auth_static_key/main.go`Input 加 `ResolvedSecrets map[string]string`
`authenticate` 的解密迴圈與 `handleResolveCredentials` 都改為「`input.ResolvedSecrets[key]`
有值就用,沒有才 fallback 舊 `kvGet`+`cryptoDecrypt`」。default(此欄 nil)行為完全等於遷移前舊碼。
- `registry/components/auth_service_account/main.go`SA JSON 取得同型(有 resolved 就用,沒有才舊路徑)。
- `tinygo build -target=wasi` 兩支皆通過(static_key 1.13MB / service_account 1.18MB,在 2MB 限制內),
copy 到 `.component-builds/{name}/component.wasm`。
**TS 端(T6 主路徑)**`cypher-executor/src/actions/auth-dispatcher.ts` 新增
`resolveSecretsFromNewHome(env, apiKey, names)`:查 D1 `credentials`api_key+name IN names)拿
`secret_ref` → `createArcrunHostFunctions(env, apiKey).secret_get(ref)`T4host 端 = `env[ref]`
取明文 → 組 `{name:value}` map,**取不到值的 name 缺席(不放空字串)** 讓 WASM fallbackT7)→
取到值的 name `UPDATE credentials SET last_used_at`。`tryAuthDispatch` 取 recipe 非 optional
`required_secrets[].key` 當 names、`resolveCredentialRefs` 用已收集的 names,兩者都把
`resolved_secrets` 塞進送 auth WASM 的 POST body。**rule 02 §2.2 對齊**:只查 ref/取值/塞字串,
不解密、不展開模板、不組 JWT(grep 確認無 `crypto.subtle`/`interpolate`/`{{secret.`/`BUILTIN_*`)。
**驗證**`cypher-executor` + `cli` `tsc --noEmit` 皆 exit 0`vitest run` 41/42(新增
`tests/auth-dispatcher.test.ts` 6 案例全過:D1 有 ref+新家有值→進 map/無 ref→缺席走 fallback
有 ref 但新家沒值 secret_get 回 null→缺席不放空字串/混合/空 names/取值後更新 last_used_at
剩 1 個 pre-existing 無關失敗 executor.test.ts「不存在」措辭,見 T8 記錄,非本次退步——
git baseline 35/36 → 41/42,只增不減)。
**待 leo21c 部署驗(本任務不部署,`acr update` 硬綁 GitHub codeload=mistakes #23,交總管/後續)**
WASM 端到端(resolved_secrets 命中新家的真實注入)在 vitest 測不到(JSPI import 限制,同 T4 記錄),
`secret_get` 的 pointer/memory-write 也待部署端到端涵蓋。TS 側 `resolveSecretsFromNewHome` 的
D1 查詢 + secret_get + last_used 更新已用真實 D1 binding + fake env(模擬 per-script secret 注入)
單元覆蓋。
2026-07-04 [cloud-worker] 卡在框架級工具缺口,非設計問題〕**T6 需要修改 2026-07-04 [cloud-worker] 卡在框架級工具缺口,非設計問題〕**T6 需要修改
`registry/components/auth_static_key/main.go`(與 `auth_service_account/main.go`** `registry/components/auth_static_key/main.go`(與 `auth_service_account/main.go`**
這兩個 auth primitive 是獨立部署的 TinyGo WASM Worker,密文值現在住在 這兩個 auth primitive 是獨立部署的 TinyGo WASM Worker,密文值現在住在
@@ -273,10 +300,17 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla
決定是否要在雲端環境補裝工具鏈(例如允許 proxy 放行 github.com release 下載,或 決定是否要在雲端環境補裝工具鏈(例如允許 proxy 放行 github.com release 下載,或
改用其他 TinyGo 取得管道)。整案不硬繞(不改用「TS 直接組 header」這種違反 rule 02 改用其他 TinyGo 取得管道)。整案不硬繞(不改用「TS 直接組 header」這種違反 rule 02
的捷徑),留給有 TinyGo 環境的一方接手 main.go 改動 + 重新編譯部署。 的捷徑),留給有 TinyGo 環境的一方接手 main.go 改動 + 重新編譯部署。
- [] T7 雙讀 fallback(§4.1)。〔同上,隨 T6 main.go 改動一併落地——D1 查得到 secret_ref - [x] T7 雙讀 fallback(§4.1)。〔2026-07-05 本機,隨 T6 一併落地。〕如原設計預期:
走新路徑,查不到就是 WASM 原有的 `kv_get`+`crypto_decrypt` 分支,本來就會自然發生, **不需額外的「fallback 判斷」程式碼**——T6 的 `resolved_secrets` 就是「有給才用、沒給
不需要額外的「fallback 判斷」程式碼,只要 T6 的 `resolved_secrets` 是「有給才用、沒給 就照舊 kv_get+crypto_decrypt」的設計(WASM 端 `input.ResolvedSecrets[key]` 的 `ok` 判斷即分流)。
就照舊」的設計即可。同樣卡在 T6 的 TinyGo 前置。〕 雙讀在**兩層**自然成立:
(1) cypher 層——`resolveSecretsFromNewHome` 查 D1 查不到 ref(從未回填)或 `secret_get` 回 null
(新家還沒值)→ 該 name 不進 `resolved_secrets` map
(2) WASM 層——收到的 `resolved_secrets` 缺該 key → 走既有 `kvGet`+`cryptoDecrypt` 舊路徑。
新寫入(T5)一律新家;舊資料未回填(T8)前仍可讀(舊 KV 不刪,§4.3 回滾錨點不動)。
單元測試涵蓋「無 ref→缺席」「有 ref 但新家沒值 secret_get 回 null→缺席」兩條 fallback 分流
(見 T6 的 `tests/auth-dispatcher.test.ts`)。default(無 resolved_secrets 欄)行為 = 遷移前舊碼,
故 WASM 端此改動可獨立部署、零行為改變地驗「沒弄壞既有」。
- [x] T8 回填端點 `POST /credentials/migrate-to-workers-secrets`(§4.2,冪等可審)。 - [x] T8 回填端點 `POST /credentials/migrate-to-workers-secrets`(§4.2,冪等可審)。
2026-07-04 [cloud-worker] 完成:`cypher-executor/src/routes/credentials.ts` 新增 2026-07-04 [cloud-worker] 完成:`cypher-executor/src/routes/credentials.ts` 新增
端點,重用 `createArcrunHostFunctions(env, apiKey).crypto_decrypt`wasi-shim.ts 端點,重用 `createArcrunHostFunctions(env, apiKey).crypto_decrypt`wasi-shim.ts