Auth
Session-based auth, root admin onboarding, and roles/permissions.
ratchet/auth provides session-based authentication (User, Role, Session models, plus a router and pipeline helpers) that ratchet serve mounts at /api/auth, ahead of the generic /api/:model router. A role's entire grant list lives on the Role.permissions json column — there is no separate Permission model.
Endpoints
| Method | Path | |
|---|---|---|
GET | /api/auth/setup | whether a root admin still needs to be created |
POST | /api/auth/setup | one-time bootstrap: create the root admin |
POST | /api/auth/register | create a user, issue a session |
POST | /api/auth/login | verify credentials, issue a session |
POST | /api/auth/logout | invalidate the current session |
GET | /api/auth/me | current user + their permissions |
PATCH | /api/auth/me | update your own email / password |
setup, register, and login all return { data: { user, token } } (setup's GET returns { data: { required } } instead) and set an HttpOnly session cookie (Secure when the request is HTTPS). The console SPA relies on the cookie; non-browser clients can instead send Authorization: Bearer <token>.
PATCH /api/auth/me is self-service: any signed-in user can change their own email and password without the users:update permission that gates admin-driven edits through PATCH /api/users/:id. Only those two keys are honoured — roleId, active, and everything else are ignored — and the write still runs through the User model's own update pipeline (hashPassword + validate). It returns the same { data: <user + permissions> } shape as GET /me. The console exposes it as the Edit profile page (/profile), linked from the account menu in both the sidebar and the workspace header.
Root admin onboarding
A fresh instance has no users, so requirePermission would lock everyone out with no way to grant the first permission. POST /api/auth/setup closes that gap: it's unauthenticated, but only works once — it checks whether any user already holds a *:* permission entry (on their role's permissions list) and 409s (SETUP_ALREADY_COMPLETE) if so. Otherwise it creates (or reuses) a Root role with a {resource: '*', action: '*', field: '*'} permission entry, creates the submitted user under that role, and signs them in — the same shape as register/login.
The same request also provisions the framework's first built-in Agent, named Ratchet, with roleId set to that same Root role — so it can call every tool from turn one (see "Agents derive their tools from a Role" below). Agent.providerId is required and there's no way to seed a working Provider without real credentials, so POST /api/auth/setup additionally requires a providerApiKey (plus optional providerKind, one of 'anthropic'/'openai', default 'anthropic', and providerUrl) and creates that Provider in the same transaction. A fresh instance ends setup with a chat-ready assistant rather than an empty Agents list. The console's /setup form collects these fields alongside the admin email/password.
The console SPA's AuthProvider checks GET /api/auth/setup on boot alongside /me. While a root admin doesn't yet exist, every console route redirects to its /setup route (a form styled like the login page, with a confirm-password field) instead of /login; once it's created, /setup itself redirects away.
Root status is keyed off the permission, not user count — a user created through public /register (below) has no role and no permissions, so it doesn't count, and /register itself is unaffected by whether setup has run.
curl -X POST http://localhost:3000/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"a@example.com","password":"hunter2"}'
curl http://localhost:3000/api/auth/me \
-H 'Authorization: Bearer <token>'How passwords are handled
The User model never declares a plaintext password field. Instead its passwordHash column declares writeAs: 'password', and the hashPassword pipeline function intercepts ctx.input.password, hashes it, and rewrites it as passwordHash before validate runs. passwordHash itself is sensitive: true, so it's never present in a response.
Auth + permission is implicit on the generic router
Every route on the generic /api/:model router requires a matching permission entry on the user's Role.permissions list by default — including reads, which use an implicit 'read' action (there's no per-model read operation to compose a pipeline step into, unlike create/update/remove). A model author doesn't need to do anything to get this; it applies automatically, the same way redactSensitiveFields or ?include= do.
A model that must stay reachable with no session at all (a public read-only catalog, say) opts out with api: { public: true }:
export const Announcement = defineModel('announcements', {
fields: { /* ... */ },
api: { public: true },
});public applies to every verb on that model — there's no per-operation granularity today.
requireAuth and requirePermission(resource, action) still exist as ordinary pipeline functions, for a dedicated router that bypasses the generic one entirely (e.g. Chat/Message's own /api/automation/* routes, or an agent tool call — automation/tool.ts's executeAgentTool calls authorizeRequest, the same check under the hood, before ever touching a model's pipeline):
requireAuthresolves theBearertoken or session cookie onctx.requestto a live, active user and stashes it onctx.user. ThrowsUNAUTHENTICATED(401) otherwise.requirePermission(resource, action)must run afterrequireAuth. It checks the user's role owns a permission entry matching(resource, action)— either side may be granted as*. ThrowsFORBIDDEN(403) otherwise, orUNAUTHENTICATEDifrequireAuthdidn't run first.
Roles and permissions
Role is an ordinary model whose entire grant list is the permissions json column — Role.permissions is [{ resource, action, field? }, ...], and there's no separate Permission model or junction table to manage. Grant a role permissions by setting permissions to the desired array, using '*' for resource/action to grant broadly and '*' for field to grant every field. A role's grants are edited through the generic REST API (a POST /api/roles create or PATCH /api/roles/:id update carrying the whole permissions array) or through the console form (see below) — replacing the entire array in one call, the same way you'd replace an array field on any model.
Role's console form (src/auth/models/role.form.tsx, shipped with the framework — a real, worked example of a builtin console form) edits name/description the normal way and, in the same form/submit, manages the role's entire permissions array via a tree of resource/action/field checkboxes where '*' collapses a fully-granted subtree into one wildcard entry. It persists with a plain POST/PATCH /api/roles, so it's gated the same "two independent checks" way any role edit is: the roles:create/roles:update grant from the requesting user's own role, plus the field grants covering the fields being written.
Agents derive their tools from a
Role. AnAgentcarries aroleId, and the tools it may call (resolveAgentTools,src/automation/tool.ts) are exactly the operations its role'spermissionsgrant — so an agent's capabilities are just a role assignment, no separate grant list. Areadgrant yields two builtin read tools per resource,list_<model>(filter/sort/offset-pagination, returns{ data, meta }) andfindOne_<model>(one row by id);create/update/removeand custom operations map to a tool each as before. When an agent tool is invoked, the call is additionally gated by the chatting user's own role (not the agent's alone) — including the samereadfield grant that scopes a REST response's columns and its?include=d relations — so an agent can never read or write more than the human driving it already could over/api/:model.
Field-level permission
field names one field of resource a role may read (action: 'read') or write (action: 'create'/'update') — or '*' for every field. It's required on a read/create/update/'*' entry and doesn't apply at all to remove (which gates a whole row, never individual fields). Each grant is one element of Role.permissions:
{ resource: 'invoices', action: 'read', field: 'total' }
{ resource: 'invoices', action: 'read', field: 'customerNotes' }
{ resource: 'invoices', action: 'update', field: '*' }This is secure-by-default, with no implicit "all fields": a role with a (resource, action) grant but no matching field grant gets zero fields for that action, not every field. A read that field permission denies simply omits that key from the response (the same shape sensitive: true fields already get); a write that touches a denied field is rejected outright, naming every offending key, rather than silently dropping it. ?filter=/?sort= on a field a role can't read is rejected the same way — otherwise field-read denial would be a trivial oracle to route around.
Two things are always exempt from field-level permission, for every role:
- Auto-injected system columns (
id,createdAt,updatedAt,deletedAt,createdById) — they aren't declarable viafield.*in the first place, and gating them would break pagination cursors,?include=, and file-field URLs for no security benefit. sensitive: truefields (e.g. a password hash) — that's a separate, absolute, non-role-based redaction: it never reaches any role, whereas field permission is about fields that are fine for some roles but not others.
?include=d relations are filtered by the same field grant, using the requesting role's read permission on the related model — embedding a row via ?include= can't be used to see fields that model's own permission denies.
Bootstrapping
Because there's no implicit "all fields," the Root role POST /api/auth/setup creates a { resource: '*', action: '*', field: '*' } entry in permissions — the '*' on field is what keeps the freshly-created admin able to see/edit anything immediately, the same way '*' on resource/action already did. When granting a narrower role by hand, remember field needs its own entry (or '*') for read/create/update — a grant that only sets resource/action and leaves field unset is rejected by requireValidPermissionTarget, not silently treated as "everything."