astroidjs 0.19.0 → 0.21.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/bin/astroid.mjs CHANGED
@@ -172,6 +172,7 @@ async function cmdDoctor(cwd, flags) {
172
172
  generateAstroidScaffoldFiles,
173
173
  astroidUsesQueues,
174
174
  astroidCrons,
175
+ astroidHasEditor,
175
176
  checkWranglerPreviews,
176
177
  astroidRunsMigrations,
177
178
  migrationsOwnershipError,
@@ -235,9 +236,15 @@ async function cmdDoctor(cwd, flags) {
235
236
  //
236
237
  // So check every binding the GENERATED code actually dereferences, not just
237
238
  // the three the baseline happens to have.
239
+ // An app with no editor (`editor: false`) uses none of the editor's
240
+ // bindings, so it isn't held to them: no draft buffer, no media bucket, and
241
+ // no mail unless its portal sends password resets.
242
+ const editor = astroidHasEditor(config);
238
243
  const requiredBindings = [
239
244
  { name: "RL", what: "KV namespace", why: "the rate limiter in src/middleware.ts" },
240
- { name: "DRAFTS", what: "KV namespace", why: "the autosave draft buffer" },
245
+ ...(editor
246
+ ? [{ name: "DRAFTS", what: "KV namespace", why: "the autosave draft buffer" }]
247
+ : []),
241
248
  ...(astroidUsesQueues(config)
242
249
  ? [
243
250
  {
@@ -263,17 +270,24 @@ async function cmdDoctor(cwd, flags) {
263
270
  // it: the magic link is console-logged in dev and EMAILED in production, so
264
271
  // a missing binding is a site nobody can sign in to—and it fails only once
265
272
  // deployed, which is the one place nothing in this repo exercises.
266
- if (/"send_email"\s*:/.test(w)) ok("wrangler: Email Sending `EMAIL` binding present");
267
- else
273
+ // An app with no editor and no portal signs nobody in, so it sends no mail.
274
+ const sendsMail = editor || Boolean(config.portal?.enabled);
275
+ if (sendsMail && /"send_email"\s*:/.test(w))
276
+ ok("wrangler: Email Sending `EMAIL` binding present");
277
+ else if (sendsMail)
268
278
  err(
269
- "wrangler.jsonc has no `send_email` binding, but production sign-in emails the " +
270
- 'magic link (it is only console-logged in dev). Add: "send_email": [{ "name": "EMAIL" }]',
279
+ editor
280
+ ? "wrangler.jsonc has no `send_email` binding, but production sign-in emails the " +
281
+ 'magic link (it is only console-logged in dev). Add: "send_email": [{ "name": "EMAIL" }]'
282
+ : "wrangler.jsonc has no `send_email` binding, but the portal emails password resets " +
283
+ 'in production (they are only console-logged in dev). Add: "send_email": [{ "name": "EMAIL" }]',
271
284
  );
272
285
 
273
286
  if (hasBinding("DB")) ok("wrangler: D1 `DB` binding present");
274
287
  else err("wrangler.jsonc has no D1 `DB` binding.");
275
- if (hasBinding("MEDIA")) ok("wrangler: R2 `MEDIA` binding present");
276
- else err("wrangler.jsonc has no R2 `MEDIA` binding.");
288
+ // An app with no editor has no media library, so no bucket to hold it.
289
+ if (editor && hasBinding("MEDIA")) ok("wrangler: R2 `MEDIA` binding present");
290
+ else if (editor) err("wrangler.jsonc has no R2 `MEDIA` binding.");
277
291
  if (/"main"\s*:\s*"src\/worker\.ts"/.test(w)) ok("wrangler: `main` → src/worker.ts");
278
292
  else warn("wrangler.jsonc `main` does not point at src/worker.ts.");
279
293
 
@@ -287,7 +301,17 @@ async function cmdDoctor(cwd, flags) {
287
301
  // JSON.parse (comments/trailing commas), matching the binding checks above.
288
302
  const expectedCrons = astroidCrons(config);
289
303
  const cronsMatch = w.match(/"crons"\s*:\s*\[([^\]]*)\]/);
290
- if (!cronsMatch) {
304
+ const declaredAny = cronsMatch && /"[^"]+"/.test(cronsMatch[1]);
305
+ if (expectedCrons.length === 0) {
306
+ // Nothing scheduled, so the generated worker has no `scheduled` handler,
307
+ // and a declared trigger would fail every time it fired.
308
+ if (declaredAny)
309
+ warn(
310
+ "wrangler.jsonc declares `triggers.crons`, but nothing in your config is scheduled, " +
311
+ "so the generated worker has no `scheduled` handler for them. Remove the triggers.",
312
+ );
313
+ else ok("wrangler: no crons, and nothing is scheduled");
314
+ } else if (!cronsMatch) {
291
315
  err(
292
316
  "wrangler.jsonc has no `triggers.crons`, but the generated `scheduled` handler " +
293
317
  `dispatches on ${expectedCrons.map((c) => `"${c}"`).join(", ")}. ` +
@@ -50,6 +50,13 @@ export declare function commerceProviderCredentials(provider: CommerceProvider,
50
50
  * `.dev.vars` would be a bug rather than a redundancy.
51
51
  */
52
52
  export declare function commerceSecretNames(commerce: CommerceConfig | undefined): string[];
53
+ /**
54
+ * The webhook signing secret a provider needs, or none when the project runs no
55
+ * pipeline (`commerce.pipeline: false`). With no receiver there is nothing to
56
+ * verify, so requiring the secret would hold checkout dormant for a value no
57
+ * code reads.
58
+ */
59
+ export declare function commerceProviderWebhookSecrets(provider: CommerceProvider, commerce: CommerceConfig | undefined): readonly string[];
53
60
  /** One provider's resolved gate. */
54
61
  export interface ProviderStatus {
55
62
  provider: CommerceProvider;
@@ -101,19 +101,28 @@ export function commerceProviderCredentials(provider, commerce) {
101
101
  export function commerceSecretNames(commerce) {
102
102
  const names = astroidCommerceProviders(commerce).flatMap((provider) => [
103
103
  ...commerceProviderCredentials(provider, commerce),
104
- COMMERCE_PROVIDER_SECRETS[provider].webhook,
104
+ ...commerceProviderWebhookSecrets(provider, commerce),
105
105
  ]);
106
106
  return [...new Set(names)];
107
107
  }
108
+ /**
109
+ * The webhook signing secret a provider needs, or none when the project runs no
110
+ * pipeline (`commerce.pipeline: false`). With no receiver there is nothing to
111
+ * verify, so requiring the secret would hold checkout dormant for a value no
112
+ * code reads.
113
+ */
114
+ export function commerceProviderWebhookSecrets(provider, commerce) {
115
+ return commerce?.pipeline === false ? [] : [COMMERCE_PROVIDER_SECRETS[provider].webhook];
116
+ }
108
117
  /** Read one provider's secrets off an env-shaped record. */
109
118
  async function resolveProvider(provider, roles, env, commerce) {
110
- const spec = COMMERCE_PROVIDER_SECRETS[provider];
111
119
  const pick = (names) => Object.fromEntries(names.map((n) => [n, env[n]]));
112
120
  const [credentials, webhook] = await Promise.all([
113
121
  // Config-aware: a multi-location project must not be held dormant waiting
114
122
  // for a SQUARE_LOCATION_ID it will never legitimately have.
115
123
  resolveModuleSecrets(pick(commerceProviderCredentials(provider, commerce))),
116
- resolveModuleSecrets(pick([spec.webhook])),
124
+ // Empty without a pipeline, which resolves as configured: nothing to verify.
125
+ resolveModuleSecrets(pick(commerceProviderWebhookSecrets(provider, commerce))),
117
126
  ]);
118
127
  return {
119
128
  provider,
package/dist/config.d.ts CHANGED
@@ -175,6 +175,18 @@ export interface CommerceConfig {
175
175
  catalog?: CatalogMirrorConfig;
176
176
  /** Square-specific options. Only meaningful when Square fills some role. */
177
177
  square?: SquareCommerceConfig;
178
+ /**
179
+ * Whether this project runs the commerce pipeline: the webhook receivers, the
180
+ * queue consumer that processes them, and the hourly catalog re-sync. Default
181
+ * `true`.
182
+ *
183
+ * Set `false` for a project that only takes payments while another project,
184
+ * or another Worker in the same repository, runs the pipeline against the
185
+ * same account. It keeps what a checkout needs, the provider's CSP origins,
186
+ * the checkout rate rule, and the checkout route, and drops the rest, along
187
+ * with the webhook signing secret nothing would verify.
188
+ */
189
+ pipeline?: boolean;
178
190
  }
179
191
  export interface SquareCommerceConfig {
180
192
  /**
@@ -195,8 +207,9 @@ export interface SquareCommerceConfig {
195
207
  export interface QueuesConfig {
196
208
  /**
197
209
  * Force the queue consumer + cron on or off. Defaults to on whenever
198
- * `commerce` is configured: a commerce provider means webhooks, and a webhook
199
- * you process inline is a webhook you drop when the provider times out.
210
+ * `commerce` is configured with its pipeline: a commerce provider means
211
+ * webhooks, and a webhook you process inline is a webhook you drop when the
212
+ * provider times out.
200
213
  */
201
214
  enabled?: boolean;
202
215
  /**
@@ -482,6 +495,24 @@ export interface StatusConfig {
482
495
  */
483
496
  checks?: boolean;
484
497
  }
498
+ /**
499
+ * Incident capture (louise-toolkit ADR 0022). Every Astroid site counts its
500
+ * failures into its own D1 and an Analytics Engine dataset; these settings add
501
+ * to that.
502
+ */
503
+ export interface IncidentsConfig {
504
+ /**
505
+ * The failures that should alert: dotted names (`commerce.checkout`) and
506
+ * path prefixes (`/cart`). A site fact, so there's no default list.
507
+ */
508
+ critical?: string[];
509
+ /**
510
+ * Also send each incident to Sentry, the operator's issue system for a
511
+ * Monitored or Supported site. It reads the DSN from the `SENTRY_DSN`
512
+ * secret, and stays dormant while that's unset or a placeholder.
513
+ */
514
+ sentry?: boolean;
515
+ }
485
516
  export interface DeployConfig {
486
517
  platform: "cloudflare";
487
518
  /** Media base for R2 + `cf-image` resizing—matches Louise's media route
@@ -497,6 +528,28 @@ export interface DeployConfig {
497
528
  */
498
529
  migrations?: boolean;
499
530
  }
531
+ /**
532
+ * A small "Site by …" line the footer renders for the agency that built the
533
+ * site. A site fact, so it has no default: omit it and nothing renders.
534
+ */
535
+ export interface CreditConfig {
536
+ /** Who built the site, for example `"Example Organization"`. */
537
+ name: string;
538
+ /** Where the credit links, as an absolute `http` or `https` URL. */
539
+ href: string;
540
+ /**
541
+ * An optional mark shown before the name: a root-relative path (for example
542
+ * `"/credit-mark.svg"`) or an `https:` URL. It's drawn as a mask filled with
543
+ * the text color, so one single-color SVG works on every theme. Only its
544
+ * shape counts; its own fill is ignored.
545
+ */
546
+ logo?: string;
547
+ /** The link's `rel`, for example `"noopener"` or `"noopener nofollow"`. The
548
+ * site decides; omitted, the link carries none. */
549
+ rel?: string;
550
+ /** The words before the name. Default `"Site by"`. */
551
+ label?: string;
552
+ }
500
553
  export interface AstroidConfig {
501
554
  /**
502
555
  * Stable project slug—the worker/D1/R2 base name and default subdomain (for example,
@@ -513,6 +566,21 @@ export interface AstroidConfig {
513
566
  tenancy?: TenancyConfig;
514
567
  /** Starting shape; sets section/module/nav defaults the site can override. */
515
568
  archetype: Archetype;
569
+ /**
570
+ * Whether the project has a Louise editor. Default `true`.
571
+ *
572
+ * Set `false` for an app with no pages to edit, such as an order app whose
573
+ * menu comes from a commerce provider and whose few settings another site's
574
+ * editor owns. The generated worker and middleware then carry no editor
575
+ * routes and no sign-in, the schema carries no content tables, and
576
+ * `wrangler.jsonc` binds no draft buffer, media bucket, or AI. What stays is
577
+ * the rate limiter, the CSP, the security headers, the public status route,
578
+ * and the `portal`, `pwa`, `commerce`, and `tenancy` modules.
579
+ *
580
+ * `defineAstroid` refuses every option that configures the editor
581
+ * alongside it, such as `sections` or `media`, rather than ignore it.
582
+ */
583
+ editor?: boolean;
516
584
  /** The single brand's theme (display name + color tokens + font). */
517
585
  theme: Theme;
518
586
  /** The editable home page, top to bottom. Omit to take the archetype default. */
@@ -573,6 +641,8 @@ export interface AstroidConfig {
573
641
  pages?: PagesConfig;
574
642
  /** The public status route's site-owned checks. */
575
643
  status?: StatusConfig;
644
+ /** Incident capture settings: what alerts, and whether Sentry gets a copy. */
645
+ incidents?: IncidentsConfig;
576
646
  /**
577
647
  * Force the contact form + `inquiries` table on or off. Omit to detect from
578
648
  * the config (a `contact` section, or a wholesale-inquiry module). Set `true`
@@ -583,6 +653,8 @@ export interface AstroidConfig {
583
653
  inquiries?: boolean;
584
654
  /** Installable-app settings. Only read when `modules` includes `"pwa"`. */
585
655
  pwa?: PwaConfig;
656
+ /** The agency credit `<Credit>` renders in the footer. Omit for none. */
657
+ credit?: CreditConfig;
586
658
  deploy?: DeployConfig;
587
659
  }
588
660
  export declare function defineAstroid(config: AstroidConfig): AstroidConfig;
package/dist/config.js CHANGED
@@ -34,6 +34,7 @@ import { AstroidConfigError } from "./errors.js";
34
34
  // type-only, so the cycle erases at build and nothing circular exists at runtime.
35
35
  // It is also dependency-free, so `create-astroid`'s graph is unchanged.
36
36
  import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "./queues/messages.js";
37
+ import { astroidHasEditor } from "./shape.js";
37
38
  /**
38
39
  * Each archetype's default home-page sections.
39
40
  *
@@ -88,6 +89,13 @@ export const ASTROID_SCAFFOLD_ROUTE_SLUGS = ["contact", "login", "work"];
88
89
  * the unreachable-trigger failure `config.crons` exists to prevent.
89
90
  */
90
91
  function assertCrons(config) {
92
+ // `queues.cron` schedules the catalog re-sync, which belongs to the pipeline.
93
+ // Without one it would be accepted and never scheduled.
94
+ if (config.commerce?.pipeline === false && typeof config.queues?.cron === "string") {
95
+ throw new AstroidConfigError("`queues.cron` schedules the catalog re-sync, which `commerce.pipeline: false` " +
96
+ "leaves to the project that runs the pipeline. Remove `queues.cron`, or use " +
97
+ "`crons` for a job of this project's own.");
98
+ }
91
99
  const crons = config.crons ?? [];
92
100
  if (crons.length === 0)
93
101
  return;
@@ -96,7 +104,7 @@ function assertCrons(config) {
96
104
  "without it the generated handler would `send` to a binding this project never creates. " +
97
105
  "Set `queues: { enabled: true }`, or drop the crons.");
98
106
  }
99
- const seen = new Map([[ASTROID_HEALTH_CRON, "the daily health scan"]]);
107
+ const seen = new Map(astroidHasEditor(config) ? [[ASTROID_HEALTH_CRON, "the daily health scan"]] : []);
100
108
  const catalog = astroidCron(config);
101
109
  if (catalog)
102
110
  seen.set(catalog, "the catalog re-sync (`queues.cron`)");
@@ -196,6 +204,79 @@ function assertMediaConfig(media) {
196
204
  "would fail with an error the media route never sees.");
197
205
  }
198
206
  }
207
+ /**
208
+ * A credit that would render as a broken link or an empty mark.
209
+ *
210
+ * The logo is limited to a root-relative path or `https:` because it lands in a
211
+ * CSS `url()`: a `data:` or `javascript:` value there is at best unrenderable,
212
+ * and a relative one resolves against each page's path rather than the site.
213
+ */
214
+ function assertCredit(credit) {
215
+ if (!credit)
216
+ return;
217
+ if (!credit.name?.trim()) {
218
+ throw new AstroidConfigError("`credit.name` is required: the name the footer credits");
219
+ }
220
+ let href;
221
+ try {
222
+ href = new URL(credit.href);
223
+ }
224
+ catch {
225
+ // Reported below with the value that failed.
226
+ }
227
+ if (!href || (href.protocol !== "https:" && href.protocol !== "http:")) {
228
+ throw new AstroidConfigError(`\`credit.href\` must be an absolute http or https URL, such as "https://example.com", ` +
229
+ `but it's "${credit.href}"`);
230
+ }
231
+ const logo = credit.logo;
232
+ if (logo !== undefined &&
233
+ !(logo.startsWith("/") && !logo.startsWith("//")) &&
234
+ !logo.startsWith("https://")) {
235
+ throw new AstroidConfigError(`\`credit.logo\` must be a root-relative path such as "/credit-mark.svg" or an ` +
236
+ `https URL, but it's "${logo}"`);
237
+ }
238
+ }
239
+ /**
240
+ * The options an app with no editor can't honor. Each configures the editor, a
241
+ * table it edits, or a surface only an editor reviews, so accepting one would
242
+ * be accepting a setting nothing reads.
243
+ */
244
+ const EDITOR_ONLY_OPTIONS = [
245
+ ["sections", "the editable home page"],
246
+ ["sectionCatalog", "the page editor's sections"],
247
+ ["blockCatalog", "the page editor's blocks"],
248
+ ["media", "the media library"],
249
+ ["pages", "the editable pages"],
250
+ ["settings", "the Settings panel"],
251
+ ];
252
+ /** Modules that only work with an editor, and why. */
253
+ const EDITOR_ONLY_MODULES = {
254
+ realtime: "it syncs editors editing one page",
255
+ wholesaleInquiry: "its inquiries are reviewed in the editor",
256
+ };
257
+ function assertEditorFree(config) {
258
+ if (astroidHasEditor(config))
259
+ return;
260
+ const without = "An app with `editor: false` has no editor";
261
+ for (const [key, what] of EDITOR_ONLY_OPTIONS) {
262
+ if (config[key] !== undefined) {
263
+ throw new AstroidConfigError(`${without}, so \`${key}\` (${what}) would do nothing. Remove it, or drop \`editor: false\`.`);
264
+ }
265
+ }
266
+ if (config.inquiries === true) {
267
+ throw new AstroidConfigError(`${without} to review inquiries in, so \`inquiries: true\` would collect messages ` +
268
+ "nobody reads. Remove it, or drop `editor: false`.");
269
+ }
270
+ for (const module of [...(config.modules ?? []), ...(config.portal?.features ?? [])]) {
271
+ const why = EDITOR_ONLY_MODULES[module];
272
+ if (why) {
273
+ throw new AstroidConfigError(`${without}, so the \`${module}\` module can't work: ${why}. Remove it, or drop \`editor: false\`.`);
274
+ }
275
+ }
276
+ if (config.deploy?.mediaBase !== undefined) {
277
+ throw new AstroidConfigError(`${without} and no media library, so \`deploy.mediaBase\` would do nothing. Remove it.`);
278
+ }
279
+ }
199
280
  export function defineAstroid(config) {
200
281
  if (!config.key || config.key.trim().length === 0) {
201
282
  throw new AstroidConfigError("Astroid config requires a non-empty `key` (it names the generated worker/D1/R2 bindings)");
@@ -231,6 +312,8 @@ export function defineAstroid(config) {
231
312
  assertCrons(config);
232
313
  assertTenancy(config);
233
314
  assertAllowSlugs(config);
315
+ assertEditorFree(config);
316
+ assertCredit(config.credit);
234
317
  if (config.portal?.gated) {
235
318
  throw new AstroidConfigError("`portal.gated` is not implemented: it is accepted but wires no guard, so the site " +
236
319
  "would be fully public while appearing gated. Remove it, and gate the whole site by " +
@@ -0,0 +1,2 @@
1
+ export * from "./names.js";
2
+ export * from "./sentry.js";
@@ -0,0 +1,12 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Incident capture, Astroid's side (louise-toolkit ADR 0022, amended).
4
+ //
5
+ // louise-toolkit counts every failure into the site's own D1 and, optionally,
6
+ // Analytics Engine. What's opinion, and so lives here: that every Astroid site
7
+ // captures incidents by default, the binding and dataset names, the migration
8
+ // an existing site needs, and the Sentry sink. Sentry is the operator's issue
9
+ // system for a Monitored or Supported site; it's never the record, and it's
10
+ // never in louise-toolkit's zero-dependency core (ADR 0016 § 7).
11
+ export * from "./names.js";
12
+ export * from "./sentry.js";
@@ -0,0 +1,17 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** The Analytics Engine binding incident counts go to. */
3
+ export declare const ASTROID_INCIDENT_EVENTS_BINDING = "INCIDENT_EVENTS";
4
+ /** The version metadata binding a report's `release` comes from. */
5
+ export declare const ASTROID_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
6
+ /** The binding the Sentry sink reads its DSN from: a Worker secret or a
7
+ * Secrets Store binding. This is its name, not the DSN. */
8
+ export declare const ASTROID_SENTRY_DSN_BINDING = "SENTRY_DSN";
9
+ /** The incident counts dataset: `<key>_incidents`, apart from the Core Web Vitals one. */
10
+ export declare function astroidIncidentEventsDataset(config: AstroidConfig): string;
11
+ /**
12
+ * `migrations/0006_incidents.sql`: the `incidents` and `dead_letters` tables
13
+ * (louise-toolkit/incidents). The same DDL drizzle-kit writes for them, with
14
+ * `IF NOT EXISTS`, since a site that already added a table by hand must not
15
+ * fail on it.
16
+ */
17
+ export declare const ASTROID_INCIDENTS_MIGRATION: string;
@@ -0,0 +1,55 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Incident capture's names and migration (louise-toolkit ADR 0022). No
4
+ // runtime imports, so the CLI's generators can read them without loading any
5
+ // louise-toolkit subpath.
6
+ /** The Analytics Engine binding incident counts go to. */
7
+ export const ASTROID_INCIDENT_EVENTS_BINDING = "INCIDENT_EVENTS";
8
+ /** The version metadata binding a report's `release` comes from. */
9
+ export const ASTROID_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
10
+ /** The binding the Sentry sink reads its DSN from: a Worker secret or a
11
+ * Secrets Store binding. This is its name, not the DSN. */
12
+ export const ASTROID_SENTRY_DSN_BINDING = "SENTRY_DSN";
13
+ /** The incident counts dataset: `<key>_incidents`, apart from the Core Web Vitals one. */
14
+ export function astroidIncidentEventsDataset(config) {
15
+ return `${config.key.replace(/[^a-z0-9_]/gi, "_")}_incidents`;
16
+ }
17
+ /**
18
+ * `migrations/0006_incidents.sql`: the `incidents` and `dead_letters` tables
19
+ * (louise-toolkit/incidents). The same DDL drizzle-kit writes for them, with
20
+ * `IF NOT EXISTS`, since a site that already added a table by hand must not
21
+ * fail on it.
22
+ */
23
+ export const ASTROID_INCIDENTS_MIGRATION = [
24
+ "-- Incidents (louise-toolkit ADR 0022): one row per fingerprint, counted by the",
25
+ "-- worker's d1Incidents sink and read by the Health panel and Watchtower. Dead",
26
+ "-- letters: each message a queue gave up on, kept for a runbook to replay.",
27
+ "-- Scaffolded by astroidjs.",
28
+ "CREATE TABLE IF NOT EXISTS `incidents` (",
29
+ "\t`fingerprint` text PRIMARY KEY NOT NULL,",
30
+ "\t`kind` text NOT NULL,",
31
+ "\t`name` text NOT NULL,",
32
+ "\t`code` text,",
33
+ "\t`message` text NOT NULL,",
34
+ "\t`path` text,",
35
+ "\t`host` text,",
36
+ "\t`release` text,",
37
+ "\t`critical` integer DEFAULT false NOT NULL,",
38
+ "\t`count` integer DEFAULT 1 NOT NULL,",
39
+ "\t`first_seen` integer NOT NULL,",
40
+ "\t`last_seen` integer NOT NULL,",
41
+ "\t`resolved_at` integer,",
42
+ "\t`reopened_at` integer",
43
+ ");",
44
+ "CREATE INDEX IF NOT EXISTS `incidents_last_seen` ON `incidents` (`last_seen`);",
45
+ "CREATE TABLE IF NOT EXISTS `dead_letters` (",
46
+ "\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,",
47
+ "\t`queue` text NOT NULL,",
48
+ "\t`message_id` text NOT NULL,",
49
+ "\t`body` text NOT NULL,",
50
+ "\t`attempts` integer NOT NULL,",
51
+ "\t`received_at` integer NOT NULL",
52
+ ");",
53
+ "CREATE INDEX IF NOT EXISTS `dead_letters_queue` ON `dead_letters` (`queue`);",
54
+ "",
55
+ ].join("\n");
@@ -0,0 +1,20 @@
1
+ import type { IncidentReport, IncidentSink } from "louise-toolkit/incidents";
2
+ import { type SecretSource } from "../secrets.js";
3
+ export interface SentryIncidentsOptions {
4
+ /** A tag naming the site, so one Sentry organization can tell sites apart. */
5
+ site?: string;
6
+ /** Sentry's `environment`. Default `"production"`. */
7
+ environment?: string;
8
+ /** Injected for tests. */
9
+ fetch?: typeof fetch;
10
+ }
11
+ /** The Sentry event for a report: its redacted message, its fingerprint, and the
12
+ * cause's stack frames. Never the raw message, the query string, or a body. */
13
+ export declare function sentryEvent(report: IncidentReport, cause: unknown, options?: Pick<SentryIncidentsOptions, "site" | "environment">): Record<string, unknown>;
14
+ /**
15
+ * A sink that sends each report to Sentry, through its envelope endpoint, with
16
+ * no SDK: `sendDefaultPii` has nothing to turn off, because nothing but the
17
+ * redacted report and the stack's frames is sent. Dormant while the DSN is
18
+ * unset or a placeholder, like every Astroid module.
19
+ */
20
+ export declare function sentryIncidents<Env>(dsn: (env: Env) => SecretSource | undefined, options?: SentryIncidentsOptions): IncidentSink<Env>;
@@ -0,0 +1,118 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The Sentry sink (louise-toolkit ADR 0022, amended): Sentry is the operator's
4
+ // issue system for a Monitored or Supported site. It's never the record, and
5
+ // never in louise-toolkit's zero-dependency core (ADR 0016 § 7).
6
+ import { readModuleSecret } from "../secrets.js";
7
+ function parseDsn(dsn) {
8
+ try {
9
+ const url = new URL(dsn);
10
+ const project = url.pathname.replace(/^\/+|\/+$/g, "");
11
+ if (!url.username || !/^\d+$/.test(project))
12
+ return null;
13
+ return { key: url.username, origin: `${url.protocol}//${url.host}`, project };
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ const V8_FRAME = /^\s*at (?:(.+?) \()?(.+?)(?::(\d+))?(?::(\d+))?\)?$/;
20
+ /** The cause's stack as Sentry frames, oldest call first, or none. Function
21
+ * names and file positions only: no message, no values. */
22
+ function frames(cause) {
23
+ let stack;
24
+ try {
25
+ stack = cause?.stack;
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ if (typeof stack !== "string")
31
+ return [];
32
+ const out = [];
33
+ for (const line of stack.split("\n")) {
34
+ if (!line.trimStart().startsWith("at "))
35
+ continue;
36
+ const match = V8_FRAME.exec(line);
37
+ if (!match)
38
+ continue;
39
+ const [, fn, filename, lineno, colno] = match;
40
+ out.push({
41
+ ...(fn ? { function: fn } : {}),
42
+ filename: filename,
43
+ ...(lineno ? { lineno: Number(lineno) } : {}),
44
+ ...(colno ? { colno: Number(colno) } : {}),
45
+ in_app: !filename.includes("node_modules"),
46
+ });
47
+ }
48
+ return out.slice(0, 50).reverse();
49
+ }
50
+ /** The Sentry event for a report: its redacted message, its fingerprint, and the
51
+ * cause's stack frames. Never the raw message, the query string, or a body. */
52
+ export function sentryEvent(report, cause, options = {}) {
53
+ const stack = frames(cause);
54
+ return {
55
+ event_id: crypto.randomUUID().replace(/-/g, ""),
56
+ timestamp: report.at / 1000,
57
+ platform: "javascript",
58
+ level: report.critical ? "fatal" : "error",
59
+ logger: "louise",
60
+ environment: options.environment ?? "production",
61
+ ...(report.release ? { release: report.release } : {}),
62
+ // One D1 row, one Sentry issue: Watchtower joins them on this.
63
+ fingerprint: [report.fingerprint],
64
+ tags: {
65
+ louise_fingerprint: report.fingerprint,
66
+ kind: report.kind,
67
+ critical: String(report.critical),
68
+ ...(options.site ? { site: options.site } : {}),
69
+ ...(report.code ? { code: report.code } : {}),
70
+ },
71
+ ...(report.kind === "fetch" && report.host && report.path
72
+ ? { request: { url: `https://${report.host}${report.path}` } }
73
+ : {}),
74
+ ...(report.path ? { transaction: report.path } : {}),
75
+ exception: {
76
+ values: [
77
+ {
78
+ type: report.name,
79
+ value: report.message,
80
+ ...(stack.length > 0 ? { stacktrace: { frames: stack } } : {}),
81
+ },
82
+ ],
83
+ },
84
+ };
85
+ }
86
+ /**
87
+ * A sink that sends each report to Sentry, through its envelope endpoint, with
88
+ * no SDK: `sendDefaultPii` has nothing to turn off, because nothing but the
89
+ * redacted report and the stack's frames is sent. Dormant while the DSN is
90
+ * unset or a placeholder, like every Astroid module.
91
+ */
92
+ export function sentryIncidents(dsn, options = {}) {
93
+ return async (report, { env, cause }) => {
94
+ const source = dsn(env);
95
+ if (source === undefined)
96
+ return;
97
+ const value = await readModuleSecret(source);
98
+ const parsed = value ? parseDsn(value) : null;
99
+ if (!parsed)
100
+ return;
101
+ const event = sentryEvent(report, cause, options);
102
+ const body = [
103
+ JSON.stringify({ event_id: event.event_id, sent_at: new Date().toISOString() }),
104
+ JSON.stringify({ type: "event" }),
105
+ JSON.stringify(event),
106
+ ].join("\n");
107
+ const response = await (options.fetch ?? fetch)(`${parsed.origin}/api/${parsed.project}/envelope/`, {
108
+ method: "POST",
109
+ headers: {
110
+ "content-type": "application/x-sentry-envelope",
111
+ "x-sentry-auth": `Sentry sentry_version=7, sentry_key=${parsed.key}, sentry_client=astroidjs`,
112
+ },
113
+ body,
114
+ });
115
+ if (!response.ok)
116
+ throw new Error(`Sentry answered ${response.status}`);
117
+ };
118
+ }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./commerce/index.js";
4
4
  export * from "./config.js";
5
5
  export * from "./email/index.js";
6
6
  export * from "./errors.js";
7
+ export * from "./incidents/index.js";
7
8
  export * from "./secrets.js";
8
9
  export * from "./map/index.js";
9
10
  export * from "./portal/index.js";
@@ -15,6 +16,7 @@ export * from "./queues/index.js";
15
16
  export * from "./schema/index.js";
16
17
  export * from "./security/index.js";
17
18
  export * from "./seo/index.js";
19
+ export * from "./shape.js";
18
20
  export * from "./status.js";
19
21
  export * from "./tenancy/index.js";
20
22
  export * from "./worker/index.js";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./commerce/index.js";
9
9
  export * from "./config.js";
10
10
  export * from "./email/index.js";
11
11
  export * from "./errors.js";
12
+ export * from "./incidents/index.js";
12
13
  export * from "./secrets.js";
13
14
  export * from "./map/index.js";
14
15
  export * from "./portal/index.js";
@@ -20,6 +21,7 @@ export * from "./queues/index.js";
20
21
  export * from "./schema/index.js";
21
22
  export * from "./security/index.js";
22
23
  export * from "./seo/index.js";
24
+ export * from "./shape.js";
23
25
  export * from "./status.js";
24
26
  export * from "./tenancy/index.js";
25
27
  export * from "./worker/index.js";
@@ -0,0 +1,19 @@
1
+ import type { D1Client } from "louise-toolkit/db";
2
+ import { type DraftBufferKV } from "louise-toolkit/editor";
3
+ import type { AstroidConfig } from "../config.js";
4
+ /** The bindings a page draft read uses: the database, and the draft buffer. */
5
+ export interface AstroidPageDraftEnv {
6
+ DB: D1Client;
7
+ /** The draft buffer the generated routes save through, when bound. */
8
+ DRAFTS?: DraftBufferKV;
9
+ }
10
+ /**
11
+ * The editor's work-in-progress snapshot of the page with id `pageId`, or
12
+ * `null` when there's none (render the live row). The buffer comes first, then
13
+ * the newest pending draft in D1, the same order a save builds on.
14
+ *
15
+ * Returns the whole snapshot: which fields a page renders from it (`sections`,
16
+ * `body`, `title`) is the site's call. Call it only in edit mode; a visitor
17
+ * sees the live row.
18
+ */
19
+ export declare function astroidPageDraft(config: AstroidConfig, env: AstroidPageDraftEnv, pageId: number): Promise<Record<string, unknown> | null>;