astroidjs 0.10.1 → 0.12.1

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.
@@ -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";
@@ -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;
@@ -7,38 +7,19 @@
7
7
  // directive except `style-src`, and the generated middleware renders that one.
8
8
  // Both have to see the same origin set, or a module's stylesheet host lands in
9
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";
10
12
  import { astroidCommerceProviders } from "../commerce/roles.js";
11
13
  /** Origins a commerce provider's client-side SDK needs. Server-only providers
12
14
  * (Fourthwall's storefront API) contribute nothing but their image host. */
13
15
  const COMMERCE_ORIGINS = {
14
- // Square Web Payments renders the card form in an iframe from the squarecdn
15
- // hosts, tokenizes against pci-connect, and pulls its own fonts. Both the
16
- // sandbox and production hosts are listed so ONE build serves either
17
- // environment — which environment you're in is a runtime secret, not a
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
18
21
  // build-time one.
19
- square: {
20
- script: ["https://sandbox.web.squarecdn.com", "https://web.squarecdn.com"],
21
- frame: [
22
- "https://sandbox.web.squarecdn.com",
23
- "https://web.squarecdn.com",
24
- "https://connect.squareupsandbox.com",
25
- "https://connect.squareup.com",
26
- ],
27
- connect: [
28
- "https://pci-connect.squareupsandbox.com",
29
- "https://pci-connect.squareup.com",
30
- "https://sandbox.web.squarecdn.com",
31
- "https://web.squarecdn.com",
32
- ],
33
- // Web Payments SDK 1.85+ attaches its own `card-wrapper.css` to the HOST
34
- // page (not just inside the iframe). Block it and `card.attach()` rejects,
35
- // so the card form never mounts.
36
- style: ["https://sandbox.web.squarecdn.com", "https://web.squarecdn.com"],
37
- font: [
38
- "https://square-fonts-production-f.squarecdn.com",
39
- "https://d1g145x70srn7h.cloudfront.net",
40
- ],
41
- },
22
+ square: squareWebPaymentsCsp(),
42
23
  // Stripe.js and its Elements/Checkout iframes.
43
24
  stripe: {
44
25
  script: ["https://js.stripe.com"],
@@ -52,11 +33,7 @@ const COMMERCE_ORIGINS = {
52
33
  // scaffold ships the widget dormant (see the dormant-until-provisioned
53
34
  // convention) and it must not need a rebuild to switch on — CSP is baked at
54
35
  // build time, the secret is a runtime value.
55
- const TURNSTILE = {
56
- script: ["https://challenges.cloudflare.com"],
57
- frame: ["https://challenges.cloudflare.com"],
58
- connect: ["https://challenges.cloudflare.com"],
59
- };
36
+ const TURNSTILE = turnstileCsp();
60
37
  // The map module. MapLibre spins its tile-decoding workers up from blob: URLs,
61
38
  // so `worker-src blob:` is not optional — without it the map renders an empty
62
39
  // canvas and the console fills with worker-construction errors.
@@ -370,6 +370,13 @@ export function generateAstroidWorker(config) {
370
370
  ? "export default composeWorker<CloudflareEnv, AstroidQueueMessage>({"
371
371
  : "export default composeWorker<CloudflareEnv>({");
372
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 },");
373
380
  p(" // The SSR fallback, wrapped in the cookie-aware Worker cache (ADR 0004).");
374
381
  p(" //");
375
382
  p(" // Wrapped UNCONDITIONALLY, and that is safe: `withEdgeCache` only stores a");
@@ -541,6 +548,10 @@ export function generateAstroidMiddleware(config) {
541
548
  "export const onRequest = createLouiseMiddleware({",
542
549
  " resolveEditor: (request) => resolveEditor(request),",
543
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,",
544
555
  // `extend` runs once and may need to populate BOTH — a tenanted site with a
545
556
  // portal resolves a tenant and a customer on the same request.
546
557
  ...(portal || tenancy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.10.1",
3
+ "version": "0.12.1",
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,11 +60,12 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "louise-toolkit": "^0.28.0"
63
+ "louise-toolkit": "^0.31.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.13.3",
67
67
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
68
+ "@vitest/coverage-v8": "4.1.11",
68
69
  "astro": "^7.2.9",
69
70
  "solid-js": "^1.9.15",
70
71
  "typescript": "^5.9.3",
@@ -87,7 +88,7 @@
87
88
  },
88
89
  "scripts": {
89
90
  "build": "tsgo -p tsconfig.build.json",
90
- "test": "vitest run",
91
+ "test": "vitest run --coverage",
91
92
  "typecheck": "tsgo --noEmit"
92
93
  }
93
94
  }