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
+36 -3
View File
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify"; import type { FastifyReply, FastifyRequest } from "fastify";
import { eq, rolePermissions, type Db } from "@parking/db"; import { eq, rolePermissions, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared"; import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
// Local JWT auth helpers — fully local, no external identity provider // Local JWT auth helpers — fully local, no external identity provider
@@ -143,10 +143,41 @@ export function initAuth(db: Db): void {
permsCache.clear(); permsCache.clear();
} }
/** Clear the permission cache. Call after ANY write to roles / role_permissions /** Clear the permission + role caches. Call after ANY write to roles / role_permissions
* (or a user's roleId) so the change takes effect on the next request. */ * or to a user's roleId / deletion, so the change takes effect on the next request. */
export function bumpPermsCache(): void { export function bumpPermsCache(): void {
permsCache.clear(); permsCache.clear();
roleCache.clear();
}
/** userId → CURRENT roleId, cached until bumpPermsCache(). */
const roleCache = new Map<string, string | null>();
/** The user's CURRENT role. The token pins the roleId that was current at LOGIN; an
* admin reassigning a user's role (or deleting the user) must take effect on the next
* request exactly like editing a role does — otherwise the reassigned user keeps the
* old role's rights until they log out (found 2026-09-05: a user moved to a new
* wash role kept 403ing on the new role's permissions). null = the user is gone. */
export function currentRoleId(sub: string): string | null {
if (!authDb) throw new Error("auth not initialised (call initAuth)");
const hit = roleCache.get(sub);
if (hit !== undefined) return hit;
const row = authDb
.select({ roleId: users.roleId, deletedAt: users.deletedAt })
.from(users)
.where(eq(users.id, sub))
.get();
const roleId = row && row.deletedAt == null ? row.roleId : null;
roleCache.set(sub, roleId);
return roleId;
}
/** After jwtVerify: replace the token's pinned roleId with the user's current one, or
* end the session if the user no longer exists. */
function refreshRole(req: FastifyRequest): void {
const roleId = currentRoleId(req.user.sub);
if (roleId === null) throw Object.assign(new Error("session no longer valid"), { statusCode: 401 });
if (roleId !== req.user.roleId) req.user.roleId = roleId;
} }
/** The permission set for a role id, cached. `admin` is always the full set. */ /** The permission set for a role id, cached. `admin` is always the full set. */
@@ -184,6 +215,7 @@ export function requirePermission(...required: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => { return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify(); // reads the token cookie (configured in server.ts) await req.jwtVerify(); // reads the token cookie (configured in server.ts)
assertCsrf(req); assertCsrf(req);
refreshRole(req);
if (!req.user || !roleHasPermissions(req.user.roleId, required)) { if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 }); throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
@@ -201,4 +233,5 @@ export async function requireAuth(
): Promise<void> { ): Promise<void> {
await req.jwtVerify(); await req.jwtVerify();
assertCsrf(req); assertCsrf(req);
refreshRole(req);
} }
+27
View File
@@ -176,3 +176,30 @@ describe("entitlement (vendor env)", () => {
expect(cfg.json().modules).toEqual(["parking", "validation"]); expect(cfg.json().modules).toEqual(["parking", "validation"]);
}); });
}); });
describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => {
const shared = await import("@parking/shared");
it("each till is guarded by its own module's permissions", () => {
expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" });
expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" });
const wash = new Set(["carwash:read", "carwash:cash"]);
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]);
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]);
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]);
// Module off → its till is not even addressable.
expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]);
});
it("the live feed admits by watch permission and filters ledger events by their module", () => {
expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual(
expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]),
);
expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read");
expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read");
expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read");
expect(shared.feedPermissionFor("payment")).toBe("event:read");
expect(shared.feedPermissionFor("validation")).toBe("event:read");
});
it("every job's permissions exist in the grid", () => {
for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p);
});
});
+40 -5
View File
@@ -5,12 +5,21 @@ import {
isModuleId, isModuleId,
isTillId, isTillId,
parseEntitledModules, parseEntitledModules,
tillGuards,
tillsFor, tillsFor,
tillsOf, tillsOf,
type ModuleId, type ModuleId,
type TillGuards,
type TillId, type TillId,
} from "@parking/shared"; } from "@parking/shared";
import { roleHasPermissions } from "./auth.js"; import { requireAuth, roleHasPermissions } from "./auth.js";
declare module "fastify" {
interface FastifyRequest {
/** Set by requireTill(): the till this request addresses (already authorized). */
till?: TillId;
}
}
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in // Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
// @parking/shared; design in wiki/decisions/venue-modules.md). // @parking/shared; design in wiki/decisions/venue-modules.md).
@@ -57,10 +66,36 @@ export function effectiveTillsFor(db: Db): TillId[] {
return tillsOf(effectiveModulesFor(db)); return tillsOf(effectiveModulesFor(db));
} }
/** The tills a role may WORK here (open/close its shift, move its cash): effective /** The tills a role may SEE (default) or WORK (`shift` / `cash`) here: the effective
* tills whose module permission the role holds. */ * tills whose module guard the role holds (each desk's money is guarded by that desk's
export function accessibleTillsFor(db: Db, roleId: string): TillId[] { * own permissions — venue-modules.md §"Permissions matrix"). */
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p])); export function tillsReadableBy(db: Db, roleId: string, kind: keyof TillGuards = "read"): TillId[] {
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]), kind);
}
/** preHandler factory for the shift/drawer routes: authenticate, parse the `till`
* (query on GET, body on POST; absent = booth; 400 `bad_till` when unknown or its
* module is off), then require the role to hold THAT TILL's guard for `kind` (403
* `till_forbidden`). The authorized till lands on `req.till`. The permission is thus
* resolved from the till, never fixed: the booth checks `shift:read`/`shift:create`/
* `drawer:create`, the wash `carwash:read`/`carwash:cash`. */
export function requireTill(db: Db, kind: keyof TillGuards, from: "query" | "body") {
return async (req: FastifyRequest, reply: FastifyReply): Promise<void | FastifyReply> => {
await requireAuth(req, reply);
const raw = from === "query" ? (req.query as { till?: unknown } | undefined)?.till : (req.body as { till?: unknown } | undefined)?.till;
const till = parseTill(db, raw);
if (!till) {
await reply.code(400).send({ error: "unknown till", code: "bad_till" });
return reply;
}
if (!roleHasPermissions(req.user.roleId, [tillGuards(till)[kind]])) {
await reply
.code(403)
.send({ error: `your role cannot ${kind === "read" ? "see" : "work"} the ${till} till`, code: "till_forbidden", till });
return reply;
}
req.till = till;
};
} }
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till /** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
@@ -422,15 +422,17 @@ describe("tills are gated by the module permission", () => {
const a = await admin(); const a = await admin();
seedTariff(db); seedTariff(db);
await seedSettings(a); await seedSettings(a);
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
const washer = await seedUser(db, { const washer = await seedUser(db, {
username: "lavazhier", roleId: "washer", username: "lavazhier", roleId: "washer",
permissions: ["carwash:read", "carwash:create", "carwash:update", "shift:read", "shift:create", "drawer:create"], permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
}); });
const w = await login(app, washer.username, washer.password); const w = await login(app, washer.username, washer.password);
// What the UI offers: only the wash till. // What the UI offers: only the wash till.
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } }); const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]); expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
// The booth's shift is refused outright (the role lacks session:read). // The booth's shift is refused outright (the role holds no shift:*).
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) }); const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
expect(booth.statusCode).toBe(403); expect(booth.statusCode).toBe(403);
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" }); expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
@@ -445,7 +447,15 @@ describe("tills are gated by the module permission", () => {
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } }); const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
expect(washCash.statusCode).toBe(200); expect(washCash.statusCode).toBe(200);
// A booth operator (session:read, no carwash:read) cannot touch the wash till. // A wash user who may look (carwash:read) but not work the till (no carwash:cash)
// sees the state and gets canWork=false; opening is refused.
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
const l = await login(app, looker.username, looker.password);
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
const booth1 = await seedUser(db, { const booth1 = await seedUser(db, {
username: "boothie", roleId: "booth-op", username: "boothie", roleId: "booth-op",
permissions: ["session:read", "payment:create", "shift:read", "shift:create"], permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
@@ -456,3 +466,29 @@ describe("tills are gated by the module permission", () => {
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]); expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
}); });
}); });
describe("a role reassignment takes effect without re-login", () => {
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await openSession("T-R");
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
const l = await login(app, looker.username, looker.password);
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
expect(before.statusCode).toBe(403);
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
expect(moved.statusCode).toBe(200);
// Same cookie, no re-login: the token's pinned role is refreshed per request.
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
expect(after.statusCode).toBe(201);
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
expect(me.roleId).toBe("wash-op");
});
});
+40 -37
View File
@@ -1,23 +1,26 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import { requirePermission, roleHasPermissions } from "../auth.js"; import type { TillId } from "@parking/shared";
import { accessibleTillsFor, parseTill } from "../modules.js"; import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js"; import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a // 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 // 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. // 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) // - POST /api/drawer/movement : operator records a cash_in/cash_out on a till.
// - GET /api/drawer/movements: list with review status. Operators see (shift:read) // Guard = the till's `cash` (booth drawer:create,
// only their own; reviewers see all + can filter status. // 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) // - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read) // - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read).
// payments + vouchers over the whole chain — the
// amount that carries across shifts).
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a // 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. // 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 // TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each
// balance and the list take a `till` filter. See wiki/concepts/shift.md "Tills". // desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions
// matrix").
interface MovementBody { interface MovementBody {
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN), /** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
@@ -42,27 +45,19 @@ interface ReviewBody {
interface MovementsQuery { interface MovementsQuery {
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */ /** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
status?: MovementStatus; 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; till?: string;
} }
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> { export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
const createGuard = requirePermission("drawer:create");
const reviewGuard = requirePermission("drawer:review"); const reviewGuard = requirePermission("drawer:review");
const readGuard = requirePermission("shift:read");
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once. // 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); const b = req.body ?? ({} as MovementBody);
if (b.type !== "cash_in" && b.type !== "cash_out") { if (b.type !== "cash_in" && b.type !== "cash_out") {
return reply.code(400).send({ error: "type must be cash_in or 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 { try {
return await shift.recordVoucher({ return await shift.recordVoucher({
type: b.type, type: b.type,
@@ -70,7 +65,7 @@ export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftSer
amountMinor: b.amountMinor, amountMinor: b.amountMinor,
reason: b.reason ?? "", reason: b.reason ?? "",
currency: b.currency, currency: b.currency,
till, till: req.till!,
}); });
} catch (err) { } catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message }); 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 // List movements + review status. Operators are hard-scoped to their OWN movements on
// reviewer sees ALL and may filter by status (the pending review queue). // the tills they may read; a reviewer sees ALL and may filter by status (the pending
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req, reply) => { // review queue).
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => {
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]); 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 q = req.query ?? {};
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined; const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
const till = q.till?.trim() ? parseTill(db, q.till.trim()) : undefined; let tills: TillId[] | undefined = canReview ? undefined : readable;
if (till === null) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); if (q.till?.trim()) {
const movements = shift.movementsWithStatus({ const parsed = parseTill(db, q.till.trim());
operator: canReview ? undefined : req.user.username, if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
status, if (!canReview && !readable.includes(parsed)) {
till, 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" }; return { movements, scope: canReview ? "all" : "self" };
}); });
// A till's physical drawer balance now. Same visibility as the open shift's X-report // 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. // (the till's read guard) — a drawer is a shared till, not per-operator data.
app.get<{ Querystring: { till?: string } }>("/api/drawer/balance", { preHandler: readGuard }, async (req, reply) => { app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
const till = parseTill(db, req.query?.till); till: req.till!,
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); ...shift.drawerBalance(req.till!),
return { till, ...shift.drawerBalance(till) }; }));
});
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal. // 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) => { app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
+50 -66
View File
@@ -1,9 +1,9 @@
import type { FastifyInstance, FastifyReply } from "fastify"; import type { FastifyInstance, FastifyReply } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import type { TillId } from "@parking/shared"; import { tillGuards, type TillId } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js"; import { requireAuth, roleHasPermissions } from "../auth.js";
import { accessibleTillsFor, effectiveTillsFor, parseTill } from "../modules.js"; import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js"; import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService, type ShiftSummary } from "../shift-service.js";
interface ShiftsQuery { interface ShiftsQuery {
/** Filter to one operator (admin-only; non-admins are forced to themselves). */ /** Filter to one operator (admin-only; non-admins are forced to themselves). */
@@ -11,15 +11,7 @@ interface ShiftsQuery {
/** ISO window over shift START time. */ /** ISO window over shift START time. */
from?: string; from?: string;
to?: string; to?: string;
/** Filter to one till; absent = every till. */ /** Filter to one till; absent = every till the role may read. */
till?: string;
}
interface TillQuery {
/** Which till (default: the booth). */
till?: string;
}
interface TillBody {
till?: string; till?: string;
} }
@@ -27,25 +19,14 @@ interface TillBody {
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and // opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it. // local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
// //
// TILLS: every endpoint takes a `till` (query on GET, body on POST; default booth). // TILLS + PERMISSIONS: every endpoint addresses a `till` (query on GET, body on POST;
// A till is addressable only when the module that declares it is effective here // default booth) and its guard is resolved FROM THE TILL (requireTill): the booth's shift
// (400 otherwise) — the wash desk's shift control passes till=carwash. WORKING a till // is `shift:read` / `shift:create`, the wash's is `carwash:read` / `carwash:cash` — each
// (open/close, its state) additionally needs the role to hold that till's module // desk's money is guarded by that desk's own permissions, so a wash role holds no
// permission (booth: session:read; carwash: carwash:read) — 403 `till_forbidden` — so a // `shift:*` at all and cannot touch the booth. See venue-modules.md §"Permissions matrix".
// wash operator's role can never open the booth's shift, nor a booth operator the
// wash's. History (`/api/shifts`) stays scoped by shift:read/cash, not by till.
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> { export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
// Reading the shift state vs. opening/closing one's own shift. const statusOf = (till: TillId, me: string, roleId: string) => {
const readGuard = requirePermission("shift:read");
const guard = requirePermission("shift:create");
const badTill = (reply: FastifyReply) => reply.code(400).send({ error: "unknown till", code: "bad_till" });
const forbidden = (reply: FastifyReply, till: TillId) =>
reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
const mayWork = (roleId: string, till: TillId) => accessibleTillsFor(db, roleId).includes(till);
const statusOf = (till: TillId, me: string) => {
const open = shift.currentOpenShift(till); const open = shift.currentOpenShift(till);
const heldBy = open?.identity ?? null; const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance(till); const drawer = shift.drawerBalance(till);
@@ -53,6 +34,8 @@ export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftServ
till, till,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null, open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me, isMine: open != null && heldBy === me,
/** May this role open/close this till's shift? (The UI offers the button only then.) */
canWork: roleHasPermissions(roleId, [tillGuards(till).shift]),
drawerMinor: drawer.balanceMinor, drawerMinor: drawer.balanceMinor,
currency: drawer.currency, currency: drawer.currency,
}; };
@@ -64,87 +47,88 @@ export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftServ
// - till: which till this describes // - till: which till this describes
// - open: the open shift { startedAt, operator } or null // - open: the open shift { startedAt, operator } or null
// - isMine: true iff the open shift belongs to the requesting operator // - isMine: true iff the open shift belongs to the requesting operator
// - canWork: may this role open/close it
// - operator: the requesting user (for the UI's own identity) // - operator: the requesting user (for the UI's own identity)
// - tills: every till THIS ROLE may work (the booth + effective modules' tills it // - tills: every till THIS ROLE may read — what the UI offers controls for
// holds the permission for) — what the UI offers controls for app.get("/api/shift/current", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
app.get<{ Querystring: TillQuery }>("/api/shift/current", { preHandler: readGuard }, async (req, reply) => { operator: req.user.username,
const till = parseTill(db, req.query?.till); tills: tillsReadableBy(db, req.user.roleId),
if (!till) return badTill(reply); ...statusOf(req.till!, req.user.username, req.user.roleId),
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till); }));
return { operator: req.user.username, tills: accessibleTillsFor(db, req.user.roleId), ...statusOf(till, req.user.username) };
});
// The state of every till this role may work, in one read — the shift hub lists // The state of every till this role may read, in one read — the shift hub lists
// each open shift and offers "start" for the idle ones. // each open shift and offers "start" for the idle ones it may work.
app.get("/api/shift/tills", { preHandler: readGuard }, async (req) => { app.get("/api/shift/tills", { preHandler: requireAuth }, async (req) => {
const me = req.user.username; const me = req.user.username;
return { operator: me, tills: accessibleTillsFor(db, req.user.roleId).map((t) => statusOf(t, me)) }; return { operator: me, tills: tillsReadableBy(db, req.user.roleId).map((t) => statusOf(t, me, req.user.roleId)) };
}); });
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings + // Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed // drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
// as of now. Appends nothing — it's not an accountability mark, just a projection // as of now. Appends nothing — it's not an accountability mark, just a projection
// (the Z-report at close is the signed record). 204 when no shift is open. // (the Z-report at close is the signed record). 204 when no shift is open.
app.get<{ Querystring: TillQuery }>("/api/shift/report", { preHandler: readGuard }, async (req, reply) => { app.get("/api/shift/report", { preHandler: requireTill(db, "read", "query") }, async (req, reply) => {
const till = parseTill(db, req.query?.till); const report = shift.currentReport(req.till!);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
const report = shift.currentReport(till);
if (!report) return reply.code(204).send(); if (!report) return reply.code(204).send();
return report; return report;
}); });
// Completed shift history. SCOPED by permission: // Completed shift history. SCOPED by permission:
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored. // - a till's `read` guard (operators) → own shifts only, on the tills they may read;
// operator/from/to params ignored.
// - `shift:cash` (admin-grade) → all operators, optionally filtered by // - `shift:cash` (admin-grade) → all operators, optionally filtered by
// `operator` and a `from`/`to` time window over each shift's START. // `operator` and a `from`/`to` time window over each shift's START.
// This keeps one operator from reading another's takings while letting admins // This keeps one operator from reading another's takings while letting admins
// reconcile across the site. The data is the signed shift_z_report chain. Both // reconcile across the site. The data is the signed shift_z_report chain. Both
// scopes may filter by `till`. // scopes may filter by `till` (must be one the role may read).
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req, reply) => { app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: requireAuth }, async (req, reply) => {
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]); const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
const readable = tillsReadableBy(db, req.user.roleId);
if (!canSeeAll && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
const q = req.query ?? {}; const q = req.query ?? {};
// Non-admins are hard-scoped to themselves regardless of any operator param. // Non-admins are hard-scoped to themselves regardless of any operator param.
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username; const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
const from = canSeeAll ? q.from?.trim() || undefined : undefined; const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined; const to = canSeeAll ? q.to?.trim() || undefined : undefined;
let till: TillId | undefined; let tills: TillId[] = canSeeAll ? [] : readable; // [] = no till filter (admin)
if (q.till?.trim()) { if (q.till?.trim()) {
const parsed = parseTill(db, q.till.trim()); const parsed = parseTill(db, q.till.trim());
if (!parsed) return badTill(reply); if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
till = parsed; if (!canSeeAll && !readable.includes(parsed)) return badTill(reply, parsed);
tills = [parsed];
} }
const shifts = shift.listShifts({ operator, from, to, till }); const shifts: ShiftSummary[] =
tills.length === 0
? shift.listShifts({ operator, from, to })
: tills.flatMap((till) => shift.listShifts({ operator, from, to, till })).sort((a, b) => b.index - a.index);
// Admins also get the distinct operator list (unfiltered) for the filter // Admins also get the distinct operator list (unfiltered) for the filter
// dropdown — operators don't see other names, so it's scope-gated. // dropdown — operators don't see other names, so it's scope-gated.
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: effectiveTillsFor(db) }; if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: tillsReadableBy(db, req.user.roleId) };
return { shifts, scope: "self", tills: effectiveTillsFor(db) }; return { shifts, scope: "self", tills: readable };
}); });
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the // NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
// feature is no longer part of the shift route. See wiki/concepts/shift.md. // feature is no longer part of the shift route. See wiki/concepts/shift.md.
app.post<{ Body: TillBody }>("/api/shift/open", { preHandler: guard }, async (req, reply) => { app.post("/api/shift/open", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
const till = parseTill(db, req.body?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
try { try {
return await shift.open(req.user.username, till); return await shift.open(req.user.username, req.till!);
} catch (err) { } catch (err) {
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message }); if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message }); return reply.code(500).send({ error: (err as Error).message });
} }
}); });
app.post<{ Body: TillBody }>("/api/shift/close", { preHandler: guard }, async (req, reply) => { app.post("/api/shift/close", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
const till = parseTill(db, req.body?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
try { try {
return await shift.close(req.user.username, till); return await shift.close(req.user.username, req.till!);
} catch (err) { } catch (err) {
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message }); if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message }); return reply.code(500).send({ error: (err as Error).message });
} }
}); });
} }
function badTill(reply: FastifyReply, till: TillId): FastifyReply {
return reply.code(403).send({ error: `your role cannot see the ${till} till`, code: "till_forbidden", till });
}
+4 -1
View File
@@ -3,7 +3,7 @@ import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, eq, isNull, roles, users, type Db } from "@parking/db"; import { and, eq, isNull, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID } from "@parking/shared"; import { ADMIN_ROLE_ID } from "@parking/shared";
import { permissionsFor, requirePermission } from "../auth.js"; import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js"; import { softDelete } from "../recycle-bin.js";
// User management (admin). Users are created/edited at runtime here — the // User management (admin). Users are created/edited at runtime here — the
@@ -202,6 +202,8 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return reply.code(400).send({ error: "nothing to update" }); return reply.code(400).send({ error: "nothing to update" });
} }
db.update(users).set(next).where(eq(users.id, id)).run(); db.update(users).set(next).where(eq(users.id, id)).run();
// A role reassignment takes effect on the user's NEXT request (auth.ts refreshRole).
if (next.roleId) bumpPermsCache();
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!); return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
}, },
); );
@@ -251,6 +253,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return reply.code(409).send({ error: "cannot delete the last admin" }); return reply.code(409).send({ error: "cannot delete the last admin" });
} }
softDelete(db, "user", id, req.user.sub); softDelete(db, "user", id, req.user.sub);
bumpPermsCache(); // their live session ends on its next request
return { ok: true }; return { ok: true };
}, },
); );
+44 -24
View File
@@ -1,8 +1,9 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared"; import { feedPermissionFor, watchPermissions, type LedgerEvent, type Permission } from "@parking/shared";
import { requireAuth, roleHasPermissions } from "../auth.js"; import { currentRoleId, requireAuth, roleHasPermissions } from "../auth.js";
import { effectiveModulesFor } from "../modules.js";
import { import {
deviceEvents, deviceEvents,
type LaneStatusEvent, type LaneStatusEvent,
@@ -45,9 +46,14 @@ import { getOccupancy } from "../occupancy.js";
// headers on a WebSocket, so this path is unreachable from a browser and adds // headers on a WebSocket, so this path is unreachable from a browser and adds
// no CSWSH surface; the Origin allowlist still applies to both paths. // no CSWSH surface; the Origin allowlist still applies to both paths.
/** Permission required to watch the live feed (a read-only stream of ledger + // WHO may watch, and WHAT they see (venue-modules.md §"Permissions matrix", move 3):
* device status). Any role granted `report:read` may watch. */ // a role connects if it holds ANY watch permission — the core feed/occupancy/device
const WATCH_PERMISSION = "report:read" as const; // ones or an effective module's own (carwash:read) — and every pushed message is then
// FILTERED per role: a ledger event needs feedPermissionFor(type) (the owning module's,
// else event:read); occupancy + the plate backfill need session:read; device / printer /
// lane / radar need device:read. `report:read` is the REPORTS screen, not the socket: the
// wash desk gets a live queue without the booth's ledger, the booth a feed without reports.
type Viewer = { has: (p: Permission) => boolean };
/** Handshake header carrying a desktop WS ticket (see file header). */ /** Handshake header carrying a desktop WS ticket (see file header). */
const WS_TICKET_HEADER = "x-ws-ticket"; const WS_TICKET_HEADER = "x-ws-ticket";
@@ -108,18 +114,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
type OutMsg = type OutMsg =
| { | {
kind: "hello"; kind: "hello";
occupancy: ReturnType<typeof getOccupancy>; occupancy: ReturnType<typeof getOccupancy> | null;
devices: unknown; devices: unknown;
lanes: LaneStatusEvent; lanes: LaneStatusEvent | null;
radar: LanePresenceEvent; radar: LanePresenceEvent | null;
} }
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> } | { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> | null }
| { kind: "printer-status"; event: unknown } | { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: unknown } | { kind: "device-status"; event: unknown }
| { kind: "lane-status"; lanes: LaneStatusEvent } | { kind: "lane-status"; lanes: LaneStatusEvent }
| { kind: "lane-presence"; radar: LanePresenceEvent } | { kind: "lane-presence"; radar: LanePresenceEvent }
| { kind: "plate-recognized"; plate: PlateRecognizedEvent }; | { kind: "plate-recognized"; plate: PlateRecognizedEvent };
declare module "fastify" {
interface FastifyRequest {
/** The role the WS preHandler authenticated (ticket or cookie path) — for the handler's filter. */
wsRoleId?: string;
}
}
export async function wsRoutes( export async function wsRoutes(
app: FastifyInstance, app: FastifyInstance,
db: Db, db: Db,
@@ -161,14 +174,18 @@ export async function wsRoutes(
if (!req.user) { if (!req.user) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 }); throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
roleId = req.user.roleId; roleId = currentRoleId(req.user.sub) ?? "";
}
if (!roleHasPermissions(roleId, [WATCH_PERMISSION])) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
const may = watchPermissions(effectiveModulesFor(db)).some((p) => roleHasPermissions(roleId, [p]));
if (!may) throw Object.assign(new Error("forbidden"), { statusCode: 403 });
req.wsRoleId = roleId;
}, },
}, },
(socket) => { (socket, req) => {
const roleId = req.wsRoleId ?? req.user?.roleId ?? "";
const viewer: Viewer = { has: (p) => roleHasPermissions(roleId, [p]) };
const seesOccupancy = viewer.has("session:read");
const seesDevices = viewer.has("device:read");
const send = (msg: OutMsg) => { const send = (msg: OutMsg) => {
// readyState 1 = OPEN; never throw out of an event-bus callback. // readyState 1 = OPEN; never throw out of an event-bus callback.
if (socket.readyState === 1) { if (socket.readyState === 1) {
@@ -182,40 +199,43 @@ export async function wsRoutes(
// Initial snapshot so the client renders immediately, before any event: // Initial snapshot so the client renders immediately, before any event:
// occupancy AND the current device-status set (for the footer). // occupancy AND the current device-status set (for the footer).
// Each part of the snapshot only for a role that may see it (null otherwise).
send({ send({
kind: "hello", kind: "hello",
occupancy: getOccupancy(db), occupancy: seesOccupancy ? getOccupancy(db) : null,
devices: deviceMonitor.snapshot(), devices: seesDevices ? deviceMonitor.snapshot() : null,
lanes: laneStatus.snapshot(), lanes: seesDevices ? laneStatus.snapshot() : null,
radar: lanePresence.snapshot(), radar: seesDevices ? lanePresence.snapshot() : null,
}); });
// Subscribe to the live buses. Each handler recomputes occupancy from the // Subscribe to the live buses. Each handler recomputes occupancy from the
// ledger (cheap fold) so the pushed count is always authoritative. // ledger (cheap fold) so the pushed count is always authoritative.
const offLedger = deviceEvents.onLedger((event) => { const offLedger = deviceEvents.onLedger((event) => {
// Per-role filter: the event type's feed permission (module's own, else event:read).
if (!viewer.has(feedPermissionFor((event as { type: LedgerEvent["type"] }).type))) return;
// Enrich with read-time display fields (subscriber name) before fan-out. // Enrich with read-time display fields (subscriber name) before fan-out.
const enriched = enrichEvent(db, event as unknown as LedgerEvent); const enriched = enrichEvent(db, event as unknown as LedgerEvent);
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) }); send({ kind: "ledger", event: enriched, occupancy: seesOccupancy ? getOccupancy(db) : null });
}); });
const offPrinter = deviceEvents.onPrinterStatus((event) => { const offPrinter = deviceEvents.onPrinterStatus((event) => {
send({ kind: "printer-status", event }); if (seesDevices) send({ kind: "printer-status", event });
}); });
// Unified device status (all categories) for the booth footer — pushed on // Unified device status (all categories) for the booth footer — pushed on
// change; the initial set rode the hello above. // change; the initial set rode the hello above.
const offDevice = deviceEvents.onDeviceStatus((event) => { const offDevice = deviceEvents.onDeviceStatus((event) => {
send({ kind: "device-status", event }); if (seesDevices) send({ kind: "device-status", event });
}); });
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory. // Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
const offLane = deviceEvents.onLaneStatus((lanes) => { const offLane = deviceEvents.onLaneStatus((lanes) => {
send({ kind: "lane-status", lanes }); if (seesDevices) send({ kind: "lane-status", lanes });
}); });
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory. // Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
const offPresence = deviceEvents.onLanePresence((radar) => { const offPresence = deviceEvents.onLanePresence((radar) => {
send({ kind: "lane-presence", radar }); if (seesDevices) send({ kind: "lane-presence", radar });
}); });
// A late async plate recognition → backfill the badge on the matching feed row. Advisory. // A late async plate recognition → backfill the badge on the matching feed row. Advisory.
const offPlate = deviceEvents.onPlateRecognized((plate) => { const offPlate = deviceEvents.onPlateRecognized((plate) => {
send({ kind: "plate-recognized", plate }); if (seesOccupancy) send({ kind: "plate-recognized", plate });
}); });
socket.on("close", () => { socket.on("close", () => {
+14 -8
View File
@@ -7,18 +7,21 @@ import {
fetchEvents, fetchEvents,
fetchShift, fetchShift,
fetchShiftReport, fetchShiftReport,
fetchShiftTills,
fetchShifts, fetchShifts,
recordDrawerMovement, recordDrawerMovement,
reviewDrawerMovement, reviewDrawerMovement,
type DrawerMovement, type DrawerMovement,
type MovementStatus, type MovementStatus,
type SessionUser,
type ShiftSummary, type ShiftSummary,
type TillId, type TillId,
} from "./api.js"; } from "./api.js";
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js"; import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { shiftKey } from "./lib/use-shift.js"; import { shiftKey } from "./lib/use-shift.js";
import { Panel } from "./ui/Panel.js"; import { Panel } from "./ui/Panel.js";
import { tillOf, type LedgerEvent } from "@parking/shared"; import { tillGuards, tillOf, type LedgerEvent } from "@parking/shared";
import { can } from "./api.js";
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen // The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
// answers "what's in the till and why": the CURRENT drawer balance with the open // answers "what's in the till and why": the CURRENT drawer balance with the open
@@ -53,14 +56,17 @@ function StatusBadge({ status }: { status: MovementStatus }) {
); );
} }
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) { export function DrawerManager({ user, canReview }: { user: SessionUser | null; canReview: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [till, setTill] = useState<TillId>("booth"); // Which tills this role may READ (each desk's drawer is guarded by that desk's own
// Which tills exist here (the booth + effective money-taking modules') — from the // permissions) — the first one is the default view.
// booth's status read, which every till answer carries. const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") }); const tills: TillId[] = status.data?.tills.map((x) => x.till) ?? [];
const tills = status.data?.tills ?? ["booth"]; const [chosen, setChosen] = useState<TillId | null>(null);
const till = chosen && tills.includes(chosen) ? chosen : (tills[0] ?? "booth");
// Recording on a till needs that till's `cash` guard (booth drawer:create, wash carwash:cash).
const canCreate = can(user, tillGuards(till).cash);
const refresh = () => { const refresh = () => {
void qc.invalidateQueries({ queryKey: ["drawer"] }); void qc.invalidateQueries({ queryKey: ["drawer"] });
// A voucher moves the open shift's added/removed figures too (the X-report). // A voucher moves the open shift's added/removed figures too (the X-report).
@@ -73,7 +79,7 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
{tills.length > 1 && ( {tills.length > 1 && (
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{tills.map((x) => ( {tills.map((x) => (
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setTill(x)}> <button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setChosen(x)}>
{t(`till.${x}Long`)} {t(`till.${x}Long`)}
</button> </button>
))} ))}
+74 -1
View File
@@ -13,12 +13,20 @@ 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";
// 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
// built-in `admin` role is shown read-only/locked (it always has every permission // built-in `admin` role is shown read-only/locked (it always has every permission
// and can't be edited or deleted). The server enforces the same. See // and can't be edited or deleted). The server enforces the same. See
// @parking/shared PERMISSIONS. // @parking/shared PERMISSIONS.
//
// JOBS (venue-modules.md §"Permissions matrix", move 2): each EFFECTIVE module brings
// named permission bundles ("Booth operator", "Wash operator", "Merchant") offered as
// one-click chips above the grid — a chip adds/removes its bundle; the grid stays the
// 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).
/** 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[]> {
@@ -75,6 +83,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
<RoleEditor <RoleEditor
role={editing === "new" ? null : editing} role={editing === "new" ? null : editing}
grouped={grouped} grouped={grouped}
effective={(user?.modules ?? []) as ModuleId[]}
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
onSubmit={async (v) => { onSubmit={async (v) => {
try { try {
@@ -124,11 +133,37 @@ 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 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> }[] {
const has = (p: Permission) => perms.has(p);
const out: { key: string; vars?: Record<string, string> }[] = [];
// 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(", ") } });
// Partial job: holds a module's till-read (or a job's first permission) but not the
// rest of that job's OWN-resource permissions (a booth job also carries core
// permissions a supervisor legitimately leaves out — those don't count).
for (const { module, job } of jobsFor(effective)) {
const m = MODULES.find((x) => x.id === module)!;
const anchor = m.tillGuards?.read ?? job.permissions[0];
if (!anchor || !has(anchor)) continue;
const own = job.permissions.filter((p) => !has(p) && m.resources.some((r) => p.startsWith(`${r}:`)));
if (own.length > 0) out.push({ key: "roles.lintPartialJob", vars: { job: job.id, missing: own.join(", ") } });
}
return out;
}
function RoleEditor({ function RoleEditor({
role, grouped, onCancel, onSubmit, role, grouped, effective, onCancel, onSubmit,
}: { }: {
role: ManagedRole | null; role: ManagedRole | null;
grouped: Record<string, Permission[]>; grouped: Record<string, Permission[]>;
effective: readonly ModuleId[];
onCancel: () => void; onCancel: () => void;
onSubmit: (v: { name: string; permissions: Permission[] }) => void; onSubmit: (v: { name: string; permissions: Permission[] }) => void;
}) { }) {
@@ -141,6 +176,16 @@ function RoleEditor({
next.has(p) ? next.delete(p) : next.add(p); next.has(p) ? next.delete(p) : next.add(p);
return next; return next;
}); });
const jobs = useMemo(() => jobsFor(effective), [effective]);
const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
const toggleJob = (job: JobPreset) =>
setPerms((prev) => {
const next = new Set(prev);
if (job.permissions.every((p) => prev.has(p))) 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 valid = name.trim().length > 0; const valid = name.trim().length > 0;
@@ -151,6 +196,34 @@ function RoleEditor({
<input className="input" value={name} onChange={(e) => setName(e.target.value)} /> <input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
{jobs.length > 0 && (
<div className="mb-3">
<div className="label">{t("roles.jobs")}</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{jobs.map(({ module, job }) => (
<button
key={job.id}
type="button"
className={`btn btn-sm ${jobOn(job) ? "btn-primary" : ""}`}
title={job.permissions.join(", ")}
onClick={() => toggleJob(job)}
>
{t(`jobs.${job.id}`)} <span className="opacity-60">· {t(`modules.name.${module}`)}</span>
</button>
))}
</div>
<span className="hint">{t("roles.jobsHint")}</span>
</div>
)}
{lints.length > 0 && (
<div className="mb-3 rounded-term border border-term-amber/60 px-3 py-2 text-[0.75rem] text-term-amber">
{lints.map((l) => (
<div key={l.key + JSON.stringify(l.vars)}>{t(l.key, l.vars)}</div>
))}
</div>
)}
<div className="label">{t("roles.permissions")}</div> <div className="label">{t("roles.permissions")}</div>
<div className="mt-1 grid grid-cols-1 gap-1"> <div className="mt-1 grid grid-cols-1 gap-1">
{Object.entries(grouped).map(([resource, list]) => ( {Object.entries(grouped).map(([resource, list]) => (
+11 -1
View File
@@ -22,7 +22,10 @@ import { Spinner } from "./ui/Spinner.js";
export function ShiftButton({ till = "booth" }: { till?: TillId }) { export function ShiftButton({ till = "booth" }: { till?: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const { isOpen, isMine, blockedByOther, heldBy } = useShift(till); const { status, isOpen, isMine, blockedByOther, heldBy } = useShift(till);
// The till's `shift` guard (booth shift:create / wash carwash:cash). A role that may
// only LOOK sees the state text, never the button; the server refuses the same.
const canWork = status?.canWork ?? false;
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
// Closing a shift signs the Z-report and is irreversible, so the button never // Closing a shift signs the Z-report and is irreversible, so the button never
@@ -78,6 +81,7 @@ export function ShiftButton({ till = "booth" }: { till?: TillId }) {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{canWork && (
<button <button
type="button" type="button"
disabled={busy || blockedByOther} disabled={busy || blockedByOther}
@@ -93,6 +97,12 @@ export function ShiftButton({ till = "booth" }: { till?: TillId }) {
label label
)} )}
</button> </button>
)}
{!canWork && isOpen && (
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
{till === "booth" ? t("shift.headerHeldByShort", { operator: heldBy ?? "?" }) : t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })}
</span>
)}
{!isOpen && ( {!isOpen && (
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber"> <span className="text-[0.625rem] uppercase tracking-wider text-term-amber">
{till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })} {till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })}
+7 -6
View File
@@ -56,7 +56,7 @@ type CurrentShift = ShiftSummary & { open: true; isMine: boolean };
* X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open` * X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open`
* marks it for the badge + the action pane. Also returns every till the site has, so * marks it for the badge + the action pane. Also returns every till the site has, so
* the hub can offer "start shift" per till and show badges only when there are two. */ * the hub can offer "start shift" per till and show badges only when there are two. */
function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch: () => void } { function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workable: TillId[]; refetch: () => void } {
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills }); const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
const openTills = (status.data?.tills ?? []).filter((t) => t.open != null); const openTills = (status.data?.tills ?? []).filter((t) => t.open != null);
// One X-report per open till (the key carries the till list so a newly opened // One X-report per open till (the key carries the till list so a newly opened
@@ -70,7 +70,8 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch
void status.refetch(); void status.refetch();
void reports.refetch(); void reports.refetch();
}; };
const tills = status.data?.tills.map((t) => t.till) ?? ["booth"]; const tills = status.data?.tills.map((t) => t.till) ?? [];
const workable = status.data?.tills.filter((t) => t.canWork).map((t) => t.till) ?? [];
const current: CurrentShift[] = []; const current: CurrentShift[] = [];
openTills.forEach((t, i) => { openTills.forEach((t, i) => {
const x = reports.data?.[i]; const x = reports.data?.[i];
@@ -98,7 +99,7 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch
isMine: t.isMine, isMine: t.isMine,
}); });
}); });
return { current, tills, refetch }; return { current, tills, workable, refetch };
} }
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) { export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
@@ -110,7 +111,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [tillFilter, setTillFilter] = useState<TillId | "">(""); const [tillFilter, setTillFilter] = useState<TillId | "">("");
const { current, tills, refetch: refetchCurrent } = useCurrentShifts(); const { current, tills, workable, refetch: refetchCurrent } = useCurrentShifts();
const multiTill = tills.length > 1; const multiTill = tills.length > 1;
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset); const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
@@ -147,9 +148,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.data, currentIds]); }, [q.data, currentIds]);
// Tills with no open shift → offer "start" for each (gated on shift:create). // Tills this role may WORK with no open shift → offer "start" for each.
const openOn = new Set(current.map((c) => c.till)); const openOn = new Set(current.map((c) => c.till));
const startable = tills.filter((x) => !openOn.has(x)); const startable = workable.filter((x) => !openOn.has(x));
function refreshAll() { function refreshAll() {
void q.refetch(); void q.refetch();
+2
View File
@@ -1075,6 +1075,8 @@ export interface TillShiftStatus {
open: { startedAt: string; operator: string | null } | null; open: { startedAt: string; operator: string | null } | null;
/** True iff the open shift belongs to the requesting operator (can close it). */ /** True iff the open shift belongs to the requesting operator (can close it). */
isMine: boolean; isMine: boolean;
/** May this role open/close this till's shift (its module's `shift` guard)? */
canWork: boolean;
/** Live physical drawer balance of this till (cash payments + cash movements). */ /** Live physical drawer balance of this till (cash payments + cash movements). */
drawerMinor: number; drawerMinor: number;
currency: string | null; currency: string | null;
+11
View File
@@ -911,6 +911,17 @@ export const en: Catalog = {
permCount_other: "{{count}} permissions", permCount_other: "{{count}} permissions",
userCount_one: "{{count}} user", userCount_one: "{{count}} user",
userCount_other: "{{count}} users", userCount_other: "{{count}} users",
// Jobs — one-click permission bundles each module brings; the grid stays the fine-tune.
jobs: "Jobs",
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.",
},
jobs: {
"booth-operator": "Booth operator",
"booth-supervisor": "Booth supervisor",
merchant: "Merchant (validation)",
"wash-operator": "Wash operator",
}, },
shift: { shift: {
label: "Shift:", label: "Shift:",
+11
View File
@@ -925,6 +925,17 @@ export const sq = {
permCount_other: "{{count}} leje", permCount_other: "{{count}} leje",
userCount_one: "{{count}} përdorues", userCount_one: "{{count}} përdorues",
userCount_other: "{{count}} përdorues", userCount_other: "{{count}} përdorues",
// Punët — pako lejesh që sjell çdo modul; rrjeta poshtë mbetet për rregullim të imët.
jobs: "Punët",
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.",
},
jobs: {
"booth-operator": "Operator kabine",
"booth-supervisor": "Përgjegjës kabine",
merchant: "Tregtar (validime)",
"wash-operator": "Operator lavazhi",
}, },
shift: { shift: {
label: "Turni:", label: "Turni:",
+11 -2
View File
@@ -1,6 +1,6 @@
import type { AnyRoute } from "@tanstack/react-router"; import type { AnyRoute } from "@tanstack/react-router";
import type { ModuleId } from "@parking/shared"; import { watchPermissions, type ModuleId } from "@parking/shared";
import type { Permission, SessionUser } from "../api.js"; import { can, type Permission, type SessionUser } from "../api.js";
import type { rootRoute } from "../router.js"; import type { rootRoute } from "../router.js";
/** The app's root route (type only — a runtime import here would be a cycle). */ /** The app's root route (type only — a runtime import here would be a cycle). */
@@ -19,6 +19,15 @@ export function moduleOn(user: SessionUser | null, id: ModuleId): boolean {
return !!user && Array.isArray(user.modules) && user.modules.includes(id); return !!user && Array.isArray(user.modules) && user.modules.includes(id);
} }
/** May this role open the live WebSocket at all? Any watch permission (core event/
* session/device read, or an effective module's own feed permission). The server
* admits by the same rule and then filters what it pushes. NOT report:read. */
export function canWatchFeed(user: SessionUser | null): boolean {
if (!user) return false;
const effective = Array.isArray(user.modules) ? user.modules : [];
return watchPermissions(effective).some((p) => can(user, p));
}
export interface WebModuleNav { export interface WebModuleNav {
to: string; to: string;
/** i18n key for the header label. */ /** i18n key for the header label. */
+5 -4
View File
@@ -15,8 +15,9 @@ import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js";
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */ /** Server → client message shapes (mirror routes/ws.ts OutMsg). */
type WsMessage = type WsMessage =
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence } // Parts a role may not see arrive as null (the server filters per role — ws.ts).
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy } | { kind: "hello"; occupancy: Occupancy | null; devices: DeviceStatus[] | null; lanes: LaneStatus | null; radar: LanePresence | null }
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy | null }
| { kind: "printer-status"; event: unknown } | { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus } | { kind: "device-status"; event: DeviceStatus }
| { kind: "lane-status"; lanes: LaneStatus } | { kind: "lane-status"; lanes: LaneStatus }
@@ -67,7 +68,7 @@ export function useLiveFeed(enabled: boolean = true): void {
return; // ignore malformed frames return; // ignore malformed frames
} }
if (msg.kind === "hello") { if (msg.kind === "hello") {
setOccupancy(msg.occupancy); if (msg.occupancy) setOccupancy(msg.occupancy);
// Initial device-status snapshot for the footer. // Initial device-status snapshot for the footer.
if (Array.isArray(msg.devices)) setDevices(msg.devices); if (Array.isArray(msg.devices)) setDevices(msg.devices);
if (msg.lanes) setLanes(msg.lanes); if (msg.lanes) setLanes(msg.lanes);
@@ -84,7 +85,7 @@ export function useLiveFeed(enabled: boolean = true): void {
patchPlate(msg.plate.identity, msg.plate.plate); patchPlate(msg.plate.identity, msg.plate.plate);
void qc.invalidateQueries({ queryKey: qk.activeSessions }); void qc.invalidateQueries({ queryKey: qk.activeSessions });
} else if (msg.kind === "ledger") { } else if (msg.kind === "ledger") {
setOccupancy(msg.occupancy); if (msg.occupancy) setOccupancy(msg.occupancy);
pushEvent(msg.event); pushEvent(msg.event);
// Keep Query authoritative: the durable event list, occupancy totals, // Keep Query authoritative: the durable event list, occupancy totals,
// and active-sessions list refetch on the next read instead of trusting // and active-sessions list refetch on the next read instead of trusting
+19 -18
View File
@@ -45,7 +45,8 @@ import { DrawerManager } from "./DrawerManager.js";
import { LogsViewer } from "./LogsViewer.js"; import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js"; import { BackupSettings } from "./BackupSettings.js";
import { WEB_MODULES } from "./modules/index.js"; import { WEB_MODULES } from "./modules/index.js";
import { moduleOn } from "./lib/modules.js"; import { canWatchFeed, moduleOn } from "./lib/modules.js";
import { TILL_IDS, tillGuards } from "@parking/shared";
import { RecycleBin } from "./RecycleBin.js"; import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js"; import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's // Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
@@ -374,12 +375,13 @@ function RootLayout() {
// the permission its screen needs (the route guards enforce the same server-side). // the permission its screen needs (the route guards enforce the same server-side).
const show = (perm: Permission) => can(user, perm); const show = (perm: Permission) => can(user, perm);
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY // One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
// for roles the server would accept (routes/ws.ts gates on report:read). A // for roles the server would accept (routes/ws.ts admits any WATCH permission:
// merchant validator must not even attempt it: the 403'd upgrade would reconnect // event/session/device read, or an effective module's own feed permission — and
// on backoff forever and spam the server log. Same rule for the widgets that feed // then filters what it pushes per role). A merchant validator holds none and must
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read, // not even attempt it: the 403'd upgrade would reconnect on backoff forever and
// DeviceFooter → device:read). // spam the server log. Same rule for the widgets that feed off it (StatusDot) or
const canWatch = show("report:read"); // make their own gated calls (ShiftButton → shift:read, DeviceFooter → device:read).
const canWatch = canWatchFeed(user);
useLiveFeed(canWatch); useLiveFeed(canWatch);
return ( return (
@@ -422,10 +424,10 @@ function RootLayout() {
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />} show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{/* The header button is the BOOTH till's; a role that cannot work the booth {/* The header button is the BOOTH till's, guarded by the booth's own
(no session:read — e.g. the wash operator, who has their own control on shift:read (a wash role holds no shift:* at all and has its own control on
the wash desk) does not get it. The server refuses the same (403). */} the wash desk). The server resolves the same guard from the till. */}
{user && show("shift:read") && show("session:read") && <ShiftButton />} {user && show("shift:read") && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />} {user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />} {user && <ThemeToggle user={user} setUser={setUser} />}
{user && <FontScaleToggle user={user} setUser={setUser} />} {user && <FontScaleToggle user={user} setUser={setUser} />}
@@ -561,17 +563,16 @@ const drawerRoute = createRoute({
// permission. Guard on the broader of the two (create) so a review-only admin still gets // permission. Guard on the broader of the two (create) so a review-only admin still gets
// in — the redirect only fires if the user has NEITHER, which the nav already hides. // in — the redirect only fires if the user has NEITHER, which the nav already hides.
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) { // Anyone who may read a till's drawer, record on one, or review — the component
throw redirect({ to: "/" }); // shows the right view per till. (canWatchFeed-style: any of the till guards.)
} const u = context.user;
const anyTill = TILL_IDS.some((t) => can(u, tillGuards(t).read) || can(u, tillGuards(t).cash));
if (!anyTill && !can(u, "drawer:review")) throw redirect({ to: "/" });
}, },
component: function DrawerRoute() { component: function DrawerRoute() {
const { user } = rootRoute.useRouteContext(); const { user } = rootRoute.useRouteContext();
return ( return (
<DrawerManager <DrawerManager user={user} canReview={can(user, "drawer:review")} />
canCreate={can(user, "drawer:create")}
canReview={can(user, "drawer:review")}
/>
); );
}, },
}); });
+102 -20
View File
@@ -87,10 +87,13 @@ export const PERMISSIONS: readonly Permission[] = [
// action on a fresh appliance, never reachable from the running console. See // action on a fresh appliance, never reachable from the running console. See
// wiki/concepts/backup-recovery.md. // wiki/concepts/backup-recovery.md.
"backup:read", "backup:update", "backup:create", "backup:read", "backup:update", "backup:create",
// Car Wash module (venue-modules.md): read = the wash desk's queue + ticket lookup; // 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 // (+ the wash till's shift state and the wash live feed); create = intake an order;
// (categories, services, price matrix, sponsorship program) ride site:update. // update = mark done / take a bay payment / void; cash = WORK the wash till — open and
"carwash:read", "carwash:create", "carwash:update", // 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; ] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL /** 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 * 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. */ * module has no money of its own (validation) — or, for parking, the booth. */
readonly till?: TillId; readonly till?: TillId;
/** The permission that lets a role WORK this module's till: open/close its shift and /** Who may SEE and WORK this module's till — each desk's money is guarded by that
* move its cash. The booth's is the booth screen's own (`session:read`); a wash * desk's own permissions (permissions-matrix decision, 2026-09-05): `read` = see the
* operator's role holds `carwash:read` and not that, so they can never open the * shift state / X-report / balance / history; `shift` = open + close the shift;
* booth's shift — and vice versa. Enforced server-side (shift/drawer routes). */ * `cash` = record cash in/out. The booth's are parking's `shift:*` / `drawer:*`; the
readonly tillPermission?: Permission; * 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 /** 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"], resources: ["tariff", "subscription", "payment", "session"],
ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"], ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"],
till: "booth", 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 // 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"], dependsOn: ["parking"],
resources: ["validation"], resources: ["validation"],
ledgerEventTypes: ["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 // 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"], ledgerEventTypes: ["carwash_order", "carwash_payment"],
// Money taken AT THE BAY lands on the wash operator's own till, never the booth's. // Money taken AT THE BAY lands on the wash operator's own till, never the booth's.
till: "carwash", 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 /** The tills available given the EFFECTIVE modules — the booth always (parking is
* required), plus each effective module's own till. Registry order. */ * required), plus each effective module's own till. Registry order. */
export function tillsOf(effective: readonly ModuleId[]): TillId[] { 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)); return TILL_IDS.filter((t) => out.has(t));
} }
/** The tills a ROLE may work at this site: the effective tills whose module's /** The tills a ROLE may SEE (`kind` = read, default) or WORK (`shift` / `cash`) at
* `tillPermission` the role holds. What the shift/drawer routes enforce and what the * this site: the effective tills whose module guard the role holds. What the
* UI offers (header button, start buttons, drawer switch). */ * shift/drawer routes enforce and what the UI offers (header button, start buttons,
export function tillsFor(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] { * drawer switch). */
const site = tillsOf(effective); export function tillsFor(
return site.filter((t) => { effective: readonly ModuleId[],
const m = MODULES.find((x) => x.till === t); has: (p: Permission) => boolean,
return !!m && (m.tillPermission ? has(m.tillPermission) : true); kind: keyof TillGuards = "read",
}); ): TillId[] {
return tillsOf(effective).filter((t) => has(tillGuards(t)[kind]));
} }
// --- Car Wash module ---------------------------------------------------------- // --- Car Wash module ----------------------------------------------------------
+11 -7
View File
@@ -294,13 +294,17 @@ manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
- "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk - "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk
carries its own shift control. The header button stays the booth's. The shift hub lists carries its own shift control. The header button stays the booth's. The shift hub lists
every open shift with a till badge; the drawer hub switches tills. every open shift with a till badge; the drawer hub switches tills.
- **Working a till needs that till's module permission** (added 2026-09-05 after the user - **Each desk's money is guarded by that desk's own permissions** (2026-09-05, after the
found a wash user could open the *booth's* shift): the manifest names it user found a wash user could open the *booth's* shift; design on [[venue-modules]]
(`tillPermission` — booth: `session:read`, carwash: `carwash:read`), `tillsFor()` in §"Permissions matrix"). The manifest declares `tillGuards { read, shift, cash }`: booth =
`@parking/shared` resolves a role's tills, the shift/drawer routes refuse the rest with `shift:read` / `shift:create` / `drawer:create` (unchanged), carwash = `carwash:read` /
`403 till_forbidden`, and `/api/shift/tills` + `current.tills` return only the role's `carwash:cash` / `carwash:cash`. The shift + drawer routes resolve the guard FROM THE TILL
tills — so the header button, the hub's start buttons and the drawer switch never offer a (`requireTill(kind)`; `403 till_forbidden`), `/api/shift/tills` lists the tills a role may
till the server would refuse. `shift:create` alone opens nothing. read with a `canWork` flag, and history / movements without a till filter return the
union of the role's readable tills. So a wash role holds no `shift:*` at all and cannot
touch the booth by construction; the header button, the hub's start buttons and the
drawer switch never offer a till the server would refuse. (A first cut that borrowed
`session:read` as "works the booth till" lived for a few hours and is gone.)
- Not done: the per-shift *activity log* is still a time window over the whole chain (money - Not done: the per-shift *activity log* is still a time window over the whole chain (money
figures are per till, the event list is not); bay slips print on the booth printer. figures are per till, the event list is not); bay slips print on the booth printer.
+8
View File
@@ -138,3 +138,11 @@ procurement. (See [[parking-system-architecture]] §10.)
payment lands on the wash operator's own till, never the booth's). Open: vision category payment lands on the wash operator's own till, never the booth's). Open: vision category
flag, bay camera, the Bar's scope. Full design and the remaining questions on flag, bay camera, the Bar's scope. Full design and the remaining questions on
[[venue-modules]]. [[venue-modules]].
16. **Permissions matrix after venue modules.** _(Raised by the user, 2026-09-05.)_ The flat
`resource:action` grid was composed for one desk; a second desk (Car Wash) exposed borrowed
meanings (`session:read` as "works the booth till", `report:read` as "may open the socket")
and a composer at the wrong altitude. Decision + three moves (per-desk till guards, jobs on
top of the grid, a permission-scoped live feed) on [[venue-modules]] §"Permissions matrix";
moves built 2026-09-05. Open: default supervisor bundle, re-applying jobs after a module
update, signing role edits.
+57
View File
@@ -374,6 +374,63 @@ at the two seams the design names, and the registry earned its keep: **one manif
the next increment, as designed. Receipt label for a booth-paid wash is `Lavazh — <category> · the next increment, as designed. Receipt label for a booth-paid wash is `Lavazh — <category> ·
<service>` (Albanian, frozen on the payment). <service>` (Albanian, frozen on the payment).
## Permissions matrix — rethink (OPEN DECISION, raised 2026-09-05; moves 1–2 built same day)
**Why (user: "I feel we opened Pandora's box with this car wash module. We need to rethink
the permissions matrix.").** The flat `resource:action` grid was composed for ONE desk. Three
things broke once a second desk existed:
1. **Permissions named data, not jobs, and their meanings got borrowed.** `session:read` meant
"may use the booth screen"; on the first tills cut it also decided who may work the booth
till. `report:read` meant "may open the live socket". `shift:create` opened *the* shift. Each
was a proxy for a job, and proxies are how the dev `Lavazhier` role ended up with booth
rights and without `carwash:create`.
2. **Cross-cutting resources have no owner.** Shifts, drawer, events, the feed are core, but
every *instance* now belongs to a desk; the grid cannot say "shifts, but only the wash's".
3. **The composer is at the wrong altitude.** ~60 checkboxes of nouns and verbs ask the admin to
reconstruct a job from parts; at a site where the operator is the adversary a mis-composed
role is a security bug.
**Decision (three moves; the grid stays the enforcement layer — no guard semantics change for
the booth).**
- **Move 1 — each desk's money is guarded by that desk's own permissions.** The manifest
declares `tillGuards { read, shift, cash }`: booth = `shift:read` / `shift:create` /
`drawer:create` (parking's own, unchanged); carwash = `carwash:read` / **`carwash:cash`** (new)
/ `carwash:cash`. Shift + drawer routes resolve the guard FROM THE TILL
(`requireTill(kind)`), so a wash role holds no `shift:*` at all and cannot touch the booth by
construction; a role that should work both simply holds both. Replaces the one-day-old
`session:read` borrowing (`tillPermission`), which is deleted. `/api/shift/tills` lists the
tills a role may *read* with a `canWork` flag; history and movements without a till filter
return the union of the role's readable tills (admin scopes `shift:cash` / `drawer:review`
unchanged).
- **Move 2 — jobs on top of the grid.** Manifest `jobs[]` = named permission bundles: parking →
*Booth operator*, *Booth supervisor*; validation → *Merchant*; carwash → *Wash operator*. The
roles composer offers the jobs of the EFFECTIVE modules as one-click chips (add / remove the
bundle), with the grid kept as the fine-tune view, and LINTS the result: **mixes desks** (the
role may open more than one till) and **partial job** (holds a module's read permission but
not the rest of its job — e.g. a desk that can look but not create). Warnings, not blocks: the
admin is not the adversary, but must see what they composed.
- **Move 3 — the live feed follows the same rule (user: "The user should have websocket for
live events. This does not mean it can read the /reports section.").** The socket is no longer
gated on `report:read`. A role may connect if it holds ANY watch permission
(`event:read`, `session:read`, `device:read`, or an effective module's `feedPermission` —
carwash: `carwash:read`), and each pushed message is FILTERED per role: a ledger event needs
`feedPermissionFor(type)` (the owning module's, else `event:read`); occupancy needs
`session:read`; device / printer / lane / radar need `device:read`; plate backfill needs
`session:read`. So the wash desk gets a live queue without the booth's ledger, and the booth
operator keeps a feed without reports. `report:read` now means exactly the reports screen.
**Rejected.** Scoped permission strings (`shift:create@carwash`) — changes the `Permission`
type everywhere for what a manifest lookup expresses; a per-module copy of the shift/drawer
resources — the till already IS that copy. Role *templates stored in the DB* — jobs are code
(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: shifts per money-taking module — BUILT (raised + built 2026-09-05) ## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
**The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide** **The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide**
+5
View File
@@ -32,6 +32,11 @@ Authentication and authorization, kept **fully local** — a direct consequence
name. The JWT carries `roleId` (not the permission list); the guard resolves the role's permission name. The JWT carries `roleId` (not the permission list); the guard resolves the role's permission
set per-request from an **in-memory cache** (`bumpPermsCache()` on any role write), so editing a set per-request from an **in-memory cache** (`bumpPermsCache()` on any role write), so editing a
role applies immediately — no re-login, no token bloat. No Casbin/engine needed at this scale. role applies immediately — no re-login, no token bloat. No Casbin/engine needed at this scale.
**The token's `roleId` is only a hint (2026-09-05):** after every `jwtVerify` the guard replaces it
with the user's CURRENT role from the DB (`refreshRole()`; cached per user, cleared by the same
`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.
- **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.
+26
View File
@@ -3003,3 +3003,29 @@ landing, all guards bounce to `/`, `/booth` needs `session:read`. Diagnosed the
`session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The `session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The
WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills
and [[venue-modules]] §Tills → As-built. and [[venue-modules]] §Tills → As-built.
## [2026-09-05] ingest | Permissions matrix rethink — three moves built
User: "we opened Pandora's box with this car wash module … rethink the permissions matrix";
and "the user should have websocket for live events — this does not mean it can read
/reports". Decision recorded on [[venue-modules]] §"Permissions matrix" (open-questions
#16), then built: (1) per-desk till guards — manifest `tillGuards`, new `carwash:cash`,
`requireTill(kind)` resolves the guard from the till, `tillPermission`/`session:read`
borrowing removed; (2) jobs — manifest `jobs[]` (booth-operator, booth-supervisor,
merchant, wash-operator) as one-click chips in Setup → Roles with "mixes desks" / "partial
job" lints; (3) the live feed 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. 352/352 server tests; lavazhier (event:read) now shows LIVE. Their dev role
still needs `carwash:cash` (+ create/update) and should drop the booth permissions — the
"Wash operator" chip is exactly that.
## [2026-09-05] ingest | Role reassignment now takes effect without re-login
User: a user moved to a new "Lavazh NEW" role kept getting `403` on `POST /api/carwash/orders`.
Cause: the login token pins the `roleId` current at LOGIN; `/api/auth/me` read the user row (new
role) while every guard read the token (old role). Editing a role already took effect per
request (the permission cache); reassigning one did not. Fix in `auth.ts`: `refreshRole()` after
every `jwtVerify` resolves the user's CURRENT role from the DB (cached per user, cleared by
`bumpPermsCache()`, which the user update/delete routes now call); a deleted user's session
ends with 401 on its next request; the WS cookie path uses the same. Test: moved user creates
an order on the next request with the same cookie. Recorded on [[local-jwt-auth]].