Skip to main content

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.

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.
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:
RoleDefault forTypical use
viewerRead-only users (or unauth, with *)Public visitors
memberAll authenticated usersNormal app users
adminExplicitly promoted usersOwners, 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:
LevelMeaning
true / falseAllow 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' - requires ownerField; passes when that field is empty, or when the caller is the owner. Without ownerField set, behaves identically to 'own'.
  • 'collaborator' - owner OR in collaboratorsField (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' },
  },
}
  • visibilityField declares which column gates the 'published' and 'shared' rules. Use the string form ('status') to match when data.status === 'public', or the object form ({ field, value }) for any other sentinel value.
  • collaboratorsField declares 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 a team_members collection in the same scope, which must declare teamId, userId, and status columns - a row counts as a member when status is 'active' or null. Without a team_members collection registered, 'team' checks always fail. The built-in WORKSPACE_SCHEMAS ships 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.
When you set ownerField, also mark the column userBound: true so a client can’t claim someone else’s id. The SDK’s schema-lint flags this (and other visibilityField / userBound foot-guns) at worker boot.

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:
  1. 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.
  2. 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.
  3. Bypassing RBAC requires a server action. See Server actions - they call privileged worker code with the X-App-Action header, which bypasses RBAC for orchestration that the user themselves couldn’t perform.

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:
  1. Check the caller’s role. useAuth().userId gives you the user; useUsers().users.find(u => u.id === userId)?.role gives the role.
  2. Check the rule for that role and operation. read: 'published' requires the record’s visibilityField to match.
  3. Check the envelope. record.createdBy is the 'own' check; record.data.<collaboratorsField> is the collaborator check.
  4. 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