diff --git a/apps/server/src/modules.test.ts b/apps/server/src/modules.test.ts index 952e22c..217c98a 100644 --- a/apps/server/src/modules.test.ts +++ b/apps/server/src/modules.test.ts @@ -69,9 +69,12 @@ describe("activation (site admin)", () => { 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 } }); + // 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.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 } }); expect(me.json().modules).toEqual(["parking"]); @@ -116,15 +119,28 @@ describe("activation (site admin)", () => { 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 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); - expect(put.json().error).toMatch(/requires "validation"/); + expect(put.statusCode).toBe(200); + 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 () => { @@ -161,7 +177,7 @@ describe("entitlement (vendor env)", () => { 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 } }); + const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } }); expect(off.statusCode).toBe(403); }); diff --git a/apps/server/src/routes/validations.ts b/apps/server/src/routes/validations.ts index 380834e..f78112c 100644 --- a/apps/server/src/routes/validations.ts +++ b/apps/server/src/routes/validations.ts @@ -82,12 +82,15 @@ function validateProgram(b: ProgramBody): string | null { } export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise { - // 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")]; + // The PROGRAM routes (compose / read discount programs) are CORE: the discount engine + // serves every module that grants a parking discount (Car Wash's "carwash" program + // rides it), so they are never behind the validation module gate — plain site:read / + // site:update. The MERCHANT routes (mine / lookup / apply / void — the scan screen) + // are the validation module itself: module gate FIRST (403 module_disabled when the + // 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) => db diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 66471cd..c0ebaed 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1881,12 +1881,13 @@ export const MODULES: readonly ModuleManifest[] = [ jobs: [{ id: "merchant", permissions: ["validation:create"] }], }, { - // The pilot module. Depends on parking (the wash sits inside the park; the ticket - // IS the customer identity) and on validation (the sponsorship engine: a completed - // wash applies the site's "carwash" validation program to the session). + // The pilot module. Depends on parking only (the wash sits inside the park; the + // ticket IS the customer identity). The parking-discount ENGINE (validation programs + + // 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", required: false, - dependsOn: ["parking", "validation"], + dependsOn: ["parking"], resources: ["carwash"], ledgerEventTypes: ["carwash_order", "carwash_payment"], // Money taken AT THE BAY lands on the wash operator's own till, never the booth's. diff --git a/wiki/decisions/venue-modules.md b/wiki/decisions/venue-modules.md index eda53b1..a8ff654 100644 --- a/wiki/decisions/venue-modules.md +++ b/wiki/decisions/venue-modules.md @@ -230,9 +230,13 @@ vehicle. The Hikvision push's `detectionTarget` only says `vehicle`/`human` on t `programMode` for audit. The 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 - 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 - "own event, validation absorbed later" idea is superseded: validation IS the engine. With + to the customer's session automatically, attributed to the wash operator. ~~So the module + `dependsOn` **validation** (the sponsorship engine) as well as parking~~ — **corrected + 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 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 `PayStation.pay()`. A partial sponsorship leaves the remainder for the booth (verified). - **Modules reach the core only via `ServerModuleDeps`** (db, eventLog, payStation, - shiftService) — no module imports another; `dependsOn: ["parking", "validation"]` is enforced - by the activation rules (verified: carwash cannot be on with validation off). + shiftService) — no module imports another; `dependsOn: ["parking"]` (validation dropped + 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 `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 diff --git a/wiki/log.md b/wiki/log.md index b004e80..a4e5b1b 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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` and the boot log line `venue modules (entitled = …; effective = …)`. Recorded on [[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).