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
# 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
# blank = every registered module (parking,validation). Required modules (parking) are
# always on. See wiki/decisions/venue-modules.md.
# blank = every registered module (parking,validation,carwash) — a DEV convenience. In
# 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
+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
+6
View File
@@ -35,6 +35,12 @@ services:
# 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.
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:
- parking-data:/data
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
# :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.
TAG=stage-8fa66c9
TAG=stage-2aa1045
COOKIE_SECURE=0
# 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.
MODULES_ENTITLED=parking,validation
# park-2 pilots the Car Wash module (2026-09-05).
MODULES_ENTITLED=parking,carwash
VISION_ENABLED=1
# 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
+5 -4
View File
@@ -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.
+17 -5
View File
@@ -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,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
`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
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
booth|bay policy shown read-only;
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
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]].
## [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).