astroidjs 0.3.2 → 0.4.0

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.
package/README.md CHANGED
@@ -33,7 +33,7 @@ The whole shape of a project — its brand + theme + editable home, its commerce
33
33
  backend and optional modules — collapses into one typed config. **One brand per
34
34
  project:** every site Astroid targets serves a single brand from a single deploy,
35
35
  so the config describes one brand, not an array. What actually multiplexes is
36
- *editors* (Louise's org plugin) and *audiences* (a gated portal beside the public
36
+ _editors_ (Louise's org plugin) and _audiences_ (a gated portal beside the public
37
37
  site) — both options on the one brand. The vocabulary is drawn from the real
38
38
  sites Astroid targets: a storefront (coracle.coffee), a wholesale front
39
39
  (ghostfire.coffee), an artist portfolio (themidwestartist.com), and a plain
@@ -131,16 +131,16 @@ wait on the work.
131
131
  Status codes are the only backpressure signal a provider gives you, so each one
132
132
  is picked for what it tells the sender to do:
133
133
 
134
- | Situation | Code | Why |
135
- |---|---|---|
136
- | Secret unprovisioned | 503 | Dormant is temporary — keep retrying so events delivered before you set the secret still land |
137
- | Bad / missing signature | 401 | Terminal. It won't verify on retry either, and retrying turns a misconfiguration into a flood |
138
- | Body isn't JSON | 400 | Terminal for the same reason |
139
- | Enqueue failed | 503 | The signature checked out, so the event is real — ask for redelivery |
140
- | Enqueued | 202 | Accepted, not done. That's the point of a queue |
134
+ | Situation | Code | Why |
135
+ | ----------------------- | ---- | --------------------------------------------------------------------------------------------- |
136
+ | Secret unprovisioned | 503 | Dormant is temporary — keep retrying so events delivered before you set the secret still land |
137
+ | Bad / missing signature | 401 | Terminal. It won't verify on retry either, and retrying turns a misconfiguration into a flood |
138
+ | Body isn't JSON | 400 | Terminal for the same reason |
139
+ | Enqueue failed | 503 | The signature checked out, so the event is real — ask for redelivery |
140
+ | Enqueued | 202 | Accepted, not done. That's the point of a queue |
141
141
 
142
142
  On the consumer side `astroidQueueHandler` owns the dispatch every site wrote: a
143
- periodic refresh re-syncs, a webhook re-syncs *only* if it touched the catalog,
143
+ periodic refresh re-syncs, a webhook re-syncs _only_ if it touched the catalog,
144
144
  and everything else acks as a no-op. That last part matters — order and payment
145
145
  events arrive in volume and have nothing local to update, so treating them as
146
146
  actionable turns a busy sales day into a refresh storm.
@@ -195,7 +195,7 @@ settings is a site-wide kill switch that beats any page asking to be indexed —
195
195
  useful for staging.
196
196
 
197
197
  `<StructuredData>` emits a schema.org `@graph`: the business, the `WebSite`, and
198
- optionally the entity the page is *about* (a Product, a VisualArtwork). The
198
+ optionally the entity the page is _about_ (a Product, a VisualArtwork). The
199
199
  business `@type` comes from the archetype (`storefront` → `Store`, `portfolio` →
200
200
  `Person`); set `seo.businessType` to a narrower subtype whenever you know one.
201
201
  The payload is escaped with `escapeJsonLd`, not `JSON.stringify` — `stringify`
@@ -219,7 +219,7 @@ email-bombing target, so the tightest budget in the set), the portal's
219
219
  credential surfaces when `portal.enabled`, checkout when `commerce` is set. The
220
220
  session-gated editor API stays out on purpose — a limiter that can lock the
221
221
  owner out of their own studio is worse than the abuse it stops. Add or override
222
- via `security.rateRules`, which is matched *before* the defaults, so you replace
222
+ via `security.rateRules`, which is matched _before_ the defaults, so you replace
223
223
  one budget rather than the whole set.
224
224
 
225
225
  **The CSP is composed, and it's split for a reason.** `astroidSecurity(config)`
@@ -229,7 +229,7 @@ Astroid adds the one hash Astro can't produce itself: Solid's hydration
229
229
  bootstrap, injected by `@astrojs/solid-js` on every page with an island.
230
230
  Computing it from `generateHydrationScript()` means it tracks solid-js upgrades
231
231
  instead of going stale as a copy-pasted literal. Meanwhile the generated
232
- middleware rewrites *only* `style-src`, because Louise's data-driven `style=""`
232
+ middleware rewrites _only_ `style-src`, because Louise's data-driven `style=""`
233
233
  carriers need `'unsafe-inline'` and a single hash in that directive would void
234
234
  it per spec — the two cannot share one directive.
235
235
 
@@ -240,8 +240,8 @@ import astroidConfig from "./astroid.config.ts";
240
240
 
241
241
  export default defineConfig({
242
242
  security: astroidSecurity(astroidConfig),
243
- vite: { build: { ...ASTROID_VITE_BUILD } }, // assetsInlineLimit: 0 — an
244
- }); // inlined asset can't be hashed
243
+ vite: { build: { ...ASTROID_VITE_BUILD } }, // assetsInlineLimit: 0 — an
244
+ }); // inlined asset can't be hashed
245
245
  ```
246
246
 
247
247
  Enabled modules contribute their own origins (a commerce provider's SDK hosts,
@@ -249,8 +249,8 @@ the captcha frame); `security.cspOrigins` adds anything Astroid can't see.
249
249
 
250
250
  ## Modules are dormant, not broken
251
251
 
252
- Astroid's optional modules are opt-in at the *config* level, never at the
253
- *account* level: switching commerce on must not require a Square account before
252
+ Astroid's optional modules are opt-in at the _config_ level, never at the
253
+ _account_ level: switching commerce on must not require a Square account before
254
254
  `pnpm dev` will boot. So a module whose secrets aren't provisioned is **dormant**
255
255
  — it renders, it serves, it says out loud that it's simulated, and it never calls
256
256
  upstream with a dummy credential. A fresh clone runs with zero external accounts.
package/bin/astroid.mjs CHANGED
@@ -215,7 +215,7 @@ async function cmdDoctor(cwd, flags) {
215
215
  else
216
216
  err(
217
217
  "wrangler.jsonc has no `send_email` binding, but production sign-in emails the " +
218
- "magic link (it is only console-logged in dev). Add: \"send_email\": [{ \"name\": \"EMAIL\" }]",
218
+ 'magic link (it is only console-logged in dev). Add: "send_email": [{ "name": "EMAIL" }]',
219
219
  );
220
220
 
221
221
  if (hasBinding("DB")) ok("wrangler: D1 `DB` binding present");
@@ -328,7 +328,9 @@ async function cmdAstro(cwd, subcommand, flags, rest) {
328
328
  await cmdGenerate(cwd, flags, { quiet: true });
329
329
  const astroBin = resolveBin(cwd, "astro", "astro");
330
330
  if (!astroBin) {
331
- fail("Could not find `astro` in this project. Run inside an Astroid project (with astro installed).");
331
+ fail(
332
+ "Could not find `astro` in this project. Run inside an Astroid project (with astro installed).",
333
+ );
332
334
  }
333
335
  const child = spawn(process.execPath, [astroBin, subcommand, ...rest], { stdio: "inherit", cwd });
334
336
  child.on("exit", (code) => process.exit(code ?? 0));
@@ -409,7 +411,9 @@ function provisionPlan(facts) {
409
411
  /** Look up a just-created resource's id by name via a `… list` JSON command. */
410
412
  function lookupId(wranglerBin, cwd, args, pick) {
411
413
  try {
412
- const rows = JSON.parse(execFileSync(process.execPath, [wranglerBin, ...args], { cwd, encoding: "utf8" }));
414
+ const rows = JSON.parse(
415
+ execFileSync(process.execPath, [wranglerBin, ...args], { cwd, encoding: "utf8" }),
416
+ );
413
417
  return pick(Array.isArray(rows) ? rows : []) ?? null;
414
418
  } catch {
415
419
  return null;
@@ -453,7 +457,9 @@ async function cmdDeploy(cwd, flags, rest) {
453
457
  out(" Secrets: wrangler secret put SESSION_SECRET (prompted)");
454
458
  out(" Deploy: wrangler deploy\n");
455
459
  if (!facts.hasAccount) {
456
- out(" ! No account_id set — uncomment it in wrangler.jsonc or export CLOUDFLARE_ACCOUNT_ID.\n");
460
+ out(
461
+ " ! No account_id set — uncomment it in wrangler.jsonc or export CLOUDFLARE_ACCOUNT_ID.\n",
462
+ );
457
463
  }
458
464
 
459
465
  if (dryRun) {
@@ -464,10 +470,14 @@ async function cmdDeploy(cwd, flags, rest) {
464
470
  // Gate the irreversible work behind a clear yes.
465
471
  if (!assumeYes) {
466
472
  if (!process.stdin.isTTY) {
467
- fail("Refusing to provision + deploy non-interactively. Re-run with --yes (or --dry-run to preview).");
473
+ fail(
474
+ "Refusing to provision + deploy non-interactively. Re-run with --yes (or --dry-run to preview).",
475
+ );
468
476
  }
469
477
  const rl = createInterface({ input: process.stdin, output: process.stdout });
470
- const answer = (await rl.question("Provision the above, then migrate + deploy? [y/N] ")).trim().toLowerCase();
478
+ const answer = (await rl.question("Provision the above, then migrate + deploy? [y/N] "))
479
+ .trim()
480
+ .toLowerCase();
471
481
  rl.close();
472
482
  if (answer !== "y" && answer !== "yes") {
473
483
  out("Aborted.");
@@ -477,7 +487,8 @@ async function cmdDeploy(cwd, flags, rest) {
477
487
 
478
488
  const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
479
489
  if (!wranglerBin) fail("Could not find `wrangler` in this project (add it as a devDependency).");
480
- const runInherit = (args) => spawnSync(process.execPath, [wranglerBin, ...args], { cwd, stdio: "inherit" });
490
+ const runInherit = (args) =>
491
+ spawnSync(process.execPath, [wranglerBin, ...args], { cwd, stdio: "inherit" });
481
492
 
482
493
  // 1) Provision, patching discovered ids back into wrangler.jsonc.
483
494
  for (const s of plan) {
@@ -490,17 +501,28 @@ async function cmdDeploy(cwd, flags, rest) {
490
501
  }
491
502
 
492
503
  if (s.kind === "d1") {
493
- const id = lookupId(wranglerBin, cwd, ["d1", "list", "--json"], (rows) => rows.find((r) => r.name === s.name)?.uuid);
504
+ const id = lookupId(
505
+ wranglerBin,
506
+ cwd,
507
+ ["d1", "list", "--json"],
508
+ (rows) => rows.find((r) => r.name === s.name)?.uuid,
509
+ );
494
510
  if (id) {
495
- wrangler = wrangler.replace(/"database_id":\s*"<[^"]*>"/, `"database_id": ${JSON.stringify(id)}`);
511
+ wrangler = wrangler.replace(
512
+ /"database_id":\s*"<[^"]*>"/,
513
+ `"database_id": ${JSON.stringify(id)}`,
514
+ );
496
515
  writeFileSync(wranglerPath, wrangler);
497
516
  out(` ↳ database_id = ${id}`);
498
517
  } else {
499
518
  out(" ↳ couldn't auto-detect the id — fill database_id in wrangler.jsonc by hand.");
500
519
  }
501
520
  } else if (s.kind === "kv") {
502
- const id = lookupId(wranglerBin, cwd, ["kv", "namespace", "list"], (rows) =>
503
- rows.find((r) => typeof r.title === "string" && r.title.endsWith(s.name))?.id,
521
+ const id = lookupId(
522
+ wranglerBin,
523
+ cwd,
524
+ ["kv", "namespace", "list"],
525
+ (rows) => rows.find((r) => typeof r.title === "string" && r.title.endsWith(s.name))?.id,
504
526
  );
505
527
  if (id) {
506
528
  wrangler = patchKvId(wrangler, s.name, id);
@@ -514,7 +536,8 @@ async function cmdDeploy(cwd, flags, rest) {
514
536
 
515
537
  // 2) Migrations.
516
538
  out(`\n▸ wrangler d1 migrations apply DB ${remoteArgs.join(" ")}`.trimEnd());
517
- if (runInherit(["d1", "migrations", "apply", "DB", ...remoteArgs]).status !== 0) fail("Migrations failed.");
539
+ if (runInherit(["d1", "migrations", "apply", "DB", ...remoteArgs]).status !== 0)
540
+ fail("Migrations failed.");
518
541
 
519
542
  // 3) Secrets (interactive; wrangler prompts for the value).
520
543
  out("\n▸ wrangler secret put SESSION_SECRET");
@@ -50,10 +50,10 @@ export function assertAuthIsolation(config) {
50
50
  return;
51
51
  if (!portal.cookiePrefix || portal.cookiePrefix === ASTROID_EDITOR_COOKIE_PREFIX) {
52
52
  throw new AstroidConfigError(`portal.cookiePrefix must be set and distinct from the editor's (${JSON.stringify(ASTROID_EDITOR_COOKIE_PREFIX)}). Two instances sharing a cookie prefix silently sign you out of one when ` +
53
- "you sign into the other. Pick a project-specific prefix (e.g. \"acme_shop\").");
53
+ 'you sign into the other. Pick a project-specific prefix (e.g. "acme_shop").');
54
54
  }
55
55
  if (portal.tablePrefix === ASTROID_EDITOR_TABLE_PREFIX) {
56
56
  throw new AstroidConfigError(`portal.tablePrefix must differ from the editor's (${JSON.stringify(ASTROID_EDITOR_TABLE_PREFIX)}) — a shared table prefix merges the two instances into one user table. ` +
57
- 'Leave it unset (the unprefixed `user`/`session` tables) or use a distinct prefix.');
57
+ "Leave it unset (the unprefixed `user`/`session` tables) or use a distinct prefix.");
58
58
  }
59
59
  }
@@ -23,13 +23,22 @@ export declare function generateAstroidCheckoutRoute(config: AstroidConfig): str
23
23
  */
24
24
  export declare function generateAstroidSquareCard(config: AstroidConfig): string | null;
25
25
  /**
26
- * The wrangler `vars` the card input needs, or `[]`.
26
+ * The wrangler `vars` Square needs, or `[]`.
27
27
  *
28
28
  * PUBLIC values, so they are vars rather than entries in the secret roster: the
29
29
  * application id is shipped to the browser by design, and the environment is a
30
30
  * choice, not a credential. Putting them in `credentials` would also fold them
31
31
  * into the dormancy gate, which is about whether the module can safely CALL
32
32
  * Square — a different question from whether the card field can render.
33
+ *
34
+ * The two vars are gated SEPARATELY, and that split is load-bearing.
35
+ * `SQUARE_ENVIRONMENT` selects the API HOST for every Square call, so it belongs
36
+ * to any project that talks to Square at all; `SQUARE_APP_ID` only mounts the
37
+ * browser card field. Gating both on card checkout — as this did until
38
+ * `invoicing: "square"` became expressible — left a site that runs Square for
39
+ * invoicing alone with no `SQUARE_ENVIRONMENT`, and `SquareConfig.environment`
40
+ * defaults to "sandbox". Every production invoice would have been created
41
+ * against the sandbox: no error, no warning, just money that never arrives.
33
42
  */
34
43
  export declare function astroidCheckoutVars(config: AstroidConfig): {
35
44
  name: string;
@@ -20,7 +20,7 @@
20
20
  // redirects to its own hosted checkout (no card token to charge) and Stripe has
21
21
  // no catalog API, so it fills the `invoicing` role rather than `storefront`.
22
22
  import { astroidCatalogMirror } from "./mirror.js";
23
- import { astroidCommerceRoles } from "./roles.js";
23
+ import { astroidCommerceProviders, astroidCommerceRoles } from "./roles.js";
24
24
  /** Does this project take card payments in-page? Square storefront only. */
25
25
  export function usesCardCheckout(config) {
26
26
  return astroidCommerceRoles(config.commerce).storefront === "square";
@@ -65,7 +65,7 @@ export function generateAstroidCheckoutRoute(config) {
65
65
  " resolveCommerceStatus,",
66
66
  " type SecretSource,",
67
67
  " verifyCheckout,",
68
- "} from \"astroidjs\";",
68
+ '} from "astroidjs";',
69
69
  'import { createPayment } from "louise-toolkit/commerce/square";',
70
70
  'import { isSameOrigin } from "louise-toolkit/security";',
71
71
  'import astroidConfig from "../../../astroid.config.js";',
@@ -106,7 +106,7 @@ export function generateAstroidCheckoutRoute(config) {
106
106
  " // an Origin/Referer matching the host; a non-browser caller (a stripped",
107
107
  " // header) is refused. If you deliberately serve checkout from another origin,",
108
108
  " // this is the line to relax — it's yours.",
109
- " if (!isSameOrigin(request)) return json({ error: \"Forbidden\" }, 403);",
109
+ ' if (!isSameOrigin(request)) return json({ error: "Forbidden" }, 403);',
110
110
  "",
111
111
  " const body = (await request.json().catch(() => null)) as {",
112
112
  " lines?: unknown;",
@@ -115,15 +115,15 @@ export function generateAstroidCheckoutRoute(config) {
115
115
  " verificationToken?: unknown;",
116
116
  " email?: unknown;",
117
117
  " } | null;",
118
- " if (!body) return json({ error: \"Invalid JSON\" }, 400);",
118
+ ' if (!body) return json({ error: "Invalid JSON" }, 400);',
119
119
  "",
120
120
  " // The cart id is the identity half of the idempotency key. It must be high",
121
121
  " // entropy and client-generated ONCE per cart (a v4 uuid in localStorage):",
122
122
  " // regenerate it per request and a double-click charges twice; make it",
123
123
  " // guessable and someone else's identical cart can dedupe into your charge.",
124
- " const cartId = typeof body.cartId === \"string\" ? body.cartId : \"\";",
124
+ ' const cartId = typeof body.cartId === "string" ? body.cartId : "";',
125
125
  " if (!/^[0-9a-f-]{36}$/i.test(cartId)) {",
126
- " return json({ error: \"A uuid `cartId` is required\" }, 400);",
126
+ ' return json({ error: "A uuid `cartId` is required" }, 400);',
127
127
  " }",
128
128
  "",
129
129
  " // 1 + 2: re-price and refuse on mismatch.",
@@ -153,12 +153,12 @@ export function generateAstroidCheckoutRoute(config) {
153
153
  " });",
154
154
  " }",
155
155
  "",
156
- " const sourceId = typeof body.sourceId === \"string\" ? body.sourceId : \"\";",
156
+ ' const sourceId = typeof body.sourceId === "string" ? body.sourceId : "";',
157
157
  ' if (!sourceId) return json({ error: "A card token (`sourceId`) is required" }, 400);',
158
158
  "",
159
159
  " // Both are guaranteed real by the dormancy gate above; the narrowing here",
160
160
  " // is for the type system, which can't know that. `environment` is a UNION",
161
- " // (\"sandbox\" | \"production\"), not a free string — an unrecognised value",
161
+ ' // ("sandbox" | "production"), not a free string — an unrecognised value',
162
162
  " // would otherwise silently select the sandbox host in production.",
163
163
  ' const environment = env.SQUARE_ENVIRONMENT === "production" ? "production" : "sandbox";',
164
164
  " const payment = await createPayment(",
@@ -172,7 +172,7 @@ export function generateAstroidCheckoutRoute(config) {
172
172
  ' amountMoney: { amount: check.subtotalCents, currency: "USD" },',
173
173
  ' locationId: env.SQUARE_LOCATION_ID ?? "",',
174
174
  " idempotencyKey,",
175
- " ...(typeof body.verificationToken === \"string\"",
175
+ ' ...(typeof body.verificationToken === "string"',
176
176
  " ? { verificationToken: body.verificationToken }",
177
177
  " : {}),",
178
178
  ' ...(typeof body.email === "string" ? { buyerEmailAddress: body.email } : {}),',
@@ -270,37 +270,56 @@ export function generateAstroidSquareCard(config) {
270
270
  "",
271
271
  ].join("\n");
272
272
  }
273
+ /** Does this project talk to Square in ANY role — storefront, invoicing, or
274
+ * otherwise? Distinct from {@link usesCardCheckout}, which asks the narrower
275
+ * question of whether the in-page card field renders. */
276
+ function usesSquare(config) {
277
+ return astroidCommerceProviders(config.commerce).includes("square");
278
+ }
273
279
  /**
274
- * The wrangler `vars` the card input needs, or `[]`.
280
+ * The wrangler `vars` Square needs, or `[]`.
275
281
  *
276
282
  * PUBLIC values, so they are vars rather than entries in the secret roster: the
277
283
  * application id is shipped to the browser by design, and the environment is a
278
284
  * choice, not a credential. Putting them in `credentials` would also fold them
279
285
  * into the dormancy gate, which is about whether the module can safely CALL
280
286
  * Square — a different question from whether the card field can render.
287
+ *
288
+ * The two vars are gated SEPARATELY, and that split is load-bearing.
289
+ * `SQUARE_ENVIRONMENT` selects the API HOST for every Square call, so it belongs
290
+ * to any project that talks to Square at all; `SQUARE_APP_ID` only mounts the
291
+ * browser card field. Gating both on card checkout — as this did until
292
+ * `invoicing: "square"` became expressible — left a site that runs Square for
293
+ * invoicing alone with no `SQUARE_ENVIRONMENT`, and `SquareConfig.environment`
294
+ * defaults to "sandbox". Every production invoice would have been created
295
+ * against the sandbox: no error, no warning, just money that never arrives.
281
296
  */
282
297
  export function astroidCheckoutVars(config) {
283
- if (!usesCardCheckout(config))
284
- return [];
285
- return [
298
+ const vars = [];
299
+ // Browser-only, so card checkout is genuinely the right gate here.
300
+ if (usesCardCheckout(config)) {
286
301
  // Public app id from the Square dashboard (Developer → Credentials).
287
- { name: "SQUARE_APP_ID", value: "" },
288
- // "sandbox" until you have tested a real card end to end.
289
- { name: "SQUARE_ENVIRONMENT", value: "sandbox" },
290
- ];
302
+ vars.push({ name: "SQUARE_APP_ID", value: "" });
303
+ }
304
+ if (usesSquare(config)) {
305
+ // "sandbox" until you have tested a real payment end to end.
306
+ vars.push({ name: "SQUARE_ENVIRONMENT", value: "sandbox" });
307
+ }
308
+ return vars;
291
309
  }
292
310
  /**
293
311
  * The `CloudflareEnv` members the card input adds, as a block `create-astroid`
294
312
  * substitutes into `src/env.d.ts`. Empty without card checkout.
295
313
  */
296
314
  export function generateAstroidCheckoutEnv(config) {
297
- if (!usesCardCheckout(config))
298
- return "";
299
- return [
300
- " /** Square's PUBLIC application id — shipped to the browser to mount the",
301
- " * Web Payments card field. Not a secret; see wrangler.jsonc `vars`. */",
302
- " SQUARE_APP_ID: string;",
303
- ' /** Square API environment: "sandbox" or "production". */',
304
- " SQUARE_ENVIRONMENT: string;",
305
- ].join("\n");
315
+ // Mirrors the gating in `astroidCheckoutVars` — the app id is card-checkout
316
+ // only, the environment belongs to any project that calls Square at all.
317
+ const lines = [];
318
+ if (usesCardCheckout(config)) {
319
+ lines.push(" /** Square's PUBLIC application id — shipped to the browser to mount the", " * Web Payments card field. Not a secret; see wrangler.jsonc `vars`. */", " SQUARE_APP_ID: string;");
320
+ }
321
+ if (usesSquare(config)) {
322
+ lines.push(' /** Square API environment: "sandbox" or "production". Selects the API', " * host for EVERY Square call, so it is required for invoicing too. */", " SQUARE_ENVIRONMENT: string;");
323
+ }
324
+ return lines.join("\n");
306
325
  }
@@ -2,7 +2,7 @@ export { catalogNormalizer, type FourthwallProductLike, fourthwallToCatalogItem,
2
2
  export { type CheckoutVerification, checkoutIdempotencyKey, type ClientLine, type PriceLookup, verifyCheckout, type VerifiedLine, } from "./checkout.js";
3
3
  export { astroidCatalogLoaderConfig, type CatalogDatabase, type CatalogProduct, type CatalogReadOptions, readCatalog, readCatalogItem, } from "./loader.js";
4
4
  export { astroidCatalogMirror, BUILT_IN_OWNED, type CatalogMirrorConfig, generateCatalogMigrationSql, generateCatalogTable, type OwnedColumn, PULLED_COLUMNS, } from "./mirror.js";
5
- export { COMMERCE_PROVIDER_SECRETS, type CommerceStatus, commerceSecretNames, type ProviderStatus, resolveCommerceStatus, } from "./secrets.js";
5
+ export { COMMERCE_PROVIDER_SECRETS, type CommerceStatus, commerceSecretNames, type ProviderStatus, providerConfigured, resolveCommerceStatus, roleConfigured, } from "./secrets.js";
6
6
  export { assertCommerceRoles, astroidCommerceProviders, astroidCommerceRoles, type CommerceRole, hasStorefront, PROVIDER_ROLES, type ResolvedCommerceRoles, } from "./roles.js";
7
7
  export { astroidCatalogSync, astroidCatalogUpsert, type CatalogItem, type CatalogSyncOptions, type CatalogSyncResult, defaultSlug, type SyncDatabase, } from "./sync.js";
8
8
  export { astroidCheckoutVars, generateAstroidCheckoutEnv, generateAstroidCheckoutRoute, generateAstroidSquareCard, usesCardCheckout, } from "./checkout-scaffold.js";
@@ -3,7 +3,7 @@ export { catalogNormalizer, fourthwallToCatalogItem, squareToCatalogItem, } from
3
3
  export { checkoutIdempotencyKey, verifyCheckout, } from "./checkout.js";
4
4
  export { astroidCatalogLoaderConfig, readCatalog, readCatalogItem, } from "./loader.js";
5
5
  export { astroidCatalogMirror, BUILT_IN_OWNED, generateCatalogMigrationSql, generateCatalogTable, PULLED_COLUMNS, } from "./mirror.js";
6
- export { COMMERCE_PROVIDER_SECRETS, commerceSecretNames, resolveCommerceStatus, } from "./secrets.js";
6
+ export { COMMERCE_PROVIDER_SECRETS, commerceSecretNames, providerConfigured, resolveCommerceStatus, roleConfigured, } from "./secrets.js";
7
7
  export { assertCommerceRoles, astroidCommerceProviders, astroidCommerceRoles, hasStorefront, PROVIDER_ROLES, } from "./roles.js";
8
8
  export { astroidCatalogSync, astroidCatalogUpsert, defaultSlug, } from "./sync.js";
9
9
  export { astroidCheckoutVars, generateAstroidCheckoutEnv, generateAstroidCheckoutRoute, generateAstroidSquareCard, usesCardCheckout, } from "./checkout-scaffold.js";
@@ -1,20 +1,28 @@
1
1
  import type { CommerceConfig, CommerceProvider } from "../config.js";
2
2
  /** What a provider is being used FOR. */
3
- export type CommerceRole = "storefront" | "invoicing";
3
+ export type CommerceRole = "storefront" | "invoicing" | "pos";
4
4
  /**
5
5
  * Which roles each provider can serve, derived from the surface its
6
6
  * `louise-toolkit/commerce/*` client actually exposes — not from what the
7
7
  * vendor's full API could theoretically do.
8
8
  *
9
- * square catalog + orders + payments, and `createInvoice`/`publishInvoice`
9
+ * square catalog + orders + payments, `createInvoice`/`publishInvoice`,
10
+ * AND locations + per-location price overrides + inventory counts
10
11
  * stripe invoices + payment intents; NO catalog
11
- * fourthwall catalog + cart; NO invoicing
12
+ * fourthwall catalog + cart; NO invoicing, and NO locations or inventory —
13
+ * its Platform API is create-only for products, so it cannot
14
+ * model stock held at a place
15
+ *
16
+ * Square alone can serve `pos`, and that is a fact about the clients rather than
17
+ * a preference: `pos` needs `listLocations`, `location_overrides` and
18
+ * `batchChangeInventory`, none of which the other two clients expose.
12
19
  */
13
20
  export declare const PROVIDER_ROLES: Record<CommerceProvider, readonly CommerceRole[]>;
14
- /** The providers filling each role. Either may be absent. */
21
+ /** The providers filling each role. Any may be absent. */
15
22
  export interface ResolvedCommerceRoles {
16
23
  storefront?: CommerceProvider;
17
24
  invoicing?: CommerceProvider;
25
+ pos?: CommerceProvider;
18
26
  }
19
27
  /**
20
28
  * Resolve a `commerce` block into role assignments.
@@ -27,6 +35,20 @@ export interface ResolvedCommerceRoles {
27
35
  export declare function astroidCommerceRoles(commerce: CommerceConfig | undefined): ResolvedCommerceRoles;
28
36
  /** Every distinct provider this project talks to, in a stable order. */
29
37
  export declare function astroidCommerceProviders(commerce: CommerceConfig | undefined): CommerceProvider[];
38
+ /**
39
+ * True when this project sells in person — the switch for locations,
40
+ * per-location pricing and inventory.
41
+ */
42
+ export declare const hasPos: (commerce: CommerceConfig | undefined) => boolean;
43
+ /**
44
+ * True when Square is used with more than one Location, i.e. the location id
45
+ * comes from the request rather than the environment.
46
+ *
47
+ * Deliberately independent of which ROLE Square fills: a project could run
48
+ * multi-location invoicing without a `pos` storefront, and the consequence —
49
+ * no ambient `SQUARE_LOCATION_ID` — is the same either way.
50
+ */
51
+ export declare const hasMultiLocation: (commerce: CommerceConfig | undefined) => boolean;
30
52
  /** True when this project sells anything at all. */
31
53
  export declare const hasStorefront: (commerce: CommerceConfig | undefined) => boolean;
32
54
  /**
@@ -21,12 +21,19 @@ import { AstroidConfigError } from "../errors.js";
21
21
  * `louise-toolkit/commerce/*` client actually exposes — not from what the
22
22
  * vendor's full API could theoretically do.
23
23
  *
24
- * square catalog + orders + payments, and `createInvoice`/`publishInvoice`
24
+ * square catalog + orders + payments, `createInvoice`/`publishInvoice`,
25
+ * AND locations + per-location price overrides + inventory counts
25
26
  * stripe invoices + payment intents; NO catalog
26
- * fourthwall catalog + cart; NO invoicing
27
+ * fourthwall catalog + cart; NO invoicing, and NO locations or inventory —
28
+ * its Platform API is create-only for products, so it cannot
29
+ * model stock held at a place
30
+ *
31
+ * Square alone can serve `pos`, and that is a fact about the clients rather than
32
+ * a preference: `pos` needs `listLocations`, `location_overrides` and
33
+ * `batchChangeInventory`, none of which the other two clients expose.
27
34
  */
28
35
  export const PROVIDER_ROLES = {
29
- square: ["storefront", "invoicing"],
36
+ square: ["storefront", "invoicing", "pos"],
30
37
  stripe: ["invoicing"],
31
38
  fourthwall: ["storefront"],
32
39
  };
@@ -52,13 +59,29 @@ export function astroidCommerceRoles(commerce) {
52
59
  roles.storefront = commerce.storefront;
53
60
  if (commerce.invoicing)
54
61
  roles.invoicing = commerce.invoicing;
62
+ if (commerce.pos)
63
+ roles.pos = commerce.pos;
55
64
  return roles;
56
65
  }
57
66
  /** Every distinct provider this project talks to, in a stable order. */
58
67
  export function astroidCommerceProviders(commerce) {
59
- const { storefront, invoicing } = astroidCommerceRoles(commerce);
60
- return [...new Set([storefront, invoicing].filter((p) => !!p))];
68
+ const { storefront, invoicing, pos } = astroidCommerceRoles(commerce);
69
+ return [...new Set([storefront, invoicing, pos].filter((p) => !!p))];
61
70
  }
71
+ /**
72
+ * True when this project sells in person — the switch for locations,
73
+ * per-location pricing and inventory.
74
+ */
75
+ export const hasPos = (commerce) => Boolean(astroidCommerceRoles(commerce).pos);
76
+ /**
77
+ * True when Square is used with more than one Location, i.e. the location id
78
+ * comes from the request rather than the environment.
79
+ *
80
+ * Deliberately independent of which ROLE Square fills: a project could run
81
+ * multi-location invoicing without a `pos` storefront, and the consequence —
82
+ * no ambient `SQUARE_LOCATION_ID` — is the same either way.
83
+ */
84
+ export const hasMultiLocation = (commerce) => commerce?.square?.locations === "multi";
62
85
  /** True when this project sells anything at all. */
63
86
  export const hasStorefront = (commerce) => Boolean(astroidCommerceRoles(commerce).storefront);
64
87
  /**
@@ -81,7 +104,12 @@ export function assertCommerceRoles(commerce) {
81
104
  known(provider);
82
105
  if (!PROVIDER_ROLES[provider].includes(role)) {
83
106
  const able = Object.keys(PROVIDER_ROLES).filter((p) => PROVIDER_ROLES[p].includes(role));
84
- throw new AstroidConfigError(`commerce: ${provider} can't serve the "${role}" role — its louise-toolkit client has no ${role === "storefront" ? "catalog" : "invoicing"} API. Providers that can: ${able.join(", ")}.`);
107
+ const missing = {
108
+ storefront: "catalog",
109
+ invoicing: "invoicing",
110
+ pos: "locations/inventory",
111
+ }[role];
112
+ throw new AstroidConfigError(`commerce: ${provider} can't serve the "${role}" role — its louise-toolkit client has no ${missing} API. Providers that can: ${able.join(", ")}.`);
85
113
  }
86
114
  };
87
115
  // The shorthand assigns itself to a role it can serve, so it only has to be a
@@ -90,4 +118,11 @@ export function assertCommerceRoles(commerce) {
90
118
  known(commerce.provider);
91
119
  check("storefront", commerce.storefront);
92
120
  check("invoicing", commerce.invoicing);
121
+ check("pos", commerce.pos);
122
+ // `square.locations` only means something if Square is actually in play.
123
+ // Silently ignoring it would let a typo'd config look like it had opted into
124
+ // multi-location when nothing reads the setting.
125
+ if (commerce.square && !astroidCommerceProviders(commerce).includes("square")) {
126
+ throw new AstroidConfigError('commerce.square is set but no role is assigned to Square. Assign it to "storefront", "invoicing" or "pos", or drop the square block.');
127
+ }
93
128
  }
@@ -1,5 +1,6 @@
1
1
  import type { CommerceConfig, CommerceProvider } from "../config.js";
2
2
  import { type ModuleSecrets, type SecretSource } from "../secrets.js";
3
+ import { type CommerceRole } from "./roles.js";
3
4
  /**
4
5
  * Per-provider secret names, split by what they gate.
5
6
  *
@@ -25,6 +26,21 @@ export declare const COMMERCE_PROVIDER_SECRETS: Record<CommerceProvider, {
25
26
  * do, only that something is missing.
26
27
  */
27
28
  export declare const COMMERCE_PROVIDER_SETUP: Record<CommerceProvider, string>;
29
+ /**
30
+ * The credentials a provider needs **for this project's configuration**.
31
+ *
32
+ * Only Square varies, and only on one name. `SQUARE_LOCATION_ID` is required
33
+ * for a single-location project because Square's orders and payments endpoints
34
+ * refuse a request without one. Under `square.locations: "multi"` the location
35
+ * is a property of the *request* — which merchant's storefront is this? — so an
36
+ * ambient id is not just unnecessary, it is dangerous: any code path that
37
+ * defaulted to it would ring one merchant's sale against another merchant's
38
+ * books, and the sale would look perfectly successful while doing it.
39
+ *
40
+ * So it is dropped from the gate entirely rather than left optional. A name that
41
+ * is present-but-ignored is the kind of thing someone later "fixes" by using it.
42
+ */
43
+ export declare function commerceProviderCredentials(provider: CommerceProvider, commerce: CommerceConfig | undefined): readonly string[];
28
44
  /**
29
45
  * Every secret name this project's commerce configuration needs, deduplicated
30
46
  * and in a stable order.
@@ -38,7 +54,7 @@ export declare function commerceSecretNames(commerce: CommerceConfig | undefined
38
54
  export interface ProviderStatus {
39
55
  provider: CommerceProvider;
40
56
  /** Which role(s) this provider fills for the project. */
41
- roles: ("storefront" | "invoicing")[];
57
+ roles: CommerceRole[];
42
58
  /** API credentials — false means no live call can be made. */
43
59
  credentials: ModuleSecrets<string>;
44
60
  /** Webhook signing secret — false means the receiver answers 503. */
@@ -72,3 +88,22 @@ export interface CommerceStatus {
72
88
  * reason the mirror exists as a separate layer from the provider client.
73
89
  */
74
90
  export declare function resolveCommerceStatus(commerce: CommerceConfig | undefined, env: Record<string, SecretSource>): Promise<CommerceStatus>;
91
+ /**
92
+ * Is THIS provider live?
93
+ *
94
+ * `CommerceStatus.configured` is an all-or-nothing aggregate (`every`), which is
95
+ * the right answer for "is the whole module ready" and the wrong one for gating
96
+ * a single call site. A two-provider project — Fourthwall for the storefront,
97
+ * Square for invoicing — reads `configured: false` the moment either half is
98
+ * unprovisioned, so gating the working Fourthwall checkout on the aggregate
99
+ * would silently simulate it because SQUARE's secrets are still placeholders.
100
+ *
101
+ * Gate each surface on the provider it actually calls.
102
+ */
103
+ export declare function providerConfigured(status: CommerceStatus, provider: CommerceProvider): boolean;
104
+ /**
105
+ * Is the provider filling this ROLE live? The role-shaped question, for code
106
+ * that cares about the capability rather than the vendor — "can I take a
107
+ * checkout?" rather than "is Square up?".
108
+ */
109
+ export declare function roleConfigured(status: CommerceStatus, role: CommerceRole): boolean;
@@ -20,7 +20,7 @@
20
20
  // `queues/scaffold.ts`, because it is the same fact: a provider's secret set.
21
21
  // The scaffold imports it from here so the two can't drift.
22
22
  import { resolveModuleSecrets } from "../secrets.js";
23
- import { astroidCommerceProviders, astroidCommerceRoles } from "./roles.js";
23
+ import { astroidCommerceProviders, astroidCommerceRoles, hasMultiLocation, } from "./roles.js";
24
24
  /**
25
25
  * Per-provider secret names, split by what they gate.
26
26
  *
@@ -38,6 +38,8 @@ export const COMMERCE_PROVIDER_SECRETS = {
38
38
  // payments endpoints refuse a request without one, so a token on its own
39
39
  // leaves checkout broken rather than dormant. Requiring both is what makes
40
40
  // "configured" mean "can actually take money".
41
+ //
42
+ // UNLESS the project is multi-location — see `commerceProviderCredentials`.
41
43
  square: {
42
44
  credentials: ["SQUARE_ACCESS_TOKEN", "SQUARE_LOCATION_ID"],
43
45
  webhook: "SQUARE_WEBHOOK_SECRET",
@@ -67,6 +69,27 @@ export const COMMERCE_PROVIDER_SETUP = {
67
69
  stripe: "dashboard.stripe.com → Developers → API keys (secret key). Webhook secret: Developers → Webhooks → your endpoint → Signing secret.",
68
70
  fourthwall: "Fourthwall dashboard → Settings → For developers → Storefront token. Webhook secret: the same page → Webhooks.",
69
71
  };
72
+ /**
73
+ * The credentials a provider needs **for this project's configuration**.
74
+ *
75
+ * Only Square varies, and only on one name. `SQUARE_LOCATION_ID` is required
76
+ * for a single-location project because Square's orders and payments endpoints
77
+ * refuse a request without one. Under `square.locations: "multi"` the location
78
+ * is a property of the *request* — which merchant's storefront is this? — so an
79
+ * ambient id is not just unnecessary, it is dangerous: any code path that
80
+ * defaulted to it would ring one merchant's sale against another merchant's
81
+ * books, and the sale would look perfectly successful while doing it.
82
+ *
83
+ * So it is dropped from the gate entirely rather than left optional. A name that
84
+ * is present-but-ignored is the kind of thing someone later "fixes" by using it.
85
+ */
86
+ export function commerceProviderCredentials(provider, commerce) {
87
+ const spec = COMMERCE_PROVIDER_SECRETS[provider];
88
+ if (provider === "square" && hasMultiLocation(commerce)) {
89
+ return spec.credentials.filter((name) => name !== "SQUARE_LOCATION_ID");
90
+ }
91
+ return spec.credentials;
92
+ }
70
93
  /**
71
94
  * Every secret name this project's commerce configuration needs, deduplicated
72
95
  * and in a stable order.
@@ -77,17 +100,19 @@ export const COMMERCE_PROVIDER_SETUP = {
77
100
  */
78
101
  export function commerceSecretNames(commerce) {
79
102
  const names = astroidCommerceProviders(commerce).flatMap((provider) => [
80
- ...COMMERCE_PROVIDER_SECRETS[provider].credentials,
103
+ ...commerceProviderCredentials(provider, commerce),
81
104
  COMMERCE_PROVIDER_SECRETS[provider].webhook,
82
105
  ]);
83
106
  return [...new Set(names)];
84
107
  }
85
108
  /** Read one provider's secrets off an env-shaped record. */
86
- async function resolveProvider(provider, roles, env) {
109
+ async function resolveProvider(provider, roles, env, commerce) {
87
110
  const spec = COMMERCE_PROVIDER_SECRETS[provider];
88
111
  const pick = (names) => Object.fromEntries(names.map((n) => [n, env[n]]));
89
112
  const [credentials, webhook] = await Promise.all([
90
- resolveModuleSecrets(pick(spec.credentials)),
113
+ // Config-aware: a multi-location project must not be held dormant waiting
114
+ // for a SQUARE_LOCATION_ID it will never legitimately have.
115
+ resolveModuleSecrets(pick(commerceProviderCredentials(provider, commerce))),
91
116
  resolveModuleSecrets(pick([spec.webhook])),
92
117
  ]);
93
118
  return {
@@ -119,7 +144,7 @@ export async function resolveCommerceStatus(commerce, env) {
119
144
  if (providers.length === 0) {
120
145
  return { configured: false, enabled: false, providers: [], missing: [] };
121
146
  }
122
- const resolved = await Promise.all(providers.map((provider) => resolveProvider(provider, ["storefront", "invoicing"].filter((r) => roles[r] === provider), env)));
147
+ const resolved = await Promise.all(providers.map((provider) => resolveProvider(provider, ["storefront", "invoicing", "pos"].filter((r) => roles[r] === provider), env, commerce)));
123
148
  return {
124
149
  configured: resolved.every((p) => p.configured),
125
150
  enabled: true,
@@ -127,3 +152,26 @@ export async function resolveCommerceStatus(commerce, env) {
127
152
  missing: resolved.flatMap((p) => [...p.credentials.missing, ...p.webhook.missing]),
128
153
  };
129
154
  }
155
+ /**
156
+ * Is THIS provider live?
157
+ *
158
+ * `CommerceStatus.configured` is an all-or-nothing aggregate (`every`), which is
159
+ * the right answer for "is the whole module ready" and the wrong one for gating
160
+ * a single call site. A two-provider project — Fourthwall for the storefront,
161
+ * Square for invoicing — reads `configured: false` the moment either half is
162
+ * unprovisioned, so gating the working Fourthwall checkout on the aggregate
163
+ * would silently simulate it because SQUARE's secrets are still placeholders.
164
+ *
165
+ * Gate each surface on the provider it actually calls.
166
+ */
167
+ export function providerConfigured(status, provider) {
168
+ return status.providers.some((p) => p.provider === provider && p.configured);
169
+ }
170
+ /**
171
+ * Is the provider filling this ROLE live? The role-shaped question, for code
172
+ * that cares about the capability rather than the vendor — "can I take a
173
+ * checkout?" rather than "is Square up?".
174
+ */
175
+ export function roleConfigured(status, role) {
176
+ return status.providers.some((p) => p.roles.includes(role) && p.configured);
177
+ }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SectionCatalog } from "louise-toolkit/content";
1
+ import type { BlockCatalog, SectionCatalog } from "louise-toolkit/content";
2
2
  import type { RateRule } from "louise-toolkit/security";
3
3
  import type { CatalogMirrorConfig } from "./commerce/mirror.js";
4
4
  import type { astroidSectionCatalog } from "./components/sections.js";
@@ -156,8 +156,40 @@ export interface CommerceConfig {
156
156
  * Fourthwall as the storefront, because neither can do the other's job.
157
157
  */
158
158
  invoicing?: CommerceProvider;
159
+ /**
160
+ * In-person selling: physical inventory held at real places, sold at a
161
+ * counter or a market stall rather than through the site's own cart.
162
+ *
163
+ * Separate from `storefront` because they are genuinely different jobs and a
164
+ * site commonly runs both. themidwestartist.com sells print-on-demand merch
165
+ * through Fourthwall (`storefront`) while originals and self-stocked prints
166
+ * live in Square (`pos`) across several shops and galleries — one catalog per
167
+ * rail, neither able to do the other's job.
168
+ *
169
+ * What `pos` turns on that `storefront` does not: locations, per-location
170
+ * pricing, and per-location inventory.
171
+ */
172
+ pos?: CommerceProvider;
159
173
  /** The catalog mirror's shape — its mode, table name, and owned columns. */
160
174
  catalog?: CatalogMirrorConfig;
175
+ /** Square-specific options. Only meaningful when Square fills some role. */
176
+ square?: SquareCommerceConfig;
177
+ }
178
+ export interface SquareCommerceConfig {
179
+ /**
180
+ * How many Square Locations this project sells at.
181
+ *
182
+ * `"single"` (the default) is the ordinary case: one location, its id supplied
183
+ * once as `SQUARE_LOCATION_ID`, and every order placed against it.
184
+ *
185
+ * `"multi"` is the multi-merchant model — each merchant is a Location, and the
186
+ * id comes from the *request* (which merchant's storefront is this?) rather
187
+ * than from the environment. Setting it stops Astroid requiring
188
+ * `SQUARE_LOCATION_ID`: a single ambient location id is not merely unnecessary
189
+ * there, it is a hazard, because anything defaulting to it would silently ring
190
+ * one merchant's sale against another merchant's books.
191
+ */
192
+ locations?: "single" | "multi";
161
193
  }
162
194
  export interface QueuesConfig {
163
195
  /**
@@ -278,6 +310,20 @@ export interface AstroidConfig {
278
310
  * write paths agree. Omit to use Astroid's built-in catalog.
279
311
  */
280
312
  sectionCatalog?: SectionCatalog;
313
+ /**
314
+ * The site's catalog of BLOCK types (ADR 0005) — the block-level analogue of
315
+ * {@link sectionCatalog}, and required for any section whose def declares a
316
+ * `blocks` policy.
317
+ *
318
+ * Without it the server has no field shape to check a block against, so
319
+ * `validateSections` rejects every block `_type` as unknown and a block-bearing
320
+ * write 422s — the on-canvas block toolbar appears to work and then nothing
321
+ * saves. It also gates block rich-text **sanitization**: block fields are only
322
+ * scrubbed when their def is resolvable here.
323
+ *
324
+ * Omit for a sections-only site (no section declares `blocks`).
325
+ */
326
+ blockCatalog?: BlockCatalog;
281
327
  /** Optional capabilities switched on for this site. */
282
328
  modules?: ModuleKind[];
283
329
  /** Gated account/portal area (order tracking, client galleries). */
package/dist/config.js CHANGED
@@ -93,7 +93,7 @@ export function defineAstroid(config) {
93
93
  if (config.portal?.gated) {
94
94
  throw new AstroidConfigError("`portal.gated` is not implemented — it is accepted but wires no guard, so the site " +
95
95
  "would be fully public while appearing gated. Remove it, and gate the whole site by " +
96
- "listing the prefixes you mean in `portal.routes` (e.g. `[{ prefix: \"/\" }]` with your " +
96
+ 'listing the prefixes you mean in `portal.routes` (e.g. `[{ prefix: "/" }]` with your ' +
97
97
  "login and auth paths ahead of it).");
98
98
  }
99
99
  return config;
@@ -95,7 +95,12 @@ function describe(mail, reason, includeBody) {
95
95
  " binding to deliver it, or pass `devLog: true` if this really is local.)",
96
96
  ].join("\n");
97
97
  }
98
- return [...head, " ---", ...mail.content.text.split("\n").map((line) => ` ${line}`), " ---"].join("\n");
98
+ return [
99
+ ...head,
100
+ " ---",
101
+ ...mail.content.text.split("\n").map((line) => ` ${line}`),
102
+ " ---",
103
+ ].join("\n");
99
104
  }
100
105
  /**
101
106
  * Best-effort "are we in development?".
@@ -112,7 +112,7 @@ export async function servePmtiles(request, options) {
112
112
  // client's view of the archive.
113
113
  const got = object.range ?? {};
114
114
  const start = got.offset ?? (parsed.kind === "suffix" ? object.size - parsed.suffix : parsed.offset);
115
- const length = got.length ?? (got.suffix ?? object.size - start);
115
+ const length = got.length ?? got.suffix ?? object.size - start;
116
116
  return new Response(head ? null : (object.body ?? null), {
117
117
  status: 206,
118
118
  headers: headers({
@@ -116,7 +116,7 @@ export function generateMapEmbedComponent(config) {
116
116
  "",
117
117
  "{",
118
118
  " show ? (",
119
- ' <div',
119
+ " <div",
120
120
  ' class="astroid-map"',
121
121
  " data-astroid-map",
122
122
  " data-lat={nlat}",
@@ -159,12 +159,12 @@ export function generateMapEmbedComponent(config) {
159
159
  " })));",
160
160
  "",
161
161
  " const init = async (el: HTMLElement) => {",
162
- ' const lat = Number(el.dataset.lat);',
163
- ' const lng = Number(el.dataset.lng);',
162
+ " const lat = Number(el.dataset.lat);",
163
+ " const lng = Number(el.dataset.lng);",
164
164
  " if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;",
165
165
  "",
166
166
  " const { maplibregl, Protocol } = await load();",
167
- ' // Register the pmtiles:// protocol once so MapLibre range-reads the',
167
+ " // Register the pmtiles:// protocol once so MapLibre range-reads the",
168
168
  " // archive instead of requesting tile URLs.",
169
169
  ' maplibregl.addProtocol("pmtiles", new Protocol().tile);',
170
170
  "",
@@ -172,7 +172,7 @@ export function generateMapEmbedComponent(config) {
172
172
  " container: el,",
173
173
  ' style: JSON.parse(el.dataset.style ?? "{}"),',
174
174
  " center: [lng, lat],",
175
- ' zoom: Number(el.dataset.zoom ?? 15),',
175
+ " zoom: Number(el.dataset.zoom ?? 15),",
176
176
  " // A map that swallows page scroll is a trap on mobile; cooperative",
177
177
  " // gestures require an explicit modifier/two fingers to pan.",
178
178
  " cooperativeGestures: true,",
@@ -182,7 +182,7 @@ export function generateMapEmbedComponent(config) {
182
182
  " });",
183
183
  ' map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");',
184
184
  "",
185
- " const pin = document.createElement(\"div\");",
185
+ ' const pin = document.createElement("div");',
186
186
  " pin.style.cssText =",
187
187
  ` "width:18px;height:18px;border-radius:999px;background:${brand};" +`,
188
188
  ' "border:3px solid #fff;box-shadow:0 0 0 6px rgba(0,0,0,0.12);";',
@@ -58,7 +58,7 @@ export function generateAstroidPortalAuth(config) {
58
58
  " // only thing that applies the DUMMY_REPLACE_ME sentinel check. Built by",
59
59
  " // hand, a fresh deploy with a real EMAIL binding but a placeholder",
60
60
  " // MAIL_FROM read as configured and called the Email API with an envelope",
61
- " // sender of literally \"DUMMY_REPLACE_ME\" — rejected upstream, swallowed",
61
+ ' // sender of literally "DUMMY_REPLACE_ME" — rejected upstream, swallowed',
62
62
  " // here, and reported to the user as a reset email that was sent.",
63
63
  " const mailer = await resolveMailer(env);",
64
64
  " await sendTransactional(mailer, [",
@@ -14,7 +14,7 @@
14
14
  // them (fills real binding ids, secrets, account). `astroid generate` must
15
15
  // NEVER clobber them, or it would wipe provisioned ids — so they live in a
16
16
  // separate function the regenerate path doesn't call.
17
- import { ASTROID_VITALS_BINDING, astroidVitalsDataset, } from "../analytics/index.js";
17
+ import { ASTROID_VITALS_BINDING, astroidVitalsDataset } from "../analytics/index.js";
18
18
  import { astroidCheckoutVars } from "../commerce/checkout-scaffold.js";
19
19
  import { astroidCommerceProviders } from "../commerce/roles.js";
20
20
  import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceSecretNames, } from "../commerce/secrets.js";
@@ -130,7 +130,7 @@ export function generateAstroidWrangler(config) {
130
130
  p(" // `new_classes`) because the session keeps its authoritative state in");
131
131
  p(" // `ctx.storage`, which is the SQLite-backed store — and the storage");
132
132
  p(" // backend cannot be changed after the class is first deployed.");
133
- p(" \"migrations\": [");
133
+ p(' "migrations": [');
134
134
  p(` { "tag": ${JSON.stringify(ASTROID_REALTIME_MIGRATION_TAG)}, "new_sqlite_classes": [${JSON.stringify(ASTROID_EDIT_SESSION_CLASS)}] }`);
135
135
  p(" ],");
136
136
  }
@@ -71,7 +71,7 @@ export function generateAstroidScaffoldFiles(config) {
71
71
  "// here; the generated src/schema.ts re-exports everything from this file, so",
72
72
  "// drizzle-kit sees them and the worker can import them. Empty by default.",
73
73
  "//",
74
- "// e.g. export const redirects = sqliteTable(\"redirects\", { … });",
74
+ '// e.g. export const redirects = sqliteTable("redirects", { … });',
75
75
  "export {};",
76
76
  "",
77
77
  ].join("\n"),
@@ -70,8 +70,18 @@ export function generateWebManifest(config) {
70
70
  // A maskable icon is a separate asset, not a flag on the same file: the
71
71
  // platform crops it to its own shape, so the artwork needs padding the
72
72
  // `any` icon shouldn't have.
73
- { src: "/icons/maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
74
- { src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
73
+ {
74
+ src: "/icons/maskable-192.png",
75
+ sizes: "192x192",
76
+ type: "image/png",
77
+ purpose: "maskable",
78
+ },
79
+ {
80
+ src: "/icons/maskable-512.png",
81
+ sizes: "512x512",
82
+ type: "image/png",
83
+ purpose: "maskable",
84
+ },
75
85
  ],
76
86
  }, null, 2)}\n`;
77
87
  }
@@ -120,7 +120,7 @@ export function generateAstroidQueueSeam(config) {
120
120
  " //",
121
121
  ` // const items = (await listCatalog(token)).map(${provider ?? "provider"}ToCatalogItem);`,
122
122
  ` // const r = await astroidCatalogSync(items, { db: env.DB, table: ${JSON.stringify(table)} });`,
123
- " // if (r.failed > 0) console.warn(\"[catalog] skipped\", r.failed, r.errors);",
123
+ ' // if (r.failed > 0) console.warn("[catalog] skipped", r.failed, r.errors);',
124
124
  " //",
125
125
  " // The sync is idempotent (keyed on the provider's id) and never",
126
126
  " // writes an owner-edited column, so it's safe to run on every event.",
@@ -47,6 +47,19 @@ function pageMediaBase(config) {
47
47
  function resolveSectionCatalog(config) {
48
48
  return config.sectionCatalog ?? astroidSectionCatalog;
49
49
  }
50
+ /**
51
+ * The block catalog a `pages` write is validated + sanitized against (ADR 0005).
52
+ * Defaults to empty, which is correct for a sections-only site: with no section
53
+ * declaring a `blocks` policy, no block is ever reached.
54
+ *
55
+ * It is NOT optional once a site does declare one. Unlike the section catalog
56
+ * there's no built-in fallback to borrow — block types are wholly site-defined —
57
+ * so an unset catalog means every block `_type` reads as unknown and the write
58
+ * 422s, and block rich text goes unsanitized because its def can't be resolved.
59
+ */
60
+ function resolveBlockCatalog(config) {
61
+ return config.blockCatalog ?? {};
62
+ }
50
63
  /**
51
64
  * Return a copy of a `pages` write payload with its `sections` rich-text fields
52
65
  * sanitized against the project media base — a no-op when the write carries no
@@ -61,7 +74,7 @@ export function sanitizeAstroidPageSections(config, data) {
61
74
  if (data.sections === undefined)
62
75
  return data;
63
76
  const mediaBase = pageMediaBase(config);
64
- const sections = sanitizeSectionsRichText(data.sections, resolveSectionCatalog(config), (html) => sanitizeRichHtml(html, { mediaBase }));
77
+ const sections = sanitizeSectionsRichText(data.sections, resolveSectionCatalog(config), (html) => sanitizeRichHtml(html, { mediaBase }), resolveBlockCatalog(config));
65
78
  return { ...data, sections };
66
79
  }
67
80
  /**
@@ -77,6 +90,7 @@ export async function assertAstroidPageSections(config, data, operation = "updat
77
90
  await assertValidSections(resolveSectionCatalog(config), data.sections, {
78
91
  operation,
79
92
  mediaBase: pageMediaBase(config),
93
+ blockCatalog: resolveBlockCatalog(config),
80
94
  });
81
95
  }
82
96
  /**
@@ -11,7 +11,7 @@
11
11
  // the pages routes is wired here — versionsRoute runs it through the collection's
12
12
  // beforeChange hook, and pagesRoute (which takes no collection config) through
13
13
  // the `astroidPagesWriteHooks` spread, so both write paths enforce one contract.
14
- import { ASTROID_VITALS_BINDING, generateAstroidCwvQuery, } from "../analytics/index.js";
14
+ import { ASTROID_VITALS_BINDING, generateAstroidCwvQuery } from "../analytics/index.js";
15
15
  import { astroidEditorTable } from "../auth/index.js";
16
16
  import { astroidPortal } from "../portal/config.js";
17
17
  import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/messages.js";
@@ -194,10 +194,7 @@ export function generateAstroidWorker(config) {
194
194
  p();
195
195
  p("// Editable site_settings columns the Settings panel may write, and which of");
196
196
  p("// them resolve to a media-library asset.");
197
- const settingsImageKeys = [
198
- ...ASTROID_SETTINGS_IMAGE_KEYS,
199
- ...(config.settings?.imageKeys ?? []),
200
- ];
197
+ const settingsImageKeys = [...ASTROID_SETTINGS_IMAGE_KEYS, ...(config.settings?.imageKeys ?? [])];
201
198
  const settingsCustomKeys = config.settings?.customKeys ?? [];
202
199
  // A custom-heavy site can override (or empty) the editable base columns.
203
200
  const settingsColumns = config.settings?.columns ?? ASTROID_SETTINGS_COLUMNS;
@@ -245,11 +242,11 @@ export function generateAstroidWorker(config) {
245
242
  p("async function runHealthScan(env: CloudflareEnv) {");
246
243
  p(" const origin = env.SITE_URL ?? MEDIA_BASE;");
247
244
  p(" const [brokenLinks, missingAlt, seoGaps] = await Promise.all([");
248
- p(" checkLinks({ base: origin, paths: [\"/\"] }).catch(() => []),");
245
+ p(' checkLinks({ base: origin, paths: ["/"] }).catch(() => []),');
249
246
  p(" countRows(env, \"SELECT COUNT(*) AS n FROM media WHERE alt IS NULL OR alt = ''\"),");
250
247
  p(" countRows(");
251
248
  p(" env,");
252
- p(' "SELECT COUNT(*) AS n FROM pages WHERE status = \'published\'" +');
249
+ p(" \"SELECT COUNT(*) AS n FROM pages WHERE status = 'published'\" +");
253
250
  p(" \" AND (seo_title IS NULL OR seo_title = '' OR seo_description IS NULL OR seo_description = '')\",");
254
251
  p(" ),");
255
252
  p(" ]);");
@@ -292,7 +289,7 @@ export function generateAstroidWorker(config) {
292
289
  p(" const row = await env.DB.prepare(");
293
290
  p(' "SELECT" +');
294
291
  p(" \" (SELECT COUNT(*) FROM pages WHERE status = 'draft') AS drafts,\" +");
295
- p(' " (SELECT COUNT(DISTINCT parent_id) FROM pages_versions WHERE status = \'draft\') AS unpublished," +');
292
+ p(" \" (SELECT COUNT(DISTINCT parent_id) FROM pages_versions WHERE status = 'draft') AS unpublished,\" +");
296
293
  p(' " (SELECT MAX(updated_at) FROM pages) AS last_edited",');
297
294
  p(" ).first<{ drafts: number; unpublished: number; last_edited: number | null }>();");
298
295
  p(" if (!row) return undefined;");
@@ -352,7 +349,7 @@ export function generateAstroidWorker(config) {
352
349
  p(" // Wrapped UNCONDITIONALLY, and that is safe: `withEdgeCache` only stores a");
353
350
  p(" // response that carries a cacheable Cloudflare-CDN-Cache-Control directive,");
354
351
  p(" // and a page emits one only via `Astro.cache.set(...)` — which the scaffold");
355
- p(" // gates on ASTROID_EDGE_CACHE being \"true\" AND the request not being in edit");
352
+ p(' // gates on ASTROID_EDGE_CACHE being "true" AND the request not being in edit');
356
353
  p(" // mode. With the var off (the default) every render is `no-store`, so this");
357
354
  p(" // layer stores nothing and is a transparent pass-through.");
358
355
  p(" //");
@@ -41,12 +41,12 @@ export function generateWorkflowSchema(config) {
41
41
  " {",
42
42
  ' id: integer("id").primaryKey({ autoIncrement: true }),',
43
43
  ` ${camel(fk)}: text(${JSON.stringify(fk)}).notNull(),`,
44
- ' /** The stage this row completes (0-based). */',
44
+ " /** The stage this row completes (0-based). */",
45
45
  ' stage: integer("stage").notNull(),',
46
46
  " /** Recorded values for the stage, [{ k, v }] JSON. */",
47
47
  ' specs: text("specs", { mode: "json" }).$type<{ k: string; v: string }[]>(),',
48
48
  ' initials: text("initials").notNull(),',
49
- ' /** Account id when the pipeline runs behind a portal. */',
49
+ " /** Account id when the pipeline runs behind a portal. */",
50
50
  ' actorId: text("actor_id"),',
51
51
  ' signedAt: integer("signed_at", { mode: "timestamp" })',
52
52
  " .notNull()",
@@ -110,7 +110,7 @@ export function generateWorkflowRoute(config) {
110
110
  " specs?: { k: string; v: string }[];",
111
111
  " } | null;",
112
112
  "",
113
- " if (!body?.id || typeof body.stage !== \"number\") {",
113
+ ' if (!body?.id || typeof body.stage !== "number") {',
114
114
  ' return Response.json({ ok: false, error: "Bad request" }, { status: 400 });',
115
115
  " }",
116
116
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "astro",
@@ -17,10 +17,15 @@
17
17
  "url": "git+https://github.com/bowenlabs/louise-toolkit.git",
18
18
  "directory": "packages/astroid"
19
19
  },
20
- "type": "module",
21
20
  "bin": {
22
21
  "astroid": "./bin/astroid.mjs"
23
22
  },
23
+ "files": [
24
+ "dist",
25
+ "bin",
26
+ "src/components"
27
+ ],
28
+ "type": "module",
24
29
  "exports": {
25
30
  ".": {
26
31
  "types": "./dist/index.d.ts",
@@ -51,16 +56,18 @@
51
56
  },
52
57
  "./package.json": "./package.json"
53
58
  },
54
- "files": [
55
- "dist",
56
- "bin",
57
- "src/components"
58
- ],
59
59
  "publishConfig": {
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "louise-toolkit": "0.18.0"
63
+ "louise-toolkit": "0.19.0"
64
+ },
65
+ "devDependencies": {
66
+ "@types/node": "^24.13.3",
67
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
68
+ "solid-js": "^1.9.14",
69
+ "typescript": "^5.8.0",
70
+ "vitest": "^4.1.10"
64
71
  },
65
72
  "peerDependencies": {
66
73
  "solid-js": "^1.9.0"
@@ -70,18 +77,12 @@
70
77
  "optional": true
71
78
  }
72
79
  },
73
- "devDependencies": {
74
- "@types/node": "^24.13.3",
75
- "@typescript/native-preview": "7.0.0-dev.20260707.2",
76
- "solid-js": "^1.9.14",
77
- "typescript": "^5.8.0",
78
- "vitest": "^4.1.10"
79
- },
80
80
  "engines": {
81
81
  "node": ">=24.0.0"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsgo -p tsconfig.build.json",
85
+ "check": "vp check",
85
86
  "test": "vp test",
86
87
  "typecheck": "tsgo --noEmit"
87
88
  }
@@ -30,12 +30,7 @@
30
30
  // import is TYPE-ONLY, so it erases at build and never drags the validator (or
31
31
  // drizzle, which that entry pulls in) into a page bundle.
32
32
 
33
- import type {
34
- SectionCatalog,
35
- SectionDef,
36
- SectionField,
37
- SectionItem,
38
- } from "louise-toolkit/content";
33
+ import type { SectionCatalog, SectionDef, SectionField, SectionItem } from "louise-toolkit/content";
39
34
 
40
35
  export type { SectionCatalog, SectionDef, SectionField, SectionItem };
41
36