# 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”](#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, 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"]`.

## Roles

[Section titled “Roles”](#roles)

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”](#how-reads-are-enforced)

Reads are gated first at the app’s own visibility setting (see [Visibility, explained](/people/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.

## Worked examples

[Section titled “Worked examples”](#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”](#limits-youll-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.
