astroidjs 0.10.0 → 0.12.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.
@@ -1,4 +1,5 @@
1
- import type { AstroidConfig, CspOrigins } from "../config.js";
1
+ import type { AstroidConfig } from "../config.js";
2
+ export { astroidCspOrigins } from "../security/csp-origins.js";
2
3
  /** A `sha256-…` CSP hash, in the shape Astro's `security.csp.hashes` takes. */
3
4
  export type CspHash = `${"sha256" | "sha384" | "sha512"}-${string}`;
4
5
  /**
@@ -38,12 +39,6 @@ export interface AstroidSecurityConfig {
38
39
  * hash on the next build.
39
40
  */
40
41
  export declare function solidHydrationHash(): CspHash;
41
- /**
42
- * Every origin the project's enabled modules need, merged with the config's own
43
- * `security.cspOrigins`. Exported so a site (or `astroid doctor`) can inspect
44
- * what a config implies without rebuilding the whole policy.
45
- */
46
- export declare function astroidCspOrigins(config: AstroidConfig): Required<CspOrigins>;
47
42
  /**
48
43
  * The `security` block for `astro.config.mjs` — Astro's half of the split.
49
44
  *
@@ -61,4 +56,3 @@ export declare function astroidSecurity(config: AstroidConfig): AstroidSecurityC
61
56
  export declare const ASTROID_VITE_BUILD: {
62
57
  readonly assetsInlineLimit: 0;
63
58
  };
64
- export {};
package/dist/astro/csp.js CHANGED
@@ -27,7 +27,8 @@
27
27
  // imports.
28
28
  import { createHash } from "node:crypto";
29
29
  import { generateHydrationScript } from "solid-js/web";
30
- import { astroidCommerceProviders } from "../commerce/roles.js";
30
+ import { astroidCspOrigins } from "../security/csp-origins.js";
31
+ export { astroidCspOrigins } from "../security/csp-origins.js";
31
32
  /**
32
33
  * Hash of Solid's inline hydration bootstrap.
33
34
  *
@@ -42,80 +43,6 @@ export function solidHydrationHash() {
42
43
  const inner = generateHydrationScript().match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1] ?? "";
43
44
  return `sha256-${createHash("sha256").update(inner).digest("base64")}`;
44
45
  }
45
- /** Origins a commerce provider's client-side SDK needs. Server-only providers
46
- * (Fourthwall's storefront API) contribute nothing but their image host. */
47
- const COMMERCE_ORIGINS = {
48
- // Square Web Payments renders the card form in an iframe from the squarecdn
49
- // hosts, tokenizes against pci-connect, and pulls its own fonts. Both the
50
- // sandbox and production hosts are listed so ONE build serves either
51
- // environment — which environment you're in is a runtime secret, not a
52
- // build-time one.
53
- square: {
54
- script: ["https://sandbox.web.squarecdn.com", "https://web.squarecdn.com"],
55
- frame: [
56
- "https://sandbox.web.squarecdn.com",
57
- "https://web.squarecdn.com",
58
- "https://connect.squareupsandbox.com",
59
- "https://connect.squareup.com",
60
- ],
61
- connect: [
62
- "https://pci-connect.squareupsandbox.com",
63
- "https://pci-connect.squareup.com",
64
- "https://sandbox.web.squarecdn.com",
65
- "https://web.squarecdn.com",
66
- ],
67
- font: [
68
- "https://square-fonts-production-f.squarecdn.com",
69
- "https://d1g145x70srn7h.cloudfront.net",
70
- ],
71
- },
72
- // Stripe.js and its Elements/Checkout iframes.
73
- stripe: {
74
- script: ["https://js.stripe.com"],
75
- frame: ["https://js.stripe.com", "https://hooks.stripe.com"],
76
- connect: ["https://api.stripe.com"],
77
- },
78
- // Fourthwall is read server-side; nothing runs in the browser.
79
- fourthwall: {},
80
- };
81
- // Turnstile. Always allowed, not gated on the captcha being configured: the
82
- // scaffold ships the widget dormant (see the dormant-until-provisioned
83
- // convention) and it must not need a rebuild to switch on — CSP is baked at
84
- // build time, the secret is a runtime value.
85
- const TURNSTILE = {
86
- script: ["https://challenges.cloudflare.com"],
87
- frame: ["https://challenges.cloudflare.com"],
88
- connect: ["https://challenges.cloudflare.com"],
89
- };
90
- // The map module. MapLibre spins its tile-decoding workers up from blob: URLs,
91
- // so `worker-src blob:` is not optional — without it the map renders an empty
92
- // canvas and the console fills with worker-construction errors.
93
- //
94
- // Nothing else is needed, and that's the whole argument for the self-hosted
95
- // basemap: the PMTiles archive is served same-origin, so `connect-src` stays
96
- // `'self'` with no tile host and no API key to allow.
97
- const MAP = { worker: ["blob:"] };
98
- const DIRECTIVE_KEYS = ["script", "frame", "connect", "font", "img", "worker"];
99
- /** Merge origin lists, de-duplicated, order preserved. */
100
- function mergeOrigins(...sets) {
101
- const out = {};
102
- for (const key of DIRECTIVE_KEYS) {
103
- out[key] = [...new Set(sets.flatMap((set) => set[key] ?? []))];
104
- }
105
- return out;
106
- }
107
- /**
108
- * Every origin the project's enabled modules need, merged with the config's own
109
- * `security.cspOrigins`. Exported so a site (or `astroid doctor`) can inspect
110
- * what a config implies without rebuilding the whole policy.
111
- */
112
- export function astroidCspOrigins(config) {
113
- return mergeOrigins(TURNSTILE, ...((config.modules ?? []).includes("map") ? [MAP] : []),
114
- // EVERY provider in play, not "the" provider: a site can run Stripe for
115
- // invoicing beside Fourthwall for the storefront, and a policy that allowed
116
- // only one of them blocks the other's SDK at runtime.
117
- ...astroidCommerceProviders(config.commerce).map((p) => COMMERCE_ORIGINS[p] ?? {}), config.security?.cspOrigins ?? {});
118
- }
119
46
  /**
120
47
  * Render one directive. The name is a literal from the union above, so the
121
48
  * concatenation is a valid `CspDirective` by construction — which is what the
@@ -231,7 +231,11 @@ export function generateAstroidCheckoutRoute(config) {
231
231
  " const check = await verifyCheckout(body.lines, serverPrices, {",
232
232
  " scope: { locationId },",
233
233
  " });",
234
- " if (!check.ok) return json({ error: check.message, reason: check.reason }, 409);",
234
+ " // Every stale line, with its live price — feed `issues` to `repairCart`",
235
+ " // (louise-toolkit/commerce) to fix the whole cart in one step.",
236
+ " if (!check.ok) {",
237
+ " return json({ error: check.message, reason: check.reason, issues: check.issues }, 409);",
238
+ " }",
235
239
  "",
236
240
  " // 3: stable per cart, distinct per customer.",
237
241
  ' const idempotencyKey = await checkoutIdempotencyKey(check, "order", cartId);',
@@ -239,7 +243,11 @@ export function generateAstroidCheckoutRoute(config) {
239
243
  : [
240
244
  " // 1 + 2: re-price and refuse on mismatch.",
241
245
  " const check = await verifyCheckout(body.lines, serverPrices);",
242
- " if (!check.ok) return json({ error: check.message, reason: check.reason }, 409);",
246
+ " // Every stale line, with its live price — feed `issues` to `repairCart`",
247
+ " // (louise-toolkit/commerce) to fix the whole cart in one step.",
248
+ " if (!check.ok) {",
249
+ " return json({ error: check.message, reason: check.reason, issues: check.issues }, 409);",
250
+ " }",
243
251
  "",
244
252
  " // 3: stable per cart, distinct per customer.",
245
253
  ' const idempotencyKey = await checkoutIdempotencyKey(check, "order", cartId);',
@@ -1,3 +1,4 @@
1
+ import { type CartIssue } from "louise-toolkit/commerce";
1
2
  /** One line as the CLIENT sent it. Every field is untrusted. */
2
3
  export interface ClientLine {
3
4
  /** Provider id of the variant/item being bought. */
@@ -32,14 +33,30 @@ export interface VerifiedLine {
32
33
  * them and guessing would put the wrong sentence on the screen.
33
34
  */
34
35
  export type CheckoutRefusal = "empty" | "unavailable" | "out-of-stock" | "price-changed" | "invalid";
36
+ /**
37
+ * One way the cart disagrees with the live catalog — the toolkit's
38
+ * `CartIssue`, less the add-on case (checkout lines carry no add-ons yet).
39
+ * Hand the list to `repairCart` from `louise-toolkit/commerce` to fix the cart
40
+ * in one step.
41
+ */
42
+ export type CheckoutIssue = Exclude<CartIssue, {
43
+ kind: "modifier-unavailable";
44
+ }>;
35
45
  export type CheckoutVerification = {
36
46
  ok: true;
37
47
  lines: VerifiedLine[];
38
48
  subtotalCents: number;
39
49
  } | {
40
50
  ok: false;
51
+ /** The first problem, in cart order. */
41
52
  reason: CheckoutRefusal;
42
53
  message: string;
54
+ /**
55
+ * Every problem, in cart order, with what the catalog says now — empty
56
+ * for `"empty"` and `"invalid"`, which are about the request, not the
57
+ * catalog.
58
+ */
59
+ issues: CheckoutIssue[];
43
60
  };
44
61
  /** Look up current prices, in minor units, keyed by variant id. Anything the
45
62
  * map omits is treated as no longer purchasable. */
@@ -12,8 +12,36 @@
12
12
  //
13
13
  // The failure this prevents is not exotic. Accept `unitPrice` from the request
14
14
  // body and anyone can buy anything for a penny.
15
+ //
16
+ // The comparison itself is the toolkit's `cartIssues`, which reports EVERY
17
+ // stale line rather than the first: a refusal that names one problem at a time
18
+ // is how a customer ends up fixing a line, retrying, and being refused over the
19
+ // next. What this adds is the opinion — policing the untrusted body, and the
20
+ // sentence a customer sees.
21
+ import { cartIssues } from "louise-toolkit/commerce";
15
22
  import { AstroidUsageError } from "../errors.js";
16
23
  const MAX_QUANTITY = 999;
24
+ /** What a customer reads, by issue kind: one line affected, or several. */
25
+ const ISSUE_MESSAGES = {
26
+ "out-of-stock": ["An item in your cart just sold out.", "Some items in your cart just sold out."],
27
+ unavailable: [
28
+ "An item in your cart is no longer available.",
29
+ "Some items in your cart are no longer available.",
30
+ ],
31
+ "price-changed": [
32
+ "Prices changed — please review your cart.",
33
+ "Prices changed — please review your cart.",
34
+ ],
35
+ };
36
+ /** One sentence for the whole refusal. Mixed kinds get a sentence that covers them all. */
37
+ function issueMessage(issues) {
38
+ const kinds = new Set(issues.map((i) => i.kind));
39
+ const [kind] = kinds;
40
+ if (kinds.size > 1 || !kind)
41
+ return "Your cart changed since you filled it — please review it.";
42
+ const [one, many] = ISSUE_MESSAGES[kind];
43
+ return issues.length > 1 ? many : one;
44
+ }
17
45
  /** Accept either lookup shape without making every caller branch. */
18
46
  function normalizeLookup(result) {
19
47
  if (result instanceof Map)
@@ -42,7 +70,7 @@ export async function verifyCheckout(lines,
42
70
  // and a union of call signatures would reject the two-argument call below.
43
71
  lookup, options = {}) {
44
72
  if (!Array.isArray(lines) || lines.length === 0) {
45
- return { ok: false, reason: "empty", message: "Your cart is empty." };
73
+ return { ok: false, reason: "empty", message: "Your cart is empty.", issues: [] };
46
74
  }
47
75
  const parsed = [];
48
76
  for (const raw of lines) {
@@ -57,48 +85,25 @@ lookup, options = {}) {
57
85
  l.quantity > MAX_QUANTITY ||
58
86
  typeof l.unitPriceCents !== "number" ||
59
87
  !Number.isFinite(l.unitPriceCents)) {
60
- return { ok: false, reason: "invalid", message: "That cart isn't valid." };
88
+ return { ok: false, reason: "invalid", message: "That cart isn't valid.", issues: [] };
61
89
  }
62
90
  parsed.push({ variantId: l.variantId, quantity: l.quantity, unitPriceCents: l.unitPriceCents });
63
91
  }
64
92
  const { prices, outOfStock } = normalizeLookup(await lookup([...new Set(parsed.map((l) => l.variantId))], options.scope));
65
- const verified = [];
66
- let subtotalCents = 0;
67
- for (const line of parsed) {
68
- // Checked before the price, because a sold-out variant is usually still
69
- // priced: reading `prices` first would report it as available and let the
70
- // charge through.
71
- if (outOfStock.has(line.variantId)) {
72
- return {
73
- ok: false,
74
- reason: "out-of-stock",
75
- message: "An item in your cart just sold out.",
76
- };
77
- }
78
- const serverPrice = prices.get(line.variantId);
79
- if (serverPrice === undefined) {
80
- return {
81
- ok: false,
82
- reason: "unavailable",
83
- message: "An item in your cart is no longer available.",
84
- };
85
- }
86
- if (serverPrice !== line.unitPriceCents) {
87
- return {
88
- ok: false,
89
- reason: "price-changed",
90
- message: "Prices changed — please review your cart.",
91
- };
92
- }
93
- const lineSubtotal = serverPrice * line.quantity;
94
- verified.push({
95
- variantId: line.variantId,
96
- quantity: line.quantity,
97
- unitPriceCents: serverPrice,
98
- subtotalCents: lineSubtotal,
99
- });
100
- subtotalCents += lineSubtotal;
101
- }
93
+ // No `liveModifierIds`, so the add-on check is skipped and every issue is a
94
+ // variant one — which is what makes the narrowing to CheckoutIssue true.
95
+ const issues = cartIssues(parsed, { prices, outOfStock });
96
+ const [first] = issues;
97
+ if (first)
98
+ return { ok: false, reason: first.kind, message: issueMessage(issues), issues };
99
+ // Every line is now known to be priced, and at the price the customer saw.
100
+ const verified = parsed.map((line) => ({
101
+ variantId: line.variantId,
102
+ quantity: line.quantity,
103
+ unitPriceCents: line.unitPriceCents,
104
+ subtotalCents: line.unitPriceCents * line.quantity,
105
+ }));
106
+ const subtotalCents = verified.reduce((sum, l) => sum + l.subtotalCents, 0);
102
107
  return { ok: true, lines: verified, subtotalCents };
103
108
  }
104
109
  /**
@@ -1,5 +1,5 @@
1
1
  export { catalogNormalizer, type FourthwallProductLike, fourthwallToCatalogItem, type SquareAdapterOptions, squareItemSoldAt, type SquareItemLike, type SquareLocationOverrideLike, type SquarePresenceLike, squareToCatalogItem, } from "./adapters.js";
2
- export { type CheckoutRefusal, type CheckoutScope, type CheckoutVerification, checkoutIdempotencyKey, type ClientLine, type PriceLookup, type ScopedPriceLookup, type ScopedPrices, verifyCheckout, type VerifiedLine, type VerifyCheckoutOptions, } from "./checkout.js";
2
+ export { type CheckoutIssue, type CheckoutRefusal, type CheckoutScope, type CheckoutVerification, checkoutIdempotencyKey, type ClientLine, type PriceLookup, type ScopedPriceLookup, type ScopedPrices, verifyCheckout, type VerifiedLine, type VerifyCheckoutOptions, } 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
5
  export { COMMERCE_PROVIDER_SECRETS, type CommerceStatus, commerceSecretNames, type ProviderStatus, providerConfigured, resolveCommerceStatus, roleConfigured, } from "./secrets.js";
package/dist/config.d.ts CHANGED
@@ -378,6 +378,8 @@ export interface SecurityConfig {
378
378
  /** Per-directive origin lists contributed to the CSP. */
379
379
  export interface CspOrigins {
380
380
  script?: string[];
381
+ /** Rendered by the generated middleware (it owns `style-src`), not by Astro. */
382
+ style?: string[];
381
383
  frame?: string[];
382
384
  connect?: string[];
383
385
  font?: string[];
@@ -103,7 +103,13 @@ export function generateAstroidWrangler(config) {
103
103
  p(" // Pin your account so deploys don't prompt (or set CLOUDFLARE_ACCOUNT_ID).");
104
104
  p(' // "account_id": "<your-cloudflare-account-id>",');
105
105
  p(` "compatibility_date": ${JSON.stringify(COMPATIBILITY_DATE)},`);
106
- p(' "compatibility_flags": ["nodejs_compat"],');
106
+ // global_fetch_strictly_public (ADR 0012): a fetch to the site's own zone goes
107
+ // in through Cloudflare's front door, past the WAF, like any Internet request.
108
+ p(" // global_fetch_strictly_public: a fetch to this zone goes through Cloudflare's");
109
+ p(" // front door like any Internet request, past the WAF and bot rules (ADR 0012).");
110
+ p(" // If the daily health scan starts reporting broken links that aren't, a zone");
111
+ p(" // rule is challenging its self-crawl — allow it rather than drop the flag.");
112
+ p(' "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],');
107
113
  p(" // @astrojs/cloudflare builds this entry and wires the static assets under dist/.");
108
114
  p(' "main": "src/worker.ts",');
109
115
  const tenancy = config.tenancy;
@@ -0,0 +1,16 @@
1
+ import type { AstroidConfig, CspOrigins } from "../config.js";
2
+ /**
3
+ * Every origin the project's enabled modules need, merged with the config's own
4
+ * `security.cspOrigins`. Exported so a site (or `astroid doctor`) can inspect
5
+ * what a config implies without rebuilding the whole policy.
6
+ */
7
+ export declare function astroidCspOrigins(config: AstroidConfig): Required<CspOrigins>;
8
+ /**
9
+ * The `style-src` value the generated middleware rewrites every response to.
10
+ *
11
+ * `'unsafe-inline'` is required (Louise's data-driven `style=""` carriers and
12
+ * the editor's runtime `<style>`), which is why Astro can't own this directive:
13
+ * per spec a single hash here voids `'unsafe-inline'`. Module and config
14
+ * `style` origins ride along, e.g. the Square SDK's own stylesheet.
15
+ */
16
+ export declare function astroidCspStyleSrc(config: AstroidConfig): string;
@@ -0,0 +1,76 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The CSP origins a config implies, as plain data.
4
+ //
5
+ // Split out of `astro/csp.ts` (build-time only: `node:crypto`, `solid-js/web`)
6
+ // so the worker generator can read it too: Astro's `security.csp` renders every
7
+ // directive except `style-src`, and the generated middleware renders that one.
8
+ // Both have to see the same origin set, or a module's stylesheet host lands in
9
+ // one half of the policy and not the other.
10
+ import { squareWebPaymentsCsp } from "louise-toolkit/commerce/square-web";
11
+ import { turnstileCsp } from "louise-toolkit/forms/turnstile";
12
+ import { astroidCommerceProviders } from "../commerce/roles.js";
13
+ /** Origins a commerce provider's client-side SDK needs. Server-only providers
14
+ * (Fourthwall's storefront API) contribute nothing but their image host. */
15
+ const COMMERCE_ORIGINS = {
16
+ // Square Web Payments, from the toolkit that mounts it, so a host Square adds
17
+ // arrives with an upgrade instead of as a console violation on every site. A
18
+ // hand copy lived here and had already missed two (Square's Sentry ingest,
19
+ // and Cash Sans's font host). Both environments by default, so ONE build
20
+ // serves either: which environment you're in is a runtime secret, not a
21
+ // build-time one.
22
+ square: squareWebPaymentsCsp(),
23
+ // Stripe.js and its Elements/Checkout iframes.
24
+ stripe: {
25
+ script: ["https://js.stripe.com"],
26
+ frame: ["https://js.stripe.com", "https://hooks.stripe.com"],
27
+ connect: ["https://api.stripe.com"],
28
+ },
29
+ // Fourthwall is read server-side; nothing runs in the browser.
30
+ fourthwall: {},
31
+ };
32
+ // Turnstile. Always allowed, not gated on the captcha being configured: the
33
+ // scaffold ships the widget dormant (see the dormant-until-provisioned
34
+ // convention) and it must not need a rebuild to switch on — CSP is baked at
35
+ // build time, the secret is a runtime value.
36
+ const TURNSTILE = turnstileCsp();
37
+ // The map module. MapLibre spins its tile-decoding workers up from blob: URLs,
38
+ // so `worker-src blob:` is not optional — without it the map renders an empty
39
+ // canvas and the console fills with worker-construction errors.
40
+ //
41
+ // Nothing else is needed, and that's the whole argument for the self-hosted
42
+ // basemap: the PMTiles archive is served same-origin, so `connect-src` stays
43
+ // `'self'` with no tile host and no API key to allow.
44
+ const MAP = { worker: ["blob:"] };
45
+ const DIRECTIVE_KEYS = ["script", "style", "frame", "connect", "font", "img", "worker"];
46
+ /** Merge origin lists, de-duplicated, order preserved. */
47
+ function mergeOrigins(...sets) {
48
+ const out = {};
49
+ for (const key of DIRECTIVE_KEYS) {
50
+ out[key] = [...new Set(sets.flatMap((set) => set[key] ?? []))];
51
+ }
52
+ return out;
53
+ }
54
+ /**
55
+ * Every origin the project's enabled modules need, merged with the config's own
56
+ * `security.cspOrigins`. Exported so a site (or `astroid doctor`) can inspect
57
+ * what a config implies without rebuilding the whole policy.
58
+ */
59
+ export function astroidCspOrigins(config) {
60
+ return mergeOrigins(TURNSTILE, ...((config.modules ?? []).includes("map") ? [MAP] : []),
61
+ // EVERY provider in play, not "the" provider: a site can run Stripe for
62
+ // invoicing beside Fourthwall for the storefront, and a policy that allowed
63
+ // only one of them blocks the other's SDK at runtime.
64
+ ...astroidCommerceProviders(config.commerce).map((p) => COMMERCE_ORIGINS[p] ?? {}), config.security?.cspOrigins ?? {});
65
+ }
66
+ /**
67
+ * The `style-src` value the generated middleware rewrites every response to.
68
+ *
69
+ * `'unsafe-inline'` is required (Louise's data-driven `style=""` carriers and
70
+ * the editor's runtime `<style>`), which is why Astro can't own this directive:
71
+ * per spec a single hash here voids `'unsafe-inline'`. Module and config
72
+ * `style` origins ride along, e.g. the Square SDK's own stylesheet.
73
+ */
74
+ export function astroidCspStyleSrc(config) {
75
+ return ["'self'", "'unsafe-inline'", ...astroidCspOrigins(config).style].join(" ");
76
+ }
@@ -17,6 +17,7 @@ import { astroidPortal } from "../portal/config.js";
17
17
  import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/messages.js";
18
18
  import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, usesRealtime, } from "../realtime/scaffold.js";
19
19
  import { capturesInquiries } from "../schema/framework.js";
20
+ import { astroidCspStyleSrc } from "../security/csp-origins.js";
20
21
  import { ASTROID_REWRITE_EXCLUDE, ASTROID_TENANT_PREFIX } from "../tenancy/index.js";
21
22
  import { astroidEditorRoutePlan } from "./routes.js";
22
23
  // Astroid's default editable site_settings surface — the columns the Settings
@@ -369,6 +370,13 @@ export function generateAstroidWorker(config) {
369
370
  ? "export default composeWorker<CloudflareEnv, AstroidQueueMessage>({"
370
371
  : "export default composeWorker<CloudflareEnv>({");
371
372
  p(" routes: [...editorRoutes, mediaAssetRoute],");
373
+ p(" // Deny-by-default editor API (ADR 0012). Under /api/louise a request must");
374
+ p(" // resolve to an editor unless it's headed for a public route — the contact");
375
+ p(" // form and the vitals beacon mark themselves. Every route above still checks");
376
+ p(" // for itself; this is what catches one that forgets. It also gives route");
377
+ p(" // responses the security headers the middleware never sees, since these");
378
+ p(" // routes answer before Astro runs.");
379
+ p(" gate: { resolveEditor },");
372
380
  p(" // The SSR fallback, wrapped in the cookie-aware Worker cache (ADR 0004).");
373
381
  p(" //");
374
382
  p(" // Wrapped UNCONDITIONALLY, and that is safe: `withEdgeCache` only stores a");
@@ -470,8 +478,10 @@ export function generateAstroidWorker(config) {
470
478
  export function generateAstroidMiddleware(config) {
471
479
  // Louise's brand font is bundled + base64-inlined (no Google Fonts host to
472
480
  // allow); createLouiseMiddleware auto-allows `data:` fonts in the CSP, so the
473
- // inlined @font-face needs no manual `font-src` entry.
474
- const cspStyleSrc = "'self' 'unsafe-inline'";
481
+ // inlined @font-face needs no manual `font-src` entry. Module + config `style`
482
+ // origins (the Square SDK's stylesheet) are baked in here; `astroid build`
483
+ // regenerates this file, so a config change lands on the next deploy.
484
+ const cspStyleSrc = astroidCspStyleSrc(config);
475
485
  const portal = astroidPortal(config);
476
486
  const tenancy = config.tenancy;
477
487
  const rewritePrefix = tenancy?.rewritePrefix ?? ASTROID_TENANT_PREFIX;
@@ -538,6 +548,10 @@ export function generateAstroidMiddleware(config) {
538
548
  "export const onRequest = createLouiseMiddleware({",
539
549
  " resolveEditor: (request) => resolveEditor(request),",
540
550
  " rateLimit: { rules: RATE_RULES, kv: () => env.RL },",
551
+ " // The same deny-by-default gate for /api/louise routes that reach Astro —",
552
+ " // anything the worker's routes didn't answer. A second check behind the",
553
+ " // worker's gate, and free: the editor is resolved here on every request.",
554
+ " apiGate: true,",
541
555
  // `extend` runs once and may need to populate BOTH — a tenanted site with a
542
556
  // portal resolves a tenant and a customer on the same request.
543
557
  ...(portal || tenancy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.10.0",
3
+ "version": "0.12.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",
@@ -60,7 +60,7 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "louise-toolkit": "^0.28.0"
63
+ "louise-toolkit": "^0.30.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.13.3",