astroidjs 0.18.0 → 0.20.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 (44) hide show
  1. package/README.md +6 -7
  2. package/bin/astroid.mjs +51 -11
  3. package/dist/commerce/adapters.js +3 -3
  4. package/dist/commerce/checkout.js +1 -1
  5. package/dist/commerce/mirror.js +5 -4
  6. package/dist/commerce/roles.js +3 -3
  7. package/dist/commerce/secrets.d.ts +7 -0
  8. package/dist/commerce/secrets.js +12 -3
  9. package/dist/config.d.ts +85 -16
  10. package/dist/config.js +111 -8
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/portal/guard.js +1 -1
  14. package/dist/project/generate.js +96 -52
  15. package/dist/project/index.d.ts +1 -0
  16. package/dist/project/index.js +1 -0
  17. package/dist/project/migrations.d.ts +26 -0
  18. package/dist/project/migrations.js +78 -0
  19. package/dist/project/scaffold.d.ts +6 -0
  20. package/dist/project/scaffold.js +33 -11
  21. package/dist/queues/index.d.ts +1 -1
  22. package/dist/queues/index.js +1 -1
  23. package/dist/queues/messages.d.ts +6 -0
  24. package/dist/queues/messages.js +17 -2
  25. package/dist/queues/scaffold.js +5 -1
  26. package/dist/schema/collections.d.ts +6 -0
  27. package/dist/schema/collections.js +14 -2
  28. package/dist/schema/framework.js +1 -1
  29. package/dist/schema/generate.js +48 -4
  30. package/dist/security/index.d.ts +1 -1
  31. package/dist/security/index.js +1 -1
  32. package/dist/security/rate-rules.d.ts +9 -2
  33. package/dist/security/rate-rules.js +46 -22
  34. package/dist/seo/routes.js +3 -2
  35. package/dist/shape.d.ts +6 -0
  36. package/dist/shape.js +14 -0
  37. package/dist/status.js +22 -10
  38. package/dist/worker/generate.js +142 -10
  39. package/dist/workflow/advance.js +1 -1
  40. package/dist/workflow/config.js +1 -1
  41. package/package.json +3 -3
  42. package/src/components/Credit.astro +70 -0
  43. package/src/components/StageBar.astro +1 -1
  44. package/src/components/media-meta.ts +1 -1
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export * from "./queues/index.js";
15
15
  export * from "./schema/index.js";
16
16
  export * from "./security/index.js";
17
17
  export * from "./seo/index.js";
18
+ export * from "./shape.js";
18
19
  export * from "./status.js";
19
20
  export * from "./tenancy/index.js";
20
21
  export * from "./worker/index.js";
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export * from "./queues/index.js";
20
20
  export * from "./schema/index.js";
21
21
  export * from "./security/index.js";
22
22
  export * from "./seo/index.js";
23
+ export * from "./shape.js";
23
24
  export * from "./status.js";
24
25
  export * from "./tenancy/index.js";
25
26
  export * from "./worker/index.js";
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Role-gated routing for the portal.
4
4
  //
5
- // coracle and ghostfire independently built the same thing: a declarative table
5
+ // Two client sites independently built the same thing: a declarative table
6
6
  // of `prefix → roles`, walked once per request. Declarative rather than a guard
7
7
  // call inside each page, because a guard you have to remember to write is a
8
8
  // guard someone eventually forgets—and the page that forgets it is the one
@@ -17,10 +17,11 @@
17
17
  import { ASTROID_VITALS_BINDING, astroidVitalsDataset } from "../analytics/index.js";
18
18
  import { astroidCheckoutVars } from "../commerce/checkout-scaffold.js";
19
19
  import { astroidCommerceProviders } from "../commerce/roles.js";
20
- import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceSecretNames, } from "../commerce/secrets.js";
20
+ import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceProviderWebhookSecrets, commerceSecretNames, } from "../commerce/secrets.js";
21
21
  import { ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
22
22
  import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, usesRealtime, } from "../realtime/scaffold.js";
23
23
  import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
24
+ import { astroidHasEditor } from "../shape.js";
24
25
  import { tenancyZone } from "../tenancy/index.js";
25
26
  import { generateAstroidSchema } from "../schema/generate.js";
26
27
  import { ASTROID_AI_GATEWAY_VAR } from "../worker/gateway.js";
@@ -93,7 +94,10 @@ export function generateAstroidSecretsEnv(config) {
93
94
  for (const provider of providers) {
94
95
  const spec = COMMERCE_PROVIDER_SECRETS[provider];
95
96
  lines.push("#", `# ${provider}: ${COMMERCE_PROVIDER_SETUP[provider]}`);
96
- for (const name of [...spec.credentials, spec.webhook]) {
97
+ for (const name of [
98
+ ...spec.credentials,
99
+ ...commerceProviderWebhookSecrets(provider, config.commerce),
100
+ ]) {
97
101
  lines.push(`${name}=${ASTROID_SECRET_PLACEHOLDER}`);
98
102
  }
99
103
  }
@@ -116,6 +120,12 @@ const COMPATIBILITY_DATE = "2026-06-20";
116
120
  */
117
121
  export function generateAstroidWrangler(config) {
118
122
  const key = config.key;
123
+ // An app with no editor binds none of what the editor uses: the media bucket
124
+ // and Images, the draft buffer, Workers AI, and the CWV dataset. It sends mail
125
+ // only for a portal's password resets.
126
+ const editor = astroidHasEditor(config);
127
+ const mail = editor || Boolean(config.portal?.enabled);
128
+ const crons = astroidCrons(config);
119
129
  const mediaBase = config.deploy?.mediaBase ?? "/media";
120
130
  const hosts = config.hosts ?? [];
121
131
  const primaryHost = hosts[0];
@@ -184,7 +194,10 @@ export function generateAstroidWrangler(config) {
184
194
  // Daily: the site-health scan (broken links, missing alt text, SEO gaps).
185
195
  // Hourly (commerce only): the catalog re-sync safety net, so a missed or DLQ'd
186
196
  // webhook can only leave the site stale until the next tick.
187
- p(` "triggers": { "crons": ${JSON.stringify(astroidCrons(config))} },`);
197
+ // None at all for an app with nothing scheduled: a trigger with no handler
198
+ // is an invocation that fails every time it fires.
199
+ if (crons.length > 0)
200
+ p(` "triggers": { "crons": ${JSON.stringify(crons)} },`);
188
201
  if (astroidUsesQueues(config)) {
189
202
  const { queue, dlq } = astroidQueueNames(config);
190
203
  p(" // Provider webhooks are verified at the edge, then enqueued here so the");
@@ -208,41 +221,59 @@ export function generateAstroidWrangler(config) {
208
221
  p(" ],");
209
222
  p(" },");
210
223
  }
211
- p(" // D1 holds pages / site_settings / media / inquiries (schema in src/schema.ts,");
212
- p(" // migrations in ./migrations). Create it: `wrangler d1 create <name>`.");
224
+ if (editor) {
225
+ p(" // D1 holds pages / site_settings / media / inquiries (schema in src/schema.ts,");
226
+ p(" // migrations in ./migrations). Create it: `wrangler d1 create <name>`.");
227
+ }
228
+ else {
229
+ p(" // D1: this app's tables (src/schema.ts), or the database of the app that");
230
+ p(" // owns the schema, bound by its id. Create one: `wrangler d1 create <name>`.");
231
+ }
213
232
  p(' "d1_databases": [');
214
233
  p(" {");
215
234
  p(' "binding": "DB",');
216
235
  p(` "database_name": ${JSON.stringify(key)},`);
217
236
  p(' "database_id": "<run: wrangler d1 create ' + key + '>",');
218
- p(' "migrations_dir": "migrations",');
237
+ // Left out when another app migrates this database (`deploy.migrations:
238
+ // false`), which `astroid doctor` would otherwise report as a contradiction.
239
+ if (config.deploy?.migrations !== false)
240
+ p(' "migrations_dir": "migrations",');
219
241
  p(" },");
220
242
  p(" ],");
221
- p(" // R2 bucket for uploaded media, streamed back through the Worker at MEDIA_URL");
222
- p(" // (no public bucket). Create it: `wrangler r2 bucket create <name>-media`.");
223
- p(` "r2_buckets": [{ "binding": "MEDIA", "bucket_name": ${JSON.stringify(`${key}-media`)} }],`);
224
- p(" // Cloudflare Images: the media route reads upload dimensions + backs server-");
225
- p(" // side re-encode. Also @astrojs/cloudflare's production image service.");
226
- p(' "images": { "binding": "IMAGES" },');
227
- p(" // Analytics Engine: real-visitor Core Web Vitals. Free, and the ingest");
228
- p(" // route accepts-and-drops without it, so it costs nothing unused. Reading");
229
- p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example)—");
230
- p(" // until those are real the Health badge reads 'not measured yet'.");
231
- p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_VITALS_BINDING)}, "dataset": ${JSON.stringify(astroidVitalsDataset(config))} }],`);
232
- p(" // Workers AI. Powers the editor's rewrite + SEO-suggest buttons and alt-text");
233
- p(" // generation on upload—all of which SHIP IN THE EDITOR DRAWER already and,");
234
- p(" // without this binding, were permanently invisible: their routes answer 503");
235
- p(" // and the client hides the button. No account setup beyond the binding, and");
236
- p(" // every call is editor-gated, so a visitor can never spend your AI budget.");
237
- p(' "ai": { "binding": "AI" },');
238
- p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
239
- p(" // summary under its own key—one small singleton blob, not worth a binding");
240
- p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
241
- p(" // Named for the project, so two sites in one account don't collide.");
242
- p(" // `astroid provision` creates each and fills in its id.");
243
+ if (editor) {
244
+ p(" // R2 bucket for uploaded media, streamed back through the Worker at MEDIA_URL");
245
+ p(" // (no public bucket). Create it: `wrangler r2 bucket create <name>-media`.");
246
+ p(` "r2_buckets": [{ "binding": "MEDIA", "bucket_name": ${JSON.stringify(`${key}-media`)} }],`);
247
+ p(" // Cloudflare Images: the media route reads upload dimensions + backs server-");
248
+ p(" // side re-encode. Also @astrojs/cloudflare's production image service.");
249
+ p(' "images": { "binding": "IMAGES" },');
250
+ p(" // Analytics Engine: real-visitor Core Web Vitals. Free, and the ingest");
251
+ p(" // route accepts-and-drops without it, so it costs nothing unused. Reading");
252
+ p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example)—");
253
+ p(" // until those are real the Health badge reads 'not measured yet'.");
254
+ p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_VITALS_BINDING)}, "dataset": ${JSON.stringify(astroidVitalsDataset(config))} }],`);
255
+ p(" // Workers AI. Powers the editor's rewrite + SEO-suggest buttons and alt-text");
256
+ p(" // generation on upload—all of which SHIP IN THE EDITOR DRAWER already and,");
257
+ p(" // without this binding, were permanently invisible: their routes answer 503");
258
+ p(" // and the client hides the button. No account setup beyond the binding, and");
259
+ p(" // every call is editor-gated, so a visitor can never spend your AI budget.");
260
+ p(' "ai": { "binding": "AI" },');
261
+ p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
262
+ p(" // summary under its own key—one small singleton blob, not worth a binding");
263
+ p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
264
+ p(" // Named for the project, so two sites in one account don't collide.");
265
+ p(" // `astroid provision` creates each and fills in its id.");
266
+ }
267
+ else {
268
+ p(" // KV: RL = the security rate limiter. Named for the project, so two apps in");
269
+ p(" // one account don't collide. `astroid provision` creates it and fills in");
270
+ p(" // its id.");
271
+ }
243
272
  p(' "kv_namespaces": [');
244
273
  p(` { "binding": "RL", "id": "<run: wrangler kv namespace create ${key}-rl>" },`);
245
- p(` { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create ${key}-drafts>" },`);
274
+ if (editor) {
275
+ p(` { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create ${key}-drafts>" },`);
276
+ }
246
277
  p(" ],");
247
278
  // Email Sending. NOT optional decoration: `src/env.d.ts` declares EMAIL as a
248
279
  // required member, and Better Auth's magic-link path console-logs the link in
@@ -251,31 +282,44 @@ export function generateAstroidWrangler(config) {
251
282
  // sign-in was impossible on every DEPLOYED site, while every local build
252
283
  // and every CI scaffold passed. Nothing in this repo runs a deployed scaffold,
253
284
  // which is why it survived.
254
- p(" // Cloudflare Email Sending—magic-link sign-in + inquiry notifications.");
255
- p(" // Sign-in DEPENDS on this: in production the magic link is emailed, not logged.");
256
- p(" // Enable Email Sending for your zone, then verify the address in MAIL_FROM.");
257
- p(' "send_email": [{ "name": "EMAIL" }],');
258
- p(" // Public base for media URLs; same-origin keeps media self-contained. Read off");
259
- p(" // the runtime env by the framework-agnostic media route, so it stays a `var`.");
285
+ if (editor) {
286
+ p(" // Cloudflare Email Sending—magic-link sign-in + inquiry notifications.");
287
+ p(" // Sign-in DEPENDS on this: in production the magic link is emailed, not logged.");
288
+ }
289
+ else if (mail) {
290
+ p(" // Cloudflare Email Sending—the portal's password-reset mail, which is");
291
+ p(" // logged in dev and emailed in production.");
292
+ }
293
+ if (mail) {
294
+ p(" // Enable Email Sending for your zone, then verify the address in MAIL_FROM.");
295
+ p(' "send_email": [{ "name": "EMAIL" }],');
296
+ }
297
+ if (editor) {
298
+ p(" // Public base for media URLs; same-origin keeps media self-contained. Read off");
299
+ p(" // the runtime env by the framework-agnostic media route, so it stays a `var`.");
300
+ }
260
301
  p(' "vars": {');
261
- p(` "MEDIA_URL": ${JSON.stringify(mediaBase)},`);
302
+ if (editor)
303
+ p(` "MEDIA_URL": ${JSON.stringify(mediaBase)},`);
262
304
  p(` "SITE_URL": ${JSON.stringify(primaryHost ? `https://${primaryHost}` : `https://${key}.workers.dev`)},`);
263
- p(" // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).");
264
- p(' "OWNER_EMAIL": "",');
265
- p(" // AI Gateway for the editor's AI assists: request logs, latency and error");
266
- p(" // rates, and caching. Empty calls Workers AI directly. Create a gateway,");
267
- p(" // put its id here, and first say on the privacy page that its log holds");
268
- p(" // the text editors send to the assists.");
269
- p(` "${ASTROID_AI_GATEWAY_VAR}": "",`);
270
- p(" // Edge caching for published pages (ADR 0004). OFF by default, and the");
271
- p(" // default is the safe state: with it off every render is `no-store` and");
272
- p(" // the Worker cache layer stores nothing.");
273
- p(" //");
274
- p(" // Turn it on for a PREVIEW deploy first and walk the activation runbook");
275
- p(" // (docs/adr/0004-edge-caching.md). `caches.default` is not cleared by");
276
- p(" // Cloudflare Dev Mode or Purge Everything, so a bad prod flip is hard to");
277
- p(" // undo—this feature was reverted twice for exactly that.");
278
- p(' "ASTROID_EDGE_CACHE": "false",');
305
+ if (editor) {
306
+ p(" // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).");
307
+ p(' "OWNER_EMAIL": "",');
308
+ p(" // AI Gateway for the editor's AI assists: request logs, latency and error");
309
+ p(" // rates, and caching. Empty calls Workers AI directly. Create a gateway,");
310
+ p(" // put its id here, and first say on the privacy page that its log holds");
311
+ p(" // the text editors send to the assists.");
312
+ p(` "${ASTROID_AI_GATEWAY_VAR}": "",`);
313
+ p(" // Edge caching for published pages (ADR 0004). OFF by default, and the");
314
+ p(" // default is the safe state: with it off every render is `no-store` and");
315
+ p(" // the Worker cache layer stores nothing.");
316
+ p(" //");
317
+ p(" // Turn it on for a PREVIEW deploy first and walk the activation runbook");
318
+ p(" // (docs/adr/0004-edge-caching.md). `caches.default` is not cleared by");
319
+ p(" // Cloudflare Dev Mode or Purge Everything, so a bad prod flip is hard to");
320
+ p(" // undo—this feature was reverted twice for exactly that.");
321
+ p(' "ASTROID_EDGE_CACHE": "false",');
322
+ }
279
323
  for (const v of astroidCheckoutVars(config)) {
280
324
  // Public, not secret—the app id ships to the browser to mount the card
281
325
  // field, and the environment is a choice. Keeping them out of the secret
@@ -1,6 +1,7 @@
1
1
  export * from "./generate.js";
2
2
  export * from "./actions.js";
3
3
  export * from "./scaffold.js";
4
+ export * from "./migrations.js";
4
5
  export * from "./seed.js";
5
6
  export * from "./previews.js";
6
7
  export * from "./release.js";
@@ -5,6 +5,7 @@
5
5
  export * from "./generate.js";
6
6
  export * from "./actions.js";
7
7
  export * from "./scaffold.js";
8
+ export * from "./migrations.js";
8
9
  export * from "./seed.js";
9
10
  export * from "./previews.js";
10
11
  export * from "./release.js";
@@ -0,0 +1,26 @@
1
+ import type { ScaffoldFile } from "./scaffold.js";
2
+ /**
3
+ * The `DB` binding's `migrations_dir` from `wrangler.jsonc`, or Wrangler's
4
+ * default `migrations` when the binding sets none or the file is missing.
5
+ * Returned as written, without a trailing slash.
6
+ */
7
+ export declare function astroidMigrationsDir(wrangler: string | null | undefined): string;
8
+ /**
9
+ * Resolve each scaffold file's path against the site's migrations directory.
10
+ * Files that aren't migrations pass through unchanged. For a migration:
11
+ *
12
+ * - **Already there under any number:** the path is that file's, so a caller
13
+ * that skips existing files skips it. A site that copied `page_redirects`
14
+ * in by hand as `0009_page_redirects.sql` keeps that file and gets no second.
15
+ * - **Default number free and past the newest migration:** the default, so a
16
+ * standard project numbers exactly as before.
17
+ * - **Otherwise:** the next number after the newest migration, at the same
18
+ * width. A migration never takes a number the site already uses, and never
19
+ * sorts before one that already ran.
20
+ *
21
+ * `existing` is the file names in `migrationsDir`, in any order.
22
+ */
23
+ export declare function resolveAstroidScaffoldPaths(files: ScaffoldFile[], { migrationsDir, existing }: {
24
+ migrationsDir: string;
25
+ existing: string[];
26
+ }): ScaffoldFile[];
@@ -0,0 +1,78 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Where a scaffolded D1 migration lands in a site. The scaffold names each
4
+ // migration with a default path (`migrations/0004_page_redirects.sql`), but a
5
+ // site can point its `DB` binding at another directory with `migrations_dir`,
6
+ // and it may already number its own migrations past the default. Wrangler
7
+ // applies only the directory in `migrations_dir` and tracks each file by name,
8
+ // so a migration written anywhere else never runs, and one written onto a
9
+ // number the site already uses leaves two files claiming that number.
10
+ import { parseJsonc } from "./previews.js";
11
+ /** Wrangler's own default when a D1 binding sets no `migrations_dir`. */
12
+ const DEFAULT_MIGRATIONS_DIR = "migrations";
13
+ /** `0004_page_redirects.sql` → number `"0004"`, name `page_redirects`. */
14
+ const MIGRATION_FILE = /^(\d+)_(.+)\.sql$/;
15
+ /**
16
+ * The `DB` binding's `migrations_dir` from `wrangler.jsonc`, or Wrangler's
17
+ * default `migrations` when the binding sets none or the file is missing.
18
+ * Returned as written, without a trailing slash.
19
+ */
20
+ export function astroidMigrationsDir(wrangler) {
21
+ if (!wrangler)
22
+ return DEFAULT_MIGRATIONS_DIR;
23
+ let parsed;
24
+ try {
25
+ parsed = parseJsonc(wrangler);
26
+ }
27
+ catch {
28
+ return DEFAULT_MIGRATIONS_DIR;
29
+ }
30
+ const dir = (parsed.d1_databases ?? []).find((d) => d.binding === "DB")?.migrations_dir;
31
+ return dir ? dir.replace(/\/+$/, "") : DEFAULT_MIGRATIONS_DIR;
32
+ }
33
+ /**
34
+ * Resolve each scaffold file's path against the site's migrations directory.
35
+ * Files that aren't migrations pass through unchanged. For a migration:
36
+ *
37
+ * - **Already there under any number:** the path is that file's, so a caller
38
+ * that skips existing files skips it. A site that copied `page_redirects`
39
+ * in by hand as `0009_page_redirects.sql` keeps that file and gets no second.
40
+ * - **Default number free and past the newest migration:** the default, so a
41
+ * standard project numbers exactly as before.
42
+ * - **Otherwise:** the next number after the newest migration, at the same
43
+ * width. A migration never takes a number the site already uses, and never
44
+ * sorts before one that already ran.
45
+ *
46
+ * `existing` is the file names in `migrationsDir`, in any order.
47
+ */
48
+ export function resolveAstroidScaffoldPaths(files, { migrationsDir, existing }) {
49
+ const taken = new Map();
50
+ const byName = new Map();
51
+ for (const file of existing) {
52
+ const match = MIGRATION_FILE.exec(file);
53
+ if (!match)
54
+ continue;
55
+ taken.set(Number(match[1]), file);
56
+ byName.set(match[2], file);
57
+ }
58
+ let newest = taken.size ? Math.max(...taken.keys()) : -1;
59
+ return files.map((file) => {
60
+ if (!file.migration)
61
+ return file;
62
+ const base = file.path.slice(file.path.lastIndexOf("/") + 1);
63
+ const match = MIGRATION_FILE.exec(base);
64
+ if (!match)
65
+ return { ...file, path: `${migrationsDir}/${base}` };
66
+ const [, digits, name] = match;
67
+ const present = byName.get(name);
68
+ if (present)
69
+ return { ...file, path: `${migrationsDir}/${present}` };
70
+ const wanted = Number(digits);
71
+ const number = !taken.has(wanted) && wanted > newest ? wanted : newest + 1;
72
+ const filename = `${String(number).padStart(digits.length, "0")}_${name}.sql`;
73
+ taken.set(number, filename);
74
+ byName.set(name, filename);
75
+ newest = Math.max(newest, number);
76
+ return { ...file, path: `${migrationsDir}/${filename}` };
77
+ });
78
+ }
@@ -18,6 +18,12 @@ export interface ScaffoldFile {
18
18
  * Without it a re-run would append a duplicate every time.
19
19
  */
20
20
  marker?: string;
21
+ /**
22
+ * A D1 migration. `path` is its default, `migrations/NNNN_name.sql`; the CLI
23
+ * places it in the `DB` binding's `migrations_dir` and renumbers it past the
24
+ * site's own migrations with {@link resolveAstroidScaffoldPaths}.
25
+ */
26
+ migration?: true;
21
27
  }
22
28
  /**
23
29
  * `migrations/0004_page_redirects.sql`: the table that keeps a renamed page's
@@ -31,6 +31,7 @@ import { cwvBeaconScript } from "louise-toolkit/analytics";
31
31
  import { generateMapEmbedComponent, generateMapTileRoute } from "../map/scaffold.js";
32
32
  import { generateAstroidGalleryPage } from "../portfolio/scaffold.js";
33
33
  import { astroidPortal } from "../portal/config.js";
34
+ import { astroidHasEditor } from "../shape.js";
34
35
  import { generateAstroidPortalAuth, generateAstroidPortalAuthRoute } from "../portal/scaffold.js";
35
36
  import { generateAstroidTenancy } from "../tenancy/index.js";
36
37
  import { generateAstroidEditSession } from "../realtime/scaffold.js";
@@ -154,26 +155,45 @@ function generateAstroidSettingsHooks() {
154
155
  */
155
156
  export function generateAstroidScaffoldFiles(config) {
156
157
  const files = [];
158
+ // An app with no editor gets none of the editor's files: the content
159
+ // migrations, the CWV beacon its Health panel reads, the Actions surface
160
+ // over pages and settings, and the gallery page over the media library.
161
+ const editor = astroidHasEditor(config);
157
162
  // --- commerce: the catalog table's migration ------------------------------
158
163
  // Numbered 0003 so it lands after the template's 0000_content and the auth
159
164
  // pair (0001, 0002) that `create-astroid` writes. Without it `--commerce`
160
165
  // scaffolded a `products` table into src/schema.ts that no migration ever
161
166
  // created, and the first sync wrote nothing while reporting success.
162
167
  const catalogSql = generateCatalogMigrationSql(config);
163
- if (catalogSql)
164
- files.push({ path: "migrations/0003_catalog.sql", contents: catalogSql });
168
+ if (catalogSql) {
169
+ files.push({ path: "migrations/0003_catalog.sql", contents: catalogSql, migration: true });
170
+ }
165
171
  // --- louise-toolkit 0.35's two schema changes -----------------------------
166
172
  // Numbered after the catalog's 0003, and written into an existing site by
167
173
  // `astroid generate` because a missing scaffold file is always written. The
168
174
  // alt update is a no-op on a fresh database. Wrangler tracks migrations by
169
- // filename, so a site that already has its own 0004 keeps both.
170
- files.push({ path: "migrations/0004_page_redirects.sql", contents: ASTROID_PAGE_REDIRECTS_MIGRATION }, { path: "migrations/0005_media_alt_undecided.sql", contents: ASTROID_MEDIA_ALT_MIGRATION });
175
+ // filename. The CLI moves each into the site's `migrations_dir` and past
176
+ // the site's own numbers (see migrations.ts), so a site that already has its
177
+ // own 0004 gets the next free number instead of a second 0004.
178
+ if (editor) {
179
+ files.push({
180
+ path: "migrations/0004_page_redirects.sql",
181
+ contents: ASTROID_PAGE_REDIRECTS_MIGRATION,
182
+ migration: true,
183
+ }, {
184
+ path: "migrations/0005_media_alt_undecided.sql",
185
+ contents: ASTROID_MEDIA_ALT_MIGRATION,
186
+ migration: true,
187
+ });
188
+ }
171
189
  // --- the CWV beacon -------------------------------------------------------
172
190
  // A static file under public/, so it is same-origin and covered by
173
191
  // `script-src 'self'`—an inline script carrying generated content could not
174
192
  // be hashed into the CSP and would be blocked.
175
- const beacon = generateAstroidVitalsBeacon(config, cwvBeaconScript());
176
- files.push({ path: beacon.path, contents: beacon.contents });
193
+ if (editor) {
194
+ const beacon = generateAstroidVitalsBeacon(config, cwvBeaconScript());
195
+ files.push({ path: beacon.path, contents: beacon.contents });
196
+ }
177
197
  // --- site-owned schema tables --------------------------------------------
178
198
  // Always: the generated src/schema.ts re-exports `./schema.site.js`, so the
179
199
  // file must exist even when empty. A project declares tables Astroid doesn't
@@ -207,10 +227,12 @@ export function generateAstroidScaffoldFiles(config) {
207
227
  files.push({ path: "src/settings-hooks.ts", contents: generateAstroidSettingsHooks() });
208
228
  }
209
229
  // --- the typed Astro Actions surface --------------------------------------
210
- // Always: every project has editable pages, and the routes alone leave the
211
- // Astro-native half of ADR 0001 unbuilt. Scaffold-once because it is meant to
212
- // be added to.
213
- files.push({ path: "src/actions/index.ts", contents: generateAstroidActions(config) });
230
+ // Every project with an editor: it has editable pages, and the routes alone
231
+ // leave the Astro-native half of ADR 0001 unbuilt. Scaffold-once because it
232
+ // is meant to be added to.
233
+ if (editor) {
234
+ files.push({ path: "src/actions/index.ts", contents: generateAstroidActions(config) });
235
+ }
214
236
  // --- commerce: the server-authoritative payment seam ----------------------
215
237
  // Scaffold-once: a real store adds shipping, tax, an order row, a receipt.
216
238
  // What's fixed is the sequence that keeps a charge correct.
@@ -232,7 +254,7 @@ export function generateAstroidScaffoldFiles(config) {
232
254
  }
233
255
  // --- portfolio: the gallery page -----------------------------------------
234
256
  // "Which assets appear, in what order" is the first thing a portfolio changes.
235
- const gallery = generateAstroidGalleryPage(config);
257
+ const gallery = editor ? generateAstroidGalleryPage(config) : null;
236
258
  if (gallery)
237
259
  files.push({ path: "src/pages/work.astro", contents: gallery });
238
260
  // --- pwa: the service worker, manifest, and its headers -------------------
@@ -1,4 +1,4 @@
1
1
  export { astroidQueueHandler, type QueueHandlerOptions } from "./consumer.js";
2
- export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, type AstroidQueueMessage, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, type CatalogRefreshMessage, type WebhookMessage, } from "./messages.js";
2
+ export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, type AstroidQueueMessage, astroidCommercePipeline, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, type CatalogRefreshMessage, type WebhookMessage, } from "./messages.js";
3
3
  export { generateAstroidEnvBindings, generateAstroidQueueSeam, generateAstroidWebhookRoute, generateAstroidWebhookRoutes, } from "./scaffold.js";
4
4
  export { astroidQueue, handleWebhook, type QueueProducer, type WebhookRouteOptions, type WebhookVerifyInput, } from "./webhook.js";
@@ -1,5 +1,5 @@
1
1
  // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
2
  export { astroidQueueHandler } from "./consumer.js";
3
- export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "./messages.js";
3
+ export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCommercePipeline, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "./messages.js";
4
4
  export { generateAstroidEnvBindings, generateAstroidQueueSeam, generateAstroidWebhookRoute, generateAstroidWebhookRoutes, } from "./scaffold.js";
5
5
  export { astroidQueue, handleWebhook, } from "./webhook.js";
@@ -7,6 +7,12 @@ import type { AstroidConfig } from "../config.js";
7
7
  * provider's delivery timeout is shorter than your catalog sync.
8
8
  */
9
9
  export declare function astroidUsesQueues(config: AstroidConfig): boolean;
10
+ /**
11
+ * Whether this project runs the commerce pipeline: the webhook receivers and the
12
+ * catalog re-sync. On whenever commerce is configured, unless the config sets
13
+ * `commerce.pipeline: false` because another project runs it.
14
+ */
15
+ export declare function astroidCommercePipeline(config: AstroidConfig): boolean;
10
16
  /** Hourly. Frequent enough that stale data has a bounded lifetime, rare enough
11
17
  * to be free. */
12
18
  export declare const ASTROID_DEFAULT_CRON = "0 * * * *";
@@ -1,6 +1,7 @@
1
1
  // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
2
  //
3
3
  // What flows through the project's queue, and when it matters.
4
+ import { astroidHasEditor } from "../shape.js";
4
5
  /**
5
6
  * Whether this project runs a queue consumer + cron.
6
7
  *
@@ -9,7 +10,15 @@
9
10
  * provider's delivery timeout is shorter than your catalog sync.
10
11
  */
11
12
  export function astroidUsesQueues(config) {
12
- return config.queues?.enabled ?? Boolean(config.commerce);
13
+ return config.queues?.enabled ?? astroidCommercePipeline(config);
14
+ }
15
+ /**
16
+ * Whether this project runs the commerce pipeline: the webhook receivers and the
17
+ * catalog re-sync. On whenever commerce is configured, unless the config sets
18
+ * `commerce.pipeline: false` because another project runs it.
19
+ */
20
+ export function astroidCommercePipeline(config) {
21
+ return Boolean(config.commerce) && config.commerce?.pipeline !== false;
13
22
  }
14
23
  /** Hourly. Frequent enough that stale data has a bounded lifetime, rare enough
15
24
  * to be free. */
@@ -18,6 +27,10 @@ export const ASTROID_DEFAULT_CRON = "0 * * * *";
18
27
  export function astroidCron(config) {
19
28
  if (!astroidUsesQueues(config))
20
29
  return null;
30
+ // The re-sync is part of the pipeline, so a project that leaves the pipeline
31
+ // to another one leaves this to it too, even when its queue runs for crons.
32
+ if (config.commerce && !astroidCommercePipeline(config))
33
+ return null;
21
34
  const cron = config.queues?.cron;
22
35
  if (cron === false)
23
36
  return null;
@@ -40,7 +53,9 @@ export const ASTROID_HEALTH_CRON = "17 4 * * *";
40
53
  * agree exactly, and a mismatch is a job that silently never runs.
41
54
  */
42
55
  export function astroidCrons(config) {
43
- const crons = [ASTROID_HEALTH_CRON];
56
+ // The health scan reports to the editor's Health panel, over the editor's
57
+ // pages and media, so an app with no editor has nothing to scan or show.
58
+ const crons = astroidHasEditor(config) ? [ASTROID_HEALTH_CRON] : [];
44
59
  const catalog = astroidCron(config);
45
60
  if (catalog)
46
61
  crons.push(catalog);
@@ -11,7 +11,7 @@
11
11
  import { astroidCatalogMirror } from "../commerce/mirror.js";
12
12
  import { astroidCommerceProviders, astroidCommerceRoles } from "../commerce/roles.js";
13
13
  import { COMMERCE_PROVIDER_SECRETS } from "../commerce/secrets.js";
14
- import { ASTROID_QUEUE_BINDING } from "./messages.js";
14
+ import { ASTROID_QUEUE_BINDING, astroidCommercePipeline } from "./messages.js";
15
15
  /**
16
16
  * Per-provider webhook facts: the header, the verifier, and how it's called.
17
17
  *
@@ -244,6 +244,10 @@ export function generateAstroidWebhookRoute(config, forProvider) {
244
244
  * two roles still has one endpoint and one secret.
245
245
  */
246
246
  export function generateAstroidWebhookRoutes(config) {
247
+ // A project that leaves the pipeline to another one receives no webhooks: the
248
+ // provider delivers each event to one endpoint, and it's the other project's.
249
+ if (!astroidCommercePipeline(config))
250
+ return [];
247
251
  return astroidCommerceProviders(config.commerce).flatMap((provider) => {
248
252
  const contents = generateAstroidWebhookRoute(config, provider);
249
253
  return contents ? [{ path: `src/pages/api/webhooks/${provider}.ts`, contents }] : [];
@@ -69,6 +69,12 @@ export interface AstroidPagesHooks {
69
69
  * with a 422 instead.
70
70
  */
71
71
  export declare const ASTROID_RESERVED_SLUGS: readonly string[];
72
+ /**
73
+ * The reserved slugs for this config: {@link ASTROID_RESERVED_SLUGS}, plus the
74
+ * file routes a module scaffolds, minus any in `pages.allowSlugs`. A
75
+ * portfolio's gallery is `src/pages/work.astro`.
76
+ */
77
+ export declare function astroidReservedSlugs(config: AstroidConfig): string[];
72
78
  export declare function astroidPagesWriteHooks(config: AstroidConfig, site?: AstroidPagesHooks): {
73
79
  sanitize: (html: string) => string;
74
80
  transform: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => Promise<Record<string, unknown>>;
@@ -47,7 +47,7 @@ const pageMediaBase = astroidMediaBase;
47
47
  * The section catalog a `pages` write is validated + sanitized against: the
48
48
  * site's own (`config.sectionCatalog`) when it registered bespoke sections, else
49
49
  * Astroid's built-in vocabulary. This is what lets a site with its own section
50
- * designs (coracle's 13) keep the same write contract as a stock Astroid site.
50
+ * designs keep the same write contract as a stock Astroid site.
51
51
  */
52
52
  function resolveSectionCatalog(config) {
53
53
  return config.sectionCatalog ?? astroidSectionCatalog;
@@ -109,9 +109,21 @@ export const ASTROID_RESERVED_SLUGS = [
109
109
  "_astro",
110
110
  "api",
111
111
  "cdn-cgi",
112
+ // The scaffold's own file routes, which every site has.
113
+ "contact",
114
+ "login",
112
115
  "robots.txt",
113
116
  "sitemap.xml",
114
117
  ];
118
+ /**
119
+ * The reserved slugs for this config: {@link ASTROID_RESERVED_SLUGS}, plus the
120
+ * file routes a module scaffolds, minus any in `pages.allowSlugs`. A
121
+ * portfolio's gallery is `src/pages/work.astro`.
122
+ */
123
+ export function astroidReservedSlugs(config) {
124
+ const allowed = new Set(config.pages?.allowSlugs ?? []);
125
+ return [...ASTROID_RESERVED_SLUGS, ...(config.archetype === "portfolio" ? ["work"] : [])].filter((slug) => !allowed.has(slug));
126
+ }
115
127
  export function astroidPagesWriteHooks(config, site = {}) {
116
128
  return {
117
129
  // `body` is a richField, so it goes through pagesRoute's own sanitize seam—with
@@ -127,7 +139,7 @@ export function astroidPagesWriteHooks(config, site = {}) {
127
139
  await site.validate?.(data, ctx);
128
140
  await assertAstroidPageSections(config, data, ctx.operation);
129
141
  },
130
- reservedSlugs: [...ASTROID_RESERVED_SLUGS, ...(site.reservedSlugs ?? [])],
142
+ reservedSlugs: [...astroidReservedSlugs(config), ...(site.reservedSlugs ?? [])],
131
143
  };
132
144
  }
133
145
  export function astroidPagesCollection(config) {