feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
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:
+102
-20
@@ -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 ----------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user