7 Commits

Author SHA1 Message Date
julian ea304bbfd1 chore(resources): update TAG to stage-2aa1045 for deployment
Build desktop / desktop (push) Successful in 4m44s
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 45s
2026-09-06 10:51:05 +02:00
julian 2aa1045ddc 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
2026-09-06 10:40:01 +02:00
julian acde3bba5b feat(resources): entitle park-2 to only have the Booth and the Car Wash module. Not the Merchant Validations.
Build & push images / images (push) Successful in 2m51s
2026-09-06 10:33:48 +02:00
julian 9a13528611 fix(deploy): forward MODULES_ENTITLED into the server container (default parking,validation)
Build & push images / images (push) Successful in 3m19s
The Komodo stack env alone is compose interpolation input; only variables in the
service's environment: block reach the container. Without it every booth on 55d6242
had Car Wash entitled (unset = every module).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 10:29:36 +02:00
julian 6f88026d3e testing(resources): remove carwash and merchan validations modules from park-2 stack.
Build & push images / images (push) Successful in 3m19s
2026-09-06 10:18:08 +02:00
julian c481c1e788 chore(resources): entitle park-2 to the Car Wash module
Build & push images / images (push) Successful in 2m52s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 14:54:40 +02:00
julian 3a7c3fae11 bump(resources park-2): bump TAG to stage-55d6242
Build & push images / images (push) Successful in 2m53s
2026-09-05 14:52:41 +02:00
8 changed files with 85 additions and 24 deletions
+4 -2
View File
@@ -82,6 +82,8 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
# Comma-separated ids of the modules this site is ENTITLED to (a vendor/deployment # Comma-separated ids of the modules this site is ENTITLED to (a vendor/deployment
# decision — set in the Komodo stack env, never by a site role). The site admin then # decision — set in the Komodo stack env, never by a site role). The site admin then
# ACTIVATES within this set in Setup → Site; effective = entitled ∩ activated. Unset or # ACTIVATES within this set in Setup → Site; effective = entitled ∩ activated. Unset or
# blank = every registered module (parking,validation). Required modules (parking) are # blank = every registered module (parking,validation,carwash) — a DEV convenience. In
# always on. See wiki/decisions/venue-modules.md. # Docker, docker-compose.yml forwards it with a default of parking,validation, so a booth
# is never entitled to a module its Komodo stack env does not name. Required modules
# (parking) are always on. See wiki/decisions/venue-modules.md.
#MODULES_ENTITLED=parking,validation #MODULES_ENTITLED=parking,validation
+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
+6
View File
@@ -35,6 +35,12 @@ services:
# The booth WS live feed checks the browser Origin — must list the address operators # The booth WS live feed checks the browser Origin — must list the address operators
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected. # actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-} WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
# Venue modules this site is ENTITLED to (vendor decision, per stack in Komodo; the site
# admin activates within this set in Setup → Site). Only variables listed HERE reach the
# container — a value in the Komodo stack env alone does nothing (found 2026-09-06: every
# booth had Car Wash on). Default = what booths had before modules existed; the server
# treats a BLANK value as "every module", so never set it to "" on a booth.
MODULES_ENTITLED: ${MODULES_ENTITLED:-parking,validation}
volumes: volumes:
- parking-data:/data - parking-data:/data
depends_on: depends_on:
+3 -2
View File
@@ -85,11 +85,12 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds # Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
# exists as the pointer; we deploy the sha, not the mover. # exists as the pointer; we deploy the sha, not the mover.
TAG=stage-8fa66c9 TAG=stage-2aa1045
COOKIE_SECURE=0 COOKIE_SECURE=0
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within # Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md. # this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
MODULES_ENTITLED=parking,validation # park-2 pilots the Car Wash module (2026-09-05).
MODULES_ENTITLED=parking,carwash
VISION_ENABLED=1 VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach # platform-ws.ts, since the native WS plugin has no page context to auto-attach
+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.
+17 -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,16 @@ 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
`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
on (unset = everything). Fixed: compose forwards it with a default of `parking,validation`;
a booth is never entitled to a module its stack env does not name. Check on the box:
`docker exec <stack>-server-1 env | grep MODULES_ENTITLED` and the boot log line
`venue modules (entitled = …; effective = …)`.
- **Web:** `/wash` (the desk: ticket lookup → category/service/price → order, the site's - **Web:** `/wash` (the desk: ticket lookup → category/service/price → order, the site's
booth|bay policy shown read-only; booth|bay policy shown read-only;
the queue oldest-first with Done / Paid cash / Paid card / Void) and `/setup/carwash` the queue oldest-first with Done / Paid cash / Paid card / Void) and `/setup/carwash`
+20
View File
@@ -3029,3 +3029,23 @@ every `jwtVerify` resolves the user's CURRENT role from the DB (cached per user,
`bumpPermsCache()`, which the user update/delete routes now call); a deleted user's session `bumpPermsCache()`, which the user update/delete routes now call); a deleted user's session
ends with 401 on its next request; the WS cookie path uses the same. Test: moved user creates ends with 401 on its next request; the WS cookie path uses the same. Test: moved user creates
an order on the next request with the same cookie. Recorded on [[local-jwt-auth]]. an order on the next request with the same cookie. Recorded on [[local-jwt-auth]].
## [2026-09-06] ingest | MODULES_ENTITLED never reached the container
User set park-2 to `MODULES_ENTITLED=parking`, re-synced, destroyed + redeployed the stack —
Lavazh still there. Cause: the variable was in the Komodo stack env and `.env.example` but not
in `docker-compose.yml`'s server `environment:` block, so the container never saw it; unset =
every module → every booth on 55d6242 had Car Wash entitled. Fix: compose forwards it with
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).