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
+40 -37
View File
@@ -1,23 +1,26 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { requirePermission, roleHasPermissions } from "../auth.js";
import { accessibleTillsFor, parseTill } from "../modules.js";
import type { TillId } from "@parking/shared";
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
// only their own; reviewers see all + can filter status.
// - POST /api/drawer/movement : operator records a cash_in/cash_out on a till.
// Guard = the till's `cash` (booth drawer:create,
// wash carwash:cash).
// - GET /api/drawer/movements: list with review status, over the tills the role may
// read (own movements); reviewers (drawer:review) see all
// tills + all operators and can filter status.
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
// payments + vouchers over the whole chain — the
// amount that carries across shifts).
// - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read).
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
// judgment about the operator settled outside the app, never a cash reversal.
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); the
// balance and the list take a `till` filter. See wiki/concepts/shift.md "Tills".
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each
// desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions
// matrix").
interface MovementBody {
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
@@ -42,27 +45,19 @@ interface ReviewBody {
interface MovementsQuery {
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
status?: MovementStatus;
/** Filter to one till; absent = every till. */
/** Filter to one till; absent = every till the role may read (reviewers: every till). */
till?: string;
}
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
const createGuard = requirePermission("drawer:create");
const reviewGuard = requirePermission("drawer:review");
const readGuard = requirePermission("shift:read");
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, async (req, reply) => {
const b = req.body ?? ({} as MovementBody);
if (b.type !== "cash_in" && b.type !== "cash_out") {
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
}
const till = parseTill(db, b.till);
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
// Moving a till's cash needs that till's module permission (see routes/shift.ts).
if (!accessibleTillsFor(db, req.user.roleId).includes(till)) {
return reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
}
try {
return await shift.recordVoucher({
type: b.type,
@@ -70,7 +65,7 @@ export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftSer
amountMinor: b.amountMinor,
reason: b.reason ?? "",
currency: b.currency,
till,
till: req.till!,
});
} catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
@@ -78,29 +73,37 @@ export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftSer
}
});
// List movements + review status. Operators are hard-scoped to their OWN movements; a
// reviewer sees ALL and may filter by status (the pending review queue).
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req, reply) => {
// List movements + review status. Operators are hard-scoped to their OWN movements on
// the tills they may read; a reviewer sees ALL and may filter by status (the pending
// review queue).
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => {
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
const readable = tillsReadableBy(db, req.user.roleId);
if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
const q = req.query ?? {};
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
const till = q.till?.trim() ? parseTill(db, q.till.trim()) : undefined;
if (till === null) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
const movements = shift.movementsWithStatus({
operator: canReview ? undefined : req.user.username,
status,
till,
});
let tills: TillId[] | undefined = canReview ? undefined : readable;
if (q.till?.trim()) {
const parsed = parseTill(db, q.till.trim());
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
if (!canReview && !readable.includes(parsed)) {
return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed });
}
tills = [parsed];
}
const operator = canReview ? undefined : req.user.username;
const movements = tills
? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
: shift.movementsWithStatus({ operator, status });
return { movements, scope: canReview ? "all" : "self" };
});
// A till's physical drawer balance now. Same visibility as the open shift's X-report
// (shift:read) — a drawer is a shared till, not per-operator data.
app.get<{ Querystring: { till?: string } }>("/api/drawer/balance", { preHandler: readGuard }, async (req, reply) => {
const till = parseTill(db, req.query?.till);
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
return { till, ...shift.drawerBalance(till) };
});
// (the till's read guard) — a drawer is a shared till, not per-operator data.
app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
till: req.till!,
...shift.drawerBalance(req.till!),
}));
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {