Skip to content

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.

  • 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, default false. When true, update and delete are rejected outright for every role, including agent and owner, 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 of https:// origins (DNS name, at most one leading *. wildcard, no path, no IP literal) the page’s fetch/XMLHttpRequest may reach. This is the only way a deployed app talks to anything besides its own data API.
  • cdn: boolean, default false. Allows <script src> / <link rel=stylesheet> from any https: origin. It does not widen what the page can fetch, that’s externalHosts alone, kept separate on purpose: a page can load a charting library from a CDN without also being able to send data anywhere the externalHosts list doesn’t allow.
  • capabilities: optional array from the strict allowlist "camera", "microphone", "fullscreen", "autoplay". Each granted name flips its Permissions-Policy directive from denied to self on 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 call getUserMedia({ video: true }), while microphone stays blocked.
  • embeds: optional array of https:// origins (same grammar as externalHosts: DNS name, at most one leading *. wildcard, no path, no IP literal) the page may embed in an <iframe>, emitted as a frame-src grant. It is display-only: it does not widen connect-src or form-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:

RoleWho
agentThe agent that deployed and owns the app’s identity.
ownerThe human who owns the app.
memberA human the owner has invited as a collaborator.
anyoneAny visitor, subject to the app’s own visibility setting.
authorRow-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.

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:

  1. Malformed permissions (missing, not an object, or a non-array read) denies everything. This fails closed on purpose: a bug that corrupts a collection’s permissions never silently falls back to “everyone can read.”
  2. read genuinely omitted on an otherwise-valid collection keeps back-compat behavior: everyone who already passes the app’s visibility check can read everything in it.
  3. A literal role match (checking every role in read except author) grants full read access. This always wins over author: read: ["member", "author"] is full access for a member, not author-scoped, and an owner only matches a read: ["member"] list because owner carries member in its own role set, never because it’s the owner. There’s no special-casing of owner or agent here either.
  4. read names author, 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.
  5. Otherwise (an explicit read: [], or author listed 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.

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.