diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 84c0008..47109a0 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; 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"; // Local JWT auth helpers — fully local, no external identity provider @@ -143,10 +143,41 @@ export function initAuth(db: Db): void { permsCache.clear(); } -/** Clear the permission cache. Call after ANY write to roles / role_permissions - * (or a user's roleId) so the change takes effect on the next request. */ +/** Clear the permission + role caches. Call after ANY write to roles / role_permissions + * or to a user's roleId / deletion, so the change takes effect on the next request. */ export function bumpPermsCache(): void { permsCache.clear(); + roleCache.clear(); +} + +/** userId → CURRENT roleId, cached until bumpPermsCache(). */ +const roleCache = new Map(); + +/** 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. */ @@ -184,6 +215,7 @@ export function requirePermission(...required: Permission[]) { return async (req: FastifyRequest, _reply: FastifyReply) => { await req.jwtVerify(); // reads the token cookie (configured in server.ts) assertCsrf(req); + refreshRole(req); if (!req.user || !roleHasPermissions(req.user.roleId, required)) { throw Object.assign(new Error("forbidden"), { statusCode: 403 }); } @@ -201,4 +233,5 @@ export async function requireAuth( ): Promise { await req.jwtVerify(); assertCsrf(req); + refreshRole(req); } diff --git a/apps/server/src/modules.test.ts b/apps/server/src/modules.test.ts index 012f7b4..952e22c 100644 --- a/apps/server/src/modules.test.ts +++ b/apps/server/src/modules.test.ts @@ -176,3 +176,30 @@ describe("entitlement (vendor env)", () => { 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); + }); +}); diff --git a/apps/server/src/modules.ts b/apps/server/src/modules.ts index 86e3582..3b9348d 100644 --- a/apps/server/src/modules.ts +++ b/apps/server/src/modules.ts @@ -5,12 +5,21 @@ import { isModuleId, isTillId, parseEntitledModules, + tillGuards, tillsFor, tillsOf, type ModuleId, + type TillGuards, type TillId, } 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 // @parking/shared; design in wiki/decisions/venue-modules.md). @@ -57,10 +66,36 @@ export function effectiveTillsFor(db: Db): TillId[] { return tillsOf(effectiveModulesFor(db)); } -/** The tills a role may WORK here (open/close its shift, move its cash): effective - * tills whose module permission the role holds. */ -export function accessibleTillsFor(db: Db, roleId: string): TillId[] { - return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p])); +/** The tills a role may SEE (default) or WORK (`shift` / `cash`) here: the effective + * tills whose module guard the role holds (each desk's money is guarded by that desk's + * own permissions — venue-modules.md §"Permissions matrix"). */ +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 => { + 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 diff --git a/apps/server/src/modules/carwash/carwash.test.ts b/apps/server/src/modules/carwash/carwash.test.ts index e91f2b0..902b7db 100644 --- a/apps/server/src/modules/carwash/carwash.test.ts +++ b/apps/server/src/modules/carwash/carwash.test.ts @@ -422,15 +422,17 @@ describe("tills are gated by the module permission", () => { const a = await admin(); seedTariff(db); 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, { 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); // What the UI offers: only the wash till. 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"]); - // 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) }); expect(booth.statusCode).toBe(403); 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" } }); 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, { username: "boothie", roleId: "booth-op", 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"]); }); }); + +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"); + }); +}); diff --git a/apps/server/src/routes/drawer.ts b/apps/server/src/routes/drawer.ts index ad32e35..355574c 100644 --- a/apps/server/src/routes/drawer.ts +++ b/apps/server/src/routes/drawer.ts @@ -1,23 +1,26 @@ import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; -import { requirePermission, roleHasPermissions } from "../auth.js"; -import { accessibleTillsFor, parseTill } from "../modules.js"; +import type { TillId } from "@parking/shared"; +import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js"; +import { parseTill, requireTill, tillsReadableBy } from "../modules.js"; import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js"; // Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a // receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after // the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md. -// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create) -// - GET /api/drawer/movements: list with review status. Operators see (shift:read) -// only their own; reviewers see all + can filter status. +// - POST /api/drawer/movement : operator records a cash_in/cash_out on a till. +// Guard = the till's `cash` (booth drawer:create, +// wash carwash:cash). +// - GET /api/drawer/movements: list with review status, over the tills the role may +// read (own movements); reviewers (drawer:review) see all +// tills + all operators and can filter status. // - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review) -// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read) -// payments + vouchers over the whole chain — the -// amount that carries across shifts). +// - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read). // The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a // judgment about the operator settled outside the app, never a cash reversal. -// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); the -// balance and the list take a `till` filter. See wiki/concepts/shift.md "Tills". +// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each +// desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions +// matrix"). interface MovementBody { /** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN), @@ -42,27 +45,19 @@ interface ReviewBody { interface MovementsQuery { /** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */ status?: MovementStatus; - /** Filter to one till; absent = every till. */ + /** Filter to one till; absent = every till the role may read (reviewers: every till). */ till?: string; } export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise { - const createGuard = requirePermission("drawer:create"); const reviewGuard = requirePermission("drawer:review"); - const readGuard = requirePermission("shift:read"); // Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once. - app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => { + app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, async (req, reply) => { const b = req.body ?? ({} as MovementBody); if (b.type !== "cash_in" && b.type !== "cash_out") { return reply.code(400).send({ error: "type must be cash_in or cash_out" }); } - const till = parseTill(db, b.till); - if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); - // Moving a till's cash needs that till's module permission (see routes/shift.ts). - if (!accessibleTillsFor(db, req.user.roleId).includes(till)) { - return reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till }); - } try { return await shift.recordVoucher({ type: b.type, @@ -70,7 +65,7 @@ export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftSer amountMinor: b.amountMinor, reason: b.reason ?? "", currency: b.currency, - till, + till: req.till!, }); } catch (err) { if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message }); @@ -78,29 +73,37 @@ export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftSer } }); - // List movements + review status. Operators are hard-scoped to their OWN movements; a - // reviewer sees ALL and may filter by status (the pending review queue). - app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req, reply) => { + // List movements + review status. Operators are hard-scoped to their OWN movements on + // the tills they may read; a reviewer sees ALL and may filter by status (the pending + // review queue). + app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => { const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]); + const readable = tillsReadableBy(db, req.user.roleId); + if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" }); const q = req.query ?? {}; const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined; - const till = q.till?.trim() ? parseTill(db, q.till.trim()) : undefined; - if (till === null) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); - const movements = shift.movementsWithStatus({ - operator: canReview ? undefined : req.user.username, - status, - till, - }); + let tills: TillId[] | undefined = canReview ? undefined : readable; + if (q.till?.trim()) { + const parsed = parseTill(db, q.till.trim()); + if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); + if (!canReview && !readable.includes(parsed)) { + return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed }); + } + tills = [parsed]; + } + const operator = canReview ? undefined : req.user.username; + const movements = tills + ? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)) + : shift.movementsWithStatus({ operator, status }); return { movements, scope: canReview ? "all" : "self" }; }); // A till's physical drawer balance now. Same visibility as the open shift's X-report - // (shift:read) — a drawer is a shared till, not per-operator data. - app.get<{ Querystring: { till?: string } }>("/api/drawer/balance", { preHandler: readGuard }, async (req, reply) => { - const till = parseTill(db, req.query?.till); - if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); - return { till, ...shift.drawerBalance(till) }; - }); + // (the till's read guard) — a drawer is a shared till, not per-operator data. + app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({ + till: req.till!, + ...shift.drawerBalance(req.till!), + })); // Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal. app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => { diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index 6770c02..5caef9a 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -1,9 +1,9 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import type { Db } from "@parking/db"; -import type { TillId } from "@parking/shared"; -import { requirePermission, roleHasPermissions } from "../auth.js"; -import { accessibleTillsFor, effectiveTillsFor, parseTill } from "../modules.js"; -import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js"; +import { tillGuards, type TillId } from "@parking/shared"; +import { requireAuth, roleHasPermissions } from "../auth.js"; +import { parseTill, requireTill, tillsReadableBy } from "../modules.js"; +import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService, type ShiftSummary } from "../shift-service.js"; interface ShiftsQuery { /** Filter to one operator (admin-only; non-admins are forced to themselves). */ @@ -11,15 +11,7 @@ interface ShiftsQuery { /** ISO window over shift START time. */ from?: string; to?: string; - /** Filter to one till; absent = every till. */ - till?: string; -} - -interface TillQuery { - /** Which till (default: the booth). */ - till?: string; -} -interface TillBody { + /** Filter to one till; absent = every till the role may read. */ till?: string; } @@ -27,25 +19,14 @@ interface TillBody { // 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. // -// TILLS: every endpoint takes a `till` (query on GET, body on POST; default booth). -// A till is addressable only when the module that declares it is effective here -// (400 otherwise) — the wash desk's shift control passes till=carwash. WORKING a till -// (open/close, its state) additionally needs the role to hold that till's module -// permission (booth: session:read; carwash: carwash:read) — 403 `till_forbidden` — so a -// 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. +// TILLS + PERMISSIONS: every endpoint addresses a `till` (query on GET, body on POST; +// default booth) and its guard is resolved FROM THE TILL (requireTill): the booth's shift +// is `shift:read` / `shift:create`, the wash's is `carwash:read` / `carwash:cash` — each +// desk's money is guarded by that desk's own permissions, so a wash role holds no +// `shift:*` at all and cannot touch the booth. See venue-modules.md §"Permissions matrix". export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise { - // Reading the shift state vs. opening/closing one's own shift. - 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 statusOf = (till: TillId, me: string, roleId: string) => { const open = shift.currentOpenShift(till); const heldBy = open?.identity ?? null; const drawer = shift.drawerBalance(till); @@ -53,6 +34,8 @@ export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftServ till, open: open ? { startedAt: open.occurredAt, operator: heldBy } : null, 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, currency: drawer.currency, }; @@ -64,87 +47,88 @@ export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftServ // - till: which till this describes // - open: the open shift { startedAt, operator } or null // - 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) - // - tills: every till THIS ROLE may work (the booth + effective modules' tills it - // holds the permission for) — what the UI offers controls for - app.get<{ Querystring: TillQuery }>("/api/shift/current", { preHandler: readGuard }, async (req, reply) => { - const till = parseTill(db, req.query?.till); - if (!till) return badTill(reply); - 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) }; - }); + // - tills: every till THIS ROLE may read — what the UI offers controls for + app.get("/api/shift/current", { preHandler: requireTill(db, "read", "query") }, async (req) => ({ + operator: req.user.username, + tills: tillsReadableBy(db, req.user.roleId), + ...statusOf(req.till!, req.user.username, req.user.roleId), + })); - // The state of every till this role may work, in one read — the shift hub lists - // each open shift and offers "start" for the idle ones. - app.get("/api/shift/tills", { preHandler: readGuard }, async (req) => { + // 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 it may work. + app.get("/api/shift/tills", { preHandler: requireAuth }, async (req) => { 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 + // 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 // (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) => { - const till = parseTill(db, req.query?.till); - if (!till) return badTill(reply); - if (!mayWork(req.user.roleId, till)) return forbidden(reply, till); - const report = shift.currentReport(till); + app.get("/api/shift/report", { preHandler: requireTill(db, "read", "query") }, async (req, reply) => { + const report = shift.currentReport(req.till!); if (!report) return reply.code(204).send(); return report; }); // 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 // `operator` and a `from`/`to` time window over each shift's START. // 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 - // scopes may filter by `till`. - app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req, reply) => { + // scopes may filter by `till` (must be one the role may read). + app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: requireAuth }, async (req, reply) => { 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 ?? {}; // Non-admins are hard-scoped to themselves regardless of any operator param. const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username; const from = canSeeAll ? q.from?.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()) { const parsed = parseTill(db, q.till.trim()); - if (!parsed) return badTill(reply); - till = parsed; + if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); + 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 // dropdown — operators don't see other names, so it's scope-gated. - if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: effectiveTillsFor(db) }; - return { shifts, scope: "self", tills: effectiveTillsFor(db) }; + if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: tillsReadableBy(db, req.user.roleId) }; + return { shifts, scope: "self", tills: readable }; }); // 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. - app.post<{ Body: TillBody }>("/api/shift/open", { preHandler: guard }, 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); + app.post("/api/shift/open", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => { try { - return await shift.open(req.user.username, till); + return await shift.open(req.user.username, req.till!); } catch (err) { if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message }); return reply.code(500).send({ error: (err as Error).message }); } }); - app.post<{ Body: TillBody }>("/api/shift/close", { preHandler: guard }, 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); + app.post("/api/shift/close", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => { try { - return await shift.close(req.user.username, till); + return await shift.close(req.user.username, req.till!); } catch (err) { if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.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 }); +} diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts index a324b16..07c2ae6 100644 --- a/apps/server/src/routes/users.ts +++ b/apps/server/src/routes/users.ts @@ -3,7 +3,7 @@ import bcrypt from "bcrypt"; import type { FastifyInstance } from "fastify"; import { and, eq, isNull, roles, users, type Db } from "@parking/db"; 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"; // User management (admin). Users are created/edited at runtime here — the @@ -202,6 +202,8 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { return reply.code(400).send({ error: "nothing to update" }); } 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()!); }, ); @@ -251,6 +253,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { return reply.code(409).send({ error: "cannot delete the last admin" }); } softDelete(db, "user", id, req.user.sub); + bumpPermsCache(); // their live session ends on its next request return { ok: true }; }, ); diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index e143de8..832c9e1 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -1,8 +1,9 @@ import { randomBytes } from "node:crypto"; import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; -import type { LedgerEvent } from "@parking/shared"; -import { requireAuth, roleHasPermissions } from "../auth.js"; +import { feedPermissionFor, watchPermissions, type LedgerEvent, type Permission } from "@parking/shared"; +import { currentRoleId, requireAuth, roleHasPermissions } from "../auth.js"; +import { effectiveModulesFor } from "../modules.js"; import { deviceEvents, 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 // no CSWSH surface; the Origin allowlist still applies to both paths. -/** Permission required to watch the live feed (a read-only stream of ledger + - * device status). Any role granted `report:read` may watch. */ -const WATCH_PERMISSION = "report:read" as const; +// WHO may watch, and WHAT they see (venue-modules.md §"Permissions matrix", move 3): +// a role connects if it holds ANY watch permission — the core feed/occupancy/device +// 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). */ const WS_TICKET_HEADER = "x-ws-ticket"; @@ -108,18 +114,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined): type OutMsg = | { kind: "hello"; - occupancy: ReturnType; + occupancy: ReturnType | null; devices: unknown; - lanes: LaneStatusEvent; - radar: LanePresenceEvent; + lanes: LaneStatusEvent | null; + radar: LanePresenceEvent | null; } - | { kind: "ledger"; event: unknown; occupancy: ReturnType } + | { kind: "ledger"; event: unknown; occupancy: ReturnType | null } | { kind: "printer-status"; event: unknown } | { kind: "device-status"; event: unknown } | { kind: "lane-status"; lanes: LaneStatusEvent } | { kind: "lane-presence"; radar: LanePresenceEvent } | { 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( app: FastifyInstance, db: Db, @@ -161,14 +174,18 @@ export async function wsRoutes( if (!req.user) { throw Object.assign(new Error("forbidden"), { statusCode: 403 }); } - roleId = req.user.roleId; - } - if (!roleHasPermissions(roleId, [WATCH_PERMISSION])) { - throw Object.assign(new Error("forbidden"), { statusCode: 403 }); + roleId = currentRoleId(req.user.sub) ?? ""; } + 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) => { // readyState 1 = OPEN; never throw out of an event-bus callback. if (socket.readyState === 1) { @@ -182,40 +199,43 @@ export async function wsRoutes( // Initial snapshot so the client renders immediately, before any event: // 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({ kind: "hello", - occupancy: getOccupancy(db), - devices: deviceMonitor.snapshot(), - lanes: laneStatus.snapshot(), - radar: lanePresence.snapshot(), + occupancy: seesOccupancy ? getOccupancy(db) : null, + devices: seesDevices ? deviceMonitor.snapshot() : null, + lanes: seesDevices ? laneStatus.snapshot() : null, + radar: seesDevices ? lanePresence.snapshot() : null, }); // Subscribe to the live buses. Each handler recomputes occupancy from the // ledger (cheap fold) so the pushed count is always authoritative. 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. 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) => { - send({ kind: "printer-status", event }); + if (seesDevices) send({ kind: "printer-status", event }); }); // Unified device status (all categories) for the booth footer — pushed on // change; the initial set rode the hello above. 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. 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. 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. const offPlate = deviceEvents.onPlateRecognized((plate) => { - send({ kind: "plate-recognized", plate }); + if (seesOccupancy) send({ kind: "plate-recognized", plate }); }); socket.on("close", () => { diff --git a/apps/web/src/DrawerManager.tsx b/apps/web/src/DrawerManager.tsx index ada17d0..2b2e887 100644 --- a/apps/web/src/DrawerManager.tsx +++ b/apps/web/src/DrawerManager.tsx @@ -7,18 +7,21 @@ import { fetchEvents, fetchShift, fetchShiftReport, + fetchShiftTills, fetchShifts, recordDrawerMovement, reviewDrawerMovement, type DrawerMovement, type MovementStatus, + type SessionUser, type ShiftSummary, type TillId, } from "./api.js"; import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js"; import { shiftKey } from "./lib/use-shift.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 // 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 qc = useQueryClient(); - const [till, setTill] = useState("booth"); - // Which tills exist here (the booth + effective money-taking modules') — from the - // booth's status read, which every till answer carries. - const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") }); - const tills = status.data?.tills ?? ["booth"]; + // Which tills this role may READ (each desk's drawer is guarded by that desk's own + // permissions) — the first one is the default view. + const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills }); + const tills: TillId[] = status.data?.tills.map((x) => x.till) ?? []; + const [chosen, setChosen] = useState(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 = () => { void qc.invalidateQueries({ queryKey: ["drawer"] }); // 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.map((x) => ( - ))} diff --git a/apps/web/src/RolesManager.tsx b/apps/web/src/RolesManager.tsx index ef65eff..b3327a1 100644 --- a/apps/web/src/RolesManager.tsx +++ b/apps/web/src/RolesManager.tsx @@ -13,12 +13,20 @@ import { type SessionUser, } from "./api.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 // 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 // and can't be edited or deleted). The server enforces the same. See // @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. */ function groupByResource(perms: Permission[]): Record { @@ -75,6 +83,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) { setEditing(null)} onSubmit={async (v) => { try { @@ -124,11 +133,37 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown) 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, effective: readonly ModuleId[]): { key: string; vars?: Record }[] { + const has = (p: Permission) => perms.has(p); + const out: { key: string; vars?: Record }[] = []; + // 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({ - role, grouped, onCancel, onSubmit, + role, grouped, effective, onCancel, onSubmit, }: { role: ManagedRole | null; grouped: Record; + effective: readonly ModuleId[]; onCancel: () => void; onSubmit: (v: { name: string; permissions: Permission[] }) => void; }) { @@ -141,6 +176,16 @@ function RoleEditor({ next.has(p) ? next.delete(p) : next.add(p); 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; @@ -151,6 +196,34 @@ function RoleEditor({ setName(e.target.value)} />
+ {jobs.length > 0 && ( +
+
{t("roles.jobs")}
+
+ {jobs.map(({ module, job }) => ( + + ))} +
+ {t("roles.jobsHint")} +
+ )} + + {lints.length > 0 && ( +
+ {lints.map((l) => ( +
{t(l.key, l.vars)}
+ ))} +
+ )} +
{t("roles.permissions")}
{Object.entries(grouped).map(([resource, list]) => ( diff --git a/apps/web/src/ShiftControl.tsx b/apps/web/src/ShiftControl.tsx index 4827171..fa96d6a 100644 --- a/apps/web/src/ShiftControl.tsx +++ b/apps/web/src/ShiftControl.tsx @@ -22,7 +22,10 @@ import { Spinner } from "./ui/Spinner.js"; export function ShiftButton({ till = "booth" }: { till?: TillId }) { const { t } = useTranslation(); 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 [err, setErr] = useState(null); // 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 (
+ {canWork && (