Permissions
Role-based access control on collections, evaluated server-side in your Durable Object.
On this page
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.
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 |
New authenticated users get 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 a permissions object mapping role to operations:
permissions: {
'*': { read: 'published', create: false, update: false, delete: false },
viewer: { read: 'published', create: false, update: false, delete: false },
member: { read: true, create: true, update: 'own', delete: 'own' },
admin: { read: true, create: true, update: true, delete: true },
}
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 |
Less common:
'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
Import the type from deepspace/worker to typecheck against the full union:
import type { PermissionLevel } from 'deepspace/worker'
visibilityField and collaboratorsField#
When you use 'published' or 'shared', you tell the SDK which column to check:
{
name: 'posts',
columns: [
{ name: 'title', storage: 'text', interpretation: 'plain' },
{ name: 'status', storage: 'text', interpretation: { kind: 'select', options: ['draft', 'published'] } },
{ name: 'collaborators', storage: 'text', interpretation: { kind: 'json' } },
],
visibilityField: { field: 'status', value: 'published' },
collaboratorsField: 'collaborators',
permissions: {
member: { read: 'shared', create: true, update: 'own', delete: 'own' },
},
}
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:
{
name: 'todos',
columns: [...],
ownerField: 'assignedTo',
permissions: {
member: { read: true, update: 'own', delete: 'own' },
},
}
Now 'own' resolves against record.data.assignedTo.
uniqueOn - one-per-user rules#
Permissions decide who may write; uniqueOn decides how many times. It declares a composite uniqueness constraint over a set of columns, and the Durable Object checks it on every create and update before the row is written - so "one vote per user per poll", "one RSVP per event", "one membership row per channel" hold against a forged or replayed client write, not just against a well-behaved UI. Pair it with userBound: true on the user column and the guarantee is complete: the client cannot choose whose row it is, and it cannot create a second one.
{
name: 'votes',
columns: [
{ name: 'pollId', storage: 'text', interpretation: 'plain', required: true },
{ name: 'userId', storage: 'text', interpretation: 'plain', userBound: true, immutable: true },
{ name: 'choice', storage: 'text', interpretation: 'plain', required: true },
],
uniqueOn: ['pollId', 'userId'],
ownerField: 'userId',
permissions: {
member: { read: true, create: true, update: 'own', delete: 'own' },
},
}
A violation on a create or update is a refusal on the write's ack, not a thrown exception: the ack carries success: false with Duplicate: a record with pollId=…, userId=… already exists in votes. Handle it as the "already voted" branch. Updating choice succeeds because the unique tuple stays the same; an update that moves a row onto another row's tuple is refused.
Worked examples#
A blog with public posts and member-only drafts#
permissions: {
'*': { read: 'published', create: false, update: false, delete: false },
member: { read: true, create: true, update: 'own', delete: 'own' },
admin: { read: true, create: true, update: true, delete: true },
}
Visitors see only published posts. Members see their own drafts. Admins see everything. (The collection also needs visibilityField set - see above - for 'published' to resolve.)
A shared workspace with collaborators#
permissions: {
member: { read: 'shared', create: true, update: 'shared', delete: 'own' },
admin: { read: true, create: true, update: true, delete: true },
}
Members read and edit anything they own or are listed as a collaborator on. Only the original owner can delete (or an admin).
A private user-scoped collection#
permissions: {
member: { read: 'own', create: true, update: 'own', delete: 'own' },
}
Each user sees only their own records. Useful for things like personal preferences, drafts, or AI chat history.
Server-side enforcement#
The rules are enforced inside the Durable Object's canRead() / 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.
The users directory#
useUsers() is not a raw query over the users collection - it has its own privacy contract, enforced in the DO:
- Anonymous sockets receive no directory. A public room must not turn the users table into a public directory, so unauthenticated connections get an empty list.
- Non-admins get the public-identity projection of every registered user. Each row is cut down to
id,name,imageUrl,role, andlastSeenAt- the fields collaborators need to name each other and to see who is around - regardless of the row-levelreadrule ('own','team', ...). The row rule still governs full-row reads:useQuery('users')and the admin directory. Admins get the full row through their read policy - which is whyuseUserLookup().getEmailresolves only for admins.usePresence()readslastSeenAtstraight off this roster, so online/offline works for every role, not only for admins. - Two opt-outs live on the users schema. A role whose
readisfalse(or absent) receives an empty directory.roster: 'read-policy'scopes the directory to the rows the caller'sreadrule grants (still projected to public identity) - for apps that partition users by tenant or team and must not show names across the partition. Note that with it, every surface that names a peer throughuseUsers()(chat authors, assignees, presence) showsUnknownfor users outside the caller's rows.
The scaffold ships member: { read: 'own' } on the users schema, so a regular member's useQuery('users') is self-only while their useUsers() directory names everyone. Set roster: 'read-policy' deliberately when the product needs the directory partitioned too.
useUserLookup().getName(userId) returns null for a peer the caller's roster does not contain - it resolves against the roster, and there is no server round-trip for a miss. The "Unknown" you see rendered next to an author or assignee is the app's own fallback for that null, not a value the SDK produced; choose whatever label fits your product.
The directory from the server side#
Two other paths read the users collection, and they do not share the client roster's rules:
- The
user.listassistant tool (the tools API surface an AI chat can call) answers with what its caller is allowed to see: an admin caller gets full rows, any other caller gets the same public-identity projectionuseUsers()returns. A tool that readsusersis not automatically the roster - check what it projects, and to whom, before you expose it to a model. - Server actions bypass the projection entirely. Anything the worker calls with the
X-App-Actionheader - server actions, cron jobs, chat-history helpers - reads full rows, email included, because the app's own server code is the trust boundary there. Never return a rawusersrow from an action to a caller without cutting it down yourself.
Roles vs. visibility#
It's tempting to model "private messages" via read: '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.
Log the full record envelope locally to confirm the field values are what you expect.
Next steps#
- Schemas reference - full schema and permissions type signatures.
- Server actions - privileged writes that bypass user RBAC.