@marktiderman/genesis-sites 0.0.0-stage → 0.2.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +465 -2
  3. package/dist/assets/form-island.c0f6fac859.js +51 -0
  4. package/dist/assets/islands.77d11f5697.js +1 -0
  5. package/dist/assets/sites-forms.4e6aa42497.css +10 -0
  6. package/dist/assets/sites-forms.css +10 -0
  7. package/dist/assets/sites.css +2 -0
  8. package/dist/assets/sites.e1ffd2708b.css +2 -0
  9. package/dist/blocks.d.ts +13 -0
  10. package/dist/blocks.js +26 -0
  11. package/dist/blocks.js.map +1 -0
  12. package/dist/chunk-3VKTBE7S.js +69 -0
  13. package/dist/chunk-3VKTBE7S.js.map +1 -0
  14. package/dist/chunk-C375ZPU2.js +2241 -0
  15. package/dist/chunk-C375ZPU2.js.map +1 -0
  16. package/dist/chunk-IXMY4B56.js +877 -0
  17. package/dist/chunk-IXMY4B56.js.map +1 -0
  18. package/dist/chunk-VZUDRPFG.js +13 -0
  19. package/dist/chunk-VZUDRPFG.js.map +1 -0
  20. package/dist/chunk-ZGWGQGT2.js +595 -0
  21. package/dist/chunk-ZGWGQGT2.js.map +1 -0
  22. package/dist/import.d.ts +130 -0
  23. package/dist/import.js +330 -0
  24. package/dist/import.js.map +1 -0
  25. package/dist/index.d.ts +273 -0
  26. package/dist/index.js +254 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/registry-D_DCWRTc.d.ts +1030 -0
  29. package/dist/sections.d.ts +802 -0
  30. package/dist/sections.js +6 -0
  31. package/dist/sections.js.map +1 -0
  32. package/dist/store-DkSycDhq.d.ts +443 -0
  33. package/dist/supabase.d.ts +41 -0
  34. package/dist/supabase.js +283 -0
  35. package/dist/supabase.js.map +1 -0
  36. package/dist/web.d.ts +136 -0
  37. package/dist/web.js +479 -0
  38. package/dist/web.js.map +1 -0
  39. package/migrations/0001_genesis_sites.sql +821 -0
  40. package/migrations/0002_genesis_sites_posts.sql +404 -0
  41. package/migrations/tests/0001_genesis_sites.test.sql +349 -0
  42. package/migrations/tests/0002_genesis_sites_posts.test.sql +242 -0
  43. package/package.json +152 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Tiderman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,466 @@
1
- # Temporary Holding Version
1
+ # @marktiderman/genesis-sites
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sites and landing pages for Genesis apps. A site is a set of pages made of
4
+ sections. It is drawn from your brand, served as cached HTML with no
5
+ hydration, and published as immutable releases you can roll back.
6
+
7
+ - **Core** (`.`): zod 4 documents (`SiteDoc`, `PageDoc`, `SectionNode`, SEO,
8
+ menus, redirects) in Puck's `{ root, content }` shape, the section registry
9
+ (`defineSection`), props migrations, SEO, the publish checks, the theme from
10
+ your brand, templates and the `SiteStore` contract.
11
+ - **Sections** (`/sections`): 20 server-rendered sections on Genesis UI.
12
+ - **Web** (`/web`): `handleSiteRequest`, the renderer and request handler.
13
+ - **Supabase** (`/supabase`): `createSupabaseSiteStore`, over the
14
+ `genesis_sites` schema in `migrations/`.
15
+ - **Import** (`/import`, Node): `importAssets`, which re-hosts an old site's
16
+ images and PDFs behind a guard against server-side request forgery, and
17
+ `rewriteUrls`.
18
+ - **Block** (`/blocks`): `sitesBlock`, so `genesis add sites` installs the
19
+ migration.
20
+
21
+ Status: beta. The visual editor (Puck) is next; the editor-facing APIs
22
+ (`sectionManifests`, `sectionJsonSchema`, labels, categories, variants and
23
+ presets) are in place for it. The design and its reasoning are in
24
+ [docs/SITES.md](../../docs/SITES.md).
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ genesis add sites # copies the migrations (0001, 0002) and their pgTAP tests into supabase/
30
+ pnpm add @marktiderman/genesis-sites zod@^4
31
+ ```
32
+
33
+ Peers: `react` and `react-dom` 19, `zod` 4, `@marktiderman/genesis-ui`,
34
+ `@marktiderman/genesis-design-system`, `@marktiderman/genesis-forms` and
35
+ `-forms-web` (the form embed), `lucide-react`, and `@supabase/supabase-js` for
36
+ the Supabase store.
37
+
38
+ Then:
39
+
40
+ 1. Expose the `genesis_sites` schema to the API: add it to `schemas` under
41
+ `[api]` in `supabase/config.toml`, and to Exposed schemas in the dashboard.
42
+ 2. Decide who edits a site (see [Who may edit](#who-may-edit)).
43
+ 3. Mount `handleSiteRequest` on your server, before your app's routes.
44
+
45
+ ## A landing page in one call
46
+
47
+ ```ts
48
+ import { createMemorySiteStore, landingPage } from "@marktiderman/genesis-sites";
49
+ import { handleSiteRequest } from "@marktiderman/genesis-sites/web";
50
+
51
+ const brand = {
52
+ name: "Riverside Gym",
53
+ colors: { primary: "#0f766e", primaryDark: "#2dd4bf" },
54
+ fonts: { heading: "space-grotesk", body: "inter" },
55
+ };
56
+ const { site, pages } = landingPage({
57
+ brand,
58
+ message: {
59
+ headline: "Train with people who know your name",
60
+ subhead: "Small classes and real coaching by the river.",
61
+ benefits: [{ title: "Coached classes", body: "A coach in every class." }],
62
+ },
63
+ form: { slug: "free-class", heading: "Book a free class" },
64
+ contact: { email: "hello@riverside.example" },
65
+ });
66
+
67
+ const store = createMemorySiteStore();
68
+ await store.createSite({ id: "riverside", site, pages });
69
+ // The template's sample quotes are placeholders: replace them (or hide the
70
+ // section) and publish. A placeholder still showing blocks the publish.
71
+ await store.publish("riverside", { brand });
72
+ ```
73
+
74
+ `starterSite` makes a three-page site (home, about, contact) from the same
75
+ input. `onePageSite({ name, logo, message, colors, fonts })` seeds a site from
76
+ the least a brand can give.
77
+
78
+ ## Serving a site
79
+
80
+ ```ts
81
+ import { DEFAULT_RESERVED_PATHS } from "@marktiderman/genesis-sites";
82
+ import { handleSiteRequest } from "@marktiderman/genesis-sites/web";
83
+
84
+ export async function handle(request: Request) {
85
+ const siteId = await siteForHost(request.headers.get("host")); // your hostname table
86
+ const res = siteId
87
+ ? await handleSiteRequest(request, {
88
+ store, // a store signed in as nobody: getLive is public
89
+ brand: { getBrand: (id) => brandFor(id) },
90
+ siteId,
91
+ origin: "https://riverside.example", // the primary hostname, for canonical URLs
92
+ reserved: [...DEFAULT_RESERVED_PATHS, "/admin"],
93
+ forms: (slug) => formFor(slug), // see Forms
94
+ })
95
+ : null;
96
+ return res ?? app(request); // null: not the site's path, so your app answers
97
+ }
98
+ ```
99
+
100
+ `handleSiteRequest` answers `GET` and `HEAD` and returns `null` for anything it
101
+ doesn't own: another method, a reserved path (`/api`, `/_server`, `/auth`, and
102
+ `/_gs`, where it serves its own assets), an unknown path (or the site's 404 page
103
+ with `unknownPath: "not-found"`), or a site that isn't published. In order, it
104
+ serves:
105
+
106
+ 1. its own content-hashed assets under `/_gs/` (immutable);
107
+ 2. `robots.txt` and `sitemap.xml`;
108
+ 3. the site's redirects, then a 308 to the one address of a page (lowercase, no
109
+ trailing slash);
110
+ 4. the page: streamed HTML from `renderToReadableStream`, with no hydration, an
111
+ inline theme `<style>`, a Content-Security-Policy, `Cache-Control: public,
112
+ max-age=0, s-maxage=31536000, stale-while-revalidate=86400` and
113
+ `Vercel-Cache-Tag: site:<id>` (plus `form:<slug>` for each form the page
114
+ embeds: purge that tag when you change the form, since the page cached the
115
+ form it was rendered with).
116
+
117
+ The page loads one script, the islands loader (2.3 KB), and only when it has a
118
+ phone menu or a form. Pass `basePath` to mount a site under a path.
119
+
120
+ **Publishing and the cache.** Wrap the editor's store with
121
+ `withCacheInvalidation` so publish and rollback purge `site:<id>`. Mind what
122
+ the purge does: on Vercel, `invalidateByTag` serves each cached page once more
123
+ while it regenerates, and `dangerouslyDeleteByTag` drops it. Use the delete
124
+ for a rollback that takes something down:
125
+
126
+ ```ts
127
+ import { dangerouslyDeleteByTag, invalidateByTag } from "@vercel/functions";
128
+
129
+ const editorStore = withCacheInvalidation(store, (tags, { action }) =>
130
+ action === "rollback" ? dangerouslyDeleteByTag(tags) : invalidateByTag(tags),
131
+ );
132
+ ```
133
+
134
+ Archiving a site, or editing its brand, doesn't go through the store: purge
135
+ the tag there too (the database sends a `genesis_sites` notification on every
136
+ publish, rollback and archive, for a listener). A page rendered with a brand
137
+ that changed since the release was checked is served with a 5-minute cache
138
+ and a warning until you publish again. A failed purge is logged and rethrown.
139
+
140
+ A hidden section stays in the draft and is left out of the release, so it is
141
+ never public. A stored page that no longer parses is left out of the site
142
+ (and logged) rather than taking the whole site down.
143
+
144
+ **Preview.** `mode: "preview"` renders the drafts, with `noindex` and
145
+ `Cache-Control: private, no-store`, through a store signed in as the viewer,
146
+ so the database's `can_view` decides who sees them. Pass the signed-in
147
+ viewer's user id as `viewer`: without one, preview answers null before it
148
+ reads a draft (anon has no grant on them), and the host shows its sign-in
149
+ page or a 404. A failed read of the published posts on the live site logs and
150
+ renders an empty list (or, for one post, not found) instead of failing the page.
151
+
152
+ ## Sections
153
+
154
+ | Type | Variants | Presets |
155
+ |---|---|---|
156
+ | `nav` | simple, centered, minimal | Header with menu; Centred header; Landing page header |
157
+ | `hero` | centered, split, image-bg | Centred hero; Hero with image |
158
+ | `features` | grid, list, alternating | Three features; Feature list |
159
+ | `cta` | banner, card, split | Banner; Card |
160
+ | `testimonials` | grid, single | Quotes; One quote |
161
+ | `faq` | accordion, list | FAQ |
162
+ | `pricing` | cards, compact | Three plans |
163
+ | `logos` | row, grid | Logo row |
164
+ | `stats` | row, cards | Three numbers |
165
+ | `gallery` | grid, masonry | Photo grid |
166
+ | `rich-text` | narrow, wide | Text |
167
+ | `form-embed` | card, split | Contact form; Form card |
168
+ | `footer` | simple, columns, minimal | Footer; Footer with columns |
169
+ | `schedule` | grid, agenda | Weekly timetable; Day by day |
170
+ | `team` | cards, list | Team cards; Team list |
171
+ | `blog-list` | grid, list | Blog index; Latest three |
172
+ | `post-body` | article, cover | Article; Title over the cover |
173
+ | `map` | split, stacked | Find us; Map and address |
174
+ | `timeline` | vertical, cards | Week by week; Steps as cards |
175
+ | `file` | list, cards | Downloads; File cards |
176
+
177
+ And inside the MVP sections: `features` has a `steps` variant (numbered,
178
+ a button and a note per step) and `numbered`; `pricing` takes plan `groups`
179
+ (each with its heading, anchor and up to 12 plans) beside or instead of
180
+ `tiers`; `logos` has a `trust-bar` variant (a short claim and badges), and an
181
+ item without an image is a text badge.
182
+
183
+ - **`schedule`**: the week's classes by time and day, with coach, place,
184
+ a note and a booking link per class, and chips per class type in the
185
+ brand's colours (`types`, with a legend). On wider screens it is a table
186
+ (a caption, a header cell per day and per time, an empty slot said as "No
187
+ classes"); on a phone a day picker: radio buttons and the stylesheet's
188
+ `:has()` show one day's classes, with no script (every day shows where
189
+ `:has()` isn't supported).
190
+ - **`team`**: photo, role, credentials, a long bio, a quote, a button and a
191
+ link to the person's page (`profile`). The cards open each bio in place
192
+ (`<details>`); the list shows it all. Each person can have an `anchor`.
193
+ - **`map`**: the address as text, a picture (`image`) and a link to the
194
+ provider. No map frame is in the HTML: the islands loader shows a "Show
195
+ the map" button, and only a click puts the OpenStreetMap or Google Maps
196
+ frame on the page, so nothing loads from the provider before the visitor
197
+ asks. The frame's URL is built from the address or the coordinates, never
198
+ typed, and the page's Content-Security-Policy frames only that provider
199
+ (`frameOrigins` on the definition).
200
+ - **`timeline`**: an ordered list of steps, each with a label (a date,
201
+ "Week 1"), a title, copy, a picture and a button.
202
+ - **`file`**: downloads that say their type and size before anyone clicks
203
+ ("PDF, 1.2 MB"). Upload a PDF with `putAsset`: it is stored under
204
+ `sites/<site_id>/files/`.
205
+
206
+ ### Links
207
+
208
+ Every button and link prop is a `Link`: `{ label, href, target?, external?,
209
+ event? }`. `external: true` marks an outbound link (a booking or
210
+ membership system): it must be an http(s) URL (checked when parsed, and
211
+ inert when rendered if a document skipped the check), and it renders with
212
+ `rel="noopener noreferrer"` and `data-gs-outbound`. `target: "_blank"` opens
213
+ an http(s) link in a new tab (never a site path), with a visually hidden
214
+ "(opens in a new tab)". `event` names the click for your analytics
215
+ (`data-gs-event`, `cta` by default on buttons).
216
+
217
+ Every section is server-safe React on Genesis UI (`Band`, `Display`,
218
+ `BandLead`, `Button`), sits on a surface (`base`, `muted`, `accent`,
219
+ `inverse`), takes its heading level from the page (one `h1`, from the first
220
+ hero), and passes axe's WCAG 2.2 A and AA rules in the tests. Stored links are
221
+ checked again when they render: only site paths, `#fragments`, `http(s)`,
222
+ `mailto` and `tel` links print; anything else renders as an inert link and is
223
+ logged. Rich text is sanitised by Genesis UI's `RichText`, and JSON-LD is
224
+ escaped so stored text can't close its `<script>`.
225
+
226
+ ### Your own sections
227
+
228
+ ```ts
229
+ import { defineSection, registerSection, Line } from "@marktiderman/genesis-sites";
230
+ import { z } from "zod";
231
+
232
+ registerSection(
233
+ defineSection({
234
+ type: "class-times",
235
+ version: 1,
236
+ label: "Class times",
237
+ category: "content",
238
+ schema: z.object({ heading: Line(90), rows: z.array(z.object({ day: Line(20), time: Line(20) })).max(14) }),
239
+ variants: [{ id: "table", label: "Table" }],
240
+ defaultVariant: "table",
241
+ presets: [{ name: "Weekly times", variant: "table", props: { heading: "Class times", rows: [{ day: "Monday", time: "6 pm" }] } }],
242
+ render: ({ props, ctx }) => <ClassTimes {...props} level={ctx.headingLevel} />,
243
+ }),
244
+ );
245
+ ```
246
+
247
+ `defineSection` checks the definition (every preset against the schema, a
248
+ migration for every version step) and freezes it. A built-in type is replaced
249
+ only with `registerSection(def, { replace: true })`. When props change shape,
250
+ raise `version` and add `migrations[n]`, a pure function from version `n - 1`
251
+ props; `migrateNode` runs the steps on read and a publish writes the result
252
+ into the release. `sectionJsonSchema(type)` gives the props' JSON Schema (for
253
+ editor fields and AI output) and `sectionManifests()` the editor's catalog.
254
+
255
+ ## Publish checks
256
+
257
+ `store.publish` runs `prepareRelease`, which migrates every node and refuses
258
+ to publish (`PublishBlockedError`, with `issues`) when:
259
+
260
+ - a heading would break the outline: the renderer gives every page exactly
261
+ one `h1` (the first hero's title, or the page title, visually hidden), so
262
+ rich text may not hold another `h1` or skip a level, and a page has at most
263
+ one hero;
264
+ - a brand colour pair the sections draw is under 4.5:1 (3:1 for the focus
265
+ ring), in each colour scheme the site uses, or the publish wasn't given the
266
+ brand to check;
267
+ - an image has no alt text and isn't marked decorative;
268
+ - a section is unknown, has invalid props or was saved by newer code;
269
+ - a template placeholder is still visible;
270
+ - a page path is malformed or reserved, or the site has no home page.
271
+
272
+ Warnings (an unknown variant, a page with no hero, a missing description)
273
+ don't block.
274
+ `checkRelease`, `checkPage`, `checkContrast` and `checkHtmlOutline` are
275
+ exported for an editor's lint panel.
276
+
277
+ ## SEO
278
+
279
+ Each page gets a title from the site's template (`%s · Riverside Gym`), a
280
+ description, `robots`, a canonical URL from `origin`, and Open Graph and
281
+ Twitter tags. The home page carries `Organization` and `WebSite` JSON-LD, and
282
+ every page below it a `BreadcrumbList`. `sitemap.xml` lists the indexable
283
+ pages; `robots.txt` points at it, or disallows everything for a `noindex` site
284
+ and in preview. Moving a page (`store.movePage`) leaves a 301 from the old
285
+ path and rewrites older redirects to point at the new one, so there are no
286
+ chains.
287
+
288
+ ## Forms
289
+
290
+ A `form-embed` section names a form by slug. Your `forms` resolver says what
291
+ the slug is:
292
+
293
+ ```ts
294
+ forms: (slug) =>
295
+ slug === "free-class"
296
+ ? { kind: "definition", definition: freeClass.definition, submitUrl: "/api/forms/free-class" } // your endpoint
297
+ : slug === "crm"
298
+ ? { kind: "definition", definition: enquiry.definition, submitUrl: "https://crm.example/hooks/riverside" } // another service's
299
+ : slug === "write-to-us"
300
+ ? { kind: "mailto", email: "hello@riverside.example", subject: "A question" } // no endpoint yet
301
+ : slug === "waitlist"
302
+ ? { kind: "embed", url: "https://forms.example/waitlist", title: "Join the waitlist" }
303
+ : null,
304
+ ```
305
+
306
+ - `submitUrl` is a site path or an https URL. An https endpoint's origin is
307
+ added to the page's `connect-src` and `form-action`.
308
+ - `mailto` is the fallback for a site with no endpoint: a button that opens
309
+ the visitor's email, with the subject you give (line breaks removed). No
310
+ form is drawn, nothing is posted or stored, and a page never posts a form
311
+ to `mailto:`.
312
+ - A genesis-forms definition with no logic, pages, recall or hidden inputs
313
+ (most contact and sign-up forms) renders as a plain HTML form. The islands
314
+ loader sends `{ form, answers }` as JSON, with answers keyed by field id as
315
+ genesis-forms' `answers()` gives them. Without script, the browser posts the
316
+ same fields as an ordinary form.
317
+ - A definition that needs the engine renders with forms-web's `<Form>`, and
318
+ the form bundle loads on that page only.
319
+ - `embed` shows an https URL in a sandboxed iframe.
320
+
321
+ Either way, your endpoint checks the answers again (`validateSubmission` from
322
+ genesis-forms). An unknown slug renders nothing and logs.
323
+
324
+ ## The blog
325
+
326
+ A site's posts live beside its pages, each with a draft and a published
327
+ copy (`site_posts`, migration `0002_genesis_sites_posts.sql`). A post goes
328
+ live on its own: `publishPost` checks the draft (`checkPost`: alt text on
329
+ its images, no `h1` and no skipped heading level in its text) and makes it
330
+ the published copy, without a new release of the site. `unpublishPost`
331
+ takes it down; `deletePost` removes it. A site's rollback doesn't touch its
332
+ posts.
333
+
334
+ ```ts
335
+ const { id, rev } = await store.savePost(siteId, { slug: "first-class-tips", doc: { schemaVersion: 1, title: "Five tips for your first class", excerpt: "…", body } });
336
+ await store.savePost(siteId, { id, rev, slug: "first-class-tips", doc: edited }); // compare-and-swap: { conflict, rev } if someone saved first
337
+ await store.publishPost(id); // PublishBlockedError with `issues` when a check fails
338
+ ```
339
+
340
+ The site's post page is a page of kind `post` (at most one, never `/`): the
341
+ template every post renders in. At its own path (`/blog`) it is the blog's
342
+ index; at `/blog/<slug>` it shows that post in its `post-body` section,
343
+ with the post's title as the page's `h1`, `og:type` article, `Article`
344
+ JSON-LD and a breadcrumb through the blog. A `blog-list` section lists the
345
+ published posts, newest first, in pages (`?page=2`, plain links), anywhere
346
+ on the site; on a post it leaves that post out. The sitemap lists every
347
+ published post, `/rss.xml` is the feed, and every page links to it.
348
+
349
+ Moving a blog in: `importPosts(siteId, posts)` takes up to 500 posts once,
350
+ each with its slug and its old `publishedAt`. A slug the site already has is
351
+ skipped, and a post that fails a check comes in as a draft with its
352
+ `issues`, so nothing goes live unchecked. Re-host its pictures first (see
353
+ Importing a site) and run `rewriteUrls` over each document.
354
+
355
+ Anon reads posts only through `genesis_sites.live_posts(site_id)` (the
356
+ published posts of a live site, without bodies) and `live_post(site_id,
357
+ slug)`; drafts are never public. `withCacheInvalidation` purges
358
+ `site:<id>` after a post goes up, comes down, is deleted or imported, and
359
+ the database sends a `genesis_sites` notification with `action: "post"`.
360
+
361
+ ## Importing a site
362
+
363
+ ```ts
364
+ import { importAssets, rewriteUrls } from "@marktiderman/genesis-sites/import";
365
+
366
+ const { map, assets, failed } = await importAssets(editorStore, siteId, oldImageAndPdfUrls, { alt: (url) => oldAlt[url] });
367
+ const page = rewriteUrls(importedPage, map); // image sources and file links now point at the copies
368
+ ```
369
+
370
+ Each URL is fetched on your server, checked, and stored through the
371
+ store's `putAsset` (which records it in `site_assets`, with the image's
372
+ width and height) under `sites/<site_id>/` (a PDF under `files/`). An image
373
+ with no alt text yet is `needsAlt`; the publish check asks for it before
374
+ the page goes live. The fetch is guarded against server-side request
375
+ forgery:
376
+
377
+ - http and https only, ports 80 and 443, no user name or password in the URL;
378
+ - only public addresses: loopback, private, link-local (cloud metadata),
379
+ carrier-grade NAT, documentation, benchmark, multicast and reserved
380
+ ranges are refused, and so are their IPv6 forms (IPv4-mapped, NAT64,
381
+ 6to4, unique local, site-local);
382
+ - the address is checked on every address a name resolves to, when the
383
+ socket connects (the request's own DNS lookup), so DNS rebinding can't
384
+ slip past it, and the request uses a fresh agent, never a pooled socket
385
+ or a proxy from the environment;
386
+ - every redirect is checked the same way, and at most three are followed;
387
+ - a size cap (15 MiB by default, by `Content-Length` and while reading)
388
+ and a time limit (20 s);
389
+ - the type comes from the bytes: PNG, JPEG, GIF, WebP, AVIF or PDF. SVG,
390
+ HTML and anything else are refused, whatever the name or `Content-Type`
391
+ says; an image over 20000 pixels a side is refused too.
392
+
393
+ A URL that fails is listed in `failed` with the reason, and the rest go on.
394
+
395
+ ## Who may edit
396
+
397
+ The migration asks two hooks, which ship as stubs that fail closed:
398
+
399
+ - `genesis_sites.can_edit(site_id uuid)` answers false;
400
+ - `genesis_sites.can_view(site_id uuid)`, for draft and release reads, asks
401
+ `can_edit`.
402
+
403
+ Replace them in a migration of your own, after this one, with the same
404
+ signature:
405
+
406
+ ```sql
407
+ create or replace function genesis_sites.can_edit(site_id uuid) returns boolean
408
+ language sql stable security definer set search_path = '' as $$
409
+ select exists (select 1 from public.brand_members m
410
+ join public.brand_sites b on b.brand_id = m.brand_id
411
+ where b.site_id = can_edit.site_id and m.user_id = auth.uid() and m.role in ('owner', 'editor'))
412
+ $$;
413
+ ```
414
+
415
+ `create or replace` keeps the grants. Name the person (`auth.uid()`) in the
416
+ hook: the write functions are security definer, so your tables' row level
417
+ security doesn't filter inside them. Genesis writes the stubs only through
418
+ `genesis_sites.ensure_hook_stubs()`, over a missing hook or the untouched
419
+ stub, so neither `genesis migrate` nor a later genesis-sites migration ever
420
+ overwrites your version. `genesis_sites.reserved_paths()` works the same way
421
+ for the page paths your app keeps.
422
+
423
+ `reserved_paths()` covers redirects too: `save_site_draft` and `publish_site`
424
+ refuse a redirect whose `from` is a reserved path (any case, with or without a
425
+ query string), so a site can't take over `/auth` or your app's other routes.
426
+ `handleSiteRequest` checks `reserved` before redirects and returns `null` for
427
+ a reserved path. Anon can't execute `reserved_paths()`, so the renderer never
428
+ reads it: pass the same list to `handleSiteRequest({ reserved })` yourself.
429
+
430
+ The accessibility and content checks run in the stores (`prepareRelease`)
431
+ before `publish_site`; the database checks shape, paths and revisions. So the
432
+ checks guide editors but are not a security boundary: an editor calling the
433
+ function directly can skip them (and reports its own `brand_hash`). Access
434
+ rules can't be skipped, and the renderer checks every link again. An archived
435
+ site stays down: an editor can't publish or roll it back.
436
+
437
+ Anon reads only `genesis_sites.live_release(site_id)`: the published snapshot
438
+ of a live site. Drafts, page rows and release notes are never readable by
439
+ anon. Sites are created with the service role (`genesis_sites.create_site`).
440
+ Store uploads under `sites/<site_id>/...` so your storage policies can scope
441
+ by site with `can_edit`; the migration's header has an example policy. Limit
442
+ the bucket's `allowed_mime_types` to images and PDF: never SVG or HTML, which
443
+ a browser runs as a page. `putAsset` refuses other types before it uploads.
444
+ A public bucket serves any file to whoever has its URL, so give uploads random
445
+ names (`putAsset` does) and never put anything there that isn't meant for the
446
+ published site.
447
+
448
+ ## Stores
449
+
450
+ `createSupabaseSiteStore(client)` uses the `genesis_sites` functions: pass a
451
+ client signed in as the editor for drafts and publishing, or an anonymous one
452
+ for `getLive`. `createMemorySiteStore()` keeps the same rules in memory, for
453
+ tests, demos and local previews. Draft saves compare and swap a revision: a
454
+ save from an old revision returns `{ conflict, rev }` with the current
455
+ document instead of overwriting it.
456
+
457
+ ## Hostnames
458
+
459
+ The package keeps no hostname table. Resolve the request's host to a site id
460
+ yourself (strip any inbound tenant header), pass it as `siteId`, and pass the
461
+ site's primary hostname as `origin`. The design note covers custom domains on
462
+ Vercel.
463
+
464
+ ## License
465
+
466
+ MIT