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().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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user