Roles and permissions
The demo roles are not authentication. The permission matrix behind every guard is ROLE_PERMISSIONS in shared/auth/permissions.mjs — product code, and the file a buyer keeps. The demo toolbar's role switcher reads DEMO_ACCOUNTS from shared/auth/session.mjs — demo code, and the file a buyer deletes. Role and permission labels come from enum.role.* and permission.* keys, present in all four message catalogues. A starter ships no shared/auth/ directory at all: it has no demo data, no auth and no record search to guard.
Identity, and what a role may do
Every request but /__* and /auth/* other than /auth/session needs an
identity. React, Vue and the HTML edition send it as
Authorization: Bearer demo.<userId>; Next and Nuxt send it as the
orion.session cookie instead, because their guard has to run on the server.
No token at all resolves to usr_01 — the owner, and the default this demo
always had — so every walk and screenshot still sees the same person. A
token that names nobody is refused outright:
{ "error": { "status": 401, "code": "error.unauthorised", "detail": null } }A resolved identity can still be refused the specific thing it asked to do.
shared/auth/permissions.mjs's apiPermission() runs before any route does,
so a write a role cannot make never reaches the handler that would have
performed it — a viewer's PATCH on an order that does not exist answers 403
for the same reason a viewer's PATCH on one that does:
{ "error": { "status": 403, "code": "error.forbidden", "detail": { "permission": "orders.update" } } }A write nobody wrote a rule for is refused too, by default, rather than let
through: it needs workspace.manage, which only the owner and admin hold.
GET /api/auth/session is the one route the demo switches never touch — the
signed-in reader is not demo data, and a __error switch left on by the demo
toolbar should never make an edition's sidebar disagree about who is signed
in.
What to keep, what to delete
Keep permissions.mjs whole. Every route guard, every Can/useDenied call
and the 403 page all ask it the same question — can(role, permission) — and
a real backend answering with a real role name needs nothing else changed on
the client.
Delete session.mjs, the demo accounts (DEMO_ACCOUNTS, DEMO_ROLES), the
role switcher in the demo toolbar, login-by-email in the mock's
POST /auth/login, and the usr_01-with-no-token default. All four exist to
let a demo show three roles without a real sign-in behind them; none of them
belong once there is one.
The server contract
GET /auth/session answers { "user": { "id", "name", "role", … } }. A
missing or invalid credential is 401 — fail closed; never default to a
user, the way this demo's no-token-means-the-owner rule deliberately does for
demonstration purposes only. A role that lacks a permission is 403:
{ "error": { "code": "error.forbidden", "detail": { "permission": "orders.update" } } }The backend enforces every one of these on its own, mirroring
apiPermission() — including the default-deny rule for a write nobody wrote a
rule for, which needs workspace.manage. A client that only hides a button is
not a permission system; it is a UI that agrees with one that has to exist
somewhere else.
Reads default the other way: a GET nobody wrote a rule for is open to every
signed-in role, not refused the way an unmapped write is. apiPermission()
gates only /invoices*; every other collection's GET returns null. A new
sensitive GET — an audit log, a compensation figure, an API key — needs its
own rule added before it ships, or every role reads it.
Adding a role, a permission, a guarded route, a gated control
- A role: add it to
ROLE_PERMISSIONS, addenum.role.<role>to all four catalogues, and decide whether it gets a switcher button (demoRoleOf) — most new roles, likesupportin this demo, do not need one to be reachable by signing in as someone who holds it. - A permission: add it to
PERMISSIONS, addpermission.<permission>to all four catalogues, and add it toROLE_PERMISSIONSfor the roles that hold it. - A guarded route: add
['/the/path', 'the.permission']toROUTE_PERMISSIONS.routePermission()andcanVisit()pick it up everywhere at once — the route guard, the nav filter, the palette and the search results' fallback path. - A gated control: wrap it in
<Can permission="the.permission">to hide it, or spreaduseDenied('the.permission')onto it to disable it with a reason. Never disable a control that has to change to show its own state — hide it instead — and never leave a control enabled that the server would refuse: that is the one rule this whole layer exists to keep.
Known demo simplifications
GET /settingsis not filtered by role: an editor still receives the billing tab's data even though the tab is hidden from them. Filtering it — data minimisation, not just UI hiding — is a real backend's job, not this demo's.- The notifications tab in Settings is stored once per workspace, not once per signed-in person, because the mock has one settings object, not one per staff row.
- More generally: every rule in
apiPermission()that returns null because a write is "personal" — the account tab, the notifications tab, marking a thread read — is safe only if the backend actually stores that state per signed-in user. This mock does not; a real one that copies the rule without copying per-user storage lets a viewer change a workspace-wide setting. supportexists only in the permission matrix. It is reachable by signing in as one of thestaff04–staff06fixtures; no demo toolbar button presses it, because the toolbar only shows the three roles a buyer is meant to try first.- Editor and viewer see the same nav and the same command palette page list: Pricing is the only nav destination either of them is refused, because it is the only nav destination that needs a permission neither role holds.
Per-edition sections
Each edition's own README has its own "Roles and permissions" section, covering how that edition wires the switcher and the guard to its own routing and rendering:
html/README.mdreact/README.mdvue/README.mdnextjs/README.mdnuxt/README.md