// The only place the account-wide Cloudflare credential is used to touch // Cloudflare. Every method injects the house token into the child env and shells // wrangler. `run` is injectable so the wiring is unit-testable without wrangler. import { execFile } from "node:child_process"; import { cp, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import type { HouseCredential } from "./house-credential.js"; import { readHouseCredential } from "./house-credential.js"; import { resolveHouseScopedToken } from "./house-scoped-token.js"; import { resolveUploadDir, WRANGLER_CONFIG_NAMES, type UploadDirSource, } from "./pages-output-dir.js"; export type RunFn = ( cmd: string, args: string[], env: Record, /** Working directory for the child. Task 1770 — wrangler resolves its * `.wrangler/tmp` scratch dir against a directory it infers, and on a real * install that resolves to the immutable `server/` tree, where the mkdir is * EPERM and the deploy dies before uploading. A caller that has a writable * directory of its own passes it here rather than leaving it to inference. */ opts?: { cwd?: string }, ) => Promise<{ stdout: string }>; // The HTTP seam for the Cloudflare API. Injectable so the API-backed methods are // unit-testable without a network. Minimal subset of the global fetch: callers // read the body once via text() and branch on ok/status. `body` is set for the // POST/PUT paths (d1Create, r2ObjectPut); GET/DELETE paths omit it. // // Task 1691 widened both halves: `body` carries Uint8Array for the object-put // path, and `arrayBuffer` reads the raw object body for r2ObjectGet, which is // the one method whose response is not a JSON envelope. arrayBuffer is required // rather than optional: an optional member would force a fake-only fallback // branch in r2ObjectGet that never runs against a real fetch. export type FetchFn = ( url: string, init: { method: string; headers: Record; body?: string | Uint8Array }, ) => Promise<{ ok: boolean; status: number; text(): Promise; arrayBuffer(): Promise; }>; export interface R2Object { key: string; size: number; etag: string; lastModified: string; } export interface CfExec { d1List(): Promise<{ name: string; uuid: string }[]>; d1Create(name: string): Promise<{ uuid: string }>; d1Query(name: string, sql: string): Promise; /** * One statement with BOUND parameters, over the D1 HTTP API (Task 1910). * * `d1Query` above takes raw SQL and spawns wrangler per statement, so every * caller must interpolate its values. The portal pull writes client-supplied * filenames and paths, which must never be concatenated into SQL — a UUID * gate on one field is not a substitute for binding. * * This is deliberately NOT the convergence of the three D1 clients (Task * 1843); it is one bound path for the callers that need one, added where the * house credential already lives so no second credential path appears. */ d1Exec(name: string, sql: string, params?: unknown[]): Promise; r2List(): Promise<{ name: string }[]>; r2Create(name: string): Promise; r2ObjectList(bucket: string, prefix?: string): Promise; r2ObjectFind(bucket: string, key: string): Promise; r2ObjectGet(bucket: string, key: string): Promise; r2ObjectPut(bucket: string, key: string, body: Uint8Array): Promise; r2ObjectDelete(bucket: string, key: string): Promise; pagesProjectList(): Promise<{ name: string }[]>; pagesProjectCreate(name: string, productionBranch: string): Promise; /** `excluded` names the files dropped from the upload set, so the caller can * log what was published rather than assert it (Task 1782). `uploadDir` and * `uploadDirSource` name the asset root that was published and what decided * it, so a root published one level too high is readable from the log * without fetching the site (Task 1917). */ pagesDeploy( dir: string, project: string, branch?: string, ): Promise<{ url: string; excluded: string[]; uploadDir: string; uploadDirSource: UploadDirSource; }>; } // WRANGLER_CONFIG_NAMES lives in ./pages-output-dir.js since Task 1917. It is // the same list for two jobs that must not drift apart: the discovery order the // output-dir resolver walks, and the drop-list this file applies to the upload. // Task 1782 — the upload walk has a fixed ignore list with no config hook, so a // config sitting in the deployed tree is served publicly at /wrangler.toml, // disclosing the D1 database id and R2 bucket name, both of which embed the // account id. All three names are dropped rather than only the .toml the // data-portal template ships, so a tree that later gains a .json config cannot // quietly start republishing it. // An object key carries `/` as a real path separator (the data portal's // per-person prefixes), so it is encoded segment-wise rather than whole: // separators stay separators, and spaces and specials are escaped. This // encoding is not doc-confirmed; it is chosen so that a wrong guess surfaces as // a 404 carrying its body, never as a silently misfiled object. function encodeObjectKey(key: string): string { return key.split("/").map(encodeURIComponent).join("/"); } // The single-object endpoint, shared by get/put/delete so the three cannot drift // apart on encoding. function objectUrl(accountId: string, bucket: string, key: string): string { return `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${encodeURIComponent(bucket)}/objects/${encodeObjectKey(key)}`; } // The cast is confined to this adapter. A Uint8Array is a valid fetch body at // runtime, but the DOM lib's BodyInit does not admit TypeScript's generic // Uint8Array, so consumers compiled against DOM typings (the // ui server) reject the call the Node typings accept. Casting here keeps the // seam's own type honest for every caller and test. const defaultFetch: FetchFn = (url, init) => fetch(url, init as RequestInit); const defaultRun: RunFn = (cmd, args, env, opts) => new Promise((resolve, reject) => { execFile(cmd, args, { env, cwd: opts?.cwd }, (err, stdout, stderr) => { if (err) { reject(new Error(`${cmd} ${args.join(" ")} failed: ${err.message}\n${stderr}`)); return; } resolve({ stdout: stdout.toString() }); }); }); function parseJson(raw: string, context: string): unknown { try { return JSON.parse(raw); } catch { throw new Error(`storage-broker: ${context} returned non-JSON output: ${raw}`); } } // The Cloudflare-API envelope, in one place. Both API-backed methods (r2List, // d1Create) share it: read the body exactly once, surface a non-2xx with its // body, parse, then require an explicit `success:true`. A 2xx whose body is not // a well-formed success envelope is an anomaly to surface with its body, never // a silently empty/uuid-less result. The raw `body` is returned alongside the // parsed value so callers can raise their own shaping errors on the original // bytes rather than a re-serialised copy. async function cfApiJson( fetchFn: FetchFn, url: string, init: { method: string; headers: Record; body?: string | Uint8Array }, context: string, ): Promise<{ parsed: unknown; body: string }> { const res = await fetchFn(url, init); const body = await res.text(); if (!res.ok) { throw new Error(`storage-broker: ${context} failed: ${res.status}\n${body}`); } // `null` is valid JSON and survives parseJson, so the guard is optional- // chained: a null parsed value must surface its body like any other malformed // 2xx shape, never a bare TypeError with the body dropped. const parsed = parseJson(body, context) as { success?: boolean } | null; if (parsed?.success !== true) { throw new Error(`storage-broker: ${context} API error: ${body}`); } return { parsed, body }; } // The one pagination loop for the object listing, shared by r2ObjectList and // r2ObjectFind so the two cannot drift apart on cursor discipline — the same // reason objectUrl is shared by get/put/delete. // // 1665's rules live here and nowhere else: follow the cursor to exhaustion, // is_truncated is authoritative when present, a truncated page naming no cursor // is an anomaly to surface rather than a partial list, and a repeated cursor is // a cycle rather than progress. per_page is deliberately never sent — its // default is unconfirmed, and omitting it takes whatever page size Cloudflare // serves rather than guessing a value. // // It yields pages rather than returning a list, which is what lets r2ObjectFind // stop early: an async generator is pull-based, so a consumer that stops // iterating never triggers the next request. async function* r2ObjectPages( fetchFn: FetchFn, cred: HouseCredential, bucket: string, prefix?: string, ): AsyncGenerator { const base = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets/${encodeURIComponent(bucket)}/objects`; const seen = new Set(); let cursor: string | undefined; for (;;) { const params = new URLSearchParams(); if (prefix !== undefined) params.set("prefix", prefix); if (cursor !== undefined) params.set("cursor", cursor); const qs = params.toString(); const { parsed, body } = await cfApiJson( fetchFn, qs ? `${base}?${qs}` : base, { method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` } }, "r2 object list", ); const page = parsed as { result?: Array<{ key: string; size: number; etag: string; last_modified: string }>; result_info?: { cursor?: string | null; is_truncated?: boolean }; }; const objects: R2Object[] = []; for (const o of page.result ?? []) { objects.push({ key: o.key, size: o.size, etag: o.etag, lastModified: o.last_modified }); } yield objects; const truncated = page.result_info?.is_truncated; const next = page.result_info?.cursor; // is_truncated is authoritative *when present*: a last page may still carry // a cursor, and following it would issue a pointless request or trip the // cycle guard below on a healthy response. if (truncated === false) return; // The API says there is more but names no cursor: the response is not the // shape we understand. Surface it rather than stop, which would silently // truncate the audit's listing — the failure this loop exists to prevent. if (truncated === true && !next) { throw new Error( `storage-broker: r2 object list reported truncated but gave no pagination cursor: ${body}`, ); } // is_truncated absent: it is itself an unmeasured fact, so it must not be // what decides correctness. Fall back to cursor presence — the rule r2List // uses on the same envelope — so an envelope without the flag paginates // rather than truncating silently. if (!next) return; // A cursor seen before means the API is cycling rather than advancing: it // would loop forever, re-yielding the same objects each pass. Tracking every // cursor (not just the last) also catches a cycle of period >= 2. if (seen.has(next)) { throw new Error( `storage-broker: r2 object list returned a repeated pagination cursor: ${next}`, ); } seen.add(next); cursor = next; } } export function makeCfExec( cred: HouseCredential, run: RunFn = defaultRun, fetchFn: FetchFn = defaultFetch, ): CfExec { const env = { ...process.env, CLOUDFLARE_API_TOKEN: cred.apiToken, CLOUDFLARE_ACCOUNT_ID: cred.accountId, } as Record; // Resolve wrangler via `npx`, never bare: the brand server PATH carries no // global wrangler, only `npx wrangler` resolves (Task 1661). Bare `wrangler` // ENOENTs. The scheduling plugin's buildD1Command used to be the reference for // this form; Task 1805 retired it by moving that path off wrangler entirely. return { async d1List() { const { stdout } = await run("npx", ["wrangler", "d1", "list", "--json"], env); return parseJson(stdout, "d1 list") as { name: string; uuid: string }[]; }, async d1Create(name) { // `wrangler d1 create` has no --json flag (unlike `d1 list`/`d1 execute`), // so shelling it with --json is always rejected. The D1 API creates the // database and returns its record — incl. the uuid — as JSON directly. const url = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/d1/database`; const { parsed, body } = await cfApiJson( fetchFn, url, { method: "POST", headers: { Authorization: `Bearer ${cred.apiToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name }), }, "d1 create", ); // The envelope guard lives in cfApiJson; a uuid-less 2xx success envelope // is still an anomaly to surface with its body, never a silent create. const uuid = (parsed as { result?: { uuid?: string } }).result?.uuid; if (typeof uuid !== "string") { throw new Error(`storage-broker: d1 create API error: ${body}`); } return { uuid }; }, async d1Query(name, sql) { const { stdout } = await run( "npx", ["wrangler", "d1", "execute", name, "--remote", "--json", "--command", sql], env, ); return parseJson(stdout, "d1 execute"); }, async d1Exec(name, sql, params = []) { // The database is addressed by uuid, so the name is resolved first. An // exact name match, never "the first result": `?name=` is a filter whose // matching rule is undocumented, and taking position on trust would run // these statements against a near-named database. const listUrl = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}` + `/d1/database?name=${encodeURIComponent(name)}`; const { parsed: listed } = await cfApiJson( fetchFn, listUrl, { method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` } }, "d1 database lookup", ); const hit = ((listed as { result?: { name?: string; uuid?: string }[] }).result ?? []).find( (d) => d.name === name, ); if (!hit || typeof hit.uuid !== "string") { throw new Error(`storage-broker: no D1 database named "${name}"`); } const { parsed } = await cfApiJson( fetchFn, `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/d1/database/${hit.uuid}/query`, { method: "POST", headers: { Authorization: `Bearer ${cred.apiToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ sql, params }), }, "d1 query", ); return (parsed as { result?: unknown }).result; }, async r2List() { // `wrangler r2 bucket list` has no --json flag and emits human prose, so // it can neither be parsed nor honour the "never grep a CLI's prose" // doctrine. The R2 API returns the bucket set as JSON directly. // // The endpoint paginates: a single request returns only the first page, // and the audit reads the missing remainder as "absent", under-reporting // strays. Follow result_info.cursor to exhaustion instead. per_page is // deliberately never sent — its default and maximum are unconfirmed, and // omitting it takes whatever page size Cloudflare serves, so the loop is // complete without guessing a value that could 400 the request. const base = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets`; const buckets: { name: string }[] = []; const seen = new Set(); let cursor: string | undefined; for (;;) { const url = cursor === undefined ? base : `${base}?cursor=${encodeURIComponent(cursor)}`; const { parsed } = await cfApiJson( fetchFn, url, { method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` } }, "r2 bucket list", ); const page = parsed as { result?: { buckets?: { name: string }[] }; result_info?: { cursor?: string | null }; }; for (const b of page.result?.buckets ?? []) buckets.push({ name: b.name }); // A missing, null, or empty cursor all mean "last page" — accept every // documented last-page signal rather than depend on which one is sent. const next = page.result_info?.cursor; if (!next) return buckets; // Any cursor seen before means the API is cycling rather than advancing, // which would loop forever and re-append the same buckets each pass. // Tracking every cursor (not just the last) also catches a cycle of // period >= 2, which never repeats consecutively. Surface it instead of // hanging the broker on an unbounded wait. if (seen.has(next)) { throw new Error( `storage-broker: r2 bucket list returned a repeated pagination cursor: ${next}`, ); } seen.add(next); cursor = next; } }, async r2Create(name) { await run("npx", ["wrangler", "r2", "bucket", "create", name], env); }, // --- Pages (Task 1728) ------------------------------------------------- // // Publishing is house-run: a client account's secrets carry no master after // the 1631 remediation, and cf-token.sh refuses to mint the pages scope for // a non-house caller. These two run under the house credential only. async pagesProjectList() { // `wrangler pages project list` has no --json flag and emits human prose, // so it can neither be parsed nor honour the "never grep a CLI's prose" // doctrine — the same reason r2List uses the API. Paginate to exhaustion: // a single request returns one page, and the audit would read the missing // remainder as "absent", reporting live projects as orphans. per_page is // deliberately never sent (default and maximum unconfirmed). const base = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/pages/projects`; const projects: { name: string }[] = []; const seen = new Set(); let cursor: string | undefined; for (;;) { const url = cursor === undefined ? base : `${base}?cursor=${encodeURIComponent(cursor)}`; const { parsed } = await cfApiJson( fetchFn, url, { method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` } }, "pages project list", ); const page = parsed as { result?: { name: string }[]; result_info?: { cursor?: string | null }; }; for (const p of page.result ?? []) projects.push({ name: p.name }); const next = page.result_info?.cursor; if (!next) return projects; // A repeated cursor means the API is cycling rather than advancing; // surface it instead of looping forever (mirrors r2List). if (seen.has(next)) { throw new Error( `storage-broker: pages project list returned a repeated pagination cursor: ${next}`, ); } seen.add(next); cursor = next; } }, async pagesProjectCreate(name, productionBranch) { // `wrangler pages project create` emits human prose with no --json, the // same reason d1Create and pagesProjectList reach for the API. The Pages // API creates the project and returns its record as JSON directly. const url = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/pages/projects`; const { parsed, body } = await cfApiJson( fetchFn, url, { method: "POST", headers: { Authorization: `Bearer ${cred.apiToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name, production_branch: productionBranch }), }, "pages project create", ); // The envelope guard lives in cfApiJson; a name-less 2xx success envelope // is still an anomaly to surface with its body, never a silent create — // the deploy that follows would otherwise fail on a project the broker // believes it just made. const created = (parsed as { result?: { name?: string } }).result?.name; if (typeof created !== "string") { throw new Error(`storage-broker: pages project create API error: ${body}`); } }, async pagesDeploy(dir, project, branch) { // Task 1782 — publish a directory the broker composes, not whatever the // account's tree happens to hold. wrangler takes the upload directory from // the positional argument and finds its configuration by walking up from // the working directory, which are two independent inputs; pointing them // at different places is what removes the config from the published assets // while leaving the D1 and R2 bindings it declares fully in force. // Measured on the house account 2026-07-20: a brand-new project deployed // with the config outside the upload received the same bindings and the // same config hash as the control that uploaded it. // Task 1917 — the positional argument IS wrangler's asset root and // overrides `pages_build_output_dir`, so the directory to stage is the // declared output dir when the tree declares one. Resolution happens // before any staging: a declared dir that cannot be resolved aborts the // deploy rather than falling back to the site root and publishing one // level too high, which is what left gls-sign-1848 answering only under // /public/ while its root 404'd. const { uploadDir, source: uploadDirSource } = await resolveUploadDir(dir); const staged = await mkdtemp(join(tmpdir(), "maxy-pages-upload-")); const excluded: string[] = []; try { await cp(uploadDir, staged, { recursive: true, // Only a config at the root of what is uploaded is dropped. That root // is the served root, which is the surface Task 1782 protects. A file // of the same name anywhere below it is site content and stays // published. filter: (src) => { const rel = relative(uploadDir, src); if (!(WRANGLER_CONFIG_NAMES as readonly string[]).includes(rel)) return true; excluded.push(rel); return false; }, }); // wrangler is the only supported Pages upload path (it hashes the tree and // uploads only what is missing); the API has no single-call equivalent. const args = ["wrangler", "pages", "deploy", staged, "--project-name", project]; if (branch !== undefined) args.push("--branch", branch); // Pin the working directory to the tree being published. Left to // inference, wrangler anchors `.wrangler/tmp` at the install's `server/` // directory (its nearest package.json/node_modules root), which the // platform marks immutable — so the scratch mkdir returns EPERM and the // deploy never reaches the upload. `dir` is the realpath-confined deploy // directory the route resolved, and it is writable. Since 1782 it is also // the only place wrangler can still find this site's config, which no // longer travels with the assets. const { stdout } = await run("npx", args, env, { cwd: dir }); // The deployment URL is what the caller relays, so parse it strictly. // Strip ANSI first: wrangler colours its output when it thinks it has a // terminal, and a colour reset adjacent to the URL would otherwise be // swallowed by the host character class and returned as part of it. const plain = stdout.replace(/\[[0-9;]*m/g, ""); // Take the LAST match, not the first: wrangler prints the deployment URL // on its closing line, and any earlier .pages.dev mention (an alias or a // progress line) would otherwise win and hand back the wrong address. const urls = plain.match(/https:\/\/[A-Za-z0-9._-]+\.pages\.dev/g); const url = urls?.[urls.length - 1]; // wrangler exits 0 on some partial paths, so a url-less success is an // anomaly to surface with its output, never a silent "deployed". if (!url) { throw new Error(`storage-broker: pages deploy returned no deployment url: ${stdout}`); } return { url, excluded: excluded.sort(), uploadDir, uploadDirSource }; } finally { // A failed deploy must not strand a second copy of the site on disk. await rm(staged, { recursive: true, force: true }); } }, // --- R2 objects (Task 1691) ------------------------------------------- // // The v4 REST API exposes the object surface under // /accounts/{id}/r2/buckets/{bucket}/objects and accepts the same Bearer // token as the bucket endpoints (account-level Workers R2 Storage Write). // The r2/api/tokens/ note that "Object Read & Write" is S3-only constrains // the bucket-item-scoped *permission types*, not these endpoints. // // The endpoint's existence and token class are doc-confirmed. Its behaviour // was unmeasured until Task 1697, which probed the live v4 API on 2026-07-16 // from the laptop's own maxy-code install (no house Cloudflare credential // exists on the dev Mac, which is why the tests fake the fetch seam). What // that probe established, so nobody re-guesses it: // // - The listing is NOT in ascending UTF-8 binary order. `prefix=a/photo.jpg` // returns `a/photo.jpg.bak` before `a/photo.jpg`, so the exact key is not // the first element and `per_page=1` returns a neighbour. S3's // ListObjectsV2 ordering guarantee does not transfer to this endpoint. // - HEAD is routed only for single-segment keys: any key containing "/" // returns 405 while GET on the identical URL returns 404. // - A ranged GET is honoured at 2 MB and ignored at 12 B, so it cannot size // an object without an undocumented size threshold deciding correctness. // - per_page is bounded 0..1000 (error 10029), and per_page=0 returns zero // objects with is_truncated:true — a loop trap. It is still never sent. // // The design keeps every unmeasured fact unable to affect correctness: // per_page is never sent, order is never assumed, and nothing fails // silently. Evidence: // docs/superpowers/specs/2026-07-16-storage-broker-get-sizing-early-exit-design.md async r2ObjectList(bucket, prefix) { // The standing audit needs the complete set: a truncated object listing // reads as "those objects are absent", producing false phantom rows and // hiding real orphans — the same bug r2List had, one endpoint over. So // this drains every page. r2ObjectFind is the caller that needs one // record and must not pay for this. const objects: R2Object[] = []; for await (const page of r2ObjectPages(fetchFn, cred, bucket, prefix)) { for (const o of page) objects.push(o); } return objects; }, async r2ObjectFind(bucket, key) { // Task 1697 — the size source for a get. Scans each page for the exact key // and returns on the first match, so it holds one page rather than every // object sharing the key as a prefix, and issues no request past the page // carrying the match. // // Exact match, not prefix: a prefix of `a/photo.jpg` also matches // `a/photo.jpg.bak`, and sizing the neighbour would either reject a legal // get or admit an oversized one. Exiting on the match rather than on // position is what makes this independent of listing order — measured // 2026-07-16 against the live v4 API, the listing is NOT in ascending // UTF-8 binary order, so the exact key is not the first element and // `per_page=1` returns a neighbour. // // An absent key still drains every page, because proving absence requires // seeing them all. That residual is accepted and permanent; nothing on // this endpoint sizes one object in bounded work. See // docs/superpowers/specs/2026-07-16-storage-broker-get-sizing-early-exit-design.md for await (const page of r2ObjectPages(fetchFn, cred, bucket, key)) { const hit = page.find((o) => o.key === key); if (hit) return hit; } return null; }, async r2ObjectGet(bucket, key) { // The one method cfApiJson cannot wrap: get returns the raw object body, // not a success envelope, so there is nothing to parse or assert // success:true on. The error body is still retained on a non-2xx. const res = await fetchFn(objectUrl(cred.accountId, bucket, key), { method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` }, }); if (!res.ok) { throw new Error( `storage-broker: r2 object get failed: ${res.status}\n${await res.text()}`, ); } return new Uint8Array(await res.arrayBuffer()); }, async r2ObjectPut(bucket, key, body) { await cfApiJson( fetchFn, objectUrl(cred.accountId, bucket, key), { method: "PUT", headers: { Authorization: `Bearer ${cred.apiToken}` }, body }, "r2 object put", ); }, async r2ObjectDelete(bucket, key) { await cfApiJson( fetchFn, objectUrl(cred.accountId, bucket, key), { method: "DELETE", headers: { Authorization: `Bearer ${cred.apiToken}` } }, "r2 object delete", ); }, }; } // Task 1670 — build a CfExec whose token is a D1+R2 "storage" scope minted from // the house minter, never the minter used directly (a minter cannot run // wrangler d1/r2 or hit the D1/R2 API — it returns 10000). cf-token.sh // mints-or-reuses and persists the scope house-side; the resolved value is used // for both the wrangler run env and the API Bearer. The two UI-route broker // sites and remediate-run construct their CfExec through the house minter this // way instead of `makeCfExec(readHouseCredential(...))`. export async function makeHouseCfExec( platformRoot: string, run: RunFn = defaultRun, fetchFn: FetchFn = defaultFetch, ): Promise { const cred = readHouseCredential(platformRoot); const houseEnvPath = join(platformRoot, "config", "cloudflare-house.env"); const cfTokenSh = join(platformRoot, "plugins/cloudflare/bin/cf-token.sh"); const scoped = await resolveHouseScopedToken("storage", houseEnvPath, cfTokenSh, run); return makeCfExec({ apiToken: scoped, accountId: cred.accountId }, run, fetchFn); } /** * The Pages twin of makeHouseCfExec (Task 1728). Same house minter, different * scope: "pages" resolves CF_PAGES_D1_TOKEN rather than CF_STORAGE_TOKEN. * * The minter is handed the HOUSE env, which carries a master, so cf-token.sh's * house-fallback branch never runs and caller_is_house() is never consulted. * That is exactly why this works from the ui-server process regardless of its * ACCOUNT_ID, while a client session shelling cf-token.sh directly is denied * reason=not-house. Separate from makeHouseCfExec so hosting never widens the * storage token's scope, and storage never gains Pages Write. */ export async function makeHousePagesExec( platformRoot: string, run: RunFn = defaultRun, fetchFn: FetchFn = defaultFetch, ): Promise { const cred = readHouseCredential(platformRoot); const houseEnvPath = join(platformRoot, "config", "cloudflare-house.env"); const cfTokenSh = join(platformRoot, "plugins/cloudflare/bin/cf-token.sh"); const scoped = await resolveHouseScopedToken("pages", houseEnvPath, cfTokenSh, run); return makeCfExec({ apiToken: scoped, accountId: cred.accountId }, run, fetchFn); }