astroidjs 0.1.1 → 0.2.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.
Files changed (154) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/commerce/adapters.d.ts +60 -0
  10. package/dist/commerce/adapters.js +90 -0
  11. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  12. package/dist/commerce/checkout-scaffold.js +306 -0
  13. package/dist/commerce/checkout.d.ts +72 -0
  14. package/dist/commerce/checkout.js +124 -0
  15. package/dist/commerce/index.d.ts +8 -0
  16. package/dist/commerce/index.js +9 -0
  17. package/dist/commerce/loader.d.ts +71 -0
  18. package/dist/commerce/loader.js +90 -0
  19. package/dist/commerce/mirror.d.ts +67 -0
  20. package/dist/commerce/mirror.js +203 -0
  21. package/dist/commerce/roles.d.ts +38 -0
  22. package/dist/commerce/roles.js +93 -0
  23. package/dist/commerce/secrets.d.ts +74 -0
  24. package/dist/commerce/secrets.js +129 -0
  25. package/dist/commerce/sync.d.ts +86 -0
  26. package/dist/commerce/sync.js +154 -0
  27. package/dist/components/sections.d.ts +577 -0
  28. package/dist/components/sections.js +425 -0
  29. package/dist/config.d.ts +174 -12
  30. package/dist/config.js +43 -1
  31. package/dist/email/index.d.ts +4 -0
  32. package/dist/email/index.js +5 -0
  33. package/dist/email/inquiry.d.ts +33 -0
  34. package/dist/email/inquiry.js +63 -0
  35. package/dist/email/send.d.ts +120 -0
  36. package/dist/email/send.js +196 -0
  37. package/dist/email/templates.d.ts +24 -0
  38. package/dist/email/templates.js +184 -0
  39. package/dist/email/theme.d.ts +24 -0
  40. package/dist/email/theme.js +150 -0
  41. package/dist/errors.d.ts +14 -0
  42. package/dist/errors.js +17 -0
  43. package/dist/index.d.ts +14 -0
  44. package/dist/index.js +14 -0
  45. package/dist/map/index.d.ts +3 -0
  46. package/dist/map/index.js +4 -0
  47. package/dist/map/pmtiles.d.ts +92 -0
  48. package/dist/map/pmtiles.js +130 -0
  49. package/dist/map/scaffold.d.ts +29 -0
  50. package/dist/map/scaffold.js +212 -0
  51. package/dist/map/style.d.ts +58 -0
  52. package/dist/map/style.js +154 -0
  53. package/dist/portal/config.d.ts +26 -0
  54. package/dist/portal/config.js +50 -0
  55. package/dist/portal/guard.d.ts +48 -0
  56. package/dist/portal/guard.js +64 -0
  57. package/dist/portal/index.d.ts +5 -0
  58. package/dist/portal/index.js +6 -0
  59. package/dist/portal/nav.d.ts +26 -0
  60. package/dist/portal/nav.js +35 -0
  61. package/dist/portal/scaffold.d.ts +28 -0
  62. package/dist/portal/scaffold.js +140 -0
  63. package/dist/portal/session.d.ts +36 -0
  64. package/dist/portal/session.js +86 -0
  65. package/dist/portfolio/index.d.ts +1 -0
  66. package/dist/portfolio/index.js +4 -0
  67. package/dist/portfolio/scaffold.d.ts +9 -0
  68. package/dist/portfolio/scaffold.js +93 -0
  69. package/dist/project/actions.d.ts +3 -0
  70. package/dist/project/actions.js +106 -0
  71. package/dist/project/generate.d.ts +15 -0
  72. package/dist/project/generate.js +144 -2
  73. package/dist/project/index.d.ts +2 -0
  74. package/dist/project/index.js +2 -0
  75. package/dist/project/scaffold.d.ts +29 -0
  76. package/dist/project/scaffold.js +140 -0
  77. package/dist/pwa/generate.d.ts +49 -0
  78. package/dist/pwa/generate.js +218 -0
  79. package/dist/pwa/index.d.ts +1 -0
  80. package/dist/pwa/index.js +2 -0
  81. package/dist/queues/consumer.d.ts +29 -0
  82. package/dist/queues/consumer.js +37 -0
  83. package/dist/queues/index.d.ts +4 -0
  84. package/dist/queues/index.js +5 -0
  85. package/dist/queues/messages.d.ts +60 -0
  86. package/dist/queues/messages.js +71 -0
  87. package/dist/queues/scaffold.d.ts +44 -0
  88. package/dist/queues/scaffold.js +204 -0
  89. package/dist/queues/webhook.d.ts +60 -0
  90. package/dist/queues/webhook.js +81 -0
  91. package/dist/realtime/index.d.ts +1 -0
  92. package/dist/realtime/index.js +4 -0
  93. package/dist/realtime/scaffold.d.ts +30 -0
  94. package/dist/realtime/scaffold.js +159 -0
  95. package/dist/schema/collections.d.ts +42 -8
  96. package/dist/schema/collections.js +102 -8
  97. package/dist/schema/generate.js +10 -1
  98. package/dist/secrets.d.ts +54 -0
  99. package/dist/secrets.js +80 -0
  100. package/dist/security/index.d.ts +1 -0
  101. package/dist/security/index.js +2 -0
  102. package/dist/security/rate-rules.d.ts +21 -0
  103. package/dist/security/rate-rules.js +107 -0
  104. package/dist/seo/index.d.ts +3 -0
  105. package/dist/seo/index.js +4 -0
  106. package/dist/seo/resolve.d.ts +68 -0
  107. package/dist/seo/resolve.js +73 -0
  108. package/dist/seo/routes.d.ts +44 -0
  109. package/dist/seo/routes.js +104 -0
  110. package/dist/seo/structured-data.d.ts +51 -0
  111. package/dist/seo/structured-data.js +105 -0
  112. package/dist/status.d.ts +51 -0
  113. package/dist/status.js +113 -0
  114. package/dist/worker/generate.d.ts +18 -10
  115. package/dist/worker/generate.js +325 -37
  116. package/dist/worker/routes.d.ts +1 -1
  117. package/dist/worker/routes.js +42 -0
  118. package/dist/workflow/advance.d.ts +102 -0
  119. package/dist/workflow/advance.js +145 -0
  120. package/dist/workflow/config.d.ts +60 -0
  121. package/dist/workflow/config.js +73 -0
  122. package/dist/workflow/generate.d.ts +22 -0
  123. package/dist/workflow/generate.js +138 -0
  124. package/dist/workflow/index.d.ts +3 -0
  125. package/dist/workflow/index.js +4 -0
  126. package/package.json +21 -5
  127. package/src/components/Editable.astro +33 -9
  128. package/src/components/JustifiedGallery.astro +254 -0
  129. package/src/components/MediaSlot.astro +178 -0
  130. package/src/components/PortalShell.astro +80 -0
  131. package/src/components/RegisterSW.astro +45 -0
  132. package/src/components/Section.astro +101 -35
  133. package/src/components/Sections.astro +64 -0
  134. package/src/components/Seo.astro +57 -0
  135. package/src/components/StageBar.astro +137 -0
  136. package/src/components/StructuredData.astro +33 -0
  137. package/src/components/justify.ts +170 -0
  138. package/src/components/media-meta.ts +174 -0
  139. package/src/components/sections/AboutIntro.astro +46 -0
  140. package/src/components/sections/Banner.astro +31 -0
  141. package/src/components/sections/Contact.astro +22 -9
  142. package/src/components/sections/Cta.astro +33 -10
  143. package/src/components/sections/Faq.astro +50 -0
  144. package/src/components/sections/FeatureGrid.astro +40 -11
  145. package/src/components/sections/Gallery.astro +46 -0
  146. package/src/components/sections/Hero.astro +40 -12
  147. package/src/components/sections/LocationHours.astro +59 -0
  148. package/src/components/sections/Media.astro +44 -0
  149. package/src/components/sections/PricingTiers.astro +79 -0
  150. package/src/components/sections/ProductGrid.astro +73 -0
  151. package/src/components/sections/SplitImage.astro +61 -0
  152. package/src/components/sections/Steps.astro +58 -0
  153. package/src/components/sections/Testimonial.astro +51 -0
  154. package/src/components/sections.ts +452 -67
@@ -0,0 +1,204 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The two SCAFFOLD-ONCE files the queue pipeline needs: the consumer seam
4
+ // (`src/queue.ts`) and the provider webhook receiver.
5
+ //
6
+ // Deliberately not part of the regenerated trio. Both exist to be edited — the
7
+ // consumer is where a project says what a catalog refresh actually does, and the
8
+ // webhook route is where it narrows which events it cares about. Regenerating
9
+ // over them would erase exactly the work they're for. The same boundary
10
+ // `generateAstroidWrangler` already lives on.
11
+ import { astroidCatalogMirror } from "../commerce/mirror.js";
12
+ import { astroidCommerceProviders, astroidCommerceRoles } from "../commerce/roles.js";
13
+ import { COMMERCE_PROVIDER_SECRETS } from "../commerce/secrets.js";
14
+ import { ASTROID_QUEUE_BINDING } from "./messages.js";
15
+ /**
16
+ * Per-provider webhook facts: the header, the verifier, and how it's called.
17
+ *
18
+ * The signing-secret NAME is deliberately not here — it's one field of a
19
+ * provider's secret set, which `commerce/secrets.ts` owns so the wrangler
20
+ * generator, the status report, and this scaffold all read the same list.
21
+ */
22
+ const PROVIDERS = {
23
+ square: {
24
+ module: "louise-toolkit/commerce/square",
25
+ verifier: "verifySquareSignature",
26
+ header: "x-square-hmacsha256-signature",
27
+ // Square signs the notification URL CONCATENATED with the body, so the URL
28
+ // has to match what's configured in the Square dashboard exactly.
29
+ call: "verifySquareSignature(url.href, raw, headers.get(HEADER), secret)",
30
+ note: "Square signs `notificationUrl + body`, so url.href must match the endpoint you registered.",
31
+ },
32
+ stripe: {
33
+ module: "louise-toolkit/commerce/stripe",
34
+ verifier: "verifyStripeSignature",
35
+ header: "stripe-signature",
36
+ // Stripe's header carries a timestamp; the verifier rejects replays outside
37
+ // its tolerance, so it needs the current time.
38
+ call: 'verifyStripeSignature(raw, headers.get(HEADER) ?? "", secret, Math.floor(Date.now() / 1000))',
39
+ note: "Stripe's signature is timestamped — the verifier rejects replays outside a 5-minute tolerance.",
40
+ },
41
+ fourthwall: {
42
+ module: "louise-toolkit/commerce/fourthwall",
43
+ verifier: "verifyFourthwallSignature",
44
+ header: "x-fourthwall-hmac-sha256",
45
+ call: "verifyFourthwallSignature(raw, headers.get(HEADER), secret)",
46
+ note: "Fourthwall signs the raw body only.",
47
+ },
48
+ };
49
+ /**
50
+ * The extra `CloudflareEnv` members the queue pipeline introduces, as a block
51
+ * `create-astroid` substitutes into the scaffolded `src/env.d.ts`.
52
+ *
53
+ * Substituted rather than always present because a declaration is a promise: a
54
+ * marketing site that types `COMMERCE_QUEUE` is claiming a binding its
55
+ * `wrangler.jsonc` never creates, and the first `env.COMMERCE_QUEUE.send()`
56
+ * someone writes against that type fails at runtime with the type system's
57
+ * blessing. Empty string when the project runs no consumer.
58
+ */
59
+ export function generateAstroidEnvBindings(config) {
60
+ const providers = astroidCommerceProviders(config.commerce);
61
+ if (providers.length === 0)
62
+ return "";
63
+ return [
64
+ " /** Queue producer — verified webhooks + the cron re-sync (src/queue.ts). */",
65
+ ' COMMERCE_QUEUE: Queue<import("astroidjs").AstroidQueueMessage>;',
66
+ // One secret SET per provider: a site running Stripe for invoicing and
67
+ // Fourthwall for the storefront talks to both, credentialed and signed
68
+ // independently. Every one is optional, because every one is allowed to be
69
+ // absent — that's what leaves the module dormant rather than broken.
70
+ ...providers.flatMap((provider) => [
71
+ ` /** ${provider} API credentials. Absent or still holding the`,
72
+ " * DUMMY_REPLACE_ME sentinel reads as unconfigured, which leaves commerce",
73
+ " * dormant: the D1 mirror still serves, nothing calls upstream. */",
74
+ ...COMMERCE_PROVIDER_SECRETS[provider].credentials.map((name) => ` ${name}?: string;`),
75
+ ` /** ${provider} webhook signing secret. Until it holds a real value the`,
76
+ " * receiver answers 503, so the provider keeps retrying and events",
77
+ " * delivered before you provisioned it still land afterwards. */",
78
+ ` ${COMMERCE_PROVIDER_SECRETS[provider].webhook}?: string;`,
79
+ ]),
80
+ ].join("\n");
81
+ }
82
+ /**
83
+ * `src/queue.ts` — the consumer seam the generated worker imports.
84
+ *
85
+ * `astroidQueueHandler` already owns the dispatch every site wrote (periodic
86
+ * refresh, catalog-affecting webhook, no-op for everything else); what's left
87
+ * for the project is what "refresh" means, which is why this is a file and not
88
+ * a generated constant.
89
+ */
90
+ export function generateAstroidQueueSeam(config) {
91
+ // The STOREFRONT provider — it's the one with a catalog to re-sync. An
92
+ // invoicing-only provider has nothing for this hook to do.
93
+ const provider = astroidCommerceRoles(config.commerce).storefront;
94
+ const table = astroidCatalogMirror(config).table;
95
+ return [
96
+ "// The queue consumer — what each message actually does.",
97
+ "//",
98
+ "// Scaffolded once; yours to edit. `astroidQueueHandler` owns the dispatch",
99
+ "// (a periodic refresh and any catalog-affecting webhook trigger a re-sync;",
100
+ "// everything else acks as a no-op), so what's left here is what a refresh",
101
+ "// MEANS for this project.",
102
+ "//",
103
+ "// Throwing marks the message for retry. That's usually right — a failed",
104
+ "// refresh means the site is serving stale data — and Cloudflare routes it to",
105
+ "// the DLQ once it exceeds max_retries (wrangler.jsonc).",
106
+ 'import { astroidQueueHandler, type AstroidQueueMessage } from "astroidjs";',
107
+ "",
108
+ "export async function handleQueueMessage(",
109
+ " env: CloudflareEnv,",
110
+ " message: AstroidQueueMessage,",
111
+ "): Promise<void> {",
112
+ " await astroidQueueHandler({",
113
+ " refreshCatalog: async () => {",
114
+ provider
115
+ ? ` // TODO: fetch the ${provider} catalog, normalize each item with`
116
+ : " // TODO: fetch your catalog and normalize each item to a CatalogItem,",
117
+ provider
118
+ ? ` // ${provider}ToCatalogItem, then hand the array to astroidCatalogSync:`
119
+ : " // then hand the array to astroidCatalogSync:",
120
+ " //",
121
+ ` // const items = (await listCatalog(token)).map(${provider ?? "provider"}ToCatalogItem);`,
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);",
124
+ " //",
125
+ " // The sync is idempotent (keyed on the provider's id) and never",
126
+ " // writes an owner-edited column, so it's safe to run on every event.",
127
+ " //",
128
+ " // It THROWS if every item failed (an unapplied migration, D1 down), and",
129
+ " // letting that escape is correct: an uncaught throw here marks the",
130
+ " // message for retry. Swallow it and the queue acks, the cron acks too,",
131
+ " // and the site serves a frozen catalog with nothing in `wrangler tail`.",
132
+ " // Partial failures don't throw — that's what `r.failed` above is for.",
133
+ " void env;",
134
+ " },",
135
+ " })(message);",
136
+ "}",
137
+ "",
138
+ ]
139
+ .filter((line) => line !== "")
140
+ .join("\n");
141
+ }
142
+ /**
143
+ * The provider webhook receiver — `src/pages/api/webhooks/<provider>.ts`.
144
+ *
145
+ * Thin on purpose: `handleWebhook` owns the ordering (verify the raw body before
146
+ * parsing) and the status-code contract (which codes ask the provider to retry
147
+ * and which tell it to stop). What's here is the provider's own header and
148
+ * verifier, plus the secret read.
149
+ *
150
+ * Returns null when the project has no commerce provider — nothing to receive.
151
+ */
152
+ export function generateAstroidWebhookRoute(config, forProvider) {
153
+ const provider = forProvider ?? astroidCommerceProviders(config.commerce)[0];
154
+ if (!provider)
155
+ return null;
156
+ const p = PROVIDERS[provider];
157
+ return [
158
+ `// ${provider[0].toUpperCase()}${provider.slice(1)} webhook receiver.`,
159
+ "//",
160
+ "// Scaffolded once; yours to edit. The order is the important part and",
161
+ "// `handleWebhook` owns it: the raw body is verified BEFORE anything parses",
162
+ "// it, then the event is enqueued and this returns immediately. The consumer",
163
+ "// (src/queue.ts) does the actual work, with Cloudflare owning retries + DLQ.",
164
+ "//",
165
+ `// ${p.note}`,
166
+ "//",
167
+ "// Unprovisioned (the secret is absent or still the placeholder) answers 503,",
168
+ "// which keeps the provider retrying — so events delivered before you set the",
169
+ "// secret land afterwards instead of being lost.",
170
+ 'import type { APIRoute } from "astro";',
171
+ 'import { handleWebhook, readModuleSecret } from "astroidjs";',
172
+ 'import { env } from "cloudflare:workers";',
173
+ `import { ${p.verifier} } from ${JSON.stringify(p.module)};`,
174
+ "",
175
+ "export const prerender = false;",
176
+ "",
177
+ `const HEADER = ${JSON.stringify(p.header)};`,
178
+ "",
179
+ "export const POST: APIRoute = async ({ request, url }) => {",
180
+ " const headers = request.headers;",
181
+ " return handleWebhook(request, url, {",
182
+ ` provider: ${JSON.stringify(provider)},`,
183
+ ` secret: await readModuleSecret(env.${COMMERCE_PROVIDER_SECRETS[provider].webhook}),`,
184
+ ` queue: env.${ASTROID_QUEUE_BINDING},`,
185
+ ` verify: ({ raw, secret }) => ${p.call},`,
186
+ " });",
187
+ "};",
188
+ "",
189
+ ].join("\n");
190
+ }
191
+ /**
192
+ * Every webhook receiver this project needs, as `{ path, contents }`.
193
+ *
194
+ * Plural because roles are: a site running Stripe for invoicing beside
195
+ * Fourthwall for the storefront receives from both, each with its own signing
196
+ * secret and header. One route per provider, not per role — a provider filling
197
+ * two roles still has one endpoint and one secret.
198
+ */
199
+ export function generateAstroidWebhookRoutes(config) {
200
+ return astroidCommerceProviders(config.commerce).flatMap((provider) => {
201
+ const contents = generateAstroidWebhookRoute(config, provider);
202
+ return contents ? [{ path: `src/pages/api/webhooks/${provider}.ts`, contents }] : [];
203
+ });
204
+ }
@@ -0,0 +1,60 @@
1
+ import type { AstroidQueueMessage } from "./messages.js";
2
+ /**
3
+ * The queue producer surface used here — structural, so a real `Queue<T>`
4
+ * binding satisfies it without astroid depending on the Workers types.
5
+ *
6
+ * `Promise<unknown>` rather than `Promise<void>`: Cloudflare's `Queue.send`
7
+ * resolves to a `QueueSendResponse`, and a `void` return type would reject the
8
+ * actual binding. Nothing here reads the value.
9
+ */
10
+ export interface QueueProducer<T = AstroidQueueMessage> {
11
+ send(message: T): Promise<unknown>;
12
+ }
13
+ export interface WebhookVerifyInput {
14
+ /** The raw request body, exactly as received. */
15
+ raw: string;
16
+ headers: Headers;
17
+ url: URL;
18
+ /** The signing secret — already checked to be real by the caller. */
19
+ secret: string;
20
+ }
21
+ export interface WebhookRouteOptions {
22
+ /** Which integration this endpoint serves — carried into the message. */
23
+ provider: string;
24
+ /**
25
+ * The signing secret, or `null` when unprovisioned. Read it with
26
+ * `readModuleSecret` so a placeholder counts as absent.
27
+ */
28
+ secret: string | null;
29
+ /** Signature check over the raw body — e.g. `verifySquareSignature`. */
30
+ verify: (input: WebhookVerifyInput) => boolean | Promise<boolean>;
31
+ /** The queue binding, or null/undefined when Queues aren't provisioned. */
32
+ queue?: QueueProducer | null;
33
+ /**
34
+ * Pull the event type out of the parsed payload. Defaults to a `type` field;
35
+ * override for providers that name it differently (Fourthwall's `testMode`
36
+ * envelope, Stripe's nested object).
37
+ */
38
+ eventType?: (payload: unknown) => string;
39
+ /**
40
+ * Decide whether an event is worth queueing at all. Returning false acks the
41
+ * delivery without enqueuing — the provider is satisfied and the consumer
42
+ * isn't woken for an event nothing acts on.
43
+ */
44
+ accept?: (type: string, payload: unknown) => boolean;
45
+ }
46
+ /**
47
+ * Handle one inbound provider webhook.
48
+ *
49
+ * ```ts
50
+ * export const POST: APIRoute = ({ request, url }) =>
51
+ * handleWebhook(request, url, {
52
+ * provider: "square",
53
+ * secret: await readModuleSecret(env.SQUARE_WEBHOOK_SECRET),
54
+ * queue: env.COMMERCE_QUEUE,
55
+ * verify: ({ raw, headers, url, secret }) =>
56
+ * verifySquareSignature(url.href, raw, headers.get("x-square-hmacsha256-signature"), secret),
57
+ * });
58
+ * ```
59
+ */
60
+ export declare function handleWebhook(request: Request, url: URL, options: WebhookRouteOptions): Promise<Response>;
@@ -0,0 +1,81 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The webhook receiver: verify → enqueue → return fast.
4
+ //
5
+ // All three sites wrote this route the same way, and the ordering is the part
6
+ // worth encoding. **Verify the HMAC over the raw body before parsing anything.**
7
+ // Not for style — parsing first means an unauthenticated caller can reach the
8
+ // JSON parser and everything downstream of it, and re-serializing a parsed body
9
+ // to check the signature is how signature checks quietly stop checking anything.
10
+ // So the raw text is read once, verified, and only then parsed.
11
+ //
12
+ // The other half is what the status code means to the sender. Every provider
13
+ // here retries on non-2xx, which makes the response the only backpressure signal
14
+ // available: a 5xx means "try again", a 4xx means "never again", and returning
15
+ // the wrong one either loses the event permanently or pins the provider in a
16
+ // retry loop. Each code below is chosen for what it tells the sender to do.
17
+ const text = (body, status) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
18
+ /** Default event-type reader: a top-level `type` string. */
19
+ function defaultEventType(payload) {
20
+ const type = payload?.type;
21
+ return typeof type === "string" ? type : "";
22
+ }
23
+ /**
24
+ * Handle one inbound provider webhook.
25
+ *
26
+ * ```ts
27
+ * export const POST: APIRoute = ({ request, url }) =>
28
+ * handleWebhook(request, url, {
29
+ * provider: "square",
30
+ * secret: await readModuleSecret(env.SQUARE_WEBHOOK_SECRET),
31
+ * queue: env.COMMERCE_QUEUE,
32
+ * verify: ({ raw, headers, url, secret }) =>
33
+ * verifySquareSignature(url.href, raw, headers.get("x-square-hmacsha256-signature"), secret),
34
+ * });
35
+ * ```
36
+ */
37
+ export async function handleWebhook(request, url, options) {
38
+ // 503, not 500 or 200: the module is dormant, which is a temporary state a
39
+ // deploy fixes. 5xx keeps the provider retrying, so events delivered during
40
+ // the gap land once the secret is provisioned instead of being lost.
41
+ if (!options.secret)
42
+ return text("Webhook not configured", 503);
43
+ const raw = await request.text();
44
+ let valid = false;
45
+ try {
46
+ valid = await options.verify({ raw, headers: request.headers, url, secret: options.secret });
47
+ }
48
+ catch {
49
+ valid = false;
50
+ }
51
+ // 401 is terminal on purpose. A signature that doesn't check out will never
52
+ // check out on retry, and asking the provider to keep trying turns a
53
+ // misconfiguration into a self-inflicted flood.
54
+ if (!valid)
55
+ return text("Invalid signature", 401);
56
+ let payload;
57
+ try {
58
+ payload = JSON.parse(raw);
59
+ }
60
+ catch {
61
+ // Also terminal — a body that isn't JSON now won't become JSON later.
62
+ return text("Invalid JSON", 400);
63
+ }
64
+ const type = (options.eventType ?? defaultEventType)(payload);
65
+ if (options.accept && !options.accept(type, payload)) {
66
+ return text("Ignored", 202);
67
+ }
68
+ if (!options.queue)
69
+ return text("Queue not configured", 503);
70
+ try {
71
+ await options.queue.send({ kind: "webhook", provider: options.provider, type, payload });
72
+ }
73
+ catch {
74
+ // The signature was good, so this event is real and worth keeping. 503 asks
75
+ // the provider to redeliver rather than dropping it.
76
+ return text("Queue unavailable", 503);
77
+ }
78
+ // 202, not 200: the work hasn't happened yet, it's been accepted. That's the
79
+ // entire point of enqueuing — the response returns before the consumer runs.
80
+ return text("Accepted", 202);
81
+ }
@@ -0,0 +1 @@
1
+ export { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, generateAstroidEditSession, generateAstroidRealtimeEnv, usesRealtime, } from "./scaffold.js";
@@ -0,0 +1,4 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The realtime module's scaffold surface (ADR 0002 / #71).
4
+ export { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, generateAstroidEditSession, generateAstroidRealtimeEnv, usesRealtime, } from "./scaffold.js";
@@ -0,0 +1,30 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** The DO namespace binding name. Fixed, like the portal's prefixes: the client
3
+ * and the route both address it, and a rename is a silent 503. */
4
+ export declare const ASTROID_REALTIME_BINDING = "EDIT_SESSION";
5
+ /** The exported class name wrangler resolves for the binding. Must match the
6
+ * `class_name` in wrangler.jsonc AND be re-exported from the worker entry. */
7
+ export declare const ASTROID_EDIT_SESSION_CLASS = "EditSessionDO";
8
+ /** Migration tag for the DO class. Wrangler requires one; `v1` is the first. */
9
+ export declare const ASTROID_REALTIME_MIGRATION_TAG = "v1";
10
+ /** Is the realtime module switched on for this project? */
11
+ export declare function usesRealtime(config: AstroidConfig): boolean;
12
+ /**
13
+ * `src/edit-session.ts` — the site-owned Durable Object subclass.
14
+ *
15
+ * Scaffold-once: `persist` is where a project decides what a flush means, and
16
+ * the lock/field sets are tuning. What Astroid fixes is the delegation shape,
17
+ * because getting it wrong fails in ways that look like anything but a bug in
18
+ * this file — a missing `webSocketClose` leaks presence forever, a non-lazy
19
+ * session breaks after the first hibernation wake.
20
+ *
21
+ * Returns null when the project has no realtime module.
22
+ */
23
+ export declare function generateAstroidEditSession(config: AstroidConfig): string | null;
24
+ /**
25
+ * The `CloudflareEnv` member the realtime module adds, as a block
26
+ * `create-astroid` substitutes into `src/env.d.ts`. Empty without the module —
27
+ * a project that types a binding its wrangler.jsonc never creates is making a
28
+ * promise it doesn't keep.
29
+ */
30
+ export declare function generateAstroidRealtimeEnv(config: AstroidConfig): string;
@@ -0,0 +1,159 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The realtime module (ADR 0002 / #71): per-page live editing over a Durable
4
+ // Object, opt-in via `modules: ["realtime"]`.
5
+ //
6
+ // The package description has claimed "multi-editor sites" since 0.1.0, and this
7
+ // is the half that makes it true for two people on the SAME page. (The other
8
+ // axis — multi-EDITOR, i.e. an org of accounts — was always real.) Without it
9
+ // two editors on one page clobber each other; the server-side draft merge
10
+ // narrows the window but there is no live channel, no presence, and no signal
11
+ // that someone else is in the same field.
12
+ //
13
+ // What Astroid generates and what it deliberately does NOT:
14
+ //
15
+ // - The DO SUBCLASS is scaffold-once (`src/edit-session.ts`), because it must
16
+ // import `cloudflare:workers` — a runtime-only specifier the toolkit can't
17
+ // carry — and because its `persist` is the seam a project tunes. Louise
18
+ // ships the session LOGIC it delegates to; this is the boilerplate around it.
19
+ // - The wrangler `durable_objects` binding + `migrations` block, which is the
20
+ // part nobody gets right from memory: a DO class needs a migration tag, and
21
+ // a SQLite-backed one needs `new_sqlite_classes` rather than `new_classes`.
22
+ // - The `realtimeRoute` upgrade endpoint, in the generated worker.
23
+ //
24
+ // Persistence goes through `applySaveDraft` — the SAME path the fetch auto-save
25
+ // uses. One write path, per the ADR: the DO is a new front end to it, not a
26
+ // parallel store, so drafts, version history, publish, and read-your-writes all
27
+ // stay intact.
28
+ /** The DO namespace binding name. Fixed, like the portal's prefixes: the client
29
+ * and the route both address it, and a rename is a silent 503. */
30
+ export const ASTROID_REALTIME_BINDING = "EDIT_SESSION";
31
+ /** The exported class name wrangler resolves for the binding. Must match the
32
+ * `class_name` in wrangler.jsonc AND be re-exported from the worker entry. */
33
+ export const ASTROID_EDIT_SESSION_CLASS = "EditSessionDO";
34
+ /** Migration tag for the DO class. Wrangler requires one; `v1` is the first. */
35
+ export const ASTROID_REALTIME_MIGRATION_TAG = "v1";
36
+ /** Is the realtime module switched on for this project? */
37
+ export function usesRealtime(config) {
38
+ return (config.modules ?? []).includes("realtime");
39
+ }
40
+ /**
41
+ * `src/edit-session.ts` — the site-owned Durable Object subclass.
42
+ *
43
+ * Scaffold-once: `persist` is where a project decides what a flush means, and
44
+ * the lock/field sets are tuning. What Astroid fixes is the delegation shape,
45
+ * because getting it wrong fails in ways that look like anything but a bug in
46
+ * this file — a missing `webSocketClose` leaks presence forever, a non-lazy
47
+ * session breaks after the first hibernation wake.
48
+ *
49
+ * Returns null when the project has no realtime module.
50
+ */
51
+ export function generateAstroidEditSession(config) {
52
+ if (!usesRealtime(config))
53
+ return null;
54
+ return [
55
+ "// The per-page live editing session Durable Object (ADR 0002 / #71).",
56
+ "//",
57
+ "// Scaffolded once and yours to edit — `persist` in particular. What should NOT",
58
+ "// change is the delegation: every handler forwards to the session object, and",
59
+ "// the session is built LAZILY. A Durable Object is re-instantiated after a",
60
+ "// hibernation wake, so a session captured in a field initializer would be",
61
+ "// rebuilt anyway; the authoritative state lives in `ctx.storage` and the socket",
62
+ "// attachments, never in this class.",
63
+ "//",
64
+ "// This class must stay EXPORTED FROM src/worker.ts (it is, via a re-export) or",
65
+ "// wrangler can't resolve the `class_name` in the durable_objects binding.",
66
+ "",
67
+ 'import { DurableObject } from "cloudflare:workers";',
68
+ 'import { applySaveDraft } from "louise-toolkit/editor";',
69
+ 'import { createEditSession, type EditSession } from "louise-toolkit/realtime";',
70
+ 'import { astroidPagesCollection } from "astroidjs";',
71
+ 'import astroidConfig from "../astroid.config.js";',
72
+ 'import { pages, pagesVersions } from "./schema.js";',
73
+ "",
74
+ "const pagesCollection = astroidPagesCollection(astroidConfig);",
75
+ "",
76
+ `export class ${ASTROID_EDIT_SESSION_CLASS} extends DurableObject<CloudflareEnv> {`,
77
+ " #session?: EditSession;",
78
+ "",
79
+ " #s(): EditSession {",
80
+ " this.#session ??= createEditSession(this.ctx, {",
81
+ " // Allowlist = the collection's editable fields. A `change` for anything",
82
+ " // else is dropped here as a cheap first gate; applySaveDraft re-validates",
83
+ " // the merged draft on persist, so this is not the security boundary.",
84
+ " fields: Object.keys(pagesCollection.fields),",
85
+ " // The rich-text body is the one field where character-level merge matters,",
86
+ " // so it takes a soft-lock (one editor at a time) instead of being",
87
+ " // last-writer-wins clobbered. Locked values are never fanned out to peers,",
88
+ " // so raw rich text doesn't cross sockets.",
89
+ ' lockFields: ["body"],',
90
+ " persist: async (snapshot, editor, target) => {",
91
+ " // Guard the collection: only `pages` is realtime, and a stray target",
92
+ " // must never write into the wrong table.",
93
+ ' if (target.slug !== "pages") return;',
94
+ " // The SAME merge-over-pending-draft path the fetch auto-save uses —",
95
+ " // one write path, so drafts/history/publish semantics are identical.",
96
+ " //",
97
+ " // No `bufferKv` here on purpose: the DO's alarm IS the coalescer for",
98
+ " // this page, so routing through the KV write-buffer as well would be",
99
+ " // two layers of coalescing over one stream of edits.",
100
+ " const result = await applySaveDraft(",
101
+ " this.env,",
102
+ " { table: pages, versionsTable: pagesVersions, config: pagesCollection },",
103
+ " editor,",
104
+ " target.id,",
105
+ " snapshot,",
106
+ " );",
107
+ " // A THROW (D1 down) propagates so the alarm keeps the snapshot dirty",
108
+ " // and retries. An `ok: false` is terminal — a deleted row, an invalid",
109
+ " // draft — which a retry can't fix, so let the alarm clear it, but say",
110
+ " // so rather than dropping it silently.",
111
+ " if (!result.ok) {",
112
+ " console.warn(",
113
+ " `[realtime] draft flush for pages:${target.id} rejected: ${result.status} ${result.error}`,",
114
+ " );",
115
+ " }",
116
+ " },",
117
+ " });",
118
+ " return this.#session;",
119
+ " }",
120
+ "",
121
+ " fetch(request: Request): Promise<Response> {",
122
+ " return this.#s().fetch(request);",
123
+ " }",
124
+ "",
125
+ " webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {",
126
+ " return this.#s().webSocketMessage(ws, message);",
127
+ " }",
128
+ "",
129
+ " webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {",
130
+ " return this.#s().webSocketClose(ws, code, reason, wasClean);",
131
+ " }",
132
+ "",
133
+ " webSocketError(ws: WebSocket, error: unknown): Promise<void> {",
134
+ " return this.#s().webSocketError(ws, error);",
135
+ " }",
136
+ "",
137
+ " alarm(): Promise<void> {",
138
+ " return this.#s().alarm();",
139
+ " }",
140
+ "}",
141
+ "",
142
+ ].join("\n");
143
+ }
144
+ /**
145
+ * The `CloudflareEnv` member the realtime module adds, as a block
146
+ * `create-astroid` substitutes into `src/env.d.ts`. Empty without the module —
147
+ * a project that types a binding its wrangler.jsonc never creates is making a
148
+ * promise it doesn't keep.
149
+ */
150
+ export function generateAstroidRealtimeEnv(config) {
151
+ if (!usesRealtime(config))
152
+ return "";
153
+ return [
154
+ " /** Durable Object namespace for the per-page live editing session (ADR 0002).",
155
+ " * The realtime route answers 503 without it, so realtime is cleanly absent",
156
+ " * rather than erroring. */",
157
+ ` ${ASTROID_REALTIME_BINDING}: DurableObjectNamespace;`,
158
+ ].join("\n");
159
+ }
@@ -1,15 +1,49 @@
1
- import { type CollectionConfig, type ContentConfig } from "louise-toolkit/content";
1
+ import { type CollectionConfig, type ContentConfig } from "louise-toolkit/content/define";
2
2
  import type { AstroidConfig } from "../config.js";
3
3
  /**
4
- * The opinionated `pages` collection — the EDITABLE page fields, versioned
5
- * drafts, and full-text search. Keyed to the same names as Louise's `pagesColumns`
6
- * so a publish's `.set()` maps straight onto the physical columns; bookkeeping
7
- * columns (`id`/`status`/timestamps/`publishedVersionId`) live on the table via
8
- * `pagesColumns`, never here — matching the site's `pages-collection.ts`.
4
+ * Return a copy of a `pages` write payload with its `sections` rich-text fields
5
+ * sanitized against the project media base — a no-op when the write carries no
6
+ * `sections`. Pure; leaves every other field (and a partial PATCH's absent ones)
7
+ * untouched.
9
8
  *
10
- * Validated by `defineCollection` at build time, so a malformed field shape throws
11
- * here rather than at codegen.
9
+ * Exported because two write paths need it: the collection's `beforeChange` hook
10
+ * below, AND the raw `pagesRoute` (which does not run collection hooks — see
11
+ * {@link astroidPagesWriteHooks}).
12
12
  */
13
+ export declare function sanitizeAstroidPageSections(config: AstroidConfig, data: Record<string, unknown>): Record<string, unknown>;
14
+ /**
15
+ * Validate the (already-sanitized) `sections` of a `pages` write against the
16
+ * catalog, throwing `LouiseValidationError` — an unknown `_type`, a field of the
17
+ * wrong shape, or a setting outside its declared options is rejected with a 422
18
+ * carrying the per-field violations. A no-op when the write carries no
19
+ * `sections`, so a partial PATCH of other fields isn't spuriously validated.
20
+ */
21
+ export declare function assertAstroidPageSections(config: AstroidConfig, data: Record<string, unknown>, operation?: "create" | "update"): Promise<void>;
22
+ /**
23
+ * The write-time hooks the raw `pagesRoute` (louise-toolkit/editor) needs to
24
+ * enforce the same section contract as the draft path.
25
+ *
26
+ * `pagesRoute` writes straight to the table and — unlike `versionsRoute` — takes
27
+ * no collection config, so it never runs the `beforeChange` hook below. Left
28
+ * bare (as it was), a direct `POST` / `PATCH /api/louise/pages/:id` persists an
29
+ * unknown section `_type`, a setting outside its options, or unsanitized section
30
+ * rich text: exactly what the hook exists to stop, silently missing from the one
31
+ * route the on-canvas *structural* edits flow through. `<Sections>` then skips
32
+ * the bad `_type`, so the section just vanishes with no error anywhere.
33
+ *
34
+ * These wire the SAME sanitize + validate the hook uses into `pagesRoute`'s
35
+ * `sanitize` / `transform` / `validate` seams, so both write paths enforce one
36
+ * contract. Spread into the route config:
37
+ *
38
+ * pagesRoute({ table: pages, resolveEditor, fields, ...astroidPagesWriteHooks(config) })
39
+ */
40
+ export declare function astroidPagesWriteHooks(config: AstroidConfig): {
41
+ sanitize: (html: string) => string;
42
+ transform: (data: Record<string, unknown>) => Record<string, unknown>;
43
+ validate: (data: Record<string, unknown>, ctx: {
44
+ operation: "create" | "update";
45
+ }) => Promise<void>;
46
+ };
13
47
  export declare function astroidPagesCollection(config: AstroidConfig): CollectionConfig;
14
48
  /**
15
49
  * The Louise `ContentConfig` for an Astroid project. Today: the `pages`