Compare commits
7 Commits
55d6242c7d
...
ea304bbfd1
| Author | SHA1 | Date | |
|---|---|---|---|
| ea304bbfd1 | |||
| 2aa1045ddc | |||
| acde3bba5b | |||
| 9a13528611 | |||
| 6f88026d3e | |||
| c481c1e788 | |||
| 3a7c3fae11 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user