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
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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -82,12 +82,15 @@ function validateProgram(b: ProgramBody): string | null {
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user