fix(modules): Car Wash depends on parking only — the discount engine is core, not the validation module
Build & push images / images (push) Successful in 2m51s

A site entitled to parking,carwash had the wash silently dropped as dependency-broken.
The validation program routes (compose/read) leave the validation module gate; the
merchant scan routes (mine/lookup/apply/void) stay behind it.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-06 10:40:01 +02:00
parent acde3bba5b
commit 2aa1045ddc
5 changed files with 55 additions and 20 deletions
+21 -5
View File
@@ -69,9 +69,12 @@ describe("activation (site admin)", () => {
expect(put.json().modules).toEqual(["parking"]); expect(put.json().modules).toEqual(["parking"]);
expect(put.json().modulesActivated).toEqual(["parking"]); expect(put.json().modulesActivated).toEqual(["parking"]);
const off = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); // The merchant scan routes are the module → 403; the PROGRAM routes are core (the
// discount engine serves Car Wash too) → still 200 with validation off.
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
expect(off.statusCode).toBe(403); expect(off.statusCode).toBe(403);
expect(off.json().code).toBe("module_disabled"); expect(off.json().code).toBe("module_disabled");
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
expect(me.json().modules).toEqual(["parking"]); expect(me.json().modules).toEqual(["parking"]);
@@ -116,15 +119,28 @@ describe("activation (site admin)", () => {
expect(put.statusCode).toBe(400); expect(put.statusCode).toBe(400);
}); });
it("dependency rule: carwash cannot be on while validation is off", async () => { it("carwash runs without the validation module (the discount engine is core)", async () => {
const { cookie, csrf } = await admin(); const { cookie, csrf } = await admin();
const put = await app.inject({ const put = await app.inject({
method: "PUT", url: "/api/site-config", method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf }, headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "carwash"] }, payload: { modules: ["parking", "carwash"] },
}); });
expect(put.statusCode).toBe(400); expect(put.statusCode).toBe(200);
expect(put.json().error).toMatch(/requires "validation"/); expect(put.json().modules).toEqual(["parking", "carwash"]);
// The wash's sponsorship program is still composable and readable.
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie } })).statusCode).toBe(200);
});
it("dependency rule: a module cannot be on while a module it depends on is off", async () => {
const { cookie, csrf } = await admin();
// Every non-required module depends on parking, and parking is required — so the rule
// is exercised through the effective-set helper directly.
const shared = await import("@parking/shared");
expect(shared.resolveModuleActivation(["parking", "validation", "carwash"], ["carwash"])).toMatchObject({ ok: true });
expect(shared.effectiveModules(["parking", "carwash"], ["parking", "carwash"])).toEqual(["parking", "carwash"]);
expect(cookie && csrf).toBeTruthy();
}); });
it("a no-op resave signs nothing", async () => { it("a no-op resave signs nothing", async () => {
@@ -161,7 +177,7 @@ describe("entitlement (vendor env)", () => {
expect(put.statusCode).toBe(400); expect(put.statusCode).toBe(400);
expect(put.json().error).toMatch(/not entitled/); expect(put.json().error).toMatch(/not entitled/);
const off = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
expect(off.statusCode).toBe(403); expect(off.statusCode).toBe(403);
}); });
+9 -6
View File
@@ -82,12 +82,15 @@ 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> {
// Every route is behind the venue-module gate FIRST (403 module_disabled when the // The PROGRAM routes (compose / read discount programs) are CORE: the discount engine
// site has validation off — see ../modules.ts), then the usual permission guard. // serves every module that grants a parking discount (Car Wash's "carwash" program
const moduleOn = requireModule(db, "validation"); // rides it), so they are never behind the validation module gate — plain site:read /
const siteRead = [moduleOn, requirePermission("site:read")]; // site:update. The MERCHANT routes (mine / lookup / apply / void — the scan screen)
const siteWrite = [moduleOn, requirePermission("site:update")]; // are the validation module itself: module gate FIRST (403 module_disabled when the
const applyGuard = [moduleOn, requirePermission("validation:create")]; // site has validation off — see ../modules.ts), then the permission.
const siteRead = requirePermission("site:read");
const siteWrite = requirePermission("site:update");
const applyGuard = [requireModule(db, "validation"), requirePermission("validation:create")];
const liveProgram = (id: string) => const liveProgram = (id: string) =>
db db
+5 -4
View File
@@ -1881,12 +1881,13 @@ export const MODULES: readonly ModuleManifest[] = [
jobs: [{ id: "merchant", permissions: ["validation:create"] }], jobs: [{ id: "merchant", permissions: ["validation:create"] }],
}, },
{ {
// The pilot module. Depends on parking (the wash sits inside the park; the ticket // The pilot module. Depends on parking only (the wash sits inside the park; the
// IS the customer identity) and on validation (the sponsorship engine: a completed // ticket IS the customer identity). The parking-discount ENGINE (validation programs +
// wash applies the site's "carwash" validation program to the session). // applyValidation) is CORE — the `validation` module is just the merchant's scan
// screen — so a site can run Car Wash without any merchant validation (2026-09-06).
id: "carwash", id: "carwash",
required: false, required: false,
dependsOn: ["parking", "validation"], dependsOn: ["parking"],
resources: ["carwash"], resources: ["carwash"],
ledgerEventTypes: ["carwash_order", "carwash_payment"], ledgerEventTypes: ["carwash_order", "carwash_payment"],
// Money taken AT THE BAY lands on the wash operator's own till, never the booth's. // Money taken AT THE BAY lands on the wash operator's own till, never the booth's.
+10 -5
View File
@@ -230,9 +230,13 @@ vehicle. The Hikvision push's `detectionTarget` only says `vehicle`/`human` on t
`programMode` for audit. The `programMode` for audit. The
site admin configures, for the car wash, the same program shape a merchant validation has site admin configures, for the car wash, the same program shape a merchant validation has
(comp / first N minutes free / amount / percent, max per day); a completed wash applies it (comp / first N minutes free / amount / percent, max per day); a completed wash applies it
to the customer's session automatically, attributed to the wash operator. So the module to the customer's session automatically, attributed to the wash operator. ~~So the module
`dependsOn` **validation** (the sponsorship engine) as well as parking, and the earlier `dependsOn` **validation** (the sponsorship engine) as well as parking~~ — **corrected
"own event, validation absorbed later" idea is superseded: validation IS the engine. With 2026-09-06:** the discount ENGINE (program rows + `applyValidation()`) is CORE; the
`validation` module is only the merchant's scan screen. Car Wash depends on parking alone
(a site set to `MODULES_ENTITLED=parking,carwash` had the wash silently dropped as
"dependency broken" — the user's first field test). The earlier "own event, validation
absorbed later" idea stays superseded: the engine IS the shared piece. With
program = comp, an in-bay-paid wash lets the car out at the reader; with a partial program program = comp, an in-bay-paid wash lets the car out at the reader; with a partial program
the remainder is still paid at the booth (the reader refuses, as for any unpaid session). the remainder is still paid at the booth (the reader refuses, as for any unpaid session).
@@ -346,8 +350,9 @@ at the two seams the design names, and the registry earned its keep: **one manif
for a quote and, if the sponsorship made it zero-due, signs the $0 parking payment via for a quote and, if the sponsorship made it zero-due, signs the $0 parking payment via
`PayStation.pay()`. A partial sponsorship leaves the remainder for the booth (verified). `PayStation.pay()`. A partial sponsorship leaves the remainder for the booth (verified).
- **Modules reach the core only via `ServerModuleDeps`** (db, eventLog, payStation, - **Modules reach the core only via `ServerModuleDeps`** (db, eventLog, payStation,
shiftService) — no module imports another; `dependsOn: ["parking", "validation"]` is enforced shiftService) — no module imports another; `dependsOn: ["parking"]` (validation dropped
by the activation rules (verified: carwash cannot be on with validation off). 2026-09-06; the program routes moved out from behind the validation gate — the merchant
scan routes stay gated).
- **Deploy gotcha (2026-09-06):** `MODULES_ENTITLED` reaches the container ONLY through - **Deploy gotcha (2026-09-06):** `MODULES_ENTITLED` reaches the container ONLY through
`docker-compose.yml`'s `environment:` block — a value in the Komodo stack env alone is just `docker-compose.yml`'s `environment:` block — a value in the Komodo stack env alone is just
compose interpolation input. It was missing there, so every booth on `55d6242` had Car Wash compose interpolation input. It was missing there, so every booth on `55d6242` had Car Wash
+10
View File
@@ -3039,3 +3039,13 @@ every module → every booth on 55d6242 had Car Wash entitled. Fix: compose forw
default `parking,validation`. Troubleshoot on a booth with `docker exec … env | grep MODULES` default `parking,validation`. Troubleshoot on a booth with `docker exec … env | grep MODULES`
and the boot log line `venue modules (entitled = …; effective = …)`. Recorded on and the boot log line `venue modules (entitled = …; effective = …)`. Recorded on
[[venue-modules]] §As-built (deploy gotcha). [[venue-modules]] §As-built (deploy gotcha).
## [2026-09-06] ingest | Car Wash no longer depends on the validation module
User set `MODULES_ENTITLED=parking,carwash` on park-2 — no Lavazh. Cause: the manifest said
carwash `dependsOn: ["parking","validation"]`, so the effective set dropped it as dependency-
broken, and the program compose/read routes sat behind the validation module gate. That was a
design error: the discount ENGINE (validation program rows + `applyValidation()`) is core; the
`validation` module is only the merchant's scan screen. Fixed: `dependsOn: ["parking"]`; the
program routes are plain site:read/site:update; the merchant routes (mine/lookup/apply/void)
stay module-gated. Tests updated. Recorded on [[venue-modules]] (v1 answers item 4 + As-built).