astroidjs 0.13.0 → 0.15.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 +10 -4
- package/dist/analytics/index.js +2 -2
- package/dist/commerce/checkout-scaffold.js +18 -18
- package/dist/commerce/mirror.js +3 -3
- package/dist/config.d.ts +27 -0
- package/dist/map/scaffold.d.ts +1 -1
- package/dist/map/scaffold.js +36 -23
- package/dist/portal/config.d.ts +1 -1
- package/dist/portal/config.js +9 -8
- package/dist/portal/scaffold.js +7 -7
- package/dist/portal/session.d.ts +9 -3
- package/dist/portal/session.js +5 -0
- package/dist/portfolio/scaffold.js +4 -4
- package/dist/project/actions.js +12 -6
- package/dist/project/generate.js +11 -11
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +1 -0
- package/dist/project/scaffold.js +58 -2
- package/dist/project/seed.d.ts +22 -0
- package/dist/project/seed.js +214 -0
- package/dist/pwa/generate.js +7 -7
- package/dist/queues/scaffold.js +9 -9
- package/dist/realtime/scaffold.js +4 -4
- package/dist/schema/collections.d.ts +36 -5
- package/dist/schema/collections.js +21 -20
- package/dist/schema/generate.js +4 -4
- package/dist/security/csp-origins.js +8 -9
- package/dist/tenancy/index.js +5 -5
- package/dist/worker/generate.js +72 -38
- package/dist/worker/routes.js +10 -10
- package/dist/workflow/generate.js +7 -7
- package/package.json +4 -4
|
@@ -37,12 +37,43 @@ export declare function assertAstroidPageSections(config: AstroidConfig, data: R
|
|
|
37
37
|
*
|
|
38
38
|
* pagesRoute({ table: pages, resolveEditor, fields, ...astroidPagesWriteHooks(config) })
|
|
39
39
|
*/
|
|
40
|
-
|
|
40
|
+
/** The write context `pagesRoute` passes to a transform or validator. */
|
|
41
|
+
export interface AstroidPagesWriteContext {
|
|
42
|
+
operation: "create" | "update";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A site's own `pagesRoute` hooks, exported as `pagesHooks` from the
|
|
46
|
+
* scaffold-once `src/pages-hooks.ts` when `pages.hooks` is on.
|
|
47
|
+
*/
|
|
48
|
+
export interface AstroidPagesHooks {
|
|
49
|
+
/**
|
|
50
|
+
* Clean a page write before Astroid's section sanitize and validate run, for
|
|
51
|
+
* example to normalize the slug, clamp a title, or fill a new page's defaults.
|
|
52
|
+
* It gets only the allowlisted fields of the write.
|
|
53
|
+
*/
|
|
54
|
+
transform?: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
55
|
+
/**
|
|
56
|
+
* Reject a write, after both transforms and before Astroid's own section
|
|
57
|
+
* validation. Throw a `LouiseValidationError` for a 422 with per-field
|
|
58
|
+
* violations, for example when a required field is empty.
|
|
59
|
+
*/
|
|
60
|
+
validate?: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => void | Promise<void>;
|
|
61
|
+
/** Slugs to refuse on top of {@link ASTROID_RESERVED_SLUGS}, such as a path
|
|
62
|
+
* a site's own file route serves. */
|
|
63
|
+
reservedSlugs?: Iterable<string>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Slugs no page may take, on any site. Each one is a path Astro, Cloudflare,
|
|
67
|
+
* or Astroid serves before the catch-all page route, so a page saved under it
|
|
68
|
+
* would be unreachable, and nothing would say why. `pagesRoute` refuses them
|
|
69
|
+
* with a 422 instead.
|
|
70
|
+
*/
|
|
71
|
+
export declare const ASTROID_RESERVED_SLUGS: readonly string[];
|
|
72
|
+
export declare function astroidPagesWriteHooks(config: AstroidConfig, site?: AstroidPagesHooks): {
|
|
41
73
|
sanitize: (html: string) => string;
|
|
42
|
-
transform: (data: Record<string, unknown
|
|
43
|
-
validate: (data: Record<string, unknown>, ctx:
|
|
44
|
-
|
|
45
|
-
}) => Promise<void>;
|
|
74
|
+
transform: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => Promise<Record<string, unknown>>;
|
|
75
|
+
validate: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => Promise<void>;
|
|
76
|
+
reservedSlugs: string[];
|
|
46
77
|
};
|
|
47
78
|
export declare function astroidPagesCollection(config: AstroidConfig): CollectionConfig;
|
|
48
79
|
/**
|
|
@@ -99,24 +99,20 @@ export async function assertAstroidPageSections(config, data, operation = "updat
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* no collection config, so it never runs the `beforeChange` hook below. Left
|
|
107
|
-
* bare (as it was), a direct `POST` / `PATCH /api/louise/pages/:id` persists an
|
|
108
|
-
* unknown section `_type`, a setting outside its options, or unsanitized section
|
|
109
|
-
* rich text: exactly what the hook exists to stop, silently missing from the one
|
|
110
|
-
* route the on-canvas *structural* edits flow through. `<Sections>` then skips
|
|
111
|
-
* the bad `_type`, so the section just vanishes with no error anywhere.
|
|
112
|
-
*
|
|
113
|
-
* These wire the SAME sanitize + validate the hook uses into `pagesRoute`'s
|
|
114
|
-
* `sanitize` / `transform` / `validate` seams, so both write paths enforce one
|
|
115
|
-
* contract. Spread into the route config:
|
|
116
|
-
*
|
|
117
|
-
* pagesRoute({ table: pages, resolveEditor, fields, ...astroidPagesWriteHooks(config) })
|
|
102
|
+
* Slugs no page may take, on any site. Each one is a path Astro, Cloudflare,
|
|
103
|
+
* or Astroid serves before the catch-all page route, so a page saved under it
|
|
104
|
+
* would be unreachable, and nothing would say why. `pagesRoute` refuses them
|
|
105
|
+
* with a 422 instead.
|
|
118
106
|
*/
|
|
119
|
-
export
|
|
107
|
+
export const ASTROID_RESERVED_SLUGS = [
|
|
108
|
+
"404",
|
|
109
|
+
"_astro",
|
|
110
|
+
"api",
|
|
111
|
+
"cdn-cgi",
|
|
112
|
+
"robots.txt",
|
|
113
|
+
"sitemap.xml",
|
|
114
|
+
];
|
|
115
|
+
export function astroidPagesWriteHooks(config, site = {}) {
|
|
120
116
|
const mediaBase = pageMediaBase(config);
|
|
121
117
|
return {
|
|
122
118
|
// `body` is a richField, so it goes through pagesRoute's own sanitize seam—with
|
|
@@ -125,9 +121,14 @@ export function astroidPagesWriteHooks(config) {
|
|
|
125
121
|
sanitize: (html) => sanitizeRichHtml(html, { mediaBase }),
|
|
126
122
|
// `sections` is not a richField, so it's sanitized here in the transform,
|
|
127
123
|
// which pagesRoute runs BEFORE validate—the hook's sanitize-then-validate
|
|
128
|
-
// order.
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
// order. The site's own transform runs first, so Astroid's sanitize sees
|
|
125
|
+
// the value the site is about to store.
|
|
126
|
+
transform: async (data, ctx) => sanitizeAstroidPageSections(config, site.transform ? await site.transform(data, ctx) : data),
|
|
127
|
+
validate: async (data, ctx) => {
|
|
128
|
+
await site.validate?.(data, ctx);
|
|
129
|
+
await assertAstroidPageSections(config, data, ctx.operation);
|
|
130
|
+
},
|
|
131
|
+
reservedSlugs: [...ASTROID_RESERVED_SLUGS, ...(site.reservedSlugs ?? [])],
|
|
131
132
|
};
|
|
132
133
|
}
|
|
133
134
|
export function astroidPagesCollection(config) {
|
package/dist/schema/generate.js
CHANGED
|
@@ -31,7 +31,7 @@ export function generateAstroidSchema(config) {
|
|
|
31
31
|
if (catalog?.includes("real("))
|
|
32
32
|
drizzleImports.push("real");
|
|
33
33
|
return [
|
|
34
|
-
"// Generated by astroidjs
|
|
34
|
+
"// Generated by astroidjs—do not hand-edit.",
|
|
35
35
|
"// Source: your defineAstroid config.",
|
|
36
36
|
`import { ${drizzleImports.sort().join(", ")} } from "drizzle-orm/sqlite-core";`,
|
|
37
37
|
'import { collectionVersionsTable, type JsonValue } from "louise-toolkit/content";',
|
|
@@ -46,8 +46,8 @@ export function generateAstroidSchema(config) {
|
|
|
46
46
|
' publishedVersionId: integer("published_version_id"),',
|
|
47
47
|
"});",
|
|
48
48
|
"",
|
|
49
|
-
"// Version snapshots for pages. Field-independent
|
|
50
|
-
"// JSON blob
|
|
49
|
+
"// Version snapshots for pages. Field-independent—each saved version is one",
|
|
50
|
+
"// JSON blob—so the table only needs the slug.",
|
|
51
51
|
'export const pagesVersions = collectionVersionsTable({ slug: "pages", fields: {} });',
|
|
52
52
|
"",
|
|
53
53
|
...(catalog ? [catalog] : []),
|
|
@@ -56,7 +56,7 @@ export function generateAstroidSchema(config) {
|
|
|
56
56
|
"// Site-owned tables (the ones Astroid doesn't manage): a project declares",
|
|
57
57
|
"// its own Drizzle tables in src/schema.site.ts and they're re-exported here",
|
|
58
58
|
"// so drizzle-kit sees them and the generated worker can import them. The file",
|
|
59
|
-
"// is scaffold-once
|
|
59
|
+
"// is scaffold-once—empty until a project adds a table—so this re-export is",
|
|
60
60
|
"// always safe.",
|
|
61
61
|
'export * from "./schema.site.js";',
|
|
62
62
|
"",
|
|
@@ -34,14 +34,13 @@ const COMMERCE_ORIGINS = {
|
|
|
34
34
|
// convention) and it must not need a rebuild to switch on—CSP is baked at
|
|
35
35
|
// build time, the secret is a runtime value.
|
|
36
36
|
const TURNSTILE = turnstileCsp();
|
|
37
|
-
// The map module
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// `
|
|
44
|
-
const MAP = { worker: ["blob:"] };
|
|
37
|
+
// The map module adds nothing, and that's the whole argument for the
|
|
38
|
+
// self-hosted basemap: the PMTiles archive is served same-origin, so
|
|
39
|
+
// `connect-src` stays `'self'` with no tile host and no API key to allow. The
|
|
40
|
+
// generated `<MapEmbed>` hands MapLibre 6 a same-origin worker URL, which it
|
|
41
|
+
// constructs directly, so `worker-src 'self'` covers it with no `blob:`.
|
|
42
|
+
// (MapLibre 5 built its workers from blob: URLs; a site still on 5 adds
|
|
43
|
+
// `worker: ["blob:"]` to its own `security.cspOrigins`.)
|
|
45
44
|
const DIRECTIVE_KEYS = ["script", "style", "frame", "connect", "font", "img", "worker"];
|
|
46
45
|
/** Merge origin lists, de-duplicated, order preserved. */
|
|
47
46
|
function mergeOrigins(...sets) {
|
|
@@ -57,7 +56,7 @@ function mergeOrigins(...sets) {
|
|
|
57
56
|
* what a config implies without rebuilding the whole policy.
|
|
58
57
|
*/
|
|
59
58
|
export function astroidCspOrigins(config) {
|
|
60
|
-
return mergeOrigins(TURNSTILE,
|
|
59
|
+
return mergeOrigins(TURNSTILE,
|
|
61
60
|
// EVERY provider in play, not "the" provider: a site can run Stripe for
|
|
62
61
|
// invoicing beside Fourthwall for the storefront, and a policy that allowed
|
|
63
62
|
// only one of them blocks the other's SDK at runtime.
|
package/dist/tenancy/index.js
CHANGED
|
@@ -110,7 +110,7 @@ export function generateAstroidTenancy(config) {
|
|
|
110
110
|
const prefix = tenancy.rewritePrefix ?? ASTROID_TENANT_PREFIX;
|
|
111
111
|
const example = (tenancy.hostPattern ?? "*.example.com").replace(/^\*\./, "");
|
|
112
112
|
return [
|
|
113
|
-
"// Scaffolded once by astroidjs
|
|
113
|
+
"// Scaffolded once by astroidjs—yours to edit.",
|
|
114
114
|
"//",
|
|
115
115
|
"// What a subdomain MEANS. The generated middleware has already decided this",
|
|
116
116
|
"// host is a tenant candidate (it matches your wildcard and is not reserved);",
|
|
@@ -128,17 +128,17 @@ export function generateAstroidTenancy(config) {
|
|
|
128
128
|
"/**",
|
|
129
129
|
" * Resolve a subdomain label to a tenant, or `null` if there isn't one.",
|
|
130
130
|
" *",
|
|
131
|
-
" * `null` falls through to the ordinary site
|
|
131
|
+
" * `null` falls through to the ordinary site—which is a real choice, not a",
|
|
132
132
|
" * default: a stranger's subdomain then renders your homepage. If that is wrong",
|
|
133
133
|
' * for this project, set `tenancy.unknown: "404"` in astroid.config.ts and the',
|
|
134
|
-
" * generated middleware refuses the host instead
|
|
134
|
+
" * generated middleware refuses the host instead—unambiguously not a page.",
|
|
135
135
|
" *",
|
|
136
136
|
" * This runs on EVERY request to a tenant host, so a database lookup here is a",
|
|
137
|
-
" * query per request. Cache it
|
|
137
|
+
" * query per request. Cache it—a module-scope Map is enough within an isolate,",
|
|
138
138
|
" * KV if the tenant set is large or changes without a deploy.",
|
|
139
139
|
" */",
|
|
140
140
|
"export async function resolveTenant(label: string): Promise<Tenant | null> {",
|
|
141
|
-
` // TODO(astroid): look \`label\` up
|
|
141
|
+
` // TODO(astroid): look \`label\` up—a D1 table, a KV entry, or a literal map`,
|
|
142
142
|
` // while the set is small. Example: acme.${example} → { slug: "acme" }.`,
|
|
143
143
|
" return { slug: label };",
|
|
144
144
|
"}",
|
package/dist/worker/generate.js
CHANGED
|
@@ -101,7 +101,12 @@ export function generateAstroidWorker(config) {
|
|
|
101
101
|
// POST/PATCH path would persist an unknown section `_type`, a setting
|
|
102
102
|
// outside its options, or unsanitized section rich text. See
|
|
103
103
|
// `astroidPagesWriteHooks`.
|
|
104
|
-
|
|
104
|
+
//
|
|
105
|
+
// `versionsTable` makes a DELETE remove the page's version snapshots,
|
|
106
|
+
// which have no foreign key to the page and would otherwise orphan.
|
|
107
|
+
// `afterWrite` rebuilds the search index, which plain CRUD writes leave
|
|
108
|
+
// stale.
|
|
109
|
+
return 'pagesRoute({ table: pages, versionsTable: pagesVersions, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], ...pagesWriteHooks, afterWrite: reindexPagesSearch })';
|
|
105
110
|
case "save":
|
|
106
111
|
// No `bufferKv` here, deliberately: `saveRoute` has no such option. It
|
|
107
112
|
// writes live field saves (title, SEO) straight through, and the draft
|
|
@@ -114,7 +119,10 @@ export function generateAstroidWorker(config) {
|
|
|
114
119
|
const customArg = (config.settings?.customKeys ?? []).length
|
|
115
120
|
? ", customKeys: SETTINGS_CUSTOM_KEYS"
|
|
116
121
|
: "";
|
|
117
|
-
|
|
122
|
+
// The site's sanitize + read hooks (config.settings.hooks), spread last
|
|
123
|
+
// so they add to the call rather than replace any part of it.
|
|
124
|
+
const hooksArg = config.settings?.hooks ? ", ...settingsHooks" : "";
|
|
125
|
+
return `settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE${customArg}${hooksArg} })`;
|
|
118
126
|
}
|
|
119
127
|
// `aiRunner` rather than `(env) => env.AI`: it reads the binding AND the
|
|
120
128
|
// LOUISE_AI kill switch, so all three assists share one definition of
|
|
@@ -159,10 +167,16 @@ export function generateAstroidWorker(config) {
|
|
|
159
167
|
};
|
|
160
168
|
const lines = [];
|
|
161
169
|
const p = (s = "") => lines.push(s);
|
|
162
|
-
p("// Generated by astroidjs
|
|
170
|
+
p("// Generated by astroidjs—do not hand-edit.");
|
|
163
171
|
p("// Source: your defineAstroid config. The editor route ORDER is fixed by");
|
|
164
|
-
p("// Astroid to avoid matcher collisions
|
|
172
|
+
p("// Astroid to avoid matcher collisions—see each route's note below.");
|
|
173
|
+
p('import { env } from "cloudflare:workers";');
|
|
165
174
|
p('import { handle } from "@astrojs/cloudflare/handler";');
|
|
175
|
+
p('import type { EditorSession } from "louise-toolkit/auth";');
|
|
176
|
+
p('import { createLocalApi } from "louise-toolkit/content";');
|
|
177
|
+
p(inquiries
|
|
178
|
+
? 'import { db, inquiriesForm } from "louise-toolkit/db";'
|
|
179
|
+
: 'import { db } from "louise-toolkit/db";');
|
|
166
180
|
p("import {");
|
|
167
181
|
for (const name of editorImports)
|
|
168
182
|
p(` ${name},`);
|
|
@@ -182,8 +196,6 @@ export function generateAstroidWorker(config) {
|
|
|
182
196
|
p('import { checkLinks } from "louise-toolkit/browser";');
|
|
183
197
|
p('import { readHealthSummary, summarizeHealth, writeHealthSummary } from "louise-toolkit/health";');
|
|
184
198
|
p('import { composeWorker, isEditRequest, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
|
|
185
|
-
if (inquiries)
|
|
186
|
-
p('import { inquiriesForm } from "louise-toolkit/db";');
|
|
187
199
|
p(`import { ${tables.join(", ")} } from "./schema.js";`);
|
|
188
200
|
const astroidImports = [
|
|
189
201
|
"astroidPagesCollection",
|
|
@@ -200,17 +212,36 @@ export function generateAstroidWorker(config) {
|
|
|
200
212
|
p("// from a request; a truthy result authorizes editor writes. A generated auth");
|
|
201
213
|
p("// module is a later slice.");
|
|
202
214
|
p('import { resolveEditor } from "./auth.js";');
|
|
215
|
+
if (config.pages?.hooks) {
|
|
216
|
+
p("// Your PAGES seam: a transform and extra reserved slugs for the pages");
|
|
217
|
+
p("// route. Scaffolded once and yours to edit.");
|
|
218
|
+
p('import { pagesHooks } from "./pages-hooks.js";');
|
|
219
|
+
}
|
|
220
|
+
if (config.settings?.hooks) {
|
|
221
|
+
p("// Your SETTINGS seam: per-key sanitizers and a GET transform for the");
|
|
222
|
+
p("// Settings panel. Scaffolded once and yours to edit.");
|
|
223
|
+
p('import { settingsHooks } from "./settings-hooks.js";');
|
|
224
|
+
}
|
|
203
225
|
if (queues) {
|
|
204
226
|
p("// Your QUEUE seam: what each message actually does. Scaffolded once and");
|
|
205
|
-
p("// yours to edit
|
|
227
|
+
p("// yours to edit—`astroidQueueHandler` there covers the catalog dispatch.");
|
|
206
228
|
p('import { handleQueueMessage } from "./queue.js";');
|
|
207
229
|
}
|
|
208
230
|
p();
|
|
209
231
|
p(`const MEDIA_BASE = ${JSON.stringify(mediaBase)};`);
|
|
210
232
|
p("const pagesCollection = astroidPagesCollection(astroidConfig);");
|
|
211
233
|
p("// Sanitize + section-catalog validation for the raw pagesRoute, which runs");
|
|
212
|
-
p("// no collection hook
|
|
213
|
-
p(
|
|
234
|
+
p("// no collection hook—the same contract versionsRoute gets from the config.");
|
|
235
|
+
p(config.pages?.hooks
|
|
236
|
+
? "const pagesWriteHooks = astroidPagesWriteHooks(astroidConfig, pagesHooks);"
|
|
237
|
+
: "const pagesWriteHooks = astroidPagesWriteHooks(astroidConfig);");
|
|
238
|
+
p();
|
|
239
|
+
p("// pagesRoute writes with plain Drizzle, so the full-text index doesn't see a");
|
|
240
|
+
p("// title or slug change until something rebuilds it. Best-effort: pagesRoute");
|
|
241
|
+
p("// swallows a throw here, so a stale index never fails the write itself.");
|
|
242
|
+
p("async function reindexPagesSearch(editor: EditorSession): Promise<void> {");
|
|
243
|
+
p(" await createLocalApi(db(env.DB), pages, pagesCollection).reindexSearch({ session: editor });");
|
|
244
|
+
p("}");
|
|
214
245
|
p();
|
|
215
246
|
p("// Editable site_settings columns the Settings panel may write, and which of");
|
|
216
247
|
p("// them resolve to a media-library asset.");
|
|
@@ -230,11 +261,14 @@ export function generateAstroidWorker(config) {
|
|
|
230
261
|
p("// Delete-safety for the media library: where a media key can be REFERENCED,");
|
|
231
262
|
p("// so deleting an asset that's live on a page warns instead of silently");
|
|
232
263
|
p("// breaking it. Without these the scan has nothing to look at and every");
|
|
233
|
-
p("// delete reports 'no references'. Column names are SQL, not Drizzle keys
|
|
264
|
+
p("// delete reports 'no references'. Column names are SQL, not Drizzle keys—");
|
|
234
265
|
p("// the scan is raw SQL over the table.");
|
|
235
266
|
p("const MEDIA_REFERENCE_SOURCES = [");
|
|
236
267
|
p(' { collection: "pages", table: "pages", columns: ["body", "sections", "og_image"], labelColumn: "title" },');
|
|
237
|
-
p(
|
|
268
|
+
p(
|
|
269
|
+
// `custom` too: a site's own image settings (settings.imageKeys) live in
|
|
270
|
+
// that JSON column, and an unscanned column reports 'no references'.
|
|
271
|
+
' { collection: "settings", table: "site_settings", columns: ["logo_url", "favicon_url", "default_og_image_url", "custom"], labelColumn: "site_name" },');
|
|
238
272
|
p("];");
|
|
239
273
|
p();
|
|
240
274
|
p();
|
|
@@ -246,7 +280,7 @@ export function generateAstroidWorker(config) {
|
|
|
246
280
|
p();
|
|
247
281
|
p("// The same read, adapted for the overview slice. `readHealthSummary` yields");
|
|
248
282
|
p("// `null` for 'no scan yet' while a slice resolver signals absence with");
|
|
249
|
-
p("// `undefined
|
|
283
|
+
p("// `undefined`—the two types are otherwise identical, and this one-line");
|
|
250
284
|
p("// coercion is the whole difference.");
|
|
251
285
|
p("const overviewHealth = async (env: CloudflareEnv) =>");
|
|
252
286
|
p(" (await readSiteHealth(env)) ?? undefined;");
|
|
@@ -256,7 +290,7 @@ export function generateAstroidWorker(config) {
|
|
|
256
290
|
p();
|
|
257
291
|
p("// The daily scan. Crawls the site's own pages for broken links and counts the");
|
|
258
292
|
p("// two accessibility/SEO gaps that are cheap to compute, then persists one");
|
|
259
|
-
p("// snapshot for the dashboard to read. Every part degrades on its own
|
|
293
|
+
p("// snapshot for the dashboard to read. Every part degrades on its own—a");
|
|
260
294
|
p("// failed crawl or a failed COUNT yields zero rather than aborting the scan,");
|
|
261
295
|
p("// because a partial health report is worth strictly more than none.");
|
|
262
296
|
p("async function runHealthScan(env: CloudflareEnv) {");
|
|
@@ -279,7 +313,7 @@ export function generateAstroidWorker(config) {
|
|
|
279
313
|
p(" return summary;");
|
|
280
314
|
p("}");
|
|
281
315
|
p();
|
|
282
|
-
p("/** One COUNT, degrading to 0
|
|
316
|
+
p("/** One COUNT, degrading to 0—a missing table must not abort the scan. */");
|
|
283
317
|
p("async function countRows(env: CloudflareEnv, sql: string): Promise<number> {");
|
|
284
318
|
p(" try {");
|
|
285
319
|
p(" const row = await env.DB.prepare(sql).first<{ n: number }>();");
|
|
@@ -294,7 +328,7 @@ export function generateAstroidWorker(config) {
|
|
|
294
328
|
p("// Unhandled inquiries. The COUNT is the whole table on purpose: the");
|
|
295
329
|
p("// Inquiries tab reviews and CLEARS submissions (GET lists, DELETE removes),");
|
|
296
330
|
p("// so a row that still exists is a message still waiting on you. There is no");
|
|
297
|
-
p("// read/unread column because deletion IS the acknowledgement
|
|
331
|
+
p("// read/unread column because deletion IS the acknowledgement—which also");
|
|
298
332
|
p("// means this number goes down as you work through them, rather than being a");
|
|
299
333
|
p("// total that only ever climbs.");
|
|
300
334
|
p("const overviewInbox = async (env: CloudflareEnv) => {");
|
|
@@ -303,7 +337,7 @@ export function generateAstroidWorker(config) {
|
|
|
303
337
|
p("};");
|
|
304
338
|
}
|
|
305
339
|
p("// The Home dashboard's content counts. Raw SQL because these are COUNTs over");
|
|
306
|
-
p("// THIS project's tables
|
|
340
|
+
p("// THIS project's tables—the toolkit deliberately makes no assumption about");
|
|
307
341
|
p("// column names. A throw here degrades to a hidden card, never a 500.");
|
|
308
342
|
p("const overviewContent = async (env: CloudflareEnv) => {");
|
|
309
343
|
p(" const row = await env.DB.prepare(");
|
|
@@ -331,8 +365,8 @@ export function generateAstroidWorker(config) {
|
|
|
331
365
|
p();
|
|
332
366
|
p("// `sections` writes are validated + sanitized against the section catalog");
|
|
333
367
|
p("// before they persist, on BOTH write paths: versionsRoute runs the pages");
|
|
334
|
-
p("// collection's beforeChange hook (via `config`), and pagesRoute
|
|
335
|
-
p("// no collection config
|
|
368
|
+
p("// collection's beforeChange hook (via `config`), and pagesRoute—which takes");
|
|
369
|
+
p("// no collection config—gets the same contract from the `pagesWriteHooks`");
|
|
336
370
|
p("// spread. An unknown `_type`, a field of the wrong shape, or a setting outside");
|
|
337
371
|
p("// its declared options is a 422, not a hole in the page.");
|
|
338
372
|
p("const editorRoutes: WorkerRoute<CloudflareEnv>[] = [");
|
|
@@ -371,7 +405,7 @@ export function generateAstroidWorker(config) {
|
|
|
371
405
|
: "export default composeWorker<CloudflareEnv>({");
|
|
372
406
|
p(" routes: [...editorRoutes, mediaAssetRoute],");
|
|
373
407
|
p(" // Deny-by-default editor API (ADR 0012). Under /api/louise a request must");
|
|
374
|
-
p(" // resolve to an editor unless it's headed for a public route
|
|
408
|
+
p(" // resolve to an editor unless it's headed for a public route—the contact");
|
|
375
409
|
p(" // form and the vitals beacon mark themselves. Every route above still checks");
|
|
376
410
|
p(" // for itself; this is what catches one that forgets. It also gives route");
|
|
377
411
|
p(" // responses the security headers the middleware never sees, since these");
|
|
@@ -381,13 +415,13 @@ export function generateAstroidWorker(config) {
|
|
|
381
415
|
p(" //");
|
|
382
416
|
p(" // Wrapped UNCONDITIONALLY, and that is safe: `withEdgeCache` only stores a");
|
|
383
417
|
p(" // response that carries a cacheable Cloudflare-CDN-Cache-Control directive,");
|
|
384
|
-
p(" // and a page emits one only via `Astro.cache.set(...)
|
|
418
|
+
p(" // and a page emits one only via `Astro.cache.set(...)`—which the scaffold");
|
|
385
419
|
p(' // gates on ASTROID_EDGE_CACHE being "true" AND the request not being in edit');
|
|
386
420
|
p(" // mode. With the var off (the default) every render is `no-store`, so this");
|
|
387
421
|
p(" // layer stores nothing and is a transparent pass-through.");
|
|
388
422
|
p(" //");
|
|
389
423
|
p(" // It must be THIS cache and not Cloudflare's automatic edge cache: that one");
|
|
390
|
-
p(" // is keyed by URL, runs BEFORE the Worker, and is therefore cookie-blind
|
|
424
|
+
p(" // is keyed by URL, runs BEFORE the Worker, and is therefore cookie-blind—");
|
|
391
425
|
p(" // it will happily serve an editor a cached public page. That exact bug got");
|
|
392
426
|
p(" // this feature reverted twice (#163, #165). `withEdgeCache` strips the CDN");
|
|
393
427
|
p(" // directive from every response so the automatic cache never engages.");
|
|
@@ -421,8 +455,8 @@ export function generateAstroidWorker(config) {
|
|
|
421
455
|
p(" }");
|
|
422
456
|
if (cron) {
|
|
423
457
|
p(` if (controller.cron === ${JSON.stringify(cron)}) {`);
|
|
424
|
-
p(" // Catalog safety net. Webhooks get missed
|
|
425
|
-
p(" // deploy mid-delivery, a DLQ'd message
|
|
458
|
+
p(" // Catalog safety net. Webhooks get missed—a provider outage, a");
|
|
459
|
+
p(" // deploy mid-delivery, a DLQ'd message—and without this the site");
|
|
426
460
|
p(" // serves stale data until a human notices. Enqueued rather than run");
|
|
427
461
|
p(" // inline so it takes the same retry + DLQ path as everything else.");
|
|
428
462
|
p(' ctx.waitUntil(env.COMMERCE_QUEUE.send({ kind: "catalog_refresh" }));');
|
|
@@ -487,7 +521,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
487
521
|
const rewritePrefix = tenancy?.rewritePrefix ?? ASTROID_TENANT_PREFIX;
|
|
488
522
|
const rewriteExclude = tenancy?.rewriteExclude ?? ASTROID_REWRITE_EXCLUDE;
|
|
489
523
|
return [
|
|
490
|
-
"// Generated by astroidjs
|
|
524
|
+
"// Generated by astroidjs—do not hand-edit.",
|
|
491
525
|
"// The shared Louise middleware: rate-limit the unauthenticated POST surfaces,",
|
|
492
526
|
"// then resolve the editor session + sticky ?louise edit mode, then apply",
|
|
493
527
|
"// content-freshness + transport-security headers, and rewrite the style-src of",
|
|
@@ -508,11 +542,11 @@ export function generateAstroidMiddleware(config) {
|
|
|
508
542
|
'import astroidConfig from "../astroid.config.js";',
|
|
509
543
|
...(tenancy
|
|
510
544
|
? [
|
|
511
|
-
"// TODO(astroid): your TENANT seam
|
|
545
|
+
"// TODO(astroid): your TENANT seam—what a subdomain maps to (src/tenancy.ts).",
|
|
512
546
|
'import { resolveTenant } from "./tenancy.js";',
|
|
513
547
|
]
|
|
514
548
|
: []),
|
|
515
|
-
"// TODO(astroid): your AUTH seam
|
|
549
|
+
"// TODO(astroid): your AUTH seam—same resolveEditor as the generated worker.ts.",
|
|
516
550
|
'import { resolveEditor } from "./auth.js";',
|
|
517
551
|
// The portal's resolver lives in its OWN module, not the editor's auth
|
|
518
552
|
// seam—they're separate Better Auth instances and must not share a file.
|
|
@@ -521,9 +555,9 @@ export function generateAstroidMiddleware(config) {
|
|
|
521
555
|
"// Rate-limit the public, unauthenticated POST surface, keyed by client IP",
|
|
522
556
|
"// (fixed-window KV counter that fails open). Derived from your config: the",
|
|
523
557
|
"// editor magic-link always, plus the portal credential surfaces and checkout",
|
|
524
|
-
"// when those are enabled. Add your own via `security.rateRules` in the config
|
|
558
|
+
"// when those are enabled. Add your own via `security.rateRules` in the config—",
|
|
525
559
|
"// they're matched first, so they can also override a default's budget.",
|
|
526
|
-
"// `env.RL` is read per request (a getter)
|
|
560
|
+
"// `env.RL` is read per request (a getter)—a KV binding is only valid in",
|
|
527
561
|
"// request scope.",
|
|
528
562
|
"const RATE_RULES = astroidRateRules(astroidConfig);",
|
|
529
563
|
...(tenancy
|
|
@@ -538,7 +572,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
538
572
|
? [
|
|
539
573
|
"const PORTAL_GUARD = astroidPortalGuardConfig(astroidConfig)!;",
|
|
540
574
|
"",
|
|
541
|
-
"// The PORTAL session
|
|
575
|
+
"// The PORTAL session—a second, cookie- and table-isolated Better Auth",
|
|
542
576
|
"// instance beside the editor's. `resolvePortalSession` shares the in-flight",
|
|
543
577
|
"// lookup per request, so the guard here and the handler that runs next",
|
|
544
578
|
"// don't each pay a session round-trip.",
|
|
@@ -548,7 +582,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
548
582
|
"export const onRequest = createLouiseMiddleware({",
|
|
549
583
|
" resolveEditor: (request) => resolveEditor(request),",
|
|
550
584
|
" rateLimit: { rules: RATE_RULES, kv: () => env.RL },",
|
|
551
|
-
" // The same deny-by-default gate for /api/louise routes that reach Astro
|
|
585
|
+
" // The same deny-by-default gate for /api/louise routes that reach Astro—",
|
|
552
586
|
" // anything the worker's routes didn't answer. A second check behind the",
|
|
553
587
|
" // worker's gate, and free: the editor is resolved here on every request.",
|
|
554
588
|
" apiGate: true,",
|
|
@@ -583,7 +617,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
583
617
|
" // A syntactically-valid tenant host whose label resolved to nothing is",
|
|
584
618
|
' // NOT a page (config `tenancy.unknown: "404"`). Falling through would',
|
|
585
619
|
" // render the marketing homepage on a stranger's subdomain. Reserved",
|
|
586
|
-
" // labels, app labels, and the apex never reach here
|
|
620
|
+
" // labels, app labels, and the apex never reach here—tenantLabel",
|
|
587
621
|
" // returns null for all of them, so they are not tenant candidates.",
|
|
588
622
|
" if (tenantLabel(context.url.hostname, TENANCY) && !context.locals.tenant) {",
|
|
589
623
|
' return new Response("Not found", { status: 404 });',
|
|
@@ -593,7 +627,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
593
627
|
...(portal
|
|
594
628
|
? [
|
|
595
629
|
" // Route guard: the declarative prefix→roles table from your config.",
|
|
596
|
-
" // An /api/* route always answers in JSON
|
|
630
|
+
" // An /api/* route always answers in JSON—redirecting fetch() to an",
|
|
597
631
|
" // HTML login page returns 200 and markup, which reads as success.",
|
|
598
632
|
" const decision = portalGuard(",
|
|
599
633
|
" context.url.pathname,",
|
|
@@ -612,16 +646,16 @@ export function generateAstroidMiddleware(config) {
|
|
|
612
646
|
? [
|
|
613
647
|
" // Host dispatch: map the resolved tenant onto an internal path prefix.",
|
|
614
648
|
" // Runs after `guard`, so route policy stays written against the PUBLIC",
|
|
615
|
-
" // path. The visitor's URL is unchanged
|
|
616
|
-
" // redirect
|
|
649
|
+
" // path. The visitor's URL is unchanged—an internal rewrite, not a",
|
|
650
|
+
" // redirect—so links built from Astro.url stay correct.",
|
|
617
651
|
" //",
|
|
618
652
|
" // An unknown subdomain is YOUR decision: `resolveTenant` returning null",
|
|
619
|
-
" // falls through to the ordinary site below
|
|
653
|
+
" // falls through to the ordinary site below—or answers 404 first, when the",
|
|
620
654
|
' // config sets `tenancy.unknown: "404"` (see the guard above).',
|
|
621
655
|
" rewrite: (context) => {",
|
|
622
656
|
" // Host-agnostic paths render from their own address. An API route is",
|
|
623
657
|
" // addressed absolutely by whatever calls it and reads the host from",
|
|
624
|
-
" // `locals.tenant`, so rewriting it moves it where no route matches
|
|
658
|
+
" // `locals.tenant`, so rewriting it moves it where no route matches—",
|
|
625
659
|
" // and on an app host with a catch-all page, the PAGE answers, so",
|
|
626
660
|
" // fetch() gets HTML instead of JSON and every data load quietly fails.",
|
|
627
661
|
` if (isRewriteExcluded(context.url.pathname, ${JSON.stringify(rewriteExclude)})) {`,
|
|
@@ -630,12 +664,12 @@ export function generateAstroidMiddleware(config) {
|
|
|
630
664
|
...(Object.keys(tenancy.apps ?? {}).length
|
|
631
665
|
? [
|
|
632
666
|
" // First-party app hosts (config `tenancy.apps`): a static label→prefix",
|
|
633
|
-
" // map, checked before the tenant
|
|
667
|
+
" // map, checked before the tenant—an app exists whether or not any",
|
|
634
668
|
" // tenant does, and needs no lookup.",
|
|
635
669
|
" const app = appPrefix(context.url.hostname, TENANCY);",
|
|
636
670
|
" // `search` is preserved: the rewrite chooses which page renders, not",
|
|
637
671
|
" // what was asked of it. Dropping it silently loses filters, pagination,",
|
|
638
|
-
" // campaign tags
|
|
672
|
+
" // campaign tags—and every typed search param a routed island reads.",
|
|
639
673
|
" if (app) return `${app}${context.url.pathname}${context.url.search}`;",
|
|
640
674
|
]
|
|
641
675
|
: []),
|
package/dist/worker/routes.js
CHANGED
|
@@ -23,7 +23,7 @@ export function astroidEditorRoutePlan(config) {
|
|
|
23
23
|
{
|
|
24
24
|
name: "versions",
|
|
25
25
|
factory: "versionsRoute",
|
|
26
|
-
note: "Draft/publish + version history for pages. MUST precede pagesRoute
|
|
26
|
+
note: "Draft/publish + version history for pages. MUST precede pagesRoute—pagesRoute's /:id matcher would otherwise claim /pages/:id/versions and 400 on the non-integer id.",
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
name: "search",
|
|
@@ -33,7 +33,7 @@ export function astroidEditorRoutePlan(config) {
|
|
|
33
33
|
{
|
|
34
34
|
name: "seoFix",
|
|
35
35
|
factory: "seoFixRoute",
|
|
36
|
-
note: "One-click SEO backfill for published pages missing a title/description. MUST precede pagesRoute for the same reason versions/search do: it mounts at /api/louise/pages/generate-seo, and pagesRoute claims EVERY path under /api/louise/pages/ as an item id
|
|
36
|
+
note: "One-click SEO backfill for published pages missing a title/description. MUST precede pagesRoute for the same reason versions/search do: it mounts at /api/louise/pages/generate-seo, and pagesRoute claims EVERY path under /api/louise/pages/ as an item id—so mounted after, it would never be reached and the request would 400 on the non-integer id `generate-seo`.",
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
name: "pages",
|
|
@@ -43,7 +43,7 @@ export function astroidEditorRoutePlan(config) {
|
|
|
43
43
|
{
|
|
44
44
|
name: "save",
|
|
45
45
|
factory: "saveRoute",
|
|
46
|
-
note: "Live field saves (title, SEO)
|
|
46
|
+
note: "Live field saves (title, SEO)—the versioned body stages drafts via versionsRoute instead.",
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
name: "settings",
|
|
@@ -53,12 +53,12 @@ export function astroidEditorRoutePlan(config) {
|
|
|
53
53
|
{
|
|
54
54
|
name: "media",
|
|
55
55
|
factory: "mediaRoute",
|
|
56
|
-
note: "Media library
|
|
56
|
+
note: "Media library—list, upload, delete, reference checks.",
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
name: "editors",
|
|
60
60
|
factory: "editorsRoute",
|
|
61
|
-
note: "Editor roster (the Users panel) over Better Auth's `user` table
|
|
61
|
+
note: "Editor roster (the Users panel) over Better Auth's `user` table—a row IS an editor, and the same table is the magic-link allowlist (resolveAdmins).",
|
|
62
62
|
},
|
|
63
63
|
];
|
|
64
64
|
// AI assists that own their own path prefix. `/api/louise/ai/*` collides with
|
|
@@ -74,23 +74,23 @@ export function astroidEditorRoutePlan(config) {
|
|
|
74
74
|
routes.push({
|
|
75
75
|
name: "realtime",
|
|
76
76
|
factory: "realtimeRoute",
|
|
77
|
-
note: "WebSocket upgrade for the per-page live editing session (ADR 0002). Owns /api/louise/realtime/*. Guards the handshake as a same-origin, session-gated mutation, then forwards to the per-page Durable Object with the SERVER-resolved editor identity
|
|
77
|
+
note: "WebSocket upgrade for the per-page live editing session (ADR 0002). Owns /api/louise/realtime/*. Guards the handshake as a same-origin, session-gated mutation, then forwards to the per-page Durable Object with the SERVER-resolved editor identity—presence is never taken from the client.",
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
routes.push({
|
|
81
81
|
name: "vitals",
|
|
82
82
|
factory: "vitalsRoute",
|
|
83
|
-
note: "Public CWV ingestion (POST /api/louise/vitals). NOT session-gated
|
|
83
|
+
note: "Public CWV ingestion (POST /api/louise/vitals). NOT session-gated—these are anonymous visitor beacons—but same-origin only, and it accepts-and-drops without the dataset binding. Always 204.",
|
|
84
84
|
});
|
|
85
85
|
routes.push({
|
|
86
86
|
name: "health",
|
|
87
87
|
factory: "healthRoute",
|
|
88
|
-
note: "The site-health panel's read of the persisted scan summary. Owns /api/louise/health. Like overviewRoute, its client half already ships in the editor drawer
|
|
88
|
+
note: "The site-health panel's read of the persisted scan summary. Owns /api/louise/health. Like overviewRoute, its client half already ships in the editor drawer—unmounted, the Health card was dead UI. Returns `{ summary: null }` (a 200) until the daily cron writes the first scan, which the panel renders as 'not checked yet'.",
|
|
89
89
|
});
|
|
90
90
|
routes.push({
|
|
91
91
|
name: "ai",
|
|
92
92
|
factory: "aiRoute",
|
|
93
|
-
note: "Editor AI assists
|
|
93
|
+
note: "Editor AI assists—rewrite/expand/shorten a selection, suggest SEO for a page. Owns /api/louise/ai/*, so it collides with nothing. POST-only and editor-gated, since each call spends AI budget.",
|
|
94
94
|
});
|
|
95
95
|
if (capturesInquiries(config)) {
|
|
96
96
|
routes.push({
|
|
@@ -106,7 +106,7 @@ export function astroidEditorRoutePlan(config) {
|
|
|
106
106
|
routes.push({
|
|
107
107
|
name: "seed",
|
|
108
108
|
factory: "seedRoute",
|
|
109
|
-
note: "First-run site_settings seed. Last
|
|
109
|
+
note: "First-run site_settings seed. Last—it's a one-shot bootstrap, not a hot path.",
|
|
110
110
|
});
|
|
111
111
|
return routes;
|
|
112
112
|
}
|