···8383GET https://<your-worker>.workers.dev/xrpc/com.example.event.listRecords?startsAtMin=2026-01-01&limit=10
8484```
85858686-returns every `community.lexicon.calendar.event` record published anywhere on atproto that matches, as JSON. that's it — no PDS setup, no lexicon publishing, no relay configuration. everything scales from there: add filters, add full-text search, add more collections, turn on [spaces](./docs/04-spaces.md) for private records, mount the handler in sveltekit instead, swap the adapter for postgres.
8686+returns every `community.lexicon.calendar.event` record published anywhere on atproto that matches, as JSON. that's it — no PDS setup, no lexicon publishing, no relay configuration. everything scales from there: add filters, add full-text search, add more collections, turn on [spaces](./docs/05-spaces.md) for private records, mount the handler in sveltekit instead, swap the adapter for postgres.
87878888**not using workers?** same library, different `db`. see [adapters](./docs/01-indexing.md#adapters) for node:sqlite and postgres.
8989···9292- [Indexing](./docs/01-indexing.md) — the core: collections, ingestion, adapters
9393- [Querying](./docs/02-querying.md) — filters, sorts, hydration, search, pagination
9494- [Lexicons](./docs/03-lexicons.md) — `contrail-lex` CLI, codegen, publishing
9595-- [Spaces](./docs/04-spaces.md) — permissioned records stored by the appview
9696-- [Communities](./docs/05-communities.md) — group-controlled atproto DIDs
9797-- [Sync](./docs/06-sync.md) — reactive client-side store over `watchRecords`
9898-- [Examples](./docs/07-examples.md) — reference deployments in the repo
9595+- [Auth](./docs/04-auth.md) — service-auth JWTs, invite tokens, watch tickets, OAuth permission sets
9696+- [Spaces](./docs/05-spaces.md) — permissioned records stored by the appview
9797+- [Communities](./docs/06-communities.md) — group-controlled atproto DIDs
9898+- [Sync](./docs/07-sync.md) — reactive client-side store over `watchRecords`
9999+- [Examples](./docs/08-examples.md) — reference deployments in the repo
99100- Frameworks: [SvelteKit + Cloudflare](./docs/frameworks/sveltekit-cloudflare.md)
100101101102## Packages
+11
.changeset/spaces-owner-no-delete-bypass.md
···11+---
22+"@atmo-dev/contrail": patch
33+---
44+55+tighten spaces ACL: owners no longer bypass the "own-record" rule on delete. everyone in the member list — owner included — can only delete records they authored.
66+77+before: owner calling `space.deleteRecord` on someone else's record returned `200 { ok: true }` (ACL passed, but the adapter's SQL was already scoped to `did = caller`, so no rows were actually deleted — the response lied).
88+99+after: that same call returns `403 { error: "Forbidden", reason: "not-own-record" }`. honest response; no behavior change at the storage layer.
1010+1111+to wipe someone else's records in a space you own, delete the space itself.
+3-3
docs/01-indexing.md
···198198| `jetstreams` | Bluesky | Jetstream URLs |
199199| `relays` | Bluesky | Relay URLs for discovery |
200200| `notify` | off | `true` opens `notifyOfUpdate`; a string requires `Bearer` |
201201-| `spaces` | — | See [Spaces](./04-spaces.md) |
202202-| `community` | — | See [Communities](./05-communities.md) |
203203-| `realtime` | — | See [Sync](./06-sync.md) |
201201+| `spaces` | — | See [Spaces](./05-spaces.md) |
202202+| `community` | — | See [Communities](./06-communities.md) |
203203+| `realtime` | — | See [Sync](./07-sync.md) |
+139
docs/04-auth.md
···11+# Auth
22+33+Contrail has four auth mechanisms. Which one applies depends on who's calling and what they're asking for.
44+55+| Mechanism | Used by | For |
66+|---|---|---|
77+| Anonymous | anyone | public reads |
88+| Service-auth JWT | third-party apps acting on behalf of a user | anything permissioned (spaces, communities) |
99+| In-process server client | your own server code | loaders / actions that skip HTTP entirely |
1010+| Invite token | anonymous bearers | read-only access to a specific space |
1111+| Watch ticket | browsers | realtime subscriptions (`watchRecords`) |
1212+1313+## Service-auth JWTs
1414+1515+The standard atproto mechanism. When a third-party app wants to call your contrail service as a user, it:
1616+1717+1. Asks the user's PDS to mint a service-auth JWT with `com.atproto.server.getServiceAuth` — the token's claims include `iss` (user's DID), `aud` (your service DID), `lxm` (the specific method NSID), and a short expiry.
1818+2. Sends the request to your service with `Authorization: Bearer <jwt>` and `Atproto-Proxy: <did>#<service-id>` so the PDS knows where to route.
1919+2020+Contrail verifies every request against the public key in the issuer's DID doc (`@atcute/xrpc-server` does the heavy lifting). It checks:
2121+2222+- Signature valid
2323+- `aud` matches the `serviceDid` you configured
2424+- `lxm` covers the method being called
2525+- Token hasn't expired
2626+2727+On pass, your handler sees a populated `serviceAuth = { issuer, audience, lxm, clientId }` context and can proceed. On fail, 401 or 403 with a structured reason.
2828+2929+### The `serviceDid` gotcha
3030+3131+Use the **plain DID** (no `#fragment`) when configuring contrail:
3232+3333+```ts
3434+spaces: { serviceDid: "did:web:example.com" } // right
3535+spaces: { serviceDid: "did:web:example.com#com_example_space" } // wrong
3636+```
3737+3838+Many PDS implementations reject `aud` values containing `#fragment` in `com.atproto.server.getServiceAuth`, and contrail does strict string equality on `aud`. The fragment form belongs only in your DID doc's `service` entry, where PDSes use it to resolve the service endpoint URL for `Atproto-Proxy` routing — that's separate from JWT audience validation.
3939+4040+## In-process server client
4141+4242+When your own server code wants to call contrail, the service-auth dance is pointless — it's your code talking to your code. `createServerClient` skips it:
4343+4444+```ts
4545+import { createServerClient } from "@atmo-dev/contrail/server";
4646+4747+const client = createServerClient(async (req) => handle(req, env.DB), userDid);
4848+4949+// Calls bypass fetch entirely; acts as `userDid` for ACL purposes.
5050+const res = await client.get("com.example.event.listRecords", { params: {...} });
5151+```
5252+5353+Pass `did` to act as that user; omit it for anonymous calls against public endpoints. This is a trust boundary — anything that actually crosses a network needs a real service-auth JWT, not this shortcut.
5454+5555+See [SvelteKit + Cloudflare](./frameworks/sveltekit-cloudflare.md) for the typical loader pattern.
5656+5757+## Invite tokens
5858+5959+First-class auth for spaces. When a space owner creates an invite:
6060+6161+```
6262+com.example.space.invite.create { spaceUri, ttl?, maxUses? }
6363+ → { token: "...plaintext..." } // returned once, never again
6464+```
6565+6666+The plaintext token is handed to the user out-of-band (link, QR, email). Contrail stores only a SHA-256 hash. Redemption is one atomic UPDATE: `used_count++ WHERE hash = ? AND !revoked AND !expired AND !exhausted`.
6767+6868+Three invite kinds, depending on what the token does:
6969+7070+- **`join`** — redeemed via `com.example.space.invite.redeem` with a service-auth JWT. Adds the caller's DID to the member list. Members have full read + write inside the space; there's no per-member permission axis beyond "is a member."
7171+- **`read`** — bearer-only. The token itself grants read access when passed as `?inviteToken=<plaintext>`, no DID, no redemption. Good for sharing a read-only link that doesn't add anyone to the member list.
7272+- **`read-join`** — both. Works anonymously as a read token; can also be redeemed with a JWT to promote the caller to member.
7373+7474+Tokens can be revoked (`invite.revoke`), expire automatically (`ttl`), and be exhausted (`maxUses`).
7575+7676+## Watch tickets
7777+7878+Realtime subscriptions (`watchRecords`) can't use regular service-auth JWTs for two reasons: the WebSocket upgrade can't carry arbitrary headers, and an open socket would outlive a 60s JWT TTL. So contrail uses separate short-lived tickets.
7979+8080+Server-side minting:
8181+8282+```
8383+com.example.realtime.ticket { spaceUri }
8484+ → { ticket: "...", expiresAt: 1234567890 }
8585+```
8686+8787+The ticket is signed by `realtime.ticketSecret` (a 32-byte random, configured once), bound to `(did, spaceUri, querySpec)`. Client uses it on the SSE or WS handshake via `?ticket=...`.
8888+8989+In the `@atmo-dev/contrail-sync` client:
9090+9191+```ts
9292+createWatchStore({
9393+ url: "/xrpc/com.example.message.watchRecords?spaceUri=at://...",
9494+ mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()),
9595+});
9696+```
9797+9898+Each reconnect mints a fresh ticket, so expiry doesn't matter for long-lived subscriptions. See [Sync](./07-sync.md) for the full flow.
9999+100100+## OAuth permission sets
101101+102102+When a user grants a third-party app permission to act as them in your contrail service, the consent screen is driven by a **permission set** — a lexicon that bundles every XRPC method you expose. Contrail auto-generates `{namespace}.permissionSet` for you; `contrail-lex` publishes it alongside the other lexicons.
103103+104104+A third-party app requests scope by referencing your permission set's NSID in its OAuth metadata:
105105+106106+```jsonc
107107+"scope": "include:com.example.permissionSet"
108108+```
109109+110110+The user's PDS fetches that lexicon (via DNS-backed NSID resolution), shows the user what methods are being requested, and mints scoped service-auth JWTs on confirmation.
111111+112112+### DNS requirements
113113+114114+Permission sets live under *your* namespace (`com.example.permissionSet`), so PDSes resolve the NSID via DNS. Resolution does **not** walk up subdomains — every authority in your NSID tree needs its own `TXT` record at `_lexicon.<reversed-domain-path>`.
115115+116116+`contrail-lex publish` prints the exact records you need (also works as a `--dry-run`). Without them, permission sets can't be fetched, and users get errors instead of a consent screen.
117117+118118+## Anonymous / public
119119+120120+No auth needed for:
121121+122122+- `listRecords` / `getRecord` without `spaceUri` — returns public records
123123+- `getProfile`, `getCursor`, `getOverview`
124124+- `notifyOfUpdate` (unless you set `notify: "some-bearer-token"` in config; then it needs `Authorization: Bearer <that>`)
125125+126126+Public requests skip all verification middleware — no JWT parsing, no DID-doc fetch. Fast path.
127127+128128+## How the pieces fit
129129+130130+A typical flow for a third-party app acting as a user in a space:
131131+132132+1. App registers OAuth client pointing at your permission set NSID.
133133+2. User grants consent — PDS fetches your permission set lexicon via DNS, shows the user the methods, records the scope.
134134+3. App calls `com.atproto.server.getServiceAuth` on the user's PDS: `{ aud: "did:web:example.com", lxm: "com.example.space.putRecord", exp: <60s> }`. PDS signs, returns JWT.
135135+4. App sends `PUT /xrpc/com.example.space.putRecord` with `Authorization: Bearer <jwt>` and `Atproto-Proxy: did:web:example.com#com_example_space`.
136136+5. User's PDS reads the `Atproto-Proxy` header, resolves the service endpoint from your DID doc, forwards the request.
137137+6. Contrail verifies the JWT (signature, `aud`, `lxm`, expiry), runs ACL check (is this DID a member of that space?), dispatches the write.
138138+139139+For your own loaders/actions, steps 3–6 collapse into a single `createServerClient({did}).post(...)` call. For a browser subscribing to a feed, steps 3–5 are replaced by a ticket mint from your server. Same auth model, different surface.
-70
docs/04-spaces.md
···11-# Spaces
22-33-Auth-gated store for records that can't live on public PDSes — private events, invite-only groups, members-only chat. Opt-in; zero cost if you don't enable it.
44-55-## Mental model
66-77-> A **space** is a bag of records with one lock. The **member list** says who has the key.
88-99-- One owner (DID), one type (NSID), one key. Identified by `at://<owner>/<type>/<key>`.
1010-- Members have `read` or `write`. Owner is implicit `write`.
1111-- Optional **app policy** gates which OAuth clients can act in the space.
1212-1313-Every permission boundary is its own space. No nested ACLs. Richer roles = more spaces or app-layer checks.
1414-1515-## Enable
1616-1717-```ts
1818-import type { ContrailConfig } from "@atmo-dev/contrail";
1919-2020-const config: ContrailConfig = {
2121- namespace: "com.example",
2222- collections: { /* ... */ },
2323- spaces: {
2424- type: "com.example.event.space",
2525- serviceDid: "did:web:example.com",
2626- },
2727-};
2828-```
2929-3030-Each collection gets a parallel `spaces_records_<short>` table. Opt out per-collection:
3131-3232-```ts
3333-public_only: { collection: "com.example.public", allowInSpaces: false }
3434-```
3535-3636-## Auth
3737-3838-atproto service-auth JWTs via `@atcute/xrpc-server`. Middleware validates signature, `aud`, and `lxm` before the handler runs. Apps acting in a space mint a JWT against the user's PDS with `Atproto-Proxy: did:web:example.com#com_example_space`, forward to your service, it verifies and executes.
3939-4040-**Note:** Use the plain DID (no `#fragment`) as `serviceDid` — the fragment form only belongs in your DID doc's service entry.
4141-4242-## Unified `listRecords`
4343-4444-| Call | Returns |
4545-|---|---|
4646-| no auth, no `spaceUri` | public only |
4747-| `?spaceUri=…` + JWT | one space (ACL-gated) |
4848-| JWT, no `spaceUri` | public **unioned** with every space the caller is a member of |
4949-5050-Filters, sorts, hydration, and references work across all three. Records from a space carry a `space: <spaceUri>` field.
5151-5252-## Invites
5353-5454-Stored as hashed tokens. Redemption is a single atomic UPDATE that checks `!revoked && !expired && !exhausted`. Generate, hand out the plaintext once, verify later.
5555-5656-## XRPCs
5757-5858-- `com.example.space.create | get | list | delete`
5959-- `com.example.space.putRecord | deleteRecord | listRecords | getRecord`
6060-- `com.example.space.invite.create | redeem | revoke | list`
6161-- `com.example.space.listMembers | removeMember`
6262-6363-## What's not here
6464-6565-- No E2EE (data is operator-readable).
6666-- No FTS on `?spaceUri=…` yet.
6767-- No per-space sharding — one DB, one operator.
6868-- Not a long-term replacement for real atproto permissioned repos.
6969-7070-The design follows Daniel Holmgren's [permissioned data rough spec](https://dholms.leaflet.pub/3mhj6bcqats2o). The goal is that when real atproto permissioned repos ship, migration is mostly data movement — the API your app speaks doesn't change.
-54
docs/05-communities.md
···11-# Communities
22-33-Group-controlled atproto DIDs. A community is a DID whose signing/rotation keys are held by the appview on behalf of multiple members, with tiered access levels. Built on top of [spaces](./04-spaces.md).
44-55-## When to use this
66-77-When you want atproto records published under a *shared* identity — a team, a project, a channel — not a single user. Think: a group's published calendar events, a community's published posts.
88-99-## Two modes
1010-1111-- **Minted** — contrail creates a fresh `did:plc` for the community, holds the signing + rotation keys, publishes from it.
1212-- **Adopted** — contrail takes over an existing DID whose rotation keys were handed over by the owner.
1313-1414-Either way, the result is the same: a DID that multiple members can act through, gated by access levels.
1515-1616-## Access levels
1717-1818-Each member has a level (ranked). Levels map to write permissions. Owners can grant/revoke levels. Two reserved levels exist: `owner` and `member`. Your deployment defines the rest:
1919-2020-```ts
2121-community: {
2222- masterKey: ENV.COMMUNITY_MASTER_KEY, // 32-byte encryption key for stored credentials
2323- serviceDid: "did:web:example.com",
2424- levels: ["admin", "moderator"], // ranked, highest-first
2525-}
2626-```
2727-2828-Stored credentials (app passwords for adopted communities, signing keys for minted) are envelope-encrypted with `masterKey`. Never ship the placeholder.
2929-3030-## How it composes with spaces
3131-3232-A community *owns* spaces. Members of the community get access to community-owned spaces based on their level. Grant access per-space per-level:
3333-3434-```
3535-community.space.grant { spaceUri, level: "admin", perms: "write" }
3636-```
3737-3838-The spaces layer stays ignorant of access levels — it just sees "this DID is a member with these perms." The community layer projects member × level → space perms.
3939-4040-## XRPCs
4141-4242-- `com.example.community.mint | adopt | list | delete`
4343-- `com.example.community.invite.create | redeem | revoke | list`
4444-- `com.example.community.setAccessLevel | revoke | listMembers`
4545-- `com.example.community.space.create | grant | revoke | ...` — community-owned spaces
4646-- `com.example.community.putRecord | deleteRecord` — publish records as the community DID
4747-4848-## What's not here
4949-5050-- No per-record per-level ACLs. Model as spaces.
5151-- No auto-rotation on key compromise yet.
5252-- Adoption is irreversible without manual key surrender back to the owner.
5353-5454-The design follows zicklag's [Arbiter design sketch](https://zicklag.leaflet.pub/3mjrvb5pul224) for group management on atproto. The post is an early design note; our implementation will track it as the spec firms up.
+74
docs/05-spaces.md
···11+# Spaces
22+33+Auth-gated store for records that can't live on public PDSes — private events, invite-only groups, members-only chat. Opt-in; zero cost if you don't enable it.
44+55+## Mental model
66+77+> A **space** is a bag of records with one lock. The **member list** says who has the key.
88+99+- One owner (DID), one type (NSID), one key. Identified by `at://<owner>/<type>/<key>`.
1010+- Every member (including owner) has read + write inside the space. Delete is scoped to your own records — no one can remove records they didn't author, owner included. To wipe everything, delete the space.
1111+- Optional **app policy** gates which OAuth clients can act in the space.
1212+1313+Every permission boundary is its own space. No nested ACLs. Richer roles = more spaces or app-layer checks.
1414+1515+## Enable
1616+1717+```ts
1818+import type { ContrailConfig } from "@atmo-dev/contrail";
1919+2020+const config: ContrailConfig = {
2121+ namespace: "com.example",
2222+ collections: { /* ... */ },
2323+ spaces: {
2424+ type: "com.example.event.space",
2525+ serviceDid: "did:web:example.com",
2626+ },
2727+};
2828+```
2929+3030+Each collection gets a parallel `spaces_records_<short>` table. Opt out per-collection:
3131+3232+```ts
3333+public_only: { collection: "com.example.public", allowInSpaces: false }
3434+```
3535+3636+## Auth
3737+3838+Spaces use the standard contrail auth surface — service-auth JWTs for third-party apps, in-process server clients for your own loaders, invite tokens for anonymous read-grant links. See [Auth](./04-auth.md) for the full picture.
3939+4040+Space-specific wiring:
4141+4242+- `serviceDid` in the config is the `aud` contrail expects on incoming JWTs. Plain DID, no `#fragment`.
4343+- Apps acting in a space send `Atproto-Proxy: <serviceDid>#<service-id-from-your-did-doc>` so the user's PDS routes correctly.
4444+- Invite redemption via service-auth JWT grants membership; via `?inviteToken=...` query param grants read-only bearer access to that space.
4545+4646+## Unified `listRecords`
4747+4848+| Call | Returns |
4949+|---|---|
5050+| no auth, no `spaceUri` | public only |
5151+| `?spaceUri=…` + JWT | one space (ACL-gated) |
5252+| JWT, no `spaceUri` | public **unioned** with every space the caller is a member of |
5353+5454+Filters, sorts, hydration, and references work across all three. Records from a space carry a `space: <spaceUri>` field.
5555+5656+## Invites
5757+5858+First-class primitive — see [Auth § Invite tokens](./04-auth.md#invite-tokens) for the mechanism. Space-specific: create via `com.example.space.invite.create`, redeem via `.redeem` (membership grant) or `?inviteToken=...` query param (read-only bearer grant).
5959+6060+## XRPCs
6161+6262+- `com.example.space.create | get | list | delete`
6363+- `com.example.space.putRecord | deleteRecord | listRecords | getRecord`
6464+- `com.example.space.invite.create | redeem | revoke | list`
6565+- `com.example.space.listMembers | removeMember`
6666+6767+## What's not here
6868+6969+- No E2EE (data is operator-readable).
7070+- No FTS on `?spaceUri=…` yet.
7171+- No per-space sharding — one DB, one operator.
7272+- Not a long-term replacement for real atproto permissioned repos.
7373+7474+The design follows Daniel Holmgren's [permissioned data rough spec](https://dholms.leaflet.pub/3mhj6bcqats2o). The goal is that when real atproto permissioned repos ship, migration is mostly data movement — the API your app speaks doesn't change.
+54
docs/06-communities.md
···11+# Communities
22+33+Group-controlled atproto DIDs. A community is a DID whose signing/rotation keys are held by the appview on behalf of multiple members, with tiered access levels. Built on top of [spaces](./05-spaces.md).
44+55+## When to use this
66+77+When you want atproto records published under a *shared* identity — a team, a project, a channel — not a single user. Think: a group's published calendar events, a community's published posts.
88+99+## Two modes
1010+1111+- **Minted** — contrail creates a fresh `did:plc` for the community, holds the signing + rotation keys, publishes from it.
1212+- **Adopted** — contrail takes over an existing DID whose rotation keys were handed over by the owner.
1313+1414+Either way, the result is the same: a DID that multiple members can act through, gated by access levels.
1515+1616+## Access levels
1717+1818+Each member has a level (ranked). Levels map to write permissions. Owners can grant/revoke levels. Two reserved levels exist: `owner` and `member`. Your deployment defines the rest:
1919+2020+```ts
2121+community: {
2222+ masterKey: ENV.COMMUNITY_MASTER_KEY, // 32-byte encryption key for stored credentials
2323+ serviceDid: "did:web:example.com",
2424+ levels: ["admin", "moderator"], // ranked, highest-first
2525+}
2626+```
2727+2828+Stored credentials (app passwords for adopted communities, signing keys for minted) are envelope-encrypted with `masterKey`. Never ship the placeholder.
2929+3030+## How it composes with spaces
3131+3232+A community *owns* spaces. Members of the community get access to community-owned spaces based on their access level. Grant access per-space per-level:
3333+3434+```
3535+community.space.grant { spaceUri, subject: { did: "did:plc:..." }, accessLevel: "admin" }
3636+```
3737+3838+The spaces layer stays ignorant of access levels — it just sees "this DID is a member." The community layer projects member × level → membership in specific spaces. Once a DID is a member of a space (through a community grant or otherwise), they have full read + write inside it.
3939+4040+## XRPCs
4141+4242+- `com.example.community.mint | adopt | list | delete`
4343+- `com.example.community.invite.create | redeem | revoke | list`
4444+- `com.example.community.setAccessLevel | revoke | listMembers`
4545+- `com.example.community.space.create | grant | revoke | ...` — community-owned spaces
4646+- `com.example.community.putRecord | deleteRecord` — publish records as the community DID
4747+4848+## What's not here
4949+5050+- No per-record per-level ACLs. Model as spaces.
5151+- No auto-rotation on key compromise yet.
5252+- Adoption is irreversible without manual key surrender back to the owner.
5353+5454+The design follows zicklag's [Arbiter design sketch](https://zicklag.leaflet.pub/3mjrvb5pul224) for group management on atproto. The post is an early design note; our implementation will track it as the spec firms up.
-93
docs/06-sync.md
···11-# Sync
22-33-Client-side reactive store over contrail's `watchRecords` endpoints. Subscribes once, reconciles forever, ships with optimistic updates and an optional IndexedDB cache.
44-55-Lives in its own package:
66-77-```bash
88-pnpm add @atmo-dev/contrail-sync
99-```
1010-1111-## Basic use
1212-1313-```ts
1414-import { createWatchStore } from "@atmo-dev/contrail-sync";
1515-1616-const store = createWatchStore({
1717- url: "/xrpc/com.example.message.watchRecords?roomUri=at://...",
1818- transport: "sse", // or "ws"
1919-});
2020-2121-store.subscribe(({ records, status }) => { /* re-render */ });
2222-store.start();
2323-```
2424-2525-Framework-agnostic — wrap it in Svelte `$state`, React `useSyncExternalStore`, Vue `ref`, whatever.
2626-2727-## Transports
2828-2929-- **SSE** (default) — one HTTP request, simplest. Works everywhere.
3030-- **WS** — a two-step handshake: HTTP GET returns a snapshot + watch-scoped ticket, then you upgrade to WS. On Cloudflare, the WS terminates on a Durable Object that hibernates idle connections. Same event stream either way.
3131-3232-## Authenticated watches
3333-3434-Pass `mintTicket` for any non-public endpoint. One-shot string for SSR-minted tickets, function for fresh tickets per reconnect:
3535-3636-```ts
3737-mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()),
3838-```
3939-4040-Tickets are minted server-side via `com.example.realtime.ticket` (or any app-specific route).
4141-4242-## Optimistic updates
4343-4444-```ts
4545-store.addOptimistic({ rkey, did, record: { text: "hi" } });
4646-// later, on mutation failure:
4747-store.markFailed(rkey, err);
4848-// or explicit rollback:
4949-store.removeOptimistic(rkey);
5050-```
5151-5252-When a real record with the same `rkey` arrives via the stream, the optimistic entry is dropped automatically.
5353-5454-## IndexedDB cache
5555-5656-Instant first paint from last session's records:
5757-5858-```ts
5959-import { createIndexedDBCache } from "@atmo-dev/contrail-sync/cache-idb";
6060-6161-createWatchStore({
6262- url,
6363- cache: createIndexedDBCache(),
6464- cacheMaxRecords: 200,
6565-});
6666-```
6767-6868-Cached records show immediately; the live snapshot reconciles when the connection opens.
6969-7070-## Server-side config
7171-7272-Enable `watchRecords` emission in your Contrail config:
7373-7474-```ts
7575-realtime: {
7676- ticketSecret: ENV.REALTIME_TICKET_SECRET, // 32 bytes
7777- pubsub: new DurableObjectPubSub(env.PUBSUB), // or in-memory for dev
7878-}
7979-```
8080-8181-See [Indexing](./01-indexing.md) for the full config surface.
8282-8383-## Lifecycle
8484-8585-```
8686-idle → connecting → snapshot → live
8787- ↓ (disconnect)
8888- reconnecting → snapshot → live
8989- ↓ (stop)
9090- closed
9191-```
9292-9393-Stale records stay visible across reconnects until the fresh snapshot arrives, at which point anything the server didn't re-send is evicted. Survives offline periods cleanly.
-65
docs/07-examples.md
···11-# Examples
22-33-Every example lives in [`apps/`](https://github.com/flo-bit/contrail/tree/main/apps) and pins contrail as `workspace:*`. Clone the repo, `pnpm install`, and each one runs.
44-55-## `rsvp-atmo` — the reference deployment
66-77-[`apps/rsvp-atmo`](https://github.com/flo-bit/contrail/tree/main/apps/rsvp-atmo)
88-99-Cloudflare Workers + D1. Indexes `community.lexicon.calendar.event` and `rsvp`. Exposes the full spaces + community + realtime surface. Cron-driven Jetstream ingestion every minute.
1010-1111-Use this if you're building on Workers and want a starting point that already has deploy config wired up.
1212-1313-```bash
1414-pnpm --filter rsvp-atmo dev # local wrangler + auto-cron
1515-pnpm --filter rsvp-atmo deploy # requires D1 database created
1616-pnpm --filter rsvp-atmo sync # discover + backfill against D1
1717-```
1818-1919-## `group-chat` — full app showcase
2020-2121-[`apps/group-chat`](https://github.com/flo-bit/contrail/tree/main/apps/group-chat)
2222-2323-SvelteKit + Cloudflare Workers. The one that exercises everything: permissioned spaces for private rooms, community-controlled DIDs for groups, client-side `contrail-sync` for reactive messages, Durable Object-hibernated WebSockets for realtime delivery, OAuth-based login.
2424-2525-This is the canonical "what can contrail do" demo. If you're trying to understand how the pieces fit together end to end, read this app's code before anything else.
2626-2727-```bash
2828-pnpm --filter sveltekit-group-chat dev
2929-```
3030-3131-## `postgres` — Node + PG minimal
3232-3333-[`apps/postgres`](https://github.com/flo-bit/contrail/tree/main/apps/postgres)
3434-3535-The smallest possible Node deployment. Docker Compose for Postgres, three scripts: `sync` (discover + backfill), `ingest` (persistent Jetstream), `serve` (HTTP handler). Skips spaces/communities/realtime.
3636-3737-Use this as a template if you're running on a normal server and don't need Cloudflare's bells.
3838-3939-```bash
4040-cd apps/postgres
4141-docker compose up -d
4242-pnpm sync
4343-pnpm serve
4444-```
4545-4646-## `cloudflare-workers` — minimal Workers
4747-4848-[`apps/cloudflare-workers`](https://github.com/flo-bit/contrail/tree/main/apps/cloudflare-workers)
4949-5050-The simplest working Worker. One collection (events), one HTTP handler, cron-driven ingest. No spaces, no communities. Good for reading top-to-bottom in one sitting to see what contrail does at minimum.
5151-5252-## `sveltekit-cloudflare-workers` — SvelteKit Statusphere
5353-5454-[`apps/sveltekit-cloudflare-workers`](https://github.com/flo-bit/contrail/tree/main/apps/sveltekit-cloudflare-workers)
5555-5656-A Statusphere-style SvelteKit app with OAuth login, contrail-indexed public records, and Cloudflare adapter. No spaces/communities — think "atproto blog or status post UI." Useful as a scaffold for public-only apps.
5757-5858-## Choosing a starting point
5959-6060-| Need | Start from |
6161-|---|---|
6262-| "Just index some records" | `cloudflare-workers` or `postgres` |
6363-| "Index + SvelteKit UI, public only" | `sveltekit-cloudflare-workers` |
6464-| "Private rooms / group chat / full stack" | `group-chat` |
6565-| "Calendar-ish domain, Workers deploy" | `rsvp-atmo` |
+93
docs/07-sync.md
···11+# Sync
22+33+Client-side reactive store over contrail's `watchRecords` endpoints. Subscribes once, reconciles forever, ships with optimistic updates and an optional IndexedDB cache.
44+55+Lives in its own package:
66+77+```bash
88+pnpm add @atmo-dev/contrail-sync
99+```
1010+1111+## Basic use
1212+1313+```ts
1414+import { createWatchStore } from "@atmo-dev/contrail-sync";
1515+1616+const store = createWatchStore({
1717+ url: "/xrpc/com.example.message.watchRecords?roomUri=at://...",
1818+ transport: "sse", // or "ws"
1919+});
2020+2121+store.subscribe(({ records, status }) => { /* re-render */ });
2222+store.start();
2323+```
2424+2525+Framework-agnostic — wrap it in Svelte `$state`, React `useSyncExternalStore`, Vue `ref`, whatever.
2626+2727+## Transports
2828+2929+- **SSE** (default) — one HTTP request, simplest. Works everywhere.
3030+- **WS** — a two-step handshake: HTTP GET returns a snapshot + watch-scoped ticket, then you upgrade to WS. On Cloudflare, the WS terminates on a Durable Object that hibernates idle connections. Same event stream either way.
3131+3232+## Authenticated watches
3333+3434+Pass `mintTicket` for any non-public endpoint. One-shot string for SSR-minted tickets, function for fresh tickets per reconnect:
3535+3636+```ts
3737+mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()),
3838+```
3939+4040+Tickets are minted server-side via `com.example.realtime.ticket` (or any app-specific route).
4141+4242+## Optimistic updates
4343+4444+```ts
4545+store.addOptimistic({ rkey, did, record: { text: "hi" } });
4646+// later, on mutation failure:
4747+store.markFailed(rkey, err);
4848+// or explicit rollback:
4949+store.removeOptimistic(rkey);
5050+```
5151+5252+When a real record with the same `rkey` arrives via the stream, the optimistic entry is dropped automatically.
5353+5454+## IndexedDB cache
5555+5656+Instant first paint from last session's records:
5757+5858+```ts
5959+import { createIndexedDBCache } from "@atmo-dev/contrail-sync/cache-idb";
6060+6161+createWatchStore({
6262+ url,
6363+ cache: createIndexedDBCache(),
6464+ cacheMaxRecords: 200,
6565+});
6666+```
6767+6868+Cached records show immediately; the live snapshot reconciles when the connection opens.
6969+7070+## Server-side config
7171+7272+Enable `watchRecords` emission in your Contrail config:
7373+7474+```ts
7575+realtime: {
7676+ ticketSecret: ENV.REALTIME_TICKET_SECRET, // 32 bytes
7777+ pubsub: new DurableObjectPubSub(env.PUBSUB), // or in-memory for dev
7878+}
7979+```
8080+8181+See [Indexing](./01-indexing.md) for the full config surface.
8282+8383+## Lifecycle
8484+8585+```
8686+idle → connecting → snapshot → live
8787+ ↓ (disconnect)
8888+ reconnecting → snapshot → live
8989+ ↓ (stop)
9090+ closed
9191+```
9292+9393+Stale records stay visible across reconnects until the fresh snapshot arrives, at which point anything the server didn't re-send is evicted. Survives offline periods cleanly.
+65
docs/08-examples.md
···11+# Examples
22+33+Every example lives in [`apps/`](https://github.com/flo-bit/contrail/tree/main/apps) and pins contrail as `workspace:*`. Clone the repo, `pnpm install`, and each one runs.
44+55+## `rsvp-atmo` — the reference deployment
66+77+[`apps/rsvp-atmo`](https://github.com/flo-bit/contrail/tree/main/apps/rsvp-atmo)
88+99+Cloudflare Workers + D1. Indexes `community.lexicon.calendar.event` and `rsvp`. Exposes the full spaces + community + realtime surface. Cron-driven Jetstream ingestion every minute.
1010+1111+Use this if you're building on Workers and want a starting point that already has deploy config wired up.
1212+1313+```bash
1414+pnpm --filter rsvp-atmo dev # local wrangler + auto-cron
1515+pnpm --filter rsvp-atmo deploy # requires D1 database created
1616+pnpm --filter rsvp-atmo sync # discover + backfill against D1
1717+```
1818+1919+## `group-chat` — full app showcase
2020+2121+[`apps/group-chat`](https://github.com/flo-bit/contrail/tree/main/apps/group-chat)
2222+2323+SvelteKit + Cloudflare Workers. The one that exercises everything: permissioned spaces for private rooms, community-controlled DIDs for groups, client-side `contrail-sync` for reactive messages, Durable Object-hibernated WebSockets for realtime delivery, OAuth-based login.
2424+2525+This is the canonical "what can contrail do" demo. If you're trying to understand how the pieces fit together end to end, read this app's code before anything else.
2626+2727+```bash
2828+pnpm --filter sveltekit-group-chat dev
2929+```
3030+3131+## `postgres` — Node + PG minimal
3232+3333+[`apps/postgres`](https://github.com/flo-bit/contrail/tree/main/apps/postgres)
3434+3535+The smallest possible Node deployment. Docker Compose for Postgres, three scripts: `sync` (discover + backfill), `ingest` (persistent Jetstream), `serve` (HTTP handler). Skips spaces/communities/realtime.
3636+3737+Use this as a template if you're running on a normal server and don't need Cloudflare's bells.
3838+3939+```bash
4040+cd apps/postgres
4141+docker compose up -d
4242+pnpm sync
4343+pnpm serve
4444+```
4545+4646+## `cloudflare-workers` — minimal Workers
4747+4848+[`apps/cloudflare-workers`](https://github.com/flo-bit/contrail/tree/main/apps/cloudflare-workers)
4949+5050+The simplest working Worker. One collection (events), one HTTP handler, cron-driven ingest. No spaces, no communities. Good for reading top-to-bottom in one sitting to see what contrail does at minimum.
5151+5252+## `sveltekit-cloudflare-workers` — SvelteKit Statusphere
5353+5454+[`apps/sveltekit-cloudflare-workers`](https://github.com/flo-bit/contrail/tree/main/apps/sveltekit-cloudflare-workers)
5555+5656+A Statusphere-style SvelteKit app with OAuth login, contrail-indexed public records, and Cloudflare adapter. No spaces/communities — think "atproto blog or status post UI." Useful as a scaffold for public-only apps.
5757+5858+## Choosing a starting point
5959+6060+| Need | Start from |
6161+|---|---|
6262+| "Just index some records" | `cloudflare-workers` or `postgres` |
6363+| "Index + SvelteKit UI, public only" | `sveltekit-cloudflare-workers` |
6464+| "Private rooms / group chat / full stack" | `group-chat` |
6565+| "Calendar-ish domain, Workers deploy" | `rsvp-atmo` |
+2-2
apps/cloudflare-workers/README.md
···3939- **add a collection:** append to `collections` in `src/contrail.config.ts`; redeploy; `pnpm contrail backfill --remote` to backfill the new one.
4040- **add full-text search:** `searchable: ["field1", "field2"]`, redeploy, no backfill needed (fts indexes repopulate on ingest).
4141- **add relations / references:** see [indexing docs](../../docs/01-indexing.md).
4242-- **private records:** see [spaces docs](../../docs/04-spaces.md).
4343-- **group-controlled DIDs:** see [communities docs](../../docs/05-communities.md).
4242+- **private records:** see [spaces docs](../../docs/05-spaces.md).
4343+- **group-controlled DIDs:** see [communities docs](../../docs/06-communities.md).
···229229- [Indexing](../01-indexing.md) — config options, adapter choices
230230- [Querying](../02-querying.md) — filters, sorts, hydration, search
231231- [Lexicons](../03-lexicons.md) — generate TS types for your XRPC surface
232232-- [Spaces](../04-spaces.md) / [Communities](../05-communities.md) — private records + group-controlled DIDs, which both slot into the same handler you just mounted
233233-- [Sync](../06-sync.md) — reactive client-side subscriptions (`createWatchStore`) wrapped in Svelte `$state`
232232+- [Auth](../04-auth.md) — service-auth JWTs, invite tokens, watch tickets, OAuth permission sets
233233+- [Spaces](../05-spaces.md) / [Communities](../06-communities.md) — private records + group-controlled DIDs, which both slot into the same handler you just mounted
234234+- [Sync](../07-sync.md) — reactive client-side subscriptions (`createWatchStore`) wrapped in Svelte `$state`
234235235236## Common gotchas
236237
+2-2
packages/contrail/PERMISSIONED_DATA.md
···16161717- Each space has one owner (DID), one type (classifying NSID), one key (string). Identified by an at-uri: `at://<owner>/<type>/<key>`.
1818- A space holds records of *any* NSID. Same role a PDS repo plays, scoped to a shared context.
1919-- Each member has a **perms** value: `"read"` or `"write"`. The space owner is implicit `write`.
1919+- Every member has full read + write inside the space. No per-member permission axis — membership is the whole ACL.
2020- Each space has an optional **app policy**: `{ mode: "allow" | "deny", apps: [client_id, …] }` — gates which OAuth clients can act in the space.
21212222That's it. Every feature (private channels, invite-only threads, shared albums) is one-space-per-permission-boundary with records of whatever NSIDs the app defines. If you need finer-grained access (e.g. "admins" vs "members"), model it as multiple spaces or enforce at the app layer.
···25252626**Each permission boundary = its own space.** No nested ACLs, no per-record permissions, no per-collection policies. Matches the blog's model; keeps the library generic.
27272828-**Read/write as the only permission axis.** The library enforces: read = any member, write = members with `"write"` (owner always). Apps that need richer roles layer them on top of multiple spaces or check `clientId` / authorDid in their handlers.
2828+**Membership is the whole ACL.** Being in the member list = full read + write. No per-member permission granularity. Apps that need richer roles model them as multiple spaces or check `clientId` / `authorDid` in their handlers.
29293030**atproto service-auth JWTs.** Verification uses `@atcute/xrpc-server/auth.ServiceJwtVerifier`: validates signature against issuer's DID doc, checks `aud` matches the configured service DID, checks `lxm` covers the method. Same path third-party apps and our own code go through.
3131
···2222}
23232424describe("spaces acl", () => {
2525- it("owner can read/write/delete without a member row", () => {
2525+ it("owner can read/write without a member row", () => {
2626 const s = mkSpace();
2727- for (const op of ["read", "write", "delete"] as const) {
2727+ for (const op of ["read", "write"] as const) {
2828 const r = checkAccess({
2929 op,
3030 space: s,
···3333 });
3434 expect(r.allow).toBe(true);
3535 }
3636+ });
3737+3838+ it("owner can delete own record without a member row", () => {
3939+ const s = mkSpace();
4040+ const r = checkAccess({
4141+ op: "delete",
4242+ space: s,
4343+ callerDid: "did:plc:alice",
4444+ member: null,
4545+ targetAuthorDid: "did:plc:alice",
4646+ });
4747+ expect(r.allow).toBe(true);
3648 });
37493850 it("non-member cannot read", () => {
···97109 expect((r as any).reason).toBe("not-own-record");
98110 });
99111100100- it("delete any: owner can delete anyone's record", () => {
112112+ it("owner cannot delete someone else's record (no bypass)", () => {
101113 const s = mkSpace();
102114 const r = checkAccess({
103115 op: "delete",
···106118 member: null,
107119 targetAuthorDid: "did:plc:bob",
108120 });
109109- expect(r.allow).toBe(true);
121121+ expect(r.allow).toBe(false);
122122+ expect((r as any).reason).toBe("not-own-record");
110123 });
111124112125 it("delete by non-member: denied as not-member, not not-own-record", () => {
-13
apps/rsvp-atmo/lexicons/custom/README.md
···11-# Custom lexicons
22-33-Place your own hand-authored lexicon JSON files here.
44-55-## Folder conventions
66-77-- **`lexicons/custom/`** (this folder) — Your own hand-authored lexicons.
88-- **`lexicons/pulled/`** — Downloaded via `lex-cli pull` from the atproto registry.
99- Generated from the `pull.sources` list in `lex.config.js`.
1010-- **`lexicons/generated/`** — Auto-generated by `pnpm generate` from your Contrail config.
1111- Don't edit by hand.
1212-1313-All three are picked up by `lex-cli generate` (see `lex.config.js` `files` glob).
-5
apps/rsvp-atmo/lexicons/pulled/README.md
···11-# lexicon sources
22-33-this directory contains lexicon documents pulled from the following sources:
44-55-- atproto (nsids: app.bsky.actor.profile, community.lexicon.calendar.event, community.lexicon.calendar.rsvp, community.lexicon.location.address, community.lexicon.location.fsq, community.lexicon.location.geo, community.lexicon.location.hthree)
+4-4
packages/contrail/src/core/spaces/acl.ts
···4040 isOwner(space, did) || member != null;
41414242/** Space-level access check.
4343- * Membership = access. Any member can read and write; the app filters
4444- * writes it doesn't want on its own side. Delete keeps the owner/own-record
4545- * rule so a random member can't nuke other people's records. */
4343+ * Membership = access. Any member (including owner) can read and write.
4444+ * Delete is scoped to the caller's own records — owners don't get a bypass.
4545+ * A random member can't nuke other people's records, and neither can the
4646+ * owner. To remove a non-author record, delete the whole space. */
4647export function checkAccess(input: AclInput): AclResult {
4748 if (!checkAppPolicy(input.space.appPolicy, input.clientId)) {
4849 return { allow: false, reason: "app-not-allowed" };
···5556 }
56575758 if (input.op === "delete") {
5858- if (isOwner(input.space, input.callerDid)) return { allow: true };
5959 if (!hasMember(input.space, input.member, input.callerDid)) {
6060 return { allow: false, reason: "not-member" };
6161 }
···11-{
22- "id": "community.lexicon.location.address",
33- "defs": {
44- "main": {
55- "type": "object",
66- "required": ["country"],
77- "properties": {
88- "name": {
99- "type": "string",
1010- "description": "The name of the location."
1111- },
1212- "region": {
1313- "type": "string",
1414- "description": "The administrative region of the country. For example, a state in the USA."
1515- },
1616- "street": {
1717- "type": "string",
1818- "description": "The street address."
1919- },
2020- "country": {
2121- "type": "string",
2222- "maxLength": 10,
2323- "minLength": 2,
2424- "description": "The ISO 3166 country code. Preferably the 2-letter code."
2525- },
2626- "locality": {
2727- "type": "string",
2828- "description": "The locality of the region. For example, a city in the USA."
2929- },
3030- "postalCode": {
3131- "type": "string",
3232- "description": "The postal code of the location."
3333- }
3434- },
3535- "description": "A physical location in the form of a street address."
3636- }
3737- },
3838- "$type": "com.atproto.lexicon.schema",
3939- "lexicon": 1
4040-}