1d19d46161
- POST /credentials/migrate-to-workers-secrets:舊 KV credential 逐筆解密回填 D1+Workers Secrets,冪等可審,重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點 - GET /credentials 改讀 D1(與既有 /credentials/catalog 共用查詢);DELETE 改為新家優先、 舊 KV fallback,避免孤兒資料 - acr creds list/replace/delete 三支 CLI 薄殼指令;順手修好過期的 acr creds push(舊 client 端加密格式已被 T5 取代) - 新增 cypher-executor/tests/credentials.test.ts + D1 test fixture T6/T7(讀取/注入路徑、雙讀 fallback)需要重新編譯 registry/components/auth_static_key 的 TinyGo WASM,本環境無 tinygo 且 proxy 擋 github.com 下載,卡在工具鏈缺口,詳細分析 記錄在 credential-store-migration.md。 端到端驗證:部署到 leo21c 帳號真實跑過 GET/POST/DELETE 三分支 + migrate 端點(對真實 既存的兩筆 credential 跑,發現 cypher-executor 自己的 ENCRYPTION_KEY secret 疑似為空, 誠實記錄為待 leo/總管裁決的不可逆風險項,未擅自重設)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
168 lines
6.6 KiB
TypeScript
168 lines
6.6 KiB
TypeScript
/**
|
||
* acr creds push/list/replace/delete
|
||
*
|
||
* credential-store-migration T5(2026-07-03,Arcrun#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 薄殼原則)。
|
||
*
|
||
* 移除任何「印出 credential 值」的路徑(D19:擁有目錄,不擁有內容物,連 owner 都讀不回)。
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import yaml from 'js-yaml';
|
||
import chalk from 'chalk';
|
||
import ora from 'ora';
|
||
import { loadConfig, getCypherExecutorUrl, type ArcrunConfig } from '../lib/config.js';
|
||
|
||
function requireApiKey(config: ArcrunConfig): string {
|
||
if (!config.api_key) {
|
||
if (config.mode === 'self-hosted') {
|
||
console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。'));
|
||
console.log(chalk.gray('在專案 .env 設一行(明碼即可,這是分區標籤不是密碼):'));
|
||
console.log(chalk.cyan(' NAMESPACE=leo'));
|
||
} else {
|
||
console.error(chalk.red('缺少 api_key,請重新執行 acr init。'));
|
||
}
|
||
process.exit(1);
|
||
}
|
||
return config.api_key;
|
||
}
|
||
|
||
interface CredentialRow {
|
||
name: string;
|
||
service: string | null;
|
||
sensitivity: string;
|
||
created_at: number;
|
||
last_used_at: number | null;
|
||
}
|
||
|
||
/** acr creds list — 讀 D1 目錄顯示(不含值,D19:讀不回內容物)。 */
|
||
export async function cmdCredsList(): Promise<void> {
|
||
const config = loadConfig();
|
||
const apiKey = requireApiKey(config);
|
||
const baseUrl = getCypherExecutorUrl(config);
|
||
|
||
const res = await fetch(`${baseUrl}/credentials`, {
|
||
headers: { 'X-Arcrun-API-Key': apiKey },
|
||
});
|
||
const body = (await res.json().catch(() => null)) as
|
||
| { success?: boolean; credentials?: CredentialRow[]; error?: string }
|
||
| null;
|
||
|
||
if (!res.ok || !body?.success) {
|
||
console.error(chalk.red(`讀取失敗:${body?.error ?? `HTTP ${res.status}`}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
const rows = body.credentials ?? [];
|
||
if (rows.length === 0) {
|
||
console.log(chalk.gray('(尚無 credential)'));
|
||
return;
|
||
}
|
||
console.log(chalk.bold(`\n ${rows.length} 個 credential(只顯示目錄,值不可讀回)\n`));
|
||
for (const r of rows) {
|
||
const lastUsed = r.last_used_at ? new Date(r.last_used_at * 1000).toISOString() : '(從未使用)';
|
||
console.log(` ${chalk.cyan(r.name)} service=${r.service ?? '-'} sensitivity=${r.sensitivity} last_used=${lastUsed}`);
|
||
}
|
||
console.log();
|
||
}
|
||
|
||
/** acr creds replace <name> <value> — 整筆覆寫(只能 replace,不能 edit 局部,D19/§3)。 */
|
||
export async function cmdCredsReplace(
|
||
name: string,
|
||
value: string,
|
||
options: { service?: string; sensitivity?: string },
|
||
): Promise<void> {
|
||
const config = loadConfig();
|
||
const apiKey = requireApiKey(config);
|
||
const baseUrl = getCypherExecutorUrl(config);
|
||
|
||
const spinner = ora(` 覆寫 ${name}`).start();
|
||
try {
|
||
const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
|
||
body: JSON.stringify({ value, service: options.service, sensitivity: options.sensitivity }),
|
||
});
|
||
const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
|
||
if (!res.ok || !body?.success) {
|
||
throw new Error(body?.error ?? `HTTP ${res.status}`);
|
||
}
|
||
spinner.succeed(chalk.green(` ✓ ${name} 已覆寫`));
|
||
} catch (e) {
|
||
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
/** acr creds delete <name> — 刪除 credential(目錄 row + Workers Secret 本體)。 */
|
||
export async function cmdCredsDelete(name: string): Promise<void> {
|
||
const config = loadConfig();
|
||
const apiKey = requireApiKey(config);
|
||
const baseUrl = getCypherExecutorUrl(config);
|
||
|
||
const spinner = ora(` 刪除 ${name}`).start();
|
||
try {
|
||
const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, {
|
||
method: 'DELETE',
|
||
headers: { 'X-Arcrun-API-Key': apiKey },
|
||
});
|
||
const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
|
||
if (!res.ok || !body?.success) {
|
||
throw new Error(body?.error ?? `HTTP ${res.status}`);
|
||
}
|
||
spinner.succeed(chalk.green(` ✓ ${name} 已刪除`));
|
||
} catch (e) {
|
||
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* acr creds push [credentials.yaml] —(保留 bulk 匯入的既有慣用法,內部改走新的明文 PUT
|
||
* 路徑,不再 client 端 AES-GCM 加密——舊實作對應的 server 格式已被 T5 取代,直接沿用會 400)。
|
||
*/
|
||
export async function cmdCredsPush(filePath: string): Promise<void> {
|
||
const config = loadConfig();
|
||
const apiKey = requireApiKey(config);
|
||
|
||
let creds: Record<string, string>;
|
||
try {
|
||
const raw = readFileSync(filePath, 'utf8');
|
||
creds = yaml.load(raw) as Record<string, string>;
|
||
} catch (e) {
|
||
console.error(chalk.red(`無法讀取 ${filePath}:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
const entries = Object.entries(creds).filter(([, v]) => typeof v === 'string' && v.length > 0);
|
||
if (entries.length === 0) {
|
||
console.log(chalk.yellow('credentials.yaml 中沒有有效的 credential(請取消注解並填入值)'));
|
||
return;
|
||
}
|
||
|
||
const baseUrl = getCypherExecutorUrl(config);
|
||
console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至 ${baseUrl}\n`));
|
||
|
||
for (const [name, value] of entries) {
|
||
const spinner = ora(` ${name}`).start();
|
||
try {
|
||
const res = await fetch(`${baseUrl}/credentials`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
|
||
body: JSON.stringify({ name, value: String(value) }),
|
||
});
|
||
if (!res.ok) {
|
||
const errBody = await res.text().catch(() => '');
|
||
throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 200)}`);
|
||
}
|
||
spinner.succeed(chalk.green(` ✓ ${name}`));
|
||
} catch (e) {
|
||
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
|
||
}
|
||
}
|
||
|
||
console.log(chalk.gray('\n Credential 已存進 Workers Secrets(cypher 唯寫,讀不回值)。執行 workflow 時會自動注入。\n'));
|
||
}
|