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 +32 -8
- package/dist/commerce/secrets.d.ts +7 -0
- package/dist/commerce/secrets.js +12 -3
- package/dist/config.d.ts +74 -2
- package/dist/config.js +84 -1
- package/dist/incidents/index.d.ts +2 -0
- package/dist/incidents/index.js +12 -0
- package/dist/incidents/names.d.ts +17 -0
- package/dist/incidents/names.js +55 -0
- package/dist/incidents/sentry.d.ts +20 -0
- package/dist/incidents/sentry.js +118 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pages/index.d.ts +19 -0
- package/dist/pages/index.js +34 -0
- package/dist/project/generate.js +109 -52
- package/dist/project/scaffold.js +41 -16
- package/dist/queues/index.d.ts +1 -1
- package/dist/queues/index.js +1 -1
- package/dist/queues/messages.d.ts +6 -0
- package/dist/queues/messages.js +17 -2
- package/dist/queues/scaffold.js +5 -1
- package/dist/schema/generate.js +67 -4
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/rate-rules.d.ts +9 -2
- package/dist/security/rate-rules.js +46 -22
- package/dist/seo/routes.js +3 -2
- package/dist/shape.d.ts +6 -0
- package/dist/shape.js +14 -0
- package/dist/status.js +22 -10
- package/dist/worker/generate.js +232 -18
- package/package.json +10 -3
- package/src/components/Credit.astro +70 -0
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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;
|
package/dist/commerce/secrets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
* you process inline is a webhook you drop when the
|
|
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,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>;
|