Manifest reference
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "JournalEntry": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } }, "x-homespun-manifest": { "app": { "name": "Journal", "description": "A private, per-person journal", "icon": "📓" }, "collections": { "entries": { "schema": { "$ref": "#/$defs/JournalEntry" }, "write": ["agent", "owner", "member"], "delete": ["author"], "read": ["author"], "appendOnly": false } } }}The manifest is a plain JSON Schema 2020-12 document with one namespaced extension key,
x-homespun-manifest. It’s the whole consent surface: what it declares is exactly what the
relay enforces at runtime. Unknown keys are a hard, deploy-time rejection, never silently
ignored, and there are no implicit grants anywhere, owner and agent are never auto-added to
a permission list just because of who they are.
Fields
Section titled “Fields”app:name(required, up to 80 characters),description(up to 280 characters),icon(an emoji). Shown to a person as the app’s display identity.collections: a map of collection name to{ schema?, write, delete, read?, appendOnly? }. An app can declare zero collections (a purely presentational page).schema: a{ "$ref": "#/$defs/<Name>" }into the document’s own$defs. Optional; omit it for a schemaless collection. Cross-document refs aren’t supported.write: required, non-empty. Roles that may create or update rows.delete: required, non-empty. Roles that may delete rows.read: optional. Roles that may read rows; see “How reads are enforced” below.appendOnly: optional boolean, defaultfalse. Whentrue, update and delete are rejected outright for every role, includingagentandowner, checked before any write/delete role list. Use it for a journal or event-shaped collection whose rows are created but never changed or removed once written.
externalHosts: an array ofhttps://origins (DNS name, at most one leading*.wildcard, no path, no IP literal) the page’sfetch/XMLHttpRequestmay reach. This is the only way a deployed app talks to anything besides its own data API.cdn: boolean, defaultfalse. Allows<script src>/<link rel=stylesheet>from anyhttps:origin. It does not widen what the page can fetch, that’sexternalHostsalone, kept separate on purpose: a page can load a charting library from a CDN without also being able to send data anywhere theexternalHostslist doesn’t allow.capabilities: optional array from the strict allowlist"camera","microphone","fullscreen","autoplay". Each granted name flips itsPermissions-Policydirective from denied toselfon the served app document; anything you don’t list stays denied. Unknown values are a hard, deploy-time rejection. Example:"capabilities": ["camera"]lets the page callgetUserMedia({ video: true }), while microphone stays blocked.embeds: optional array ofhttps://origins (same grammar asexternalHosts: DNS name, at most one leading*.wildcard, no path, no IP literal) the page may embed in an<iframe>, emitted as aframe-srcgrant. It is display-only: it does not widenconnect-srcorform-action, so framing a site never lets the page send data to it. For a YouTube player, prefer the privacy-preserving nocookie host:"embeds": ["https://www.youtube-nocookie.com"].
Five roles, used across write, delete, and read:
| Role | Who |
|---|---|
agent | The agent that deployed and owns the app’s identity. |
owner | The human who owns the app. |
member | A human the owner has invited as a collaborator. |
anyone | Any visitor, subject to the app’s own visibility setting. |
author | Row-scoped: whoever (agent or human) created that specific row. |
write never accepts author: a create has no pre-existing row to check authorship against,
and write covers both create and update. delete and read accept all five. There are no
implicit grants: an audit-log collection only the agent may append to is a perfectly ordinary,
expressible manifest, owner isn’t silently added to its write list just because it’s the
owner.
How reads are enforced
Section titled “How reads are enforced”Reads are gated first at the app’s own visibility setting (see
Visibility, explained), then, per collection, by the read
list above. The decision, in order:
- Malformed
permissions(missing, not an object, or a non-arrayread) denies everything. This fails closed on purpose: a bug that corrupts a collection’s permissions never silently falls back to “everyone can read.” readgenuinely omitted on an otherwise-valid collection keeps back-compat behavior: everyone who already passes the app’s visibility check can read everything in it.- A literal role match (checking every role in
readexceptauthor) grants full read access. This always wins overauthor:read: ["member", "author"]is full access for a member, not author-scoped, and an owner only matches aread: ["member"]list becauseownercarriesmemberin its own role set, never because it’s the owner. There’s no special-casing of owner or agent here either. readnamesauthor, and the caller has a stable identity (a human or agent id, not an anonymous visitor), grants author-scoped access: they can read only the rows they created.- Otherwise (an explicit
read: [], orauthorlisted but the caller is anonymous) denies everything.
Anti-enumeration on a single row. If a whole collection denies a caller (step 5, the none
outcome), a request for any row in it returns a 403. But if the caller has author-scoped
access (step 4) and asks for a specific row by key that exists but was authored by someone
else, the response is the same 404 row not found a truly missing key would return, never a
403. This is deliberate: a 403 tied to one specific key would confirm that key exists;
the 404 is indistinguishable from “no such row,” so nobody can learn a key exists inside a
collection they can only read their own rows in.
Worked examples
Section titled “Worked examples”Suggestion box: anyone can drop in a suggestion, only the owner reads them:
"suggestions": { "write": ["anyone"], "delete": ["owner"], "read": ["owner"] }Journal, fully private, even from the owner: read: ["author"] alone means everyone,
owner included, only ever sees their own entries:
"entries": { "write": ["agent", "owner", "member"], "delete": ["author"], "read": ["author"] }Journal, owner can see everything: add "owner" to the list and the owner now matches
step 3 above (full access), while everyone else still only sees their own rows:
"entries": { "write": ["agent", "owner", "member"], "delete": ["author"], "read": ["owner", "author"] }Secret santa: the manifest’s role vocabulary has no “assigned recipient” concept, only
author. read: ["owner", "author"] on an assignments collection lets the organizer (owner)
see every pairing and each participant see only the row they authored, but “hide who has whom
from the assignee themselves” is an app-level convention your own page code has to implement
(e.g. writing the assignee’s name into a field the app’s own script simply never renders back to
them), not something the manifest expresses on its own:
"assignments": { "write": ["agent", "owner"], "delete": ["owner"], "read": ["owner", "author"] }Limits you’ll hit while authoring a manifest
Section titled “Limits you’ll hit while authoring a manifest”| Env var | Default | Meaning |
|---|---|---|
MAX_MANIFEST_BYTES | 65,536 | Whole manifest document |
MAX_SCHEMA_BYTES | 65,536 | One row-schema $defs entry |
MAX_SCHEMA_DEPTH | 32 | Nesting depth, manifest and row schemas |
MAX_COLLECTIONS_PER_APP | 32 | Collections per manifest |
MAX_EXTERNAL_HOSTS | 10 | externalHosts entries |
MAX_RECORDS_PER_COLLECTION | 50,000 | Rows per (app, collection); 0 means unlimited |
MAX_RECORD_DATA_BYTES | 65,536 | One row's serialized data |
MAX_RECORDS_PER_PAGE | 200 | Hard pagination ceiling |
MAX_ROWS_PER_APP | 100,000 | Whole-app row count |
MAX_STORAGE_BYTES_PER_APP | 1,073,741,824 | Whole-app storage |
MAX_BLOB_BYTES | 5,000,000 | Per-attachment upload |
MAX_BLOBS_PER_APP_BYTES | 100,000,000 | Per-app attachment aggregate |
MAX_BLOBS_PER_AGENT_BYTES | 500,000,000 | Per-agent attachment aggregate |
BLOB_PRESIGN_TTL_SECONDS | 600 | Presigned upload URL lifetime |
BLOB_TOKEN_TTL_APP_SECONDS | 2,592,000 | /b/<token> app-scope capability URL |
BLOB_TOKEN_TTL_AGENT_SECONDS | 86,400 | /b/<token> agent-scope capability URL |
RATE_LIMIT | 120 | General per-IP limiter on /v1/* and /s/*; 0 disables |
RATE_LIMIT_WINDOW_SECONDS | 60 | Window for RATE_LIMIT |
ANON_RECORD_WRITE_RATE_LIMIT | 20 | Anonymous public-app record writes |
ANON_RECORD_WRITE_RATE_WINDOW_SECONDS | 60 | Window for ANON_RECORD_WRITE_RATE_LIMIT |
REGISTER_RATE_LIMIT | 5 | POST /v1/register per IP |
REGISTER_RATE_WINDOW_SECONDS | 3,600 | Window for REGISTER_RATE_LIMIT |
MAGIC_LINK_RATE_LIMIT | 3 | Keyed on (IP, email) |
MAGIC_LINK_RATE_WINDOW_SECONDS | 900 | Window for MAGIC_LINK_RATE_LIMIT |
MAX_APPS_PER_AGENT | 50 | Open apps per agent; 0 means unlimited |
MAX_PARTICIPANTS_PER_APP | 32 | Members per app |
MAX_WS_CONNECTIONS_PER_APP | 16 | Concurrent WebSocket connections per app |
DEFAULT_TTL_SECONDS | 15,768,000 | Default app lifetime |
MAX_TTL_SECONDS | 31,536,000 | Maximum app lifetime |
APP_GRANT_TTL_SECONDS | 60 | One-time main-to-usercontent handoff credential |
APP_SESSION_TTL_SECONDS | 2,592,000 | App session token |
INVITE_TOKEN_TTL_SECONDS | 604,800 | Member-invite link |
FEED_PAGE_MAX | 500 | Change-feed catch-up pagination ceiling |
ROW_PAGE_MAX | 1,000 | Full row-snapshot pagination ceiling |
Generated at build time from packages/relay/src/config.ts. All are overridable
by an operator via the matching environment variable; these are the shipped defaults.
Full limits reference, covering rate limits and TTLs too, is coming in a later PR; the table
above is generated the same way that page’s will be, directly from
packages/relay/src/config.ts, so it can’t drift from what the relay actually enforces.