> ## 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.

# Permissions

> Role-based access control on collections, evaluated server-side in your Durable Object.

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.

<Info>
  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.
</Info>

## 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:

```ts theme={null}
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`](#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:

```ts theme={null}
import type { PermissionLevel } from 'deepspace/worker'
```

## `visibilityField` and `collaboratorsField`

When you use `'published'` or `'shared'`, you tell the SDK which column to check:

```ts theme={null}
{
  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`](/concepts/architecture#cross-app-shared-scopes) 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`:

```ts theme={null}
{
  name: 'todos',
  columns: [...],
  ownerField: 'assignedTo',
  permissions: {
    member: { read: true, update: 'own', delete: 'own' },
  },
}
```

Now `'own'` resolves against `record.data.assignedTo`.

<Tip>
  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](/sdk-reference/worker/schemas#schema-lint-warnings) flags this (and other `visibilityField` / `userBound` foot-guns) at worker boot.
</Tip>

## Worked examples

### A blog with public posts and member-only drafts

```ts theme={null}
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

```ts theme={null}
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

```ts theme={null}
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](/guides/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](/sdk-reference/worker/schemas) 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`](/sdk-reference/worker/schemas), 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

* [Schemas reference](/sdk-reference/worker/schemas) - full schema and permissions type signatures.
* [Server actions](/guides/server-actions) - privileged writes that bypass user RBAC.
