Published spaces: a space as a small public website (server)

Merged
CA
5d ago
+4176-65

Server side of published spaces (architecture §7.10). Stacked on #158 (locked notes follow exposure); merge that first.

A space's owner publishes it at /@<handle>/, HEY World–style, with nothing to set up: each note with a title and a body is a post.

The site

  • Pages: posts with excerpts, 30 to a page (/, /page/2/); a page per post (<slug>-<short id>, an old slug redirects 301) with its tags and newer/older; /tags/ as a tree with counts, a page per tag (nested tags included, on one page: under a tag, page/2/ would be a nested tag's address); /feed.xml (Atom, newest 50, absolute links from the app's origin); /sitemap.xml; /search.
  • Rendering: the editor's Markdown via goldmark, raw HTML escaped; body headings step down under the title's <h1>; app images copied in and sized; #tags linked by the apps' own scanner (search.TagSpans, the same rules, with positions).
  • Look: the app icon's neutrals with the space's accent (not its icon), generated and contrast-checked by scripts/space_icons.py → publish/assets/theme.css. Semantic, shallow markup with contextual classes; light/dark from a tiny script in <head>, system preference without it. Custom CSS (≤ 64 KB) comes after the site's styles, which sit in @layer notesd, so it wins without !important; CSS variables are the main hooks; its @imports are hoisted.

Performance (as discussed)

  • Rendered in the background to data/public/<handle>/ and served as files from a manifest (never a path built from the URL, never the database): on publish, at startup, and once a space is quiet 10 s (≤ 2 min). Only changed files are rewritten, atomically.
  • Cacheable by anyone: max-age=60, stale-while-revalidate=86400 + ETag/304; images, CSS and script content-named and immutable; gzipped copies stored; no cookies read or set; CSP allows only the site's own script.
  • Search, the one dynamic page: FTS5 through 2 read connections of its own per file (database.OpenReader), at most NumCPU (≥ 2) at once → 503 Retry-After, 30/min per visitor → 429 (the forwarded address when the proxy is on a private address), answers cached a minute (keyed as typed), noindex. Untitled or empty notes in the index are skipped by reading up to 4 batches of 50.

Data, by the two-kind rule

  • system.db published_spaces (migration 0006): file, space_id, handle (unique), origin, published. Unpublishing keeps the handle; deleting the space frees it, deleting an account frees its own spaces' handles (a shared space keeps its address and goes with the hand-off).
  • Notes file space_publish (migration 0014): the custom CSS, carried by ShareOut/ShareIn; a new sharing.OnShare hook moves the address to the space's file in the transaction recording the share.

Rules

  • Publishing is the published trait and exposes the space (audience: ["public"]), so locked notes follow #158's rule: 409 space_has_locked to publish, 422 locked_in_exposed_space while published. Publishing records its row inside WithoutLocked (which now passes its transaction, so the CSS is written in it too).
  • The default space can't be published (409 space_default): it's where a refused locked note is kept.
  • An account publishes its own spaces; a shared space's owner publishes it (403 not_space_owner for members).

API

GET/PUT/DELETE /api/v1/spaces/{id}/publish ({handle?, origin?, css?} → {handle, url, origin, published, css}); errors not_published (404), handle_taken (409). The trait: "At notes.example/@garden" / "Public at notes.example/@garden", problem "Publishing failed".

Also

  • userdata.Registry open/change listeners are lists, read at commit time: the mirror and the publisher both hear every file, including those opened before a feature installed.
  • auth.BeforeRemove gets api.RemoveAccount (sharing hand-off + dropping the account's own sites); the users endpoint reloads the publisher at once.
  • Web app: the service worker leaves /@ to the network (it served the app shell for a site's page to anyone with the app installed).
  • Docs: architecture §7.10 (new), §9 table, §11.1 layout; API reference; where data lives; backups (skip public/); server README.

Not in this PR

Web Space info UI (phase 2), custom domains (phase 3), Android/iOS on/off + handle (phase 4). Until phase 2, publishing is API-only; the apps already show the published trait and its lock refusal.
Deviation from the plan: GET /spaces doesn't gain a published: {handle, url} field; the trait carries the address and GET /spaces/{id}/publish the settings, so the list doesn't repeat them.

Testing

  • mise run lint, typecheck, test (Go -race, web 1125, sync engine JVM + JS) ✅
  • Unit: rendering (headings, raw HTML, images, tags incl. emphasis/code/links/dot levels), slugs/short ids, excerpts, stylesheet layering and @import hoisting, the whole site's layout (pagination, post page, tag tree counts, 404 root, feed, gzip), search 503, visitor keys, TagSpans offsets, a line ending in a tag, gzip q-values, Limiter.Take under a burst, a deleted space or missing file dropping its address (this hung the publisher before the fix).
  • API end to end: publish/settings/origin/default; trait; home, redirect, unknown site; images only from the manifest; 304 and gzip ETags; custom CSS; re-render after a change; tag pages; slug redirect after a title change; locked-note refusals both ways; unpublish keeps the handle; handles (generated -2, taken, invalid, renamed, freed on delete); a published space later shared keeps its site and CSS, member 403, member sees the trait; account removal takes its site down; search shows only published notes, finds posts past 50 unshown matches, caches per spelling, noindex, per-visitor 429.
  • Smoke: the real binary with a fresh data dir: publish over the API, /@garden-notes/ served, a new post appears after the quiet period, nothing logged as an error.

Summary by cubic

Builds on the locked-notes exposure change: a space's owner can publish it as a small public website at /@<handle>/ with nothing to set up — every note with a title and a body becomes a post. The published trait exposes the space, so locked notes are refused in both directions, the default space can't be published, and only the owner publishes.

The site and serving

  • Posts with excerpts, 30 to a page, plus a page per post (old slugs redirect 301), a tags tree with counts and a page per tag, an Atom feed, a sitemap, and search.
  • Markdown is rendered server-side with raw HTML escaped, app images copied in, tags linked by the apps' own rules; theme colors come from the space's accent with contrast checks, and custom CSS (≤64 KB) overrides the site's styles through @layer, with its @imports split by CSS tokens (strings, comments, brackets) so a statement inside url() isn't cut off.
  • Sites render in the background to data/public/<handle>/ and are served from a manifest — never from the URL or the database — on publish, at startup, and after 10 s of quiet; only changed files are rewritten, and a render finding the space deleted or its file gone drops the address.
  • Pages are cacheable by anyone (ETags, gzip when accepted, immutable content-named assets, no cookies); search is the only dynamic page, limited to a few concurrent reads (503) and 30/min per visitor (429), with answers cached a minute as typed, noindex, and up to four batches read to find posts among notes the site doesn't show.

Data and API

  • The address lives in system.db (published_spaces) and the custom CSS in the space's file (space_publish), carried when the space is shared; the address moves to the space's file in the same transaction.
  • Adds GET/PUT/DELETE /api/v1/spaces/{id}/publish; deleting a space or account takes the site down and frees its handle.
  • The web app's service worker leaves /@ to the network; userdata.Registry open/change listeners are lists read at commit time so the mirror and publisher hear every file.