feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed

Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix):

- `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from
  (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in
  @parking/shared surface a followed job whose bundle grew past the role in a later
  release; the roles list shows a "behind <job>" badge with a one-click "Update to job"
  (the union, nothing removed) and the editor lints it. Never a runtime union: the grid
  stays the explicit enforcement layer and an update never widens a role without a click.
- Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
  name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes
  now takes the ledger.
- booth-supervisor already carries subscription:*; the stale open note is closed.

Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-06 12:52:47 +02:00
parent e14e31a840
commit 50c18405b6
14 changed files with 329 additions and 17 deletions
+25
View File
@@ -1937,6 +1937,31 @@ export function tillOfEvent(type: LedgerEventType, payload: { till?: TillId } |
return m?.till ?? BOOTH_TILL;
}
/** A job preset by id, with the module that declares it (null = no such job — e.g. a
* job remembered by a role whose module was removed from the registry). */
export function jobById(id: string): { module: ModuleId; job: JobPreset } | null {
for (const m of MODULES) for (const job of m.jobs) if (job.id === id) return { module: m.id, job };
return null;
}
/** The jobs a role FOLLOWS whose bundle has grown past what the role holds: the role
* was built from the chip, a later release added a permission to the job, and the
* role fell behind. The admin re-applies with one click (or drops the job); the grid
* is never expanded silently. Jobs no longer in the registry are ignored. */
export function jobsBehind(
jobs: readonly string[],
has: (p: Permission) => boolean,
): { job: string; missing: Permission[] }[] {
const out: { job: string; missing: Permission[] }[] = [];
for (const id of jobs) {
const found = jobById(id);
if (!found) continue;
const missing = found.job.permissions.filter((p) => !has(p));
if (missing.length > 0) out.push({ job: id, missing });
}
return out;
}
export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
return tillsFor(effective, has, "shift");
}