PocketBase auth rules
Every collection in TinyCld needs auth rules. Without them, PocketBase falls back to “superusers only” and every insert, list, view, update, and delete from a non-superuser session fails with:
Only superusers can perform this action.
You add rules at collection-creation time inside your pb-migrations/<timestamp>_create_<thing>.js file.
The five rules
PocketBase collections accept five auth rules:
| Rule | When it fires | Default if omitted |
|---|---|---|
listRule | filtered list / search | superuser-only |
viewRule | single-record fetch by id | superuser-only |
createRule | record creation | superuser-only |
updateRule | record update | superuser-only |
deleteRule | record delete | superuser-only |
Each rule is a string in PocketBase’s filter language. It evaluates against the record being accessed plus the special @request.auth and @request.data namespaces. When the expression is truthy, the action is allowed.
Owned by one user (most common)
A deployment is one workspace, so there is no organization to scope by: a private record carries an owner relation straight to the users collection (id _pb_users_auth_), and the rule is “the caller is the owner”. Always AND it with the disabled check — core blocks a suspended account from getting a token, but PocketBase evaluates these rules for a token that already exists, so the rule is the whole authorization:
const enabled = '@request.auth.disabled != true'
const isOwner = 'owner = @request.auth.id'
new Collection({
type: 'base',
name: 'todo_items',
listRule: `${enabled} && ${isOwner}`,
viewRule: `${enabled} && ${isOwner}`,
createRule: `${enabled} && ${isOwner}`,
updateRule: `${enabled} && ${isOwner}`,
deleteRule: `${enabled} && ${isOwner}`,
fields: [
{ name: 'name', type: 'text', required: true, max: 200 },
{
name: 'owner',
type: 'relation',
required: true,
collectionId: '_pb_users_auth_',
cascadeDelete: true,
maxSelect: 1,
},
// ...
],
})
This is exactly what @tinycld/contacts ships.
Shared by members with roles
Calendars, boards, and shared mailboxes keep a membership collection (calendar_members: calendar, user, role) and let the rule walk it with PocketBase’s back-relation syntax. Name the roles that may write rather than excluding one — ?!= "viewer" silently grants write to every role added later:
const enabled = '@request.auth.disabled != true'
const authed = '@request.auth.id != ""'
const notGuest = '@request.auth.role != "guest"'
const isMember = 'calendar_members_via_calendar.user ?= @request.auth.id'
const isOwner = `${isMember} && calendar_members_via_calendar.role ?= "owner"`
const viaWriter = 'calendar.calendar_members_via_calendar.user ?= @request.auth.id && ' +
'(calendar.calendar_members_via_calendar.role ?= "owner" || ' +
'calendar.calendar_members_via_calendar.role ?= "editor")'
calendars.listRule = `${enabled} && ${isMember}`
calendars.createRule = `${authed} && ${notGuest} && ${enabled}`
calendars.updateRule = `${enabled} && ${isOwner}`
events.createRule = `${enabled} && ${viaWriter}`
A user’s workspace role (owner, admin, member, guest) lives on the users record itself, so a rule reads it directly: @request.auth.role != "guest". Anything a rule cannot express because it sees only one row — “don’t remove the last owner” — stays a Go hook, with the rule as defence in depth.
Public read, owner write
Public share-link content, blog posts, anything where read is open but writes are gated:
listRule: '', // empty string = anyone
viewRule: '',
createRule: 'owner = @request.auth.id',
updateRule: 'owner = @request.auth.id',
deleteRule: 'owner = @request.auth.id',
Empty string '' means the rule allows everyone. null (or omitting the rule) means superusers only — which is rarely what you want at the API level.
Locked down
If a collection is only ever written by Go server hooks (audit logs, system events), set every rule to null:
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
The Go side bypasses rules with app.Save(record) calls in hooks; null rules at the API level enforce that no client can write directly.
Common patterns
- Read-only after creation: set
updateRule: null(or just omit it). - Admins and the owner only:
@request.auth.role = "owner" || @request.auth.role = "admin". - No guests:
@request.auth.role != "guest"oncreateRule, so a share-link guest can read what was shared but not create records of their own. - Time-limited access:
expires_at > @now(PocketBase fills@nowautomatically).
Where to find existing examples
Every present feature package’s pb-migrations/ is a working reference:
- Contacts:
~/code/tinycld/contacts/pb-migrations/1712000000_create_contacts.js - Mail:
~/code/tinycld/mail/pb-migrations/1713000000_create_mail_collections.js - Calendar:
~/code/tinycld/calendar/pb-migrations/1715000000_create_calendar_collections.js - Drive:
~/code/tinycld/drive/pb-migrations/1716000000_create_drive_collections.js
The @tinycld/bootstrap scaffolder writes a starter migration that already includes the owner-scoped rules and an owner relation to users; rename or remove the field as your data model evolves.
Diagnostics
If you see “Only superusers can perform this action” at runtime, the rule for the action you tried (insert → createRule, list → listRule, etc.) is null or missing. Run pnpm run db:reset from tinycld/ after editing the migration so the new rules take effect — PocketBase doesn’t hot-reload rule changes from a previously-applied migration.