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().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);
});
+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> {
// 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