astroidjs 0.1.2 → 0.3.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 +240 -5
- package/bin/astroid.mjs +185 -9
- package/dist/analytics/index.d.ts +37 -0
- package/dist/analytics/index.js +108 -0
- package/dist/astro/csp.d.ts +64 -0
- package/dist/astro/csp.js +173 -0
- package/dist/astro/index.d.ts +1 -0
- package/dist/astro/index.js +7 -0
- package/dist/auth/index.d.ts +27 -0
- package/dist/auth/index.js +59 -0
- package/dist/commerce/adapters.d.ts +60 -0
- package/dist/commerce/adapters.js +90 -0
- package/dist/commerce/checkout-scaffold.d.ts +42 -0
- package/dist/commerce/checkout-scaffold.js +306 -0
- package/dist/commerce/checkout.d.ts +72 -0
- package/dist/commerce/checkout.js +124 -0
- package/dist/commerce/index.d.ts +8 -0
- package/dist/commerce/index.js +9 -0
- package/dist/commerce/loader.d.ts +71 -0
- package/dist/commerce/loader.js +90 -0
- package/dist/commerce/mirror.d.ts +69 -0
- package/dist/commerce/mirror.js +214 -0
- package/dist/commerce/roles.d.ts +38 -0
- package/dist/commerce/roles.js +93 -0
- package/dist/commerce/secrets.d.ts +74 -0
- package/dist/commerce/secrets.js +129 -0
- package/dist/commerce/sync.d.ts +86 -0
- package/dist/commerce/sync.js +154 -0
- package/dist/components/sections.d.ts +577 -0
- package/dist/components/sections.js +425 -0
- package/dist/config.d.ts +239 -12
- package/dist/config.js +49 -1
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.js +5 -0
- package/dist/email/inquiry.d.ts +33 -0
- package/dist/email/inquiry.js +63 -0
- package/dist/email/send.d.ts +120 -0
- package/dist/email/send.js +196 -0
- package/dist/email/templates.d.ts +24 -0
- package/dist/email/templates.js +184 -0
- package/dist/email/theme.d.ts +24 -0
- package/dist/email/theme.js +150 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/map/index.d.ts +3 -0
- package/dist/map/index.js +4 -0
- package/dist/map/pmtiles.d.ts +92 -0
- package/dist/map/pmtiles.js +130 -0
- package/dist/map/scaffold.d.ts +29 -0
- package/dist/map/scaffold.js +212 -0
- package/dist/map/style.d.ts +58 -0
- package/dist/map/style.js +154 -0
- package/dist/portal/config.d.ts +26 -0
- package/dist/portal/config.js +56 -0
- package/dist/portal/guard.d.ts +54 -0
- package/dist/portal/guard.js +64 -0
- package/dist/portal/index.d.ts +5 -0
- package/dist/portal/index.js +6 -0
- package/dist/portal/nav.d.ts +26 -0
- package/dist/portal/nav.js +35 -0
- package/dist/portal/scaffold.d.ts +28 -0
- package/dist/portal/scaffold.js +140 -0
- package/dist/portal/session.d.ts +36 -0
- package/dist/portal/session.js +86 -0
- package/dist/portfolio/index.d.ts +1 -0
- package/dist/portfolio/index.js +4 -0
- package/dist/portfolio/scaffold.d.ts +9 -0
- package/dist/portfolio/scaffold.js +93 -0
- package/dist/project/actions.d.ts +3 -0
- package/dist/project/actions.js +121 -0
- package/dist/project/generate.d.ts +15 -0
- package/dist/project/generate.js +144 -2
- package/dist/project/index.d.ts +2 -0
- package/dist/project/index.js +2 -0
- package/dist/project/scaffold.d.ts +29 -0
- package/dist/project/scaffold.js +166 -0
- package/dist/pwa/generate.d.ts +49 -0
- package/dist/pwa/generate.js +218 -0
- package/dist/pwa/index.d.ts +1 -0
- package/dist/pwa/index.js +2 -0
- package/dist/queues/consumer.d.ts +29 -0
- package/dist/queues/consumer.js +37 -0
- package/dist/queues/index.d.ts +4 -0
- package/dist/queues/index.js +5 -0
- package/dist/queues/messages.d.ts +60 -0
- package/dist/queues/messages.js +71 -0
- package/dist/queues/scaffold.d.ts +44 -0
- package/dist/queues/scaffold.js +204 -0
- package/dist/queues/webhook.d.ts +60 -0
- package/dist/queues/webhook.js +81 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +4 -0
- package/dist/realtime/scaffold.d.ts +30 -0
- package/dist/realtime/scaffold.js +159 -0
- package/dist/schema/collections.d.ts +41 -7
- package/dist/schema/collections.js +110 -12
- package/dist/schema/framework.js +5 -0
- package/dist/schema/generate.js +17 -1
- package/dist/secrets.d.ts +54 -0
- package/dist/secrets.js +80 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/rate-rules.d.ts +21 -0
- package/dist/security/rate-rules.js +110 -0
- package/dist/seo/index.d.ts +3 -0
- package/dist/seo/index.js +4 -0
- package/dist/seo/resolve.d.ts +68 -0
- package/dist/seo/resolve.js +73 -0
- package/dist/seo/routes.d.ts +44 -0
- package/dist/seo/routes.js +104 -0
- package/dist/seo/structured-data.d.ts +51 -0
- package/dist/seo/structured-data.js +105 -0
- package/dist/status.d.ts +51 -0
- package/dist/status.js +113 -0
- package/dist/worker/generate.d.ts +18 -10
- package/dist/worker/generate.js +353 -42
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +42 -0
- package/dist/workflow/advance.d.ts +102 -0
- package/dist/workflow/advance.js +145 -0
- package/dist/workflow/config.d.ts +60 -0
- package/dist/workflow/config.js +73 -0
- package/dist/workflow/generate.d.ts +22 -0
- package/dist/workflow/generate.js +138 -0
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.js +4 -0
- package/package.json +21 -4
- package/src/components/Editable.astro +33 -9
- package/src/components/JustifiedGallery.astro +254 -0
- package/src/components/MediaSlot.astro +178 -0
- package/src/components/PortalShell.astro +80 -0
- package/src/components/RegisterSW.astro +45 -0
- package/src/components/Section.astro +101 -35
- package/src/components/Sections.astro +64 -0
- package/src/components/Seo.astro +57 -0
- package/src/components/StageBar.astro +137 -0
- package/src/components/StructuredData.astro +33 -0
- package/src/components/justify.ts +170 -0
- package/src/components/media-meta.ts +174 -0
- package/src/components/sections/AboutIntro.astro +46 -0
- package/src/components/sections/Banner.astro +31 -0
- package/src/components/sections/Contact.astro +22 -9
- package/src/components/sections/Cta.astro +33 -10
- package/src/components/sections/Faq.astro +50 -0
- package/src/components/sections/FeatureGrid.astro +40 -11
- package/src/components/sections/Gallery.astro +46 -0
- package/src/components/sections/Hero.astro +40 -12
- package/src/components/sections/LocationHours.astro +59 -0
- package/src/components/sections/Media.astro +44 -0
- package/src/components/sections/PricingTiers.astro +79 -0
- package/src/components/sections/ProductGrid.astro +73 -0
- package/src/components/sections/SplitImage.astro +61 -0
- package/src/components/sections/Steps.astro +58 -0
- package/src/components/sections/Testimonial.astro +51 -0
- package/src/components/sections.ts +452 -67
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The checkout SEAM — the piece that made `archetype: "storefront"` a storefront
|
|
4
|
+
// that couldn't take a card.
|
|
5
|
+
//
|
|
6
|
+
// Everything around this already existed: `verifyCheckout` re-prices server-side,
|
|
7
|
+
// `checkoutIdempotencyKey` dedupes a double-clicked Pay button, the rate rule on
|
|
8
|
+
// `/api/checkout` was in the middleware, `/checkout` and `/cart` were in the
|
|
9
|
+
// noindex list, and the CSP already allowed Square's Web Payments hosts. What was
|
|
10
|
+
// missing was the route in the middle, so none of it was reachable.
|
|
11
|
+
//
|
|
12
|
+
// SCOPE, deliberately narrow. This generates the server-authoritative PAYMENT
|
|
13
|
+
// path and a card input — not a cart, not a checkout page, not shipping or tax.
|
|
14
|
+
// Where a cart lives (localStorage, D1, a portal session), what it holds, and how
|
|
15
|
+
// it renders are project decisions Astroid has no business making, and a
|
|
16
|
+
// half-opinionated cart is worse than none. What is NOT a project decision is the
|
|
17
|
+
// order of operations that keeps a charge correct, and that is what lives here.
|
|
18
|
+
//
|
|
19
|
+
// Square only, for now, and named rather than pretended-generic: Fourthwall
|
|
20
|
+
// redirects to its own hosted checkout (no card token to charge) and Stripe has
|
|
21
|
+
// no catalog API, so it fills the `invoicing` role rather than `storefront`.
|
|
22
|
+
import { astroidCatalogMirror } from "./mirror.js";
|
|
23
|
+
import { astroidCommerceRoles } from "./roles.js";
|
|
24
|
+
/** Does this project take card payments in-page? Square storefront only. */
|
|
25
|
+
export function usesCardCheckout(config) {
|
|
26
|
+
return astroidCommerceRoles(config.commerce).storefront === "square";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* `src/pages/api/checkout.ts` — the server-authoritative payment route.
|
|
30
|
+
*
|
|
31
|
+
* Scaffold-once: a real store adds shipping, tax, an order record, a receipt
|
|
32
|
+
* email. What Astroid fixes is the sequence, because every step of it is a place
|
|
33
|
+
* where getting it wrong costs money rather than throwing an error.
|
|
34
|
+
*
|
|
35
|
+
* Returns null unless this project takes card payments.
|
|
36
|
+
*/
|
|
37
|
+
export function generateAstroidCheckoutRoute(config) {
|
|
38
|
+
if (!usesCardCheckout(config))
|
|
39
|
+
return null;
|
|
40
|
+
const { table } = astroidCatalogMirror(config);
|
|
41
|
+
return [
|
|
42
|
+
"// Server-authoritative checkout (POST /api/checkout).",
|
|
43
|
+
"//",
|
|
44
|
+
"// Scaffolded once and yours to extend — shipping, tax, an order row, a",
|
|
45
|
+
"// receipt email all belong here. What should NOT change is the ORDER of the",
|
|
46
|
+
"// steps below; each one is load-bearing:",
|
|
47
|
+
"//",
|
|
48
|
+
"// 1. Re-price every line from the D1 catalog mirror. The client's price is",
|
|
49
|
+
"// a STALENESS CHECK, never an input to the charge. Accept `unitPrice`",
|
|
50
|
+
"// from the request body and anyone can buy anything for a penny.",
|
|
51
|
+
"// 2. Refuse on mismatch rather than charging the server's number silently.",
|
|
52
|
+
"// Being charged more than the page said is worse than being asked to",
|
|
53
|
+
"// review the cart.",
|
|
54
|
+
"// 3. Derive the idempotency key from the verified cart AND the cart id, so",
|
|
55
|
+
"// a double-clicked Pay button charges once while two customers buying",
|
|
56
|
+
"// the same thing stay two charges.",
|
|
57
|
+
"// 4. Charge only when commerce is actually provisioned. With placeholder",
|
|
58
|
+
"// secrets this simulates instead — it must never call Square with a",
|
|
59
|
+
"// dummy credential.",
|
|
60
|
+
'import type { APIRoute } from "astro";',
|
|
61
|
+
'import { env } from "cloudflare:workers";',
|
|
62
|
+
"import {",
|
|
63
|
+
" checkoutIdempotencyKey,",
|
|
64
|
+
" readCatalog,",
|
|
65
|
+
" resolveCommerceStatus,",
|
|
66
|
+
" type SecretSource,",
|
|
67
|
+
" verifyCheckout,",
|
|
68
|
+
"} from \"astroidjs\";",
|
|
69
|
+
'import { createPayment } from "louise-toolkit/commerce/square";',
|
|
70
|
+
'import { isSameOrigin } from "louise-toolkit/security";',
|
|
71
|
+
'import astroidConfig from "../../../astroid.config.js";',
|
|
72
|
+
"",
|
|
73
|
+
"export const prerender = false;",
|
|
74
|
+
"",
|
|
75
|
+
"/** Server-side prices, in minor units, straight from the catalog mirror. */",
|
|
76
|
+
"async function serverPrices(variantIds: string[]): Promise<Map<string, number>> {",
|
|
77
|
+
" // `readCatalog` returns the product ARRAY (published only by default).",
|
|
78
|
+
" const items = await readCatalog({",
|
|
79
|
+
" db: env.DB,",
|
|
80
|
+
` table: ${JSON.stringify(table)},`,
|
|
81
|
+
" });",
|
|
82
|
+
" const prices = new Map<string, number>();",
|
|
83
|
+
" for (const item of items) {",
|
|
84
|
+
" // The mirror stores MAJOR units (dollars); the charge is in minor units.",
|
|
85
|
+
" // `Math.round` is not decoration — 19.99 * 100 is 1998.9999999999998, and",
|
|
86
|
+
" // a float cent here fails the exact-equality staleness check on every",
|
|
87
|
+
" // single checkout.",
|
|
88
|
+
" if (variantIds.includes(item.externalId)) {",
|
|
89
|
+
" prices.set(item.externalId, Math.round(item.price * 100));",
|
|
90
|
+
" }",
|
|
91
|
+
" }",
|
|
92
|
+
" return prices;",
|
|
93
|
+
"}",
|
|
94
|
+
"",
|
|
95
|
+
"const json = (body: unknown, status = 200) =>",
|
|
96
|
+
" new Response(JSON.stringify(body), {",
|
|
97
|
+
" status,",
|
|
98
|
+
' headers: { "content-type": "application/json" },',
|
|
99
|
+
" });",
|
|
100
|
+
"",
|
|
101
|
+
"export const POST: APIRoute = async ({ request }) => {",
|
|
102
|
+
" // Same-origin only. Checkout is the one public POST that moves money, so it",
|
|
103
|
+
" // gets the CSRF gate every other public write already has (the contact form,",
|
|
104
|
+
" // the vitals beacon): a cross-origin page must not be able to drive a charge",
|
|
105
|
+
" // or probe prices, even though the card token itself is single-use. Requires",
|
|
106
|
+
" // an Origin/Referer matching the host; a non-browser caller (a stripped",
|
|
107
|
+
" // header) is refused. If you deliberately serve checkout from another origin,",
|
|
108
|
+
" // this is the line to relax — it's yours.",
|
|
109
|
+
" if (!isSameOrigin(request)) return json({ error: \"Forbidden\" }, 403);",
|
|
110
|
+
"",
|
|
111
|
+
" const body = (await request.json().catch(() => null)) as {",
|
|
112
|
+
" lines?: unknown;",
|
|
113
|
+
" cartId?: unknown;",
|
|
114
|
+
" sourceId?: unknown;",
|
|
115
|
+
" verificationToken?: unknown;",
|
|
116
|
+
" email?: unknown;",
|
|
117
|
+
" } | null;",
|
|
118
|
+
" if (!body) return json({ error: \"Invalid JSON\" }, 400);",
|
|
119
|
+
"",
|
|
120
|
+
" // The cart id is the identity half of the idempotency key. It must be high",
|
|
121
|
+
" // entropy and client-generated ONCE per cart (a v4 uuid in localStorage):",
|
|
122
|
+
" // regenerate it per request and a double-click charges twice; make it",
|
|
123
|
+
" // guessable and someone else's identical cart can dedupe into your charge.",
|
|
124
|
+
" const cartId = typeof body.cartId === \"string\" ? body.cartId : \"\";",
|
|
125
|
+
" if (!/^[0-9a-f-]{36}$/i.test(cartId)) {",
|
|
126
|
+
" return json({ error: \"A uuid `cartId` is required\" }, 400);",
|
|
127
|
+
" }",
|
|
128
|
+
"",
|
|
129
|
+
" // 1 + 2: re-price and refuse on mismatch.",
|
|
130
|
+
" const check = await verifyCheckout(body.lines, serverPrices);",
|
|
131
|
+
" if (!check.ok) return json({ error: check.message, reason: check.reason }, 409);",
|
|
132
|
+
"",
|
|
133
|
+
" // 3: stable per cart, distinct per customer.",
|
|
134
|
+
' const idempotencyKey = await checkoutIdempotencyKey(check, "order", cartId);',
|
|
135
|
+
"",
|
|
136
|
+
" // 4: dormant until provisioned. An unconfigured store still re-prices and",
|
|
137
|
+
" // still refuses a stale cart — it just doesn't move money.",
|
|
138
|
+
" // Cast as the toolkit's own `astroidModuleStatus` does: `readSecret`",
|
|
139
|
+
" // accepts a plain string OR a Secrets Store binding, which CloudflareEnv",
|
|
140
|
+
" // types more narrowly than the resolver's `SecretSource` map.",
|
|
141
|
+
" const status = await resolveCommerceStatus(",
|
|
142
|
+
" astroidConfig.commerce,",
|
|
143
|
+
" env as unknown as Record<string, SecretSource>,",
|
|
144
|
+
" );",
|
|
145
|
+
" if (!status.configured) {",
|
|
146
|
+
" console.info(",
|
|
147
|
+
' `[astroid:commerce] simulated checkout — unprovisioned: ${status.missing.join(", ")}`,',
|
|
148
|
+
" );",
|
|
149
|
+
" return json({",
|
|
150
|
+
" simulated: true,",
|
|
151
|
+
" subtotalCents: check.subtotalCents,",
|
|
152
|
+
" idempotencyKey,",
|
|
153
|
+
" });",
|
|
154
|
+
" }",
|
|
155
|
+
"",
|
|
156
|
+
" const sourceId = typeof body.sourceId === \"string\" ? body.sourceId : \"\";",
|
|
157
|
+
' if (!sourceId) return json({ error: "A card token (`sourceId`) is required" }, 400);',
|
|
158
|
+
"",
|
|
159
|
+
" // Both are guaranteed real by the dormancy gate above; the narrowing here",
|
|
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",
|
|
162
|
+
" // would otherwise silently select the sandbox host in production.",
|
|
163
|
+
' const environment = env.SQUARE_ENVIRONMENT === "production" ? "production" : "sandbox";',
|
|
164
|
+
" const payment = await createPayment(",
|
|
165
|
+
" {",
|
|
166
|
+
' accessToken: env.SQUARE_ACCESS_TOKEN ?? "",',
|
|
167
|
+
" environment,",
|
|
168
|
+
" },",
|
|
169
|
+
" {",
|
|
170
|
+
" sourceId,",
|
|
171
|
+
" // The SERVER's number, never the client's.",
|
|
172
|
+
' amountMoney: { amount: check.subtotalCents, currency: "USD" },',
|
|
173
|
+
' locationId: env.SQUARE_LOCATION_ID ?? "",',
|
|
174
|
+
" idempotencyKey,",
|
|
175
|
+
" ...(typeof body.verificationToken === \"string\"",
|
|
176
|
+
" ? { verificationToken: body.verificationToken }",
|
|
177
|
+
" : {}),",
|
|
178
|
+
' ...(typeof body.email === "string" ? { buyerEmailAddress: body.email } : {}),',
|
|
179
|
+
" },",
|
|
180
|
+
" );",
|
|
181
|
+
"",
|
|
182
|
+
" // TODO(you): persist an order row, send a receipt, clear the cart.",
|
|
183
|
+
" return json({",
|
|
184
|
+
" paymentId: payment.id,",
|
|
185
|
+
" status: payment.status,",
|
|
186
|
+
" receiptUrl: payment.receiptUrl,",
|
|
187
|
+
" });",
|
|
188
|
+
"};",
|
|
189
|
+
"",
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* `src/components/SquareCard.astro` — the card input.
|
|
194
|
+
*
|
|
195
|
+
* Square's Web Payments SDK renders the field in an iframe from their CDN and
|
|
196
|
+
* hands back a single-use token, so the raw card number never touches the Worker
|
|
197
|
+
* and the site stays out of PCI scope. That is the entire reason this is a
|
|
198
|
+
* component and not an `<input>`.
|
|
199
|
+
*
|
|
200
|
+
* Returns null unless this project takes card payments.
|
|
201
|
+
*/
|
|
202
|
+
export function generateAstroidSquareCard(config) {
|
|
203
|
+
if (!usesCardCheckout(config))
|
|
204
|
+
return null;
|
|
205
|
+
return [
|
|
206
|
+
"---",
|
|
207
|
+
"// Square Web Payments card input.",
|
|
208
|
+
"//",
|
|
209
|
+
"// The card field is an IFRAME served by Square's CDN — the raw number never",
|
|
210
|
+
"// enters this page's DOM and never reaches the Worker, which is what keeps the",
|
|
211
|
+
"// site out of PCI scope. `tokenize()` returns a single-use token; POST it to",
|
|
212
|
+
"// /api/checkout, which re-prices server-side before charging.",
|
|
213
|
+
"//",
|
|
214
|
+
"// The CSP already allows Square's hosts (astroidSecurity adds them when",
|
|
215
|
+
"// commerce is on), so no policy change is needed.",
|
|
216
|
+
'import { env } from "cloudflare:workers";',
|
|
217
|
+
"",
|
|
218
|
+
"// The PUBLIC application id — safe in the browser, unlike the access token.",
|
|
219
|
+
"// Absent (an unprovisioned store) → render nothing rather than a dead form.",
|
|
220
|
+
"const appId = env.SQUARE_APP_ID;",
|
|
221
|
+
"const locationId = env.SQUARE_LOCATION_ID;",
|
|
222
|
+
'const environment = env.SQUARE_ENVIRONMENT ?? "sandbox";',
|
|
223
|
+
"const ready = Boolean(appId && locationId);",
|
|
224
|
+
"---",
|
|
225
|
+
"",
|
|
226
|
+
"{",
|
|
227
|
+
" ready ? (",
|
|
228
|
+
" <div",
|
|
229
|
+
' id="square-card-host"',
|
|
230
|
+
" data-app-id={appId}",
|
|
231
|
+
" data-location-id={locationId}",
|
|
232
|
+
" data-environment={environment}",
|
|
233
|
+
" >",
|
|
234
|
+
' <div id="square-card" />',
|
|
235
|
+
" </div>",
|
|
236
|
+
" ) : (",
|
|
237
|
+
' <p class="text-sm opacity-70">',
|
|
238
|
+
" Card payments are not configured yet — set SQUARE_APP_ID and",
|
|
239
|
+
" SQUARE_LOCATION_ID.",
|
|
240
|
+
" </p>",
|
|
241
|
+
" )",
|
|
242
|
+
"}",
|
|
243
|
+
"",
|
|
244
|
+
"{/* Processed (NOT is:inline) so Astro's security.csp hashes it into",
|
|
245
|
+
" script-src. Per-request values ride on the host element's data-* rather",
|
|
246
|
+
" than a define:vars script, which could not be hashed. */}",
|
|
247
|
+
"<script>",
|
|
248
|
+
' import { mountCard } from "louise-toolkit/commerce/square-web";',
|
|
249
|
+
"",
|
|
250
|
+
' const host = document.getElementById("square-card-host");',
|
|
251
|
+
" if (host) {",
|
|
252
|
+
" mountCard(",
|
|
253
|
+
' host.dataset.appId ?? "",',
|
|
254
|
+
' host.dataset.locationId ?? "",',
|
|
255
|
+
' host.dataset.environment ?? "sandbox",',
|
|
256
|
+
' "#square-card",',
|
|
257
|
+
" )",
|
|
258
|
+
" .then((card) => {",
|
|
259
|
+
" // Expose the handle so your checkout form can tokenize on submit:",
|
|
260
|
+
" // const token = await window.__squareCard.tokenize();",
|
|
261
|
+
" // then POST { lines, cartId, sourceId: token } to /api/checkout.",
|
|
262
|
+
" (window as unknown as { __squareCard?: unknown }).__squareCard = card;",
|
|
263
|
+
' host.dispatchEvent(new CustomEvent("square-card-ready", { bubbles: true }));',
|
|
264
|
+
" })",
|
|
265
|
+
" .catch((err) => {",
|
|
266
|
+
' console.error("[astroid:commerce] card input failed to mount", err);',
|
|
267
|
+
" });",
|
|
268
|
+
" }",
|
|
269
|
+
"</script>",
|
|
270
|
+
"",
|
|
271
|
+
].join("\n");
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The wrangler `vars` the card input needs, or `[]`.
|
|
275
|
+
*
|
|
276
|
+
* PUBLIC values, so they are vars rather than entries in the secret roster: the
|
|
277
|
+
* application id is shipped to the browser by design, and the environment is a
|
|
278
|
+
* choice, not a credential. Putting them in `credentials` would also fold them
|
|
279
|
+
* into the dormancy gate, which is about whether the module can safely CALL
|
|
280
|
+
* Square — a different question from whether the card field can render.
|
|
281
|
+
*/
|
|
282
|
+
export function astroidCheckoutVars(config) {
|
|
283
|
+
if (!usesCardCheckout(config))
|
|
284
|
+
return [];
|
|
285
|
+
return [
|
|
286
|
+
// 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
|
+
];
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The `CloudflareEnv` members the card input adds, as a block `create-astroid`
|
|
294
|
+
* substitutes into `src/env.d.ts`. Empty without card checkout.
|
|
295
|
+
*/
|
|
296
|
+
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");
|
|
306
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** One line as the CLIENT sent it. Every field is untrusted. */
|
|
2
|
+
export interface ClientLine {
|
|
3
|
+
/** Provider id of the variant/item being bought. */
|
|
4
|
+
variantId: string;
|
|
5
|
+
quantity: number;
|
|
6
|
+
/**
|
|
7
|
+
* The unit price, in minor units, that the customer was SHOWN. Compared
|
|
8
|
+
* against the server's price; never used to compute the charge.
|
|
9
|
+
*/
|
|
10
|
+
unitPriceCents: number;
|
|
11
|
+
}
|
|
12
|
+
/** A line after the server has re-priced it. */
|
|
13
|
+
export interface VerifiedLine {
|
|
14
|
+
variantId: string;
|
|
15
|
+
quantity: number;
|
|
16
|
+
/** The server's price. This is what gets charged. */
|
|
17
|
+
unitPriceCents: number;
|
|
18
|
+
subtotalCents: number;
|
|
19
|
+
}
|
|
20
|
+
export type CheckoutVerification = {
|
|
21
|
+
ok: true;
|
|
22
|
+
lines: VerifiedLine[];
|
|
23
|
+
subtotalCents: number;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
reason: "empty" | "unavailable" | "price-changed" | "invalid";
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
/** Look up current prices, in minor units, keyed by variant id. Anything the
|
|
30
|
+
* map omits is treated as no longer purchasable. */
|
|
31
|
+
export type PriceLookup = (variantIds: string[]) => Promise<Map<string, number>>;
|
|
32
|
+
/**
|
|
33
|
+
* Re-price a cart against the provider and decide whether it may proceed.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* const check = await verifyCheckout(body.lines, (ids) => serverPrices(env, ids));
|
|
37
|
+
* if (!check.ok) return json({ error: check.message }, 409);
|
|
38
|
+
* await charge(check.subtotalCents); // the SERVER's number
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare function verifyCheckout(lines: unknown, lookup: PriceLookup): Promise<CheckoutVerification>;
|
|
42
|
+
/**
|
|
43
|
+
* A deterministic idempotency key for one buyer's checkout attempt.
|
|
44
|
+
*
|
|
45
|
+
* Providers dedupe on this, so the same key must mean the same charge — which
|
|
46
|
+
* cuts both ways, and the second direction is the one that costs money. It is
|
|
47
|
+
* derived from the verified cart *and* `identity`, not from a random value or a
|
|
48
|
+
* timestamp: a customer double-clicking Pay sends the same key twice and is
|
|
49
|
+
* charged once, while a customer who changes their cart pays under a different
|
|
50
|
+
* key and is charged correctly.
|
|
51
|
+
*
|
|
52
|
+
* **`identity` is required, and it is what makes the key safe.** Without it the
|
|
53
|
+
* key was a pure function of the cart contents, so two DIFFERENT customers
|
|
54
|
+
* buying the same thing for the same price produced byte-identical keys. Stripe
|
|
55
|
+
* and Square scope idempotency keys per account and retain them for ~24h, so the
|
|
56
|
+
* provider replayed the first customer's PaymentIntent instead of creating the
|
|
57
|
+
* second's: the second buyer was never charged, no second order existed, and the
|
|
58
|
+
* site reported success. On a single-SKU storefront that is ordinary traffic,
|
|
59
|
+
* not an edge case.
|
|
60
|
+
*
|
|
61
|
+
* Pass something stable across a retry of THIS attempt and distinct between
|
|
62
|
+
* buyers — a cart id, a checkout-session id, or a portal user id. Do not pass a
|
|
63
|
+
* value that varies per request (a fresh uuid defeats the dedupe and a
|
|
64
|
+
* double-click charges twice), and do not pass a constant.
|
|
65
|
+
*
|
|
66
|
+
* `scope` remains the OPERATION — `"order"` vs `"refund"` — so the two can never
|
|
67
|
+
* collide for one buyer. It is not an identity and never was.
|
|
68
|
+
*/
|
|
69
|
+
export declare function checkoutIdempotencyKey(verified: {
|
|
70
|
+
lines: VerifiedLine[];
|
|
71
|
+
subtotalCents: number;
|
|
72
|
+
}, scope: string, identity: string): Promise<string>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Server-authoritative checkout.
|
|
4
|
+
//
|
|
5
|
+
// A cart arrives from the browser, so every number in it is a claim, not a fact.
|
|
6
|
+
// The rule this encodes — taken from coracle.coffee's working checkout — is that
|
|
7
|
+
// the client's price is a **staleness check**, never an input to the charge:
|
|
8
|
+
// look the price up server-side, and if it disagrees with what the customer was
|
|
9
|
+
// shown, refuse rather than silently charging a different amount. Refusing is
|
|
10
|
+
// the customer-friendly branch too; being charged more than the page said is
|
|
11
|
+
// worse than being asked to review the cart.
|
|
12
|
+
//
|
|
13
|
+
// The failure this prevents is not exotic. Accept `unitPrice` from the request
|
|
14
|
+
// body and anyone can buy anything for a penny.
|
|
15
|
+
import { AstroidUsageError } from "../errors.js";
|
|
16
|
+
const MAX_QUANTITY = 999;
|
|
17
|
+
/**
|
|
18
|
+
* Re-price a cart against the provider and decide whether it may proceed.
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const check = await verifyCheckout(body.lines, (ids) => serverPrices(env, ids));
|
|
22
|
+
* if (!check.ok) return json({ error: check.message }, 409);
|
|
23
|
+
* await charge(check.subtotalCents); // the SERVER's number
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export async function verifyCheckout(lines, lookup) {
|
|
27
|
+
if (!Array.isArray(lines) || lines.length === 0) {
|
|
28
|
+
return { ok: false, reason: "empty", message: "Your cart is empty." };
|
|
29
|
+
}
|
|
30
|
+
const parsed = [];
|
|
31
|
+
for (const raw of lines) {
|
|
32
|
+
const l = raw;
|
|
33
|
+
// A non-integer or negative quantity is the other half of the price
|
|
34
|
+
// exploit: a quantity of -1 turns a charge into a refund on some providers.
|
|
35
|
+
if (typeof l?.variantId !== "string" ||
|
|
36
|
+
!l.variantId ||
|
|
37
|
+
typeof l.quantity !== "number" ||
|
|
38
|
+
!Number.isInteger(l.quantity) ||
|
|
39
|
+
l.quantity < 1 ||
|
|
40
|
+
l.quantity > MAX_QUANTITY ||
|
|
41
|
+
typeof l.unitPriceCents !== "number" ||
|
|
42
|
+
!Number.isFinite(l.unitPriceCents)) {
|
|
43
|
+
return { ok: false, reason: "invalid", message: "That cart isn't valid." };
|
|
44
|
+
}
|
|
45
|
+
parsed.push({ variantId: l.variantId, quantity: l.quantity, unitPriceCents: l.unitPriceCents });
|
|
46
|
+
}
|
|
47
|
+
const prices = await lookup([...new Set(parsed.map((l) => l.variantId))]);
|
|
48
|
+
const verified = [];
|
|
49
|
+
let subtotalCents = 0;
|
|
50
|
+
for (const line of parsed) {
|
|
51
|
+
const serverPrice = prices.get(line.variantId);
|
|
52
|
+
if (serverPrice === undefined) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: "unavailable",
|
|
56
|
+
message: "An item in your cart is no longer available.",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (serverPrice !== line.unitPriceCents) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: "price-changed",
|
|
63
|
+
message: "Prices changed — please review your cart.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const lineSubtotal = serverPrice * line.quantity;
|
|
67
|
+
verified.push({
|
|
68
|
+
variantId: line.variantId,
|
|
69
|
+
quantity: line.quantity,
|
|
70
|
+
unitPriceCents: serverPrice,
|
|
71
|
+
subtotalCents: lineSubtotal,
|
|
72
|
+
});
|
|
73
|
+
subtotalCents += lineSubtotal;
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, lines: verified, subtotalCents };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A deterministic idempotency key for one buyer's checkout attempt.
|
|
79
|
+
*
|
|
80
|
+
* Providers dedupe on this, so the same key must mean the same charge — which
|
|
81
|
+
* cuts both ways, and the second direction is the one that costs money. It is
|
|
82
|
+
* derived from the verified cart *and* `identity`, not from a random value or a
|
|
83
|
+
* timestamp: a customer double-clicking Pay sends the same key twice and is
|
|
84
|
+
* charged once, while a customer who changes their cart pays under a different
|
|
85
|
+
* key and is charged correctly.
|
|
86
|
+
*
|
|
87
|
+
* **`identity` is required, and it is what makes the key safe.** Without it the
|
|
88
|
+
* key was a pure function of the cart contents, so two DIFFERENT customers
|
|
89
|
+
* buying the same thing for the same price produced byte-identical keys. Stripe
|
|
90
|
+
* and Square scope idempotency keys per account and retain them for ~24h, so the
|
|
91
|
+
* provider replayed the first customer's PaymentIntent instead of creating the
|
|
92
|
+
* second's: the second buyer was never charged, no second order existed, and the
|
|
93
|
+
* site reported success. On a single-SKU storefront that is ordinary traffic,
|
|
94
|
+
* not an edge case.
|
|
95
|
+
*
|
|
96
|
+
* Pass something stable across a retry of THIS attempt and distinct between
|
|
97
|
+
* buyers — a cart id, a checkout-session id, or a portal user id. Do not pass a
|
|
98
|
+
* value that varies per request (a fresh uuid defeats the dedupe and a
|
|
99
|
+
* double-click charges twice), and do not pass a constant.
|
|
100
|
+
*
|
|
101
|
+
* `scope` remains the OPERATION — `"order"` vs `"refund"` — so the two can never
|
|
102
|
+
* collide for one buyer. It is not an identity and never was.
|
|
103
|
+
*/
|
|
104
|
+
export async function checkoutIdempotencyKey(verified, scope, identity) {
|
|
105
|
+
// Empty is rejected rather than defaulted: a falsy identity would silently
|
|
106
|
+
// restore the collision this parameter exists to close, and a charge that
|
|
107
|
+
// goes missing is not something the caller finds out about.
|
|
108
|
+
if (typeof identity !== "string" || identity.trim().length === 0) {
|
|
109
|
+
throw new AstroidUsageError("checkoutIdempotencyKey requires a non-empty `identity` (a cart id, checkout-session id, " +
|
|
110
|
+
"or user id). Without it the key is a function of the cart alone, so two customers " +
|
|
111
|
+
"buying the same items collide and the second is never charged.");
|
|
112
|
+
}
|
|
113
|
+
const canonical = JSON.stringify({
|
|
114
|
+
scope,
|
|
115
|
+
identity,
|
|
116
|
+
total: verified.subtotalCents,
|
|
117
|
+
lines: verified.lines.map((l) => `${l.variantId}:${l.quantity}:${l.unitPriceCents}`).sort(),
|
|
118
|
+
});
|
|
119
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
|
120
|
+
return [...new Uint8Array(digest)]
|
|
121
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
122
|
+
.join("")
|
|
123
|
+
.slice(0, 40);
|
|
124
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { catalogNormalizer, type FourthwallProductLike, fourthwallToCatalogItem, type SquareItemLike, squareToCatalogItem, } from "./adapters.js";
|
|
2
|
+
export { type CheckoutVerification, checkoutIdempotencyKey, type ClientLine, type PriceLookup, verifyCheckout, type VerifiedLine, } from "./checkout.js";
|
|
3
|
+
export { astroidCatalogLoaderConfig, type CatalogDatabase, type CatalogProduct, type CatalogReadOptions, readCatalog, readCatalogItem, } from "./loader.js";
|
|
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";
|
|
6
|
+
export { assertCommerceRoles, astroidCommerceProviders, astroidCommerceRoles, type CommerceRole, hasStorefront, PROVIDER_ROLES, type ResolvedCommerceRoles, } from "./roles.js";
|
|
7
|
+
export { astroidCatalogSync, astroidCatalogUpsert, type CatalogItem, type CatalogSyncOptions, type CatalogSyncResult, defaultSlug, type SyncDatabase, } from "./sync.js";
|
|
8
|
+
export { astroidCheckoutVars, generateAstroidCheckoutEnv, generateAstroidCheckoutRoute, generateAstroidSquareCard, usesCardCheckout, } from "./checkout-scaffold.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
export { catalogNormalizer, fourthwallToCatalogItem, squareToCatalogItem, } from "./adapters.js";
|
|
3
|
+
export { checkoutIdempotencyKey, verifyCheckout, } from "./checkout.js";
|
|
4
|
+
export { astroidCatalogLoaderConfig, readCatalog, readCatalogItem, } from "./loader.js";
|
|
5
|
+
export { astroidCatalogMirror, BUILT_IN_OWNED, generateCatalogMigrationSql, generateCatalogTable, PULLED_COLUMNS, } from "./mirror.js";
|
|
6
|
+
export { COMMERCE_PROVIDER_SECRETS, commerceSecretNames, resolveCommerceStatus, } from "./secrets.js";
|
|
7
|
+
export { assertCommerceRoles, astroidCommerceProviders, astroidCommerceRoles, hasStorefront, PROVIDER_ROLES, } from "./roles.js";
|
|
8
|
+
export { astroidCatalogSync, astroidCatalogUpsert, defaultSlug, } from "./sync.js";
|
|
9
|
+
export { astroidCheckoutVars, generateAstroidCheckoutEnv, generateAstroidCheckoutRoute, generateAstroidSquareCard, usesCardCheckout, } from "./checkout-scaffold.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CatalogItem } from "./sync.js";
|
|
2
|
+
/** A product as the site renders it: the mirror row, decoded. */
|
|
3
|
+
export interface CatalogProduct extends CatalogItem {
|
|
4
|
+
/** Public URL segment — owner-owned, stable across provider renames. */
|
|
5
|
+
slug: string;
|
|
6
|
+
status: "draft" | "published";
|
|
7
|
+
sortOrder: number;
|
|
8
|
+
featured: boolean;
|
|
9
|
+
/** Owned columns this project added, passed through untyped. */
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
/** The D1 surface the read needs. Structural, so a real `D1Database` fits. */
|
|
13
|
+
export interface CatalogDatabase {
|
|
14
|
+
prepare(query: string): {
|
|
15
|
+
bind(...values: unknown[]): {
|
|
16
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
17
|
+
all<T = Record<string, unknown>>(): Promise<{
|
|
18
|
+
results: T[];
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
all<T = Record<string, unknown>>(): Promise<{
|
|
22
|
+
results: T[];
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface CatalogReadOptions {
|
|
27
|
+
db: CatalogDatabase;
|
|
28
|
+
table: string;
|
|
29
|
+
/**
|
|
30
|
+
* Include `draft` rows. Off by default: new remote items land as draft
|
|
31
|
+
* precisely so they don't appear until someone approves them, and a read that
|
|
32
|
+
* ignored that would undo the point of the status column. Turn it on for the
|
|
33
|
+
* editor's own views.
|
|
34
|
+
*/
|
|
35
|
+
includeDrafts?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read the catalog. Ordered by `sortOrder` then name, so an owner's manual
|
|
39
|
+
* ordering wins and everything they haven't ordered stays stable rather than
|
|
40
|
+
* shuffling per query.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readCatalog(options: CatalogReadOptions): Promise<CatalogProduct[]>;
|
|
43
|
+
/** Read one product by its public slug. Null when absent or still a draft. */
|
|
44
|
+
export declare function readCatalogItem(slug: string, options: CatalogReadOptions): Promise<CatalogProduct | null>;
|
|
45
|
+
/**
|
|
46
|
+
* The config to hand `defineCatalogLoader`, wired to the mirror.
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* // src/loaders/catalog.ts
|
|
50
|
+
* import { defineCatalogLoader } from "louise-toolkit/astro";
|
|
51
|
+
* import { astroidCatalogLoaderConfig } from "astroidjs";
|
|
52
|
+
*
|
|
53
|
+
* export const catalogLoader = defineCatalogLoader(
|
|
54
|
+
* astroidCatalogLoaderConfig({ db: env.DB, table: "products" }),
|
|
55
|
+
* );
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* Identical for every provider — which is the whole point.
|
|
59
|
+
*/
|
|
60
|
+
export declare function astroidCatalogLoaderConfig(options: CatalogReadOptions & {
|
|
61
|
+
name?: string;
|
|
62
|
+
}): {
|
|
63
|
+
name: string;
|
|
64
|
+
loadCatalog: (filter?: {
|
|
65
|
+
featured?: boolean;
|
|
66
|
+
}) => Promise<{
|
|
67
|
+
items: CatalogProduct[];
|
|
68
|
+
}>;
|
|
69
|
+
loadItem: (id: string) => Promise<CatalogProduct | null>;
|
|
70
|
+
idOf: (item: CatalogProduct) => string;
|
|
71
|
+
};
|