From 23d6379be824b67accc308c76360c35d3bf6986b Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 5 Sep 2026 11:04:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(modules):=20venue-module=20registry=20?= =?UTF-8?q?=E2=80=94=20entitled=20=E2=88=A9=20activated,=20requireModule,?= =?UTF-8?q?=20Setup=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the Car Wash pilot (wiki/decisions/venue-modules.md, build-order steps 1 + 3). No Car Wash code yet; validation is the first module behind the seam, unchanged in behaviour. - @parking/shared: MODULE_IDS, ModuleManifest, MODULES (parking required; validation dependsOn parking), parseEntitledModules / resolveModuleActivation / effectiveModules as pure functions. - DB: site_config.modules_json (migration 0026, hand-written + journal; additive, nullable = everything entitled). - Server: modules.ts (entitledModules from MODULES_ENTITLED env, activated from site_config, effective set, requireModule preHandler → 403 module_disabled); modules/index.ts registers folder-based modules by iterating the registry (modules/validation); site-config GET exposes modules/modulesEntitled/modulesActivated, PUT takes the full desired set, enforces entitlement + dependency rules (400 with reason) and signs one config_change per module that actually flips; /api/auth/me carries the effective set; validation routes guarded requireModule → requirePermission. - Web: lib/modules.ts + modules/{index,validation}; router.tsx spreads WEB_MODULES into nav + route tree (validate route no longer named there); Setup → Site "Modules" panel (required shown disabled, dependencies as hints, server refusal shown verbatim); validation sections + programs fetch gated on the module; App invalidates the router whenever the session changes (route-context consumers only re-read on navigation — the nav was stale after a flip, and after every other setUser too). - Lavazh validation station retired (STATIONS = ["bar"]; rows untouched). - Deploy: MODULES_ENTITLED=parking,validation explicit in both booth stacks; documented in .env.example. - Tests: modules.test.ts (7); suite 329/329; web build clean; Playwright round-trip on /setup/site verified live. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU --- .gitignore | 5 + apps/server/.env.example | 8 + apps/server/src/modules.test.ts | 166 ++++++++++++++++++++ apps/server/src/modules.ts | 59 +++++++ apps/server/src/modules/index.ts | 51 ++++++ apps/server/src/modules/validation/index.ts | 13 ++ apps/server/src/routes/auth.ts | 4 + apps/server/src/routes/site.ts | 58 +++++++ apps/server/src/routes/validations.ts | 10 +- apps/server/src/server.ts | 12 +- apps/web/src/App.tsx | 8 + apps/web/src/SiteSettings.tsx | 106 +++++++++++-- apps/web/src/ValidationSetup.tsx | 16 +- apps/web/src/api.ts | 11 +- apps/web/src/lib/i18n/en.ts | 13 +- apps/web/src/lib/i18n/sq.ts | 13 +- apps/web/src/lib/modules.ts | 37 +++++ apps/web/src/modules/index.ts | 9 ++ apps/web/src/modules/validation/index.tsx | 34 ++++ apps/web/src/router.tsx | 41 +++-- komodo/resources.toml | 6 + packages/db/drizzle/0026_site_modules.sql | 6 + packages/db/drizzle/meta/_journal.json | 9 +- packages/db/src/schema.ts | 6 + packages/shared/src/index.ts | 133 ++++++++++++++++ wiki/decisions/venue-modules.md | 56 ++++++- wiki/log.md | 15 ++ 27 files changed, 848 insertions(+), 57 deletions(-) create mode 100644 apps/server/src/modules.test.ts create mode 100644 apps/server/src/modules.ts create mode 100644 apps/server/src/modules/index.ts create mode 100644 apps/server/src/modules/validation/index.ts create mode 100644 apps/web/src/lib/modules.ts create mode 100644 apps/web/src/modules/index.ts create mode 100644 apps/web/src/modules/validation/index.tsx create mode 100644 packages/db/drizzle/0026_site_modules.sql diff --git a/.gitignore b/.gitignore index b507264..0d3f925 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,8 @@ dist/ graphify-out/ parking.sqlite*.bak-* questions.txt + +# session planning files (planning-with-files skill) +task_plan.md +findings.md +progress.md diff --git a/apps/server/.env.example b/apps/server/.env.example index 9c9069f..edf31d1 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -77,3 +77,11 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos # camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay. # VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR) # ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz) + +# Venue modules -------------------------------------------------------------- +# Comma-separated ids of the modules this site is ENTITLED to (a vendor/deployment +# decision — set in the Komodo stack env, never by a site role). The site admin then +# ACTIVATES within this set in Setup → Site; effective = entitled ∩ activated. Unset or +# blank = every registered module (parking,validation). Required modules (parking) are +# always on. See wiki/decisions/venue-modules.md. +#MODULES_ENTITLED=parking,validation diff --git a/apps/server/src/modules.test.ts b/apps/server/src/modules.test.ts new file mode 100644 index 0000000..8bd1c12 --- /dev/null +++ b/apps/server/src/modules.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createTestDb } from "@parking/db/testing"; +import { type Db } from "@parking/db"; +import type { FastifyInstance } from "fastify"; +import { buildServer } from "./server.js"; +import { seedUser, login } from "./test-helpers.js"; + +// Venue modules — entitled ∩ activated, enforced server-side (wiki/decisions/ +// venue-modules.md). Boots the real app over an in-memory DB and drives it with +// app.inject, like routes.test.ts. + +let db: Db; +let close: () => void; +let app: FastifyInstance; +const savedEnv = process.env.MODULES_ENTITLED; + +async function boot(): Promise { + const t = createTestDb(); + db = t.db; + close = t.close; + app = await buildServer({ db }); + await app.ready(); +} + +beforeEach(async () => { + delete process.env.MODULES_ENTITLED; + await boot(); +}); +afterEach(async () => { + await app.close(); + close(); + if (savedEnv === undefined) delete process.env.MODULES_ENTITLED; + else process.env.MODULES_ENTITLED = savedEnv; +}); + +async function admin() { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + return login(app, username, password); +} + +describe("defaults (no env, nothing activated)", () => { + it("every registered module is entitled, activated and effective; /me carries the set", async () => { + const { cookie } = await admin(); + const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); + expect(cfg.statusCode).toBe(200); + const body = cfg.json(); + expect(body.modulesEntitled).toEqual(["parking", "validation"]); + expect(body.modulesActivated).toEqual(["parking", "validation"]); + expect(body.modules).toEqual(["parking", "validation"]); + + const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); + expect(me.json().modules).toEqual(["parking", "validation"]); + + // A module route answers normally while the module is on. + const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); + expect(programs.statusCode).toBe(200); + }); +}); + +describe("activation (site admin)", () => { + it("deactivating validation 403s its routes with module_disabled, signs a config_change, and is reversible", async () => { + const { cookie, csrf } = await admin(); + const put = await app.inject({ + method: "PUT", url: "/api/site-config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { modules: ["parking"] }, + }); + expect(put.statusCode).toBe(200); + expect(put.json().modules).toEqual(["parking"]); + expect(put.json().modulesActivated).toEqual(["parking"]); + + const off = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); + expect(off.statusCode).toBe(403); + expect(off.json().code).toBe("module_disabled"); + + const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); + expect(me.json().modules).toEqual(["parking"]); + + // The flip is on the signed ledger, attributed. + const events = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } }); + expect(events.statusCode).toBe(200); + const list = (events.json().events ?? events.json()) as Array<{ type: string; payload: Record }>; + const flip = list.find((e) => e.type === "config_change" && e.payload?.setting === "modules.validation"); + expect(flip).toBeTruthy(); + expect(flip!.payload).toMatchObject({ value: false, prev: true, operator: "boss" }); + + // Nothing was deleted: re-enable and the route is back. + const back = await app.inject({ + method: "PUT", url: "/api/site-config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { modules: ["parking", "validation"] }, + }); + expect(back.json().modules).toEqual(["parking", "validation"]); + const on = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); + expect(on.statusCode).toBe(200); + }); + + it("required modules cannot be deactivated (parking is always included)", async () => { + const { cookie, csrf } = await admin(); + const put = await app.inject({ + method: "PUT", url: "/api/site-config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { modules: [] }, + }); + expect(put.statusCode).toBe(200); + expect(put.json().modules).toEqual(["parking"]); + }); + + it("rejects unknown ids with 400", async () => { + const { cookie, csrf } = await admin(); + const put = await app.inject({ + method: "PUT", url: "/api/site-config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { modules: ["parking", "carwash"] }, + }); + expect(put.statusCode).toBe(400); + }); + + it("a no-op resave signs nothing", async () => { + const { cookie, csrf } = await admin(); + const before = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } }); + const countBefore = ((before.json().events ?? before.json()) as unknown[]).length; + await app.inject({ + method: "PUT", url: "/api/site-config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { modules: ["parking", "validation"] }, + }); + const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } }); + expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore); + }); +}); + +describe("entitlement (vendor env)", () => { + it("MODULES_ENTITLED=parking: validation is neither offered nor activatable, and its routes 403", async () => { + await app.close(); + close(); + process.env.MODULES_ENTITLED = "parking"; + await boot(); + const { cookie, csrf } = await admin(); + + const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); + expect(cfg.json().modulesEntitled).toEqual(["parking"]); + expect(cfg.json().modules).toEqual(["parking"]); + + const put = await app.inject({ + method: "PUT", url: "/api/site-config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { modules: ["parking", "validation"] }, + }); + expect(put.statusCode).toBe(400); + expect(put.json().error).toMatch(/not entitled/); + + const off = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); + expect(off.statusCode).toBe(403); + }); + + it("required modules are entitled even when the env omits them; unknown ids are ignored", async () => { + await app.close(); + close(); + process.env.MODULES_ENTITLED = "validation,bogus"; + await boot(); + const { cookie } = await admin(); + const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); + expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]); + }); +}); diff --git a/apps/server/src/modules.ts b/apps/server/src/modules.ts new file mode 100644 index 0000000..0f855ea --- /dev/null +++ b/apps/server/src/modules.ts @@ -0,0 +1,59 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { eq, siteConfig, type Db } from "@parking/db"; +import { + effectiveModules, + isModuleId, + parseEntitledModules, + type ModuleId, +} from "@parking/shared"; + +// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in +// @parking/shared; design in wiki/decisions/venue-modules.md). +// +// entitled MODULES_ENTITLED env (vendor, Komodo stack) — unset = everything. +// activated site_config.modules_json (site admin, Setup → Site) — null = everything +// entitled. +// effective what requireModule() enforces and what /api/auth/me + /api/site-config +// hand the SPA so it can hide nav. The web only HIDES; this file ENFORCES. +// +// Both inputs are re-read per request: one env read and one single-row SELECT on the +// site_config singleton — cheap, and it means a change takes effect on the next request +// with no cache to invalidate (the same reason the presence-bypass flags aren't cached). + +/** The modules this deployment is entitled to. Unknown ids in the env are ignored + * (logged once at boot by registerModules). */ +export function entitledModules(): ModuleId[] { + return parseEntitledModules(process.env.MODULES_ENTITLED).entitled; +} + +/** Parse the persisted activation list off a site_config row. null = never set. A + * corrupt/unknown value is treated as "never set" rather than locking modules off. */ +export function activatedModulesOf(row: { modulesJson?: string | null } | undefined): ModuleId[] | null { + const raw = row?.modulesJson; + if (raw == null) return null; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + return parsed.filter(isModuleId); + } catch { + return null; + } +} + +/** The effective set for this site right now. */ +export function effectiveModulesFor(db: Db): ModuleId[] { + const row = db.select({ modulesJson: siteConfig.modulesJson }).from(siteConfig).where(eq(siteConfig.id, 1)).get(); + return effectiveModules(entitledModules(), activatedModulesOf(row)); +} + +/** preHandler: reject the call when `id` is not effective at this site. Compose it + * BEFORE requirePermission in a preHandler array so a disabled module answers the + * same way for every role — 403 with code "module_disabled" — and never reaches + * the permission/CSRF path. */ +export function requireModule(db: Db, id: ModuleId) { + return async (_req: FastifyRequest, _reply: FastifyReply): Promise => { + if (!effectiveModulesFor(db).includes(id)) { + throw Object.assign(new Error(`module disabled: ${id}`), { statusCode: 403, code: "module_disabled" }); + } + }; +} diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts new file mode 100644 index 0000000..1b25925 --- /dev/null +++ b/apps/server/src/modules/index.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from "fastify"; +import type { Db } from "@parking/db"; +import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared"; +import type { EventLog } from "../event-log.js"; +import { effectiveModulesFor } from "../modules.js"; +import { validationModule } from "./validation/index.js"; + +// The server-side module registry. A module's routes live in its own folder +// (apps/server/src/modules//index.ts) and are registered by iterating +// @parking/shared's MODULES — so adding a module is one manifest entry + one folder + +// one line in SERVER_MODULES below, with nothing else in the core touched +// (wiki/decisions/venue-modules.md, "A module = a manifest + three folders"). +// +// `parking` is registered in the manifest but has NO folder yet: its routes are still +// the flat list in server.ts. That is deliberate — the seam is drawn, the code moves +// across it subsystem by subsystem as each is touched, not in one big move. + +export interface ServerModuleDeps { + db: Db; + eventLog: EventLog; +} + +export interface ServerModule { + id: ModuleId; + register(app: FastifyInstance, deps: ServerModuleDeps): Promise; +} + +const SERVER_MODULES: Partial> = { + validation: validationModule, +}; + +/** Register every folder-based module in registry order, then log what this site + * is entitled to / has effective, so a "why is X missing" question is answerable + * from the container log alone. */ +export async function registerModules(app: FastifyInstance, deps: ServerModuleDeps): Promise { + for (const manifest of MODULES) { + const impl = SERVER_MODULES[manifest.id]; + if (impl) { + if (impl.id !== manifest.id) throw new Error(`module registry mismatch: ${impl.id} registered under ${manifest.id}`); + await impl.register(app, deps); + } + } + const { entitled, unknown } = parseEntitledModules(process.env.MODULES_ENTITLED); + if (unknown.length > 0) { + app.log.warn({ unknown }, "MODULES_ENTITLED names unknown module ids — ignored"); + } + app.log.info( + { entitled, effective: effectiveModulesFor(deps.db) }, + "venue modules (entitled = MODULES_ENTITLED env; effective = entitled ∩ site activation)", + ); +} diff --git a/apps/server/src/modules/validation/index.ts b/apps/server/src/modules/validation/index.ts new file mode 100644 index 0000000..bf4a4ad --- /dev/null +++ b/apps/server/src/modules/validation/index.ts @@ -0,0 +1,13 @@ +import { validationRoutes } from "../../routes/validations.js"; +import type { ServerModule } from "../index.js"; + +// Merchant-scan ticket validation as a venue module. Kept for the Bar until a Bar +// module absorbs it (wiki/decisions/venue-modules.md, decision 1). The routes +// themselves still live in routes/validations.ts (unchanged location, now guarded by +// requireModule("validation")); this folder is the registry hook. +export const validationModule: ServerModule = { + id: "validation", + async register(app, { db, eventLog }) { + await validationRoutes(app, db, eventLog); + }, +}; diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 6a07d7a..d8d1808 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,6 +1,7 @@ import bcrypt from "bcrypt"; import type { FastifyInstance } from "fastify"; import { eq, roles, users, type Db } from "@parking/db"; +import { effectiveModulesFor } from "../modules.js"; import { clearAuthCookies, newCsrfToken, @@ -107,6 +108,9 @@ function sessionView( fontScale: user.fontScale, fullName: user.fullName ?? null, email: user.email ?? null, + // Effective venue modules (entitled ∩ activated) so the SPA can hide nav/routes + // on first paint. The server still enforces via requireModule — this is display. + modules: effectiveModulesFor(db), ...(csrf ? { csrfToken: csrf } : {}), }; } diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index 43b804e..bd66f2a 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -1,7 +1,9 @@ import type { FastifyInstance } from "fastify"; import { eq, siteConfig, type Db } from "@parking/db"; +import { MODULES, effectiveModules, isModuleId, resolveModuleActivation, type ModuleId } from "@parking/shared"; import { requirePermission } from "../auth.js"; import type { EventLog } from "../event-log.js"; +import { activatedModulesOf, entitledModules } from "../modules.js"; import { getOccupancy } from "../occupancy.js"; // Site config (capacity) + live occupancy. Occupancy is a fold over the signed @@ -36,6 +38,10 @@ interface SiteConfigBody extends Partial> { /** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's * plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */ anprEntryEnabled?: boolean; + /** Venue modules to ACTIVATE (full desired set). Validated against the entitlement + * and the registry's dependency rules; required modules are always included. Each + * module that actually flips signs a config_change. See wiki/decisions/venue-modules.md. */ + modules?: unknown; } /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default @@ -48,6 +54,13 @@ type SiteConfig = { anprEntryEnabled: boolean; bypassPresenceRadar: boolean; bypassPresenceCamera: boolean; + /** Effective venue modules = entitled ∩ activated (what the server enforces). */ + modules: ModuleId[]; + /** What this deployment is entitled to (MODULES_ENTITLED env) — the Setup → Site + * panel offers exactly these to toggle. */ + modulesEntitled: ModuleId[]; + /** What the site admin has activated (null in storage = everything entitled). */ + modulesActivated: ModuleId[]; } & Record; function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { @@ -59,11 +72,22 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf anprEntryEnabled: row?.anprEntryEnabled ?? true, bypassPresenceRadar: row?.bypassPresenceRadar ?? false, bypassPresenceCamera: row?.bypassPresenceCamera ?? false, + ...moduleView(row), } as SiteConfig; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; return out; } +function moduleView(row: typeof siteConfig.$inferSelect | undefined) { + const entitled = entitledModules(); + const activated = activatedModulesOf(row) ?? entitled; + return { + modules: effectiveModules(entitled, activated), + modulesEntitled: entitled, + modulesActivated: activated, + }; +} + /** Trim a text field; empty string becomes null so blank input clears it. */ function normText(v: unknown): string | null { if (v == null) return null; @@ -136,6 +160,40 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL } const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + + // Venue-module activation. The body carries the full DESIRED set; the shared rules + // (required always on, must be entitled, dependencies effective) decide, and every + // module whose effective state actually flips is signed as a config_change — the + // same attribution pattern as the presence-bypass endpoint below. Disabling never + // deletes anything: tables/history/grants stay, routes 403, UI hides. + if ("modules" in body) { + const requested = body.modules; + if (!Array.isArray(requested) || !requested.every(isModuleId)) { + return reply.code(400).send({ + error: `modules must be an array of module ids (${MODULES.map((m) => m.id).join(", ")})`, + }); + } + const entitled = entitledModules(); + const result = resolveModuleActivation(entitled, requested); + if (!result.ok) return reply.code(400).send({ error: result.error }); + const prevEffective = new Set(effectiveModules(entitled, activatedModulesOf(existing) ?? entitled)); + const nextEffective = new Set(effectiveModules(entitled, result.modules)); + const operator = req.user?.username ?? "unknown"; + for (const m of MODULES) { + const was = prevEffective.has(m.id); + const now = nextEffective.has(m.id); + if (was !== now) { + await eventLog?.append({ + type: "config_change", + source: "manual", + identity: `module:${m.id}`, + payload: { setting: `modules.${m.id}`, value: now, prev: was, operator }, + }); + } + } + patch.modulesJson = JSON.stringify(result.modules); + } + const updatedAt = new Date().toISOString(); if (existing) { db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run(); diff --git a/apps/server/src/routes/validations.ts b/apps/server/src/routes/validations.ts index 3afe60f..e6e5ba9 100644 --- a/apps/server/src/routes/validations.ts +++ b/apps/server/src/routes/validations.ts @@ -12,6 +12,7 @@ import { } from "@parking/db"; import { VALIDATION_MODES, type ValidationMode } from "@parking/shared"; import { requirePermission } from "../auth.js"; +import { requireModule } from "../modules.js"; import type { EventLog } from "../event-log.js"; import { liveValidations, sessionValidations } from "../validations.js"; @@ -75,9 +76,12 @@ function validateProgram(b: ProgramBody): string | null { } export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise { - const siteRead = requirePermission("site:read"); - const siteWrite = requirePermission("site:update"); - const applyGuard = requirePermission("validation:create"); + // Every route is behind the venue-module gate FIRST (403 module_disabled when the + // site has validation off — see ../modules.ts), then the usual permission guard. + const moduleOn = requireModule(db, "validation"); + const siteRead = [moduleOn, requirePermission("site:read")]; + const siteWrite = [moduleOn, requirePermission("site:update")]; + const applyGuard = [moduleOn, requirePermission("validation:create")]; const liveProgram = (id: string) => db diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b0043f9..52cb9e5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -45,7 +45,7 @@ import { shiftRoutes } from "./routes/shift.js"; import { drawerRoutes } from "./routes/drawer.js"; import { entryRoutes } from "./routes/entry.js"; import { siteRoutes } from "./routes/site.js"; -import { validationRoutes } from "./routes/validations.js"; +import { registerModules } from "./modules/index.js"; import { snapshotRoutes } from "./routes/snapshots.js"; import { tariffRoutes } from "./routes/tariffs.js"; import { printerRoutes } from "./routes/printers.js"; @@ -296,10 +296,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise { + if (user) void router.invalidate(); + }, [user]); + // Apply the signed-in user's preferred language + theme + font scale whenever they // resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults // before auth resolves; on logout, fall back so the Login screen is consistent. diff --git a/apps/web/src/SiteSettings.tsx b/apps/web/src/SiteSettings.tsx index 7e60440..15e6b9c 100644 --- a/apps/web/src/SiteSettings.tsx +++ b/apps/web/src/SiteSettings.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useRouteContext } from "@tanstack/react-router"; import { + fetchMe, fetchOccupancy, fetchSiteConfig, fetchValidationPrograms, @@ -10,7 +12,9 @@ import { type SiteConfig, type ValidationProgramView, } from "./api.js"; -import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js"; +import { STATIONS, ValidationStationsPanel, defaultProgram, stationLabelKey, type StationId } from "./ValidationSetup.js"; +import { MODULES, type ModuleId } from "@parking/shared"; +import type { RouterContext } from "./router.js"; // Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a // fold over the signed ledger); capacity and the metadata fields are admin-editable. @@ -42,23 +46,43 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { // station's `active` (persisted at once — each flip signs a config_change); the // right-column panel edits the enabled stations. See validation-discounts.md. const [programs, setPrograms] = useState([]); + // Venue modules: what this site is entitled to (vendor-set), what the admin has + // activated, and the effective set. Toggling persists at once (the server signs a + // config_change per module that flips and validates dependencies). See + // wiki/decisions/venue-modules.md. + const [mods, setMods] = useState<{ entitled: ModuleId[]; activated: ModuleId[]; effective: ModuleId[] } | null>(null); + const [modMsg, setModMsg] = useState(null); + const moduleOn = (id: ModuleId) => mods?.effective.includes(id) ?? false; + // The header nav gates module entries on the SESSION's module set (/api/auth/me), + // so a flip here must refresh the session too or the nav stays stale until reload + // (App re-validates the router whenever `user` changes). + const { setUser } = useRouteContext({ strict: false }) as RouterContext; function reload() { fetchOccupancy().then(setOcc).catch(() => {}); } + /** The validation programs are a module route — only ask for them while the + * module is effective (the server 403s otherwise, which would land in app_logs + * as a failed request every time an admin opens this page). */ + function loadPrograms(effective: ModuleId[]) { + if (!canEdit || !effective.includes("validation")) { + setPrograms([]); + return; + } + fetchValidationPrograms() + .then((r) => setPrograms(r.programs)) + .catch(() => {}); + } useEffect(() => { reload(); - if (canEdit) { - fetchValidationPrograms() - .then((r) => setPrograms(r.programs)) - .catch(() => {}); - } fetchSiteConfig() .then((c) => { setCapInput(c.capacity == null ? "" : String(c.capacity)); setExitVoucherDefault(c.exitVoucherDefault); setReserveSubs(c.reserveSubscriberSpots); setAnprEntry(c.anprEntryEnabled); + setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules }); + loadPrograms(c.modules); const m: Record = {}; for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]); setMeta(m); @@ -73,7 +97,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { const existing = programs.find((p) => p.id === id); const body = existing ? { ...existing, active } - : { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active }; + : { ...defaultProgram(id, t(stationLabelKey(id))), active }; try { const saved = await saveValidationProgram(id, body); setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]); @@ -82,6 +106,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { } } + /** Flip a module: send the full desired activation set; the server decides + * (required always on, must be entitled, dependencies) and echoes the result. */ + async function toggleModule(id: ModuleId, on: boolean) { + if (!mods) return; + setModMsg(null); + const next = on ? [...new Set([...mods.activated, id])] : mods.activated.filter((m) => m !== id); + try { + const c = await saveSiteConfig({ modules: next }); + setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules }); + loadPrograms(c.modules); + const me = await fetchMe(); + if (me) setUser(me); + } catch (e) { + setModMsg((e as Error).message); + } + } + async function save() { setMsg(null); const raw = capInput.trim(); @@ -164,22 +205,53 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
- {t("val.sectionTitle")} + {t("modules.sectionTitle")}
- {t("val.sectionHint")} -
- {STATIONS.map((id) => ( -
)} - {canEdit && ( + {canEdit && moduleOn("validation") && ( setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])} diff --git a/apps/web/src/ValidationSetup.tsx b/apps/web/src/ValidationSetup.tsx index 2dd19a1..65c4985 100644 --- a/apps/web/src/ValidationSetup.tsx +++ b/apps/web/src/ValidationSetup.tsx @@ -15,10 +15,20 @@ import { // fixed stations. Amounts are entered in MAJOR units and stored in integer minor // units (the tariff-composer convention). See wiki/concepts/validation-discounts.md. -/** The two well-known stations the checkboxes toggle. */ -export const STATIONS = ["bar", "lavazh"] as const; +/** The well-known merchant stations the checkboxes toggle. Was `["bar", "lavazh"]`; + * the Lavazh (car-wash) station was retired 2026-09-05 — the Car Wash module + * sponsors parking through its own order flow instead (wiki/decisions/ + * venue-modules.md). Existing `lavazh` program rows are untouched data; the server + * accepts any kebab slug, so they simply no longer have a checkbox. */ +export const STATIONS = ["bar"] as const; export type StationId = (typeof STATIONS)[number]; +/** i18n label for a station's checkbox / tab. */ +const STATION_LABEL_KEY: Record = { bar: "val.enableBar" }; +export function stationLabelKey(id: StationId): string { + return STATION_LABEL_KEY[id]; +} + /** A blank program draft for a station enabled for the first time. */ export function defaultProgram(id: StationId, label: string): Omit { return { @@ -220,7 +230,7 @@ export function ValidationStationsPanel({ className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`} onClick={() => setTab(p.id)} > - {t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")} + {t(stationLabelKey(p.id as StationId))} ))} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index ab0bb5f..fe212db 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -16,7 +16,7 @@ import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js" import { logFailedRequest } from "./lib/logger.js"; import { apiUrl, platformFetch } from "./lib/origin.js"; import { inTauri } from "./lib/tauri-env.js"; -import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared"; +import type { AppLogRecord, ModuleId, ValidationLine, ValidationMode } from "@parking/shared"; const CSRF_COOKIE = "parking_csrf"; const CSRF_HEADER = "X-CSRF-Token"; @@ -98,6 +98,9 @@ export interface SessionUser { fullName: string | null; /** Optional contact email (profile metadata); null if unset. */ email: string | null; + /** Effective venue modules at this site (entitled ∩ activated) — what the SPA may + * SHOW; the server enforces. See lib/modules.ts. */ + modules: ModuleId[]; /** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on * desktop) parking_csrf cookie — see the file header. Absent/unused in the * browser build, which reads the cookie directly instead. */ @@ -1257,6 +1260,12 @@ export interface SiteConfig { bypassPresenceRadar: boolean; /** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */ bypassPresenceCamera: boolean; + /** Effective venue modules (entitled ∩ activated). */ + modules: ModuleId[]; + /** What this deployment is entitled to (vendor-set) — the toggles offered in Setup. */ + modulesEntitled: ModuleId[]; + /** What the site admin has activated. Send the full desired set via saveSiteConfig. */ + modulesActivated: ModuleId[]; parkName: string | null; operatorName: string | null; /** NIUS — Albanian tax/identification number. */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 71ece3c..79e5523 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -56,6 +56,16 @@ export const en: Catalog = { changeServer: "Change server", changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?", }, + modules: { + sectionTitle: "Modules", + sectionHint: "Optional parts of the system this site uses. What can be switched on here is decided at deployment; switching one off hides it and refuses its actions — nothing is deleted.", + required: "Always on.", + requires: "Requires: {{deps}}", + name: { + parking: "Parking", + validation: "Merchant validations (Bar)", + }, + }, update: { available: "Update available", prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)", @@ -756,9 +766,8 @@ export const en: Catalog = { val: { // /setup/site sectionTitle: "Merchant validations", - sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.", + sectionHint: "An in-park merchant (the bar) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.", enableBar: "Bar", - enableLavazh: "Car wash", labelName: "Receipt label", labelNamePh: "e.g. Car wash — first hour free", mode: "Discount type", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 05685b1..50c3166 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -59,6 +59,16 @@ export const sq = { changeServer: "Ndrysho serverin", changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?", }, + modules: { + sectionTitle: "Modulet", + sectionHint: "Pjesët opsionale të sistemit që përdor ky park. Çfarë mund të aktivizohet këtu vendoset gjatë instalimit; çaktivizimi e fsheh modulin dhe refuzon veprimet e tij — asgjë nuk fshihet.", + required: "Gjithmonë aktiv.", + requires: "Kërkon: {{deps}}", + name: { + parking: "Parkimi", + validation: "Validime tregtare (Bar)", + }, + }, update: { available: "Përditësim i disponueshëm", prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)", @@ -769,9 +779,8 @@ export const sq = { val: { // /setup/site sectionTitle: "Validime tregtare", - sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.", + sectionHint: "Bari brenda parkut skanon biletën e hyrjes dhe bën zbritje — pagesa dhe fatura bëhen në kabinë.", enableBar: "Bar", - enableLavazh: "Lavazh", labelName: "Etiketa në faturë", labelNamePh: "p.sh. Lavazh — 1 orë falas", mode: "Lloji i zbritjes", diff --git a/apps/web/src/lib/modules.ts b/apps/web/src/lib/modules.ts new file mode 100644 index 0000000..badd8a9 --- /dev/null +++ b/apps/web/src/lib/modules.ts @@ -0,0 +1,37 @@ +import type { AnyRoute } from "@tanstack/react-router"; +import type { ModuleId } from "@parking/shared"; +import type { Permission, SessionUser } from "../api.js"; +import type { rootRoute } from "../router.js"; + +/** The app's root route (type only — a runtime import here would be a cycle). */ +export type RootRoute = typeof rootRoute; + +// Venue modules — the web side. The server ENFORCES the effective set +// (requireModule); this file only decides what to SHOW. A module's nav entries and +// routes live in its own folder (apps/web/src/modules//index.tsx) and are +// discovered through WEB_MODULES below, so router.tsx never names a module's screens. +// See wiki/decisions/venue-modules.md. + +/** Is the module effective for this session? `modules` comes from /api/auth/me + * (entitled ∩ activated); a server too old to send it hides every module rather + * than showing something it would 403 — fail closed on the display side too. */ +export function moduleOn(user: SessionUser | null, id: ModuleId): boolean { + return !!user && Array.isArray(user.modules) && user.modules.includes(id); +} + +export interface WebModuleNav { + to: string; + /** i18n key for the header label. */ + labelKey: string; + /** Shown only if the role holds this permission (and the module is on). */ + perm: Permission; +} + +export interface WebModule { + id: ModuleId; + /** Header nav entries, in display order. */ + nav: readonly WebModuleNav[]; + /** Build this module's routes under the given root. Called once at router + * assembly; each route's own beforeLoad must gate on moduleOn + permission. */ + routes(root: RootRoute): AnyRoute[]; +} diff --git a/apps/web/src/modules/index.ts b/apps/web/src/modules/index.ts new file mode 100644 index 0000000..b0358da --- /dev/null +++ b/apps/web/src/modules/index.ts @@ -0,0 +1,9 @@ +import type { WebModule } from "../lib/modules.js"; +import { validationModule } from "./validation/index.js"; + +// The web-side module registry, in display order. Adding a module = its folder here +// + one entry below (+ the manifest in @parking/shared). router.tsx spreads these +// into the nav and the route tree and never names a module's screens itself. +// `parking` has no folder yet — its screens are still declared directly in +// router.tsx; they move behind this seam subsystem by subsystem. +export const WEB_MODULES: readonly WebModule[] = [validationModule]; diff --git a/apps/web/src/modules/validation/index.tsx b/apps/web/src/modules/validation/index.tsx new file mode 100644 index 0000000..eba40a6 --- /dev/null +++ b/apps/web/src/modules/validation/index.tsx @@ -0,0 +1,34 @@ +import { createRoute, redirect, useRouteContext } from "@tanstack/react-router"; +import { can } from "../../api.js"; +import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js"; +import type { RouterContext } from "../../router.js"; +import { ValidateScreen } from "../../ValidateScreen.js"; + +// Merchant-scan ticket validation as a venue module (kept for the Bar — +// wiki/decisions/venue-modules.md, decision 1). The merchant (bar) scan-and-validate +// screen is usually the ONLY page a merchant user's role can reach. The server +// enforces module-on + the program↔user binding on apply; the gates here are +// defence in depth / display. See wiki/concepts/validation-discounts.md. + +export const validationModule: WebModule = { + id: "validation", + nav: [{ to: "/validate", labelKey: "nav.validate", perm: "validation:create" }], + routes(root: RootRoute) { + const validateRoute = createRoute({ + getParentRoute: () => root, + path: "/validate", + beforeLoad: ({ context }) => { + const ctx = context as RouterContext; + if (!moduleOn(ctx.user, "validation") || !can(ctx.user, "validation:create")) { + throw redirect({ to: "/booth" }); + } + }, + component: function ValidateRoute() { + const { user } = useRouteContext({ strict: false }) as RouterContext; + if (!user) return null; + return ; + }, + }); + return [validateRoute]; + }, +}; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 8836b01..3abf1e8 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -48,7 +48,8 @@ import { DrawerManager } from "./DrawerManager.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { LogsViewer } from "./LogsViewer.js"; import { BackupSettings } from "./BackupSettings.js"; -import { ValidateScreen } from "./ValidateScreen.js"; +import { WEB_MODULES } from "./modules/index.js"; +import { moduleOn } from "./lib/modules.js"; import { RecycleBin } from "./RecycleBin.js"; import { Profile } from "./Profile.js"; // Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's @@ -555,9 +556,14 @@ function RootLayout() {