Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix",
open-questions #16) — the grid stays the enforcement layer:
- Move 1: each desk's money is guarded by that desk's own permissions. Manifest
tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create
(unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes
resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot
touch the booth by construction. Replaces the session:read borrowing (tillPermission).
/api/shift/tills lists the role's readable tills with canWork; history/movements
without a till filter return the union of readable tills.
- Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor,
merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and
"partial job" lints (warnings, never blocks).
- Move 3: the live WebSocket admits any watch permission (event/session/device read or
a module's feedPermission) and filters every push per role; report:read is the
reports screen only.
Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves
the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's
role applies on the next request and a deleted user's session ends with 401.
Tests: till guards + look-only role, feed rules, every job's permissions exist, role
reassignment without re-login. 353/353.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Self-service profile: any signed-in user edits their OWN fullName/email and
changes their OWN password (proving the current one), without any user:*
permission. New routes PUT /api/auth/profile + /api/auth/password act only on
req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile
reachable from the header username chip. email added to the session view +
SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green.
Desktop in CI: new .gitea/workflows/build-desktop.yml builds .deb + .AppImage
on every push to dev/main and uploads them as unsigned workflow artifacts
(per-commit test build). Signed/versioned release stays on release.yml (tag v*).
Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI
split), log entry.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were
hard and unrecoverable. Now they soft-delete into a recycle bin.
Schema (migration 0012): nullable deleted_at + deleted_by on users, roles,
subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified
against a copy of the live DB.
Backend: each resource's DELETE route STAMPS instead of removing; every
catalog list filters deleted_at IS NULL. New recycle-bin module + routes
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a
new recyclebin:read/update/delete permission. A 6-hourly + startup sweep
auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 =
forever).
Invariants: soft-deleted users can't log in (login rejects deleted_at;
no-lockout counts live admins only); a soft-deleted subscription doesn't
open the barrier; plans are versioned so a delete stamps all versions of
the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't
auto-cascade a dangling role (guard resolves missing role to empty perms).
The signed append-only ledger is OUT of scope (no delete path).
Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge +
purge confirm; api client + i18n (sq + en parity).
Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4
integration: delete -> can't-login -> restore -> login, purge, gating,
409 reuse). server 103/103; build+lint+test 19/19.
Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Documents the deploy-time requirement that the cookie fail-safe fix (7629d5d)
introduced: the LAN appliance serves the SPA same-origin over plain http, where a
Secure cookie is never sent — so it MUST set COOKIE_SECURE=0 or operators can't log
in. A TLS deploy leaves it unset.
- wiki/concepts/disk-os-hardening.md: new "Deploy-time server configuration (runbook)"
section listing the security-load-bearing env (JWT_SECRET, EVENT_SIGNING_KEY,
COOKIE_SECURE=0) with the why + the network-scoped justification.
- wiki/entities/local-jwt-auth.md: corrected the stale "Secure when NODE_ENV=production"
cookie line to the Secure-by-default / opt-out model.
- wiki/log.md: entry.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Sync the two canonical reference pages with this session's features: local-jwt-auth
gains the new log resource / log:read permission in the RBAC grid (links app-logs);
first-run-setup notes the one-car-one-ticket presence-loop/cooldown guard the admin
configures on a relay (links entry-double-press).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The dynamic-RBAC management routes are themselves grantable (role:* and
user:*), so a non-admin holding them could self-escalate: edit their own
role to add a permission they lack, mint a privileged role, assign someone
the admin role, or reset/delete a more-privileged account. Found by the
commit security review (2× HIGH).
Fix — enforce the RBAC invariant "you cannot grant beyond yourself":
- roles.ts: role:create/update reject any permission not held by the caller
(escalates()). An admin holds the full set, so it stays unrestricted.
- users.ts: user:create/update reject assigning a role whose permissions
exceed the caller's; update/password-reset/delete reject acting on a user
whose current role exceeds the caller's (exceedsCaller()).
The existing no-lockout + builtin-admin protections are unchanged.
Verified: 10-assertion inject test — manager (role:* + user:* but no
tariff:update, not admin) gets 403 on self-grant, minting a privileged role,
assigning/resetting/deleting an admin; admin stays unrestricted; the manager
can still create peers + in-scope roles (not over-blocked). Full build green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Booth reality breaks a fixed clock (relief late/absent, forced double shifts),
and a shift is a separate explicit boundary. Drop expiresIn from the global jwt
config and from login; the token carries no exp. Cookie maxAge = 30 days so a
browser restart doesn't log out an active operator; logout still clears it.
Replace the dev-only token shim with real authentication.
Backend:
- @fastify/cookie; JWT carried in an HttpOnly + SameSite=Strict cookie
(parking_token), read from the cookie not the Authorization header.
- Double-submit CSRF: readable parking_csrf cookie + X-CSRF-Token header, both
cross-checked against a csrf claim baked into the JWT; enforced on mutations.
- Routes: POST /api/auth/login (bcrypt, constant-time-ish), POST logout,
GET me. requireRole now verifies the cookie + CSRF + role.
- seed-admin script (pnpm --filter @parking/server seed-admin) for the first
admin; no bootstrap endpoint.
- Removed SETUP_AUTH_BYPASS and catalog.authBypass entirely; setup endpoints
use the cookie admin guard like everything else.
Frontend:
- apiFetch wrapper: credentials:'include' + X-CSRF-Token on mutations.
- Login form; App gates on /api/auth/me and only shows setup to admins; logout.
- Wizard token field removed (auth is the session cookie).
Deploy:
- deploy/nginx.conf: prod reverse proxy, SPA + /api same-origin, TLS, so the
Secure cookies work. Dev stays same-origin via the Vite proxy.
Verified (curl + browser): wrong pass -> 401; login sets cookies; me -> admin;
assign without CSRF -> 403, with -> 201; no cookie -> 401; session persists
across reload. wiki/local-jwt-auth updated.
- open-questions #7: symmetric vs. asymmetric JWT signing key (from the
commit security review). Prefer RS256/EdDSA so verifying hosts hold only a
public key — mirrors the ATECC608 / challenge-response "public key only"
property. Decide before multi-host/multi-lane deployment.
- Mark esp32-custom-controller status: deferred per decision not to build
device-level auth now; access control stays on UHPPOTE + network isolation
(noted in open-questions #6).
- local-jwt-auth: document hardened secret handling + 8h expiry and the
asymmetric-key pointer.
- Update index.md and append a log.md entry.
Turborepo (pnpm workspaces) with all dependencies pinned to latest
mutually-compatible versions: turbo 2.9, TypeScript 6, Fastify 5,
React 19, Vite 8, better-sqlite3 12 + Drizzle ORM 0.45.
Layout:
- apps/server Fastify backend (local JWT auth + role guard, /health)
- apps/web React 19 + Vite 8 operator SPA
- packages/db Drizzle schema on SQLite/WAL; append-only events + users
- packages/devices reader/printer/relay adapter interfaces (intent-only relay)
- packages/shared shared domain types
Architecture constraints from the design wiki are encoded in the scaffold:
append-only hash-chained + signed event log, device-agnostic adapters,
"a barrier is not a door" (relay expresses intent only), fully-local
offline-first auth.
wiki/ is an LLM-maintained Obsidian knowledge base (28 pages) ingested
from the architecture & design notes, with its own maintenance schema.
Verified: pnpm install, full turbo build (5/5), server boots and serves
/health, drizzle-kit generates the initial migration.