feat(modules): venue-module registry — entitled ∩ activated, requireModule, Setup panel

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
This commit is contained in:
2026-09-05 11:04:39 +02:00
parent db9c3e0e31
commit 23d6379be8
27 changed files with 848 additions and 57 deletions
+5
View File
@@ -28,3 +28,8 @@ dist/
graphify-out/ graphify-out/
parking.sqlite*.bak-* parking.sqlite*.bak-*
questions.txt questions.txt
# session planning files (planning-with-files skill)
task_plan.md
findings.md
progress.md
+8
View File
@@ -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. # 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) # 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) # 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
+166
View File
@@ -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<void> {
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<string, unknown> }>;
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"]);
});
});
+59
View File
@@ -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<void> => {
if (!effectiveModulesFor(db).includes(id)) {
throw Object.assign(new Error(`module disabled: ${id}`), { statusCode: 403, code: "module_disabled" });
}
};
}
+51
View File
@@ -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/<id>/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<void>;
}
const SERVER_MODULES: Partial<Record<ModuleId, ServerModule>> = {
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<void> {
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)",
);
}
@@ -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);
},
};
+4
View File
@@ -1,6 +1,7 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, roles, users, type Db } from "@parking/db"; import { eq, roles, users, type Db } from "@parking/db";
import { effectiveModulesFor } from "../modules.js";
import { import {
clearAuthCookies, clearAuthCookies,
newCsrfToken, newCsrfToken,
@@ -107,6 +108,9 @@ function sessionView(
fontScale: user.fontScale, fontScale: user.fontScale,
fullName: user.fullName ?? null, fullName: user.fullName ?? null,
email: user.email ?? 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 } : {}), ...(csrf ? { csrfToken: csrf } : {}),
}; };
} }
+58
View File
@@ -1,7 +1,9 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db"; import { eq, siteConfig, type Db } from "@parking/db";
import { MODULES, effectiveModules, isModuleId, resolveModuleActivation, type ModuleId } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
import { activatedModulesOf, entitledModules } from "../modules.js";
import { getOccupancy } from "../occupancy.js"; import { getOccupancy } from "../occupancy.js";
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed // Site config (capacity) + live occupancy. Occupancy is a fold over the signed
@@ -36,6 +38,10 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's /** 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. */ * plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
anprEntryEnabled?: boolean; 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 /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
@@ -48,6 +54,13 @@ type SiteConfig = {
anprEntryEnabled: boolean; anprEntryEnabled: boolean;
bypassPresenceRadar: boolean; bypassPresenceRadar: boolean;
bypassPresenceCamera: 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<TextField, string | null>; } & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
@@ -59,11 +72,22 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
anprEntryEnabled: row?.anprEntryEnabled ?? true, anprEntryEnabled: row?.anprEntryEnabled ?? true,
bypassPresenceRadar: row?.bypassPresenceRadar ?? false, bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
bypassPresenceCamera: row?.bypassPresenceCamera ?? false, bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
...moduleView(row),
} as SiteConfig; } as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out; 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. */ /** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null { function normText(v: unknown): string | null {
if (v == null) return 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(); 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(); const updatedAt = new Date().toISOString();
if (existing) { if (existing) {
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run(); db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
+7 -3
View File
@@ -12,6 +12,7 @@ import {
} from "@parking/db"; } from "@parking/db";
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared"; import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import { requireModule } from "../modules.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
import { liveValidations, sessionValidations } from "../validations.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<void> { export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
const siteRead = requirePermission("site:read"); // Every route is behind the venue-module gate FIRST (403 module_disabled when the
const siteWrite = requirePermission("site:update"); // site has validation off — see ../modules.ts), then the usual permission guard.
const applyGuard = requirePermission("validation:create"); 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) => const liveProgram = (id: string) =>
db db
+7 -5
View File
@@ -45,7 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
import { drawerRoutes } from "./routes/drawer.js"; import { drawerRoutes } from "./routes/drawer.js";
import { entryRoutes } from "./routes/entry.js"; import { entryRoutes } from "./routes/entry.js";
import { siteRoutes } from "./routes/site.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 { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js"; import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
@@ -296,10 +296,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md. // at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
await siteRoutes(app, db, eventLog); await siteRoutes(app, db, eventLog);
// Merchant validations (bar / lavazh): setup panel config + the merchant user's // Venue modules (wiki/decisions/venue-modules.md): folder-based modules register
// scan-and-apply. The booth settlement folds the applied validations into its // here by iterating the shared registry — today that is `validation` (merchant
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md. // validations for the Bar; the booth settlement folds applied validations into its
await validationRoutes(app, db, eventLog); // quote, pay-station.ts). `parking` is in the registry too but its routes are still
// the flat list above; they move behind the seam subsystem by subsystem.
await registerModules(app, { db, eventLog });
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) + // Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md. // read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
+8
View File
@@ -41,6 +41,14 @@ export function App() {
}); });
}, []); }, []);
// Route-context consumers (RootLayout's nav, route beforeLoad guards) only re-read
// the router context on navigation — NOT when this `user` state changes. So after
// any session refresh (login, profile edit, a venue-module flip in Setup → Site)
// re-validate the current matches once React has committed the new context.
useEffect(() => {
if (user) void router.invalidate();
}, [user]);
// Apply the signed-in user's preferred language + theme + font scale whenever they // 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 // 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. // before auth resolves; on logout, fall back so the Login screen is consistent.
+79 -7
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRouteContext } from "@tanstack/react-router";
import { import {
fetchMe,
fetchOccupancy, fetchOccupancy,
fetchSiteConfig, fetchSiteConfig,
fetchValidationPrograms, fetchValidationPrograms,
@@ -10,7 +12,9 @@ import {
type SiteConfig, type SiteConfig,
type ValidationProgramView, type ValidationProgramView,
} from "./api.js"; } 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 // 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. // 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 // station's `active` (persisted at once — each flip signs a config_change); the
// right-column panel edits the enabled stations. See validation-discounts.md. // right-column panel edits the enabled stations. See validation-discounts.md.
const [programs, setPrograms] = useState<ValidationProgramView[]>([]); const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
// 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<string | null>(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() { function reload() {
fetchOccupancy().then(setOcc).catch(() => {}); fetchOccupancy().then(setOcc).catch(() => {});
} }
useEffect(() => { /** The validation programs are a module route — only ask for them while the
reload(); * module is effective (the server 403s otherwise, which would land in app_logs
if (canEdit) { * as a failed request every time an admin opens this page). */
function loadPrograms(effective: ModuleId[]) {
if (!canEdit || !effective.includes("validation")) {
setPrograms([]);
return;
}
fetchValidationPrograms() fetchValidationPrograms()
.then((r) => setPrograms(r.programs)) .then((r) => setPrograms(r.programs))
.catch(() => {}); .catch(() => {});
} }
useEffect(() => {
reload();
fetchSiteConfig() fetchSiteConfig()
.then((c) => { .then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity)); setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault); setExitVoucherDefault(c.exitVoucherDefault);
setReserveSubs(c.reserveSubscriberSpots); setReserveSubs(c.reserveSubscriberSpots);
setAnprEntry(c.anprEntryEnabled); setAnprEntry(c.anprEntryEnabled);
setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules });
loadPrograms(c.modules);
const m: Record<string, string> = {}; const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]); for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m); setMeta(m);
@@ -73,7 +97,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const existing = programs.find((p) => p.id === id); const existing = programs.find((p) => p.id === id);
const body = existing const body = existing
? { ...existing, active } ? { ...existing, active }
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active }; : { ...defaultProgram(id, t(stationLabelKey(id))), active };
try { try {
const saved = await saveValidationProgram(id, body); const saved = await saveValidationProgram(id, body);
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]); 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() { async function save() {
setMsg(null); setMsg(null);
const raw = capInput.trim(); const raw = capInput.trim();
@@ -163,6 +204,35 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.anprEntryHint")}</span> <span className="hint block">{t("site.anprEntryHint")}</span>
</span> </span>
</label> </label>
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("modules.sectionTitle")}
</div>
<span className="hint -mt-2">{t("modules.sectionHint")}</span>
<div className="grid gap-1.5">
{MODULES.filter((m) => mods?.entitled.includes(m.id)).map((m) => (
<label key={m.id} className="flex items-start gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="mt-0.5 accent-term-amber"
checked={moduleOn(m.id)}
disabled={m.required || !mods}
onChange={(e) => toggleModule(m.id, e.target.checked)}
/>
<span>
{t(`modules.name.${m.id}`)}
{m.required && <span className="hint block">{t("modules.required")}</span>}
{m.dependsOn.length > 0 && (
<span className="hint block">
{t("modules.requires", { deps: m.dependsOn.map((d) => t(`modules.name.${d}`)).join(", ") })}
</span>
)}
</span>
</label>
))}
{modMsg && <span className="text-[0.75rem] text-term-red">{modMsg}</span>}
</div>
{moduleOn("validation") && (
<>
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted"> <div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("val.sectionTitle")} {t("val.sectionTitle")}
</div> </div>
@@ -176,10 +246,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
checked={programs.find((p) => p.id === id)?.active ?? false} checked={programs.find((p) => p.id === id)?.active ?? false}
onChange={(e) => toggleStation(id, e.target.checked)} onChange={(e) => toggleStation(id, e.target.checked)}
/> />
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")} {t(stationLabelKey(id))}
</label> </label>
))} ))}
</div> </div>
</>
)}
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted"> <div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")} {t("site.parkDetails")}
</div> </div>
@@ -211,7 +283,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
</div> </div>
)} )}
</section> </section>
{canEdit && ( {canEdit && moduleOn("validation") && (
<ValidationStationsPanel <ValidationStationsPanel
programs={programs} programs={programs}
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])} onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
+13 -3
View File
@@ -15,10 +15,20 @@ import {
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor // fixed stations. Amounts are entered in MAJOR units and stored in integer minor
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md. // units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
/** The two well-known stations the checkboxes toggle. */ /** The well-known merchant stations the checkboxes toggle. Was `["bar", "lavazh"]`;
export const STATIONS = ["bar", "lavazh"] as const; * 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]; export type StationId = (typeof STATIONS)[number];
/** i18n label for a station's checkbox / tab. */
const STATION_LABEL_KEY: Record<StationId, string> = { 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. */ /** A blank program draft for a station enabled for the first time. */
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> { export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
return { return {
@@ -220,7 +230,7 @@ export function ValidationStationsPanel({
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`} className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
onClick={() => setTab(p.id)} onClick={() => setTab(p.id)}
> >
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")} {t(stationLabelKey(p.id as StationId))}
</button> </button>
))} ))}
</div> </div>
+10 -1
View File
@@ -16,7 +16,7 @@ import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js"
import { logFailedRequest } from "./lib/logger.js"; import { logFailedRequest } from "./lib/logger.js";
import { apiUrl, platformFetch } from "./lib/origin.js"; import { apiUrl, platformFetch } from "./lib/origin.js";
import { inTauri } from "./lib/tauri-env.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_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token"; const CSRF_HEADER = "X-CSRF-Token";
@@ -98,6 +98,9 @@ export interface SessionUser {
fullName: string | null; fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */ /** Optional contact email (profile metadata); null if unset. */
email: string | null; 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-only: the CSRF token also echoed via the (JS-unreadable, on
* desktop) parking_csrf cookie — see the file header. Absent/unused in the * desktop) parking_csrf cookie — see the file header. Absent/unused in the
* browser build, which reads the cookie directly instead. */ * browser build, which reads the cookie directly instead. */
@@ -1257,6 +1260,12 @@ export interface SiteConfig {
bypassPresenceRadar: boolean; bypassPresenceRadar: boolean;
/** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */ /** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */
bypassPresenceCamera: boolean; 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; parkName: string | null;
operatorName: string | null; operatorName: string | null;
/** NIUS — Albanian tax/identification number. */ /** NIUS — Albanian tax/identification number. */
+11 -2
View File
@@ -56,6 +56,16 @@ export const en: Catalog = {
changeServer: "Change server", changeServer: "Change server",
changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?", 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: { update: {
available: "Update available", available: "Update available",
prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)", prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)",
@@ -756,9 +766,8 @@ export const en: Catalog = {
val: { val: {
// /setup/site // /setup/site
sectionTitle: "Merchant validations", 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", enableBar: "Bar",
enableLavazh: "Car wash",
labelName: "Receipt label", labelName: "Receipt label",
labelNamePh: "e.g. Car wash — first hour free", labelNamePh: "e.g. Car wash — first hour free",
mode: "Discount type", mode: "Discount type",
+11 -2
View File
@@ -59,6 +59,16 @@ export const sq = {
changeServer: "Ndrysho serverin", changeServer: "Ndrysho serverin",
changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?", 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: { update: {
available: "Përditësim i disponueshëm", 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.)", 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: { val: {
// /setup/site // /setup/site
sectionTitle: "Validime tregtare", 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", enableBar: "Bar",
enableLavazh: "Lavazh",
labelName: "Etiketa në faturë", labelName: "Etiketa në faturë",
labelNamePh: "p.sh. Lavazh — 1 orë falas", labelNamePh: "p.sh. Lavazh — 1 orë falas",
mode: "Lloji i zbritjes", mode: "Lloji i zbritjes",
+37
View File
@@ -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/<id>/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[];
}
+9
View File
@@ -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];
+34
View File
@@ -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 <ValidateScreen user={user} />;
},
});
return [validateRoute];
},
};
+20 -21
View File
@@ -48,7 +48,8 @@ import { DrawerManager } from "./DrawerManager.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { LogsViewer } from "./LogsViewer.js"; import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.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 { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js"; import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's // Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
@@ -555,9 +556,14 @@ function RootLayout() {
<nav className="flex items-center gap-1"> <nav className="flex items-center gap-1">
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />} {show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />} {show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role {/* Venue-module nav entries (e.g. the Bar merchant's scan-and-validate screen,
grants ONLY validation:create, so this is often their whole nav. */} often that role's whole nav): shown iff the module is effective at this
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />} site AND the role holds the entry's permission. See lib/modules.ts. */}
{WEB_MODULES.flatMap((m) =>
m.nav
.filter((n) => moduleOn(user, m.id) && show(n.perm))
.map((n) => <NavLink key={n.to} to={n.to} label={t(n.labelKey)} />),
)}
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the {/* Drawer — record cash movements (operator) / review them (admin). Shown if the
user can do either. See wiki/concepts/shift.md. */} user can do either. See wiki/concepts/shift.md. */}
{(show("drawer:create") || show("drawer:review")) && ( {(show("drawer:create") || show("drawer:review")) && (
@@ -625,8 +631,13 @@ const indexRoute = createRoute({
path: "/", path: "/",
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
// A merchant-only user (validation:create without the booth's session:read) // A merchant-only user (validation:create without the booth's session:read)
// lands on their scan-and-validate screen; everyone else on the booth. // lands on their scan-and-validate screen — if the validation module is on at
if (can(context.user, "validation:create") && !can(context.user, "session:read")) { // this site; everyone else on the booth.
if (
moduleOn(context.user, "validation") &&
can(context.user, "validation:create") &&
!can(context.user, "session:read")
) {
throw redirect({ to: "/validate" }); throw redirect({ to: "/validate" });
} }
throw redirect({ to: "/booth" }); throw redirect({ to: "/booth" });
@@ -639,20 +650,6 @@ const boothRoute = createRoute({
component: BoothScreen, component: BoothScreen,
}); });
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
// merchant user's role can reach. The server enforces the program↔user binding on
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
const validateRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/validate",
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
component: function ValidateRoute() {
const { user } = rootRoute.useRouteContext();
if (!user) return null;
return <ValidateScreen user={user} />;
},
});
// Back-compat redirects for paths that moved. Most config screens live under /setup; // Back-compat redirects for paths that moved. Most config screens live under /setup;
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone // Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing // /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
@@ -898,7 +895,9 @@ const profileRoute = createRoute({
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
boothRoute, boothRoute,
validateRoute, // Venue-module routes (e.g. /validate) — each module gates its own routes on
// moduleOn + permission. See modules/index.ts.
...WEB_MODULES.flatMap((m) => m.routes(rootRoute)),
...legacyRedirects, ...legacyRedirects,
profileRoute, profileRoute,
shiftRoute, shiftRoute,
+6
View File
@@ -51,6 +51,9 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
# exists as the pointer; we deploy the sha, not the mover. # exists as the pointer; we deploy the sha, not the mover.
TAG=stage-8fa66c9 TAG=stage-8fa66c9
COOKIE_SECURE=0 COOKIE_SECURE=0
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
MODULES_ENTITLED=parking,validation
VISION_ENABLED=1 VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach # platform-ws.ts, since the native WS plugin has no page context to auto-attach
@@ -84,6 +87,9 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
# exists as the pointer; we deploy the sha, not the mover. # exists as the pointer; we deploy the sha, not the mover.
TAG=stage-8fa66c9 TAG=stage-8fa66c9
COOKIE_SECURE=0 COOKIE_SECURE=0
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
MODULES_ENTITLED=parking,validation
VISION_ENABLED=1 VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach # platform-ws.ts, since the native WS plugin has no page context to auto-attach
@@ -0,0 +1,6 @@
-- Venue modules: which optional modules the site admin has ACTIVATED (JSON array of
-- module ids, e.g. ["parking","validation"]). null = never set → everything the site is
-- entitled to (MODULES_ENTITLED env). Effective set = entitled ∩ activated, computed server-
-- side (apps/server/src/modules.ts); each change signs a config_change. Additive, nullable:
-- existing deployments see no behaviour change. See wiki/decisions/venue-modules.md.
ALTER TABLE `site_config` ADD `modules_json` text;
+7
View File
@@ -183,6 +183,13 @@
"when": 1788078414270, "when": 1788078414270,
"tag": "0025_backup_last_status", "tag": "0025_backup_last_status",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1788596918862,
"tag": "0026_site_modules",
"breakpoints": true
} }
] ]
} }
+6
View File
@@ -233,6 +233,12 @@ export const siteConfig = sqliteTable("site_config", {
* own price and may differ. null = no site default set. See * own price and may differ. null = no site default set. See
* wiki/entities/subscription.md. */ * wiki/entities/subscription.md. */
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"), subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
/** Venue modules the site admin has ACTIVATED (JSON array of ModuleId, e.g.
* ["parking","validation"]). null = never set → everything the site is entitled to.
* The effective set is entitled (MODULES_ENTITLED env) ∩ this, computed server-side
* (apps/server/src/modules.ts); each change signs a config_change. Disabling a module
* never deletes anything. See wiki/decisions/venue-modules.md. */
modulesJson: text("modules_json"),
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car /** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
* (by quantity) even when they're not parked — so transients see "full" sooner and * (by quantity) even when they're not parked — so transients see "full" sooner and
* the subscriber's spot is held. When OFF (default), only cars physically inside * the subscriber's spot is held. When OFF (default), only cars physically inside
+133
View File
@@ -1705,3 +1705,136 @@ export interface Signer {
* verifies via its public key). */ * verifies via its public key). */
verify(payload: string, signature: string): boolean; verify(payload: string, signature: string): boolean;
} }
// --- Venue modules -------------------------------------------------------------
// Optional per-site features (Car Wash, Bar, …) and — deliberately — the parking
// product itself are MODULES on a shared venue core (identity/roles, the signed
// ledger, devices, shift/cash, printing, reports, site config). One binary; a module
// is enabled per site at RUNTIME as `entitled ∩ activated`:
// - entitled = what the vendor deployed for this site (MODULES_ENTITLED env, set in
// the Komodo stack; unset = every registered module — existing
// deployments keep working unchanged);
// - activated = what the site admin has switched on in Setup → Site
// (site_config.modules_json; null = everything entitled).
// The server ENFORCES the effective set (requireModule guard, apps/server/src/
// modules.ts); the web only HIDES nav/routes from it. Disabling never deletes:
// tables stay migrated, history stays, role grants stay; routes reject and UI hides.
// Design + rationale: wiki/decisions/venue-modules.md.
export const MODULE_IDS = ["parking", "validation"] as const;
export type ModuleId = (typeof MODULE_IDS)[number];
export interface ModuleManifest {
readonly id: ModuleId;
/** Cannot be deactivated (and is always entitled). Parking is the product today. */
readonly required: boolean;
/** Modules that must be effective for this one to be activated. Enforced at the point
* of change (activating with a dependency off is refused; deactivating a dependency of
* an active module is refused) and again when computing the effective set. */
readonly dependsOn: readonly ModuleId[];
/** Permission resources this module contributes to the catalog (informational for the
* role composer; the core resources belong to no module). */
readonly resources: readonly Resource[];
/** Ledger event types this module appends (informational; the union stays ONE
* append-only type — see LedgerEventType). */
readonly ledgerEventTypes: readonly LedgerEventType[];
}
/** The registry. Adding a module = one entry here + its server/web folders
* (apps/server/src/modules/<id>, apps/web/src/modules/<id>). Order = display order. */
export const MODULES: readonly ModuleManifest[] = [
{
id: "parking",
required: true,
dependsOn: [],
resources: ["tariff", "subscription", "payment", "session"],
ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"],
},
{
// Merchant-scan ticket validation, kept for the Bar until a Bar module absorbs it
// (wiki/decisions/venue-modules.md, decision 1).
id: "validation",
required: false,
dependsOn: ["parking"],
resources: ["validation"],
ledgerEventTypes: ["validation"],
},
];
export function isModuleId(v: unknown): v is ModuleId {
return typeof v === "string" && (MODULE_IDS as readonly string[]).includes(v);
}
export function moduleManifest(id: ModuleId): ModuleManifest {
const m = MODULES.find((x) => x.id === id);
if (!m) throw new Error(`unknown module: ${id}`);
return m;
}
/** Ids of the modules that can never be off. */
export const REQUIRED_MODULE_IDS: readonly ModuleId[] = MODULES.filter((m) => m.required).map((m) => m.id);
/** Parse a comma-separated entitlement list (the MODULES_ENTITLED env). Unknown ids
* are dropped (returned in `unknown` so the caller can warn); required modules are
* always included; unset/blank = everything registered. */
export function parseEntitledModules(raw: string | undefined | null): { entitled: ModuleId[]; unknown: string[] } {
const trimmed = (raw ?? "").trim();
if (trimmed === "") return { entitled: [...MODULE_IDS], unknown: [] };
const entitled = new Set<ModuleId>(REQUIRED_MODULE_IDS);
const unknown: string[] = [];
for (const part of trimmed.split(",")) {
const id = part.trim();
if (id === "") continue;
if (isModuleId(id)) entitled.add(id);
else unknown.push(id);
}
return { entitled: MODULE_IDS.filter((id) => entitled.has(id)), unknown };
}
export type ModuleActivationResult =
| { ok: true; modules: ModuleId[] }
| { ok: false; error: string };
/** Validate a requested activation set against the entitlement. Required modules are
* always included; anything not entitled or with an inactive dependency is refused
* with a human-readable reason (the UI shows it verbatim). Returns the normalized set
* in registry order. */
export function resolveModuleActivation(
entitled: readonly ModuleId[],
requested: readonly ModuleId[],
): ModuleActivationResult {
const active = new Set<ModuleId>(REQUIRED_MODULE_IDS);
for (const id of requested) active.add(id);
for (const id of active) {
if (!entitled.includes(id)) return { ok: false, error: `module "${id}" is not entitled for this site` };
}
for (const id of active) {
for (const dep of moduleManifest(id).dependsOn) {
if (!active.has(dep)) return { ok: false, error: `module "${id}" requires "${dep}" to be enabled` };
}
}
return { ok: true, modules: MODULE_IDS.filter((id) => active.has(id)) };
}
/** The effective set = required ∪ (entitled ∩ activated), then any module whose
* dependency is not effective is dropped (defensive: an entitlement can shrink after
* activation was recorded). `activated === null` means "never set" → everything
* entitled. Registry order. */
export function effectiveModules(entitled: readonly ModuleId[], activated: readonly ModuleId[] | null): ModuleId[] {
const on = new Set<ModuleId>(REQUIRED_MODULE_IDS);
for (const id of activated ?? entitled) {
if (entitled.includes(id)) on.add(id);
}
// Drop dependency-broken modules until stable (the registry is tiny; a loop is fine).
let changed = true;
while (changed) {
changed = false;
for (const id of [...on]) {
if (moduleManifest(id).dependsOn.some((dep) => !on.has(dep))) {
on.delete(id);
changed = true;
}
}
}
return MODULE_IDS.filter((id) => on.has(id));
}
+54 -2
View File
@@ -8,8 +8,8 @@ status: open
# Venue modules — Car Wash, Bar/Restaurant, and Parking as peers # Venue modules — Car Wash, Bar/Restaurant, and Parking as peers
**Status: OPEN.** Design captured from a working session with the user on 2026-09-04, after the **Status: OPEN** (Car Wash not yet built; the module registry IS — see "As-built" below). Design
desktop-shell run closed. Nothing here is built. Decisions marked **(settled)** were stated by the captured from working sessions with the user on 2026-09-04/05. Decisions marked **(settled)** were stated by the
user in that session; everything else is the proposed shape awaiting a go. user in that session; everything else is the proposed shape awaiting a go.
## The ask ## The ask
@@ -219,6 +219,58 @@ platform already owns:
collected and labelled first; the bay-count signal can ship before it (presence only). collected and labelled first; the bay-count signal can ship before it (presence only).
Rough size: four to six weeks including the registry. Rough size: four to six weeks including the registry.
## As-built: the registry (2026-09-05, build-order steps 1 + 3)
Built as the groundwork for the Car Wash pilot. `parking` and `validation` are registered;
no parking code moved (the seam exists, the code crosses it as each subsystem is touched).
- **`packages/shared/src/index.ts`** — `MODULE_IDS`, `ModuleManifest` {`id`, `required`,
`dependsOn`, `resources`, `ledgerEventTypes`}, the `MODULES` registry, and the rules as pure
functions: `parseEntitledModules(env)` (unset/blank = everything; required always in; unknown
ids reported), `resolveModuleActivation(entitled, requested)` (required always in; refuses
not-entitled and missing-dependency with a human-readable reason), `effectiveModules(entitled,
activated)` (required ∪ entitled ∩ activated, dependency-broken modules dropped).
- **DB** — `site_config.modules_json` (nullable JSON array; null = everything entitled),
migration `0026_site_modules` (hand-written + journal entry: `drizzle-kit generate` needs a
TTY and this repo's snapshots stop at 0003 — migrations have been hand-written since).
- **Server** — `apps/server/src/modules.ts`: `entitledModules()` (env, read per request),
`activatedModulesOf(row)`, `effectiveModulesFor(db)`, and the **`requireModule(db, id)`**
preHandler (403, `code: "module_disabled"`), composed BEFORE `requirePermission` in a
preHandler array so a disabled module answers identically for every role.
`apps/server/src/modules/index.ts` iterates `MODULES` and calls each folder-based module's
`register(app, deps)` (today: `modules/validation/index.ts` → `routes/validations.ts`,
unchanged location, now guarded); boot logs `{entitled, effective}` so "why is X missing" is
answerable from the container log. `routes/site.ts`: GET returns `modules` / `modulesEntitled`
/ `modulesActivated`; PUT accepts the full desired `modules` set, validates via the shared
rules (400 with the reason), and signs one `config_change` `{setting: "modules.<id>", value,
prev, operator}` per module whose effective state actually flips (no-op resaves sign nothing).
`routes/auth.ts` `sessionView` carries `modules` so the SPA can hide nav on first paint.
- **Web** — `apps/web/src/lib/modules.ts` (`moduleOn(user, id)`, `WebModule` {nav, routes(root)}),
`apps/web/src/modules/index.ts` (`WEB_MODULES`), `modules/validation/index.tsx` (the
`/validate` route + nav entry, gated on module-on + permission). `router.tsx` spreads
`WEB_MODULES` into the header nav and the route tree and no longer names the validate screen.
`SiteSettings.tsx`: a **Modules** panel listing the entitled modules (required ones shown
disabled, dependencies shown as a hint); each flip PUTs the full set and shows the server's
refusal reason verbatim; the merchant-validation section only renders when `validation` is
effective. i18n `modules.*` (en + sq).
**Gotcha found in the browser check:** route-context consumers (the header nav) only re-read
the router context on navigation, so `setUser(freshMe)` alone left the nav stale after a
flip — `App.tsx` now `router.invalidate()`s whenever `user` changes (fixes the same latent
issue for every other `setUser` caller). The programs fetch is also gated on the module being
effective, so opening Setup → Site with validation off no longer logs a 403 to app_logs.
Verified live (Playwright against the Vite dev server): flip off → "Validations" leaves the
header and the validation sections hide; flip on → both return, no reload.
- **Deploy** — `MODULES_ENTITLED=parking,validation` added explicitly to both booth stacks in
`komodo/resources.toml`; documented in `apps/server/.env.example`.
- **Lavazh station retired** (step 1): `STATIONS = ["bar"]`; existing `lavazh` program rows are
untouched data (the server accepts any kebab slug) — they simply have no checkbox now.
- **Tests** — `apps/server/src/modules.test.ts` (7): defaults; deactivate → 403
`module_disabled` + signed flip + reversible; required can't be deactivated; unknown id → 400;
no-op resave signs nothing; `MODULES_ENTITLED=parking` → not offered, not activatable, routes
403; required entitled even when omitted, unknown ids ignored. Full suite 329/329.
- **Acceptance test for Car Wash** (unchanged): one manifest entry, one `SERVER_MODULES` line,
one `WEB_MODULES` line, its two folders, its migration — nothing else in the core touched.
## Open questions to settle before building ## Open questions to settle before building
- ~~Platform name~~ — **settled 2026-09-05: it stays `parking-system` / `com.parking.desktop`.** - ~~Platform name~~ — **settled 2026-09-05: it stays `parking-system` / `com.parking.desktop`.**
+15
View File
@@ -2922,3 +2922,18 @@ for the Bar until a Bar module exists and absorbs it. Only the Lavazh station is
Wash ships (Car Wash sponsors parking via its own order event). Two sponsorship mechanisms Wash ships (Car Wash sponsors parking via its own order event). Two sponsorship mechanisms
coexist for now, accepted. Build order updated; validation is registered as its own module in the coexist for now, accepted. Build order updated; validation is registered as its own module in the
registry so Bar can later depend on or absorb it. registry so Bar can later depend on or absorb it.
## [2026-09-05] feat | Venue-module registry built (pilot groundwork): entitled ∩ activated, requireModule, Setup panel
Implemented build-order steps 1 + 3 of [[venue-modules]]: Lavazh validation station retired
(STATIONS = ["bar"], rows untouched); @parking/shared gains MODULE_IDS / ModuleManifest / MODULES
(parking required, validation dependsOn parking) plus the pure rule functions; site_config
gets modules_json (migration 0026, hand-written — drizzle-kit generate needs a TTY and the
snapshots end at 0003); server modules.ts adds requireModule(db, id) (403 module_disabled,
composed before requirePermission), modules/index.ts registers folder-based modules by
iterating the registry (validation is the first), site-config GET/PUT expose and set the
activation with dependency rules and one signed config_change per module that actually flips,
/api/auth/me carries the effective set; web gains lib/modules.ts + modules/{index,validation}
and router.tsx spreads WEB_MODULES into nav + route tree, SiteSettings gets a Modules panel and
hides the validation section when the module is off; MODULES_ENTITLED set explicitly in both
booth stacks. 7 new server tests, suite 329/329, web build clean. Car Wash next.