Spaces
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.
Mental model
A space is a bag of records with one lock. The member list says who has the key.
- One owner (DID), one type (NSID), one key. Identified by
ats://<owner>/<type>/<key>— distinct scheme from atproto record URIs (at://) so the two can’t be confused at any layer. - 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.
- Optional app policy gates which OAuth clients can act in the space.
Every permission boundary is its own space. No nested ACLs. Richer roles = more spaces or app-layer checks.
Enable
import type { ContrailConfig } from "@atmo-dev/contrail";
const config: ContrailConfig = {
namespace: "com.example",
collections: { /* ... */ },
spaces: {
type: "com.example.event.space",
serviceDid: "did:web:example.com",
},
};
Each collection gets a parallel spaces_records_<short> table. Opt out per-collection:
public_only: { collection: "com.example.public", allowInSpaces: false }
Auth
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 for the full picture.
Space-specific wiring:
serviceDidin the config is theaudcontrail expects on incoming JWTs. Plain DID, no#fragment.- Apps acting in a space send
Atproto-Proxy: <serviceDid>#<service-id-from-your-did-doc>so the user’s PDS routes correctly. - Invite redemption via service-auth JWT grants membership; via
?inviteToken=...query param grants read-only bearer access to that space.
Unified listRecords
| Call | Returns |
|---|---|
no auth, no spaceUri | public only |
?spaceUri=… + JWT | one space (ACL-gated) |
JWT, no spaceUri | public unioned with every space the caller is a member of |
Filters, sorts, hydration, and references work across all three. Records from a space carry a space: <spaceUri> field — same on listRecords/getRecord responses and watchRecords stream events.
Invites
First-class primitive — see Auth § 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).
XRPCs
com.example.space.create | get | list | deletecom.example.space.putRecord | deleteRecord | listRecords | getRecordcom.example.space.invite.create | redeem | revoke | listcom.example.space.listMembers | removeMember
What’s not here
- No E2EE (data is operator-readable).
- No FTS on
?spaceUri=…yet. - No per-space sharding — one DB, one operator.
- Not a long-term replacement for real atproto permissioned repos.
The design follows Daniel Holmgren’s permissioned data rough spec . The goal is that when real atproto permissioned repos ship, migration is mostly data movement — the API your app speaks doesn’t change.