merge: 上次裝到一半死掉的帳號要能再裝一次(Arcrun#123)+清單分頁

總管逐筆審過才併:
· 接管同名資源的唯一依據是呼叫端聲明 createNameIsOurs,預設 false(fail-closed)
· 安裝器有資格聲明——複驗過推導:baseName=arcrun-rag-<SHA256('arcrun-rag:'+email) 前8碼>
  ⇒ 同一 email 每次算出同一組名字,別人算不到
· acr CLI 那條路沒有聲明(createName 是裸 binding 名,證明不了是誰的)——正確
· 2b(已部署 worker 的綁定優先)沒被放寬,#97 的保護還在
· 分頁修法同批:三支清單端點形狀不同(D1 沒有 total_pages),終止條件刻意只用
  result_info 在不在+total_count 對不對得上;看不完整就 throw ⇒ 停手,
  「我不知道」不准被當成「它沒有」

真帳號實測(geek6688):正向接得回、反向 fail-closed 擋得住、兩個時間窗都通。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-14 19:58:02 +08:00
10 changed files with 1170 additions and 59 deletions
+93 -6
View File
@@ -24,6 +24,19 @@ import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* 清單端點每頁抓幾筆。100 是 CF 這幾支端點通用的安全上限(KV 官方上限就是 100)。
* 這個數字**不影響正確性**——`cfListAll` 會一直翻到底;它只決定要打幾次 API。
*/
const LIST_PER_PAGE = 100;
/**
* 翻頁的安全上限。100 頁 × 100 筆 = 10,000 顆,遠超 CF 的帳號上限
* KV namespace 每帳號 1,000)⇒ 正常帳號永遠碰不到。
* 碰到了就是 CF 那邊的行為變了,這種時候**寧可 throw 也不回一份不完整的清單**。
*/
const LIST_MAX_PAGES = 100;
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
@@ -57,9 +70,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/**
* 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。
* `resultInfo` CF 回應裡的 `result_info`(不分頁的端點是 `null`),`cfListAll` 靠它翻頁。
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
* @returns {Promise<{ok: boolean, status: number, result?: any, resultInfo?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
@@ -76,7 +90,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
return { ok: true, status: res.status, result: data.result, resultInfo: data?.result_info ?? null };
}
/**
@@ -90,6 +104,75 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
return result;
}
/**
* 把一支「列出帳號上有什麼」的端點**翻到底**,回傳全部項目。
*
* 【為什麼非翻不可——這是 Arcrun#123 的續集,不是效能優化】
* 三支清單方法原本只打 `?per_page=100`,也就是**只看第一頁**。同一個截斷,
* 在 #123 的修法前後,後果**不一樣**:
*
* | 被截掉的那顆 | 規則走到哪 | 結果 |
* |---|---|---|
* | #123 修好**前**worker 綁著它,但它落在第二頁 | 2b 判「綁著的資源不見了」 | 產生 blocker,**停手**(過度保守,但安全) |
* | #123 修好**後**:名字落在第二頁 | 2c 判「這個名字沒被佔走」 | **去建 → CF 回 title already exists ⇒ #123 的死路原樣回來** |
*
* ⇒ 修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。所以規約是:
* **看不完整就不准當作看完了**——翻不完、或翻出來的數量對不上 CF 自己回報的
* `total_count`,一律 throw,讓 `planResources` 把它變成 blocker
* (README 規則第 3 條:說不準就整趟停手,一顆都不建)。
*
* 【三支端點的分頁行為不一樣,這裡刻意不假設它們同款】(2026-08-14 在 geek6688 帳號實測)
* - `/storage/kv/namespaces`:真分頁,`result_info` `{page, per_page, count, total_count, total_pages}`
* - `/d1/database`:真分頁,但 `result_info` **沒有 `total_pages`**(實測 `{page, per_page, count, total_count}`
* ⇒ **不准拿 `total_pages` 當終止條件**,那個欄位在 D1 上是 `undefined`
* - `/vectorize/v2/indexes`**不分頁**`result_info` 是 `null`,帶 `page``per_page` 也被忽略(一次回全部)
*
* 所以終止條件只用「三支都有、或三支都沒有」的兩件事:`result_info` 在不在、`total_count` 對不對得上。
* 對不分頁的那支,這支等於只打一次就回來(那兩個被忽略的參數實測無害);
* 而萬一 CF 哪天替它補上分頁,這支會自己跟著翻——不必等下一次災情才想起來改。
*
* @param {string} path 不含分頁參數的端點路徑(可自帶其他 query)
* @param {string} what 出錯訊息裡怎麼稱呼它
* @returns {Promise<any[]>}
*/
async function cfListAll(path, what) {
/** @type {any[]} */
const items = [];
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
const sep = path.includes('?') ? '&' : '?';
const res = await cfRaw(`${path}${sep}per_page=${LIST_PER_PAGE}&page=${page}`);
if (!res.ok) {
throw new Error(`${what} 失敗(第 ${page} 頁):${res.error ?? `HTTP ${res.status}`}`);
}
const batch = Array.isArray(res.result) ? res.result : [];
items.push(...batch);
const info = res.resultInfo;
// 這支端點沒有分頁(Vectorize v2)⇒ 這一趟拿到的就是全部。
if (!info) return items;
const total = Number(info.total_count);
if (Number.isFinite(total)) {
if (items.length >= total) return items;
// CF 說還有,卻一筆都不給 ⇒ 我們看不到全部。**不准安靜地當作看完了。**
if (batch.length === 0) {
throw new Error(
`${what} 只讀到 ${items.length} 筆,但 Cloudflare 說共有 ${total} 筆,第 ${page} 頁卻是空的。` +
`看不到帳號上的全部資源就沒辦法判斷該不該新建——停手。`,
);
}
continue; // total_count 說還有就繼續翻(不看 total_pages:D1 根本沒這個欄位)
}
// 沒有 total_count 可對,只剩「這一頁沒裝滿 ⇒ 沒有下一頁」可用。
if (batch.length < LIST_PER_PAGE) return items;
}
throw new Error(
`${what} 翻超過 ${LIST_MAX_PAGES} 頁還沒到底(已讀 ${items.length} 筆)。` +
`這不正常,寧可停手,也不拿一份不完整的清單去判斷該不該新建資源。`,
);
}
return {
cfRaw,
@@ -122,7 +205,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<Map<string, string>>} title → id */
async listKvNamespaces() {
/** @type {Array<{id: string, title: string}>} */
const result = await cf('/storage/kv/namespaces?per_page=100');
// 翻到底才算數(只看第一頁會讓 Arcrun#123 安靜復發,理由見 cfListAll
const result = await cfListAll('/storage/kv/namespaces', 'KV namespace');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
@@ -131,7 +215,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
const result = await cf('/d1/database?per_page=100');
// 翻到底才算數。D1 的 result_info **沒有 total_pages**,所以終止條件只認 total_count。
const result = await cfListAll('/d1/database', 'D1 資料庫');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
@@ -140,8 +225,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
// 這支端點**目前不分頁**`result_info` 是 null),走 cfListAll 等同只打一次;
// 但 CF 哪天替它補上分頁,這裡會自己跟著翻,不必等下一次災情才想起來改。
const result = await cfListAll('/vectorize/v2/indexes', 'Vectorize index');
return result.map((i) => i.name);
},
/**
+96 -17
View File
@@ -106,6 +106,19 @@
* @property {string} binding
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
* @property {string} createName
* @property {boolean} [createNameIsOurs]
* 呼叫端在此**聲明**`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
* ⇒ 帳號上若已經有一顆**恰好同名**的資源,它只可能是我們上一次沒裝完留下的(Arcrun#123)。
*
* 🔴 這個聲明是「接管同名資源」的**唯一**依據,預設 falsefail-closed)。
* 只有在名字**推導得出、而且推導的輸入是使用者自己的身分**時才准聲明 true——
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
* slug `SHA-256('arcrun-rag:' + email)` 取前 8 碼,同一個 email 每次算出同一組名字,
* 別人算不到、也不會不小心撞上)。
*
* ⚠️ **不准**因為「名字看起來像我們的」就聲明 true。`acr` 那條從 wrangler.toml 讀到的
* createName 是裸 binding 名(`WEBHOOKS`)或 toml 宣告的庫名(`arcrun-kbdb`)——
* 那種名字使用者自己也可能拿去用,**證明不了是我們的**,所以那條路一律不聲明。
*/
/**
@@ -113,7 +126,12 @@
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的
* @property {string} from 從哪顆已部署的 worker 上讀到的。`reclaimed` 時為空字串——
* **沒有任何 worker 綁著它正是接收它的前提**(Arcrun#123),不是漏填。
* @property {boolean} [reclaimed]
* true = 這顆不是從某顆 worker 的綁定讀出來的,而是「帳號上已經有一顆我們自己命名的同名資源、
* 卻沒有人綁著」⇒ 上一次沒裝完留下的,這次把它接回來用(Arcrun#123)。
* 給呼叫端做診斷/統計用;**使用者不必知道「殘骸」這個詞**,對外一律講「沿用你原本的資源」。
*/
/**
@@ -144,7 +162,11 @@
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* 🔴 接回上次沒裝完留下的那顆(`reclaimed`**仍然算 `adopted`**,不另開第三種值——
* 它本來就是「沿用既有資源」,而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
* (安裝器那句「沿用你原本的 N 項資源」)不會因為多一種值就悄悄漏數。
* @property {string} [from]
* @property {boolean} [reclaimed] 見 PlannedAdopt.reclaimedArcrun#123)。
*/
/**
@@ -251,19 +273,25 @@ export async function planResources(api, requirements, mode) {
else byKey.set(key, [req]);
}
/** @type {Map<ResourceKind, Set<string>>} */
// 帳號上現有的資源,一種只查一次。**名字 → 身分**KV/D1 是 idVectorize 的身分就是名字)。
//
// 為什麼連名字都收下來(本來只留 `.values()`):
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
/** @type {Map<ResourceKind, Map<string, string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = async (kind) => {
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
const listExistingByName = async (kind) => {
const hit = existingCache.get(kind);
if (hit) return hit;
/** @type {Set<string>} */
let set;
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
else set = new Set(await api.listVectorizeIndexes());
existingCache.set(kind, set);
return set;
/** @type {Map<string, string>} */
let map;
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
else if (kind === 'd1') map = await api.listD1Databases();
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
existingCache.set(kind, map);
return map;
};
for (const [, reqs] of byKey) {
@@ -291,10 +319,10 @@ export async function planResources(api, requirements, mode) {
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
/** @type {Set<string>} */
/** @type {Map<string, string>} */
let existing;
try {
existing = await listExisting(kind);
existing = await listExistingByName(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
@@ -302,7 +330,7 @@ export async function planResources(api, requirements, mode) {
);
continue;
}
if (!existing.has(value)) {
if (![...existing.values()].includes(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
@@ -316,12 +344,62 @@ export async function planResources(api, requirements, mode) {
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding全新帳號
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
//
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
//
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
// ——會弄丟資料的是那個動作,不是這個。
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
// `createNameIsOurs`),沒聲明就停手。
/** @type {Map<string, string>} */
let existingByName;
try {
existingByName = await listExistingByName(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
`${msg(e)})。不確定就不建——停手。`,
);
continue;
}
const createName = reqs[0].createName;
const sameName = existingByName.get(createName);
if (sameName !== undefined) {
if (!reqs[0].createNameIsOurs) {
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
blockers.push(
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]}` +
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
);
continue;
}
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
continue;
}
create.push({
kind,
binding,
createName: reqs[0].createName,
createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
@@ -401,6 +479,7 @@ export async function applyResourcePlan(api, plan) {
value: a.value,
origin: 'adopted',
from: a.from,
...(a.reclaimed ? { reclaimed: true } : {}),
});
}
/** @type {string[]} */
+101
View File
@@ -135,6 +135,13 @@ class FakeCloudflare implements ResourceApi {
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
async createKvNamespace(title: string): Promise<string> {
// 🔴 Arcrun#123:真的 Cloudflare **不准同名**——
// `a namespace with this account ID and title already exists`。
// 這個假帳號原本沒有模擬這條限制,於是「上一次裝到一半死掉」的帳號在測試裡
// 看起來只是「多建幾顆孤兒」,實際上是**再也裝不起來**。少了這一行,#123 測不出來。
if (this.kv.has(title)) {
throw new Error('a namespace with this account ID and title already exists');
}
const id = `NEW-kvid-${this.createdKv.length}`;
this.kv.set(title, id);
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
@@ -486,6 +493,100 @@ test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
});
// ═════════════════════════════════════════════════════════════════════════════
// Arcrun#123 —— 「上一次裝到一半死掉」的帳號,再裝一次要能成功
//
// 這一格與 #97 的差別:#97 是「worker 綁著資源,卻被重新綁到新建的空殼」(會弄丟資料);
// #123 是「資源建好了、worker 一顆都還沒部署」——**沒有任何綁定可以當事實**,
// 而帳號上偏偏已經有一批同名資源 ⇒ 舊規則判「可以新建」⇒ CF 拒絕 ⇒ 這個帳號從此裝不起來。
//
// 封測者 1.4.45 實撞。逃過驗證的原因:我們只測乾淨帳號與完整安裝。
// ═════════════════════════════════════════════════════════════════════════════
/**
* 安裝器那條路的需求:`createName` 是安裝器**自己替這台實例算出來的**
* `arcrun-rag-<slugFromEmail(email)>-…`)⇒ 有資格聲明 createNameIsOurs。
* 對照 collectRequirements()(走 tomlcreateName 是裸 binding 名 ⇒ 不得聲明)。
*/
function installerRequirements(claimOwnership = true): BindingRequirement[] {
const own = claimOwnership ? { createNameIsOurs: true } : {};
const out: BindingRequirement[] = [];
for (const { requirements } of [collectRequirements()]) {
for (const r of requirements) {
if (r.kind === 'kv_namespace') {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kv-${r.binding.toLowerCase()}`, ...own });
} else if (r.kind === 'd1') {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kbdb`, ...own });
} else {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-embed`, ...own });
}
}
}
return out;
}
test('#123 ①:上次裝到一半死掉的帳號 → 接回上次留下的那批,一顆都不必新建', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true }); // 資源在、worker 一顆都沒有
const kvBefore = cf.kv.size;
const plan = await planResources(cf, installerRequirements(), 'init');
assert.deepEqual(plan.blockers, [], '半殘帳號不該有 blocker——用戶只要再按一次就該裝得起來');
assert.deepEqual(plan.create, [], '一顆都不該新建');
assert.ok(plan.adopt.length > 0 && plan.adopt.every((a) => a.reclaimed === true),
'全部都是「接回上次留下的」');
const resolved = await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
assert.equal(cf.kv.size, kvBefore, '帳號上的顆數不變(沒有再留下一批孤兒)');
// 綁到的是上次建的那幾顆本尊
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.value, 'kvid-webhooks');
assert.equal(resolved.get(bindingKey('d1', 'DB'))!.value, 'd1id-kbdb');
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.origin, 'adopted',
'origin 維持 adopted——呼叫端既有的 adopted/created 統計不會漏數');
});
test('#123 ①對照組:修好之前,同一個帳號會被 CF 用「title already exists」擋死', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
// 不聲明所有權 ⇒ 走的是修好之前的判斷(「沒人綁著就可以新建」)。
// 舊版會直接送 POST → 撞 CF 的同名限制;新版在 plan 階段就停手,兩者都裝不起來,
// 差別在**新版一顆資源都不會被建出來**,而且訊息說得出人話。
const plan = await planResources(cf, installerRequirements(false), 'init');
assert.ok(plan.blockers.length > 0, '證明不了是自己的 → fail-closed 停手');
assert.match(plan.blockers.join('\n'), /RES-NAME-TAKEN/, '要給得出可回報的錯誤碼');
assert.doesNotMatch(plan.blockers.join('\n'), /後台|dashboard/,
'不准叫使用者自己去 Cloudflare 後台處理(#121D88');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '被擋下時一顆都不能被建出來');
});
test('#123 ②紅線:名字真的不是我們的(走 toml 的裸 binding 名)→ 停手,不接管也不新建', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
await cf.createKvNamespace('WEBHOOKS'); // 使用者自己建的、剛好叫這個名字
cf.createdKv.length = 0;
const { requirements } = collectRequirements(); // createName = 裸 binding 名,不得聲明所有權
const plan = await planResources(cf, requirements, 'init');
assert.ok(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '一顆都沒建');
});
test('#123 ③:全新帳號照舊建得出整套(D82 第一步不可退化)', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0; // 真正的空帳號
const plan = await planResources(cf, installerRequirements(), 'init');
assert.deepEqual(plan.blockers, [], '全新帳號不該有 blocker');
assert.ok(plan.adopt.length === 0, '全新帳號沒有東西可沿用');
const resolved = await applyResourcePlan(cf, plan);
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length, '9 顆 KV 全部建出來');
assert.equal(cf.createdD1.length, 1, 'D1 建一顆(兩個綁定共用)');
assert.ok([...resolved.values()].every((r) => r.origin === 'created'), '全部都是新建的');
});
test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
const orig = globalThis.fetch;
try {
+60 -4
View File
@@ -20,6 +20,54 @@
`planResources()`(不寫入,只出計畫)與 `applyResourcePlan()`(有 blocker 就拒絕執行)分兩段,
所以「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,不是靠誰記得寫 early return。
### 1.1 第 2 條的例外:上一次裝到一半死掉(Arcrun#123)
「沒有人綁過它」有**兩種**成因,第 2 條原本只想到第一種:
| | 名字在帳號上 | 有 worker 綁著 | 該怎麼做 |
|---|---|---|---|
| 新版本新增的 binding/全新帳號 | ❌ | ❌ | 新建(照舊) |
| **上次安裝建到一半就中斷** | ✅ | ❌ | **接回那一顆**(否則 CF 回「title already exists」,這個帳號**永遠裝不起來**) |
接管同名資源的**唯一**依據是呼叫端在 `BindingRequirement` 上聲明 `createNameIsOurs: true`
意思是「這個名字是我用**使用者自己的身分**可重現地算出來的」——安裝器的
`arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 合格;`acr` 從 wrangler.toml 讀到的裸 binding 名
`WEBHOOKS`)**不合格**,因為使用者自己也可能拿那個名字去建東西。
沒聲明就撞名 ⇒ **停手**(訊息帶 `RES-NAME-TAKEN` 錯誤碼讓使用者回報,
**不叫他自己去 Cloudflare 後台動手**)。
🔴 這**不是**把 #97 刪掉的「照名字 ensure」搬回來。差別:#97 是**找不到就新建一顆頂上去**
(會把活著的實例洗成空的);這裡是**找到才沿用、找不到才照舊新建**,而且排在
「已部署的綁定=事實」之後——名字永遠只在「確定沒有任何綁定可看」時才有發言權。
### 1.2 上面那條的前提:**清單必須是完整的**(Arcrun#123 的續集)
1.1 整條規則建立在一個沒被說出口的假設上:「我列出來的,就是帳號上全部的資源」。
`cf-resource-api.mjs` 原本三支清單方法只打 `?per_page=100`——**只看第一頁**。
CF 的 KV 上限是每帳號 1,000 顆,所以「超過一頁」不是理論狀況。
同一個截斷,在 1.1 修好前後**後果不一樣**,這才是它非修不可的理由:
| 被截掉的那顆 | 規則走到哪 | 結果 |
|---|---|---|
| 1.1 修好**前**:worker 綁著它,但它落在第二頁 | 「綁著的資源不見了」 | blocker,**停手**(誣告使用者,但安全) |
| 1.1 修好**後**:同名殘骸落在第二頁 | 「這個名字沒被佔走」 | **去建 → CF 回 title already exists ⇒ #123 的死路原樣回來** |
⇒ 1.1 把這個洞從「叫得太大聲」變成「**安靜地復發**」。
所以規約是:**看不完整就不准當作看完了**。`cfListAll` 會翻到底;翻不完、
或翻出來的數量對不上 CF 自己回報的 `total_count`,一律 throw ⇒ 變成 blocker ⇒
整趟停手(第 3 條)。**「我不知道」永遠不准被當成「它沒有」。**
三支端點的分頁行為**不一樣**(2026-08-14 在 `geek6688` 帳號實測,別假設它們同款):
| 端點 | `result_info` | 備註 |
|---|---|---|
| `/storage/kv/namespaces` | `{page, per_page, count, total_count, total_pages}` | 真分頁 |
| `/d1/database` | `{page, per_page, count, total_count}` | 真分頁,但**沒有 `total_pages`** ⇒ 不准拿它當終止條件 |
| `/vectorize/v2/indexes` | `null` | **不分頁**`page``per_page` 被忽略,一次回全部 |
---
## 2. 為什麼在這裡,不在 cypher-executor 的 API
@@ -47,8 +95,10 @@
| `rule.mjs` | 規則本體:`planResources` / `applyResourcePlan` / `parseWranglerRequirements` 把 CF 回應讀成事實的 `normalizeLiveBindings` / `normalizeLiveVars` |
| `cf-resource-api.mjs` | `ResourceApi` 的 CF REST 實作(只用 global `fetch`)。**眼睛也要共用**——見下 §5 |
| `installer-entry.mjs` | 安裝器唯一該碰的入口:`resolveInstanceResources()` |
| `tests/fixture-account.mjs` | 假 Cloudflare 帳號(`fetch` 替身)+種情境 |
| `tests/fixture-account.mjs` | 假 Cloudflare 帳號(`fetch` 替身)+種情境。**清單端點照真 CF 分頁**(KV 有 `total_pages`D1 沒有/Vectorize 不分頁),形狀是 2026-08-14 在真帳號實打抄回來的 |
| `tests/demo.mjs` | `node shared/resource-rule/tests/demo.mjs`——零依賴、零建置就能跑的示範 |
| `tests/half-finished-install.mjs` | #123 的迴歸守衛:上次裝到一半死掉的帳號,回來再按一次要裝得起來(§1.1) |
| `tests/list-pagination.mjs` | #123 的**續集**:帳號上資源多到一頁裝不下時,規則看到的仍是全部(§1.2) |
🔴 **零依賴是硬規則**:只准 import 同目錄的兄弟檔,不准碰 `node:*`
有外部依賴就會有某條路吃不到它。`cli/tests/single-implementation.test.ts` ③ 會擋。
@@ -112,8 +162,10 @@ if (r.blocked) {
## 6. 驗收
```bash
cd cli && npm test # 58 項,含下列三組
node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑
cd cli && npm test # 73 項,含下列三組
node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨立跑
node shared/resource-rule/tests/half-finished-install.mjs # #123
node shared/resource-rule/tests/list-pagination.mjs # #123 續集(清單分頁)
```
| 測試 | 證的事 |
@@ -122,11 +174,15 @@ node shared/resource-rule/tests/demo.mjs # 安裝器那條路,零依賴獨
| `cli/tests/single-implementation.test.ts` | ①規則的 7 支函式全 repo 只有這裡有實作 ②鏡射逐位元組相同 ③共用層零依賴 |
| `cli/tests/resource-adoption.test.ts` | #97 本身的迴歸(沿用/不多建/四種停手情境),改共用層後照樣全過 |
種情境(`tests/fixture-account.mjs``SCENARIOS`):
種情境(`tests/fixture-account.mjs``SCENARIOS`):
- `fresh` — 沒裝過 → **正常建新的**(不能為了沿用而變成永遠不建)
- `installed` — 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在
- `renamed`**資源在但名字與預期完全不同** → 仍然沿用(#97 的病根,專門驗)
- `half-finished`**資源已建、worker 一顆都沒部署** → 接回殘骸(#123 的病根)
另有一個與情境正交的旋鈕:`makeAccount(情境, { decoyKv, decoyD1 })` 會在帳號上多塞
N 顆「別人的」資源,把我們自己那幾顆擠到第二頁以後——§1.2 的分頁測試靠它。
---
+93 -6
View File
@@ -24,6 +24,19 @@ import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* 清單端點每頁抓幾筆。100 是 CF 這幾支端點通用的安全上限(KV 官方上限就是 100)。
* 這個數字**不影響正確性**——`cfListAll` 會一直翻到底;它只決定要打幾次 API。
*/
const LIST_PER_PAGE = 100;
/**
* 翻頁的安全上限。100 頁 × 100 筆 = 10,000 顆,遠超 CF 的帳號上限
* KV namespace 每帳號 1,000)⇒ 正常帳號永遠碰不到。
* 碰到了就是 CF 那邊的行為變了,這種時候**寧可 throw 也不回一份不完整的清單**。
*/
const LIST_MAX_PAGES = 100;
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
@@ -57,9 +70,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/**
* 把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。
* `resultInfo` CF 回應裡的 `result_info`(不分頁的端點是 `null`),`cfListAll` 靠它翻頁。
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
* @returns {Promise<{ok: boolean, status: number, result?: any, resultInfo?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
@@ -76,7 +90,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result };
return { ok: true, status: res.status, result: data.result, resultInfo: data?.result_info ?? null };
}
/**
@@ -90,6 +104,75 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
return result;
}
/**
* 把一支「列出帳號上有什麼」的端點**翻到底**,回傳全部項目。
*
* 【為什麼非翻不可——這是 Arcrun#123 的續集,不是效能優化】
* 三支清單方法原本只打 `?per_page=100`,也就是**只看第一頁**。同一個截斷,
* 在 #123 的修法前後,後果**不一樣**:
*
* | 被截掉的那顆 | 規則走到哪 | 結果 |
* |---|---|---|
* | #123 修好**前**worker 綁著它,但它落在第二頁 | 2b 判「綁著的資源不見了」 | 產生 blocker,**停手**(過度保守,但安全) |
* | #123 修好**後**:名字落在第二頁 | 2c 判「這個名字沒被佔走」 | **去建 → CF 回 title already exists ⇒ #123 的死路原樣回來** |
*
* ⇒ 修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。所以規約是:
* **看不完整就不准當作看完了**——翻不完、或翻出來的數量對不上 CF 自己回報的
* `total_count`,一律 throw,讓 `planResources` 把它變成 blocker
* (README 規則第 3 條:說不準就整趟停手,一顆都不建)。
*
* 【三支端點的分頁行為不一樣,這裡刻意不假設它們同款】(2026-08-14 在 geek6688 帳號實測)
* - `/storage/kv/namespaces`:真分頁,`result_info` `{page, per_page, count, total_count, total_pages}`
* - `/d1/database`:真分頁,但 `result_info` **沒有 `total_pages`**(實測 `{page, per_page, count, total_count}`
* ⇒ **不准拿 `total_pages` 當終止條件**,那個欄位在 D1 上是 `undefined`
* - `/vectorize/v2/indexes`**不分頁**`result_info` 是 `null`,帶 `page``per_page` 也被忽略(一次回全部)
*
* 所以終止條件只用「三支都有、或三支都沒有」的兩件事:`result_info` 在不在、`total_count` 對不對得上。
* 對不分頁的那支,這支等於只打一次就回來(那兩個被忽略的參數實測無害);
* 而萬一 CF 哪天替它補上分頁,這支會自己跟著翻——不必等下一次災情才想起來改。
*
* @param {string} path 不含分頁參數的端點路徑(可自帶其他 query)
* @param {string} what 出錯訊息裡怎麼稱呼它
* @returns {Promise<any[]>}
*/
async function cfListAll(path, what) {
/** @type {any[]} */
const items = [];
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
const sep = path.includes('?') ? '&' : '?';
const res = await cfRaw(`${path}${sep}per_page=${LIST_PER_PAGE}&page=${page}`);
if (!res.ok) {
throw new Error(`${what} 失敗(第 ${page} 頁):${res.error ?? `HTTP ${res.status}`}`);
}
const batch = Array.isArray(res.result) ? res.result : [];
items.push(...batch);
const info = res.resultInfo;
// 這支端點沒有分頁(Vectorize v2)⇒ 這一趟拿到的就是全部。
if (!info) return items;
const total = Number(info.total_count);
if (Number.isFinite(total)) {
if (items.length >= total) return items;
// CF 說還有,卻一筆都不給 ⇒ 我們看不到全部。**不准安靜地當作看完了。**
if (batch.length === 0) {
throw new Error(
`${what} 只讀到 ${items.length} 筆,但 Cloudflare 說共有 ${total} 筆,第 ${page} 頁卻是空的。` +
`看不到帳號上的全部資源就沒辦法判斷該不該新建——停手。`,
);
}
continue; // total_count 說還有就繼續翻(不看 total_pages:D1 根本沒這個欄位)
}
// 沒有 total_count 可對,只剩「這一頁沒裝滿 ⇒ 沒有下一頁」可用。
if (batch.length < LIST_PER_PAGE) return items;
}
throw new Error(
`${what} 翻超過 ${LIST_MAX_PAGES} 頁還沒到底(已讀 ${items.length} 筆)。` +
`這不正常,寧可停手,也不拿一份不完整的清單去判斷該不該新建資源。`,
);
}
return {
cfRaw,
@@ -122,7 +205,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<Map<string, string>>} title → id */
async listKvNamespaces() {
/** @type {Array<{id: string, title: string}>} */
const result = await cf('/storage/kv/namespaces?per_page=100');
// 翻到底才算數(只看第一頁會讓 Arcrun#123 安靜復發,理由見 cfListAll
const result = await cfListAll('/storage/kv/namespaces', 'KV namespace');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
@@ -131,7 +215,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
const result = await cf('/d1/database?per_page=100');
// 翻到底才算數。D1 的 result_info **沒有 total_pages**,所以終止條件只認 total_count。
const result = await cfListAll('/d1/database', 'D1 資料庫');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
@@ -140,8 +225,10 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
// 這支端點**目前不分頁**`result_info` 是 null),走 cfListAll 等同只打一次;
// 但 CF 哪天替它補上分頁,這裡會自己跟著翻,不必等下一次災情才想起來改。
const result = await cfListAll('/vectorize/v2/indexes', 'Vectorize index');
return result.map((i) => i.name);
},
/**
+96 -17
View File
@@ -106,6 +106,19 @@
* @property {string} binding
* @property {string} worker 需要它的 worker script 名(= wrangler.toml 的 `name`)。
* @property {string} createName
* @property {boolean} [createNameIsOurs]
* 呼叫端在此**聲明**`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
* ⇒ 帳號上若已經有一顆**恰好同名**的資源,它只可能是我們上一次沒裝完留下的(Arcrun#123)。
*
* 🔴 這個聲明是「接管同名資源」的**唯一**依據,預設 falsefail-closed)。
* 只有在名字**推導得出、而且推導的輸入是使用者自己的身分**時才准聲明 true——
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
* slug `SHA-256('arcrun-rag:' + email)` 取前 8 碼,同一個 email 每次算出同一組名字,
* 別人算不到、也不會不小心撞上)。
*
* ⚠️ **不准**因為「名字看起來像我們的」就聲明 true。`acr` 那條從 wrangler.toml 讀到的
* createName 是裸 binding 名(`WEBHOOKS`)或 toml 宣告的庫名(`arcrun-kbdb`)——
* 那種名字使用者自己也可能拿去用,**證明不了是我們的**,所以那條路一律不聲明。
*/
/**
@@ -113,7 +126,12 @@
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的
* @property {string} from 從哪顆已部署的 worker 上讀到的。`reclaimed` 時為空字串——
* **沒有任何 worker 綁著它正是接收它的前提**(Arcrun#123),不是漏填。
* @property {boolean} [reclaimed]
* true = 這顆不是從某顆 worker 的綁定讀出來的,而是「帳號上已經有一顆我們自己命名的同名資源、
* 卻沒有人綁著」⇒ 上一次沒裝完留下的,這次把它接回來用(Arcrun#123)。
* 給呼叫端做診斷/統計用;**使用者不必知道「殘骸」這個詞**,對外一律講「沿用你原本的資源」。
*/
/**
@@ -144,7 +162,11 @@
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* 🔴 接回上次沒裝完留下的那顆(`reclaimed`**仍然算 `adopted`**,不另開第三種值——
* 它本來就是「沿用既有資源」,而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
* (安裝器那句「沿用你原本的 N 項資源」)不會因為多一種值就悄悄漏數。
* @property {string} [from]
* @property {boolean} [reclaimed] 見 PlannedAdopt.reclaimedArcrun#123)。
*/
/**
@@ -251,19 +273,25 @@ export async function planResources(api, requirements, mode) {
else byKey.set(key, [req]);
}
/** @type {Map<ResourceKind, Set<string>>} */
// 帳號上現有的資源,一種只查一次。**名字 → 身分**KV/D1 是 idVectorize 的身分就是名字)。
//
// 為什麼連名字都收下來(本來只留 `.values()`):
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
/** @type {Map<ResourceKind, Map<string, string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = async (kind) => {
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
const listExistingByName = async (kind) => {
const hit = existingCache.get(kind);
if (hit) return hit;
/** @type {Set<string>} */
let set;
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
else set = new Set(await api.listVectorizeIndexes());
existingCache.set(kind, set);
return set;
/** @type {Map<string, string>} */
let map;
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
else if (kind === 'd1') map = await api.listD1Databases();
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
existingCache.set(kind, map);
return map;
};
for (const [, reqs] of byKey) {
@@ -291,10 +319,10 @@ export async function planResources(api, requirements, mode) {
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
/** @type {Set<string>} */
/** @type {Map<string, string>} */
let existing;
try {
existing = await listExisting(kind);
existing = await listExistingByName(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
@@ -302,7 +330,7 @@ export async function planResources(api, requirements, mode) {
);
continue;
}
if (!existing.has(value)) {
if (![...existing.values()].includes(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
@@ -316,12 +344,62 @@ export async function planResources(api, requirements, mode) {
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding全新帳號
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
//
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
//
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
// ——會弄丟資料的是那個動作,不是這個。
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
// `createNameIsOurs`),沒聲明就停手。
/** @type {Map<string, string>} */
let existingByName;
try {
existingByName = await listExistingByName(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
`${msg(e)})。不確定就不建——停手。`,
);
continue;
}
const createName = reqs[0].createName;
const sameName = existingByName.get(createName);
if (sameName !== undefined) {
if (!reqs[0].createNameIsOurs) {
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
blockers.push(
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]}` +
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
);
continue;
}
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
continue;
}
create.push({
kind,
binding,
createName: reqs[0].createName,
createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
@@ -401,6 +479,7 @@ export async function applyResourcePlan(api, plan) {
value: a.value,
origin: 'adopted',
from: a.from,
...(a.reclaimed ? { reclaimed: true } : {}),
});
}
/** @type {string[]} */
+163 -9
View File
@@ -45,14 +45,21 @@ export function requirements() {
return out;
}
/** 安裝器替這台實例算出來的名字前綴(`arcrun-rag-<slugFromEmail(email)>`)。 */
export const BASE_NAME = 'arcrun-rag-yuga3bse';
/**
* 種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
* 種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
* 這正是 #97 的病根所在:規則**不准**拿名字當識別。
*
* @typedef {'fresh' | 'installed' | 'renamed'} Scenario
* `resourcesExist` 與 `deployed` **刻意拆開**:兩者不一致的那一格
* (資源在、worker 不在)就是 Arcrun#123 ——「上一次裝到一半死掉」的帳號。
* 本檔原本只有 `deployed` 一個旗標,所以那個狀態**表達不出來,也就沒被測到**。
*
* @typedef {'fresh' | 'installed' | 'renamed' | 'half-finished'} Scenario
*/
/** @type {Record<Scenario, {label: string, deployed: boolean, titleFor: (binding: string) => string}>} */
/** @type {Record<Scenario, {label: string, deployed: boolean, resourcesExist?: boolean, d1Title?: string, titleFor: (binding: string) => string}>} */
export const SCENARIOS = {
fresh: {
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
@@ -62,7 +69,7 @@ export const SCENARIOS = {
installed: {
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>',
deployed: true,
titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`,
titleFor: (b) => `${BASE_NAME}-kv-${b.toLowerCase()}`,
},
renamed: {
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
@@ -70,11 +77,101 @@ export const SCENARIOS = {
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
},
'half-finished': {
label: '上一次裝到一半死掉(KV/D1 已建在帳號上,一顆 worker 都還沒部署)— Arcrun#123',
deployed: false,
resourcesExist: true,
titleFor: (b) => `${BASE_NAME}-kv-${b.toLowerCase()}`,
// 這顆殘骸是**安裝器**留下的 ⇒ 名字要照安裝器真正會取的那個(`-db`),
// 不是上面兩個情境沿用的歷史名(`-kbdb`)。名字不對,這個測試就測不到真的那條路。
d1Title: `${BASE_NAME}-db`,
},
};
/**
* 安裝器那條路的需求清單:`createName` 是**安裝器自己替這台實例算出來的**,
* 所以它有資格聲明 `createNameIsOurs`(見 rule.mjs 該欄位的推導條件)。
*
* 對照 `requirements()`(走 wrangler.tomlcreateName 是裸 binding 名 ⇒ **不得**聲明)。
*
* @param {boolean} [claimOwnership] 預設 true;傳 false 就是「安裝器忘了聲明」的對照組。
*/
export function installerRequirements(claimOwnership = true, d1CreateName = `${BASE_NAME}-kbdb`) {
const out = [];
for (const [worker, need] of Object.entries(WORKER_NEEDS)) {
for (const b of need.kv) {
out.push({
kind: 'kv_namespace', binding: b, worker,
createName: `${BASE_NAME}-kv-${b.toLowerCase()}`,
...(claimOwnership ? { createNameIsOurs: true } : {}),
});
}
for (const d of need.d1) {
out.push({
kind: 'd1', binding: d.binding, worker,
createName: d1CreateName,
...(claimOwnership ? { createNameIsOurs: true } : {}),
});
}
}
return out;
}
/**
* 真實 CF 的行為:**同名建不出來**KV 回 400「a namespace with this account ID and title
* already exists」,D1 回 code 7502「Database with name … already exists」——兩條都在
* `geek6688` 帳號上實打驗過)。這一層就是封測者撞到的那道牆。
*
* fixture 過去沒有模擬它,所以「重建一批孤兒」這個假設從來沒被戳破(Arcrun#123)。
*
* 🔴 這支**自己會翻頁**(`per_page` 開很大)。它要是只看第一頁,就會在
* 「帳號上資源很多」的測試裡漏認同名 ⇒ 反而把被測的 bug 蓋住。
*
* @param {ReturnType<typeof makeAccount>} account
* @returns {typeof globalThis.fetch}
*/
export function cfRejectsDuplicateNames(account) {
const inner = account.fetch;
const BASE = 'https://api.cloudflare.com/client/v4/accounts/x';
/** @param {string} path @returns {Promise<any[]>} */
const listAll = async (path) => {
const res = await inner(`${BASE}${path}?per_page=100000&page=1`, {});
return (await res.json()).result ?? [];
};
/** @param {string} message */
const conflict = (message) =>
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
/** @type {typeof globalThis.fetch} */
// @ts-expect-error — 測試替身
return async (input, init) => {
const url = new URL(typeof input === 'string' ? input : String(input));
const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, '');
const method = (init?.method ?? 'GET').toUpperCase();
if (method === 'POST' && (path === '/storage/kv/namespaces' || path === '/d1/database')) {
const body = JSON.parse(String(init?.body));
if (path === '/storage/kv/namespaces') {
const taken = (await listAll(path)).some((/** @type {{title: string}} */ n) => n.title === body.title);
if (taken) return conflict('a namespace with this account ID and title already exists');
} else {
const taken = (await listAll(path)).some((/** @type {{name: string}} */ d) => d.name === body.name);
if (taken) return conflict(`Database with name: '${body.name}' already exists`);
}
}
return inner(input, init);
};
}
/**
* 建一個假帳號 + 對應的 `fetch` 替身。
*
* @param {object} [opts]
* @param {number} [opts.decoyKv] 帳號上另外還有幾顆「別人的」KV(排在我們的前面)
* @param {number} [opts.decoyD1] 同上,D1
*
* @param {Scenario} scenario
* @returns {{
* fetch: typeof globalThis.fetch,
@@ -85,8 +182,12 @@ export const SCENARIOS = {
* requestLog: string[],
* }}
*/
export function makeAccount(scenario) {
export function makeAccount(scenario, opts = {}) {
const spec = SCENARIOS[scenario];
// 「這個帳號上還有很多**別人的**資源」。用途:把我們自己那幾顆擠到第二頁以後,
// 驗清單有沒有翻頁。CF 的 KV 上限是每帳號 1,000 顆,>100 是真實會發生的規模。
const decoyKv = opts.decoyKv ?? 0;
const decoyD1 = opts.decoyD1 ?? 0;
/** title → id */
const kv = new Map();
/** name → uuid */
@@ -109,15 +210,23 @@ export function makeAccount(scenario) {
const kvIdByBinding = new Map();
const D1_ID = 'd1id-kbdb-REAL';
if (spec.deployed) {
// 誘餌**先塞**,我們自己的才排在它們後面 ⇒ 只看第一頁就一定看不到我們的那幾顆。
// (真 CF 的排序不歸我們管;這裡刻意排成「最壞情況」,因為要證的正是最壞情況下也看得到。)
for (let i = 0; i < decoyKv; i++) kv.set(`someone-elses-kv-${String(i).padStart(4, '0')}`, `kvid-decoy-${i}`);
for (let i = 0; i < decoyD1; i++) d1.set(`someone-elses-db-${String(i).padStart(4, '0')}`, `d1id-decoy-${i}`);
// 資源存不存在,與 worker 部署了沒,是**兩件事**(#123:中斷的安裝會讓前者為真、後者為假)。
if (spec.resourcesExist ?? spec.deployed) {
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
for (const b of KV_BINDINGS) {
const id = `kvid-${b.toLowerCase()}-REAL`;
kv.set(spec.titleFor(b), id);
kvIdByBinding.set(b, id);
}
d1.set('arcrun-rag-yuga3bse-kbdb', D1_ID);
d1.set(spec.d1Title ?? `${BASE_NAME}-kbdb`, D1_ID);
}
if (spec.deployed) {
// 已部署的 worker 上綁著它們——**這才是規則要看的事實**
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
const bindings = [];
@@ -137,6 +246,48 @@ export function makeAccount(scenario) {
status,
headers: { 'Content-Type': 'application/json' },
});
/**
* 分頁的清單回應——**照真 Cloudflare 的形狀**,不是照我們方便的形狀。
*
* 【這些假資料憑什麼代表得了真的 CF 回應】
* 2026-08-14 拿 `geek6688` 帳號實打過三支端點(唯讀,只列不建),逐字抄回來的:
*
* ```
* GET /storage/kv/namespaces?per_page=5&page=1
* → result_info {"count":5,"page":1,"per_page":5,"total_count":9,"total_pages":2}
* GET /storage/kv/namespaces?per_page=5&page=2
* → 4 筆,result_info {"count":4,"page":2,"per_page":5,"total_count":9,"total_pages":2}
* GET /d1/database?per_page=5&page=1
* → result_info {"count":1,"page":1,"per_page":5,"total_count":1} ← **沒有 total_pages**
* GET /vectorize/v2/indexes?per_page=1&page=1
* → 2 筆(分頁參數被忽略),result_info: null ← **這支不分頁**
* ```
*
* 🔴 **三支的形狀不一樣,這裡就必須不一樣**。假資料要是三支都長成 KV 那樣,
* 就會養出「拿 `total_pages` 當終止條件」這種在 D1 上必壞的實作,而測試全綠。
* 假資料失真=測了個假的,比沒測更糟。
*
* @param {any[]} all 這個端點上「全部」的東西
* @param {URLSearchParams} q 呼叫端帶來的分頁參數
* @param {{totalPages: boolean}} shape 這支端點的 result_info 帶不帶 total_pages
*/
const okPaged = (all, q, shape) => {
const perPage = Number(q.get('per_page')) || 20;
const page = Number(q.get('page')) || 1;
const slice = all.slice((page - 1) * perPage, page * perPage);
const info = {
count: slice.length,
page,
per_page: perPage,
total_count: all.length,
...(shape.totalPages ? { total_pages: Math.max(1, Math.ceil(all.length / perPage)) } : {}),
};
return new Response(JSON.stringify({ success: true, result: slice, errors: [], result_info: info }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
/** @param {string} message @param {number} status */
const fail = (message, status) =>
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
@@ -162,7 +313,8 @@ export function makeAccount(scenario) {
}
if (path === '/storage/kv/namespaces' && method === 'GET') {
return ok([...kv].map(([title, id]) => ({ id, title })));
// 真分頁,result_info 帶 total_pages(實測形狀,見 okPaged
return okPaged([...kv].map(([title, id]) => ({ id, title })), url.searchParams, { totalPages: true });
}
if (path === '/storage/kv/namespaces' && method === 'POST') {
const id = `kvid-NEW-${created.kv.length + 1}`;
@@ -171,7 +323,8 @@ export function makeAccount(scenario) {
return ok({ id, title: body.title });
}
if (path === '/d1/database' && method === 'GET') {
return ok([...d1].map(([name, uuid]) => ({ uuid, name })));
// 真分頁,但 result_info **沒有 total_pages**(實測形狀,見 okPaged
return okPaged([...d1].map(([name, uuid]) => ({ uuid, name })), url.searchParams, { totalPages: false });
}
if (path === '/d1/database' && method === 'POST') {
const uuid = `d1id-NEW-${created.d1.length + 1}`;
@@ -180,6 +333,7 @@ export function makeAccount(scenario) {
return ok({ uuid, name: body.name });
}
if (path === '/vectorize/v2/indexes' && method === 'GET') {
// 這支**不分頁**:分頁參數被忽略、`result_info` 是 null(實測,見 okPaged 檔頭那段)
return ok(vectorize.map((name) => ({ name })));
}
if (path === '/vectorize/v2/indexes' && method === 'POST') {
@@ -0,0 +1,160 @@
// @ts-check
/**
* half-finished-install.mjs — Arcrun#123 的迴歸守衛。
*
* node shared/resource-rule/tests/half-finished-install.mjs
*
* 【要證的那句話(leo 2026-08-14 的驗收線)】
* 「一個**上次裝到一半死掉**的帳號,用戶只做一件事——回安裝器再按一次——就要能裝成功。」
* ⇒ 不准叫用戶開 Cloudflare 後台、不准叫他跑指令、不准要他懂 namespacebinding。
*
* 【為什麼這個狀態逃過了 1.4.45 之前所有驗證】
* 我們測的是「乾淨帳號 + 完整安裝」。而這個 bug 只在
* **資源已建、worker 未部署** 這一格才撞得到——`fixture-account.mjs` 原本
* 只有 `deployed` 一個旗標,連表達這個狀態的能力都沒有。
*
* 零依賴、零建置:跟 demo.mjs 一樣,跑得起來本身就是
* 「安裝器把 repo archive 拉下來就能直接用」的證據。
*/
import { planResources, applyResourcePlan, ResourcePlanBlocked, bindingKey } from '../rule.mjs';
import { createCloudflareResourceApi } from '../cf-resource-api.mjs';
import {
makeAccount, installerRequirements, requirements, KV_BINDINGS, BASE_NAME, cfRejectsDuplicateNames,
} from './fixture-account.mjs';
let failed = 0;
/** @param {boolean} cond @param {string} what */
function check(cond, what) {
console.log(` ${cond ? '✅' : '❌'} ${what}`);
if (!cond) failed++;
}
/** @param {string} title */
function section(title) {
console.log(`\n━━━ ${title} ━━━`);
}
// 「真 CF 會拒絕同名」這個替身搬去 fixture-account.mjs 了(pagination.mjs 也要用同一份,
// 而且它必須自己會翻頁——只看第一頁的版本會在「帳號上資源很多」的測試裡漏認同名)。
/** @param {ReturnType<typeof makeAccount>} account */
const apiFor = (account, fetchImpl) =>
createCloudflareResourceApi({ accountId: 'acct-123', apiToken: 'tok-123', fetch: fetchImpl ?? account.fetch });
// ═══════════════════════════════════════════════════════════════════════════
section('① 驗收線本身:半殘帳號 + 安裝器再按一次 → 裝得起來,且一顆資源都不必新建');
// ═══════════════════════════════════════════════════════════════════════════
{
const account = makeAccount('half-finished');
const api = apiFor(account, cfRejectsDuplicateNames(account));
// mode='init':安裝器看的是自己的紀錄(`deployed:<account>:`),上次沒裝完就沒有那筆
// ⇒ 這一輪照定義是「新裝」。半殘狀態必須在 init 這條路上就被處理掉。
const plan = await planResources(api, installerRequirements(true, `${BASE_NAME}-db`), 'init');
check(plan.blockers.length === 0, `不該有任何 blocker(實得 ${plan.blockers.length} 條)`);
if (plan.blockers.length) console.log(plan.blockers.map((b) => ` · ${b}`).join('\n'));
check(plan.create.length === 0, `一顆都不該新建(實得 ${plan.create.length} 顆要建)`);
// 11 9 個 KV binding + 2 個 D1 bindingCREDENTIALS_DBDB,兩個指向同一顆庫)。
// 這裡數的是**綁定**,不是資源顆數——底下那條 D1 斷言才在證「兩個綁定指到同一顆」。
check(plan.adopt.length === 11, `11 個綁定全部接回來(實得 ${plan.adopt.length}`);
check(plan.adopt.every((a) => a.reclaimed === true), '每一顆都標記為「接回上次留下的」');
const resolved = await applyResourcePlan(api, plan);
check(account.created.kv.length === 0, `帳號上不該多出任何 KV(實得 ${account.created.kv.length}`);
check(account.created.d1.length === 0, `帳號上不該多出任何 D1(實得 ${account.created.d1.length}`);
// 綁到的必須是**上次留下的那幾顆本尊**,不是新的空殼
const webhooks = resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'));
check(webhooks?.value === account.kvIdFor('WEBHOOKS'), 'WEBHOOKS 綁回上次建的那一顆本尊');
check(webhooks?.origin === 'adopted', `origin 仍是 adopted(實得 ${webhooks?.origin})——` +
'安裝器現有的「沿用你原本的 N 項資源」統計不會漏數');
const everyBindingResolved = KV_BINDINGS.every((b) => resolved.has(bindingKey('kv_namespace', b)));
check(everyBindingResolved, '9 個 KV binding 全部都有著落(安裝可以繼續往下走)');
check(resolved.get(bindingKey('d1', 'DB'))?.value === account.d1Id
&& resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value === account.d1Id,
'kbdb 與 cypher 兩個 D1 binding 指到同一顆(維持「整台一顆 D1」的形狀)');
}
// ═══════════════════════════════════════════════════════════════════════════
section('② 對照組:修好之前是什麼下場(沒有聲明 createNameIsOurs ⇒ 走舊行為)');
// ═══════════════════════════════════════════════════════════════════════════
{
// 不聲明所有權時,規則不准接管 ⇒ 停手。這也是舊版**會撞牆**的那條路:
// 舊版會直接送 POST,然後被 CF 用「title already exists」打回來。
const account = makeAccount('half-finished');
const api = apiFor(account, cfRejectsDuplicateNames(account));
const plan = await planResources(api, installerRequirements(false, `${BASE_NAME}-db`), 'init');
check(plan.blockers.length > 0, '證明不了是自己的 → 一定要停手(fail-closed');
check(plan.blockers.join('\n').includes('RES-NAME-TAKEN'), '錯誤碼要在訊息裡(讓用戶回報,而不是自己去後台動手)');
const said = plan.blockers.join('\n');
check(!/Cloudflare 後台|dashboard|自己刪|去刪/.test(said), '訊息不准叫使用者自己去 CF 後台處理(#121/D88');
await assertRejects(() => applyResourcePlan(api, plan));
check(account.created.kv.length === 0 && account.created.d1.length === 0,
'被擋下時一顆資源都不能被建出來(plan/apply 兩段的結構保證)');
}
// ═══════════════════════════════════════════════════════════════════════════
section('③ 紅線:不准接管「真的不是我們的」同名資源');
// ═══════════════════════════════════════════════════════════════════════════
{
// 使用者自己在帳號上建了一顆叫 WEBHOOKS 的 KV。走 wrangler.toml 那條路(acr)時
// createName 就是裸 binding 名 `WEBHOOKS` ⇒ 撞名。那顆**可能真的是他自己的**
// ⇒ 規則不准接管,也不准新建一顆頂上去。
const account = makeAccount('fresh');
const api = apiFor(account);
await api.createKvNamespace('WEBHOOKS'); // ← 使用者自己的東西
account.created.kv.length = 0; // 歸零,只算「規則這一趟建了什麼」
const plan = await planResources(api, requirements(), 'init');
check(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
check(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
await assertRejects(() => applyResourcePlan(api, plan));
check(account.created.kv.length === 0, '一顆都沒建');
}
// ═══════════════════════════════════════════════════════════════════════════
section('④ D82 三步不可退化:既有的三種情境行為完全不變');
// ═══════════════════════════════════════════════════════════════════════════
{
// 全新帳號:照舊該建的全建出來
const fresh = makeAccount('fresh');
const freshPlan = await planResources(apiFor(fresh), installerRequirements(), 'init');
check(freshPlan.blockers.length === 0, '全新帳號:無 blocker');
check(freshPlan.adopt.length === 0, '全新帳號:沒有東西可沿用');
// 11 個綁定 → 10 顆要建:兩個 D1 綁定宣告同一個 createName,被 shareSameResource 收斂成一顆。
check(freshPlan.create.length === 10, `全新帳號:11 個綁定收斂成 10 顆要建(實得 ${freshPlan.create.length}`);
await applyResourcePlan(apiFor(fresh), freshPlan);
check(fresh.created.kv.length === 9 && fresh.created.d1.length === 1,
`全新帳號:實際建出 9 KV + 1 D1(實得 ${fresh.created.kv.length} / ${fresh.created.d1.length}`);
// 已裝好的實例跑更新:#97 的核心保證——沿用既有、一顆都不新建
const installed = makeAccount('installed');
const upPlan = await planResources(apiFor(installed), installerRequirements(), 'update');
check(upPlan.blockers.length === 0, '已裝好:無 blocker');
check(upPlan.create.length === 0, '已裝好:一顆都不新建(#97');
check(upPlan.adopt.every((a) => !a.reclaimed), '已裝好:全部來自 worker 綁定,沒有一顆走「接回殘骸」那條路');
await applyResourcePlan(apiFor(installed), upPlan);
check(installed.created.kv.length === 0 && installed.created.d1.length === 0, '已裝好:帳號上顆數不變');
// 使用者把資源改過名:規則不看名字,照樣沿用 worker 綁著的那幾顆(#97 的另一面)
const renamed = makeAccount('renamed');
const rnPlan = await planResources(apiFor(renamed), installerRequirements(), 'update');
check(rnPlan.blockers.length === 0, '改過名:無 blocker');
check(rnPlan.create.length === 0, '改過名:一顆都不新建——名字對不上也不影響(規則只看綁定)');
check(rnPlan.adopt.every((a) => !a.reclaimed), '改過名:沒有一顆是靠名字對上的');
}
/** @param {() => Promise<unknown>} fn */
async function assertRejects(fn) {
try {
await fn();
check(false, 'applyResourcePlan 應該要丟 ResourcePlanBlocked,但它沒有');
} catch (e) {
check(e instanceof ResourcePlanBlocked, 'applyResourcePlan 丟 ResourcePlanBlocked');
}
}
console.log(`\n${failed === 0 ? '✅ 全部通過' : `${failed} 項失敗`}`);
process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,192 @@
// @ts-check
/**
* list-pagination.mjs — 「帳號上的資源多到一頁裝不下」時的迴歸守衛。
*
* node shared/resource-rule/tests/list-pagination.mjs
*
* 【要證的那句話】
* 「規則看到的帳號清單,就是帳號上**真正的全部**」——不論那個帳號有多少顆資源。
*
* 【為什麼這是 Arcrun#123 的續集,而不是一個獨立的小 bug】
* 三支清單方法原本只打 `?per_page=100`(只看第一頁)。同一個截斷,
* 在 #123 的修法前後**後果不一樣**:
*
* · 修法**前**:被截掉的是「worker 綁著的那顆」→ 2b 判「綁著的資源不見了」
* → 產生 blocker → **停手**。過度保守,但安全。
* · 修法**後**:被截掉的是「同名殘骸」→ 2c 判「這個名字沒被佔走」
* → **去建 → CF 回 title already exists → #123 的死路原樣回來**。
*
* ⇒ #123 的修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。
* 所以它必須跟 #123 同一批修掉,否則那張票只是把災情延後到「資源比較多的帳號」。
*
* 【假資料憑什麼代表得了真的 CF】
* `fixture-account.mjs` 的 `okPaged` 是照 2026-08-14 在 `geek6688` 帳號**實打**的回應
* 逐字抄回來的形狀(唯讀,只列不建)——關鍵是三支端點**形狀不一樣**:
* KV 的 `result_info` 有 `total_pages`D1 **沒有**Vectorize 根本 `null`(不分頁)。
* 假資料要是三支都照 KV 抄,就會養出「拿 `total_pages` 當終止條件」這種在 D1 上必壞的
* 實作,而測試全綠。**假資料失真=測了個假的。**
*
* 零依賴、零建置,跟 demo.mjs 一樣直接 node 跑。
*/
import { planResources, applyResourcePlan, ResourcePlanBlocked } from '../rule.mjs';
import { createCloudflareResourceApi } from '../cf-resource-api.mjs';
import {
makeAccount, installerRequirements, KV_BINDINGS, BASE_NAME, cfRejectsDuplicateNames,
} from './fixture-account.mjs';
let failed = 0;
/** @param {boolean} cond @param {string} what */
function check(cond, what) {
console.log(` ${cond ? '✅' : '❌'} ${what}`);
if (!cond) failed++;
}
/** @param {string} title */
function section(title) {
console.log(`\n━━━ ${title} ━━━`);
}
const apiFor = (account, fetchImpl) =>
createCloudflareResourceApi({ accountId: 'acct-123', apiToken: 'tok-123', fetch: fetchImpl ?? account.fetch });
/** 帳號上「別人的」資源顆數。250 > 100 ⇒ 我們自己那幾顆一定落在第三頁。 */
const DECOY = 250;
// ═══════════════════════════════════════════════════════════════════════════
section('① 清單本身:第二頁以後的東西真的被看見了');
// ═══════════════════════════════════════════════════════════════════════════
{
const account = makeAccount('installed', { decoyKv: DECOY, decoyD1: DECOY });
const api = apiFor(account);
const kv = await api.listKvNamespaces();
check(kv.size === DECOY + KV_BINDINGS.length,
`KV 要讀滿 ${DECOY + KV_BINDINGS.length} 顆(實得 ${kv.size})——只看第一頁的話這裡是 100`);
// 我們自己那幾顆排在誘餌後面 ⇒ 它們在第三頁。看得到=真的翻過去了。
const lastOne = `${BASE_NAME}-kv-${KV_BINDINGS[KV_BINDINGS.length - 1].toLowerCase()}`;
check(kv.has(lastOne), `最後一頁那顆(${lastOne})也在清單裡`);
const d1 = await api.listD1Databases();
check(d1.size === DECOY + 1, `D1 要讀滿 ${DECOY + 1} 顆(實得 ${d1.size}`);
check(d1.has(`${BASE_NAME}-kbdb`), '第三頁的那顆 D1 也在清單裡');
// 真的打了三頁,不是靠某個 per_page 開很大蒙混過去
const kvPages = account.requestLog.filter((l) => l.startsWith('GET /storage/kv/namespaces'));
check(kvPages.length === 3, `KV 清單分三次抓(實得 ${kvPages.length} 次):\n ${kvPages.join('\n ')}`);
check(kvPages.some((l) => l.includes('page=3')), '確實有打到 page=3');
const d1Pages = account.requestLog.filter((l) => l.startsWith('GET /d1/database'));
check(d1Pages.length === 3, `D1 清單分三次抓(實得 ${d1Pages.length} 次)`);
}
// ═══════════════════════════════════════════════════════════════════════════
section('② 修法「前」那一面:已裝好的實例,不准因為看不完整就誣告「你的資源不見了」');
// ═══════════════════════════════════════════════════════════════════════════
{
// 使用者好好地裝著,只是帳號上東西多。2b 要拿清單確認「綁著的那顆還在」——
// 清單被截斷 ⇒ 規則會說「這顆在你的 Cloudflare 帳號上找不到了」⇒ 好好的更新被硬擋。
const account = makeAccount('installed', { decoyKv: DECOY, decoyD1: DECOY });
const plan = await planResources(apiFor(account), installerRequirements(), 'update');
check(plan.blockers.length === 0, `不該有任何 blocker(實得 ${plan.blockers.length} 條)`);
if (plan.blockers.length) console.log(plan.blockers.map((b) => ` · ${b}`).join('\n'));
check(!plan.blockers.join('\n').includes('找不到了'), '不准出現「這顆在你的帳號上找不到了」這種誣告');
check(plan.create.length === 0, `一顆都不該新建(實得 ${plan.create.length}`);
check(plan.adopt.length === 11, `11 個綁定全部沿用(實得 ${plan.adopt.length}`);
}
// ═══════════════════════════════════════════════════════════════════════════
section('③ 修法「後」那一面(安靜復發的那條):半殘帳號 + 資源很多 ⇒ 仍要接回,不准去建');
// ═══════════════════════════════════════════════════════════════════════════
{
// 這一格就是本檔存在的理由:
// 殘骸在第三頁 → 清單被截斷 → 2c 判「名字沒被佔走」→ 送 POST → CF 拒絕 → #123 復發。
// 而且是**安靜地**復發:規則自己覺得一切正常。
const account = makeAccount('half-finished', { decoyKv: DECOY, decoyD1: DECOY });
const api = apiFor(account, cfRejectsDuplicateNames(account));
const plan = await planResources(api, installerRequirements(true, `${BASE_NAME}-db`), 'init');
check(plan.blockers.length === 0, `不該有任何 blocker(實得 ${plan.blockers.length} 條)`);
if (plan.blockers.length) console.log(plan.blockers.map((b) => ` · ${b}`).join('\n'));
check(plan.create.length === 0, `一顆都不該新建(實得 ${plan.create.length} 顆要建)`);
check(plan.adopt.length === 11, `11 個綁定全部接回來(實得 ${plan.adopt.length}`);
check(plan.adopt.every((a) => a.reclaimed === true), '每一顆都標記為「接回上次留下的」');
// 走完 apply:CF 那道「同名建不出來」的牆還在,這一趟不准撞上去。
await applyResourcePlan(api, plan);
check(account.created.kv.length === 0 && account.created.d1.length === 0,
`帳號上不該多出任何資源(實得 KV ${account.created.kv.length}D1 ${account.created.d1.length}`);
}
// ═══════════════════════════════════════════════════════════════════════════
section('④ 三支端點形狀不同,一支都不能壞');
// ═══════════════════════════════════════════════════════════════════════════
{
const account = makeAccount('fresh');
const api = apiFor(account);
// Vectorize`result_info` 是 null(不分頁)。翻頁邏輯不能因此漏東西、也不能掛掉。
await api.createVectorizeIndex('idx-a');
await api.createVectorizeIndex('idx-b');
await api.createVectorizeIndex('idx-c');
const idx = await api.listVectorizeIndexes();
check(idx.length === 3 && idx.includes('idx-c'), `不分頁的端點照樣讀得到全部(實得 ${idx.length} 個)`);
// D1`result_info` **沒有 total_pages**。拿 total_pages 當終止條件的實作會在這裡爆。
const many = makeAccount('installed', { decoyD1: DECOY });
const d1 = await apiFor(many).listD1Databases();
check(d1.size === DECOY + 1, `D1 沒有 total_pages 也要翻得完(實得 ${d1.size}`);
// 空帳號:第一頁就是空的,不能誤判成「還有下一頁」而空轉
const empty = makeAccount('fresh');
const none = await apiFor(empty).listKvNamespaces();
check(none.size === 0, `空帳號回 0 顆且不空轉(實得 ${none.size}`);
check(empty.requestLog.filter((l) => l.startsWith('GET /storage/kv/namespaces')).length === 1,
'空帳號只打一次清單');
}
// ═══════════════════════════════════════════════════════════════════════════
section('⑤ 看不完整時要**大聲停手**,不准安靜地當作看完了');
// ═══════════════════════════════════════════════════════════════════════════
{
// CF 說共有 300 筆,卻從第二頁起一筆都不給。這種時候「回一份不完整的清單」
// 就是災難的入口(規則會拿它去判斷該不該新建)⇒ 必須 throw ⇒ 變成 blocker ⇒ 整趟停手。
const liar = async (input) => {
const url = new URL(String(input));
if (!url.pathname.endsWith('/storage/kv/namespaces')) {
return new Response(JSON.stringify({ success: true, result: [], errors: [], result_info: null }),
{ status: 200, headers: { 'Content-Type': 'application/json' } });
}
const page = Number(url.searchParams.get('page'));
const result = page === 1 ? Array.from({ length: 100 }, (_, i) => ({ id: `id-${i}`, title: `t-${i}` })) : [];
return new Response(JSON.stringify({
success: true, result, errors: [],
result_info: { count: result.length, page, per_page: 100, total_count: 300, total_pages: 3 },
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const api = createCloudflareResourceApi({ accountId: 'a', apiToken: 't', fetch: /** @type {any} */ (liar) });
let threw = null;
try {
await api.listKvNamespaces();
} catch (e) {
threw = e;
}
check(threw !== null, '讀不完整 → 要 throw,不准回一份殘缺清單');
check(String(threw?.message ?? '').includes('300'), `訊息要說清楚少了什麼(實得:${threw?.message}`);
// 而且這個 throw 要在規則那一層變成 blockerfail-closed),不是讓整個安裝器炸掉
const plan = await planResources(api, installerRequirements(), 'init');
check(plan.blockers.length > 0, '規則要把它變成 blocker');
// 讀不到清單的那一種(KV)**一顆都不准排新建**——「不知道」不等於「它沒有」。
// (D1 那邊清單讀得到,照規則排新建是對的;反正整份計畫被 blocker 擋著,一顆都不會真的被建。)
check(!plan.create.some((c) => c.kind === 'kv_namespace'), '讀不到清單的那一種資源不准排新建');
try {
await applyResourcePlan(api, plan);
check(false, 'applyResourcePlan 應該要丟 ResourcePlanBlocked,但它沒有');
} catch (e) {
check(e instanceof ResourcePlanBlocked, 'applyResourcePlan 丟 ResourcePlanBlocked');
}
}
console.log(`\n${failed === 0 ? '✅ 全部通過' : `${failed} 項失敗`}`);
process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,116 @@
// @ts-check
/**
* verify-against-real-account.mjs — 在**真的 Cloudflare 帳號**上驗 Arcrun#123。
*
* CF_API_TOKEN=<token> CF_ACCOUNT_ID=<id> node shared/resource-rule/tests/verify-against-real-account.mjs
*
* 【為什麼需要這一支】
* `half-finished-install.mjs` 是離線的(fetch 替身)——它證明**判斷**對,
* 但證明不了「真的 Cloudflare 會不會照我們以為的方式回應」。#123 的整個病根
* 就是**我們以為 CF 會讓我們重建,實際上它拒絕**。那種錯,只有真帳號驗得出來。
*
* 【它會對你的帳號做什麼】
* · 讀:列 KVD1Vectorize、讀幾顆 worker 的綁定
* · 寫:**只建一顆**名字帶 `-zz123tst-` 的一次性 KV,用完**一定刪掉**(finally 保證)
* · 🔴 **不碰你任何既有資源**:不部署 worker、不改綁定、不刪別的東西
* (測試用的 worker script 名是刻意不存在的,所以規則看到的是「沒有人綁著它」)
*
* 安全開關:
* DRY_RUN=true 只盤點與說明會做什麼,不建也不刪
* KEEP=true 測完不刪那顆測試 KV(除錯用;正常不要開)
*/
import { planResources, applyResourcePlan } from '../rule.mjs';
import { createCloudflareResourceApi } from '../cf-resource-api.mjs';
const TOKEN = process.env.CF_API_TOKEN || process.env.CLOUDFLARE_API_TOKEN;
const ACCOUNT = process.env.CF_ACCOUNT_ID;
const DRY = process.env.DRY_RUN === 'true';
const KEEP = process.env.KEEP === 'true';
if (!TOKEN || !ACCOUNT) {
console.error('缺 CF_API_TOKEN 或 CF_ACCOUNT_ID。');
console.error(' CF_API_TOKEN=<token> CF_ACCOUNT_ID=<id> node shared/resource-rule/tests/verify-against-real-account.mjs');
process.exit(1);
}
/** 一次性測試用的實例短碼——刻意帶 zz 前綴,不可能跟真的實例撞。 */
const BASE = 'arcrun-rag-zz123tst';
const TEST_KV_TITLE = `${BASE}-kv-oauth_kv`;
/** 刻意用**不存在**的 worker 名:規則會讀到 404 ⇒「沒有人綁著它」⇒ 走 #123 那條路。 */
const GHOST_WORKER = 'arcrun-mcp-zz123tst-does-not-exist';
const api = createCloudflareResourceApi({ accountId: ACCOUNT, apiToken: TOKEN });
let failed = 0;
const check = (ok, what) => { console.log(` ${ok ? '✅' : '❌'} ${what}`); if (!ok) failed++; };
console.log('Arcrun#123 真帳號驗證');
console.log(`帳號:${ACCOUNT}`);
console.log(`會建一顆:${TEST_KV_TITLE}(測完刪除)${DRY ? ' ← DRY_RUN,不會真的建' : ''}\n`);
// ── 0. 先盤點,讓人看得到「我沒動你的東西」 ────────────────────────────
const before = await api.listKvNamespaces();
console.log(`帳號上現有 KV${before.size}`);
if (before.has(TEST_KV_TITLE)) {
console.log(`⚠️ 帳號上已經有 ${TEST_KV_TITLE}(上次沒清乾淨?)——直接沿用它做這次測試。`);
}
if (DRY) {
console.log('\nDRY_RUN:到此為止,什麼都沒建也沒刪。');
process.exit(0);
}
/** @type {string | undefined} */
let testKvId = before.get(TEST_KV_TITLE);
const weCreatedIt = testKvId === undefined;
try {
// ── 1. 製造「上次裝到一半死掉」:資源在、worker 不在 ──────────────────
if (weCreatedIt) {
testKvId = await api.createKvNamespace(TEST_KV_TITLE);
console.log(`\n① 已建立測試殘骸 ${TEST_KV_TITLE}${testKvId}`);
} else {
console.log(`\n① 沿用既有的 ${TEST_KV_TITLE}${testKvId}`);
}
const reqs = (claim) => [{
kind: /** @type {const} */ ('kv_namespace'),
binding: 'OAUTH_KV',
worker: GHOST_WORKER,
createName: TEST_KV_TITLE,
...(claim ? { createNameIsOurs: true } : {}),
}];
// ── 2. 修好之後:應該接回那一顆,一顆都不建 ──────────────────────────
console.log('\n② 修復後(安裝器聲明 createNameIsOurs');
const plan = await planResources(api, reqs(true), 'init');
check(plan.blockers.length === 0, `不該有 blocker(實得 ${plan.blockers.length}`);
plan.blockers.forEach((b) => console.log(' ·', b.slice(0, 150)));
check(plan.create.length === 0, `不該有東西要新建(實得 ${plan.create.length}`);
check(plan.adopt.length === 1 && plan.adopt[0].reclaimed === true, '應該標記為「接回上次留下的」');
const kvCountBeforeApply = (await api.listKvNamespaces()).size;
const resolved = await applyResourcePlan(api, plan);
const kvCountAfterApply = (await api.listKvNamespaces()).size;
check(kvCountAfterApply === kvCountBeforeApply, `apply 之後帳號上顆數不變(${kvCountBeforeApply}${kvCountAfterApply}`);
check(resolved.get('kv_namespace:OAUTH_KV')?.value === testKvId, '綁到的是那顆殘骸本尊,不是新建的空殼');
// ── 3. 沒聲明來歷:必須 fail-closed(#97 的反向災情) ─────────────────
console.log('\n③ 沒聲明 createNameIsOurs(不能證明是我們的)');
const blocked = await planResources(api, reqs(false), 'init');
check(blocked.blockers.length > 0, '必須停手,不准接管');
check(/RES-NAME-TAKEN/.test(blocked.blockers.join('\n')), '訊息要帶可回報的錯誤碼');
check(!/後台|dashboard/.test(blocked.blockers.join('\n')), '訊息不准叫使用者自己去 CF 後台(#121/D88');
} finally {
// ── 4. 清乾淨(不管上面成功失敗都要跑) ──────────────────────────────
if (testKvId && weCreatedIt && !KEEP) {
const res = await api.cfRaw(`/storage/kv/namespaces/${testKvId}`, { method: 'DELETE' });
console.log(`\n④ 清理:刪除 ${TEST_KV_TITLE}${res.ok ? '✅ 已刪除' : '⚠️ 刪除失敗,請手動刪:' + res.error}`);
} else if (KEEP) {
console.log(`\n④ KEEP=true,保留 ${TEST_KV_TITLE}(記得自己刪)`);
}
}
console.log(`\n${failed === 0 ? '✅ 真帳號驗證全部通過' : `${failed} 項失敗`}`);
process.exit(failed === 0 ? 0 : 1);