20c7610371
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>
166 lines
6.3 KiB
TypeScript
166 lines
6.3 KiB
TypeScript
/**
|
||
* acr creds push/list/replace/delete
|
||
*
|
||
* 寫入路徑=「明文值 + TLS 傳輸」,值交由 CF Workers Secrets 託管。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'));
|
||
}
|