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 { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db"; import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared"; import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js"; import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { softDelete } from "../recycle-bin.js"; import { softDelete } from "../recycle-bin.js";
// Role management (admin). Roles are DATA: an admin composes a role from the // 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 // 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 // only put permissions they ALREADY hold onto a role. An admin (full set) is
// unrestricted, which is the intended behaviour. // 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 { interface RoleBody {
name: string; name: string;
permissions: string[]; permissions: string[];
jobs?: string[];
} }
interface UpdateBody { interface UpdateBody {
name?: string; name?: string;
permissions?: 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); const VALID = new Set<string>(PERMISSIONS);
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
return { ok: true, perms: [...out] }; 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 readGuard = requirePermission("role:read");
const createGuard = requirePermission("role:create"); const createGuard = requirePermission("role:create");
const updateGuard = requirePermission("role:update"); const updateGuard = requirePermission("role:update");
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
name: role.name, name: role.name,
builtin: role.builtin === 1, builtin: role.builtin === 1,
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms, permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
jobs: jobsOf(roleId),
userCount, 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). */ /** Replace a role's permission rows with `perms` (in a single pass). */
function setPermissions(roleId: string, perms: Permission[]): void { function setPermissions(roleId: string, perms: Permission[]): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run(); 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 ?? []); const cleaned = cleanPermissions(req.body?.permissions ?? []);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad }); 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); 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}` }); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
const id = randomUUID(); const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run(); db.insert(roles).values({ id, name, builtin: 0 }).run();
setPermissions(id, cleaned.perms); setPermissions(id, cleaned.perms);
setJobs(id, jobs.jobs);
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, null, shapeOf(id));
return reply.code(201).send(roleView(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) { if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be edited" }); return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
} }
const prev = shapeOf(id);
if (req.body?.name != null) { if (req.body?.name != null) {
const name = req.body.name.trim(); 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}` }); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
setPermissions(id, cleaned.perms); 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(); bumpPermsCache();
await signRoleChange(req, id, prev, shapeOf(id));
return roleView(id); return roleView(id);
}, },
); );
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (holders > 0) { if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` }); 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); softDelete(db, "role", id, req.user.sub);
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, prev, null);
return { ok: true }; 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 // 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. // built-in admin role is protected; the last admin can't be removed. See auth.ts.
await userRoutes(app, db); await userRoutes(app, db);
await roleRoutes(app, db);
// Vision (ANPR) client — built early so the device monitor can include the vision // 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 // 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. // 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. // See wiki/concepts/first-run-setup.md, entry-exit-points.md.
await setupRoutes(app, db, visionClient, eventLog); await setupRoutes(app, db, visionClient, eventLog);
await roleRoutes(app, db, eventLog);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events), // Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from // guarded by source-IP allowlist + a shared-secret path token, both read from
+49 -8
View File
@@ -13,7 +13,7 @@ import {
type SessionUser, type SessionUser,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.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 // 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 // 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): // 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 // "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). // 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. */ /** Group "resource:action" permissions by resource for the grid rows. */
function groupByResource(perms: Permission[]): Record<string, Permission[]> { 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"> <span className="text-[0.6875rem] text-term-muted">
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })} {t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span> </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>
<div className="flex gap-2"> <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 && ( {canUpdate && !r.builtin && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button> <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); } 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. */ /** The jobs the composer offers: every effective module's, in registry order. */
function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] { 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 }))); 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. */ /** 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 has = (p: Permission) => perms.has(p);
const out: { key: string; vars?: Record<string, string> }[] = []; 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. // Mixes desks: may OPEN more than one till.
const workable: TillId[] = tillsFor(effective, has, "shift"); const workable: TillId[] = tillsFor(effective, has, "shift");
if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } }); if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } });
@@ -165,11 +194,14 @@ function RoleEditor({
grouped: Record<string, Permission[]>; grouped: Record<string, Permission[]>;
effective: readonly ModuleId[]; effective: readonly ModuleId[];
onCancel: () => void; onCancel: () => void;
onSubmit: (v: { name: string; permissions: Permission[] }) => void; onSubmit: (v: { name: string; permissions: Permission[]; jobs: string[] }) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(role?.name ?? ""); const [name, setName] = useState(role?.name ?? "");
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? [])); 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) => const toggle = (p: Permission) =>
setPerms((prev) => { setPerms((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -177,15 +209,24 @@ function RoleEditor({
return next; return next;
}); });
const jobs = useMemo(() => jobsFor(effective), [effective]); const jobs = useMemo(() => jobsFor(effective), [effective]);
const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p)); const complete = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
const toggleJob = (job: JobPreset) => 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) => { setPerms((prev) => {
const next = new Set(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); else for (const p of job.permissions) next.add(p);
return next; 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; const valid = name.trim().length > 0;
@@ -244,7 +285,7 @@ function RoleEditor({
<div className="mt-3 flex justify-end gap-2"> <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-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>
</div> </div>
); );
+5 -2
View File
@@ -213,6 +213,9 @@ export interface ManagedRole {
name: string; name: string;
builtin: boolean; builtin: boolean;
permissions: Permission[]; 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; userCount: number;
} }
@@ -241,10 +244,10 @@ export function deleteUser(id: string): Promise<{ ok: boolean }> {
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> { export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
return apiFetch("/api/roles"); 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) }); 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) }); return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
} }
export function deleteRole(id: string): Promise<{ ok: boolean }> { 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.", 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?", 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.", 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: { jobs: {
"booth-operator": "Booth operator", "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.", 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?", 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.", 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: { jobs: {
"booth-operator": "Operator kabine", "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, "when": 1788605000000,
"tag": "0028_carwash_config", "tag": "0028_carwash_config",
"breakpoints": true "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", { export const users = sqliteTable("users", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
username: text("username").notNull().unique(), 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; 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[] { export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
return tillsFor(effective, has, "shift"); 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. (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]] **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 §Tills). The three loose ends closed 2026-09-06:
job should be *re-applicable* after a module update (today a chip only adds/removes the bundle - `booth-supervisor` DOES carry `subscription:read/create/update` (plus `tariff:read`,
as it is now); an audit `config_change` on role edits. `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) ## 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 `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 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. 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 - **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 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. **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 `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 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. `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]].