DeepSpace enforces permissions in the Durable Object - the server checks every read and write before it broadcasts to clients. Permissions are declared per-collection in the schema.Documentation Index
Fetch the complete documentation index at: https://docs.deep.space/llms.txt
Use this file to discover all available pages before exploring further.
Client-side filtering is not a security boundary. The DO drops records the caller can’t read before sending them over the WebSocket, so anything that arrives on the client is data the caller is allowed to see.
Roles
Every user has a role on each app’s RecordRoom. The built-in roles are:| Role | Default for | Typical use |
|---|---|---|
viewer | Read-only users (or unauth, with *) | Public visitors |
member | All authenticated users | Normal app users |
admin | Explicitly promoted users | Owners, moderators |
member by default. Override that with defaultRole on the users schema. The only in-SDK way to promote a user is useUsers().setRole(userId, 'admin'), which the server rejects unless the caller is already an admin. The app owner is pinned to admin at connect time, so you don’t promote them manually.
Unauthenticated callers use the '*' wildcard key in the permissions block - there is no anonymous role identifier.
The permissions block
Every collection schema has apermissions object mapping role to operations:
read, update, and delete each accept a PermissionLevel - the union of boolean | 'own' | 'unclaimed-or-own' | 'collaborator' | 'team' | 'access' | 'published' | 'shared'. create is the exception: it accepts a boolean only - you either let a role create rows or you don’t.
The levels you’ll reach for most often:
| Level | Meaning |
|---|---|
true / false | Allow all / deny all |
'own' | Caller is the owner. Default: record.createdBy === userId; override with ownerField. |
'published' | Owner OR matches visibilityField |
'shared' | Owner OR in collaboratorsField OR matches visibilityField |
'team' | Owner OR in collaboratorsField OR member of the team named in teamField |
'unclaimed-or-own'- requiresownerField; passes when that field is empty, or when the caller is the owner. WithoutownerFieldset, behaves identically to'own'.'collaborator'- owner OR incollaboratorsField(owners always pass)'access'- equivalent to'team'; prefer'team'for clarity
deepspace/worker to typecheck against the full union:
visibilityField and collaboratorsField
When you use 'published' or 'shared', you tell the SDK which column to check:
visibilityFielddeclares which column gates the'published'and'shared'rules. Use the string form ('status') to match whendata.status === 'public', or the object form ({ field, value }) for any other sentinel value.collaboratorsFielddeclares which column holds the JSON array of collaborator userIds checked by'shared','collaborator', and'team'.teamField(used by'team'/'access') declares which column holds the team ID. Membership is resolved against ateam_memberscollection in the same scope, which must declareteamId,userId, andstatuscolumns - a row counts as a member whenstatusis'active'or null. Without ateam_memberscollection registered,'team'checks always fail. The built-inWORKSPACE_SCHEMASships one.
ownerField
By default, 'own' checks against record.createdBy (set automatically when the record was created). To tie ownership to a different field (for example, an assignedTo user instead of the creator), set ownerField:
'own' resolves against record.data.assignedTo.
Worked examples
A blog with public posts and member-only drafts
visibilityField set - see above - for 'published' to resolve.)
A shared workspace with collaborators
A private user-scoped collection
Server-side enforcement
The rules are enforced inside the Durable Object’scanRead() / canWrite() checks. Three things follow from this:
- Permissions are checked before data ships over the wire. A user without read access never receives the records - they’re filtered out at the DO before the WebSocket broadcast.
- Client-side filtering is not enough. Don’t rely on the UI to hide records the user shouldn’t see. A determined attacker reading WebSocket frames sees exactly what the DO sent.
- Bypassing RBAC requires a server action. See Server actions - they call privileged worker code with the
X-App-Actionheader, which bypasses RBAC for orchestration that the user themselves couldn’t perform.
Roles vs. visibility
It’s tempting to model “private messages” viaread: 'own', but 'own' only matches a single user. Every participant in a DM needs read access, but only that exact set - so reach for a participant-list pattern instead.
The built-in directory conversations schema does this out of the box: it declares collaboratorsField: 'ParticipantIds' and visibilityField: 'Visibility', with read: 'shared' on member. Setting a conversation’s Visibility to 'private' and listing user IDs in ParticipantIds is then enough - the DO’s canRead check filters every record before broadcast.
For CHANNELS_SCHEMA, the included type: 'public' | 'private' | 'dm' column is informational only - the schema’s member permission is read: true, so the SDK does not gate channels by type on its own. If you need private channels, apply the same pattern: add a participants column, declare it as collaboratorsField, and switch read to 'shared' or 'collaborator'.
Debugging “why can’t this user see X?”
When a record isn’t visible when you think it should be:- Check the caller’s role.
useAuth().userIdgives you the user;useUsers().users.find(u => u.id === userId)?.rolegives the role. - Check the rule for that role and operation.
read: 'published'requires the record’svisibilityFieldto match. - Check the envelope.
record.createdByis the'own'check;record.data.<collaboratorsField>is the collaborator check. - Check the
*rule. If the user is signed out (anonymous), only*applies.
Next steps
- Schemas reference - full schema and permissions type signatures.
- Server actions - privileged writes that bypass user RBAC.