Skip to content

v0.95.0 — Roles and the Gatekeeper

Shipped 2026-09-20 — merged to main as 8440d02f, tagged v0.95.0, pushed. Built on version/0.95.0, twenty-two commits. The fifth of the releases ROADMAP.md sequences from the August 2026 heuristic evaluation, after the v0.94.0 rename slotted ahead of it; the roadmap page is roles-and-gatekeeper, part two of the permissions overhaul, standing on v0.93.0's users table and resolveActor(req). The developer page is Permissions, read routes and the Gatekeeper.

This is the release that closes the leaks the evaluation found. Before it, whether a route checked access was a property of the route — present on some, missing on others, subtly different where present — and three different permission models answered the same question three ways. After it, one table and one resolver answer it, one component every private read passes through enforces it, every GET route is in exactly one of four declared sets with a guard that fails the build on an undeclared one, a socket cannot join a room its actor may not read, and an admin is an ordinary user until they turn on a mode nobody can miss.

Roles

One grants table — (principal_kind, principal_id, entity_type, entity_id, role, granted_by, granted_at), both dialects, pg migrations 0032 and 0033 — replaces the campaign role string, character_permissions levels and token_permissions allow rows. Four built-in roles (Owner, Editor, Collaborator, Viewer) are defined in code in @purple-cat/shared over a closed list of six verbs (read, play, edit, delete, share, manage-roles); custom roles are rows in roles, per campaign, over the same verbs. Admin is not a role (PM-D3). There is no deny (PM-D1).

One resolver. effectivePermissions(actor, entity) loads the cascade — token → map → campaign, and token → character → every campaign the character is linked to; map, party and faction → campaign — and hands it to pure rules that touch no database: an explicit grant on the entity decides, otherwise the nearest ancestor that says anything; Owner cascades unconditionally and cannot be overridden downward (v0.61.0's "a GM's control is a floor", generalised), except that a character caps what its campaigns confer at Collaborator (a GM plays a player's character and may not open Build mode on it — the cap R.4's parity test found missing); several roles at the deciding level are unioned; nothing subtracts. whoCan(entity, verb) is the inverse, implemented by running the same resolver over every candidate so it cannot disagree. Ownership stays in its column — campaigns.owner, characters.user_id, parties.user_id, tokens.owner are read as Owner grants on the way past, never copied — so a code path that creates an entity cannot forget the row.

The migration backfills the three legacy tables into grants at boot, idempotently, with deterministic ids; a parity test asserts every actor's effective permissions against the old model over a fixture, both ways. The existing checks — checkCampaignAccess, resolveCharacterAccess, the token impersonation helpers and v0.88.0's impersonation levels — became thin calls to the resolver, with the v0.61.0 and v0.88.0 tests passing unmodified as the proof.

Found on the way (R.5a). The backfill runs once at boot, but the invite, accept, add-member and remove-member routes, setCharacterPermission and the token allow helpers keep writing the legacy tables until v0.96.0's editor writes grants — and checkCampaignAccess, unlike resolveCharacterAccess, had no bridge to the legacy row. On a running server a membership accepted at 10am conferred nothing until the next restart, and a removed member kept access. Every legacy write now mirrors into grants at the time (services/grantsMirror.ts), with the backfill's own translation and ids, and a source-text test names every write site.

The Gatekeeper

The component (middleware/gatekeeper.ts). Express has no "after" middleware, so it is registered before the handler and wraps res.json: the handler builds its answer as it always has, and the wrapped res.json walks the body against the actor — inside the error envelope, so a 403 it decides carries kind and code — before sending it. Three shapes: one (403 when unreadable), list (unreadable rows dropped, the count logged at debug level), within (the path names the entity; checked before the handler runs, which is what lets it guard the two routes that stream an archive). A mapper places a row that is not a grant entity by the entity that decides — a drawing by its map — and may answer PUBLIC or several entities; a row it cannot place fails closed. permissionSession(actor) memoises the cascade per request and prefetches a list in one row query and one grants query per type, which is what keeps the map's token list — the hot paint path — at under fifteen queries for three hundred tokens rather than six hundred.

The inventory (routes/readRoutes.inventory.ts). All 126 router.get registrations, keyed <file> <router> <path>, in exactly one of four sets: 80 Guarded with the shape each declares; 15 Public, 24 Self-scoped and 7 Admin-only, each with a written reason. The roadmap page named three sets; the fourth exists because a requireAdmin route is a capability gate and fits none of the others honestly.

Wired, one router group per commit, each with a two-user test over the real routers on the real schema: campaigns and members; maps, layers, tokens, drawings, VBL and fog; characters and every sub-collection; parties, factions and relationships; chat; reference data. Five routes had no access check of any kind before this release — GET /api/tokens/:id, map layers, VBL shapes, fog shapes, and GET /api/map-objects, which listed by layer across every map in the database — and GET /api/conditions with no campaignId returned every campaign's rows. The per-route checks that existed stay; the Gatekeeper is a floor on privacy, not a ceiling on strictness, and two handlers are tighter than it.

The socket side. A socket had no identity at all: joinCampaign took any campaign id and user:join any user id, the client's word for it. Now the client sends the identity provider's token in the handshake (re-read on every reconnect; the hook waits for a provider before opening), socketAuth verifies it as requireAuth does and refuses a socket it cannot identify, joinCampaign and presence:join go through the resolver and answer joinCampaign:denied, user:join joins the actor's own room, and presence carries the actor's id. The two global emits v0.90.0 missed (map:updated, image:ready) are room-scoped.

The guard (routes/readRoutes.guard.test.ts) audits every registration against the inventory both ways — no unclassified route, no stale line, no route in two sets — and checks that a Guarded route carries gatekeeper.<shape>( on its registration in the declared shape and that no other route carries one. It runs against a synthetic tree too, because a guard whose only run is the passing one has never been seen to fail.

Superuser Mode

Admins are users. ADMIN_USER_ID — one id in an environment variable — short-circuited seventeen checks and is read nowhere now; a guard keeps it out. An admin holds Owner on what they create and is granted the rest like anyone. The demo seeder, which inserted campaigns with no owner that only the env-var admin could open, writes the seeding admin as owner and sits behind requireAdmin (it clears tables and was unauthenticated).

The mode. PUT /api/admin/superuser { on }, behind requireAdmin, sets users.superuser_until (pg 0034) eight hours out or clears it; resolveActor reads it into req.actor.superuser (cached ten seconds per user; a lookup failure is off); the Gatekeeper, the resolver, the socket room gate and the legacy helpers all pass while it is set. A server-side switch, never a header, and it does not travel with impersonation. The client shows a fixed, full-width banner at the bottom of every screen with the one Exit Superuser Mode button; entering is one button on the Users admin page; every entry and exit is logged. viaAdmin on a character's access now means the mode.

The Module API

1.37.0 (additive): a module route's request carries req.actor and req.effectivePermissions — the platform's own resolver, bound to the same actor a platform route would see — when the caller sent a token, and nothing extra otherwise. Module routes stay public; one that needs an actor checks for one. No module release is required.

Also in this release

  • testing/routeHarness.ts: the route harness server-core never had — the real initializeDatabase() on an in-memory sqlite, the real routers behind a header identity — and the finding it produced: the fresh-sqlite DDL in db.ts lacks eight columns and three tables dbSchema.ts declares (tokens.mounted_on_token_id among them; roll_tables, roll_table_entries, validation_rule_preferences). The harness reconciles and records them; fixing the DDL is its own task.
  • permissionSession's per-request cache and batched prefetch.
  • The dead DrawingEvents class and its three global emits are gone.
  • The infra still passes ADMIN_USER_ID into the task definition; nothing reads it, and removing it is a Terraform change for its own release.

Decisions

PM-D1 (grant-only, no deny rows), PM-D3 (Admin is a flag, not a role) and R-D1 (filter lists, 403 singletons) were taken at their recommendations. One more was made here: a fourth route set, Admin-only, rather than filing requireAdmin routes under one of the three the page named.