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