v0.96.0 — Permissions UI and audit log
Shipped 2026-09-20 — merged to main as 3ae854f4, tagged v0.96.0.
Built on version/0.96.0, eighteen commits. The sixth of the
releases the near-term roadmap sequences from the
August 2026 heuristic evaluation; the roadmap page is
permissions-ui-and-audit-log,
part three of the permissions overhaul,
standing on v0.95.0's grants, effectivePermissions and whoCan. The
help page is Sharing and permissions.
v0.95.0 made the model one; this makes the UI one. Three sharing surfaces —
the campaign invite panel, the character access modal with its share
button, the token ownership panel — each with its own vocabulary, become one
PermissionsEditor for every entity: who has access and at what role,
where each person's access comes from, what a change would do before it is
made, custom roles, what a new member gets, and a history of every change
that outlives the thing it is about. Decision UI-D1 (a Collaborator may
see the effective-permissions tab, read-only) was taken at its
recommendation. The four items queued under Next Release rode along.
The editor
<PermissionsEditor entity>(UI.1) — one component, any of the six entity types. The list of principals with their role; add by username (the picker searchesusers.usernameby prefix, never an email); remove; change role. The Owner row is locked and says why. Custom roles appear beside the built-ins when the campaign has any, and a role above the caller's own is offered disabled with the reason, not hidden. Server:/api/permissions—GET /users?q=,GET /:type/:id,PUT/DELETE /:type/:id/:userId;shareto write, up to your own level, never the Owner. Every write goes throughsetGrant(services/grantWrites.ts), which keeps the legacy tables in step the other way round from v0.95.0's mirror, so the bridge cannot hold a stale level above a grant.- Effective (UI.2) — the second tab: everyone who can do anything with
the entity, their effective role, and one sentence saying where it comes
from — "granted here on 12 Sep", "from Campaign X", "owner of Campaign X",
"Superuser Mode". This is v0.95.0's
whoCan, rendered; grant-only is why every row has exactly one explanation. Read-only to a Collaborator (UI-D1). - Preview before apply (UI.3) — nothing is written on a click. Role
changes, removals and adds land in a pending set with a count; Preview
asks
POST /:type/:id/preview, which resolves the affected users against a patched in-memory cascade — the entity and every child beneath it, up to 500 per type — and answers who gains what and who loses what, including "also loses access to 3 maps and 7 tokens inside it"; Apply commits the set as one, throughConfirmDestructivewhen anyone loses access. - Custom roles (UI.4) — on the campaign's Sharing tab: a name, the
six-verb checklist, which entity types it is offered on
(
roles.applies_to, pg migration 0035). Deleting a role somebody holds asks what those grants become — another role or nothing — with the count./api/campaigns/:campaignId/roles:manage-rolesto write;sharesees the list read-only. - The three surfaces render the one editor (UI.5): the campaign Share modal, the top-nav campaign access modal, the character sheet's Share and the character list's, the token settings drawer. Invite links, email invites and access requests keep their flows around it. Two wiring tests, two hooks and two components that only the old surfaces used are gone.
- What a new member gets (UI.6) — a row on the Sharing tab that writes
a
role-defaultgrant on the campaign (principal_id = new-member); every join path — invite link, email invite, add a known player — reads it. Absent, a new member is a Collaborator, which is whatmemberalways meant. This row is the seam campaign configuration fills with "can players make maps?" style questions.
The audit log
permission_events(A.1; pg migration 0036, sqlite DDL) — one row per grant write: entity, actor, principal, old role, new role, the mechanism (reason), whether Superuser Mode was on, when. Written by every path that writes a grant: the editor (editor), the sharing routes' mirror (mirror), the boot backfill (backfill, inserted rows only, no actor), a custom role's deletion (role-deleted:<replacement>), the campaign default (default-changed), an entity's grants being dropped (entity-deleted). The actor comes from an AsyncLocalStorage storeresolveActorruns each request inside (utils/actorContext.ts), so a write four calls below a route is attributed without threadingreqthrough twenty-two signatures; the editor's path sets its reason the same way, so the mirror beneath it logseditor, notmirror. A write that changes nothing writes no row; a failure to record never fails the write.- History (A.2) — a third tab on the editor, for whoever may share:
newest first, one sentence per row ("kenjura changed player-xbmt from
Collaborator to Viewer"; "the backfill made bob an Editor"; "alice removed
bob (was Editor), via a role being deleted — in Superuser Mode"), filterable
by whom it was about.
GET /api/permissions/:type/:id/events. Admins see every entity's from Permission history on/admin/data, by entity id, so a deleted entity's rows still show. - Retention (A.3) — nothing deletes from the log but the admin purge:
no foreign keys, and a guard test walks server-core for any other
delete(schema.permissionEvents).POST /api/admin/data/permission-events/purge { olderThanDays }, floor 30 days, behind a confirm on the same section.
Username everywhere
- v0.90.0's Q.12 guard tightens (F.1): a given name and any
.emailread now count, and the allow-list is three named groups — OWN (the seam and the profile menu, asserted to be the only non-admin display), ADMIN (each file asserted to gate on admin status), PLUMBING (the invite address the viewer typed).firstNameleaves the sharedpublicDisplayNamechain, the client seam'sPlatformUserandownPublicName: every user has had ausers.usernamesince v0.93.0, so it was a provider field with no job./api/character-sharing/known-userslists registered users fromusers, not the provider's directory. A deadUserCardwent with it.
Also in this release
- The help site wears the Retro theme.
docs/help/style.cssre-themed from the marketing site's violet to the app's default skin — dark isretro.csstoken for token, light is the same palette on parchment; Cinzel, Crimson Text, IM Fell; gold links on the dark ground because the sienna accent is 2.7:1 there; contrast ratios computed and stated per token. - The near-term roadmap lives on the roadmap page.
ROADMAP.md's body became the default Near-term tab of the roadmap, Every entry the second;tabs.jsremembers the tab and resolves a hash into whichever tab holds it — by suffix against ursa's numbered heading ids, so the plan's#decisions-waiting-on-youanchors work on the published page for the first time. The validator's version-slot check reads the tab's headings;ROADMAP.mdis a pointer. - The mobile Actions modal groups by power. It was a flat list;
groupMobileActionskeeps the category groups and replaces Powers with one group per power, matched by the derived action ids on the linked character'spower.actions[], fetched only while the modal is open. - A bug tracker, specified. bug-tracker:
fingerprinted, counted rows as a second sink beside v0.92.0's email/log,
a server-side
reportFault()from the last-resort handler and the process-level handlers, an/admin/bugslist, admin notifications on a new fingerprint. Proposed, ~9 tasks. - Found and fixed on the way: the A.1 migration file was committed as its header comment alone (the DDL lost between generating and annotating); the migrator recorded it as applied and the table did not exist. A guard now fails the build on a journalled migration that is all comment.
- Found and recorded: Mantine's modal transitions and the Browser pane's hidden-tab throttling do not mix — a page-hidden tab renders a modal's fade one frame at a time, and a WebGL map does not render at all. Live verification of the map on a phone viewport was not possible from here; the mobile grouping is covered by its unit and contract tests.
Decisions
UI-D1 (a Collaborator sees the effective-permissions tab, read-only) was
taken at its recommendation. Two more were made here: History is shown to
whoever may share — the verb that lets you write to the list it records —
rather than to Editors by name, so a custom role that can share can also
see what was shared; and the log's timestamps are kept strictly increasing
within a process, so an Apply of five changes reads back in the order they
happened.