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 realinitializeDatabase()on an in-memory sqlite, the real routers behind a header identity — and the finding it produced: the fresh-sqlite DDL indb.tslacks eight columns and three tablesdbSchema.tsdeclares (tokens.mounted_on_token_idamong 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
DrawingEventsclass and its three global emits are gone. - The infra still passes
ADMIN_USER_IDinto 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.