astroidjs 0.2.0 → 0.3.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.
@@ -0,0 +1,27 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /**
3
+ * The editor Better Auth instance's table prefix — `louise_user`,
4
+ * `louise_session`, … The unprefixed names are left free for a second (portal)
5
+ * instance. Consumed by the generated `editorsRoute` and mirrored by the
6
+ * scaffolded `src/auth.ts` (`getLouiseAuth({ tablePrefix })`) + its migration.
7
+ */
8
+ export declare const ASTROID_EDITOR_TABLE_PREFIX = "louise_";
9
+ /**
10
+ * The editor instance keeps Better Auth's default cookie prefix (the Louise
11
+ * editor client is built against it). Named here so the isolation guard can
12
+ * reject a portal that would collide with it.
13
+ */
14
+ export declare const ASTROID_EDITOR_COOKIE_PREFIX = "better-auth";
15
+ /** The editor's Better Auth table name for a given model (e.g. `louise_user`). */
16
+ export declare function astroidEditorTable(model: string): string;
17
+ /**
18
+ * Reject a portal whose isolation would collide with the editor instance on the
19
+ * same origin. Called from `defineAstroid`. A no-op when there is no portal.
20
+ *
21
+ * The two dangerous overlaps:
22
+ * - a shared cookie prefix → signing into one instance signs you out of the
23
+ * other (the intermittent-prod-logout the fixed defaults exist to prevent);
24
+ * - a shared table prefix → the two instances read/write one `user` table, so
25
+ * an editor and a customer can become the same account.
26
+ */
27
+ export declare function assertAuthIsolation(config: AstroidConfig): void;
@@ -0,0 +1,59 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Editor-auth convention + the two-instance isolation guard.
4
+ //
5
+ // Astroid runs up to TWO Better Auth instances on one origin: the EDITOR (the
6
+ // studio — magic-link + passkey, a DB-managed admin allowlist) and, optionally, a
7
+ // second PORTAL instance (customers/members/a shop account — see `portal/`). The
8
+ // editor owns Better Auth's default mount (`/api/auth`) and cookie because the
9
+ // Louise editor client hardcodes them; its tables are namespaced with the
10
+ // `louise_` prefix so a second instance can take the unprefixed `user`/`session`
11
+ // tables without collision. The portal is the one that MOVES — a distinct mount,
12
+ // cookie prefix, and (by default) table prefix.
13
+ //
14
+ // The failure this guards against is subtle and intermittent: two instances that
15
+ // share a cookie prefix silently sign you out of one when you sign into the other,
16
+ // in production, looking like a session bug rather than a config one. So the
17
+ // isolation is asserted at config load, naming the collision.
18
+ import { AstroidConfigError } from "../errors.js";
19
+ import { astroidPortal } from "../portal/config.js";
20
+ /**
21
+ * The editor Better Auth instance's table prefix — `louise_user`,
22
+ * `louise_session`, … The unprefixed names are left free for a second (portal)
23
+ * instance. Consumed by the generated `editorsRoute` and mirrored by the
24
+ * scaffolded `src/auth.ts` (`getLouiseAuth({ tablePrefix })`) + its migration.
25
+ */
26
+ export const ASTROID_EDITOR_TABLE_PREFIX = "louise_";
27
+ /**
28
+ * The editor instance keeps Better Auth's default cookie prefix (the Louise
29
+ * editor client is built against it). Named here so the isolation guard can
30
+ * reject a portal that would collide with it.
31
+ */
32
+ export const ASTROID_EDITOR_COOKIE_PREFIX = "better-auth";
33
+ /** The editor's Better Auth table name for a given model (e.g. `louise_user`). */
34
+ export function astroidEditorTable(model) {
35
+ return `${ASTROID_EDITOR_TABLE_PREFIX}${model}`;
36
+ }
37
+ /**
38
+ * Reject a portal whose isolation would collide with the editor instance on the
39
+ * same origin. Called from `defineAstroid`. A no-op when there is no portal.
40
+ *
41
+ * The two dangerous overlaps:
42
+ * - a shared cookie prefix → signing into one instance signs you out of the
43
+ * other (the intermittent-prod-logout the fixed defaults exist to prevent);
44
+ * - a shared table prefix → the two instances read/write one `user` table, so
45
+ * an editor and a customer can become the same account.
46
+ */
47
+ export function assertAuthIsolation(config) {
48
+ const portal = astroidPortal(config);
49
+ if (!portal)
50
+ return;
51
+ if (!portal.cookiePrefix || portal.cookiePrefix === ASTROID_EDITOR_COOKIE_PREFIX) {
52
+ throw new AstroidConfigError(`portal.cookiePrefix must be set and distinct from the editor's (${JSON.stringify(ASTROID_EDITOR_COOKIE_PREFIX)}). Two instances sharing a cookie prefix silently sign you out of one when ` +
53
+ "you sign into the other. Pick a project-specific prefix (e.g. \"acme_shop\").");
54
+ }
55
+ if (portal.tablePrefix === ASTROID_EDITOR_TABLE_PREFIX) {
56
+ throw new AstroidConfigError(`portal.tablePrefix must differ from the editor's (${JSON.stringify(ASTROID_EDITOR_TABLE_PREFIX)}) — a shared table prefix merges the two instances into one user table. ` +
57
+ 'Leave it unset (the unprefixed `user`/`session` tables) or use a distinct prefix.');
58
+ }
59
+ }
@@ -12,13 +12,15 @@ export interface OwnedColumn {
12
12
  export interface CatalogMirrorConfig {
13
13
  /**
14
14
  * `mirror` keeps the provider's catalog fields in D1 (fast reads, briefly
15
- * stale); `overlay` keeps only the owner's fields and reads the catalog live.
16
- * Default `mirror`.
15
+ * stale); `overlay` keeps only the owner's fields and reads the catalog live;
16
+ * `live` manages no catalog table at all (the site owns any overlay table and
17
+ * reads the catalog live from a cache). Default `mirror`.
17
18
  */
18
- mode?: "mirror" | "overlay";
19
- /** Table name. Default `products`. */
19
+ mode?: "mirror" | "overlay" | "live";
20
+ /** Table name. Default `products`. Ignored in `live` mode (no table). */
20
21
  table?: string;
21
- /** The owner-editable columns, on top of the built-ins below. */
22
+ /** The owner-editable columns, on top of the built-ins below. Ignored in
23
+ * `live` mode (the overlay table, if any, is the site's own). */
22
24
  owned?: Record<string, OwnedColumn>;
23
25
  }
24
26
  /**
@@ -14,13 +14,18 @@
14
14
  //
15
15
  // mirror — pulled + owned columns both live in D1 (themidwestartist.com).
16
16
  // Reads are one local query. The catalog can be stale between syncs.
17
- // overlay — only the owned columns live in D1, keyed by the provider's id
18
- // (coracle.coffee's `product_display_meta`). The catalog is read
19
- // live from the provider and joined at read time: never stale,
20
- // but every read costs a provider round-trip (cache accordingly).
17
+ // overlay — only the owned columns live in D1, keyed by the provider's id.
18
+ // The catalog is read live from the provider and joined at read
19
+ // time: never stale, but every read costs a provider round-trip
20
+ // (cache accordingly).
21
+ // live — Astroid manages NO catalog table at all: the catalog is read live
22
+ // from the provider (cached), and any owner-side overlay table is
23
+ // the SITE's own (coracle.coffee's `product_display_meta`, declared
24
+ // in schema.site.ts and joined in the site's loader). Use when the
25
+ // existing overlay shape predates Astroid and must be preserved 1:1.
21
26
  //
22
- // `overlay` is just `mirror` with an empty pulled set, so one generator serves
23
- // both and a project can switch by changing one word.
27
+ // `overlay` is just `mirror` with an empty pulled set; `live` emits neither table
28
+ // nor migration. One generator serves all three and a project switches by one word.
24
29
  /**
25
30
  * Columns Astroid always PULLS, overwriting each sync. Fixed rather than
26
31
  * configurable because they're the intersection of what every provider returns —
@@ -101,6 +106,9 @@ export function generateCatalogTable(config) {
101
106
  if (!config.commerce)
102
107
  return null;
103
108
  const { mode, table, owned } = astroidCatalogMirror(config);
109
+ // `live` mode: Astroid manages no catalog table (the site owns any overlay).
110
+ if (mode === "live")
111
+ return null;
104
112
  const lines = [];
105
113
  const p = (s = "") => lines.push(s);
106
114
  p(`// The catalog ${mode}. The provider is the source of truth; these rows are`);
@@ -177,6 +185,9 @@ export function generateCatalogMigrationSql(config) {
177
185
  if (!config.commerce)
178
186
  return null;
179
187
  const { mode, table, owned } = astroidCatalogMirror(config);
188
+ // `live` mode: no Astroid-managed table, so no migration.
189
+ if (mode === "live")
190
+ return null;
180
191
  const cols = [
181
192
  " `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL",
182
193
  " `external_id` text NOT NULL",
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SectionCatalog } from "louise-toolkit/content";
1
2
  import type { RateRule } from "louise-toolkit/security";
2
3
  import type { CatalogMirrorConfig } from "./commerce/mirror.js";
3
4
  import type { astroidSectionCatalog } from "./components/sections.js";
@@ -119,6 +120,26 @@ export interface Portal {
119
120
  * accounts by hand, and a portal is usually for people you already know.
120
121
  */
121
122
  signUp?: boolean;
123
+ /**
124
+ * Where the portal's Better Auth instance mounts — its own handler, separate
125
+ * from the editor's `/api/auth`. Default `/api/portal-auth`. Override when a
126
+ * site already ships a second instance at a different path (e.g. a shop
127
+ * account at `/api/shop-auth`) whose live cookies must not change.
128
+ */
129
+ basePath?: string;
130
+ /**
131
+ * Cookie prefix for the portal instance — MUST differ from the editor's
132
+ * (Better Auth's default), or signing into one instance signs you out of the
133
+ * other. Default `"portal"`. `defineAstroid` rejects a colliding value.
134
+ */
135
+ cookiePrefix?: string;
136
+ /**
137
+ * Table-name prefix for the portal's Better Auth tables. Default `"portal_"`
138
+ * (`portal_user`, …); set `""` to take the unprefixed `user`/`session` tables
139
+ * (the editor owns `louise_*`, so they don't collide). MUST differ from the
140
+ * editor's `louise_` prefix.
141
+ */
142
+ tablePrefix?: string;
122
143
  }
123
144
  export interface CommerceConfig {
124
145
  /**
@@ -203,6 +224,29 @@ export interface CspOrigins {
203
224
  img?: string[];
204
225
  worker?: string[];
205
226
  }
227
+ export interface SettingsConfig {
228
+ /**
229
+ * Override the editable base `site_settings` columns. Defaults to Astroid's
230
+ * standard set (`ASTROID_SETTINGS_COLUMNS`). A **custom-heavy** site whose
231
+ * settings shape doesn't align with the base column names keeps everything in
232
+ * `custom` by passing `[]` — otherwise a key that happens to match a base
233
+ * column name (e.g. `contactEmail`) would route to that column instead of
234
+ * `custom`, where the site's render reads it.
235
+ */
236
+ columns?: string[];
237
+ /**
238
+ * Site-specific settings keys stored in the `site_settings.custom` JSON column,
239
+ * on top of (or, with `columns: []`, instead of) Astroid's base columns. The
240
+ * generated `settingsRoute` + Action accept these; the Settings panel writes
241
+ * them through the `settingsExtension` groups a site supplies to
242
+ * `mountSettings`. A site with a rich settings shape (coracle's footer columns,
243
+ * hours table, ui strings, shop/order config) lists their top-level keys here.
244
+ */
245
+ customKeys?: string[];
246
+ /** Extra media-library image keys beyond the base logo/favicon/OG defaults —
247
+ * settings values validated as media-library URLs on write. */
248
+ imageKeys?: string[];
249
+ }
206
250
  export interface DeployConfig {
207
251
  platform: "cloudflare";
208
252
  /** Media base for R2 + `cf-image` resizing — matches Louise's media route
@@ -223,6 +267,17 @@ export interface AstroidConfig {
223
267
  theme: Theme;
224
268
  /** The editable home page, top to bottom. Omit to take the archetype default. */
225
269
  sections?: SectionKind[];
270
+ /**
271
+ * A site-provided section catalog that REPLACES the built-in one for
272
+ * SERVER-side validation + sanitization of `pages.sections` (the generated
273
+ * pages route + versions route). A site with bespoke section designs — its own
274
+ * `.astro` components and field defs (coracle's 13 sections) — registers them
275
+ * here so writes to its custom `_type`s validate instead of 422-ing against the
276
+ * built-in vocabulary. The on-canvas editor already uses the site's catalog
277
+ * (its `mountSections` call passes it); this closes the server half so both
278
+ * write paths agree. Omit to use Astroid's built-in catalog.
279
+ */
280
+ sectionCatalog?: SectionCatalog;
226
281
  /** Optional capabilities switched on for this site. */
227
282
  modules?: ModuleKind[];
228
283
  /** Gated account/portal area (order tracking, client galleries). */
@@ -235,6 +290,16 @@ export interface AstroidConfig {
235
290
  seo?: SeoConfig;
236
291
  /** Additions to the rate-limit rules + CSP origins Astroid derives. */
237
292
  security?: SecurityConfig;
293
+ /** Site-specific editable settings — extra `custom` keys + image keys on top
294
+ * of Astroid's base `site_settings` columns. */
295
+ settings?: SettingsConfig;
296
+ /**
297
+ * Force the contact form + `inquiries` table on or off. Omit to detect from
298
+ * the config (a `contact` section, or a wholesale-inquiry module). Set `true`
299
+ * when a bespoke section captures inquiries under a name Astroid can't see
300
+ * (coracle's custom `contactForm`); set `false` to suppress it entirely.
301
+ */
302
+ inquiries?: boolean;
238
303
  /** Installable-app settings. Only read when `modules` includes `"pwa"`. */
239
304
  pwa?: PwaConfig;
240
305
  deploy?: DeployConfig;
package/dist/config.js CHANGED
@@ -19,6 +19,7 @@
19
19
  // `ModuleKind` are extracted from the real sites Astroid targets — a storefront
20
20
  // (coracle), a wholesale front (ghostfire), an artist portfolio (megbowen), and a
21
21
  // plain marketing baseline (louise-web).
22
+ import { assertAuthIsolation } from "./auth/index.js";
22
23
  import { assertCommerceRoles } from "./commerce/roles.js";
23
24
  import { AstroidConfigError } from "./errors.js";
24
25
  /**
@@ -74,6 +75,11 @@ export function defineAstroid(config) {
74
75
  // Fourthwall, a storefront over Stripe) fails here rather than at runtime on
75
76
  // the first invoice, as a missing function.
76
77
  assertCommerceRoles(config.commerce);
78
+ // A portal is a SECOND Better Auth instance beside the editor's. Reject any
79
+ // isolation that would collide with the editor on the same origin (a shared
80
+ // cookie prefix silently cross-signs-out; a shared table prefix merges the two
81
+ // user tables) — the intermittent-prod failure the fixed defaults prevent.
82
+ assertAuthIsolation(config);
77
83
  // `portal.gated` is declared and resolved but read by NOTHING — the guard
78
84
  // table is built from `portal.routes` alone, and `portalGuard` allows any
79
85
  // unmatched path. So a site that set it believed the whole site sat behind a
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./analytics/index.js";
2
+ export * from "./auth/index.js";
2
3
  export * from "./commerce/index.js";
3
4
  export * from "./config.js";
4
5
  export * from "./email/index.js";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // Public entry. The configuration surface (`defineAstroid`) is the first
5
5
  // inhabitant; the generator, theme system, and section library follow.
6
6
  export * from "./analytics/index.js";
7
+ export * from "./auth/index.js";
7
8
  export * from "./commerce/index.js";
8
9
  export * from "./config.js";
9
10
  export * from "./email/index.js";
@@ -26,9 +26,15 @@ export function astroidPortal(config) {
26
26
  const roles = portal.roles?.length ? portal.roles : ["customer"];
27
27
  return {
28
28
  enabled: true,
29
- basePath: ASTROID_PORTAL_BASE_PATH,
30
- cookiePrefix: ASTROID_PORTAL_COOKIE_PREFIX,
31
- tablePrefix: ASTROID_PORTAL_TABLE_PREFIX,
29
+ // Isolation is configurable so a site with an existing second instance
30
+ // (coracle's shop account at /api/shop-auth, cookie `coracle_shop`, the
31
+ // unprefixed `user` tables) keeps its live mount + cookies unchanged. The
32
+ // defaults stay the safe distinct-from-editor values; `defineAstroid`'s
33
+ // `assertAuthIsolation` rejects a resolved value that collides with the
34
+ // editor. `?? ""` is respected for tablePrefix (empty = unprefixed tables).
35
+ basePath: portal.basePath ?? ASTROID_PORTAL_BASE_PATH,
36
+ cookiePrefix: portal.cookiePrefix ?? ASTROID_PORTAL_COOKIE_PREFIX,
37
+ tablePrefix: portal.tablePrefix ?? ASTROID_PORTAL_TABLE_PREFIX,
32
38
  roles,
33
39
  defaultRole: roles[0],
34
40
  routes: portal.routes?.length ? portal.routes : DEFAULT_ROUTES,
@@ -1,5 +1,11 @@
1
- /** A signed-in portal user, as far as the guard is concerned. */
1
+ /** A signed-in portal user. The guard only reads `role`; `id` + `email` are the
2
+ * universal identity fields every instance resolves, typed so consumers
3
+ * (`locals.portalUser`) read them without a cast. Anything else a project's
4
+ * resolver returns (name, phone, a linked commerce id) is reachable via the
5
+ * index signature. */
2
6
  export interface PortalUser {
7
+ id: string;
8
+ email: string;
3
9
  role: string;
4
10
  [key: string]: unknown;
5
11
  }
@@ -1,3 +1,3 @@
1
1
  import type { AstroidConfig } from "../config.js";
2
2
  /** `src/actions/index.ts` — the typed mutation surface, scaffolded once. */
3
- export declare function generateAstroidActions(_config: AstroidConfig): string;
3
+ export declare function generateAstroidActions(config: AstroidConfig): string;
@@ -16,7 +16,23 @@
16
16
  // pre-wired against the same tables and the same collection config the generated
17
17
  // worker uses.
18
18
  /** `src/actions/index.ts` — the typed mutation surface, scaffolded once. */
19
- export function generateAstroidActions(_config) {
19
+ export function generateAstroidActions(config) {
20
+ const customKeys = config.settings?.customKeys ?? [];
21
+ const extraImageKeys = config.settings?.imageKeys ?? [];
22
+ const columnsOverride = config.settings?.columns;
23
+ // Kept in step with the generated worker's settingsRoute: site-specific keys go
24
+ // to site_settings.custom, extra image keys widen the media-strict set, a
25
+ // custom-heavy site can override the base columns. Emitted as literals only
26
+ // when present, so a stock project's Action is unchanged.
27
+ const settingsExtra = [
28
+ columnsOverride
29
+ ? ` columns: ${JSON.stringify(columnsOverride)},`
30
+ : " columns: ASTROID_SETTINGS_COLUMNS,",
31
+ ...(customKeys.length ? [` customKeys: ${JSON.stringify(customKeys)},`] : []),
32
+ extraImageKeys.length
33
+ ? ` imageKeys: [...ASTROID_SETTINGS_IMAGE_KEYS, ...${JSON.stringify(extraImageKeys)}],`
34
+ : " imageKeys: ASTROID_SETTINGS_IMAGE_KEYS,",
35
+ ];
20
36
  return [
21
37
  "// The typed Astro Actions surface — ADR 0001 layer 2.",
22
38
  "//",
@@ -94,8 +110,7 @@ export function generateAstroidActions(_config) {
94
110
  " // The SAME allowlist the generated worker enforces, imported rather",
95
111
  " // than copied — a second literal here is a list that drifts from the",
96
112
  " // one the routes check against, and nothing would fail when it did.",
97
- " columns: ASTROID_SETTINGS_COLUMNS,",
98
- " imageKeys: ASTROID_SETTINGS_IMAGE_KEYS,",
113
+ ...settingsExtra,
99
114
  ' mediaBase: astroidConfig.deploy?.mediaBase ?? "/media",',
100
115
  " }),",
101
116
  " ),",
@@ -30,6 +30,7 @@ import { generateAstroidActions } from "./actions.js";
30
30
  import { cwvBeaconScript } from "louise-toolkit/analytics";
31
31
  import { generateMapEmbedComponent, generateMapTileRoute } from "../map/scaffold.js";
32
32
  import { generateAstroidGalleryPage } from "../portfolio/scaffold.js";
33
+ import { astroidPortal } from "../portal/config.js";
33
34
  import { generateAstroidPortalAuth, generateAstroidPortalAuthRoute } from "../portal/scaffold.js";
34
35
  import { generateAstroidEditSession } from "../realtime/scaffold.js";
35
36
  import { generatePwaHeaders, generateServiceWorker, generateWebManifest } from "../pwa/generate.js";
@@ -58,6 +59,23 @@ export function generateAstroidScaffoldFiles(config) {
58
59
  // be hashed into the CSP and would be blocked.
59
60
  const beacon = generateAstroidVitalsBeacon(config, cwvBeaconScript());
60
61
  files.push({ path: beacon.path, contents: beacon.contents });
62
+ // --- site-owned schema tables --------------------------------------------
63
+ // Always: the generated src/schema.ts re-exports `./schema.site.js`, so the
64
+ // file must exist even when empty. A project declares tables Astroid doesn't
65
+ // manage here (an existing overlay predating Astroid, a redirects map, a
66
+ // customer address book) and they flow to drizzle-kit + the worker for free.
67
+ files.push({
68
+ path: "src/schema.site.ts",
69
+ contents: [
70
+ "// Site-owned Drizzle tables — the ones Astroid doesn't manage. Declare them",
71
+ "// here; the generated src/schema.ts re-exports everything from this file, so",
72
+ "// drizzle-kit sees them and the worker can import them. Empty by default.",
73
+ "//",
74
+ "// e.g. export const redirects = sqliteTable(\"redirects\", { … });",
75
+ "export {};",
76
+ "",
77
+ ].join("\n"),
78
+ });
61
79
  // --- the typed Astro Actions surface --------------------------------------
62
80
  // Always: every project has editable pages, and the routes alone leave the
63
81
  // Astro-native half of ADR 0001 unbuilt. Scaffold-once because it is meant to
@@ -133,8 +151,16 @@ export function generateAstroidScaffoldFiles(config) {
133
151
  // Always non-null alongside portalAuth (same `astroidPortal` gate), but the
134
152
  // types don't know that and a silent drop here is a portal that cannot
135
153
  // authenticate — so assert it rather than `?.`-ing it away.
136
- if (route)
137
- files.push({ path: "src/pages/api/portal-auth/[...all].ts", contents: route });
154
+ //
155
+ // The route file lives at the mount path, because Astro routing is
156
+ // file-path-based: a portal mounted at `/api/shop-auth` needs its catch-all
157
+ // at `src/pages/api/shop-auth/[...all].ts`, not the default `/api/portal-auth`.
158
+ // Derive it from the resolved basePath so a configured mount and its route
159
+ // can't drift.
160
+ const portal = astroidPortal(config);
161
+ if (route) {
162
+ files.push({ path: `src/pages${portal.basePath}/[...all].ts`, contents: route });
163
+ }
138
164
  }
139
165
  return files;
140
166
  }
@@ -38,6 +38,15 @@ import { astroidSectionCatalog } from "../components/sections.js";
38
38
  function pageMediaBase(config) {
39
39
  return config.deploy?.mediaBase ?? "/media";
40
40
  }
41
+ /**
42
+ * The section catalog a `pages` write is validated + sanitized against: the
43
+ * site's own (`config.sectionCatalog`) when it registered bespoke sections, else
44
+ * Astroid's built-in vocabulary. This is what lets a site with its own section
45
+ * designs (coracle's 13) keep the same write contract as a stock Astroid site.
46
+ */
47
+ function resolveSectionCatalog(config) {
48
+ return config.sectionCatalog ?? astroidSectionCatalog;
49
+ }
41
50
  /**
42
51
  * Return a copy of a `pages` write payload with its `sections` rich-text fields
43
52
  * sanitized against the project media base — a no-op when the write carries no
@@ -52,7 +61,7 @@ export function sanitizeAstroidPageSections(config, data) {
52
61
  if (data.sections === undefined)
53
62
  return data;
54
63
  const mediaBase = pageMediaBase(config);
55
- const sections = sanitizeSectionsRichText(data.sections, astroidSectionCatalog, (html) => sanitizeRichHtml(html, { mediaBase }));
64
+ const sections = sanitizeSectionsRichText(data.sections, resolveSectionCatalog(config), (html) => sanitizeRichHtml(html, { mediaBase }));
56
65
  return { ...data, sections };
57
66
  }
58
67
  /**
@@ -65,7 +74,7 @@ export function sanitizeAstroidPageSections(config, data) {
65
74
  export async function assertAstroidPageSections(config, data, operation = "update") {
66
75
  if (data.sections === undefined)
67
76
  return;
68
- await assertValidSections(astroidSectionCatalog, data.sections, {
77
+ await assertValidSections(resolveSectionCatalog(config), data.sections, {
69
78
  operation,
70
79
  mediaBase: pageMediaBase(config),
71
80
  });
@@ -9,6 +9,11 @@
9
9
  * wholesale-inquiry module. Shared by table selection (here) and route selection
10
10
  * (the worker route plan). */
11
11
  export function capturesInquiries(config) {
12
+ // Explicit override wins — a site whose inquiry surface is a bespoke section
13
+ // (coracle's custom `contactForm`) can't be detected from the built-in
14
+ // vocabulary, so it says so directly.
15
+ if (typeof config.inquiries === "boolean")
16
+ return config.inquiries;
12
17
  const wantsWholesale = (mods) => (mods ?? []).includes("wholesaleInquiry");
13
18
  return ((config.sections ?? []).includes("contact") ||
14
19
  wantsWholesale(config.modules) ||
@@ -53,5 +53,12 @@ export function generateAstroidSchema(config) {
53
53
  ...(catalog ? [catalog] : []),
54
54
  `export { ${framework.join(", ")} };`,
55
55
  "",
56
+ "// Site-owned tables (the ones Astroid doesn't manage): a project declares",
57
+ "// its own Drizzle tables in src/schema.site.ts and they're re-exported here",
58
+ "// so drizzle-kit sees them and the generated worker can import them. The file",
59
+ "// is scaffold-once — empty until a project adds a table — so this re-export is",
60
+ "// always safe.",
61
+ 'export * from "./schema.site.js";',
62
+ "",
56
63
  ].join("\n");
57
64
  }
@@ -60,7 +60,10 @@ export function astroidRateRules(config) {
60
60
  windowSec: WINDOW,
61
61
  });
62
62
  if (config.portal?.enabled) {
63
- const base = ASTROID_PORTAL_BASE_PATH;
63
+ // The portal's mount is configurable (a site may already ship a second
64
+ // instance at its own path), so the credential-surface rules must track the
65
+ // resolved base path, not the default constant — mirrors `astroidPortal`.
66
+ const base = config.portal.basePath ?? ASTROID_PORTAL_BASE_PATH;
64
67
  // Customer credentials, unlike the editor's, are password-based — so these
65
68
  // guard credential stuffing and enumeration, not just mail volume.
66
69
  rules.push({
@@ -12,6 +12,7 @@
12
12
  // beforeChange hook, and pagesRoute (which takes no collection config) through
13
13
  // the `astroidPagesWriteHooks` spread, so both write paths enforce one contract.
14
14
  import { ASTROID_VITALS_BINDING, generateAstroidCwvQuery, } from "../analytics/index.js";
15
+ import { astroidEditorTable } from "../auth/index.js";
15
16
  import { astroidPortal } from "../portal/config.js";
16
17
  import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/messages.js";
17
18
  import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, usesRealtime, } from "../realtime/scaffold.js";
@@ -102,8 +103,15 @@ export function generateAstroidWorker(config) {
102
103
  // writes live field saves (title, SEO) straight through, and the draft
103
104
  // buffer belongs to the versioned body — i.e. to versionsRoute.
104
105
  return 'saveRoute({ resolveEditor, collections: { pages: { table: pages, fields: ["title", "seoTitle", "seoDescription"] } } })';
105
- case "settings":
106
- return "settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE })";
106
+ case "settings": {
107
+ // Site-specific keys (config.settings.customKeys) are merged into
108
+ // site_settings.custom; omitted entirely when a project has none, so a
109
+ // stock site's route call is unchanged.
110
+ const customArg = (config.settings?.customKeys ?? []).length
111
+ ? ", customKeys: SETTINGS_CUSTOM_KEYS"
112
+ : "";
113
+ return `settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE${customArg} })`;
114
+ }
107
115
  case "ai":
108
116
  return "aiRoute({ resolveEditor, ai: (env) => env.AI })";
109
117
  case "seoFix":
@@ -114,9 +122,11 @@ export function generateAstroidWorker(config) {
114
122
  // upload — so it costs nothing on a project that doesn't want it.
115
123
  return "mediaRoute({ table: media, resolveEditor, referenceSources: MEDIA_REFERENCE_SOURCES, altText: (env) => env.AI })";
116
124
  case "editors":
117
- // Better Auth owns the `user` table (a NAME, not a Drizzle table), so this
118
- // route takes the default `"user"`; a `tablePrefix` would rename it.
119
- return 'editorsRoute({ table: "user", resolveEditor })';
125
+ // The editor instance's user table is `louise_`-prefixed (the editor
126
+ // convention — the unprefixed `user` table is left for a second/portal
127
+ // instance). This route takes the table NAME, matching the
128
+ // `tablePrefix` the scaffolded `src/auth.ts` passes to `getLouiseAuth`.
129
+ return `editorsRoute({ table: ${JSON.stringify(astroidEditorTable("user"))}, resolveEditor })`;
120
130
  case "form":
121
131
  // `onSubmit` fires AFTER the insert and off the response path, so the
122
132
  // notify + confirm pair is store-and-forward by construction: the
@@ -184,8 +194,21 @@ export function generateAstroidWorker(config) {
184
194
  p();
185
195
  p("// Editable site_settings columns the Settings panel may write, and which of");
186
196
  p("// them resolve to a media-library asset.");
187
- p(`const SETTINGS_COLUMNS = ${JSON.stringify(ASTROID_SETTINGS_COLUMNS)};`);
188
- p(`const SETTINGS_IMAGE_KEYS = ${JSON.stringify(ASTROID_SETTINGS_IMAGE_KEYS)};`);
197
+ const settingsImageKeys = [
198
+ ...ASTROID_SETTINGS_IMAGE_KEYS,
199
+ ...(config.settings?.imageKeys ?? []),
200
+ ];
201
+ const settingsCustomKeys = config.settings?.customKeys ?? [];
202
+ // A custom-heavy site can override (or empty) the editable base columns.
203
+ const settingsColumns = config.settings?.columns ?? ASTROID_SETTINGS_COLUMNS;
204
+ // Annotated because a custom-heavy site's `columns: []` would otherwise infer
205
+ // `any[]` (implicit-any under strict).
206
+ p(`const SETTINGS_COLUMNS: string[] = ${JSON.stringify(settingsColumns)};`);
207
+ p(`const SETTINGS_IMAGE_KEYS = ${JSON.stringify(settingsImageKeys)};`);
208
+ if (settingsCustomKeys.length) {
209
+ p("// Site-specific keys stored in the site_settings.custom JSON column.");
210
+ p(`const SETTINGS_CUSTOM_KEYS = ${JSON.stringify(settingsCustomKeys)};`);
211
+ }
189
212
  p();
190
213
  p("// Delete-safety for the media library: where a media key can be REFERENCED,");
191
214
  p("// so deleting an asset that's live on a page warns instead of silently");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "astro",
@@ -60,7 +60,7 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "louise-toolkit": "0.16.0"
63
+ "louise-toolkit": "0.17.0"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "solid-js": "^1.9.0"