Skip to content

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 searches users.username by 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; share to write, up to your own level, never the Owner. Every write goes through setGrant (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, through ConfirmDestructive when 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-roles to write; share sees 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-default grant 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 what member always 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 store resolveActor runs each request inside (utils/actorContext.ts), so a write four calls below a route is attributed without threading req through twenty-two signatures; the editor's path sets its reason the same way, so the mirror beneath it logs editor, not mirror. 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 .email read 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). firstName leaves the shared publicDisplayName chain, the client seam's PlatformUser and ownPublicName: every user has had a users.username since v0.93.0, so it was a provider field with no job. /api/character-sharing/known-users lists registered users from users, not the provider's directory. A dead UserCard went with it.

Also in this release

  • The help site wears the Retro theme. docs/help/style.css re-themed from the marketing site's violet to the app's default skin — dark is retro.css token 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.js remembers 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-you anchors work on the published page for the first time. The validator's version-slot check reads the tab's headings; ROADMAP.md is a pointer.
  • The mobile Actions modal groups by power. It was a flat list; groupMobileActions keeps the category groups and replaces Powers with one group per power, matched by the derived action ids on the linked character's power.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/bugs list, 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.