feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
CI / check (push) Successful in 46s
Build & push images / images (push) Successful in 2m58s
Build desktop / desktop (push) Successful in 4m53s

Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix",
open-questions #16) — the grid stays the enforcement layer:

- Move 1: each desk's money is guarded by that desk's own permissions. Manifest
  tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create
  (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes
  resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot
  touch the booth by construction. Replaces the session:read borrowing (tillPermission).
  /api/shift/tills lists the role's readable tills with canWork; history/movements
  without a till filter return the union of readable tills.
- Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor,
  merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and
  "partial job" lints (warnings, never blocks).
- Move 3: the live WebSocket admits any watch permission (event/session/device read or
  a module's feedPermission) and filters every push per role; report:read is the
  reports screen only.

Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves
the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's
role applies on the next request and a deleted user's session ends with 401.

Tests: till guards + look-only role, feed rules, every job's permissions exist, role
reassignment without re-login. 353/353.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-05 14:45:48 +02:00
parent a9ccf9e20c
commit 55d6242c7d
24 changed files with 654 additions and 206 deletions
+102 -20
View File
@@ -87,10 +87,13 @@ export const PERMISSIONS: readonly Permission[] = [
// action on a fresh appliance, never reachable from the running console. See
// wiki/concepts/backup-recovery.md.
"backup:read", "backup:update", "backup:create",
// Car Wash module (venue-modules.md): read = the wash desk's queue + ticket lookup;
// create = intake an order; update = mark done / take a bay payment / void. Settings
// (categories, services, price matrix, sponsorship program) ride site:update.
"carwash:read", "carwash:create", "carwash:update",
// Car Wash module (venue-modules.md): read = the wash desk's queue + ticket lookup
// (+ the wash till's shift state and the wash live feed); create = intake an order;
// update = mark done / take a bay payment / void; cash = WORK the wash till — open and
// close its shift, record its cash in/out (the wash's own `shift:create` +
// `drawer:create`; see ModuleManifest.tillGuards). Settings (categories, services,
// price matrix, sponsorship program) ride site:update.
"carwash:read", "carwash:create", "carwash:update", "carwash:cash",
] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL
@@ -1805,11 +1808,31 @@ export interface ModuleManifest {
* operators open shifts on that till and reconcile that drawer. Absent = the
* module has no money of its own (validation) — or, for parking, the booth. */
readonly till?: TillId;
/** The permission that lets a role WORK this module's till: open/close its shift and
* move its cash. The booth's is the booth screen's own (`session:read`); a wash
* operator's role holds `carwash:read` and not that, so they can never open the
* booth's shift — and vice versa. Enforced server-side (shift/drawer routes). */
readonly tillPermission?: Permission;
/** Who may SEE and WORK this module's till — each desk's money is guarded by that
* desk's own permissions (permissions-matrix decision, 2026-09-05): `read` = see the
* shift state / X-report / balance / history; `shift` = open + close the shift;
* `cash` = record cash in/out. The booth's are parking's `shift:*` / `drawer:*`; the
* wash's are `carwash:read` / `carwash:cash`. A wash role holds no `shift:*` at all,
* so it cannot touch the booth by construction. Required when `till` is set. */
readonly tillGuards?: TillGuards;
/** The permission that admits this module's ledger events to a role's live feed
* (`ledgerEventTypes` above). Absent = the core `event:read`. */
readonly feedPermission?: Permission;
/** JOBS — named permission bundles the role composer offers as one click ("Booth
* operator", "Wash operator"). The grid stays the enforcement layer; a job is only a
* starting point the admin may fine-tune. Names live in the web i18n (`jobs.<id>`). */
readonly jobs: readonly JobPreset[];
}
export interface TillGuards {
readonly read: Permission;
readonly shift: Permission;
readonly cash: Permission;
}
export interface JobPreset {
readonly id: string;
readonly permissions: readonly Permission[];
}
/** The registry. Adding a module = one entry here + its server/web folders
@@ -1822,7 +1845,28 @@ export const MODULES: readonly ModuleManifest[] = [
resources: ["tariff", "subscription", "payment", "session"],
ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"],
till: "booth",
tillPermission: "session:read",
tillGuards: { read: "shift:read", shift: "shift:create", cash: "drawer:create" },
jobs: [
{
// Runs the booth: sessions, payments, own shift + drawer, the live feed, devices.
id: "booth-operator",
permissions: [
"session:read", "session:create", "payment:read", "payment:create", "event:read",
"shift:read", "shift:create", "drawer:create", "device:read",
],
},
{
// Everything the operator has, plus what an operator must NOT: voids, every
// operator's shifts, drawer review, reports, subscriptions, tariff reading.
id: "booth-supervisor",
permissions: [
"session:read", "session:create", "payment:read", "payment:create", "event:read",
"shift:read", "shift:create", "drawer:create", "device:read",
"event:void", "shift:cash", "drawer:review", "report:read",
"subscription:read", "subscription:create", "subscription:update", "tariff:read", "validation:read",
],
},
],
},
{
// Merchant-scan ticket validation, kept for the Bar until a Bar module absorbs it
@@ -1832,6 +1876,9 @@ export const MODULES: readonly ModuleManifest[] = [
dependsOn: ["parking"],
resources: ["validation"],
ledgerEventTypes: ["validation"],
// A merchant's whole role: scan-and-validate, nothing else. Their validation events
// ride the booth log (event:read), so no feed permission of their own.
jobs: [{ id: "merchant", permissions: ["validation:create"] }],
},
{
// The pilot module. Depends on parking (the wash sits inside the park; the ticket
@@ -1844,10 +1891,44 @@ export const MODULES: readonly ModuleManifest[] = [
ledgerEventTypes: ["carwash_order", "carwash_payment"],
// Money taken AT THE BAY lands on the wash operator's own till, never the booth's.
till: "carwash",
tillPermission: "carwash:read",
tillGuards: { read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" },
feedPermission: "carwash:read",
jobs: [
// Runs the wash desk and its own till; sees nothing of the booth.
{ id: "wash-operator", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] },
],
},
];
/** The guards of a till (its module's `tillGuards`). */
export function tillGuards(till: TillId): TillGuards {
const m = MODULES.find((x) => x.till === till);
if (!m?.tillGuards) throw new Error(`till without guards: ${till}`);
return m.tillGuards;
}
/** Which permission admits a ledger event type to a role's live feed: the owning
* module's `feedPermission`, else the core `event:read`. */
export function feedPermissionFor(type: LedgerEventType): Permission {
const m = MODULES.find((x) => x.ledgerEventTypes.includes(type));
return m?.feedPermission ?? "event:read";
}
/** Every permission that admits a role to the live WebSocket at all (it then receives
* only what each permission covers): the core feed/occupancy/device permissions plus
* each effective module's own feed permission. `report:read` is NOT among them — the
* reports screen and the live feed are different things (user, 2026-09-05). */
export function watchPermissions(effective: readonly ModuleId[]): Permission[] {
const out = new Set<Permission>(["event:read", "session:read", "device:read"]);
for (const m of MODULES) if (m.feedPermission && effective.includes(m.id)) out.add(m.feedPermission);
return [...out];
}
/** Which tills a role may work more than one of — the composer's "mixes desks" lint. */
export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
return tillsFor(effective, has, "shift");
}
/** The tills available given the EFFECTIVE modules — the booth always (parking is
* required), plus each effective module's own till. Registry order. */
export function tillsOf(effective: readonly ModuleId[]): TillId[] {
@@ -1856,15 +1937,16 @@ export function tillsOf(effective: readonly ModuleId[]): TillId[] {
return TILL_IDS.filter((t) => out.has(t));
}
/** The tills a ROLE may work at this site: the effective tills whose module's
* `tillPermission` the role holds. What the shift/drawer routes enforce and what the
* UI offers (header button, start buttons, drawer switch). */
export function tillsFor(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
const site = tillsOf(effective);
return site.filter((t) => {
const m = MODULES.find((x) => x.till === t);
return !!m && (m.tillPermission ? has(m.tillPermission) : true);
});
/** The tills a ROLE may SEE (`kind` = read, default) or WORK (`shift` / `cash`) at
* this site: the effective tills whose module guard the role holds. What the
* shift/drawer routes enforce and what the UI offers (header button, start buttons,
* drawer switch). */
export function tillsFor(
effective: readonly ModuleId[],
has: (p: Permission) => boolean,
kind: keyof TillGuards = "read",
): TillId[] {
return tillsOf(effective).filter((t) => has(tillGuards(t)[kind]));
}
// --- Car Wash module ----------------------------------------------------------