Patches Pages
Every actor has a Page: a personal site expressed as a portable declarative document, stored on the actor's node and rendered by clients. Source of truth: INITIAL_VISION.md §170–§172 (Amendment A), ADR 0012.
Status: implemented (Phase 4.5). The document schema/validator (packages/domain), storage (§3), PageService (§4, apps/server/src/modules/pages/), the Ink renderer (apps/tui/src/pages/render/), patches visit, and nameplate rendering (P45-004..007) are all implemented and tested. B-023's structured block-by-block page editor (the TUI editor was originally $EDITOR-on-raw-JSON only) and B-024's Friends block data source (a bulk "list mutual follows" RPC) have since landed too — see §6.
Pages are the personal-web pillar (§175, pillar 3). They are not a profile decoration — the profile is what you see next to a name; a Page is what you visit. Inline identity presentation is the nameplate, documented in §173 and summarized in §8 below.
1. The shape of the decision
Three constraints determined the format:
- The primary client is a terminal. Ink does not render HTML — it renders a React component tree to a terminal via Yoga flexbox layout. Any markup-shaped format would be unrenderable without shipping a browser engine.
- A web renderer comes later and must consume the same data, not a translation of it.
- Visiting someone's page must never execute their code.
Hence: a versioned declarative document that is inert data, validated by the server, rendered by whichever client is asking.
The server never renders. No server-side HTML, no template engine, no theme engine. The server stores, validates, versions, and serves.
2. Document schema
{
"version": 1,
"theme": {
"accent": "#c678dd",
"background": "default",
"foreground": "default",
"border": "round", // single | double | round | ascii | none
"avatarStyle": "block",
},
"pages": [
{
"slug": "index",
"title": "allison",
"blocks": [
{ "type": "Hero", "title": "hi, i'm allison", "subtitle": "techno + terminals" },
{ "type": "Text", "body": "..." },
{ "type": "TopEight", "actors": ["@bob", "@carol@other.node"] },
{ "type": "Guestbook", "limit": 20 },
],
},
],
}Blocks (v1 vocabulary)
| Block | Renders | Phase |
|---|---|---|
Text | plain text paragraph | 4.5 |
Markdown | safe Markdown subset, no raw HTML | 4.5 |
Links | labeled link list, URLs validated per §104 | 4.5 |
Posts | the actor's recent posts, chronological | 4.5 |
TopEight | a chosen handful of actors | 4.5 |
Friends | mutuals / follow list excerpt | 4.5 |
Guestbook | recent guestbook entries + sign action | 4.5 |
Badges | server-attested badges only (§173) | 4.5 |
AsciiArt | fixed-width art, control chars stripped | 4.5 |
Spacer | vertical space | 4.5 |
Hero | title/subtitle banner | 4.5 |
Image | one Patches media item | 5 |
Gallery | several Patches media items | 5 |
NowPlaying | a text status line | later |
Image and Gallery are defined in the schema at Phase 4.5 but render as a placeholder until the Phase 5 media pipeline exists (§176). The schema may lead the pipeline; the renderer may not fake it.
Rules
- Blocks are a flat list. No recursive nesting in v1 — recursion is renderer complexity and a denial-of-service surface with no v1 payoff.
- Strict on write, lenient on render. The server validates strictly against the declared
versionand rejects unknown block types and unknown fields. A renderer ignores block types it doesn't support and shows a visible placeholder rather than failing the page. This is what lets clients ship on different schedules without breaking each other. - Validation lives in
packages/domainso the server, the TUI editor, and any future web editor share one definition.
Limits (enforced server-side, published via GetNodeInfo)
| Limit | Value |
|---|---|
| Serialized document | ≤ 64 KiB |
| Sub-pages per actor | ≤ 32 |
| Blocks per sub-page | ≤ 128 |
| Text per block | ≤ 8 KiB |
| Guestbook entry | ≤ 500 characters |
| Page asset storage | capabilities.maxSiteStorageBytes, per node (§174) |
3. Storage
| Table | Purpose |
|---|---|
pages | one row per actor; points at the current revision |
page_revisions | immutable document snapshots — a bad edit is recoverable and moderation has an audit trail |
page_assets | media attached to a page, counted against the storage capability |
guestbook_entries | visitor entries, moderatable |
Columns are in data-model.md.
4. PageService (server implementation)
Status: implemented (P45-003) — apps/server/src/modules/pages/. Full RPC contract in api.md. Notable behavior beyond the wire contract:
- Block-aware, uniformly (spec §62).
GetPage,ListGuestbook, andSignGuestbookeach report the samePAGE_NOT_FOUNDfor a nonexistent actor, an actor with no page yet, and a blocked-either-direction caller — never aPERMISSION_DENIEDthat would leak which case applies to a blocked caller. GetPage'sdocumentbytes are the raw stored revision, re-serialized as-is rather than round-tripped back throughpackages/domain's types. A revision was already validated strictly at write time; re-parsing on read would risk silently dropping fields written by a newer schema version this server doesn't recognize (spec §171's forward-compatibility requirement). Only the conveniencethemeextract on the response is derived throughpackages/domain's lenient parser, and degrades to empty rather than failing the read.SignGuestbookis rate-limited on two independent buckets — the caller's network peer and their actor id (GuestbookRateLimitService) — because unlikeModerationService's report rate limit,SignGuestbookalways has an authenticated actor behind it, so both signals are meaningful.- One guestbook per page, not per sub-page.
ListGuestbookRequest/SignGuestbookRequestcarry aslug, butguestbook_entriesis keyed onpage_idonly (page.entity.ts) — there is no per-sub-page guestbook yet, even though aGuestbookblock could in principle appear on more than one sub-page.slugis validated on every call so a future multi-guestbook schema change doesn't also need a wire change, but today it only affectsGetPageResponse.active_slug. RemoveGuestbookEntryis owner-only today — moderator removal is a documented follow-up (Bbacklog), not yet implemented.ReportGuestbookEntryreusesreports.subject_type = 'GUESTBOOK_ENTRY'(P45-003) rather than a second reports table, and is not itself rate-limited (onlySignGuestbookis) — the proto's own doc comment onReportGuestbookEntrydoesn't call for one, unlikeSignGuestbook's.
5. Addressing
patches visit @allison # their page, index slug
patches visit @allison/links # a sub-page
patches visit @carol@other.node # a page on another node (federation)Web, later: allison.patches.page.
6. Rendering
PatchesPage document (data)
|
+--------------+--------------+
| |
Ink renderer React DOM renderer
(apps/tui, Phase 4.5) (web, later)Both renderers consume the same document; neither is privileged, and neither is a translation of the other. A third-party client can render a Page without any rendering contract with us — that is what makes the format portable rather than merely stored.
The Ink renderer degrades by terminal capability the same way the rest of the TUI does (truecolor → 256 → 16 → none), and a page must remain readable at every level.
TUI implementation (P45-004..007)
apps/tui/src/screens/PageScreen.tsx is the entry point: v on a ProfileScreen opens the viewed actor's page, g v opens the caller's own, and patches visit @handle[/slug] (apps/tui/src/cli/args.ts) launches the TUI straight onto a page, skipping connect. One GetPage call fetches the whole document (every sub-page); [/] switches sub-pages entirely client-side, no re-fetch.
apps/tui/src/pages/render/blocks.tsx's PageBlocksView renders every §171 block type (Text, Markdown, Image, Links, Posts, Gallery, Friends, TopEight, Guestbook, NowPlaying, Badges, AsciiArt, Spacer, Hero), plus packages/domain's lenient-parse Unknown placeholder for a block type this client doesn't recognize — never a failed page. A few notes on specific blocks:
Image/Galleryrender through the exact same@patches/terminal-mediapath a post attachment uses (components/MediaAttachments.tsx) — Kitty inline when the terminal and a media session support it, the spec §75 fallback box otherwise. P5-003 landed in the same change as the renderer, so this is the real thing rather than the static placeholder P45-005 originally scoped.Postsfetches the page owner's recent posts viaListActorPostsand renders them with the samePostRowa timeline uses (no drill-into-thread yet from inside a page — a documented follow-up, not this task's scope).TopEightresolves each@handleviaGetActorByHandleand renders withNameplate; a@handle@remote-nodereference (federation is a seam, not implemented) renders as plain sanitized text rather than attempting a lookup that would always fail.Friendsrenders mutual follows viaSocialGraphService.ListMutualFollows(B-024) — a self-join onfollows, keyset-paginated, called throughPatchesApi.listMutualFollows(a public read, no session required) fromapps/tui/src/pages/render/blocks.tsx.Guestbookfetches viaListGuestbook;s(only shown/available when aGuestbookblock is present and the viewer has a session) opens an inline compose line,EntercallsSignGuestbook, and the block re-fetches.Linksentries across everyLinksblock on the current sub-page are flattened into onej/k-navigable list;Enteropens the selected one with the OS default handler (apps/tui/src/pages/open-link.ts, the same argument-array-only spawn convention asoon a media attachment — spec §76).
Editing (e, shown only to the page's owner) is the $VISUAL/$EDITOR raw-JSON round trip from §172/P45-006's "or raw JSON in $EDITOR" option: apps/tui/src/pages/editor.ts writes the current document to a temp file, hands the terminal to the editor via a blocking, argument-array-only spawnSync (never through a shell), and re-reads the result. Ink keeps holding the alternate screen throughout — this only looks right on return because editors that matter here (vim, nano, emacs -nw) enter and restore their own alternate screen; an $EDITOR that doesn't would leave visual debris until the next full Ink re-render. The result is validated with packages/domain's parsePageStrict; a validation error (or invalid JSON) keeps the previous document on screen, shows the error, and persists the unsaved edit to a draft file (apps/tui/src/pages/draft-store.ts, same XDG_DATA_HOME pattern as a compose draft) so pressing e again resumes from exactly what was typed rather than losing it. The structured block-by-block editor P45-006 also scoped ("add/remove/reorder … OR as raw JSON") is deferred — B-023.
Nameplates (P45-007): every place an actor's name renders in the Pages surface — TopEight, Guestbook entries — goes through the shared Nameplate component, same as PostRow/SearchScreen/NotificationsScreen/ProfileScreen elsewhere in the TUI.
7. Security
No user-authored executable code in the portable format, in any client, ever. No React, MDX, JS, template language, or expression evaluator. This is §111's "feed definitions are data, not code" and §153's prohibition on remote JS plugins, applied to the personal web.
- Images must be Patches media (§27–§32). Arbitrary remote image URLs are never fetched or embedded — that is an SSRF vector, a tracking vector, and a visitor IP leak.
- Links are validated per §104:
http/httpsonly;javascript:,data:,file:rejected. - Markdown renders from a safe subset with raw HTML passthrough disabled.
- Control characters and escape sequences are stripped from every user-supplied string. Otherwise a Page becomes a way to scribble on a visitor's terminal — the terminal-native equivalent of XSS. Themes and nameplates must not be able to break layout or write outside the block being rendered.
- Guestbooks are hostile input. Entries are plain text, blocked actors cannot sign, creation is rate-limited (§102) and reportable (§64), and both the page owner and moderators can remove entries. Guestbooks have decades of spam precedent; they ship with these controls or they don't ship.
Advanced web mode (later, web-only)
A future capability may allow user-authored HTML/CSS/assets. If built, it must:
- be served from an isolated origin (
*.patches.pageor a dedicated usercontent domain), never same-origin with the application, - carry a strict
Content-Security-Policyincludingscript-src 'none', - have no access to any Patches session, token, or cookie,
- count against the actor's storage capability.
The TUI is unaffected — it renders the portable document only. Writing these constraints down now is deliberate: it prevents the mode from being retrofitted onto the app origin later by someone in a hurry.
8. Nameplates (adjacent, not the same thing)
A nameplate (§173) is how an actor appears everywhere their name appears — timeline, thread, mention, follower list: name color/gradient, glyph, badges, avatar frame, status line, profile border. Stored as a bounded (≤ 2 KiB) validated document on the actor.
- Degrades by terminal capability; a nameplate is never required to read a post.
- Readability wins: contrast floor, no zero-width or bidirectional trickery, no control characters, bounded width, and a plain mode that strips all decoration.
- Badges are server-attested only (node admin, moderator, supporter, verified domain). A user cannot set badge text — free-text badges are handle spoofing with extra steps.
- A nameplate must never impersonate another actor's handle or a system message.
- Validated at write time against the capabilities that node grants that user (§174). On import from another node, unsupported decoration is preserved but not rendered, so migration never silently destroys someone's identity.
9. Federation
The page manifest is advertised as a Patches extension property on the actor document. A plain Fediverse server that doesn't understand it receives an ordinary actor and loses nothing. Pages are part of the export archive (§164), so moving nodes moves your page.