refactor: 移除已廢棄的自管加密金鑰機制(credential 全面託管 CF Workers Secrets)

leo 2026-07-20 明令:「已經改用 cf 自己的 secrets,不要再說它了」
「我希望以後再也看不到這個詞再出現」

背景:credential 早已遷移至 CF Workers per-script Secrets + D1 目錄,
舊的自管金鑰(client 端 AES-GCM + KV 密文 + crypto_decrypt)是遷移期遺留。
本次連根移除,含一併作廢的死 SaaS 碼。

移除:
- 舊 KV 密文解密路徑(credential-injector.ts 整檔、dual-read fallback)
  前置驗證:leo21c / youlin 兩帳號 CREDENTIALS_KV 實測 *:cred:* 皆 0 筆
- migrate-to-workers-secrets 搬家端點(回填已完成,無可回填)
- /register 路由與 generateApiKey(HMAC 產 ak_ key 是 SaaS 遺物;
  self-hosted 走 namespace 明碼 D21,已無人使用)
- platform_crypto component(三帳號實測 404 已退役,無 workflow 引用)

保留(附理由):
- crypto_decrypt 保留為永遠回失敗的 stub——現役三個 auth .wasm 仍宣告該
  import,缺項會讓 WASM instantiate 直接失敗。待零件重編後可真正刪除。

順帶修復(原不在範圍,但會實際壞事):
- /auth/callback 有 `if (!key) redirect(server_error)` 閘,未設該 secret 的
  實例會登入直接失敗 → 已移除
- OAuth 兩處把 provider token 寫進舊加密 KV(租戶鍵與實際 api_key 在 rotate
  後必然分歧,已失效)→ 改導向 Workers Secrets,包 try/catch 不影響登入
- acr init Standard 模式呼叫已刪除的 /register → 改引導 OAuth 取 key
- .claude/rules 與 system-dev/docs 是同一規範的兩份鏡像,先前只改 rules
  導致鏡像仍在教舊做法 → 已同步(此類雙檔同步應納入檢查)

新用戶安裝從此零 secret 前置。
測試 187/188(唯一 fail 為 pre-existing,stash 驗證與本次無關);
cypher-executor 與 cli typecheck 全綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 01:32:16 +08:00
committed by uncle6me-web
parent b9b94d7852
commit 20c7610371
64 changed files with 214 additions and 2197 deletions
+17 -10
View File
@@ -30,7 +30,8 @@
|---|---|
| `u6u.http_request` | 發 HTTP 請求 |
| `u6u.kv_get` | 讀 Cloudflare KVWorker 側依 key 前綴路由到正確 KV) |
| `u6u.crypto_decrypt` | AES-GCM 解密(encryption key 永不暴露給 WASM |
| `u6u.secret_get` | 讀 CF Workers Secrets(只放行 `CRED_` 前綴 |
| `u6u.crypto_decrypt` | ⚠️ 已廢除,保留成永遠回失敗的 stub(現役 wasm 仍宣告此 import |
| `u6u.crypto_sign_rs256` | RSA-SHA256 簽章(PKCS8 bytes 傳入) |
**所有 host function 在 `cypher-executor/src/lib/wasi-shim.ts` 實作**。零件透過 WASI import 使用。
@@ -50,16 +51,22 @@
**警告:R2 不存平台內建零件的 WASM**。平台零件已 bundle 進各自的 Worker binary`[[wasm_modules]]``import ... assert { type: 'webassembly' }`)。
## 加解密規範
## Credential 儲存規範
- **演算法**AES-GCM 256-bit
- **加密位置**Client 端(CLI / Python SDK / JS SDK
- Python`cryptography` 套件
- JSWeb Crypto API`crypto.subtle`
- **解密位置**Server 端 **WASM primitive**(透過 host function `crypto_decrypt`
- cypher-executor TS **不解密**,只提供 host function
- `ENCRYPTION_KEY` 只在 Worker host function 內部讀取,**永不經 stdin / 回傳值傳給 WASM**
- **傳輸格式**`{ name, encrypted, iv }`iv base64、encrypted base64
**arcrun 不自管加密金鑰。** credential 明文由 **Cloudflare Workers Secrets** 託管
per-script secret,掛在用戶自己的 cypher worker 上):
- secret 名稱 = `CRED_{NAME}_{sha256(api_key)[:8]}`(跨租戶命名隔離
- D1 `credentials` 表只存**目錄**api_key / name / service / secret_ref),不存值
- D19:擁有目錄,不擁有內容物——連 owner 都讀不回,只能覆寫/刪除
- 需要 worker 設 `CF_SECRETS_API_TOKEN`(機密,用戶手動 put+ `CF_ACCOUNT_ID`(自動注入)
> 註:`crypto_decrypt` host function 是**永遠回失敗的 stub**,因為現役三個 `auth_*`
> `.wasm` 仍宣告該 import(缺項會讓 WASM instantiate 失敗)。三個零件重編後即可刪除。
- **取用位置**Server 端 **WASM primitive**(透過 host function `secret_get(ref)`
- cypher-executor TS 不碰業務邏輯,只提供 host function
- `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他機密
- **傳輸格式**:明文值走 TLS`POST /credentials`),不做 client 端加密
## 網路部署
+5 -2
View File
@@ -62,10 +62,13 @@ find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler.
**CI 只提供 Cloudflare 驗證,不碰 runtime secret**
- GH Actions secrets:`CLOUDFLARE_API_TOKEN``CLOUDFLARE_ACCOUNT_ID`(一次性設好)
- Runtime secret(例:`ENCRYPTION_KEY``OPENAI_KEY``GOOGLE_API_KEY`):
- Runtime secret(例:`CF_SECRETS_API_TOKEN``OPENAI_KEY``GOOGLE_API_KEY`):
- **由 richblack 一次性手動** `wrangler secret put <KEY>` 設進各 Worker
- 不進 CI,不進 `wrangler.toml` `[vars]`
- 需要的 Worker:`auth_static_key``auth_service_account`(兩個都要 `ENCRYPTION_KEY`)
- 需要的 Worker:`arcrun-cypher-executor`(`CF_SECRETS_API_TOKEN`——寫 credential 進
Workers Secrets 用;`CF_ACCOUNT_ID` 非機密由 `acr init` 自動注入)
> `auth_static_key` / `auth_service_account` 不需要任何 secret。
---
@@ -19,4 +19,3 @@ id = "e7f4320f88d343f187e35e3543dd74c9"
binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0"
# ENCRYPTION_KEY 透過 wrangler secret put 設定
@@ -11,7 +11,6 @@
*
* 安全邊界:
* - api_key 經 stdin 傳進 WASM,同時綁到 host function 的 kv_get 做越權檢查
* - ENCRYPTION_KEY 只存在於 host function 的 closure 中,不會進入 WASM 記憶體
* - private key 只以 PKCS8 bytes 傳給 crypto_sign_rs256 host function,decrypt 後 plaintext 不離開 WASM
*/
@@ -20,5 +20,3 @@ id = "e7f4320f88d343f187e35e3543dd74c9"
binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# wrangler secret put ENCRYPTION_KEY
@@ -8,7 +8,6 @@
*
* 安全邊界:
* - api_key 經 stdin 傳進 WASM,同時綁到 host function 的 kv_get 做越權檢查
* - ENCRYPTION_KEY 只存在於 host function 的 closure 中,不會進入 WASM 記憶體
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
@@ -20,5 +20,3 @@ id = "e7f4320f88d343f187e35e3543dd74c9"
binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# wrangler secret put ENCRYPTION_KEY
Binary file not shown.
@@ -1,14 +0,0 @@
{
"name": "arcrun-platform-crypto",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
-898
View File
@@ -1,898 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.0
version: 4.12.17
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250408.0
version: 4.20260505.1
typescript:
specifier: ^5.4.0
version: 5.9.3
wrangler:
specifier: ^4.0.0
version: 4.87.0(@cloudflare/workers-types@4.20260505.1)
packages:
'@cloudflare/kv-asset-handler@0.5.0':
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
engines: {node: '>=22.0.0'}
'@cloudflare/unenv-preset@2.16.1':
resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: '>1.20260305.0 <2.0.0-0'
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260430.1':
resolution: {integrity: sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260430.1':
resolution: {integrity: sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260430.1':
resolution: {integrity: sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260430.1':
resolution: {integrity: sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260430.1':
resolution: {integrity: sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260505.1':
resolution: {integrity: sha512-Uz9D2hcwB4/pdnmCU7RsgknY8TQ5st0cQMMN6h/hvWt1TCt99GUkbi6dMgWdP7jXfIfh+S/EI5zQugI9RZn4Bw==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.17:
resolution: {integrity: sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@4.20260430.0:
resolution: {integrity: sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==}
engines: {node: '>=22.0.0'}
hasBin: true
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici@7.24.8:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260430.1:
resolution: {integrity: sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.87.0:
resolution: {integrity: sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==}
engines: {node: '>=22.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260430.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {}
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260430.1
'@cloudflare/workerd-darwin-64@1.20260430.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260430.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260430.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260430.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260430.1':
optional: true
'@cloudflare/workers-types@4.20260505.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.15': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
hono@4.12.17: {}
kleur@4.1.5: {}
miniflare@4.20260430.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.24.8
workerd: 1.20260430.1
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.7.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
undici@7.24.8: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260430.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260430.1
'@cloudflare/workerd-darwin-arm64': 1.20260430.1
'@cloudflare/workerd-linux-64': 1.20260430.1
'@cloudflare/workerd-linux-arm64': 1.20260430.1
'@cloudflare/workerd-windows-64': 1.20260430.1
wrangler@4.87.0(@cloudflare/workers-types@4.20260505.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260430.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260430.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260505.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
@@ -1,8 +0,0 @@
allowBuilds:
esbuild: true
sharp: true
workerd: true
onlyBuiltDependencies:
- esbuild
- sharp
- workerd
@@ -1,108 +0,0 @@
/**
* arcrun platform_crypto Worker
*
* POST / → JSON input {action, ...} → JSON output
*
* Actions:
* generate_api_key — HMAC-SHA256(email, ENCRYPTION_KEY) → ak_{hex[:32]}
* encrypt — AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}base64
* random_token — crypto random bytes → hex string
*
* 安全邊界:ENCRYPTION_KEY 只存在於 closure,永不進入外部(rule 02 §2.2)。
* 此 Worker 直接用 crypto.subtle 實作,不走 WASM runner。
* TinyGo WASM async host function 在 Cloudflare Workers 的 u6u namespace 不支援 Promise.
* WASM 零件 (registry/components/platform_crypto/) 保留作為 edge-Go 移植時的參考。
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
type Env = {
ENCRYPTION_KEY: string;
};
type Input = {
action: string;
email?: string;
plaintext?: string;
bytes?: number;
};
const app = new Hono<{ Bindings: Env }>();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'platform_crypto' }));
app.post('/', async (c) => {
let input: Input;
try {
input = await c.req.json() as Input;
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey) {
return c.json({ success: false, error: 'ENCRYPTION_KEY not configured' }, 503);
}
try {
switch (input.action) {
case 'generate_api_key': {
if (!input.email) return c.json({ success: false, error: 'email 必填' }, 400);
const apiKey = await generateApiKey(input.email, encryptionKey);
return c.json({ success: true, api_key: apiKey });
}
case 'encrypt': {
if (!input.plaintext) return c.json({ success: false, error: 'plaintext 必填' }, 400);
const { encrypted, iv } = await aesEncrypt(input.plaintext, encryptionKey);
return c.json({ success: true, encrypted, iv });
}
case 'random_token': {
const numBytes = (input.bytes ?? 32) > 0 ? (input.bytes ?? 32) : 32;
const token = randomHex(numBytes);
return c.json({ success: true, token });
}
default:
return c.json({ success: false, error: `不支援的 action: ${input.action}` }, 400);
}
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
// ── Crypto implementations (rule 02 §2.2: crypto.subtle 只准在 wasi-shim.ts 或 platform_crypto) ──
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(email));
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'ak_' + hex.slice(0, 32);
}
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
new TextEncoder().encode(plaintext),
);
const toB64 = (buf: ArrayBuffer | Uint8Array) =>
btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
return { encrypted: toB64(enc), iv: toB64(iv) };
}
function randomHex(numBytes: number): string {
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -1,15 +0,0 @@
name = "arcrun-platform-crypto"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
[vars]
COMPONENT_ID = "platform_crypto"
[[routes]]
pattern = "platform-crypto.arcrun.dev/*"
zone_name = "arcrun.dev"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# wrangler secret put ENCRYPTION_KEY
+3 -7
View File
@@ -30,19 +30,15 @@ CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
# ── ② 身份與加密(自架單人用,這兩格你自己決定/保管)──────────────────────────
# ── ② 身份(自架單人用)──────────────────────────────────────────────────────
#
# NAMESPACE:你的資料分區標籤。隨便取個英數小名即可(例:leo、myteam)。
# 這不是密碼,只是用來分隔你的資料。
#
# ENCRYPTION_KEY:你的 credential 加密金鑰,64 個以上的 hex 字元。你自己保管。
# 不會的話,AI 可以幫你產一串:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# ⚠️ 這串忘了 = 你之前上傳加密的 credential 就解不開了,請留底。
# (安裝完還要把「同一串」設進你的 worker,acr init 會印確切指令給你跟著做。)
# credential 不再需要自管加密金鑰:明文由 CF Workers Secrets 託管,
# `acr creds push` 會直接寫進你自己的 worker。)
#
NAMESPACE=
ENCRYPTION_KEY=
# ── ③ 各服務的 token(要連哪個服務才填哪個;可之後再加)────────────────────────
+2 -2
View File
@@ -19,7 +19,7 @@
- [~] **credential 注入 401 修復**`{{credential.X}}` 注入失敗,用戶被迫把 token 明文寫進 workflow。
**根因**:此語法系統沒實裝(三條 template 路徑都不認 `credential.` namespace)。
**修法**design §8richblack 2026-06-10 確認):auth_static_key 加 `resolve_credentials` actionWASM 解密)
+ graph-executor `resolveCredentialRefs` 偵測回填(不碰 ENCRYPTION_KEYrule 02 §2.2)。
+ graph-executor `resolveCredentialRefs` 偵測回填(不碰金鑰rule 02 §2.2)。
**8.1-8.4 done**tinygo build OK + tsc 0 + §2.2 自檢綠)。**待 8.5 端對端驗收**(部署 + 真 OpenAI key + 全新帳號打 2xx)。
- [ ] **§8 P1/P2 recipe/workflow list 遷 D1** — CF KV list 免費僅 1000/日,不修=用戶用一用就 429,
免費承諾破功。D1 現已可建(依賴解除),架構拍板走 kbdb `/entries` HTTP 雙寫不加 binding。
@@ -34,7 +34,7 @@
### ⚪ P2(不擋封測)
- [ ] `arcrun.dev/llms.txt` servelanding/public 補檔)
- [ ] ENCRYPTION_KEY 冪等性、MCP account-source、recipe submit uuid 回傳
- [ ] MCP account-source、recipe submit uuid 回傳
- [ ] 文件遷移階段二/三 + wiki modules/
- [ ] 下方第一期殘項:步驟 2(acr recipe test)、步驟 5b(資料外流警示 SDD)、步驟 6(搬家拆 matrix
+3 -9
View File
@@ -161,27 +161,21 @@ acr init --self-hosted
你不需要懂 git、不需要懂 tinygo、不需要手動建任何東西——預編譯好的零件(`.wasm`)直接從 GitHub 下載,用**你自己的** CF token 部署到**你的**帳號。
**最後一步:身份設定(你自己持有,工具不碰)。** self-hosted 是單租戶——你不需要平台發的 API Key,只需要個你自己填的值。在專案建一個 `.env`
**最後一步:身份設定(你自己持有,工具不碰)。** self-hosted 是單租戶——你不需要平台發的 API Key,只需要個你自己填的值。在專案建一個 `.env`
```bash
# .env(已被 gitignoreCLI 會自動讀)
NAMESPACE=leo # 你的資料分區標籤(明碼即可,這不是密碼)
ENCRYPTION_KEY=<64+ hex> # credential 加密金鑰,你自己保管(忘了 = 解不開已上傳的 credential
# 生成 keynode -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
> `NAMESPACE` 只是「你的資料放哪個分區」的標籤,不是密碼——要防別人呼叫你的 webhook,請對 webhook 加保護(見下)。
把**同一把** `ENCRYPTION_KEY` 也設進你的 workerruntime 解密要用,CLI 會印確切指令):
credential **不需要**你自管加密金鑰:`acr creds push` 走 TLS 把值送進你自己的 worker,由 **Cloudflare Workers Secrets** 託管(連你自己都讀不回,只能覆寫/刪除)。這需要 cypher worker 有一把能打 Workers Scripts secrets API 的 CF token`acr init` 會印確切指令):
```bash
wrangler secret put ENCRYPTION_KEY --name arcrun-cypher-executor
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-static-key
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-service-account
wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor
```
> 不想自己 put?跑 `acr init` 時明示同意,AI 可代你設——但預設由你自己 put(金鑰是你持有的)。
完成。之後有新版零件,跑 `acr update` 一樣自動拉新、重部署。
> 想先不碰 Cloudflare、純在本機感受語法?`acr init --local` 然後直接跳到下面「寫一個工作流」。
+1 -1
View File
@@ -18,7 +18,7 @@ const SOURCE_LABEL: Record<ConfigSource, string> = {
};
/** 敏感欄位只印前綴,避免把 token 完整印到終端 / log。*/
const SENSITIVE = new Set(['api_key', 'encryption_key', 'cf_api_token']);
const SENSITIVE = new Set(['api_key', 'cf_api_token']);
function mask(field: string, value: string): string {
if (SENSITIVE.has(field) && value.length > 8) return `${value.slice(0, 8)}`;
+3 -5
View File
@@ -1,11 +1,9 @@
/**
* acr creds push/list/replace/delete
*
* credential-store-migration T52026-07-03Arcrun#2)已把 server 端寫入路徑從
* 「client AES-GCM 加密 + {name,encrypted,iv}」改為「明文值 + TLS 傳輸」(§2.4 選項甲:
* arcrun 不再自管 ENCRYPTION_KEY,密文改由 CF Workers Secrets 託管)。T9(§3 治理端點)
* 對應把 CLI 薄殼換成新的 list/replace/delete 三支指令:全部只做「讀 argv/yaml → 呼叫
* cypher-executor API → 印結果」,不做任何加解密或業務邏輯(rule 07 薄殼原則)。
* 寫入路徑=「明文值 + TLS 傳輸」,值交由 CF Workers Secrets 託管。CLI 是薄殼:
* list/replace/delete 三支指令全部只做「讀 argv/yaml → 呼叫 cypher-executor API →
* 印結果」,不做任何加解密或業務邏輯(rule 07 薄殼原則)。
*
* 移除任何「印出 credential 值」的路徑(D19:擁有目錄,不擁有內容物,連 owner 都讀不回)。
*/
+15 -41
View File
@@ -11,7 +11,6 @@ import { saveConfig, type ArcrunConfig } from '../lib/config.js';
import { CfAccountClient } from '../lib/cf-api.js';
import {
REQUIRED_KV_NAMESPACES,
SECRET_TARGET_WORKERS,
downloadAndDeploy,
type DeployContext,
} from '../lib/deploy.js';
@@ -19,7 +18,7 @@ import { cmdInstallHarness } from './install-harness.js';
import { cmdMcpSetup } from './mcp-setup.js';
import { detectEnvironment, printPreflight, verifyInstall } from '../lib/preflight.js';
const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register';
const ARCRUN_LOGIN_URL = 'https://arcrun.dev/login';
async function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
const answer = await rl.question(chalk.cyan(`? ${question}: `));
@@ -103,40 +102,23 @@ async function initLocal(): Promise<void> {
}
async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> {
console.log(chalk.gray(' Standard 模式:只需要 email,不需要 Cloudflare 帳號\n'));
console.log(chalk.gray(' Standard 模式:用 arcrun.dev 帳號登入取得 API Key\n'));
const email = await prompt(rl, 'Email(用來取得 API Key');
// API Key 發放走網站 OAuth 登入(/auth/google/start、/auth/github/start)。
// CLI 是薄殼,不自己發 key(rule 07),只引導用戶去拿再貼回來。
console.log(' 1. 開啟 ' + chalk.cyan(ARCRUN_LOGIN_URL) + ' 用 Google / GitHub 登入');
console.log(' 2. 在 Dashboard 複製你的 API Keyak_ 開頭)\n');
process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...'));
const apiKey = (await prompt(rl, 'API Keyak_...')).trim();
let apiKey = '';
let encryptionKey = '';
try {
const res = await fetch(ARCRUN_REGISTER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`取得失敗(${res.status}):${err}`);
}
const data = await res.json() as { api_key: string; encryption_key: string };
apiKey = data.api_key;
encryptionKey = data.encryption_key;
console.log(chalk.green(' ✓'));
} catch (e) {
console.log(chalk.yellow(`${e instanceof Error ? e.message : e}`));
console.log(chalk.yellow(' 請確認網路連線後重新執行 acr init\n'));
if (!apiKey.startsWith('ak_')) {
console.log(chalk.yellow('\n ✗ API Key 應以 ak_ 開頭,請重新執行 acr init\n'));
process.exit(1);
}
const config: ArcrunConfig = {
mode: 'standard',
api_key: apiKey,
encryption_key: encryptionKey,
};
saveConfig(config);
@@ -322,28 +304,20 @@ async function initSelfHosted(
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
// 下一步:身份設定(self-hosted 單租戶——namespace 明碼用戶自填、encryption_key 用戶自保管)。
// 下一步:身份設定(self-hosted 單租戶——namespace 明碼用戶自填)。
// 工具不生成、不 hash、不外傳任何 key(守 rule 05 精神:secret 不進自動化,由用戶持有)。
console.log(chalk.bold('\n 下一步 ①:在這個專案建 .env(你自己填,工具不碰):'));
console.log(chalk.cyan(' NAMESPACE=leo # 你的資料分區標籤(明碼即可,不是密碼)'));
console.log(chalk.cyan(' ENCRYPTION_KEY=<64+ hex> # credential 加密金鑰,你自己保管'));
console.log(chalk.gray(' 生成 keynode -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'));
console.log(chalk.gray(' NAMESPACE 是分區標籤非密碼;要防外部呼叫請對 webhook 加保護。'));
console.log(chalk.gray(' ENCRYPTION_KEY 忘了 = 解不開已上傳的 credential。.env 已被 gitignore。)'));
console.log(chalk.bold('\n 下一步 ②:把同一把 ENCRYPTION_KEY 設進你的 workerruntime 解密要用):'));
for (const w of SECRET_TARGET_WORKERS) {
console.log(chalk.cyan(` wrangler secret put ENCRYPTION_KEY --name ${w}`));
}
console.log(chalk.gray(` ${SECRET_TARGET_WORKERS.length} 個 Worker 共用同一把(與 .env 的 ENCRYPTION_KEY 一致)。`));
console.log(chalk.gray(' 不想自己跑?跑 acr init 時授權(明示同意)我可代設——但預設由你自己 put(你持有 key)。\n'));
console.log(chalk.gray(' .env 已被 gitignore。)'));
console.log(chalk.gray(' credential 不需要自管加密金鑰:明文由 CF Workers Secrets 託管。\n'));
// credential-store-migration T3(§2.3):cypher worker 要有一把「能打 CF Workers Scripts
// secrets 管理 API 的 token」才能讓 POST/PUT /credentials 把密文寫進 Workers Secrets。
// 比照 ENCRYPTION_KEY 的既有模式(印手動指令,不是工具自動 put——CF_ACCOUNT_ID 非機密,
// 已由 downloadAndDeploy/injectWranglerConfig 自動注入(同 WORKER_SUBDOMAIN 模式),
// 印手動指令而非工具自動 put——CF_ACCOUNT_ID 非機密,已由 downloadAndDeploy/
// injectWranglerConfig 自動注入(同 WORKER_SUBDOMAIN 模式),
// 只有 CF_SECRETS_API_TOKEN(機密)需要用戶手動 put。
console.log(chalk.bold(' 下一步 :把能打 Workers Scripts secrets API 的 CF token 設進 cypher worker'));
console.log(chalk.bold(' 下一步 :把能打 Workers Scripts secrets API 的 CF token 設進 cypher worker'));
console.log(chalk.cyan(` wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor`));
console.log(chalk.gray(' 貼你剛才用來部署的同一個 CF API Token(需含 Workers Scripts:Edit 權限)。'));
console.log(chalk.gray(' 用途:POST/PUT /credentials 把密文寫進 Workers per-script Secretscredential-store-migration T5)。'));
-40
View File
@@ -164,43 +164,3 @@ export class CfAccountClient {
return result.uuid;
}
}
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
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');
}
+2 -5
View File
@@ -12,7 +12,6 @@ export interface ArcrunConfig {
mode: 'local' | 'standard' | 'self-hosted';
// Standard 模式
api_key?: string; // arcrun.dev API Keyak_前綴)
encryption_key?: string; // AES-GCM key,與 cypher-executor ENCRYPTION_KEY secret 一致
// Self-hosted 模式
cloudflare_account_id?: string;
user_kv_namespace_id?: string;
@@ -58,8 +57,6 @@ const ENV_MAP: Record<string, keyof ArcrunConfig> = {
NAMESPACE: 'api_key',
ARCRUN_NAMESPACE: 'api_key',
ARCRUN_API_KEY: 'api_key',
ARCRUN_ENCRYPTION_KEY: 'encryption_key',
ENCRYPTION_KEY: 'encryption_key',
ARCRUN_CYPHER_EXECUTOR_URL: 'cypher_executor_url',
ARCRUN_MCP_URL: 'mcp_url',
CLOUDFLARE_ACCOUNT_ID: 'cloudflare_account_id',
@@ -117,7 +114,7 @@ function readProjectConfig(): Partial<ArcrunConfig> | undefined {
/**
* .env .arcrun.yaml process.env Node/Python
* .env NAMESPACE / ENCRYPTION_KEY shell envshell > .env
* .env NAMESPACE / CLOUDFLARE_* shell envshell > .env
* dotenv KEY=VALUE//
*/
let _envFileLoaded = false;
@@ -193,7 +190,7 @@ export function resolveConfigSources(): Array<{ field: keyof ArcrunConfig; value
const project = readProjectConfig() ?? {};
const env = readEnvOverrides();
const fields: (keyof ArcrunConfig)[] = [
'mode', 'api_key', 'encryption_key', 'cloudflare_account_id',
'mode', 'api_key', 'cloudflare_account_id',
'cf_api_token', 'cypher_executor_url', 'mcp_url',
];
const rows: Array<{ field: keyof ArcrunConfig; value: string; source: ConfigSource }> = [];
-7
View File
@@ -129,13 +129,6 @@ export const REQUIRED_KV_NAMESPACES = [
'OAUTH_KV',
] as const;
/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/
export const SECRET_TARGET_WORKERS = [
'arcrun-cypher-executor',
'arcrun-auth-static-key',
'arcrun-auth-service-account',
] as const;
/** downloadAndDeploy 2.5tarball root worker resolve
* worker runtime depstier1 component honotier2 cypher/registry/mcp/kbdb
* zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml
@@ -6,15 +6,15 @@
*
* (rule 02 §2.2):
* - **** credential / template / JWT
* - auth primitive WASM ( host function `crypto_decrypt` )
* - auth primitive WASM ( host function `secret_get` )
* - recipe primitive Worker+HTTP fetch
*
* `auth_static_key` + `auth_service_account` + `auth_oauth2`,
* Phase 4 `auth_mtls`mTLS handshake Worker runtime
*
* 執行時機:graph-executor runner , ctx :
* 1. dispatcher( return enriched ctx)
* 2. fallback `injectCredentials`(Phase 1.9 )
* 1. dispatcher return enriched ctx
* 2. ctx (T10 injectCredentials fallback )
*/
import type { Bindings } from '../types';
@@ -250,7 +250,7 @@ function replaceCredentialRefs(value: unknown, resolved: Record<string, string>)
*
* rule 02 §2.2**** {{credential.X}} names
* auth_static_key WASM `resolve_credentials` actionWASM kv_get + crypto_decrypt
* ENCRYPTION_KEY
*
*
* - {{credential.}} WASM
* - / credential throw
@@ -1,235 +0,0 @@
/**
* Credential Injector
*
*
* 1. auth recipeauth_recipe:{componentId} in RECIPES KV
* auth recipe static_key, service_account
* flat injection
*
* Auth Recipe
* - static_key inject.header/query/body {{secret.KEY}}
* - service_accountJWT signing token exchange {{runtime.access_token}}
* - _auth_headers / _auth_query / _auth_body
*
*
* - RECIPES KV credentials_required recipe
* - BUILTIN_CREDENTIALS_MAP
* - inject_as context
*/
import type { Bindings } from '../types';
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
import type { AuthRecipeDefinition } from '../routes/recipes';
export interface CredentialRequirement {
key: string; // CREDENTIALS_KV 的 credential 名稱(如 gmail_token
inject_as: string; // 注入到 input 的欄位名稱(如 access_token
}
/** 內建 API recipe 的 credentials_required(對應 component-loader 的 BUILTIN_API_RECIPES*/
const BUILTIN_CREDENTIALS_MAP: Record<string, CredentialRequirement[]> = {
gmail: [{ key: 'gmail_token', inject_as: 'access_token' }],
google_sheets: [{ key: 'google_oauth', inject_as: 'access_token' }],
telegram: [{ key: 'telegram_bot_token', inject_as: 'bot_token' }],
line_notify: [{ key: 'line_token', inject_as: 'token' }],
};
// ── AES-GCM 解密 ──────────────────────────────────────────────────────────────
async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise<string> {
const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string };
const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToUint8Array(iv) },
cryptoKey,
base64ToUint8Array(encrypted),
);
return new TextDecoder().decode(decrypted);
}
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 base64ToUint8Array(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// ── 解密所有 required_secrets → { key: decryptedValue } ──────────────────────
async function decryptSecrets(
recipe: AuthRecipeDefinition,
apiKey: string,
env: Bindings,
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
for (const req of recipe.required_secrets) {
if (req.optional) continue;
const kvKey = `${apiKey}:cred:${req.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${req.key}${req.label}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 加入 ${req.key}: "your-value"\n` +
` 2. 執行:acr creds push`,
);
}
result[req.key] = await decryptCredential(record, env.ENCRYPTION_KEY);
}
return result;
}
// ── Template 展開:{{secret.KEY}} 和 {{runtime.KEY}} ─────────────────────────
function interpolateTemplate(
template: string,
secrets: Record<string, string>,
runtime: Record<string, string>,
): string {
return template.replace(/\{\{(secret|runtime)\.(\w+)\}\}/g, (_, ns, key) => {
if (ns === 'secret') return secrets[key] ?? '';
if (ns === 'runtime') return runtime[key] ?? '';
return '';
});
}
function interpolateRecord(
record: Record<string, string>,
secrets: Record<string, string>,
runtime: Record<string, string>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(record)) {
result[k] = interpolateTemplate(v, secrets, runtime);
}
return result;
}
// ── Auth Recipe 注入(新路徑)────────────────────────────────────────────────
async function injectFromAuthRecipe(
recipe: AuthRecipeDefinition,
input: Record<string, unknown>,
env: Bindings,
apiKey: string,
): Promise<Record<string, unknown>> {
// 解密所有 required_secrets
const secrets = await decryptSecrets(recipe, apiKey, env);
// runtime tokenservice_account 路徑已改走 auth-dispatcher → auth_service_account WASM;
// 這條 TS fallback 只處理 static_key (runtime 為空即可),service_account 永遠不會走到這裡
const runtime: Record<string, string> = {};
if (recipe.primitive === 'service_account') {
throw new Error(
`service_account primitive 應由 auth-dispatcher → auth_service_account WASM 處理,` +
`不應進到 credential-injector TS fallback (service=${recipe.service})`,
);
}
// 展開 inject 模板
const authHeaders = recipe.inject.header
? interpolateRecord(recipe.inject.header, secrets, runtime)
: {};
const authQuery = recipe.inject.query
? interpolateRecord(recipe.inject.query, secrets, runtime)
: {};
const authBody = recipe.inject.body
? interpolateRecord(recipe.inject.body, secrets, runtime)
: {};
return {
...input,
_auth_headers: authHeaders,
_auth_query: authQuery,
_auth_body: authBody,
};
}
// ── 舊有路徑:flat injection(向後相容)──────────────────────────────────────
async function loadCredentialsRequired(
componentId: string,
env: Bindings,
): Promise<CredentialRequirement[]> {
const recipe = await resolveRecipe(componentId, env.RECIPES);
if (recipe?.credentials_required?.length) {
return recipe.credentials_required;
}
return BUILTIN_CREDENTIALS_MAP[componentId] ?? [];
}
// ── 主入口 ────────────────────────────────────────────────────────────────────
/**
* credential
*
* @param componentId - canonical_id hash
* @param input - merged context
* @param env - Cloudflare Worker Bindings
* @param apiKey - API Keyak_前綴 KV namespace
*/
export async function injectCredentials(
componentId: string,
input: Record<string, unknown>,
env: Bindings,
apiKey?: string,
): Promise<Record<string, unknown>> {
// 沒有 api_key → local 模式,略過
if (!apiKey) return input;
// ── 新路徑:auth recipe ──
const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES);
if (authRecipe) {
return injectFromAuthRecipe(authRecipe, input, env, apiKey);
}
// ── 舊路徑:flat injection(向後相容)──
const required = await loadCredentialsRequired(componentId, env);
if (required.length === 0) return input;
const enriched = { ...input };
for (const cred of required) {
const kvKey = `${apiKey}:cred:${cred.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${cred.key}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 中加入 ${cred.key}: "your-token"\n` +
` 2. 執行:acr creds push`,
);
}
try {
const decrypted = await decryptCredential(record, env.ENCRYPTION_KEY);
enriched[cred.inject_as] = decrypted;
} catch (e) {
throw new Error(
`credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` +
`修復步驟:重新執行 acr creds push。`,
);
}
}
return enriched;
}
+9 -16
View File
@@ -1,7 +1,6 @@
// arcrun 圖遍歷引擎 — 支援完整 Cypher 語意關係
import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types';
import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types';
import { injectCredentials } from './actions/credential-injector';
import { tryAuthDispatch, resolveCredentialRefs } from './actions/auth-dispatcher';
import { expandPromptRecipe } from './lib/recipe-expander';
import { resolveRecipe } from './routes/recipes';
@@ -246,8 +245,8 @@ export class GraphExecutor {
};
// 用戶面 {{credential.NAME}} 展開(design §8):偵測 node.data 裡用戶寫的
// {{credential.X}} → 交 auth_static_key WASM resolve_credentials 解密回填。
// 解密在 WASMrule 02 §2.2),此處只偵測+回填,不碰 ENCRYPTION_KEY
// {{credential.X}} → 交 auth_static_key WASM resolve_credentials 取值回填。
// 取值在 WASMrule 02 §2.2),此處只偵測+回填,不碰任何秘密值
if (this.env && this.apiKey) {
mergedContext = await resolveCredentialRefs(mergedContext, this.env, this.apiKey);
}
@@ -283,19 +282,13 @@ export class GraphExecutor {
}
}
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
if (this.env) {
// 先試 auth dispatcher(新路徑,走 auth primitive WASM Worker via HTTP
// 命中才 return;否則 fallback 到舊 injectCredentialsPhase 1.9 會刪除)
if (this.apiKey) {
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
if (dispatched) {
mergedContext = dispatched;
} else {
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
}
} else {
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
// 走 auth dispatcherauth primitive WASM Worker via HTTP)——值住 CF Workers
// Secrets,由 WASM 內 secret_get 取用。
if (this.env && this.apiKey) {
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
if (dispatched) {
mergedContext = dispatched;
}
}
-2
View File
@@ -12,7 +12,6 @@ import { docsRouter } from './routes/docs';
import { webhooksRouter } from './routes/webhooks';
import { webhooksCrudRouter } from './routes/webhooks-crud';
import { webhooksListRouter } from './routes/webhooks-list';
import { registerRouter } from './routes/register';
import { recipesRouter } from './routes/recipes';
import { credentialsRouter } from './routes/credentials';
import { webhooksNamedRouter } from './routes/webhooks-named';
@@ -48,7 +47,6 @@ app.route('/', webhooksRouter);
app.route('/', webhooksNamedRouter); // 必須在 webhooksCrudRouter 前(避免 /webhooks/:token 攔截 /webhooks/named
app.route('/', webhooksCrudRouter);
app.route('/', webhooksListRouter);
app.route('/', registerRouter);
app.route('/', recipesRouter);
app.route('/', credentialsRouter);
app.route('/', authRouter);
+1 -1
View File
@@ -319,7 +319,7 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
// ── Auth Recipe Runner ────────────────────────────────────────────────────────
//
// credential-injector 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。
// auth-dispatcher 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。
// 這裡只需要讀取這些欄位,合併進 fetch,再清除 _auth_* 不傳給下游。
function makeAuthRecipeRunner(recipe: AuthRecipeDefinition): ComponentRunner {
@@ -308,7 +308,7 @@ export function extractCompletedDays(text: string): string[] {
}
/** /
* T10 ENCRYPTION_KEY
*
* caller */
function trimUnbalancedParen(s: string): string {
let depth = 0;
+14 -128
View File
@@ -10,17 +10,16 @@
/**
* createArcrunHostFunctions env
* cypher-executor Bindings, auth primitive Worker
* CREDENTIALS_KV / RECIPES / ENCRYPTION_KEY Worker
* CREDENTIALS_KV / RECIPES Worker
*/
export interface ArcrunHostEnv {
CREDENTIALS_KV: KVNamespace;
RECIPES: KVNamespace;
ENCRYPTION_KEY: string;
/**
* credential-store-migration T4§2.5/§5CF Workers per-script Secrets env var
* worker`env[ref]`T1.5 spike
* index signature `secret_get` host function secret_ref
* ENCRYPTION_KEY
*
*/
[secretRef: string]: unknown;
}
@@ -55,7 +54,7 @@ export interface WasiShim {
* .wasm host function syscall
*
*
* - encryption key `crypto_decrypt` host function 使 WASM
* - `secret_get` `CRED_` WASM worker
* - `kv_get` Worker key auth-dispatcher.ts
*/
export interface WasiHostFunctions {
@@ -72,16 +71,8 @@ export interface WasiHostFunctions {
secret_get?: (ref: string) => Promise<string | null>;
/** KV 寫入:用於快取 access_token 等短效值,ttlSeconds=0 表示不設 TTL */
kv_put?: (key: string, value: string, ttlSeconds: number) => Promise<void>;
/** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */
crypto_decrypt?: (encryptedB64: string, ivB64: string) => Promise<string>;
/** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
/** HMAC-SHA256(data, ENCRYPTION_KEY) → raw bytes */
crypto_hmac_sha256?: (data: Uint8Array) => Promise<Uint8Array>;
/** AES-GCM 加密(plaintext, ENCRYPTION_KEY) → {encryptedB64, ivB64} */
crypto_aes_encrypt?: (plaintext: Uint8Array) => Promise<{ encryptedB64: string; ivB64: string }>;
/** crypto random bytes → hex string */
crypto_random_bytes?: (numBytes: number) => string;
}
/**
@@ -422,23 +413,13 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
})
: () => 1,
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
// 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼)
crypto_decrypt: hostFunctions?.crypto_decrypt
? hostWrap(async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const dec = new TextDecoder();
const encB64 = dec.decode(new Uint8Array(memory.buffer, encPtr, encLen));
const ivB64 = dec.decode(new Uint8Array(memory.buffer, ivPtr, ivLen));
try {
const plaintext = await hostFunctions!.crypto_decrypt!(encB64, ivB64);
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(plaintext));
} catch {
return 1;
}
})
: () => 1,
// crypto_decrypt — 已停用,永遠回 1(失敗)。
//
// ⚠️ 不能整條移除:現役 auth_static_key / auth_service_account / auth_oauth2 的
// .wasm 仍宣告 `//go:wasmimport u6u crypto_decrypt`import 缺項會讓 WASM
// **instantiate 直接失敗**(不是呼叫才失敗)→ 所有認證零件全掛。故保留成 stub,
// 讓連結成立。待三個零件的 Go 原始碼移除該 wasmimport 並重編 wasm 後,才可刪掉這條。
crypto_decrypt: () => 1,
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
@@ -457,52 +438,6 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
})
: () => 1,
// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功,output = raw bytes
crypto_hmac_sha256: hostFunctions?.crypto_hmac_sha256
? hostWrap(async (dataPtr: number, dataLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen));
try {
const sig = await hostFunctions!.crypto_hmac_sha256!(data);
return writeOut(memory.buffer, outPtr, outLenPtr, sig);
} catch {
return 1;
}
})
: () => 1,
// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功
crypto_aes_encrypt: hostFunctions?.crypto_aes_encrypt
? hostWrap(async (plaintextPtr: number, plaintextLen: number,
outEncPtr: number, outEncLenPtr: number,
outIvPtr: number, outIvLenPtr: number): Promise<number> => {
if (!memory) return 1;
const plaintext = new Uint8Array(new Uint8Array(memory.buffer, plaintextPtr, plaintextLen));
try {
const { encryptedB64, ivB64 } = await hostFunctions!.crypto_aes_encrypt!(plaintext);
const encBytes = new TextEncoder().encode(encryptedB64);
const ivBytes = new TextEncoder().encode(ivB64);
const s1 = writeOut(memory.buffer, outEncPtr, outEncLenPtr, encBytes);
const s2 = writeOut(memory.buffer, outIvPtr, outIvLenPtr, ivBytes);
return s1 !== 0 ? s1 : s2;
} catch {
return 1;
}
})
: () => 1,
// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功,output = hex string
crypto_random_bytes: hostFunctions?.crypto_random_bytes
? (numBytes: number, outPtr: number, outLenPtr: number): number => {
if (!memory) return 1;
try {
const hexStr = hostFunctions!.crypto_random_bytes!(numBytes);
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(hexStr));
} catch {
return 1;
}
}
: () => 1,
},
},
@@ -636,11 +571,11 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
// ── Worker 端 host function 實作(Phase 0.6)──────────────────────────────────
//
// 唯一合法位置:AES-GCM 解密與 RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。
// 唯一合法位置:RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。
// 由 component-loader 的 WASM runner 路徑呼叫,注入進 createWasiShim。
//
// 安全邊界:
// 1. `ENCRYPTION_KEY` 只在 `crypto_decrypt` 內部讀 env,絕不經 stdin/回傳值傳給 WASM
// 1. `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他 env 機密
// 2. `kv_get` 依 key 前綴路由,且 `{api_key}:cred:*` 必須符合 stdin 傳入的 api_key(越權檢查)
// 3. 未知前綴回傳 nullWASM 收到 kv_get 回傳 2 = 找不到)
@@ -692,23 +627,6 @@ async function routedKvPut(env: ArcrunHostEnv, apiKey: string, key: string, valu
// 其他 key 前綴拒絕寫入(安全邊界)
}
/**
* AES-GCM encryption key env.ENCRYPTION_KEY function
* WASM base64 UTF-8 plaintext
*/
async function aesGcmDecrypt(env: ArcrunHostEnv, encryptedB64: string, ivB64: string): Promise<string> {
const keyBytes = hexToUint8Array(env.ENCRYPTION_KEY);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToUint8Array(ivB64) },
cryptoKey,
base64ToUint8Array(encryptedB64),
);
return new TextDecoder().decode(plaintext);
}
/**
* RSASSA-PKCS1-v1_5 + SHA-256 private key PKCS8 bytes WASM PEM
*/
@@ -730,7 +648,7 @@ async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise<
*
* `routedKvGet` design.md §2.3secret_ref CRED_
* `CRED_` refWASM worker
* `ENCRYPTION_KEY` / `CF_SECRETS_API_TOKEN` credential env var
* `CF_SECRETS_API_TOKEN` credential env var
* null `kv_get` /
*/
function secretGet(env: ArcrunHostEnv, ref: string): string | null {
@@ -740,7 +658,7 @@ function secretGet(env: ArcrunHostEnv, ref: string): string | null {
}
/**
* arcrun host function kv_get / crypto_decrypt / crypto_sign_rs256 / secret_get
* arcrun host function kv_get / kv_put / crypto_sign_rs256 / secret_get
* WASM runnercomponent-loader WASM api_key
*
* http_request factory auth primitive WASM API WASM
@@ -750,39 +668,7 @@ export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): W
return {
kv_get: (key: string) => routedKvGet(env, apiKey, key),
kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds),
crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64),
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
secret_get: async (ref: string) => secretGet(env, ref),
};
}
/**
* platform_crypto host functions
* apiKey KV routing
* ENCRYPTION_KEY closure WASM
*/
export function createPlatformCryptoHostFunctions(encryptionKey: string): WasiHostFunctions {
const toB64 = (buf: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buf)));
return {
crypto_hmac_sha256: async (data: Uint8Array): Promise<Uint8Array> => {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(sig);
},
crypto_aes_encrypt: async (plaintext: Uint8Array): Promise<{ encryptedB64: string; ivB64: string }> => {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext);
return { encryptedB64: toB64(enc), ivB64: toB64(iv.buffer) };
},
crypto_random_bytes: (numBytes: number): string => {
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
},
};
}
+29 -37
View File
@@ -9,6 +9,7 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { storeCredential } from './credentials';
export const authRouter = new Hono<{ Bindings: Bindings }>();
@@ -48,26 +49,13 @@ function getLandingOrigin(c: { req: { raw: Request } }): string {
return 'https://arcrun.dev';
}
/** 產生 API KeyHMAC-SHA256 of email,與 /register 相同邏輯) */
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
const msgData = new TextEncoder().encode(email);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'ak_' + hex.slice(0, 32);
}
/** AES-GCM 加密,回傳 {encrypted, iv}base64),與 SDK 格式相同 */
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext));
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
return { encrypted: toB64(enc), iv: toB64(iv) };
/**
* API Key /me/api-key/rotate
*
* key email USERS_KV api_key
*/
function generateApiKey(): string {
return 'ak_' + randomToken(24);
}
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
@@ -198,16 +186,14 @@ authRouter.get('/auth/callback', async (c) => {
}
await c.env.SESSIONS_KV.delete(`state:${state}`);
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey) {
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
try {
let email: string;
let displayName: string;
let avatarUrl: string | undefined;
let providerId: string;
// provider token 要存進 credential 新家,但必須用最終的 api_key 當租戶鍵,
// 而 api_key 要等下方 USERS_KV upsert 才決定 → 先暫存,稍後再寫。
let pendingCredential: { name: string; value: string; service: string } | null = null;
const provider = stateRecord.provider;
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
@@ -240,12 +226,10 @@ authRouter.get('/auth/callback', async (c) => {
avatarUrl = userInfo.picture;
providerId = userInfo.sub;
// 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
// 存 Google refresh_token 供 auth_oauth2 零件使用(實際寫入在 apiKey 決定後,見下方
// pendingCredential)。Google 只在首次授權時回傳 refresh_token,後續登入為 undefined
if (tokenData.refresh_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
pendingCredential = { name: 'google_refresh_token', value: tokenData.refresh_token, service: 'google_user' };
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2
void upsertAuthRecipe(c.env.RECIPES, {
@@ -319,12 +303,10 @@ authRouter.get('/auth/callback', async (c) => {
avatarUrl = userInfo.avatar_url;
providerId = String(userInfo.id);
// 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// GitHub 沒有 refresh_tokenaccess_token 長效(直到 revoke
// 存 GitHub access_token 供 auth_static_key 零件使用(實際寫入在 apiKey 決定後,見
// 下方 pendingCredential)。GitHub 沒有 refresh_tokenaccess_token 長效(直到 revoke
if (tokenData.access_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
pendingCredential = { name: 'github_access_token', value: tokenData.access_token, service: 'github_user' };
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
void upsertAuthRecipe(c.env.RECIPES, {
@@ -352,8 +334,8 @@ authRouter.get('/auth/callback', async (c) => {
const updated: UserRecord = { ...existing, display_name: displayName, avatar_url: avatarUrl };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
} else {
// New user — generate api key (same HMAC logic as /register)
apiKey = await generateApiKey(email, encryptionKey);
// New user — generate a random api key
apiKey = generateApiKey();
const newUser: UserRecord = {
email, display_name: displayName, avatar_url: avatarUrl,
api_key: apiKey, provider, provider_id: providerId,
@@ -364,6 +346,16 @@ authRouter.get('/auth/callback', async (c) => {
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
}
// provider token → credential 新家(Workers Secrets + D1)。登入本身不該因為存
// credential 失敗而失敗(例如 self-hosted 未設 CF_SECRETS_API_TOKEN)→ 吞錯誤只記 log。
if (pendingCredential) {
try {
await storeCredential(c.env, apiKey, pendingCredential.name, pendingCredential.value, pendingCredential.service);
} catch (e) {
console.error('存 provider token 失敗(不影響登入):', e instanceof Error ? e.message : String(e));
}
}
// Create session (TTL 7 days)
const sessionId = randomToken(32);
const session: SessionRecord = {
+20 -71
View File
@@ -17,11 +17,7 @@
* 2026-07-03 T1.5 spike SDD §6 Q-b leo
* trade-off leo
*
* credential-store-migration T8§4.2 + T9§3
* - `POST /credentials/migrate-to-workers-secrets`X-Arcrun-API-Key
* `{api_key}:cred:{name}` KV row wasi-shim crypto_decrypt
* PUT Workers Secrets D1 upsert D1
* secret_ref row ok/skipped/failmindset §7
* credential-store-migration T9§3
* - `GET /credentials` D1 `/credentials/catalog` query
* `/catalog` Console
* - `DELETE /credentials/:name` D1 secret_ref Workers Secret + D1 row
@@ -31,7 +27,6 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { sha256Prefix } from '../lib/hash';
import { createArcrunHostFunctions } from '../lib/wasi-shim';
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
@@ -52,6 +47,25 @@ async function deriveSecretRef(apiKey: string, name: string): Promise<string> {
return `CRED_${name.toUpperCase()}_${hash8.toUpperCase()}`;
}
/**
* credential CF Workers Secrets + D1
*
* OAuth callback /credentials provider token
* auth primitive `POST /credentials`
*
*/
export async function storeCredential(
env: Bindings,
apiKey: string,
name: string,
value: string,
service: string | null,
): Promise<void> {
const secretRef = await deriveSecretRef(apiKey, name);
await putWorkerSecret(env, secretRef, value);
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service, 'standard', secretRef);
}
function validateName(name: unknown): name is string {
return typeof name === 'string' && /^\w+$/.test(name);
}
@@ -308,68 +322,3 @@ credentialsRouter.get('/credentials', async (c) => {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
}
});
interface MigrateResult {
name: string;
ok: boolean;
skipped?: boolean;
error?: string;
}
// POST /credentials/migrate-to-workers-secrets — 回填(T8,§4.2):一次性、冪等、可審。
// 把呼叫者名下舊 `{api_key}:cred:{name}` KV row{encrypted, iv} AES-GCM 密文)逐一解密
// →(重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點,本檔不重新實作解密)→ PUT 進 Workers
// Secrets → D1 upsert 目錄。冪等:D1 已有該 (api_key,name) row 且 secret_ref 非空 → 跳過。
// 不刪 KV 舊密文(§4.3 回滾錨點——雙讀 fallback、廢除 ENCRYPTION_KEY 前的安全網)。
credentialsRouter.post('/credentials/migrate-to-workers-secrets', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const cryptoDecrypt = createArcrunHostFunctions(c.env, apiKey).crypto_decrypt;
if (!cryptoDecrypt) {
return c.json({ success: false, error: 'crypto_decrypt host function 未就緒' }, 500);
}
const prefix = `${apiKey}:cred:`;
const list = await c.env.CREDENTIALS_KV.list({ prefix });
const results: MigrateResult[] = [];
for (const key of list.keys) {
const name = key.name.slice(prefix.length);
try {
const existingRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name);
if (existingRef) {
results.push({ name, ok: true, skipped: true });
continue;
}
const raw = await c.env.CREDENTIALS_KV.get(key.name);
if (!raw) {
results.push({ name, ok: false, error: 'KV row 讀不到值(可能已被刪除)' });
continue;
}
const { encrypted, iv } = JSON.parse(raw) as { encrypted: string; iv: string };
const plaintext = await cryptoDecrypt(encrypted, iv);
const secretRef = await deriveSecretRef(apiKey, name);
await putWorkerSecret(c.env, secretRef, plaintext);
await upsertCredentialRow(c.env.CREDENTIALS_DB, apiKey, name, null, 'standard', secretRef);
results.push({ name, ok: true });
} catch (e) {
// 誠實回報逐筆 fail,不假綠(mindset §7
results.push({ name, ok: false, error: e instanceof Error ? e.message : String(e) });
}
}
const failed = results.filter(r => !r.ok);
return c.json({
success: failed.length === 0,
total: results.length,
migrated: results.filter(r => r.ok && !r.skipped).length,
skipped: results.filter(r => r.skipped).length,
failed: failed.length,
results,
});
});
+1 -1
View File
@@ -401,7 +401,7 @@ recipesRouter.delete('/recipes/:id', async (c) => {
* 2. rec_xxxxxxxx idx:{hash} canonical_id canonical
* 3. canonical_id idx:installed:{canonical_id} recipe:{uuid}
* fallback key recipe:{canonical_id} / migration
* component-loader/auth-dispatcher/credential-injector
* component-loader/auth-dispatcher
*/
export async function resolveRecipe(
id: string,
-46
View File
@@ -1,46 +0,0 @@
// POST /register — API Key 發放
// email → HMAC-SHA256(email, ENCRYPTION_KEY) → api_key (ak_ 前綴)
// 同一個 email 永遠得到相同的 Key,無需資料庫
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const registerRouter = new Hono<{ Bindings: Bindings }>();
registerRouter.post('/register', async (c) => {
let email: string;
try {
const body = await c.req.json() as { email?: string };
email = (body.email ?? '').trim().toLowerCase();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
if (!email || !email.includes('@')) {
return c.json({ success: false, error: 'email 格式不正確' }, 400);
}
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey || encryptionKey.length < 32) {
return c.json({ success: false, error: 'server configuration error' }, 500);
}
// HMAC-SHA256(email, ENCRYPTION_KEY) → hex → 取前 32 字元 → ak_ 前綴
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
const msgData = new TextEncoder().encode(email);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
const apiKey = 'ak_' + hex.slice(0, 32);
return c.json({
success: true,
api_key: apiKey,
encryption_key: encryptionKey, // 用戶需要此 key 才能加密上傳 credential
email,
message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。',
});
});
-1
View File
@@ -46,7 +46,6 @@ export type Bindings = {
AI: Ai;
// 環境變數
ENVIRONMENT: string;
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES keywrangler secret
MULTI_TENANT?: string; // "false" = Self-hosted 單租戶模式,預設 "true"
// OAuth Secretswrangler secret
GOOGLE_CLIENT_ID?: string;
+1 -72
View File
@@ -1,12 +1,10 @@
/**
* credential-store-migration T8+ T9
* credential
*
* `putWorkerSecret` / `deleteWorkerSecret`
* Cloudflare API`fetch` api.cloudflare.comwrangler.test.toml
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID CF API
* - D1-only GET /credentials/credentials/catalog
* - migrate skip D1 row putWorkerSecret
* - migrate CF token row fail
* - DELETE D1 row fallback KV deleteWorkerSecret
* CF Workers Secrets API / leo21c
* curl credential-store-migration.md T8/T9
@@ -90,75 +88,6 @@ describe('GET /credentials (D1, T9)', () => {
});
});
describe('POST /credentials/migrate-to-workers-secrets (T8)', () => {
beforeEach(async () => {
await clearTenantRows();
await env.CREDENTIALS_KV.list({ prefix: `${API_KEY}:cred:` }).then(async (list) => {
for (const k of list.keys) await env.CREDENTIALS_KV.delete(k.name);
});
});
it('缺 X-Arcrun-API-Key → 401', async () => {
const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { method: 'POST' });
expect(res.status).toBe(401);
});
it('D1 已有 row(曾回填過)→ 跳過,不誤判為失敗', async () => {
await insertCredentialRow('already_migrated', 'CRED_ALREADY_MIGRATED_ABCDEF01');
// 對應的舊 KV row 仍在(§4.3 回滾錨點:回填後不刪 KV),驗證「有 D1 row 就跳過」而非重打 CF API
await env.CREDENTIALS_KV.put(
`${API_KEY}:cred:already_migrated`,
JSON.stringify({ encrypted: 'irrelevant', iv: 'irrelevant' }),
);
const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', {
method: 'POST',
headers: { 'X-Arcrun-API-Key': API_KEY },
});
const body = await res.json() as {
success: boolean; total: number; migrated: number; skipped: number; failed: number;
results: Array<{ name: string; ok: boolean; skipped?: boolean }>;
};
expect(body.success).toBe(true);
expect(body.total).toBe(1);
expect(body.skipped).toBe(1);
expect(body.migrated).toBe(0);
expect(body.failed).toBe(0);
expect(body.results[0]).toMatchObject({ name: 'already_migrated', ok: true, skipped: true });
});
it('無任何舊 KV row → 空結果,success:true(沒東西可回填不是失敗)', async () => {
const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', {
method: 'POST',
headers: { 'X-Arcrun-API-Key': API_KEY },
});
const body = await res.json() as { success: boolean; total: number };
expect(body.success).toBe(true);
expect(body.total).toBe(0);
});
it('真正需要回填的 row(D1 無資料)在測試環境缺 CF token 時誠實回報 fail,不假綠', async () => {
await env.CREDENTIALS_KV.put(
`${API_KEY}:cred:needs_migration`,
JSON.stringify({ encrypted: 'ZmFrZQ==', iv: 'ZmFrZQ==' }),
);
const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', {
method: 'POST',
headers: { 'X-Arcrun-API-Key': API_KEY },
});
const body = await res.json() as {
success: boolean; failed: number; results: Array<{ name: string; ok: boolean; error?: string }>;
};
// 解密本身可能因假造的 base64 密文而失敗,或走到 putWorkerSecret 因缺 CF_SECRETS_API_TOKEN 失敗——
// 兩者都應該落在「誠實回報 fail」而非靜默假裝成功
expect(body.success).toBe(false);
expect(body.failed).toBe(1);
const row = body.results.find(r => r.name === 'needs_migration');
expect(row?.ok).toBe(false);
expect(row?.error).toBeTruthy();
});
});
describe('DELETE /credentials/:name (T9)', () => {
beforeEach(clearTenantRows);
+4 -5
View File
@@ -231,7 +231,7 @@ function makeFakeEnv(overrides: Record<string, unknown> = {}): ArcrunHostEnv {
return {
CREDENTIALS_KV: fakeKv,
RECIPES: fakeKv,
ENCRYPTION_KEY: 'deadbeef'.repeat(8),
CF_SECRETS_API_TOKEN: 'fake-cf-token',
...overrides,
};
}
@@ -249,11 +249,10 @@ describe('createArcrunHostFunctions — secret_get', () => {
await expect(hostFns.secret_get!('CRED_NOT_SET')).resolves.toBeNull();
});
it('非 CRED_ 前綴 → 一律拒絕回 null(即使 env 上真的有這個值,如 ENCRYPTION_KEY', async () => {
it('非 CRED_ 前綴 → 一律拒絕回 null(即使 env 上真的有這個值,如 CF_SECRETS_API_TOKEN', async () => {
const env = makeFakeEnv();
const hostFns = createArcrunHostFunctions(env, 'ak_test');
// ENCRYPTION_KEY 是 worker 自己的機密,WASM 不該透過 secret_get 拿到(安全邊界)
await expect(hostFns.secret_get!('ENCRYPTION_KEY')).resolves.toBeNull();
// CF_SECRETS_API_TOKEN 是 worker 自己的機密,WASM 不該透過 secret_get 拿到(安全邊界)
await expect(hostFns.secret_get!('CF_SECRETS_API_TOKEN')).resolves.toBeNull();
});
@@ -266,7 +265,7 @@ describe('createArcrunHostFunctions — secret_get', () => {
describe('u6u.secret_get — WASI import wiring', () => {
// 誠實註記(撞牆記錄):vitest-pool-workers 環境的 WebAssembly 支援 JSPIhostWrap() 因此把
// secret_get(以及既有的 kv_get / crypto_decrypt 等所有 async host function)包成
// secret_get(以及既有的 kv_get / crypto_sign_rs256 等所有 async host function)包成
// `WebAssembly.Suspending` 物件而非一般函式——這類物件設計上只能當 WASM import 綁定使用,
// 不能在 JS 端直接 `fn(...)` 呼叫(會拋 "is not a function")。用 probe 測試證實
// kv_get 的 import 同樣是 `Suspending` 物件、同樣不可直接呼叫——這是既有架構的環境限制,
-1
View File
@@ -44,7 +44,6 @@ database_id = "test-credentials-db-id"
[vars]
ENVIRONMENT = "test"
ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
# 分流台勾掉 route 測試:KBDB 指到假 hostfetchMock 攔截,絕不外連——尤其不打官方 uncle6 fallback
KBDB_BASE_URL = "https://kbdb.test"
CONSOLE_TENANT = "leo"
-1
View File
@@ -114,7 +114,6 @@ service = "arcrun-validate-json"
[vars]
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)。
+1 -1
View File
@@ -120,7 +120,7 @@ self-host 自動部署可以參考同一套掃描邏輯(`find . -name wrangler
- **把 cypher-executor 的 `[vars] WORKER_SUBDOMAIN` 改成 CC 自己的帳號 subdomain**self-host 關鍵,見 P0 #9cypher-executor 走 `arcrun-{name}.{subdomain}.workers.dev` 對內 URL)。
- **seed 降級 recipe + auth recipe 進 RECIPES KV**:新帳號 KV 是空的。把 §2.1 那些 recipekbdb_get/gmail_send/...+ auth recipe seed 寫進去。auth recipe seed 已有 `cypher-executor/scripts/seed-auth-recipes.ts`API recipe 需確認有對應 seed 機制(routes/recipes.ts 是動態 push,可能要寫一份 seed 腳本或用 `acr recipe push`)。
- 寫回 config(現有欄位已足夠)。
2. **runtime secret 不進 CLI 自動化**`ENCRYPTION_KEY` 等由 CC 自己 `wrangler secret put`rule 05 禁止 secret 進自動化流程)。CLI 應在最後**印出提示**告訴 CC 要手動 put 哪些 secret 到哪些 worker。
2. **runtime secret 不進 CLI 自動化**`CF_SECRETS_API_TOKEN` 等由 CC 自己 `wrangler secret put`rule 05 禁止 secret 進自動化流程)。CLI 應在最後**印出提示**告訴 CC 要手動 put 哪些 secret 到哪些 worker。
**驗收(客觀證據,不是口頭宣布 — mindset §7)**
- richblack 用全新 CF 帳號跑 `acr init --self-hosted` → 全程無手動建 KV / 部署。
+4 -4
View File
@@ -41,13 +41,13 @@ npm i -g arcrun && acr install-harness
· Account / Workers KV Storage / Edit
· Account / D1 / Edit ← **必勾**arcrun 用 D1 存 workflow/recipe;漏勾會 init 時 D1 建失敗(Authentication error
→ 複製產生的 token。(不需要 R2、不需要綁信用卡——D1 也在免費額度,不綁卡。)
- `NAMESPACE`:隨便取個英數小名(非密碼)。`ENCRYPTION_KEY`:你可幫他產
`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`)。
- `NAMESPACE`:隨便取個英數小名(非密碼)。credential 不需要自管加密金鑰。
- 使用者把值貼進 .env(或貼給你、你幫他填進對應格)。**CLOUDFLARE 兩格沒填,後面什麼都跑不了。**
- 連外部服務(如 Notion)的 token 也填進 .env 的 ③ 區,之後 `acr creds push` 加密上傳。
- 連外部服務(如 Notion)的 token 也填進 .env 的 ③ 區,之後 `acr creds push` 上傳(走 TLS,存進 CF Workers Secrets
4. `acr init --self-hosted`(你幫跑,讀 .env 的 CF 憑證)—— 自動建資源、部署、seed、寫 .mcp.jsonMCP 連線)。
跑完會印「安裝驗收」逐項 ✓/✗;有 ✗ 照它給的指令補(多數 `acr update` 冪等重試)。
5. 跑完照提示 `wrangler secret put ENCRYPTION_KEY`CLI 會印確切指令)。
5. 跑完照提示 `wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor`CLI 會印確切指令
用途=讓 cypher 能把 credential 寫進 Workers Secrets)。
6. 把使用者需求拆成 workflow → `acr push`。完成給客觀證據(HTTP 2xx / trace)。
## 暴露提醒
@@ -1,56 +0,0 @@
canonical_id: "platform_crypto"
display_name: "Platform Crypto Primitive"
category: "platform"
version: "v1"
wasi_target: "preview1"
stability: "stable"
runtime_compat:
- "cf-workers"
- "workerd"
- "wazero"
constraints:
max_size_kb: 2048
max_cold_start_ms: 50
no_network_syscall: true
no_filesystem_syscall: true
io_model: "stdin_stdout_json"
input_schema:
type: object
required: [action]
properties:
action:
type: string
enum: [generate_api_key, encrypt, random_token]
email:
type: string
description: generate_api_key 用
plaintext:
type: string
description: encrypt 用
bytes:
type: integer
description: random_token 用,預設 32
output_schema:
type: object
properties:
success:
type: boolean
api_key:
type: string
description: generate_api_key 結果,ak_ 前綴
encrypted:
type: string
description: encrypt 結果,base64
iv:
type: string
description: encrypt 結果,base64
token:
type: string
description: random_token 結果,hex
tags: [platform, crypto, internal]
description: |
平台內部 crypto primitive。
- generate_api_key: HMAC-SHA256(email, ENCRYPTION_KEY) → ak_xxx
- encrypt: AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}base64
- random_token: crypto random bytes → hex string
ENCRYPTION_KEY 由 host 持有,永不進入 WASM。
-206
View File
@@ -1,206 +0,0 @@
// platform_crypto — Arcrun 平台內部 crypto primitive
//
// Actions:
// generate_api_key — HMAC-SHA256(email, ENCRYPTION_KEY) → ak_{hex[:32]}
// encrypt — AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}base64
// random_token — crypto random bytes → hex string
//
// ENCRYPTION_KEY 由 host 持有,永不進入 WASM。
//
// Host imports:
// u6u.crypto_hmac_sha256 — HMAC-SHA256(data, key=ENCRYPTION_KEY) → raw bytes
// u6u.crypto_aes_encrypt — AES-GCM(plaintext, key=ENCRYPTION_KEY) → encrypted_b64 + iv_b64
// u6u.crypto_random_bytes — crypto-random bytes → hex string
//
//go:build tinygo
package main
import (
"encoding/json"
"io"
"os"
"strings"
"unsafe"
)
// ── host function 宣告 ───────────────────────────────────────────────────────
// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功
// key = host 的 ENCRYPTION_KEYoutput = raw byteshex encode 由 WASM 做)
//
//go:wasmimport u6u crypto_hmac_sha256
func hostCryptoHmacSha256(
dataPtr uintptr, dataLen uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功
// output: encryptedbase64)放 outEncivbase64)放 outIv
//
//go:wasmimport u6u crypto_aes_encrypt
func hostCryptoAesEncrypt(
plaintextPtr uintptr, plaintextLen uint32,
outEncPtr uintptr, outEncLenPtr uintptr,
outIvPtr uintptr, outIvLenPtr uintptr,
) uint32
// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功
// output: hex string
//
//go:wasmimport u6u crypto_random_bytes
func hostCryptoRandomBytes(
numBytes uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
// ── 型別 ─────────────────────────────────────────────────────────────────────
type Input struct {
Action string `json:"action"`
Email string `json:"email,omitempty"`
Plaintext string `json:"plaintext,omitempty"`
Bytes int `json:"bytes,omitempty"`
}
// ── main ─────────────────────────────────────────────────────────────────────
func main() {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
writeError("failed to read stdin: " + err.Error())
return
}
var input Input
if err := json.Unmarshal(raw, &input); err != nil {
writeError("invalid input JSON: " + err.Error())
return
}
switch input.Action {
case "generate_api_key":
if input.Email == "" {
writeError("email 必填")
return
}
sig, ok := hmacSha256([]byte(input.Email))
if !ok {
writeError("HMAC-SHA256 失敗")
return
}
apiKey := "ak_" + hex(sig)[:32]
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"api_key": apiKey,
})
os.Stdout.Write(out)
case "encrypt":
if input.Plaintext == "" {
writeError("plaintext 必填")
return
}
encB64, ivB64, ok := aesEncrypt([]byte(input.Plaintext))
if !ok {
writeError("AES-GCM 加密失敗")
return
}
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"encrypted": encB64,
"iv": ivB64,
})
os.Stdout.Write(out)
case "random_token":
n := input.Bytes
if n <= 0 {
n = 32
}
token, ok := randomBytes(uint32(n))
if !ok {
writeError("random bytes 失敗")
return
}
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"token": token,
})
os.Stdout.Write(out)
default:
writeError("不支援的 action: " + input.Action)
}
}
// ── helpers ───────────────────────────────────────────────────────────────────
func writeError(msg string) {
out, _ := json.Marshal(map[string]interface{}{
"success": false,
"error": msg,
})
os.Stdout.Write(out)
}
func hmacSha256(data []byte) ([]byte, bool) {
if len(data) == 0 {
return nil, false
}
outBuf := make([]byte, 64) // SHA-256 = 32 bytes raw
var outLen uint32
status := hostCryptoHmacSha256(
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return nil, false
}
return outBuf[:outLen], true
}
func aesEncrypt(plaintext []byte) (string, string, bool) {
if len(plaintext) == 0 {
return "", "", false
}
encBuf := make([]byte, 65536)
ivBuf := make([]byte, 64)
var encLen, ivLen uint32
status := hostCryptoAesEncrypt(
uintptr(unsafe.Pointer(&plaintext[0])), uint32(len(plaintext)),
uintptr(unsafe.Pointer(&encBuf[0])), uintptr(unsafe.Pointer(&encLen)),
uintptr(unsafe.Pointer(&ivBuf[0])), uintptr(unsafe.Pointer(&ivLen)),
)
if status != 0 {
return "", "", false
}
return string(encBuf[:encLen]), string(ivBuf[:ivLen]), true
}
func randomBytes(n uint32) (string, bool) {
outBuf := make([]byte, n*2+4) // hex = 2 chars per byte
var outLen uint32
status := hostCryptoRandomBytes(
n,
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", false
}
return string(outBuf[:outLen]), true
}
// hex encodes raw bytes to lowercase hex string
func hex(b []byte) string {
const hexChars = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, v := range b {
out[i*2] = hexChars[v>>4]
out[i*2+1] = hexChars[v&0xf]
}
return string(out)
}
// strings import 只為了 strings.Builderinterpolate 用,這裡不需要但 import 要保留給未來)
var _ = strings.Builder{}
+17 -10
View File
@@ -30,7 +30,8 @@
|---|---|
| `u6u.http_request` | 發 HTTP 請求 |
| `u6u.kv_get` | 讀 Cloudflare KVWorker 側依 key 前綴路由到正確 KV) |
| `u6u.crypto_decrypt` | AES-GCM 解密(encryption key 永不暴露給 WASM |
| `u6u.secret_get` | 讀 CF Workers Secrets(只放行 `CRED_` 前綴 |
| `u6u.crypto_decrypt` | ⚠️ 已廢除,保留成永遠回失敗的 stub(現役 wasm 仍宣告此 import |
| `u6u.crypto_sign_rs256` | RSA-SHA256 簽章(PKCS8 bytes 傳入) |
**所有 host function 在 `cypher-executor/src/lib/wasi-shim.ts` 實作**。零件透過 WASI import 使用。
@@ -50,16 +51,22 @@
**警告:R2 不存平台內建零件的 WASM**。平台零件已 bundle 進各自的 Worker binary`[[wasm_modules]]``import ... assert { type: 'webassembly' }`)。
## 加解密規範
## Credential 儲存規範
- **演算法**AES-GCM 256-bit
- **加密位置**Client 端(CLI / Python SDK / JS SDK
- Python`cryptography` 套件
- JSWeb Crypto API`crypto.subtle`
- **解密位置**Server 端 **WASM primitive**(透過 host function `crypto_decrypt`
- cypher-executor TS **不解密**,只提供 host function
- `ENCRYPTION_KEY` 只在 Worker host function 內部讀取,**永不經 stdin / 回傳值傳給 WASM**
- **傳輸格式**`{ name, encrypted, iv }`iv base64、encrypted base64
**arcrun 不自管加密金鑰。** credential 明文由 **Cloudflare Workers Secrets** 託管
per-script secret,掛在用戶自己的 cypher worker 上):
- secret 名稱 = `CRED_{NAME}_{sha256(api_key)[:8]}`(跨租戶命名隔離
- D1 `credentials` 表只存**目錄**api_key / name / service / secret_ref),不存值
- D19:擁有目錄,不擁有內容物——連 owner 都讀不回,只能覆寫/刪除
- 需要 worker 設 `CF_SECRETS_API_TOKEN`(機密,用戶手動 put+ `CF_ACCOUNT_ID`(自動注入)
> 註:`crypto_decrypt` host function 是**永遠回失敗的 stub**,因為現役三個 `auth_*`
> `.wasm` 仍宣告該 import(缺項會讓 WASM instantiate 失敗)。三個零件重編後即可刪除。
- **取用位置**Server 端 **WASM primitive**(透過 host function `secret_get(ref)`
- cypher-executor TS 不碰業務邏輯,只提供 host function
- `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他機密
- **傳輸格式**:明文值走 TLS`POST /credentials`),不做 client 端加密
## 網路部署
@@ -5,6 +5,9 @@ superseded_by: ""
# Design Document: arcrun MVP
> ⚠️ 本檔的 credential 注入程式碼範例描述舊儲存機制,已於 2026-07-20 移除。
> 現行做法見 `.claude/rules/01-tech-stack.md`「Credential 儲存規範」。
## Overview
arcrun MVP 的核心設計原則是**最小異動、最快可用**。所有目標都能透過以下三個操作達成:
@@ -97,7 +97,6 @@
- [x] 14. `acr init` 已實作,修正項:
- [x] 14.1 Standard 模式不再傳送 `cf_api_token` 至 arcrun.dev(只傳 `email`
- [x] 14.2 `require()` 改用 `await import()` 修正 ES module 相容
- [ ] 14.3 **待補**`acr init` 需詢問 `ARCRUN_ENCRYPTION_KEY` 並寫入 config(目前加密 key 需手動設定)
- _Requirements: 4.2, 6.3_
- [x] 15. `acr creds push` 已實作
@@ -193,7 +192,6 @@
- [ ] A. `builtins/` 清理:`initComponents.ts` 仍用舊的 HTTP endpoint 模式上架零件(`buildComponentDefs` 含 URL),應改為呼叫 `POST /submit` 送 WASM binary + contract,或直接移除 builtins(功能已整合到 registry
- [ ] B. `validate` 指令 credential 檢測邏輯修復(見 Phase 4 Task 18
- [ ] C. `acr init` 加入 `ARCRUN_ENCRYPTION_KEY` 設定步驟
- [ ] D. `acr parts` YAML 解析改用 `js-yaml`
---
+3 -4
View File
@@ -63,10 +63,10 @@ P0 全部清除才啟動封測。
|---|------|------|------|
| 1 | acr parts scaffold 正確輸出 | ✅ 完成 | 21 個零件內建清單 |
| 2 | acr recipe push 端對端 | ✅ 完成 | httpbin_post 驗證通過 |
| 3 | acr creds push 代碼 | ✅ 完成 | 需 ARCRUN_ENCRYPTION_KEY |
| 3 | acr creds push 代碼 | ✅ 完成 | 值交 CF Workers Secrets 託管 |
| 4 | credential 注入端對端 | ✅ 完成 | 無 token 時錯誤訊息正確 |
| 5 | acr push + webhook trigger | ✅ 完成 | 端對端驗證通過 |
| 6 | acr creds push 實測 | ✅ 完成 | /register 回傳 encryption_keyacr init 自動存入 configCLI 1.0.9|
| 6 | acr creds push 實測 | ✅ 完成 | acr init 自動存入 configCLI 1.0.9|
| 7 | Google Sheets 真實寫入 | ⚠️ 部分驗證 | credential 注入已驗證;實際 Sheets 寫入需真實 OAuth token |
| 8 | 第三方服務認證 recipe | ✅ 完成 | 20 個服務(Notion/Slack/GitHub/OpenAI 等),CLI 1.1.0 |
| **9** | **cypher-executor outbound HTTP fetch 全失效** | ✅ **已解決 2026-05-13**(CF 同 zone 自循環死鎖,改走 workers.dev)| 詳見下方專段 |
@@ -339,8 +339,7 @@ acr push 就會自動建立 cron-idx 並開始定時觸發。
## 四、封測前 P3(啟動當天)
- [ ] 封測者 email 呼叫 `/register`取得 api_key
- [ ] 將 ARCRUN_ENCRYPTION_KEY 以安全方式提供給封測者
- [ ] 引導封測者到 arcrun.dev 登入取得 api_key
- [ ] 確認聯絡管道
---
@@ -1,5 +1,9 @@
# Credential Store 遷移 SDD — KV → D1(目錄)+ CF Workers per-script Secrets(密文)
> ⚠️ **歷史記錄(本卷 T1-T10 已全數完成,2026-07-20 收尾)**。本檔記述的是「從舊自管金鑰
> 遷移到 CF Workers Secrets」的過程,文中提及的舊機制**均已不存在**,僅供考古,勿依此操作。
> 現行做法見 `.claude/rules/01-tech-stack.md`「Credential 儲存規範」。
> 建立:2026-06-29 by arcrun CC|對應 issueArcrun#13(優先序 3)|決策:leo 2026-06-29 拍板(D19
> **修訂 2026-07-03T1.5 spike,證據 Arcrun#2**:密文的家由「CF Secrets Store 產品」改為「**CF Workers per-script Secrets**」(`wrangler secret put` / API `PUT /accounts/{id}/workers/scripts/{script}/secrets` 那套)。原因:T1 spike 實證 Secrets Store 的 Worker binding 部署時靜態宣告、`.get(ref)` 參數被忽略,不支援「runtime 依 D1 ref 動態查任意 secret」;T1.5 spike 四驗證全過(API 動態加免重部署/`env[ref]` 動態索引可行/14 把未觸上限/重部署後存活),方向定案。§2.3、§2.5、T3、T4 已依此改寫;**雙讀過渡、回填、回滾、治理(D1 只存 metadata+secret_ref 不存密文)設計不動**。D212026-07-02 leo 拍板):當作 SaaS 不存在,按單用戶自架設計,多租戶段落標「future SaaS 再議」。
> 範圍宣告:本檔是既有 SDD `credential-primitives-wasm/` 的補充設計(rule 02 §4.3 例外:現有 SDD 目錄內新增單檔)。**不施工,先 SDD,總管審對齊後放行。**
@@ -370,6 +374,11 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla
清空 → 另外手工在 KV 塞一筆「只存在舊 KV、無 D1 row」的假資料,`DELETE`
legacy-kv fallback 分支,CF API 核對 KV key 真的被刪除。兩分支皆對真實
leo21c 帳號驗證通過,非模擬。
- [ ] T10 回填驗證 + 觀察期 → 廢 ENCRYPTION_KEY(§4.4leo 明示放行)。
- [x] T10 回填驗證 + 觀察期 → 舊自管金鑰路徑全數移除(§4.4leo 明示放行)。2026-07-20 完成:
兩帳號 CREDENTIALS_KV 實測 0 筆密文(無可回填)→ 移除舊雙讀 fallback、回填端點、
client 端加密與相關零件(`platform_crypto``credential-injector.ts``register.ts`)。
`crypto_decrypt` host function 保留成永遠回失敗的 stub(現役三個 `auth_*` .wasm 仍宣告
該 import,缺項會讓 WASM instantiate 失敗);三個零件重編後即可刪除。
**本卷至此全數完成,屬歷史記錄。**
> **每個 cred 操作跨 TS / WASM / host-function / 兩個 store,必端到端實測**(防再假綠,mindset §7)。原「T1 不通則整案停」已兌現一輪:T1 負結果 → 整案停 → 總管裁決轉向 → T1.5 全過 → **T2-T9 解凍**。備援(若施工再撞死路):codegen binding + 自動重部署(Arcrun#2 總管裁決的方向 1)。
@@ -1,5 +1,8 @@
# Credential Store 重設計提案(A telegram 一致性 + B KV→D1 + C 友善前門)
> ⚠️ **歷史記錄**:本檔的儲存決策已被 `credential-store-migration.md` 取代並執行完畢
> 2026-07-20)。文中提及的舊自管金鑰機制**已不存在**,僅供考古,勿依此操作。
> 建立:2026-06-29 by arcrun CC|更新:2026-06-29(依 leo 最終精確 spec 改寫)|對應 issueArcrun#13
> 範圍宣告:本檔是既有 SDD `credential-primitives-wasm/` 的補充設計筆記(不是新 SDD 子系統,rule 02 §4.3 例外)。
> 詞彙(leo 堅持精確):**零件/component=TinyGo WASM**、**recipe=http_request+固定設定(打最終 API**、
@@ -5,6 +5,11 @@ superseded_by: ""
# Design Document: Credential Primitives TS → WASM 改寫
> ⚠️ **本檔的 credential 儲存/解密段落已過時**`crypto_decrypt` 現為永遠回失敗的 stub
> credential 值改由 CF Workers Secrets 託管、經 `secret_get(ref)` 取用)。遷移已於
> 2026-07-20 完成,見 `credential-store-migration.md`;現行做法見
> `.claude/rules/01-tech-stack.md`「Credential 儲存規範」。以下解密相關敘述僅供考古。
## Overview
`cypher-executor` 中以 TypeScript 實作的 credential 注入邏輯,改寫為 4 個獨立的 WASM 零件。這是 `credential_parts.md` 長期規格的實現,不再是「未來 Phase」。
@@ -1,5 +1,8 @@
# Implementation Tasks: Credential Primitives TS → WASM
> ⚠️ **歷史記錄**:文中 credential 加解密相關的完成記錄描述的是舊機制,該機制已於
> 2026-07-20 完全移除(見 `credential-store-migration.md` T10)。僅供考古,勿依此操作。
**嚴格規範(richblack 2026-04-19**cypher-executor TS 不得實作任何 credential / auth / template / JWT / 解密邏輯。全部走 TinyGo WASM + host functions(方案 B)。
**封測狀態**:推遲(richblack 2026-04-19 決定)。先完成 Phase 1-3 清除違規 TS,再啟動封測。
@@ -38,7 +38,6 @@
|---|---|---|
| `ARCRUN_MODE` | `mode` | local/standard/self-hosted |
| `ARCRUN_API_KEY` | `api_key` | standard |
| `ARCRUN_ENCRYPTION_KEY` | `encryption_key` | standard/self-hosted |
| `ARCRUN_CYPHER_EXECUTOR_URL` | `cypher_executor_url` | self-hosted 指向自己的 cypher |
| `CLOUDFLARE_ACCOUNT_ID` | `cloudflare_account_id` | self-hosted(沿用 wrangler 慣用名)|
| `CLOUDFLARE_API_TOKEN` | `cf_api_token` | self-hosted(沿用 wrangler 慣用名)|
@@ -25,7 +25,7 @@
- **`wrangler deploy` 部署全部 Worker**(用戶已裝 wrangler
- seed auth recipe + API recipe 進 RECIPES KV
- 寫回 `~/.arcrun/config.yaml`
- 印出「手動 `wrangler secret put ENCRYPTION_KEY` ×3」提示(secret 不自動化,rule 05
- 印出「手動 `wrangler secret put CF_SECRETS_API_TOKEN`」提示(secret 不自動化,rule 05
### 關鍵技術決策(richblack 2026-06-02
| 決策 | 選擇 | 理由 |
@@ -82,10 +82,8 @@ acr init --self-hosted
├─ 7. 寫回 configmode: self-hosted + 所有 id + cypher_executor_url = 部署後 workers.dev URL
└─ 8. 印手動 secret 提示:
wrangler secret put ENCRYPTION_KEY --name arcrun-cypher-executor
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-static-key
wrangler secret put ENCRYPTION_KEY --name arcrun-auth-service-account
(三 Worker 共用同一把 key,見 memory: encryption-key-drift-trap
wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor
credential 值交 CF Workers Secrets 託管;auth 零件不需 secret
```
### `acr update`(同一條路,未來新零件)
@@ -208,7 +206,7 @@ codebase 只有 auth recipe seed。新增 `seed-api-recipes.ts`,把現役 API
1. richblack 用**全新 CF 帳號** + wrangler 已裝 + 一個 CF API Token 跑 `acr init --self-hosted`
→ 全程無手動建 KV / 無手動 clone / 無 tinygo / 無手動填 namespace id。
2. 跑完印 secret 提示,richblack 手動 `wrangler secret put ENCRYPTION_KEY` ×3
2. 跑完印 secret 提示,richblack 手動 `wrangler secret put CF_SECRETS_API_TOKEN`
3. `acr push` 一個含 http_request + 自建 recipe 的 workflow → trigger → **HTTP 2xx + execution trace**
4. 冪等:重跑 init 不重建已存在 KV / 不報錯。
5. `acr update` 拉新 codeload tarballtag)→ 重部署成功。
@@ -286,23 +284,22 @@ credential/部署/執行三條路徑全建在多租戶 `{api_key}:cred:{name}`
### 設計(richblack 2026-06-06
**self-hosted 的「api_key」= 明碼 namespace,用戶在 `.env` 自填。工具不生成、不 hash、不外傳。**
- 欄(像 username/password,但都由用戶持有):
- 欄(由用戶持有):
- `NAMESPACE=leo`(明碼分區標籤,當 KV 前綴;**非密碼**)
- `ENCRYPTION_KEY=<64+ hex>`(creds 加密金鑰,用戶自保管;須與 worker secret 一致)
- **誠實限制**mindset §7):namespace 明碼不提供認證。要防外部呼叫 → 對 webhook 加保護(mindset §6);arcrun 不做授權判斷(mindset §3)。
- **零分叉**SaaS 仍走 register 發的真 api_keyself-hosted 走 .env 的 NAMESPACE。**同一條 `api_key` 路徑、同一份 config 解析**,只是值來源不同。
### 改法
| 檔案 | 改動 |
|---|---|
| `cli/src/lib/config.ts` | `ENV_MAP``NAMESPACE`/`ARCRUN_NAMESPACE`/`ENCRYPTION_KEY` → 對應 `api_key`/`encryption_key`;新增 `.env` 自動載入(就近往上找,shell > .env,無 dotenv 依賴)|
| `cli/src/commands/creds.ts``push.ts` | self-hosted 缺值時,引導設 `.env` 的 NAMESPACE/ENCRYPTION_KEY(不再叫去 register|
| `cli/src/commands/init.ts` | self-hosted 結尾改提示「在 .env 設 NAMESPACE + ENCRYPTION_KEY」+「把同把 key wrangler secret put 進 worker;不想自己跑可明示同意我代設」|
| `cli/src/lib/config.ts` | `ENV_MAP``NAMESPACE`/`ARCRUN_NAMESPACE` → 對應 `api_key`;新增 `.env` 自動載入(就近往上找,shell > .env,無 dotenv 依賴)|
| `cli/src/commands/creds.ts``push.ts` | self-hosted 缺值時,引導設 `.env` 的 NAMESPACE |
| `cli/src/commands/init.ts` | self-hosted 結尾改提示「在 .env 設 NAMESPACE」+「把 CF_SECRETS_API_TOKEN wrangler secret put 進 worker;不想自己跑可明示同意我代設」|
| `cypher-executor/src/routes/webhooks-named.ts` | 新增 `POST /webhooks/named/:ns/:name/trigger`namespace 走 path,公開表單免 header);與 header 路徑共用 `triggerNamed`,不分叉 |
| `cli/src/commands/push.ts` | self-hosted 輸出 path-based 公開 webhook URL(免 header|
### 為何 namespace 進 URL path 安全
namespace 是明碼標籤非密碼,放 path 不洩漏任何祕密。真正的祕密是 `ENCRYPTION_KEY`(在 worker secret + 用戶 .env,永不進 URL/path)。要防外部濫用 webhook 是「webhook 保護層」職責,非 namespace。
namespace 是明碼標籤非密碼,放 path 不洩漏任何祕密。真正的祕密是 credential 值(由 CF Workers Secrets 託管,永不進 URL/path)。要防外部濫用 webhook 是「webhook 保護層」職責,非 namespace。
## 7.8 壓測修正(2026-06-08):onboarding 四缺陷 → pip 式自我修復安裝(CHANGE,待 review
@@ -349,7 +346,7 @@ namespace 是明碼標籤非密碼,放 path 不洩漏任何祕密。真正的
- [x] **P1**`acr whoami``cli/src/commands/whoami.ts`,人讀 + `--json`,印 mode/帳號/連哪台/來源層,薄殼讀 resolveConfigSources+ MCP `arcrun_whoami``mcp/src/tools/arcrun_whoami.ts`,回報 orgNamespace + binding,與 CLI 對齊)。AI 問工具拿身份,不自己 curl 猜。cli+mcp tsc exit 0。**2026-06-09 完成**
- [x] **P2**MCP 掛載重啟提示(D3)。`cli/src/commands/mcp-setup.ts` 寫完 `.mcp.json` 後印「⚠ 請重啟 IDE/client 才會載入 project scope MCP」+ 信任工作區提醒。經 init 也會流到(init 呼叫 cmdMcpSetup)。**2026-06-09 完成**
- [~] **P3**README/GitHub 入口整理成「AI 讀了就照著裝」的單一指引(D 無腦入口)。**部分完成 2026-06-09**
- 新增 repo 根 `.env.example` 範本(CF Account/Token 基礎兩格 + NAMESPACE/ENCRYPTION_KEY + 服務 token 區,
- 新增 repo 根 `.env.example` 範本(CF Account/Token 基礎兩格 + NAMESPACE + 服務 token 區,
每格上面白話說明「去哪申請、怎麼拿」,值留空。`.gitignore``!.env.example` 放行進 repo)。
- `llms.txt` step 3 改成「**你(AI)先 `cp .env.example .env`**,帶用戶填值(用戶只填「=」右邊)」——
把「建 .env 結構」從用戶身上挪到 AI,用戶只做貼值。step 4 補 init「安裝驗收」說明。
@@ -57,7 +57,7 @@ superseded_by: ""
| P3 | `acr install-harness`(裝防護)| 否 | 自己跑 | CC 代跑(CC 讀 llms.txt 第一步就做)|
| P4 | **建 CF 帳號 + 拿 Account ID + API Token** | **是**(憑證在用戶手上)| 自己建 | **CC 白癡化手把手帶**(§1.0 步驟2|
| P5 | `acr init --self-hosted`(貼 token,自動建 KV/部署/seed| 否 | 自己跑 | CC 代跑(用 P4 拿到的兩串)|
| P6 | `wrangler secret put ENCRYPTION_KEY` ×3 | 否 | 自己跑 | CC 代跑 |
| P6 | `wrangler secret put CF_SECRETS_API_TOKEN` | 否 | 自己跑 | CC 代跑 |
| P7 | 連 arcrun MCP`claude mcp add`,CC 偏好的工具)| 否 | 自己跑 | CC 代跑(install-harness 順便)|
### 兩種執行者
@@ -62,10 +62,13 @@ find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler.
**CI 只提供 Cloudflare 驗證,不碰 runtime secret**。
- GH Actions secrets:`CLOUDFLARE_API_TOKEN``CLOUDFLARE_ACCOUNT_ID`(一次性設好)
- Runtime secret(例:`ENCRYPTION_KEY``OPENAI_KEY``GOOGLE_API_KEY`):
- Runtime secret(例:`CF_SECRETS_API_TOKEN``OPENAI_KEY``GOOGLE_API_KEY`):
- **由 richblack 一次性手動** `wrangler secret put <KEY>` 設進各 Worker
- 不進 CI,不進 `wrangler.toml` `[vars]`
- 需要的 Worker:`auth_static_key``auth_service_account`(兩個都要 `ENCRYPTION_KEY`)
- 需要的 Worker:`arcrun-cypher-executor`(`CF_SECRETS_API_TOKEN`——寫 credential 進
Workers Secrets 用;`CF_ACCOUNT_ID` 非機密由 `acr init` 自動注入)
> `auth_static_key` / `auth_service_account` 不需要任何 secret。
---
+4
View File
@@ -1,6 +1,10 @@
# 5. Records — 日誌 + 驗收報告
> 線上事件復盤、測試報告、決策軌跡。
>
> ⚠️ **本目錄是「發生過什麼」的檔案館,不是現行做法**。內容反映當時狀態,其中提及的
> 機制可能早已移除(例如 credential 的舊自管金鑰路徑已於 2026-07-20 完全移除)。
> 勿依本目錄任何內容操作;現行規範一律以 `.claude/rules/` 為準。
## 線上事件
@@ -1,5 +1,8 @@
# 2026-05-29 credential 解密失敗(兩個 Worker 的 ENCRYPTION_KEY 漂移)
> ⚠️ 歷史記錄(2026-07-20 起本文所述機制已完全移除,本文僅供考古,勿依此操作)。
> 現行 credential 做法見 `.claude/rules/01-tech-stack.md`「Credential 儲存規範」。
> **症狀**`acr recipe test kbdb`credential 注入)回 HTTP 500`auth_static_key``credential kbdb_api_key 解密失敗`
> **根因(主)**`arcrun-auth-static-key` Worker 的 `ENCRYPTION_KEY` secret 跟正本(cypher-executor / CLI 用的那把)值不同、格式也不同(44-char base64 vs 64-char hex)。AES-GCM 用錯 key 必然解密失敗。
> **根因(附)**`component-loader.ts``res.json().catch(() => res.text())` 讀 response body → body 被讀兩次 → `Body has already been used`
@@ -1,5 +1,8 @@
# 交付前自測 Checklistpre-customer)— 2026-06-07
> ⚠️ 歷史記錄(2026-06-07 當時的清單)。其中 credential 相關步驟所依據的舊自管金鑰機制
> 已於 2026-07-20 完全移除,**勿照本文操作**;現行做法見 `.claude/rules/01-tech-stack.md`
> 給 **你(人)** 在交給客戶前跑一次的精簡清單,不是給 Haiku 操盤的詳細壓測(那份在
> 壓測-recipe-library-2026-06-07.md)。順序照客戶真實旅程。**任一項 ❌ = 不能交付。**
> 過關標準都是客觀證據(HTTP 2xx / D1 數字 / 檔案存在),禁口頭過關。
@@ -1,5 +1,8 @@
# 壓測 Test Case — Recipe 公庫/私庫機制 + UUID2026-06-07 deploy 後)
> ⚠️ 歷史記錄。文中 credential 相關步驟依據的舊自管金鑰機制已於 2026-07-20 完全移除,
> **勿照本文操作**;現行做法見 `.claude/rules/01-tech-stack.md`
> 對象:本次上線的 kbdb-base §7.5(公庫/私庫雙向、UUID 身份、市場數據)+ 回歸。
>
> **操盤模型:全程 Haiku。Haiku 能搞定是「設計目標」不只是壓測手段(richblack 2026-06-07)。**
@@ -23,7 +23,6 @@
## credential / recipe
- [[多worker-ENCRYPTION_KEY同步]] — 各 Worker secret put 同份 key,漂移→解密失敗表現為 401/403
- [[Recipe-UUID市場模型]] — canonical+uuid+author 多維身份,per-uuid 市場數據
## kbdb
@@ -1,35 +0,0 @@
---
tags: [credential, 部署, 架構決策, 踩坑]
gloss: 多個 Workerauth primitive + cypher-executor)共享 ENCRYPTION_KEY,用 wrangler secret put 手動設進各 Worker secret store,不用 KV。
---
# 多 Worker ENCRYPTION_KEY 同步
← [[decisions/00-INDEX]]
**來源**`system-dev/wiki/decisions-summary.md`(多 Worker ENCRYPTION_KEY 同步)、`.claude/rules/01-tech-stack.md` 加解密規範
**最後更新**2026-06-27
## 摘要
Auth static_key / Auth service_account / cypher-executor 三個 Worker 都需 ENCRYPTION_KEY 來解密 credential。用各 Worker 的 secret store(非環境變數)存放;初期化時 `acr init` 生成一份 key,用 `wrangler secret put ENCRYPTION_KEY` 各設一次。
## 重點
- **Secret 存儲位置**:各 Worker 的 secret storeCloudflare 原生機制),不是環境變數、不是 KV。環境變數會洩漏到日誌;KV 的 list 操作可能外洩。
- **初始化流程**`acr init` 生成一份 32 字節隨機 key → 印出一次 → 用戶自己跑 `wrangler secret put ENCRYPTION_KEY --path <path>` 分別設進三個 Worker。
- **冪等性問題**:目前 `acr init` 多跑幾次會生成不同 key(不冪等)。長期應改成「init 檢查現有 config → 若存在 key 則重用舊的、否則生成新的」。
- **漂移陷阱**:若某個 Worker 的 key 遺漏或與其他 Worker 不同 → credential 解密失敗 → 表現為 401/403(用戶困惑,難debug)。
- **驗證方法**init 完成後做一個 test workflow 打一個認證過的 API(如 gmail),確認三個 Worker 的 key 一致。
## 實體
- **ENCRYPTION_KEY**(加密密鑰)— 32 字節 AES-GCM 密鑰,用來解密用戶的 credential。
- **Secret store**Cloudflare 密鑰存儲)— Worker 的原生機制,用 `wrangler secret put` 設置、runtime 讀取,內容不外洩。
- **密鑰漂移**key drift)— 多個 Worker 持有不同版本的 ENCRYPTION_KEY,導致某些 Worker 解密失敗。
- **冪等性**idempotency)— `acr init` 多次運行應產生同一結果(目前不達成)。
- **解密失敗**decryption failure)— Worker 因 key 不匹配無法解密 credential,表現為 401/403 錯誤。
## 關聯
### 內文知識關係
- ENCRYPTION_KEY >> 分散存儲於 >> Secret store
- 密鑰漂移 >> 導致 >> 解密失敗
- 冪等性 >> 缺乏時 >> 重跑 init 造成 key 不一致
### 卡片關係
- (相關 memory`encryption-key-drift-trap` — 解密失敗先比 key 指紋;屬 auto-memory 非 card
-26
View File
@@ -110,32 +110,6 @@ compatibility_flags = [ "nodejs_compat", "global_fetch_strictly_public" ]
---
## 多 Worker ENCRYPTION_KEY 同步(2026-05-29
**Qauth_static_key / auth_service_account / cypher-executor 都需 ENCRYPTION_KEY,怎麼保持一致?**
**決策**
- secret 存在各 Worker 的 secret store(非環境變數,避免洩漏)
- `wrangler secret put ENCRYPTION_KEY` 手動設進各 Worker
- 初始化:`acr init` 生成,展示一次,user 自己 secret put
**為什麼不用 KV**
- secret 是敏感內容,不應在 KV 存(會被 list 洩漏)
- secret store 是 Cloudflare 的原生機制
**冪等性**
- `acr init` 多跑幾次,生成不同 key(目前不冪等)
- 若要冪等,init 應檢查現有 config → reuse 舊 key
**避坑**
- init 完成後驗證所有三個 Worker 都有同一份 key
- 若某個 Worker 的 key 遺漏或不同 → credential 解密失敗(會表現為 401/403)
- 重跑 init 不要覆蓋舊 secret(目前沒有 checkneed improvement
**詳見**2026-05-29-encryption-key-drift.md、rule 01 加解密
---
## Recipe UUID 模型(kbdb-base §7.5
**Q:多作者同 canonical recipe 怎麼並存?**
-20
View File
@@ -98,26 +98,6 @@ metadata:
---
## 6. 多 Worker 共用 ENCRYPTION_KEY 漂移(encryption-key-drift
**錯誤模式**:同一個 key 在多個 Worker 的 secret store 中不一致。
**根因**
- `acr init --self-hosted` 不是幪等的
- 用戶重跑 init,key 重新生成或未同步到所有 Worker
- 某個 Worker 還用舊 key,解密失敗
**後果**
- credential 無法解密,workflow 執行失敗(401/403
- 調試難度高,表現為「缺 credential」
**避坑**
- init 完成後,驗證所有 secret_target_workersauth_static_key / auth_service_account / cypher-executor)都有同一份 key
- 若已部署過,重跑 init 時 **skip secret put**(不要覆蓋)
- 或提供「檢查 key 一致性」的端點(未實作)
---
## 7. 同 zone 1042self-hosted cypher 打 auth worker
**錯誤模式**cypher 和 auth worker 同 zone(都是 {sub}.workers.dev),cypher fetch 打 auth 返回 522。
+1 -2
View File
@@ -252,7 +252,6 @@ metadata:
| 4 份 inline http_request host fn 抽共用 helper | 🟡 中 | 待 dedup | http_request/claude_api/kbdb_upsert_block/km_writer 各自複製貼上同段(這次假綠修也是逐份改) |
| `arcrun.dev/llms.txt` 404 | 🟡 中 | 未 serve | landing/public 缺檔;GitHub repo 內正常(test/5 走 GitHub 不阻擋) |
| MCP account-source | 🟡 中 | 記錄中 | self-hosted MCP 指官方不指自己(§5.2 已知) |
| ENCRYPTION_KEY 冪等性 | 🟡 中 | 設計中 | init 多跑生成新 key,無法複用舊 key |
| recipe submitted 後沒有 uuid | 🟡 中 | 待驗 | submit-p 應回 uuidCLI 拿不到 |
| ~~KV list 爆量~~ | ✅ 已解 | §8 P0 部署 | cron list→單 key get1440/日→02026-06-09 |
| ~~onboarding 缺陷(4 項)~~ | ✅ 已解 | §7.8 P0/P1/P2 + P3 部分 | CLI 1.3.4Haiku 壓測證實裝+init 不跳過 |
@@ -310,7 +309,7 @@ metadata:
6. [ ] `arcrun.dev/llms.txt` servelanding/public 補檔)
7. [ ] 補 wiki modules/(文件遷移階段一~三已於 2026-06-14 完成)
8. [ ] ENCRYPTION_KEY 冪等性、MCP account-source、recipe submit uuid 回傳
8. [ ] MCP account-source、recipe submit uuid 回傳
---