@rubytech/create-maxy-code 0.1.490 → 0.1.492

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +46 -0
  3. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  4. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.d.ts +2 -0
  5. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.d.ts.map +1 -0
  6. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.js +135 -0
  7. package/payload/platform/lib/storage-broker/dist/__tests__/pages-output-dir.test.js.map +1 -0
  8. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +7 -1
  9. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  10. package/payload/platform/lib/storage-broker/dist/cf-exec.js +26 -14
  11. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/pages-output-dir.d.ts +19 -0
  13. package/payload/platform/lib/storage-broker/dist/pages-output-dir.d.ts.map +1 -0
  14. package/payload/platform/lib/storage-broker/dist/pages-output-dir.js +185 -0
  15. package/payload/platform/lib/storage-broker/dist/pages-output-dir.js.map +1 -0
  16. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +65 -0
  17. package/payload/platform/lib/storage-broker/src/__tests__/pages-output-dir.test.ts +167 -0
  18. package/payload/platform/lib/storage-broker/src/cf-exec.ts +40 -16
  19. package/payload/platform/lib/storage-broker/src/pages-output-dir.ts +194 -0
  20. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +30 -8
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +8 -4
  22. package/payload/platform/plugins/cloudflare/references/hosting-sites.md +1 -1
  23. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +4 -4
  24. package/payload/server/{chunk-KZFE3MOY.js → chunk-RPUTKSAJ.js} +134 -16
  25. package/payload/server/server.js +5 -5
  26. package/payload/server/{src-UYDFMWF4.js → src-JN6PIAJN.js} +1 -1
@@ -0,0 +1,194 @@
1
+ // Where a Pages deploy's assets actually start. Task 1917 — `wrangler pages
2
+ // deploy <dir>` treats the positional argument as the asset root and overrides
3
+ // `pages_build_output_dir`, so handing it the site root publishes a declared
4
+ // output dir one level too deep and the served root 404s. Measured on glsmith
5
+ // 2026-07-22: two trees with identical shape and config, one deployed by house
6
+ // wrangler (document at the root, 200) and one by this broker (document
7
+ // reachable only under /public/, root 404).
8
+ //
9
+ // Only the one key is interpreted, in any of the three config formats. This is
10
+ // deliberately not a wrangler config parser: everything else in the file is
11
+ // wrangler's business and stays wrangler's business.
12
+
13
+ import { lstat, readFile, realpath } from "node:fs/promises";
14
+ import { isAbsolute, join, relative, resolve } from "node:path";
15
+
16
+ // The filenames `wrangler pages deploy` discovers as its own configuration, in
17
+ // wrangler's own precedence order (wrangler 4.112.0, wrangler-dist/cli.js:3189
18
+ // — json, then jsonc, then toml). The order is load-bearing twice: it decides
19
+ // which config declares the output dir here, and Task 1782 drops all three from
20
+ // the upload so a config is never served publicly at /wrangler.toml, where it
21
+ // would disclose the D1 database id and R2 bucket name.
22
+ export const WRANGLER_CONFIG_NAMES = [
23
+ "wrangler.json",
24
+ "wrangler.jsonc",
25
+ "wrangler.toml",
26
+ ] as const;
27
+
28
+ export type UploadDirSource = (typeof WRANGLER_CONFIG_NAMES)[number] | "site-root";
29
+
30
+ export type UploadDirResolution = {
31
+ /** Absolute path to stage and hand wrangler as the asset root. */
32
+ uploadDir: string;
33
+ /** Which input decided it, so the caller logs the reason and not the value alone. */
34
+ source: UploadDirSource;
35
+ /** The verbatim declared value, present only when a config decided it. */
36
+ declared?: string;
37
+ };
38
+
39
+ const KEY = "pages_build_output_dir";
40
+
41
+ /** Strips line comments and block comments that sit outside string literals.
42
+ * JSON has no comments and jsonc does, so a jsonc file is de-commented before
43
+ * JSON.parse. Scanning character-wise rather than by regex is what keeps a
44
+ * `//` inside a value (a path like "a//b") from being eaten as a comment. */
45
+ function stripJsonComments(text: string): string {
46
+ let out = "";
47
+ let inString = false;
48
+ let escaped = false;
49
+ for (let i = 0; i < text.length; i++) {
50
+ const c = text[i];
51
+ if (inString) {
52
+ out += c;
53
+ if (escaped) escaped = false;
54
+ else if (c === "\\") escaped = true;
55
+ else if (c === '"') inString = false;
56
+ continue;
57
+ }
58
+ if (c === '"') {
59
+ inString = true;
60
+ out += c;
61
+ continue;
62
+ }
63
+ if (c === "/" && text[i + 1] === "/") {
64
+ while (i < text.length && text[i] !== "\n") i++;
65
+ out += "\n";
66
+ continue;
67
+ }
68
+ if (c === "/" && text[i + 1] === "*") {
69
+ i += 2;
70
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
71
+ i++;
72
+ continue;
73
+ }
74
+ out += c;
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** Reads the one key from a TOML file's top level. Top level means before the
80
+ * first `[section]` header: a key under `[env.preview]` is that environment's,
81
+ * not the deploy's, and reading it would publish the wrong root. Nothing else
82
+ * in the file is interpreted. */
83
+ function readTomlKey(text: string): string | undefined {
84
+ for (const raw of text.split("\n")) {
85
+ const line = raw.trim();
86
+ if (line === "" || line.startsWith("#")) continue;
87
+ if (line.startsWith("[")) return undefined;
88
+ const m = /^pages_build_output_dir\s*=\s*(?:"([^"]*)"|'([^']*)')\s*(?:#.*)?$/.exec(line);
89
+ if (m) return m[1] ?? m[2];
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ /** Reads the one key from a JSON or JSONC file's top level. A present key whose
95
+ * value is not a string is an error rather than an ignore: the tree declared an
96
+ * output dir, and publishing its parent instead is the defect this closes. */
97
+ function readJsonKey(text: string, name: string, jsonc: boolean): string | undefined {
98
+ let parsed: unknown;
99
+ try {
100
+ parsed = JSON.parse(jsonc ? stripJsonComments(text) : text);
101
+ } catch (err) {
102
+ throw new Error(`storage-broker: ${name} could not be parsed: ${String(err)}`);
103
+ }
104
+ if (typeof parsed !== "object" || parsed === null) {
105
+ throw new Error(`storage-broker: ${name} is not an object`);
106
+ }
107
+ const value = (parsed as Record<string, unknown>)[KEY];
108
+ if (value === undefined) return undefined;
109
+ if (typeof value !== "string") {
110
+ throw new Error(`storage-broker: ${name} declares a non-string ${KEY}`);
111
+ }
112
+ return value;
113
+ }
114
+
115
+ /** True when `child` is not `root` or a descendant of it. */
116
+ function escapes(root: string, child: string): boolean {
117
+ const rel = relative(root, child);
118
+ return rel.startsWith("..") || isAbsolute(rel);
119
+ }
120
+
121
+ function outsideError(name: string, declared: string, resolved: string, root: string): Error {
122
+ return new Error(
123
+ `storage-broker: ${name} declares ${KEY}="${declared}", which resolves to ${resolved}, outside the site root ${root}`,
124
+ );
125
+ }
126
+
127
+ /** Resolves the directory whose contents become the served root for `dir`.
128
+ *
129
+ * A tree that declares nothing keeps the site root, which is what every deploy
130
+ * did before Task 1917. A tree that declares an output dir gets that dir. A
131
+ * tree that declares one which cannot be resolved is an error naming the path:
132
+ * falling back to the site root here is exactly the defect this closes, because
133
+ * the deploy then succeeds while serving the wrong root. */
134
+ export async function resolveUploadDir(dir: string): Promise<UploadDirResolution> {
135
+ for (const name of WRANGLER_CONFIG_NAMES) {
136
+ let text: string;
137
+ try {
138
+ text = await readFile(join(dir, name), "utf8");
139
+ } catch {
140
+ continue; // absent, or not readable as a file: the next name is wrangler's next choice
141
+ }
142
+ const declared =
143
+ name === "wrangler.toml"
144
+ ? readTomlKey(text)
145
+ : readJsonKey(text, name, name === "wrangler.jsonc");
146
+ // A config that declares nothing leaves the site root as the asset root,
147
+ // which is wrangler's own behaviour for a Pages project with no output dir.
148
+ if (declared === undefined) return { uploadDir: dir, source: "site-root" };
149
+ const uploadDir = resolve(dir, declared);
150
+ // Confinement, lexically first. A declared value is file content, and
151
+ // resolving file content into a publish path through the route that holds
152
+ // the account-wide Cloudflare credential is the moment it has to be
153
+ // bounded. This pass costs no filesystem call and rejects the plain
154
+ // `"../.."` before anything is touched.
155
+ if (escapes(dir, uploadDir)) throw outsideError(name, declared, uploadDir, dir);
156
+ // lstat, not stat: the link itself is what matters here, and stat would
157
+ // report a link to a directory as a directory.
158
+ let info;
159
+ try {
160
+ info = await lstat(uploadDir);
161
+ } catch {
162
+ throw new Error(
163
+ `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} does not exist`,
164
+ );
165
+ }
166
+ // A link is refused wherever it points. The staging copy takes the upload
167
+ // directory as its source and does not dereference, so a link source fails
168
+ // the copy outright; and a target that can change between this check and
169
+ // that copy is not something to publish from a route holding the
170
+ // account-wide credential.
171
+ if (info.isSymbolicLink()) {
172
+ throw new Error(
173
+ `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} is a symbolic link; the published root must be a real directory`,
174
+ );
175
+ }
176
+ if (!info.isDirectory()) {
177
+ throw new Error(
178
+ `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} is not a directory`,
179
+ );
180
+ }
181
+ // Confinement again, on the real paths. The lexical pass above is blind to
182
+ // symlinks in the declared value's own parent segments: a declared "a/b"
183
+ // whose `a` links out of the tree stays inside by string comparison while
184
+ // resolving anywhere on disk, and the deploy would then publish whatever it
185
+ // resolves to. Both sides are resolved because the site root itself commonly
186
+ // arrives through a symlinked prefix (macOS /var, a linked account
187
+ // directory), which would otherwise read as an escape on its own.
188
+ const realDir = await realpath(dir);
189
+ const realUpload = await realpath(uploadDir);
190
+ if (escapes(realDir, realUpload)) throw outsideError(name, declared, realUpload, realDir);
191
+ return { uploadDir, source: name, declared };
192
+ }
193
+ return { uploadDir: dir, source: "site-root" };
194
+ }
@@ -22,6 +22,10 @@ Auth for every `wrangler`/API call is the **reused per-scope narrow token** of `
22
22
 
23
23
  ---
24
24
 
25
+ > **Client accounts on a multi-tenant install — every Cloudflare call in this skill runs through the storage broker.** A client sub-account is issued no Cloudflare credential: after the 1631 storage isolation its `secrets/cloudflare.env` carries no master, and `cf-token.sh` denies it the `pages` scope with `reason=not-house`. That denial is the boundary working, not a broken token, and it is never worked around by minting, by sourcing the house env, or by asking anyone for a token. Read every `cf-token.sh` and `wrangler d1 execute` line below as the **house / single-tenant** path; each one names its client substitute beside it. The map is one-to-one — `storage-d1-create` for a new database, `storage-d1-query` for every SQL statement, `storage-pages-create` to claim a brand-new Pages project, `storage-pages-deploy` to publish it. `storage-d1-query` runs `wrangler d1 execute <name> --remote --json` house-side, so every SQL statement in this skill transfers verbatim and the result is always JSON. The one exception is § 1a Access gating, which needs an Access-scoped token the broker does not offer: a client account that needs a gated document asks the house to apply the gate.
26
+ >
27
+ > **Never ask a client, a signer, or any client contact to create or send a Cloudflare token.** Provisioning a master is an operator action on the house account. A credential request sent to the person you are building the document for is a failure of this skill, not a remediation for one.
28
+
25
29
  ## 0. Pre-flight — block on each prerequisite
26
30
 
27
31
  Do **not** deploy until all three pass. Each failure has one exact remediation; stop and give it.
@@ -31,7 +35,13 @@ Do **not** deploy until all three pass. Each failure has one exact remediation;
31
35
 
32
36
  Dispatch (§ 6) sends through `email-send`; without a configured inbox the acceptance is captured but never delivered.
33
37
 
34
- 2. **The Cloudflare master token must exist.** With `SECRETS_DIR` per `api.md` (`~/<brand>-code/data/accounts/<accountId>/secrets`):
38
+ 2. **The Cloudflare master token must exist — house and single-tenant accounts only.** Which case you are in is a property of the account, so read it rather than assume it:
39
+
40
+ ```bash
41
+ jq -r '.role // "unset"' "${ACCOUNT_DIR}/account.json"
42
+ ```
43
+
44
+ `client` means the broker path (the callout above): the master is absent by design, this item does not apply, and you go straight to item 3. Anything else is the house or single-tenant path, which runs the check below. If that command instead errors `Could not open file /account.json`, `ACCOUNT_DIR` is unset — that is item 3's failure surfacing early, so settle item 3 first and come back rather than guessing which path you are on. With `SECRETS_DIR` per `api.md` (`~/<brand>-code/data/accounts/<accountId>/secrets`):
35
45
 
36
46
  ```bash
37
47
  ( test -s "${SECRETS_DIR}/cloudflare.env" && grep -q '^CLOUDFLARE_API_TOKEN=' "${SECRETS_DIR}/cloudflare.env" ) \
@@ -41,6 +51,8 @@ Do **not** deploy until all three pass. Each failure has one exact remediation;
41
51
  On `missing`, stop:
42
52
  > "There's no Cloudflare master token. Provision it once per `cloudflare/references/api.md` § Provisioning the master token, then ask me again."
43
53
 
54
+ That provisioning is an operator action on the house account. Never relay it onward, and never ask anyone outside the operator seat to create or send a Cloudflare token — not a client, not a signer, not a client's own hosting contact.
55
+
44
56
  3. **The account directory must be set (attachment capability).** `email-send` attaches the signed PDF by absolute path under the account directory, and § 5 persists the base PDF there. If `ACCOUNT_DIR` is unset, the attachment silently degrades to a **text-only** send — neither party receives the PDF — and there is nowhere to persist the base render. Assert it before any deploy or sweep:
45
57
 
46
58
  ```bash
@@ -80,6 +92,8 @@ Per-signer stamped copies (§ 6) are written alongside, in the same `<DOC_REF>`
80
92
 
81
93
  The § 1 default — an unguessable hex slug plus `noindex` — keeps a low-sensitivity document private by obscurity. For a **confidential** proposal to a **named** recipient, add a **Cloudflare Access** gate in front of the page so only allowlisted emails can load it at all. Gating is **additive**: the slug and `noindex` stay; the gate sits in front of them. It is never the default and never replaces them.
82
94
 
95
+ > **Client account:** Access gating is the house's to apply. The broker offers no Access tool, and `cf-token.sh access` denies a client account that scope with the same `reason=not-house` it denies `pages`, so mint nothing here and run none of the calls below. Instead **request the gate**, handing the house three things: the **Pages project** carrying the document, the **document path or paths** to gate, and the **signer emails** (plus the business owner's) for the policy `include`. Say in the same request that **`/api/*` on that project is gated alongside those paths**, because gating the document path alone leaves `POST /api/accept` forgeable and `GET /api/status` world-readable, the bypass this section describes below. Never ask the client or the signer for a Cloudflare token (the callout that precedes § 0). Everything that follows (the One-Time PIN pre-flight, the app and policy calls, the `gate-probe`, and the neutral root portal) stays as written and is the house's to run.
96
+
83
97
  **One-Time PIN is the only correct identity provider here — and why.** The signer is an external party who belongs to **no** Cloudflare organization, so SSO, Google, GitHub, SAML and every other org-membership IdP cannot authenticate them. Cloudflare's built-in **One-Time PIN** emails a login code to **any allowlisted address** with no org membership required — exactly the external-signer case. Prescribe `onetimepin`; do not reach for another IdP type for an external signer (a higher-assurance provider is a separate capability, not this skill).
84
98
 
85
99
  **Load-bearing pre-flight — a gate requires an enabled login method.** Creating an Access application and policy does **not** enable a login method. If the org has **zero** identity providers when the gate goes live, the login page offers **nothing** and locks out *everyone, including the operator*. Before activating any Access app, list the org's IdPs and confirm the list is non-empty:
@@ -298,6 +312,8 @@ document.getElementById("esign").addEventListener("submit", async (e) => {
298
312
 
299
313
  Follow `d1-data-capture.md` for creating the database, the `wrangler.toml` binding, and the both-Pages-Edit-**and**-D1-Edit token. The schema here is generic — it imposes no business fields; operator-defined fields live in `captured_json`:
300
314
 
315
+ > **Client account:** create the database with `storage-d1-create` if `storage-d1-list` does not already show it, then run the exact statement below through `storage-d1-query` (`name` is the database, `sql` is the statement verbatim). The schema, the column order, and both uniqueness guards are unchanged — including the "keep `doc_ref` defined before `token`" rule below, which is a property of the SQL, not of how it is executed.
316
+
301
317
  ```bash
302
318
  CLOUDFLARE_API_TOKEN="${MINTED_PAGES_D1}" wrangler d1 execute <db-name> --remote --command \
303
319
  "CREATE TABLE IF NOT EXISTS acceptances (
@@ -426,6 +442,8 @@ export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
426
442
 
427
443
  Deploy the document + both Functions via `hosting-sites.md`, with the both-Pages-Edit-and-D1-Edit token. The `[[d1_databases]]` binding in `wrangler.toml` is what wires the deployed Functions to the `acceptances` database.
428
444
 
445
+ > **Client account:** claim a brand-new Pages project with `storage-pages-create`, then publish it with `storage-pages-deploy` from a folder under the account's own `sites/`. Create first — a `storage-pages-deploy` against a project with no owner on record fails at `reason=unregistered`. A project that already exists on Cloudflare and was not created by this account is the house-adopt case (`storage-pages-adopt`, house admin only), so ask the house rather than re-creating it under a second name. The deploy itself runs house-side, so the Pages credential never enters the account. Keep `wrangler.toml` with its `[[d1_databases]]` binding at the root of that site folder exactly as the house does: the broker copies the tree to a staging dir with any root `wrangler.toml` filtered out and points wrangler at the copy, so the binding stays fully in force while the config is never published (a published `wrangler.toml` discloses the D1 database id and the R2 bucket name, both of which embed the account id). The site name is a single path segment; a name containing `/` is refused. Everything after the deploy — the base render, reading every rendered page, the handed-back `https://` URL — is unchanged.
446
+
429
447
  **Gated sites also ship the neutral root `index.html` (§ 1a).** When the document is Access-gated, the neutral portal goes in the **same Pages output dir** as the document(s), so the production deploy serves it at `/` in the same shot — no separate deploy. Redeploying *only* the root portal needs a **Pages-Write-only** token: the portal touches no database, so no D1 scope is minted for it.
430
448
 
431
449
  **Render the base PDF once, here, keyed by `DOC_REF`.** Immediately after the deploy, render the live document to PDF a single time and persist it under the account directory (§ 1). This is the *only* place the browser is used — `browser-navigate` to the deployed document URL, then `browser-pdf-save`, the `invoicing.md` step-4 pattern:
@@ -447,7 +465,9 @@ Before persisting, **read every rendered page** of `base.pdf` — full-bleed pag
447
465
 
448
466
  Dispatch runs **only** inside a live operator session on the account's own device — when the operator asks for new acceptances, or when an in-session watcher running inside a still-live session performs the sweep. There is no off-device dispatcher; never register a Claude Code `/schedule` routine or any headless/cloud scheduler to run it (§ 8.2 says why). Mint one **D1-Edit** token here — `${MINTED_D1_EDIT}` — per `api.md` § Minting a narrow token, resolving the permission-group id as `d1-data-capture.md` § 0 does. Reads use the Edit token too: the D1 **query** endpoint rejects a D1-Read token (`d1-data-capture.md`), so a separate read-scoped token is not minted.
449
467
 
450
- 1. **Read the unswept rows** with `${MINTED_D1_EDIT}`:
468
+ > **Client account:** mint nothing. Both statements in this section run through `storage-d1-query` against the same database, executed house-side, and the read returns JSON either way. Everything else about dispatch is identical: it still runs only inside a live operator session, it still stamps the persisted base, and it still flips `swept` one row at a time after both sends verify.
469
+
470
+ 1. **Read the unswept rows** with `${MINTED_D1_EDIT}` (client account: the same `SELECT` through `storage-d1-query`):
451
471
 
452
472
  ```bash
453
473
  CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --json --command \
@@ -495,7 +515,7 @@ Dispatch runs **only** inside a live operator session on the account's own devic
495
515
 
496
516
  **Owner-copy visibility.** The owner copy only *arrives* if the agent's mailbox actually receives mail addressed to the business address. A recipient-filter that drops mail to the business address (the bug fixed in `rubytech-shared-catchall-mailbox`) makes the owner copy vanish while the signer copy arrives — check that filter first if the owner reports a missing confirmation.
497
517
 
498
- - **Mark this row — and only this row — swept, once both sends return a verified result**, keyed by its `id`:
518
+ - **Mark this row — and only this row — swept, once both sends return a verified result**, keyed by its `id` (client account: the same `UPDATE` through `storage-d1-query`):
499
519
 
500
520
  ```bash
501
521
  CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
@@ -512,6 +532,8 @@ The acceptance flow is proven end-to-end on the **live** Pages deployment — th
512
532
 
513
533
  **When the document is Access-gated (§ 1a), `/api/*` is gated too** — so the `curl` POSTs to `/api/accept` and the `GET /api/status` below are themselves **Access-challenged (302 to login), not direct 200s**. Run this end-to-end verification either **before** the gate is applied, or from a context that can authenticate to Access — the same authenticated-session requirement § 5's base render already carries. The `wrangler d1 execute` rows reach D1 through the Cloudflare API, not the gated host, so they verify unchanged.
514
534
 
535
+ > **Client account:** the `curl` steps are unchanged — they hit the public Pages host and need no credential. Every `wrangler d1 execute` step below runs through `storage-d1-query` with the same SQL, and the row counts it must return are the same. The contract is the D1 row either way, never the POST status.
536
+
515
537
  ```bash
516
538
  # 1. POST a first acceptance to the live Function.
517
539
  curl -sS -X POST -H "Content-Type: application/json" \
@@ -547,7 +569,7 @@ Surface every step as verb + target ("inserting a test acceptance", "sweeping un
547
569
 
548
570
  ## 8. Failure modes and the signal for each
549
571
 
550
- The flow has no application logging — the signal surface is the **D1 row state** plus the persisted-artifact filesystem, reconciled by standing checks. Every `wrangler d1 execute` below uses the **D1-Edit** token. The failure modes that emit no event are the ones this section exists to cover.
572
+ The flow has no application logging — the signal surface is the **D1 row state** plus the persisted-artifact filesystem, reconciled by standing checks. Every `wrangler d1 execute` below uses the **D1-Edit** token; on a client account every one of them runs through `storage-d1-query` with the same SQL instead, and § 8.9 is the house's to run because Access has no broker tool. The failure modes that emit no event are the ones this section exists to cover.
551
573
 
552
574
  ### 8.1 POST silently 500s at the insert (token missing D1 Edit)
553
575
 
@@ -559,7 +581,7 @@ The sweep never ran, or `email-send` failed after the row existed — no log, be
559
581
 
560
582
  > Dispatch runs **only** inside a live operator session on the account's own device. Never register a Claude Code `/schedule` routine, a `RemoteTrigger`, a `CronCreate` durable job, or any off-device/headless scheduler to run the sweep. `/schedule` spawns a fresh agent on **claude.ai's cloud**, which has none of this account's D1 token, `email-send`, `stamp.mjs`, or `ACCOUNT_DIR` — it physically cannot stamp or send. It is a Claude Code capability, not a Maxy one, and it is not a substitute for the in-session sweep. Monitoring and dispatch happen in-session, while the session is live. That is the only path.
561
583
 
562
- The reconciliation query, run inside a live session at open and before close:
584
+ The reconciliation query, run inside a live session at open and before close (client account: through `storage-d1-query`):
563
585
 
564
586
  ```bash
565
587
  CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
@@ -585,7 +607,7 @@ d="${ACCOUNT_DIR}/e-sign/<DOC_REF>"
585
607
 
586
608
  ### 8.4 Duplicate-acceptance row by token (same-signer idempotency regressed)
587
609
 
588
- Signal: a `token` appears more than once — the `token`-UNIQUE handling regressed.
610
+ Signal: a `token` appears more than once — the `token`-UNIQUE handling regressed (client account: through `storage-d1-query`).
589
611
 
590
612
  ```bash
591
613
  CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
@@ -596,7 +618,7 @@ Healthy signature: re-POSTing a token leaves its count at one.
596
618
 
597
619
  ### 8.5 More than one acceptance per `doc_ref` (the lock failed — primary standing check)
598
620
 
599
- The lock's failure is a **no-event** failure: an extra row appears silently — the `doc_ref` guard was dropped on a redeploy, never applied to a pre-existing table, or a race slipped through. This is the primary lock check; run it on the same cadence as the § 8.2 reconciliation, reusing the same D1-Edit token:
621
+ The lock's failure is a **no-event** failure: an extra row appears silently — the `doc_ref` guard was dropped on a redeploy, never applied to a pre-existing table, or a race slipped through. This is the primary lock check; run it on the same cadence as the § 8.2 reconciliation, reusing the same D1-Edit token (client account: through `storage-d1-query`):
600
622
 
601
623
  ```bash
602
624
  CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --command \
@@ -635,7 +657,7 @@ CLOUDFLARE_API_TOKEN="${MINTED_ACCESS}" curl -sS \
635
657
 
636
658
  Run on the same cadence as the § 8.2 reconciliation, but with an **Access-scoped** token — not the `${MINTED_D1_EDIT}` the other §8 checks share (this is a `curl` to the Access API, not a `wrangler d1` query). Two adjacent failures are **not** this one and need a different fix: a signer **refused after** the login page offered One-Time PIN is an allowlist miss (their email is absent from the policy `include`, § 1a); a signer who never receives the code despite the method being offered is an email-deliverability problem, not a gate defect. Gating is configuration state, so dispatch is unaffected — the sweep still stamps the persisted base (§ 1a, § 6), never the gated page.
637
659
 
638
- **Ground-truth lifeline** for one document's whole acceptance lifecycle:
660
+ **Ground-truth lifeline** for one document's whole acceptance lifecycle (client account: through `storage-d1-query`):
639
661
 
640
662
  ```bash
641
663
  CLOUDFLARE_API_TOKEN="${MINTED_D1_EDIT}" wrangler d1 execute <db-name> --remote --json --command \
@@ -55,10 +55,14 @@ describe('schema.sql', () => {
55
55
  })
56
56
  })
57
57
 
58
- // The broker runs `wrangler pages deploy <dir>` with <dir> = the site root, so
59
- // the positional directory IS the asset root and pages_build_output_dir is
60
- // overridden. A nested public/ therefore publishes the site one level too deep
61
- // and `/` returns 404 the live failure Task 1774 closes.
58
+ // The template's flat shape and its `pages_build_output_dir = "."` have to agree,
59
+ // because the two together are what puts index.html at the served root. Until
60
+ // Task 1917 the broker handed wrangler the site root as the positional asset
61
+ // directory, which overrode the declared output dir, so a nested public/ served
62
+ // the site one level too deep and `/` returned 404 — the live failure Task 1774
63
+ // closed by flattening. Since 1917 the broker publishes the declared output dir,
64
+ // so a mismatch here no longer 404s; these assertions keep the shipped template
65
+ // on the one shape that is deployed and measured.
62
66
  describe('template layout matches what the deploy publishes', () => {
63
67
  it('has no public/ level', () => {
64
68
  expect(existsSync(join(TEMPLATE, 'public'))).toBe(false)
@@ -33,7 +33,7 @@ If the site captures form data, add the `[[d1_databases]]` binding from `d1-data
33
33
 
34
34
  ## 3. Load the reused deploy token
35
35
 
36
- > **On a multi-tenant install, a client account does not run this step at all.** After the 1631 storage isolation, a client's `cloudflare.env` carries no master, and `cf-token.sh` denies it the `pages` scope (`reason=not-house`) — correctly, since that master is account-wide. A client account publishes with the `storage-pages-deploy` tool, which runs the deploy house-side; the project must already be recorded to the account by the house (`storage-pages-adopt`), because the project-to-folder mapping is not derivable and is never guessed. The rest of this section applies to the **house account and single-tenant installs**, where the master still lives in the account secrets file. If you are a client account and you reached a `not-house` denial here, that is this branch, not a broken token: use the tool.
36
+ > **On a multi-tenant install, a client account does not run this step at all.** After the 1631 storage isolation, a client's `cloudflare.env` carries no master, and `cf-token.sh` denies it the `pages` scope (`reason=not-house`) — correctly, since that master is account-wide. A client account publishes with the `storage-pages-deploy` tool, which runs the deploy house-side; the project must already be recorded to the account by the house (`storage-pages-adopt`), because the project-to-folder mapping is not derivable and is never guessed. The rest of this section applies to the **house account and single-tenant installs**, where the master still lives in the account secrets file. If you are a client account and you reached a `not-house` denial here, that is this branch, not a broken token: use the tool. The tool reads `pages_build_output_dir` from the site root's `wrangler.toml` (or `wrangler.json`/`.jsonc`) and publishes that directory as the served root, which is the same asset root step 4 below passes positionally. A declared output dir that is missing fails the deploy naming the path rather than publishing its parent.
37
37
 
38
38
  A Pages deploy needs **Account · Cloudflare Pages · Edit**. If the site has Pages Functions hitting D1, the token also needs **Account · D1 · Edit** (the single most common breakage — see `d1-data-capture.md`). The `pages` scope resolves the stable `<brand>-pages-d1` token, which carries both. Resolve it with the deterministic helper, which mints it once only if absent and reuses it thereafter. This is the one command; do not source the secrets file and hand the resulting `CLOUDFLARE_API_TOKEN` to `wrangler` — that variable holds the **master**, not the per-scope token, and `wrangler pages` then returns `Authentication error [code: 10000]`:
39
39
 
@@ -98,11 +98,11 @@ Copy `template/` to `<accountDir>/sites/<project>/` and fill `wrangler.toml`'s `
98
98
  placeholder in the assembled tree.**
99
99
 
100
100
  `sites/<project>/` is the only location the broker will publish: `resolveSiteDir` resolves
101
- `<accountDir>/sites/<name>` and refuses anything else with `invalid-site`. The tree is flat because
102
- the deploy publishes the directory it is handed — `index.html`, `portal.css`, `portal.js`,
101
+ `<accountDir>/sites/<name>` and refuses anything else with `invalid-site`. The tree is flat: `index.html`, `portal.css`, `portal.js`,
103
102
  `functions/` and `wrangler.toml` all sit at its root, with no nested asset level, and
104
- `pages_build_output_dir` reads `"."` to match. A nested asset level publishes the site one level too
105
- deep, which serves 404 at the domain root.
103
+ `pages_build_output_dir` reads `"."` to match. The deploy publishes whatever
104
+ directory `pages_build_output_dir` declares, so the two must agree; a nested asset level that the
105
+ config does not declare publishes the site one level too deep, which serves 404 at the domain root.
106
106
 
107
107
  The deploy serves everything in that tree except a fixed ignore list — `functions/`, `_worker.js`,
108
108
  `_redirects`, `_headers`, `_routes.json`, `node_modules`, `.git`, `.DS_Store`, `.wrangler` — and
@@ -92,19 +92,19 @@ function readHouseCredential(platformRoot) {
92
92
  import { execFile as execFile2 } from "child_process";
93
93
  import { cp, mkdtemp, rm } from "fs/promises";
94
94
  import { tmpdir } from "os";
95
- import { join as join2, relative } from "path";
95
+ import { join as join3, relative as relative2 } from "path";
96
96
 
97
97
  // ../lib/storage-broker/src/house-scoped-token.ts
98
98
  import { readFileSync as readFileSync2 } from "fs";
99
99
  import { execFile } from "child_process";
100
- var defaultRun = (cmd, args, env) => new Promise((resolve, reject) => {
100
+ var defaultRun = (cmd, args, env) => new Promise((resolve2, reject) => {
101
101
  execFile(cmd, args, { env }, (err, stdout, stderr) => {
102
102
  if (err) {
103
103
  reject(new Error(`${cmd} ${args.join(" ")} failed: ${err.message}
104
104
  ${stderr}`));
105
105
  return;
106
106
  }
107
- resolve({ stdout: stdout.toString() });
107
+ resolve2({ stdout: stdout.toString() });
108
108
  });
109
109
  });
110
110
  function readKey(path, key) {
@@ -145,8 +145,123 @@ function keyForScope(scope) {
145
145
  }
146
146
  }
147
147
 
148
+ // ../lib/storage-broker/src/pages-output-dir.ts
149
+ import { lstat, readFile, realpath } from "fs/promises";
150
+ import { isAbsolute, join as join2, relative, resolve } from "path";
151
+ var WRANGLER_CONFIG_NAMES = [
152
+ "wrangler.json",
153
+ "wrangler.jsonc",
154
+ "wrangler.toml"
155
+ ];
156
+ var KEY = "pages_build_output_dir";
157
+ function stripJsonComments(text) {
158
+ let out = "";
159
+ let inString = false;
160
+ let escaped = false;
161
+ for (let i = 0; i < text.length; i++) {
162
+ const c = text[i];
163
+ if (inString) {
164
+ out += c;
165
+ if (escaped) escaped = false;
166
+ else if (c === "\\") escaped = true;
167
+ else if (c === '"') inString = false;
168
+ continue;
169
+ }
170
+ if (c === '"') {
171
+ inString = true;
172
+ out += c;
173
+ continue;
174
+ }
175
+ if (c === "/" && text[i + 1] === "/") {
176
+ while (i < text.length && text[i] !== "\n") i++;
177
+ out += "\n";
178
+ continue;
179
+ }
180
+ if (c === "/" && text[i + 1] === "*") {
181
+ i += 2;
182
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
183
+ i++;
184
+ continue;
185
+ }
186
+ out += c;
187
+ }
188
+ return out;
189
+ }
190
+ function readTomlKey(text) {
191
+ for (const raw of text.split("\n")) {
192
+ const line = raw.trim();
193
+ if (line === "" || line.startsWith("#")) continue;
194
+ if (line.startsWith("[")) return void 0;
195
+ const m = /^pages_build_output_dir\s*=\s*(?:"([^"]*)"|'([^']*)')\s*(?:#.*)?$/.exec(line);
196
+ if (m) return m[1] ?? m[2];
197
+ }
198
+ return void 0;
199
+ }
200
+ function readJsonKey(text, name, jsonc) {
201
+ let parsed;
202
+ try {
203
+ parsed = JSON.parse(jsonc ? stripJsonComments(text) : text);
204
+ } catch (err) {
205
+ throw new Error(`storage-broker: ${name} could not be parsed: ${String(err)}`);
206
+ }
207
+ if (typeof parsed !== "object" || parsed === null) {
208
+ throw new Error(`storage-broker: ${name} is not an object`);
209
+ }
210
+ const value = parsed[KEY];
211
+ if (value === void 0) return void 0;
212
+ if (typeof value !== "string") {
213
+ throw new Error(`storage-broker: ${name} declares a non-string ${KEY}`);
214
+ }
215
+ return value;
216
+ }
217
+ function escapes(root, child) {
218
+ const rel = relative(root, child);
219
+ return rel.startsWith("..") || isAbsolute(rel);
220
+ }
221
+ function outsideError(name, declared, resolved, root) {
222
+ return new Error(
223
+ `storage-broker: ${name} declares ${KEY}="${declared}", which resolves to ${resolved}, outside the site root ${root}`
224
+ );
225
+ }
226
+ async function resolveUploadDir(dir) {
227
+ for (const name of WRANGLER_CONFIG_NAMES) {
228
+ let text;
229
+ try {
230
+ text = await readFile(join2(dir, name), "utf8");
231
+ } catch {
232
+ continue;
233
+ }
234
+ const declared = name === "wrangler.toml" ? readTomlKey(text) : readJsonKey(text, name, name === "wrangler.jsonc");
235
+ if (declared === void 0) return { uploadDir: dir, source: "site-root" };
236
+ const uploadDir = resolve(dir, declared);
237
+ if (escapes(dir, uploadDir)) throw outsideError(name, declared, uploadDir, dir);
238
+ let info;
239
+ try {
240
+ info = await lstat(uploadDir);
241
+ } catch {
242
+ throw new Error(
243
+ `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} does not exist`
244
+ );
245
+ }
246
+ if (info.isSymbolicLink()) {
247
+ throw new Error(
248
+ `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} is a symbolic link; the published root must be a real directory`
249
+ );
250
+ }
251
+ if (!info.isDirectory()) {
252
+ throw new Error(
253
+ `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} is not a directory`
254
+ );
255
+ }
256
+ const realDir = await realpath(dir);
257
+ const realUpload = await realpath(uploadDir);
258
+ if (escapes(realDir, realUpload)) throw outsideError(name, declared, realUpload, realDir);
259
+ return { uploadDir, source: name, declared };
260
+ }
261
+ return { uploadDir: dir, source: "site-root" };
262
+ }
263
+
148
264
  // ../lib/storage-broker/src/cf-exec.ts
149
- var WRANGLER_CONFIG_NAMES = ["wrangler.json", "wrangler.jsonc", "wrangler.toml"];
150
265
  function encodeObjectKey(key) {
151
266
  return key.split("/").map(encodeURIComponent).join("/");
152
267
  }
@@ -154,14 +269,14 @@ function objectUrl(accountId, bucket, key) {
154
269
  return `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${encodeURIComponent(bucket)}/objects/${encodeObjectKey(key)}`;
155
270
  }
156
271
  var defaultFetch = (url, init) => fetch(url, init);
157
- var defaultRun2 = (cmd, args, env, opts) => new Promise((resolve, reject) => {
272
+ var defaultRun2 = (cmd, args, env, opts) => new Promise((resolve2, reject) => {
158
273
  execFile2(cmd, args, { env, cwd: opts?.cwd }, (err, stdout, stderr) => {
159
274
  if (err) {
160
275
  reject(new Error(`${cmd} ${args.join(" ")} failed: ${err.message}
161
276
  ${stderr}`));
162
277
  return;
163
278
  }
164
- resolve({ stdout: stdout.toString() });
279
+ resolve2({ stdout: stdout.toString() });
165
280
  });
166
281
  });
167
282
  function parseJson(raw, context) {
@@ -373,15 +488,18 @@ function makeCfExec(cred, run = defaultRun2, fetchFn = defaultFetch) {
373
488
  }
374
489
  },
375
490
  async pagesDeploy(dir, project, branch) {
376
- const staged = await mkdtemp(join2(tmpdir(), "maxy-pages-upload-"));
491
+ const { uploadDir, source: uploadDirSource } = await resolveUploadDir(dir);
492
+ const staged = await mkdtemp(join3(tmpdir(), "maxy-pages-upload-"));
377
493
  const excluded = [];
378
494
  try {
379
- await cp(dir, staged, {
495
+ await cp(uploadDir, staged, {
380
496
  recursive: true,
381
- // Only a config at the root of the tree is wrangler's own. A nested
382
- // file of the same name is site content and stays published.
497
+ // Only a config at the root of what is uploaded is dropped. That root
498
+ // is the served root, which is the surface Task 1782 protects. A file
499
+ // of the same name anywhere below it is site content and stays
500
+ // published.
383
501
  filter: (src) => {
384
- const rel = relative(dir, src);
502
+ const rel = relative2(uploadDir, src);
385
503
  if (!WRANGLER_CONFIG_NAMES.includes(rel)) return true;
386
504
  excluded.push(rel);
387
505
  return false;
@@ -396,7 +514,7 @@ function makeCfExec(cred, run = defaultRun2, fetchFn = defaultFetch) {
396
514
  if (!url) {
397
515
  throw new Error(`storage-broker: pages deploy returned no deployment url: ${stdout}`);
398
516
  }
399
- return { url, excluded: excluded.sort() };
517
+ return { url, excluded: excluded.sort(), uploadDir, uploadDirSource };
400
518
  } finally {
401
519
  await rm(staged, { recursive: true, force: true });
402
520
  }
@@ -477,15 +595,15 @@ ${await res.text()}`
477
595
  }
478
596
  async function makeHouseCfExec(platformRoot, run = defaultRun2, fetchFn = defaultFetch) {
479
597
  const cred = readHouseCredential(platformRoot);
480
- const houseEnvPath = join2(platformRoot, "config", "cloudflare-house.env");
481
- const cfTokenSh = join2(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
598
+ const houseEnvPath = join3(platformRoot, "config", "cloudflare-house.env");
599
+ const cfTokenSh = join3(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
482
600
  const scoped = await resolveHouseScopedToken("storage", houseEnvPath, cfTokenSh, run);
483
601
  return makeCfExec({ apiToken: scoped, accountId: cred.accountId }, run, fetchFn);
484
602
  }
485
603
  async function makeHousePagesExec(platformRoot, run = defaultRun2, fetchFn = defaultFetch) {
486
604
  const cred = readHouseCredential(platformRoot);
487
- const houseEnvPath = join2(platformRoot, "config", "cloudflare-house.env");
488
- const cfTokenSh = join2(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
605
+ const houseEnvPath = join3(platformRoot, "config", "cloudflare-house.env");
606
+ const cfTokenSh = join3(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
489
607
  const scoped = await resolveHouseScopedToken("pages", houseEnvPath, cfTokenSh, run);
490
608
  return makeCfExec({ apiToken: scoped, accountId: cred.accountId }, run, fetchFn);
491
609
  }
@@ -133,7 +133,7 @@ import {
133
133
  registerResource,
134
134
  resolveOwner,
135
135
  tooLargeMessage
136
- } from "./chunk-KZFE3MOY.js";
136
+ } from "./chunk-RPUTKSAJ.js";
137
137
  import {
138
138
  __commonJS,
139
139
  __toESM
@@ -9660,9 +9660,9 @@ app3.post("/pages/deploy", pagesDeployBodyLimit, async (c) => {
9660
9660
  `[pages-broker] op=project-ensure pub=${pub} project=${project} existed=${existed} created=${!existed}`
9661
9661
  );
9662
9662
  const started = Date.now();
9663
- const { url, excluded } = await exec.pagesDeploy(dir, project, branch);
9663
+ const { url, excluded, uploadDir, uploadDirSource } = await exec.pagesDeploy(dir, project, branch);
9664
9664
  console.error(
9665
- `[pages-broker] op=stage pub=${pub} excluded=${excluded.length > 0 ? excluded.join(",") : "none"}`
9665
+ `[pages-broker] op=stage pub=${pub} upload=${uploadDir} uploadFrom=${uploadDirSource} excluded=${excluded.length > 0 ? excluded.join(",") : "none"}`
9666
9666
  );
9667
9667
  console.error(`[pages-broker] op=deploy pub=${pub} ranMs=${Date.now() - started} url=${url}`);
9668
9668
  const httpStatus = await verifyDeployed(url);
@@ -10032,7 +10032,7 @@ app3.post("/r2/object/delete", envelopeBodyLimit("write"), async (c) => {
10032
10032
  });
10033
10033
  var storage_broker_default = app3;
10034
10034
  async function runStorageAudit(root) {
10035
- const { reconcileStorage } = await import("./src-UYDFMWF4.js");
10035
+ const { reconcileStorage } = await import("./src-JN6PIAJN.js");
10036
10036
  const cf = await makeHouseCfExec(root);
10037
10037
  const accountsDir = resolve11(root, "..", "data", "accounts");
10038
10038
  const strays = (await reconcileStorage({
@@ -10072,7 +10072,7 @@ async function runStorageAudit(root) {
10072
10072
  );
10073
10073
  }
10074
10074
  async function runPagesAudit(root) {
10075
- const { reconcilePages } = await import("./src-UYDFMWF4.js");
10075
+ const { reconcilePages } = await import("./src-JN6PIAJN.js");
10076
10076
  const cf = await makeHousePagesExec(root);
10077
10077
  const r = await reconcilePages({
10078
10078
  cfProjects: async () => (await cf.pagesProjectList()).map((p) => p.name),