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
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { jobsBehind } from "@parking/shared";
import { buildServer } from "../server.js";
import { login, seedUser } from "../test-helpers.js";
// Roles are data composed from the permission grid (venue-modules.md §Permissions
// matrix): every edit is SIGNED as a config_change, and a role remembers the manifest
// JOBS it was built from so a grown job can be surfaced and re-applied.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
type Auth = { cookie: string; csrf: string };
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
async function admin(): Promise<Auth> {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
return login(app, username, password);
}
async function roleChanges(a: Auth) {
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).filter(
(e) => e.type === "config_change" && String(e.payload.setting).startsWith("role."),
);
}
describe("role edits are signed and jobs are remembered", () => {
it("create / update / delete each sign one config_change with prev + value + operator; a no-op resave signs nothing", async () => {
const a = await admin();
const created = await app.inject({
method: "POST", url: "/api/roles", headers: hdrs(a),
payload: { name: "Lavazh", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
});
expect(created.statusCode).toBe(201);
const role = created.json();
expect(role.jobs).toEqual(["wash-operator"]);
let evs = await roleChanges(a);
expect(evs).toHaveLength(1);
expect(evs[0]!.payload).toMatchObject({
setting: `role.${role.id}`, prev: null, operator: "boss",
value: { name: "Lavazh", jobs: ["wash-operator"] },
});
expect((evs[0]!.payload.value as { permissions: string[] }).permissions).toEqual(["carwash:cash", "carwash:create", "carwash:read", "carwash:update"]);
// Same content again → nothing new on the chain.
const same = await app.inject({
method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a),
payload: { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
});
expect(same.statusCode).toBe(200);
expect(await roleChanges(a)).toHaveLength(1);
// A real change: prev is the old shape, value the new.
const renamed = await app.inject({ method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a), payload: { name: "Lavazh NEW" } });
expect(renamed.statusCode).toBe(200);
evs = await roleChanges(a);
expect(evs).toHaveLength(2);
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh" }, value: { name: "Lavazh NEW" } });
const gone = await app.inject({ method: "DELETE", url: `/api/roles/${role.id}`, headers: hdrs(a) });
expect(gone.statusCode).toBe(200);
evs = await roleChanges(a);
expect(evs).toHaveLength(3);
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh NEW" }, value: null });
});
it("unknown jobs are refused; a role built from a job that later grew reports what it is missing", async () => {
const a = await admin();
const bad = await app.inject({ method: "POST", url: "/api/roles", headers: hdrs(a), payload: { name: "X", permissions: [], jobs: ["bar-tender"] } });
expect(bad.statusCode).toBe(400);
// Compose "behind": the role follows wash-operator but holds only part of today's bundle
// — exactly what an older release's chip would have left once the job grew.
const r = (await app.inject({
method: "POST", url: "/api/roles", headers: hdrs(a),
payload: { name: "Old wash", permissions: ["carwash:read", "carwash:create"], jobs: ["wash-operator"] },
})).json();
const view = (await app.inject({ method: "GET", url: "/api/roles", headers: { cookie: a.cookie } })).json().roles.find((x: { id: string }) => x.id === r.id);
const has = new Set<string>(view.permissions);
expect(jobsBehind(view.jobs, (p) => has.has(p))).toEqual([{ job: "wash-operator", missing: ["carwash:update", "carwash:cash"] }]);
// Re-apply = the union; then nothing is behind.
const fixed = (await app.inject({
method: "PUT", url: `/api/roles/${r.id}`, headers: hdrs(a),
payload: { permissions: [...has, "carwash:update", "carwash:cash"] },
})).json();
const has2 = new Set<string>(fixed.permissions);
expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]);
});
});
+74 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { softDelete } from "../recycle-bin.js";
// Role management (admin). Roles are DATA: an admin composes a role from the
@@ -18,14 +19,30 @@ import { softDelete } from "../recycle-bin.js";
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
// only put permissions they ALREADY hold onto a role. An admin (full set) is
// unrestricted, which is the intended behaviour.
//
// EVERY role edit is SIGNED on the ledger as a `config_change` (setting `role.<id>`,
// value/prev = the role's name + permissions + jobs, operator = who) — a role edit is a
// privilege change, and under this threat model the only setting an admin could alter
// without a trace. A role also REMEMBERS the manifest JOBS it was composed from
// (role_jobs) so a later release that grows a job's bundle can be surfaced and
// re-applied — the grid is never expanded silently (venue-modules.md §Permissions matrix).
interface RoleBody {
name: string;
permissions: string[];
jobs?: string[];
}
interface UpdateBody {
name?: string;
permissions?: string[];
jobs?: string[];
}
/** What a signed role change records (before/after). */
interface RoleShape {
name: string;
permissions: Permission[];
jobs: string[];
}
const VALID = new Set<string>(PERMISSIONS);
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
return { ok: true, perms: [...out] };
}
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
/** Validate + dedupe a requested job list against the registry's job presets. */
function cleanJobs(input: unknown): { ok: true; jobs: string[] } | { ok: false; bad: string } {
if (input == null) return { ok: true, jobs: [] };
if (!Array.isArray(input)) return { ok: false, bad: "jobs must be an array" };
const out = new Set<string>();
for (const j of input) {
if (typeof j !== "string" || !jobById(j)) return { ok: false, bad: `unknown job: ${String(j)}` };
out.add(j);
}
return { ok: true, jobs: [...out] };
}
export async function roleRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog): Promise<void> {
const readGuard = requirePermission("role:read");
const createGuard = requirePermission("role:create");
const updateGuard = requirePermission("role:update");
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
name: role.name,
builtin: role.builtin === 1,
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
jobs: jobsOf(roleId),
userCount,
};
}
function jobsOf(roleId: string): string[] {
return db.select({ jobId: roleJobs.jobId }).from(roleJobs).where(eq(roleJobs.roleId, roleId)).all().map((r) => r.jobId).sort();
}
/** The role as the ledger records it (sorted so two identical shapes compare equal). */
function shapeOf(roleId: string): RoleShape | null {
const v = roleView(roleId);
if (!v) return null;
return { name: v.name, permissions: [...v.permissions].sort() as Permission[], jobs: v.jobs };
}
/** Replace a role's remembered jobs. */
function setJobs(roleId: string, jobs: string[]): void {
db.delete(roleJobs).where(eq(roleJobs.roleId, roleId)).run();
for (const jobId of jobs) db.insert(roleJobs).values({ roleId, jobId }).run();
}
/** Sign a role change. `prev` null = created; `value` null = deleted. Skipped when
* nothing changed (a no-op resave leaves no trace, like the site-config flips). */
async function signRoleChange(req: { user?: { username?: string } }, roleId: string, prev: RoleShape | null, value: RoleShape | null): Promise<void> {
if (JSON.stringify(prev) === JSON.stringify(value)) return;
await eventLog?.append({
type: "config_change",
source: "manual",
payload: { setting: `role.${roleId}`, value, prev, operator: req.user?.username ?? "unknown" },
});
}
/** Replace a role's permission rows with `perms` (in a single pass). */
function setPermissions(roleId: string, perms: Permission[]): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
@@ -104,13 +162,17 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
const cleaned = cleanPermissions(req.body?.permissions ?? []);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
const jobs = cleanJobs(req.body?.jobs);
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
const over = escalates(req.user.roleId, cleaned.perms);
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run();
setPermissions(id, cleaned.perms);
setJobs(id, jobs.jobs);
bumpPermsCache();
await signRoleChange(req, id, null, shapeOf(id));
return reply.code(201).send(roleView(id));
});
@@ -125,6 +187,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
}
const prev = shapeOf(id);
if (req.body?.name != null) {
const name = req.body.name.trim();
@@ -140,7 +203,13 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
setPermissions(id, cleaned.perms);
}
if (req.body?.jobs != null) {
const jobs = cleanJobs(req.body.jobs);
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
setJobs(id, jobs.jobs);
}
bumpPermsCache();
await signRoleChange(req, id, prev, shapeOf(id));
return roleView(id);
},
);
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
}
const prev = shapeOf(id);
softDelete(db, "role", id, req.user.sub);
bumpPermsCache();
await signRoleChange(req, id, prev, null);
return { ok: true };
},
);
+1 -1
View File
@@ -117,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// RBAC administration: compose roles (role:*) + manage users (user:*). The
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
await userRoutes(app, db);
await roleRoutes(app, db);
// Vision (ANPR) client — built early so the device monitor can include the vision
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
@@ -144,6 +143,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
await setupRoutes(app, db, visionClient, eventLog);
await roleRoutes(app, db, eventLog);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from
+49 -8
View File
@@ -13,7 +13,7 @@ import {
type SessionUser,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
import { MODULES, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared";
import { MODULES, jobsBehind, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared";
// Role management (admin). Compose a role from the permission grid (a checkbox
// matrix of resource × action) and name it; users are then assigned a role. The
@@ -27,6 +27,11 @@ import { MODULES, tillsFor, type JobPreset, type ModuleId, type TillId } from "@
// fine-tune + enforcement layer. The editor LINTS the result (warnings, never blocks):
// "mixes desks" (may open more than one till) and "partial job" (holds a module's read
// permission but not the rest of its job — a desk that can look but not act).
//
// A role REMEMBERS the jobs it follows (chips on at save, or bundles fully present). When
// a later release grows a job, the role shows as "behind" it — in the list (with a
// one-click re-apply) and in the editor — instead of silently falling short the way the
// wash operator's price list did (2026-09-06). Every save is signed on the ledger.
/** Group "resource:action" permissions by resource for the grid rows. */
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
@@ -111,8 +116,17 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
<span className="text-[0.6875rem] text-term-muted">
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span>
{behindOf(r).map((b) => (
<span key={b.job} className="rounded-term border border-term-amber/60 px-1.5 py-0.5 text-[0.625rem] text-term-amber" title={b.missing.join(", ")}>
{t("roles.behind", { job: t(`jobs.${b.job}`) })}
</span>
))}
</div>
<div className="flex gap-2">
{canUpdate && !r.builtin && behindOf(r).length > 0 && (
<button type="button" className="btn btn-primary btn-sm" title={behindOf(r).flatMap((b) => b.missing).join(", ")}
onClick={() => reapply(r, invalidate, onError)}>{t("roles.reapply")}</button>
)}
{canUpdate && !r.builtin && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
)}
@@ -133,15 +147,30 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown)
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
}
/** The jobs a role follows that have grown past it (this release's bundles). */
function behindOf(r: ManagedRole): { job: string; missing: Permission[] }[] {
const has = new Set(r.permissions);
return jobsBehind(r.jobs ?? [], (p) => has.has(p));
}
/** Re-apply = add what the followed jobs now carry. Nothing is removed; the save is
* signed like any other role edit. */
async function reapply(r: ManagedRole, ok: () => void, onError: (e: unknown) => void) {
const missing = behindOf(r).flatMap((b) => b.missing);
try { await updateRole(r.id, { permissions: [...new Set([...r.permissions, ...missing])] }); ok(); } catch (e) { onError(e); }
}
/** The jobs the composer offers: every effective module's, in registry order. */
function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] {
return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job })));
}
/** Composer lints — warnings about what the admin just composed. */
function lintRole(perms: Set<Permission>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
function lintRole(perms: Set<Permission>, jobs: Set<string>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
const has = (p: Permission) => perms.has(p);
const out: { key: string; vars?: Record<string, string> }[] = [];
// Behind a job it follows: the bundle grew (a newer release) past what the role holds.
for (const b of jobsBehind([...jobs], has)) out.push({ key: "roles.lintJobBehind", vars: { job: b.job, missing: b.missing.join(", ") } });
// Mixes desks: may OPEN more than one till.
const workable: TillId[] = tillsFor(effective, has, "shift");
if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } });
@@ -165,11 +194,14 @@ function RoleEditor({
grouped: Record<string, Permission[]>;
effective: readonly ModuleId[];
onCancel: () => void;
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
onSubmit: (v: { name: string; permissions: Permission[]; jobs: string[] }) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(role?.name ?? "");
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? []));
// The jobs this role follows: what was remembered, plus (at save) any bundle that is
// fully present — so a role composed before jobs were remembered picks them up.
const [jobIds, setJobIds] = useState<Set<string>>(new Set(role?.jobs ?? []));
const toggle = (p: Permission) =>
setPerms((prev) => {
const next = new Set(prev);
@@ -177,15 +209,24 @@ function RoleEditor({
return next;
});
const jobs = useMemo(() => jobsFor(effective), [effective]);
const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
const toggleJob = (job: JobPreset) =>
const complete = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
const jobOn = (job: JobPreset) => jobIds.has(job.id) || complete(job);
const toggleJob = (job: JobPreset) => {
const on = jobOn(job);
setJobIds((prev) => {
const next = new Set(prev);
on ? next.delete(job.id) : next.add(job.id);
return next;
});
setPerms((prev) => {
const next = new Set(prev);
if (job.permissions.every((p) => prev.has(p))) for (const p of job.permissions) next.delete(p);
if (on) for (const p of job.permissions) next.delete(p);
else for (const p of job.permissions) next.add(p);
return next;
});
const lints = useMemo(() => lintRole(perms, effective), [perms, effective]);
};
const lints = useMemo(() => lintRole(perms, jobIds, effective), [perms, jobIds, effective]);
const followed = () => jobs.filter(({ job }) => jobIds.has(job.id) || complete(job)).map(({ job }) => job.id);
const valid = name.trim().length > 0;
@@ -244,7 +285,7 @@ function RoleEditor({
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}>{t("common.save")}</button>
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms], jobs: followed() })}>{t("common.save")}</button>
</div>
</div>
);
+5 -2
View File
@@ -213,6 +213,9 @@ export interface ManagedRole {
name: string;
builtin: boolean;
permissions: Permission[];
/** The manifest JOBS this role follows (composed from their chips). A later release
* that grows a job shows the role as "behind" it — re-applied with one click. */
jobs: string[];
userCount: number;
}
@@ -241,10 +244,10 @@ export function deleteUser(id: string): Promise<{ ok: boolean }> {
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
return apiFetch("/api/roles");
}
export function createRole(body: { name: string; permissions: Permission[] }): Promise<ManagedRole> {
export function createRole(body: { name: string; permissions: Permission[]; jobs?: string[] }): Promise<ManagedRole> {
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
}
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
export function updateRole(id: string, body: { name?: string; permissions?: Permission[]; jobs?: string[] }): Promise<ManagedRole> {
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function deleteRole(id: string): Promise<{ ok: boolean }> {
+3
View File
@@ -917,6 +917,9 @@ export const en: Catalog = {
jobsHint: "A job adds its permissions in one click; fine-tune below. Tap it again to remove them.",
lintMixedTills: "This role can open more than one till ({{tills}}) — one person, two drawers. Intended?",
lintPartialJob: "Partial \"{{job}}\": missing {{missing}} — this desk can look but not act.",
lintJobBehind: "Behind \"{{job}}\": this release added {{missing}} to the job. Tap the job chip off and on to take it, or tick it below.",
behind: "behind {{job}}",
reapply: "Update to job",
},
jobs: {
"booth-operator": "Booth operator",
+3
View File
@@ -931,6 +931,9 @@ export const sq = {
jobsHint: "Një punë shton lejet e saj me një klik; rregulloji poshtë. Kliko sërish për t'i hequr.",
lintMixedTills: "Ky rol mund të hapë më shumë se një arkë ({{tills}}) — një person, dy arka. E qëllimshme?",
lintPartialJob: "\"{{job}}\" e pjesshme: mungojnë {{missing}} — kjo tavolinë sheh, por nuk vepron.",
lintJobBehind: "Pas \"{{job}}\": ky version i shtoi punës {{missing}}. Hiqe dhe rivendose punën për t'i marrë, ose shënoji poshtë.",
behind: "pas {{job}}",
reapply: "Përditëso sipas punës",
},
jobs: {
"booth-operator": "Operator kabine",
+10
View File
@@ -0,0 +1,10 @@
-- Roles remember the manifest JOBS they were composed from (venue-modules.md §"Permissions
-- matrix", move 2) so a role built from a job chip can be flagged and re-applied when a
-- later release grows that job's bundle. The permission grid stays the enforcement layer.
CREATE TABLE `role_jobs` (
`role_id` text NOT NULL,
`job_id` text NOT NULL,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `role_jobs_role_id_job_id_unique` ON `role_jobs` (`role_id`,`job_id`);
+7
View File
@@ -204,6 +204,13 @@
"when": 1788605000000,
"tag": "0028_carwash_config",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1788690000000,
"tag": "0029_role_jobs",
"breakpoints": true
}
]
}
+18
View File
@@ -53,6 +53,24 @@ export const rolePermissions = sqliteTable(
}),
);
/** The JOBS a role follows (venue-modules.md §"Permissions matrix", move 2): the
* manifest job presets the admin composed it from. Remembered so a later release that
* grows a job's bundle can be surfaced ("this role is behind the Wash operator job")
* and re-applied with one click — never expanded silently at runtime: what a role may
* do is always the explicit `role_permissions` grid. */
export const roleJobs = sqliteTable(
"role_jobs",
{
roleId: text("role_id")
.notNull()
.references(() => roles.id),
jobId: text("job_id").notNull(),
},
(t) => ({
uniq: unique().on(t.roleId, t.jobId),
}),
);
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull().unique(),
+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");
}
+17 -3
View File
@@ -439,9 +439,23 @@ resources — the till already IS that copy. Role *templates stored in the DB*
(they change with the module), roles are data; keep that line.
**Status.** Moves 1, 2 and 3 built 2026-09-05 (see the Tills as-built below and [[shift]]
§Tills). Open: whether `booth-supervisor` should carry `subscription:*` by default; whether a
job should be *re-applicable* after a module update (today a chip only adds/removes the bundle
as it is now); an audit `config_change` on role edits.
§Tills). The three loose ends closed 2026-09-06:
- `booth-supervisor` DOES carry `subscription:read/create/update` (plus `tariff:read`,
`validation:read`) — it already did; the note was stale. Decided: a supervisor sells and
edits subscriptions by default.
- **Jobs are remembered and re-appliable.** A role stores the jobs it follows (`role_jobs`:
the chips on at save, plus any bundle fully present). `jobsBehind(jobs, has)` in
`@parking/shared` lists a followed job whose bundle has GROWN past the role (a newer
release added a permission); the roles list shows a "behind <job>" badge with a one-click
"Update to job" (the union; nothing removed), the editor lints it. Deliberately NOT a
runtime union: what a role may do is always the explicit grid, and a software update never
changes it without an admin's click — see the threat model. The first failure of this kind
was the wash operator's empty price list (the settings read needed `site:read`; now
`carwash:read` OR `site:read`, `requireAnyPermission`).
- **Role edits are signed.** Create/update/delete each append one `config_change`
(`setting: role.<id>`, `value`/`prev` = name + sorted permissions + jobs, `operator`); a
no-op resave signs nothing. A role edit is a privilege change and was the one setting an
admin could alter without a trace.
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
+5
View File
@@ -37,6 +37,11 @@ Authentication and authorization, kept **fully local** — a direct consequence
`bumpPermsCache()`, which user update/delete now call), so REASSIGNING a user's role — or deleting
the user (→ 401 on their next request) — applies immediately too. Found when a user moved to a new
wash role kept the old role's rights until logout.
**Role edits are signed (2026-09-06):** every create/update/delete of a role appends a
`config_change` (`role.<id>`, before/after shape, operator) to the ledger, and a role remembers
the manifest JOBS it was composed from (`role_jobs`) so a job that grows in a later release can
be re-applied with one click rather than expanding silently — [[venue-modules]] §Permissions
matrix.
- **Protected built-in `admin` role** (`id='admin'`, `builtin=1`): non-editable, non-deletable, and
always resolves to the FULL permission set in code. The app refuses to delete or downgrade the
**last user holding admin** — administration can never be locked out of the appliance.
+10
View File
@@ -3070,3 +3070,13 @@ category/service pickers stayed empty. Cause: `GET /api/carwash/settings` was gu
`site:read` only — the price list is Setup's data AND the desk's working data. Fixed with a
new `requireAnyPermission(...)` guard (auth.ts): the read opens to `carwash:read` OR
`site:read`; the write stays `site:update`. Regression test in carwash.test.ts.
## [2026-09-06] ingest | Permissions matrix loose ends: jobs remembered + re-appliable, role edits signed
Roles now store the jobs they follow (`role_jobs`, migration 0029); `jobsBehind()` in
`@parking/shared` surfaces a followed job whose bundle grew past the role; the roles list shows a
"behind <job>" badge + "Update to job" (union, nothing removed) and the editor lints it. Not a
runtime union by decision (the grid stays explicit; an update never widens a role without a
click). Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
name + permissions + jobs, operator); a no-op resave signs nothing. The stale "should
booth-supervisor carry subscription:*" note is closed — it already does. Tests: routes/roles.test.ts.
Updated [[venue-modules]] §Permissions matrix status, [[local-jwt-auth]].