Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 215a3ac405 | |||
| e0cfeb5e71 | |||
| 8129b63a8c | |||
| f9bd586265 | |||
| aa546235fb | |||
| c637b2783c | |||
| 77b2acb1ca | |||
| 10923164ad | |||
| 0a22eab4a8 | |||
| 9d65099d9b | |||
| 8155ff456b | |||
| 492a08a079 | |||
| 8a437d0c4b |
@@ -0,0 +1,44 @@
|
|||||||
|
# Build context hygiene for the server + vision images (context = repo root).
|
||||||
|
# Keep the context small and NEVER bake build artifacts, secrets, or the live DB.
|
||||||
|
|
||||||
|
# Node / build outputs (rebuilt inside the image)
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/.turbo/
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# Python (vision) — rebuilt by uv inside the image
|
||||||
|
**/.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
**/.mypy_cache/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/.ruff_cache/
|
||||||
|
|
||||||
|
# Secrets + local env (the image gets config via runtime env, never baked)
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
|
||||||
|
# NEVER bake the live signed-ledger DB (or any of its WAL/SHM/backup variants) into an
|
||||||
|
# image — it lives on a mounted volume. Match the base file AND every -wal/-shm/.bak-*
|
||||||
|
# sibling (deploy copies the package dir's files, ignoring .gitignore).
|
||||||
|
**/*.sqlite
|
||||||
|
**/*.sqlite-*
|
||||||
|
**/parking.sqlite*
|
||||||
|
|
||||||
|
# Desktop app is built by its own tag-only release.yml, not these images
|
||||||
|
apps/desktop/
|
||||||
|
|
||||||
|
# VCS, logs, caches, editor cruft
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
*.log
|
||||||
|
**/.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Wiki raw sources / large docs (not needed to build)
|
||||||
|
wiki/raw/
|
||||||
|
|
||||||
|
# Plans / scratch
|
||||||
|
.planning/
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
name: Build desktop
|
||||||
|
|
||||||
|
# Build the Tauri desktop installers (.deb + .AppImage) on every push to dev/main and
|
||||||
|
# upload them as workflow ARTIFACTS — a downloadable, per-commit build for testing the
|
||||||
|
# native shell. This is NOT a release: it's unsigned (no updater key) and creates no Gitea
|
||||||
|
# Release. Signed, versioned releases stay on release.yml (tag v* → .deb/.rpm/.AppImage +
|
||||||
|
# latest.json for the auto-updater). See wiki/decisions/desktop-shell-tauri.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
paths:
|
||||||
|
- 'apps/desktop/**'
|
||||||
|
- 'apps/web/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.gitea/workflows/build-desktop.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
desktop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install Tauri system deps
|
||||||
|
# Same set release.yml uses (verified): WebKitGTK 4.1 + libsoup-3 + the GTK/
|
||||||
|
# appindicator/rsvg stack + AppImage tooling (patchelf, file).
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
file \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo + target
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
apps/desktop/src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build desktop bundle (.deb + .AppImage)
|
||||||
|
# Unsigned — no TAURI_SIGNING_* here (this is a test artifact, not an updater
|
||||||
|
# release). The config sets createUpdaterArtifacts:true (release.yml signs them),
|
||||||
|
# which makes tauri DEMAND the signing key and fail without it — so override it to
|
||||||
|
# false for this build via --config (a JSON patch merged over tauri.conf.json).
|
||||||
|
# --bundles restricts to the two installers we ship; tauri builds the web SPA
|
||||||
|
# first (beforeBuildCommand), so the desktop UI matches.
|
||||||
|
run: >
|
||||||
|
pnpm --filter @parking/desktop bundle
|
||||||
|
--bundles deb,appimage
|
||||||
|
--config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||||
|
|
||||||
|
- name: Collect installers
|
||||||
|
id: collect
|
||||||
|
# Copy out the two installers under SPACE-FREE names (tauri names them
|
||||||
|
# "Parking System_0.0.0_amd64.deb" — spaces break asset URLs). Short SHA in the
|
||||||
|
# name so a downloaded file is traceable to its commit.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
|
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
|
||||||
|
mkdir -p dist
|
||||||
|
deb=$(find "$BUNDLE/deb" -name '*.deb' | head -1)
|
||||||
|
app=$(find "$BUNDLE/appimage" -name '*.AppImage' | head -1)
|
||||||
|
cp "$deb" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.deb"
|
||||||
|
cp "$app" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.AppImage"
|
||||||
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
|
- name: Publish to a rolling per-branch pre-release
|
||||||
|
# actions/upload-artifact's backend isn't reliable on this Gitea runner, so we
|
||||||
|
# publish to a Gitea RELEASE via the API instead (the proven pattern from
|
||||||
|
# release.yml — built-in token, plain curl). One ROLLING pre-release per branch
|
||||||
|
# (tag desktop-<branch>): delete + recreate each push so it always holds the
|
||||||
|
# latest dev/main installer. This is NOT the signed updater release (release.yml,
|
||||||
|
# tag v*) — it's a prerelease, unsigned, with no latest.json.
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.api_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: desktop-${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
auth="Authorization: token ${TOKEN}"
|
||||||
|
# Drop any existing rolling release for this branch (ignore if absent) so its
|
||||||
|
# tag + stale assets don't pile up; recreate it fresh below.
|
||||||
|
OLD=$(curl -sS -H "$auth" "${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -n "$OLD" ]; then
|
||||||
|
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/releases/${OLD}" || true
|
||||||
|
# Also delete the tag itself so the recreate points at this commit.
|
||||||
|
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/git/refs/tags/${TAG}" || true
|
||||||
|
fi
|
||||||
|
REL=$(curl -sS -X POST -H "$auth" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"Desktop build (${GITHUB_REF_NAME})\",\"body\":\"Unsigned per-commit desktop installers from ${GITHUB_REF_NAME} @ ${GITHUB_SHA}. Rolling — overwritten each push. Not an updater release.\",\"draft\":false,\"prerelease\":true}" \
|
||||||
|
"${API}/repos/${REPO}/releases")
|
||||||
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
echo "release id: ${REL_ID}"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "uploading ${name}"
|
||||||
|
curl -sS -X POST -H "$auth" -H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}" \
|
||||||
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
|
done
|
||||||
|
echo "done"
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
name: Build & push images
|
||||||
|
|
||||||
|
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
||||||
|
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, main→:main).
|
||||||
|
# Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle). Mirrors the
|
||||||
|
# house pattern (cf. trm/processor build.yml). See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
paths:
|
||||||
|
- 'apps/server/**'
|
||||||
|
- 'apps/web/**'
|
||||||
|
- 'apps/vision/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'docker-compose*.yml'
|
||||||
|
- '.dockerignore'
|
||||||
|
- '.gitea/workflows/build-images.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.infra.msai.al/mca/parking_solution
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
images:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Set up uv (for @parking/vision checks)
|
||||||
|
# Install uv via its official standalone script rather than a third-party action —
|
||||||
|
# the Gitea runner can't reliably resolve astral-sh/setup-uv. uv provisions the
|
||||||
|
# pinned Python (apps/vision/.python-version) itself. Add it to PATH for later steps.
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sync vision deps
|
||||||
|
working-directory: apps/vision
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
# Don't publish a broken image — run the same checks as ci.yml first.
|
||||||
|
- name: Build + lint + test (Turbo)
|
||||||
|
run: pnpm turbo run build lint test
|
||||||
|
|
||||||
|
- name: Compute tags
|
||||||
|
id: meta
|
||||||
|
# BRANCH = the pushed branch (dev|main); SHA = short commit. Two tags per image:
|
||||||
|
# the moving branch tag + an immutable branch-SHA tag.
|
||||||
|
run: |
|
||||||
|
BRANCH="${GITHUB_REF_NAME}"
|
||||||
|
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
|
||||||
|
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.infra.msai.al
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build & push SERVER (API + SPA)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/server/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||||
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Build & push VISION (ANPR)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: apps/vision
|
||||||
|
file: apps/vision/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}
|
||||||
|
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache,mode=max
|
||||||
|
|
||||||
|
# Optional: trigger a Komodo stack redeploy (cf. trm/processor). Enable by setting the
|
||||||
|
# KOMODO_* secrets; left guarded so it no-ops until the parking stack is wired.
|
||||||
|
- name: Trigger Komodo redeploy
|
||||||
|
if: success() && vars.KOMODO_ENABLED == 'true'
|
||||||
|
env:
|
||||||
|
URL: ${{ secrets.KOMODO_STACK_WEBHOOK_URL }}
|
||||||
|
SECRET: ${{ secrets.KOMODO_WEBHOOK_SECRET }}
|
||||||
|
run: |
|
||||||
|
body="{\"ref\":\"refs/heads/${GITHUB_REF_NAME}\"}"
|
||||||
|
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "X-Hub-Signature-256: sha256=$sig" \
|
||||||
|
-d "$body" \
|
||||||
|
"$URL"
|
||||||
+19
-2
@@ -31,9 +31,26 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Set up uv (Python toolchain for @parking/vision)
|
||||||
|
# The vision service is a Python package wired into the Turbo graph via a
|
||||||
|
# package.json shim; its lint/typecheck/test scripts shell to `uv run …`. CI
|
||||||
|
# has no Python by default, so `uv run` would fail with "uv: not found" and
|
||||||
|
# break the whole Turbo run. Install uv via its official standalone script
|
||||||
|
# (the Gitea runner can't reliably resolve astral-sh/setup-uv); uv provisions the
|
||||||
|
# pinned Python (.python-version) itself. See wiki/decisions/vision-service-packaging.md.
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sync vision deps
|
||||||
|
# Light deps + the dev group (ruff/mypy/pytest) only — NOT the optional `alpr`
|
||||||
|
# extra (heavy onnx/model stack), which isn't needed to lint/typecheck/test.
|
||||||
|
working-directory: apps/vision
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
- name: Build + lint (Turbo)
|
- name: Build + lint (Turbo)
|
||||||
# Covers tsc typecheck, vite build, and i18n catalog type-parity (a missing
|
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
|
||||||
# sq/en key fails the build). 14 tasks across the workspace.
|
# key fails the build), AND the vision service's ruff lint via uv.
|
||||||
run: pnpm turbo run build lint
|
run: pnpm turbo run build lint
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Booth reverse proxy. `:80` matches ANY hostname/IP, so the booth is reachable as
|
||||||
|
# http://<booth-ip>/, http://localhost/, or http://parksystems.msai.al/ (the name pointed
|
||||||
|
# at the booth's IP via hosts/DNS on-site) — with no domain baked into any image. The SPA
|
||||||
|
# uses a relative /api base, so everything (HTTP + the /api/ws WebSocket, which Caddy
|
||||||
|
# upgrades automatically) just flows through to the server container.
|
||||||
|
#
|
||||||
|
# TLS later: replace `:80` with the real hostname (e.g. `parksystems.msai.al`), uncomment
|
||||||
|
# Caddy's :443 in docker-compose.prod.yml, and Caddy auto-provisions HTTPS. For a private
|
||||||
|
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
||||||
|
:80 {
|
||||||
|
encode gzip
|
||||||
|
reverse_proxy server:3000
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# Parking SERVER image: Fastify API + the bundled React SPA (one container serves both —
|
||||||
|
# offline-first single appliance). Build CONTEXT is the REPO ROOT (it's a pnpm/turbo
|
||||||
|
# monorepo). better-sqlite3 is a native module → build stage needs node-gyp toolchain,
|
||||||
|
# runtime needs libstdc++. Mirrors the house multi-stage pattern (cf. trm/processor).
|
||||||
|
# See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
|
# ---- deps: cache-friendly pnpm fetch (only manifests change the layer) ----
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
# Workspace manifests + lock first, so the fetch layer caches across source edits.
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
|
COPY apps/server/package.json apps/server/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/vision/package.json apps/vision/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
COPY packages/devices/package.json packages/devices/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm fetch
|
||||||
|
|
||||||
|
# ---- build: install (offline from the fetched store) + turbo build everything ----
|
||||||
|
FROM deps AS build
|
||||||
|
ENV CI=true
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --offline
|
||||||
|
# Force the SPA to use a SAME-ORIGIN (relative) API base for THIS image. Vite auto-loads
|
||||||
|
# apps/web/.env.production, which sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
|
||||||
|
# DESKTOP build — but here Fastify serves the SPA same-origin, so an absolute base would
|
||||||
|
# make the browser hit 127.0.0.1:3000 cross-origin and fail CORS. `.env.production.local`
|
||||||
|
# has higher precedence than `.env.production`, so this empties it for the server image only.
|
||||||
|
RUN echo 'VITE_API_BASE=' > apps/web/.env.production.local
|
||||||
|
# Builds shared/db/devices, the server dist, AND the web SPA dist (apps/web/dist).
|
||||||
|
RUN pnpm turbo run build --filter=@parking/server --filter=@parking/web
|
||||||
|
# `pnpm deploy` produces a SELF-CONTAINED prod bundle for the server in /deploy: a hoisted
|
||||||
|
# node_modules with only @parking/server's prod deps (incl. the workspace packages' built
|
||||||
|
# dist + their native deps like better-sqlite3 — properly linked, unlike `prune` at root).
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm --filter=@parking/server --legacy deploy --prod /deploy
|
||||||
|
# The server's own dist + scripts (deploy copies the package's package.json + files, but we
|
||||||
|
# copy dist explicitly so the layout under /deploy is predictable). The web SPA + db
|
||||||
|
# migrations are copied in the runtime stage from their build locations.
|
||||||
|
|
||||||
|
# ---- runtime: slim, non-root ----
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||||
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|
||||||
|
# The self-contained deploy bundle: dist/ + a hoisted node_modules carrying the server's
|
||||||
|
# prod deps AND the workspace packages (@parking/db|devices|shared) with their built dist,
|
||||||
|
# the drizzle migrations, and the native better-sqlite3 binding. Single COPY — no scattered
|
||||||
|
# package dirs, no root node_modules.
|
||||||
|
COPY --from=build --chown=app:app /deploy ./
|
||||||
|
|
||||||
|
# The built SPA — served by Fastify static at WEB_DIST_DIR. (Not part of the server's deploy
|
||||||
|
# bundle, so copied from the web build output.)
|
||||||
|
COPY --from=build --chown=app:app /app/apps/web/dist ./web/dist
|
||||||
|
|
||||||
|
# DB lives on a mounted volume (never in the image). Default points at /data.
|
||||||
|
ENV DATABASE_URL=/data/parking.sqlite
|
||||||
|
ENV WEB_DIST_DIR=/app/web/dist
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
RUN mkdir -p /data && chown app:app /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
USER app
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- "http://localhost:${PORT:-3000}/health" >/dev/null 2>&1 || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Container entrypoint for the parking server. Applies DB migrations against the mounted
|
||||||
|
# volume (DATABASE_URL), optionally seeds the first admin, then execs the server. Idempotent:
|
||||||
|
# the runtime migrator (drizzle-orm migrator, no drizzle-kit) only applies pending migrations,
|
||||||
|
# so a restart is a no-op. See packages/db/scripts/migrate-runtime.mjs.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[entrypoint] DATABASE_URL=${DATABASE_URL}"
|
||||||
|
|
||||||
|
# Apply migrations against the mounted DB file (creates it + the schema on first boot).
|
||||||
|
# The migrator ships inside the @parking/db package in the deploy bundle's node_modules.
|
||||||
|
node node_modules/@parking/db/scripts/migrate-runtime.mjs
|
||||||
|
|
||||||
|
# Optional first-boot admin seed: set SEED_ADMIN=1 plus ADMIN_USER + ADMIN_PASS (the seed
|
||||||
|
# script PROMPTS when these are unset, which would hang a container — so require ADMIN_PASS).
|
||||||
|
# The seed is idempotent: it won't overwrite an existing user unless FORCE=1.
|
||||||
|
if [ "${SEED_ADMIN}" = "1" ]; then
|
||||||
|
if [ -z "${ADMIN_PASS}" ]; then
|
||||||
|
echo "[entrypoint] SEED_ADMIN=1 but ADMIN_PASS is unset — skipping seed (would hang on prompt)"
|
||||||
|
else
|
||||||
|
echo "[entrypoint] seeding admin (${ADMIN_USER:-admin})"
|
||||||
|
node scripts/seed-admin.mjs || echo "[entrypoint] seed-admin skipped/failed (non-fatal)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] starting server"
|
||||||
|
exec "$@"
|
||||||
@@ -418,7 +418,9 @@ export class ExitFlow {
|
|||||||
|
|
||||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const exited = rows.some((r) => r.type === "vehicle_exit");
|
// A `void` (cancelled ticket) closes the session like an exit, so a voided ticket
|
||||||
|
// presented at exit reads as "already closed" — never re-opens. See void-flow.ts.
|
||||||
|
const exited = rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
|
||||||
|
|
||||||
let paidAt: string | null = null;
|
let paidAt: string | null = null;
|
||||||
let graceExitMin: number | null = null;
|
let graceExitMin: number | null = null;
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ function exit(identity: string) {
|
|||||||
signature: "x", keyId: "test",
|
signature: "x", keyId: "test",
|
||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
|
function voidEvt(identity: string) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "void",
|
||||||
|
identity, payload: { sessionRef: identity, voidReason: "misprint" }, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
|
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
|
||||||
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
||||||
}
|
}
|
||||||
@@ -57,6 +65,12 @@ describe("occupancyCount", () => {
|
|||||||
entry("A"); exit("A"); entry("A");
|
entry("A"); exit("A"); entry("A");
|
||||||
expect(occupancyCount(db)).toBe(1);
|
expect(occupancyCount(db)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a voided (cancelled) entry does NOT count inside", () => {
|
||||||
|
entry("A"); entry("B");
|
||||||
|
voidEvt("B"); // B's ticket was a misprint — cancelled
|
||||||
|
expect(occupancyCount(db)).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getOccupancy — capacity + full gate", () => {
|
describe("getOccupancy — capacity + full gate", () => {
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ export function occupancyCount(db: Db): number {
|
|||||||
.all();
|
.all();
|
||||||
const balance = new Map<string, number>();
|
const balance = new Map<string, number>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
// A `void` (cancelled ticket) closes the session like an exit — the car never entered
|
||||||
|
// (misprint), so it must not count inside. See void-flow.ts.
|
||||||
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
||||||
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
else if (r.type === "vehicle_exit" || r.type === "void")
|
||||||
|
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||||
}
|
}
|
||||||
let open = 0;
|
let open = 0;
|
||||||
for (const v of balance.values()) if (v > 0) open += 1;
|
for (const v of balance.values()) if (v > 0) open += 1;
|
||||||
@@ -68,7 +71,7 @@ export function reservedSubscriberSpots(db: Db): number {
|
|||||||
if (pl.permitId == null) continue; // transient
|
if (pl.permitId == null) continue; // transient
|
||||||
net.set(id, (net.get(id) ?? 0) + 1);
|
net.set(id, (net.get(id) ?? 0) + 1);
|
||||||
subOf.set(id, pl.permitId);
|
subOf.set(id, pl.permitId);
|
||||||
} else if (r.type === "vehicle_exit") {
|
} else if (r.type === "vehicle_exit" || r.type === "void") {
|
||||||
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,9 @@ export class PayStation {
|
|||||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
||||||
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
|
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
|
||||||
const exitRow = rows.find((r) => r.type === "vehicle_exit");
|
// A `void` (cancelled ticket) closes the session like an exit — a voided ticket is no
|
||||||
|
// longer open and can't be paid/exited. See void-flow.ts.
|
||||||
|
const exitRow = rows.find((r) => r.type === "vehicle_exit" || r.type === "void");
|
||||||
const open = !exitRow;
|
const open = !exitRow;
|
||||||
|
|
||||||
let paidAt: string | null = null;
|
let paidAt: string | null = null;
|
||||||
@@ -366,7 +368,8 @@ export class PayStation {
|
|||||||
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
||||||
byId.set(id, a);
|
byId.set(id, a);
|
||||||
} else if (r.type === "vehicle_exit") {
|
} else if (r.type === "vehicle_exit" || r.type === "void") {
|
||||||
|
// A `void` closes the session like an exit — drop it from the active list.
|
||||||
const a = byId.get(id);
|
const a = byId.get(id);
|
||||||
if (a) a.exitedAt = r.occurredAt;
|
if (a) a.exitedAt = r.occurredAt;
|
||||||
} else if (r.type === "payment") {
|
} else if (r.type === "payment") {
|
||||||
|
|||||||
@@ -183,10 +183,17 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly-
|
||||||
|
// printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's
|
||||||
|
// entry is normally in the same window; this skips it when both are in range.)
|
||||||
|
const voided = new Set<string>();
|
||||||
|
for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity);
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
||||||
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
||||||
if (row.type === "vehicle_entry") {
|
if (row.type === "vehicle_entry") {
|
||||||
|
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||||
totals.entries++;
|
totals.entries++;
|
||||||
p.entries++;
|
p.entries++;
|
||||||
const h = localParts(row.occurredAt, tz).h;
|
const h = localParts(row.occurredAt, tz).h;
|
||||||
|
|||||||
@@ -29,6 +29,33 @@ interface ThemeBody {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
||||||
|
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
|
||||||
|
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
||||||
|
// other account. "" clears a field (→ null). See wiki/entities/local-jwt-auth.md.
|
||||||
|
interface ProfileBody {
|
||||||
|
fullName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-service password change: the user proves they hold the CURRENT password before
|
||||||
|
// setting a new one — unlike the admin reset (users.ts), which sets it outright. This is
|
||||||
|
// why it lives here and not behind a permission: it's account-self-care, not admin power.
|
||||||
|
interface PasswordBody {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_PASSWORD = 8;
|
||||||
|
|
||||||
|
/** Trim a self-service profile string; "" (or whitespace) → null (clear the field).
|
||||||
|
* Returns undefined for an absent key so an update only touches what was sent. */
|
||||||
|
function cleanProfileField(v: string | null | undefined): string | null | undefined {
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
const trimmed = typeof v === "string" ? v.trim() : "";
|
||||||
|
return trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
/** The session shape the SPA bootstraps from: identity + role + its permission
|
/** The session shape the SPA bootstraps from: identity + role + its permission
|
||||||
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||||
* permissions are the source of truth. */
|
* permissions are the source of truth. */
|
||||||
@@ -41,6 +68,7 @@ function sessionView(
|
|||||||
language: string;
|
language: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||||
@@ -54,6 +82,7 @@ function sessionView(
|
|||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
|
email: user.email ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,4 +170,52 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return { theme };
|
return { theme };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
||||||
|
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
||||||
|
app.put<{ Body: ProfileBody }>(
|
||||||
|
"/api/auth/profile",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const fullName = cleanProfileField(req.body?.fullName);
|
||||||
|
const email = cleanProfileField(req.body?.email);
|
||||||
|
const patch: Record<string, string | null> = {};
|
||||||
|
if (fullName !== undefined) patch.fullName = fullName;
|
||||||
|
if (email !== undefined) patch.email = email;
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
return reply.code(400).send({ error: "nothing to update" });
|
||||||
|
}
|
||||||
|
await db.update(users).set(patch).where(eq(users.id, req.user.sub)).run();
|
||||||
|
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||||
|
if (!row) return reply.code(401).send({ error: "session no longer valid" });
|
||||||
|
return sessionView(db, row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change MY own password — must prove the CURRENT one first (defends against a walked-up,
|
||||||
|
// already-logged-in booth: a passerby can't silently re-key the account). New password
|
||||||
|
// >= MIN_PASSWORD. Distinct from the admin reset (users.ts), which needs no current pw.
|
||||||
|
app.put<{ Body: PasswordBody }>(
|
||||||
|
"/api/auth/password",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const currentPassword = req.body?.currentPassword ?? "";
|
||||||
|
const newPassword = req.body?.newPassword ?? "";
|
||||||
|
if (newPassword.length < MIN_PASSWORD) {
|
||||||
|
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
||||||
|
}
|
||||||
|
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||||
|
if (!row) {
|
||||||
|
clearAuthCookies(reply);
|
||||||
|
return reply.code(401).send({ error: "session no longer valid" });
|
||||||
|
}
|
||||||
|
const ok = await bcrypt.compare(currentPassword, row.passwordHash);
|
||||||
|
if (!ok) {
|
||||||
|
return reply.code(403).send({ error: "current password is incorrect" });
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await db.update(users).set({ passwordHash }).where(eq(users.id, req.user.sub)).run();
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type PayStation,
|
type PayStation,
|
||||||
} from "../pay-station.js";
|
} from "../pay-station.js";
|
||||||
import type { ExitFlow } from "../exit-flow.js";
|
import type { ExitFlow } from "../exit-flow.js";
|
||||||
|
import type { VoidFlow } from "../void-flow.js";
|
||||||
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||||
import { printPaymentReceipt } from "../booth-print.js";
|
import { printPaymentReceipt } from "../booth-print.js";
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ interface VoucherBody {
|
|||||||
interface ReceiptBody {
|
interface ReceiptBody {
|
||||||
identity: string;
|
identity: string;
|
||||||
}
|
}
|
||||||
|
interface VoidBody {
|
||||||
|
identity: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function payRoutes(
|
export async function payRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -43,6 +48,7 @@ export async function payRoutes(
|
|||||||
payStation: PayStation,
|
payStation: PayStation,
|
||||||
exitFlow: ExitFlow,
|
exitFlow: ExitFlow,
|
||||||
shift: ShiftService,
|
shift: ShiftService,
|
||||||
|
voidFlow: VoidFlow,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Reads (lookup, active sessions, quote) need session/payment read; the booth
|
// Reads (lookup, active sessions, quote) need session/payment read; the booth
|
||||||
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
|
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
|
||||||
@@ -50,6 +56,7 @@ export async function payRoutes(
|
|||||||
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
|
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
|
||||||
const guard = requirePermission("payment:create");
|
const guard = requirePermission("payment:create");
|
||||||
const readGuard = requirePermission("session:read");
|
const readGuard = requirePermission("session:read");
|
||||||
|
const voidGuard = requirePermission("event:void");
|
||||||
|
|
||||||
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
|
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
|
||||||
// re-open is processed, so every taking is attributed to a shift (one operator's
|
// re-open is processed, so every taking is attributed to a shift (one operator's
|
||||||
@@ -125,6 +132,28 @@ export async function payRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event
|
||||||
|
// referencing the entry, with the operator + a REQUIRED reason — the entry itself is
|
||||||
|
// never edited/deleted (append-only). The session projection folds the void to CLOSED,
|
||||||
|
// so the voided car stops counting inside and can't be paid/exited. Opens NO barrier
|
||||||
|
// (the misprinted ticket's car never entered). Gated on event:void + an open shift
|
||||||
|
// (the booth accountability period). Refusals (subscription / already exited / already
|
||||||
|
// voided / already paid) → 409. See void-flow.ts, wiki/concepts/append-only-event-chain.md.
|
||||||
|
app.post<{ Body: VoidBody }>(
|
||||||
|
"/api/tickets/void",
|
||||||
|
{ preHandler: [voidGuard, requireShift] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
const reason = (req.body?.reason ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
if (!reason) return reply.code(400).send({ error: "a cancellation reason is required" });
|
||||||
|
const operator = req.user?.username ?? "unknown";
|
||||||
|
const res = await voidFlow.voidTicket({ identity, reason, operator });
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||||
|
return reply.code(201).send(res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Quote: what does this session owe right now? (No side effect.)
|
// Quote: what does this session owe right now? (No side effect.)
|
||||||
app.get<{ Querystring: QuoteQuery }>(
|
app.get<{ Querystring: QuoteQuery }>(
|
||||||
"/api/pay/quote",
|
"/api/pay/quote",
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { eq, users, type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// Self-service profile (routes/auth.ts): /api/auth/profile + /api/auth/password. These act
|
||||||
|
// ONLY on the signed-in user, need NO `user:*` permission (any role), and the password change
|
||||||
|
// must prove the current password. Distinct from admin user-management (routes/users.ts).
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/auth/profile (self-service)", () => {
|
||||||
|
it("a permission-less user can edit their OWN name + email", async () => {
|
||||||
|
// 'viewer' role with NO user:* permission — profile is not gated on it.
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "cashier", roleId: "viewer", permissions: [],
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/profile",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fullName: "Mon Kukaleshi", email: "mon@example.com" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.fullName).toBe("Mon Kukaleshi");
|
||||||
|
expect(body.email).toBe("mon@example.com");
|
||||||
|
// Persisted to the caller's own row.
|
||||||
|
const row = db.select().from(users).where(eq(users.username, "cashier")).get();
|
||||||
|
expect(row?.fullName).toBe("Mon Kukaleshi");
|
||||||
|
expect(row?.email).toBe("mon@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a field when sent ""', async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "u2", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
// First set a name…
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/profile",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fullName: "Old Name" },
|
||||||
|
});
|
||||||
|
// …then clear it with whitespace (→ null).
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/profile",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fullName: " " },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().fullName).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty patch (nothing to update)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "u3", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/profile",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a session (401 without a token)", async () => {
|
||||||
|
const res = await app.inject({ method: "PUT", url: "/api/auth/profile", payload: { fullName: "x" } });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/auth/password (self-service)", () => {
|
||||||
|
it("changes the password when the current one is correct, and the new one then logs in", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "p1", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/password",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { currentPassword: password, newPassword: "brand-new-pw-123" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Old password no longer works; new one does.
|
||||||
|
const oldTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||||
|
expect(oldTry.statusCode).toBe(401);
|
||||||
|
const newTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "brand-new-pw-123" } });
|
||||||
|
expect(newTry.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the current password is wrong (403) and leaves the password unchanged", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "p2", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/password",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { currentPassword: "not-it", newPassword: "brand-new-pw-123" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
// Original password still works.
|
||||||
|
const still = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||||
|
expect(still.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a too-short new password (400)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "p3", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/password",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { currentPassword: password, newPassword: "short" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import { deviceEvents } from "./device-events.js";
|
|||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { VoidFlow } from "./void-flow.js";
|
||||||
import { PayStation } from "./pay-station.js";
|
import { PayStation } from "./pay-station.js";
|
||||||
import { SubscriptionFlow } from "./subscription-flow.js";
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import { ShiftService } from "./shift-service.js";
|
import { ShiftService } from "./shift-service.js";
|
||||||
@@ -43,6 +44,7 @@ import { printerRoutes } from "./routes/printers.js";
|
|||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
import { deviceStatusRoutes } from "./routes/device-status.js";
|
import { deviceStatusRoutes } from "./routes/device-status.js";
|
||||||
import { wsRoutes } from "./routes/ws.js";
|
import { wsRoutes } from "./routes/ws.js";
|
||||||
|
import { registerSpa } from "./static-spa.js";
|
||||||
|
|
||||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||||
// plugins emitting onto a shared internal event bus; auth is fully local
|
// plugins emitting onto a shared internal event bus; auth is fully local
|
||||||
@@ -231,7 +233,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
||||||
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
||||||
const payStation = new PayStation(db, eventLog, app.log);
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
await payRoutes(app, db, payStation, exitFlow, shiftService);
|
// Ticket-void (cancel a wrongly-printed ticket): appends a signed `void` referencing the
|
||||||
|
// entry; the session projection folds it closed. See void-flow.ts.
|
||||||
|
const voidFlow = new VoidFlow(db, eventLog, app.log);
|
||||||
|
await payRoutes(app, db, payStation, exitFlow, shiftService, voidFlow);
|
||||||
|
|
||||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
@@ -296,5 +301,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeInput());
|
app.addHook("onClose", async () => unsubscribeInput());
|
||||||
|
|
||||||
|
// LAST: serve the built React SPA (apps/web/dist) when present — so one container
|
||||||
|
// serves the API + the operator UI (offline-first single appliance). No-op in dev (no
|
||||||
|
// build → the Vite dev server serves the UI). Registered after every API route and
|
||||||
|
// GET-only with /api + /health excluded, so it can never shadow the backend.
|
||||||
|
// See static-spa.ts + wiki/decisions/container-deployment.md.
|
||||||
|
await registerSpa(app);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
// Serve the built React SPA (apps/web/dist) from the Fastify server, so ONE container
|
||||||
|
// serves both the API and the operator UI — matching the offline-first single-appliance
|
||||||
|
// model (the booth has no separate web host). This is a NO-OP in dev (the Vite dev server
|
||||||
|
// serves the SPA on its own port and no dist exists), so it never changes local behavior.
|
||||||
|
//
|
||||||
|
// Registration order matters: this is registered LAST, after every API route, and its
|
||||||
|
// catch-all is GET-only and explicitly excludes /api, /health, and the WS path — so it
|
||||||
|
// can never shadow the backend. See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
|
/** Where the built SPA lives. Override with WEB_DIST_DIR (the container sets it). Default
|
||||||
|
* resolves relative to this file's dist location: apps/server/dist → ../../web/dist, the
|
||||||
|
* layout the image lays down (/app/dist + /app/web/dist → ../web/dist from dist). */
|
||||||
|
function resolveWebDist(): string {
|
||||||
|
const fromEnv = process.env.WEB_DIST_DIR;
|
||||||
|
if (fromEnv) return resolve(fromEnv);
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
// In the image the server runs from /app/dist and the SPA sits at /app/web/dist.
|
||||||
|
return resolve(here, "../web/dist");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register SPA static serving if a build is present. Returns true when wired, false when
|
||||||
|
* skipped (dev / no build). Serves assets from the dist dir and falls back to index.html
|
||||||
|
* for any non-API GET so client-side routing (TanStack Router) works on deep links/reload.
|
||||||
|
*/
|
||||||
|
export async function registerSpa(app: FastifyInstance): Promise<boolean> {
|
||||||
|
const root = resolveWebDist();
|
||||||
|
const indexHtml = resolve(root, "index.html");
|
||||||
|
if (!existsSync(indexHtml)) {
|
||||||
|
app.log.info(`SPA static serving disabled (no build at ${root})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.register(fastifyStatic, { root, wildcard: false });
|
||||||
|
|
||||||
|
// SPA fallback: any GET that didn't match an API route or a real static file returns
|
||||||
|
// index.html (client routing). EXCLUDE the backend surfaces so a missing /api route
|
||||||
|
// still 404s as JSON rather than silently returning the HTML shell. WS upgrades and
|
||||||
|
// non-GET methods are never touched (this is a GET-only notFound handler path).
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
const url = req.raw.url ?? "/";
|
||||||
|
if (req.method !== "GET" || url.startsWith("/api") || url.startsWith("/health")) {
|
||||||
|
return reply.code(404).send({ error: "not found" });
|
||||||
|
}
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.log.info(`SPA static serving enabled from ${root}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { VoidFlow } from "./void-flow.js";
|
||||||
|
import { PayStation } from "./pay-station.js";
|
||||||
|
import { occupancyCount } from "./occupancy.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger, seedTariff } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// Cancel (void) a wrongly-printed ticket: a SIGNED `void` event that references the entry
|
||||||
|
// and folds the session CLOSED. The entry itself is never edited/deleted (append-only).
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let voidFlow: VoidFlow;
|
||||||
|
let pay: PayStation;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
voidFlow = new VoidFlow(db, log, silentLogger());
|
||||||
|
pay = new PayStation(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
async function enter(identity: string, payload?: Record<string, unknown>) {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity, payload: payload ?? null });
|
||||||
|
}
|
||||||
|
function voids(identity: string) {
|
||||||
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "void");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VoidFlow.voidTicket", () => {
|
||||||
|
it("voids an open transient ticket: signs a void, closes the session, drops occupancy", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
expect(occupancyCount(db)).toBe(1);
|
||||||
|
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
|
||||||
|
const v = voids("T1");
|
||||||
|
expect(v).toHaveLength(1);
|
||||||
|
const pl = v[0]!.payload as Record<string, unknown>;
|
||||||
|
expect(pl.voidReason).toBe("misprint");
|
||||||
|
expect(pl.operator).toBe("alice");
|
||||||
|
expect(pl.voidedEntryRef).toBeDefined();
|
||||||
|
expect(pl.reasonCode).toBe("void.ticketCancelled");
|
||||||
|
|
||||||
|
// Folds: not inside, not an active session, no longer "open".
|
||||||
|
expect(occupancyCount(db)).toBe(1 - 1);
|
||||||
|
expect(pay.activeSessions().some((s) => s.identity === "T1")).toBe(false);
|
||||||
|
expect(pay.lookup("T1").open).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a reason", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: " ", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(voids("T1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an unknown ticket", async () => {
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "ghost", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/no such ticket/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second void (already cancelled)", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: "again", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/already cancelled/i);
|
||||||
|
expect(voids("T1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an already-exited session", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
await log.append({ type: "vehicle_exit", direction: "exit", identity: "T1" });
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/already exited/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a PAID ticket (refund is a separate action)", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
await log.append({ type: "payment", identity: "T1", payload: { sessionRef: "T1", amountMinor: 100, currency: "ALL" } });
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/already paid/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a subscription occurrence (closed via its own flow)", async () => {
|
||||||
|
await enter("SUBSESS-x", { permit: true, permitId: "sub-1" });
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "SUBSESS-x", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/subscription/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the signed chain verifiable after a void", async () => {
|
||||||
|
seedTariff(db);
|
||||||
|
await enter("T1");
|
||||||
|
await voidFlow.voidTicket({ identity: "T1", reason: "test", operator: "alice" });
|
||||||
|
// The void is the newest signed row; the chain is intact (verifier is exercised by
|
||||||
|
// the event-log on append — a broken chain would have thrown).
|
||||||
|
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
const last = rows[rows.length - 1]!;
|
||||||
|
expect(last.type).toBe("void");
|
||||||
|
expect(last.prevHash).toBeTruthy();
|
||||||
|
expect(last.signature).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { eq, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||||
|
import { reasonPayload } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Cancel a wrongly-printed transient ticket by appending a SIGNED `void` event that
|
||||||
|
// references the entry. The signed ledger is append-only and hash-chained — the
|
||||||
|
// vehicle_entry is NEVER edited or deleted; the void is a new appended row that the
|
||||||
|
// session projection folds to CLOSE the session (so a voided car stops counting inside
|
||||||
|
// and can't be paid/exited). Fully traceable: the operator + a required reason are signed
|
||||||
|
// into the void payload. A misprinted ticket's car never entered, so voiding opens NO
|
||||||
|
// barrier. See wiki/concepts/append-only-event-chain.md, parking-session.md.
|
||||||
|
|
||||||
|
export interface VoidResult {
|
||||||
|
readonly ok: boolean;
|
||||||
|
/** English reason on refusal (localized client-side via the reasonCode it mirrors). */
|
||||||
|
readonly reason?: string;
|
||||||
|
/** The void event's identity on success (= the entry identity). */
|
||||||
|
readonly identity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoidFlow {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Serialize concurrent voids of the SAME ticket (double-click / double-scan). */
|
||||||
|
readonly #inFlight = new Set<string>();
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Void (cancel) a transient ticket. Guards, then appends a signed `void`. Refuses:
|
||||||
|
* unknown ticket, a subscription occurrence (use the subscription flow), an already-
|
||||||
|
* exited or already-voided session, or a session that has a payment (a paid ticket is a
|
||||||
|
* refund situation — out of scope). `reason` is REQUIRED (the route enforces non-empty).
|
||||||
|
*/
|
||||||
|
async voidTicket(args: { identity: string; reason: string; operator: string }): Promise<VoidResult> {
|
||||||
|
const identity = args.identity.trim();
|
||||||
|
const reason = args.reason.trim();
|
||||||
|
if (!identity) return { ok: false, reason: "missing ticket id" };
|
||||||
|
if (!reason) return { ok: false, reason: "a cancellation reason is required" };
|
||||||
|
|
||||||
|
if (this.#inFlight.has(identity)) return { ok: false, reason: "cancel already in flight" };
|
||||||
|
this.#inFlight.add(identity);
|
||||||
|
try {
|
||||||
|
return await this.#run(identity, reason, args.operator);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`void-flow failed (${identity}): ${(err as Error).message}`);
|
||||||
|
return { ok: false, reason: (err as Error).message };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #run(identity: string, reason: string, operator: string): Promise<VoidResult> {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return { ok: false, reason: "no such ticket (no entry for this id)" };
|
||||||
|
|
||||||
|
// Subscriptions are closed via their own flow — ticket-void would double-mean permitId.
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (entryPl.permit === true || entryPl.permitId != null) {
|
||||||
|
return { ok: false, reason: "this is a subscription occurrence — cancel it via the subscription, not a ticket void" };
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit")) {
|
||||||
|
return { ok: false, reason: "session already exited — nothing to cancel" };
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.type === "void")) {
|
||||||
|
return { ok: false, reason: "ticket already cancelled" };
|
||||||
|
}
|
||||||
|
// A paid ticket is a refund, not a misprint cancel — out of scope.
|
||||||
|
if (rows.some((r) => r.type === "payment")) {
|
||||||
|
return { ok: false, reason: "ticket already paid — a refund is a separate action, not a cancellation" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "void",
|
||||||
|
identity,
|
||||||
|
// `sessionRef` + `voidedEntryRef` tie the void to the entry; `voidReason` + `operator`
|
||||||
|
// make it traceable. The reasonCode localizes; the free-text reason is the operator's note.
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("void.ticketCancelled", { reason }),
|
||||||
|
sessionRef: identity,
|
||||||
|
voidedEntryRef: entry.id,
|
||||||
|
voidReason: reason,
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Best-effort close the projection cache (the ledger fold is the truth either way).
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.update(sessions)
|
||||||
|
.set({ exitedAt: new Date().toISOString(), state: "voided" })
|
||||||
|
.where(eq(sessions.id, identity))
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`void session-cache close failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.info(`ticket ${identity} cancelled by ${operator}: ${reason}`);
|
||||||
|
// NO barrier action — the misprinted ticket's car never entered.
|
||||||
|
return { ok: true, identity };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# Parking VISION image: the Python/uv ANPR microservice. Build CONTEXT is apps/vision
|
||||||
|
# (self-contained Python package; no monorepo deps). Ships WITH the `alpr` extra (real
|
||||||
|
# fast-alpr/onnxruntime stack) but the engine is env-selected: VISION_RECOGNIZER=stub
|
||||||
|
# (default, boots anywhere) or fast_alpr (prod). See wiki/decisions/container-deployment.md,
|
||||||
|
# wiki/decisions/vision-service-packaging.md.
|
||||||
|
|
||||||
|
# uv-provided Python 3.12 (matches apps/vision/.python-version).
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV UV_LINK_MODE=copy \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
|
||||||
|
# Manifests first so the heavy `uv sync` layer caches across source edits.
|
||||||
|
COPY pyproject.toml uv.lock .python-version ./
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-install-project --extra alpr
|
||||||
|
|
||||||
|
# ---- project source ----
|
||||||
|
COPY vision_service/ ./vision_service/
|
||||||
|
COPY README.md ./
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --extra alpr
|
||||||
|
|
||||||
|
# Non-root runtime user, created BEFORE the model pre-warm so the weights cache lands in
|
||||||
|
# this user's HOME (~/.cache) — the SAME path the runtime reads. (fast-alpr's
|
||||||
|
# open-image-models caches under $HOME/.cache/open-image-models keyed to HOME, ignoring
|
||||||
|
# HF_HOME/XDG_CACHE_HOME — so the pre-warm MUST run as the runtime user, not root.)
|
||||||
|
RUN useradd --system --create-home --uid 999 vision \
|
||||||
|
&& chown -R vision:vision /app
|
||||||
|
USER vision
|
||||||
|
|
||||||
|
# Pre-warm the fast-alpr model weights INTO the image (as the vision user → /home/vision/
|
||||||
|
# .cache) so the prod recognizer is OFFLINE-first: ALPR() downloads weights on first
|
||||||
|
# construction, which would otherwise need network on the appliance's first scan. Best-effort
|
||||||
|
# — if the build host has no network this is skipped and weights fetch lazily at runtime.
|
||||||
|
# NB: NO --mount=type=cache here — a BuildKit cache mount at ~/.cache is NOT committed to the
|
||||||
|
# image layer, so the downloaded weights would vanish. They must write to the real layer.
|
||||||
|
RUN uv run python -c "from fast_alpr import ALPR; ALPR()" \
|
||||||
|
|| echo "[build] model pre-warm skipped (no network) — weights fetch at runtime"
|
||||||
|
|
||||||
|
# Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
|
||||||
|
ENV VISION_RECOGNIZER=stub \
|
||||||
|
VISION_HOST=0.0.0.0 \
|
||||||
|
VISION_PORT=8089
|
||||||
|
EXPOSE 8089
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
|
||||||
|
|
||||||
|
CMD ["uv", "run", "uvicorn", "vision_service.app:app", "--host", "0.0.0.0", "--port", "8089"]
|
||||||
+101
-17
@@ -4,6 +4,7 @@ import * as Dialog from "@radix-ui/react-dialog";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
boothExit,
|
boothExit,
|
||||||
|
can,
|
||||||
fetchSiteConfig,
|
fetchSiteConfig,
|
||||||
lookupSession,
|
lookupSession,
|
||||||
openShift,
|
openShift,
|
||||||
@@ -11,8 +12,10 @@ import {
|
|||||||
printReceipt,
|
printReceipt,
|
||||||
printVoucher,
|
printVoucher,
|
||||||
reopenBarrier,
|
reopenBarrier,
|
||||||
|
voidTicket,
|
||||||
type SessionLookup,
|
type SessionLookup,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { rootRoute } from "./router.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
@@ -52,6 +55,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
|
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
|
||||||
// first, then the modal reveals "Open barrier". This flips true once paid.
|
// first, then the modal reveals "Open barrier". This flips true once paid.
|
||||||
const [windowPaid, setWindowPaid] = useState(false);
|
const [windowPaid, setWindowPaid] = useState(false);
|
||||||
|
// Cancel (void) a wrongly-printed ticket: a small reason prompt, then a signed void.
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
const canVoid = can(user, "event:void");
|
||||||
|
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
||||||
|
const [voidReason, setVoidReason] = useState("");
|
||||||
|
|
||||||
const s: SessionLookup | undefined = session.data;
|
const s: SessionLookup | undefined = session.data;
|
||||||
// Checkbox default comes from config the first time it loads; operator can toggle.
|
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||||
@@ -127,6 +135,29 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A wrongly-printed ticket is cancellable only while it's a TRANSIENT, UNPAID, OPEN
|
||||||
|
// session (a subscription is closed via its own flow; a paid ticket is a refund). The
|
||||||
|
// server enforces all of this too; the UI just hides the action when it can't apply.
|
||||||
|
const canCancel = !!(canVoid && shiftReady && s?.found && s.open && !isSubscription && !alreadyPaid);
|
||||||
|
|
||||||
|
async function handleVoidTicket() {
|
||||||
|
const reason = voidReason.trim();
|
||||||
|
if (!reason) return;
|
||||||
|
setError(null);
|
||||||
|
setPhase("finishing");
|
||||||
|
try {
|
||||||
|
await voidTicket(identity, reason);
|
||||||
|
setResult(t("pay.ticketCancelled"));
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
|
setPhase("done");
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleReprintReceipt() {
|
async function handleReprintReceipt() {
|
||||||
setReprinting(true);
|
setReprinting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -365,6 +396,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cancel-ticket reason prompt (revealed by the "Cancel ticket" button).
|
||||||
|
A few presets + free text; a reason is REQUIRED. Voiding appends a
|
||||||
|
signed `void` event — the entry is never edited. */}
|
||||||
|
{voiding && phase !== "done" && (
|
||||||
|
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
|
{t("pay.cancelTicketTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVoidReason(t(`pay.cancelReason.${k}`))}
|
||||||
|
className={voidReason === t(`pay.cancelReason.${k}`) ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||||
|
>
|
||||||
|
{t(`pay.cancelReason.${k}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="input mt-2 w-full"
|
||||||
|
value={voidReason}
|
||||||
|
onChange={(e) => setVoidReason(e.target.value)}
|
||||||
|
placeholder={t("pay.cancelReasonPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||||
{result && (
|
{result && (
|
||||||
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||||
@@ -439,27 +500,50 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{t("pay.assistOpenReveal")}
|
{t("pay.assistOpenReveal")}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
) : (
|
) : voiding ? (
|
||||||
|
// Cancel-ticket confirm (reason prompt is shown above).
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePayAndExit}
|
onClick={handleVoidTicket}
|
||||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
disabled={!voidReason.trim() || phase === "finishing"}
|
||||||
className="btn btn-go btn-lg"
|
className="btn btn-danger btn-lg"
|
||||||
>
|
>
|
||||||
{phase === "paying"
|
{phase === "finishing" ? t("pay.cancelling") : t("pay.confirmCancelTicket")}
|
||||||
? t("pay.takingPayment")
|
|
||||||
: phase === "finishing"
|
|
||||||
? voucher
|
|
||||||
? t("pay.printingVoucher")
|
|
||||||
: t("pay.opening")
|
|
||||||
: alreadyPaid
|
|
||||||
? voucher
|
|
||||||
? t("pay.printVoucher")
|
|
||||||
: t("pay.openBarrier")
|
|
||||||
: voucher
|
|
||||||
? t("pay.payAndVoucher")
|
|
||||||
: t("pay.payAndOpen")}
|
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Cancel a wrongly-printed ticket (transient, unpaid, open only;
|
||||||
|
gated on event:void). Reveals the reason prompt above. */}
|
||||||
|
{canCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVoiding(true)}
|
||||||
|
className="btn btn-ghost btn-sm text-term-red"
|
||||||
|
>
|
||||||
|
{t("pay.cancelTicket")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePayAndExit}
|
||||||
|
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||||
|
className="btn btn-go btn-lg"
|
||||||
|
>
|
||||||
|
{phase === "paying"
|
||||||
|
? t("pay.takingPayment")
|
||||||
|
: phase === "finishing"
|
||||||
|
? voucher
|
||||||
|
? t("pay.printingVoucher")
|
||||||
|
: t("pay.opening")
|
||||||
|
: alreadyPaid
|
||||||
|
? voucher
|
||||||
|
? t("pay.printVoucher")
|
||||||
|
: t("pay.openBarrier")
|
||||||
|
: voucher
|
||||||
|
? t("pay.payAndVoucher")
|
||||||
|
: t("pay.payAndOpen")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { changeMyPassword, updateMyProfile, type SessionUser } from "./api.js";
|
||||||
|
|
||||||
|
// Self-service profile: the signed-in user edits their OWN display name + email and
|
||||||
|
// changes their OWN password (proving the current one). This is NOT the admin
|
||||||
|
// user-manager (UsersManager.tsx) — it never touches another account, username, or
|
||||||
|
// role, and needs no `user:*` permission. See routes/auth.ts (/api/auth/profile,
|
||||||
|
// /api/auth/password) and wiki/entities/local-jwt-auth.md.
|
||||||
|
|
||||||
|
const MIN_PASSWORD = 8;
|
||||||
|
|
||||||
|
export function Profile({
|
||||||
|
user,
|
||||||
|
setUser,
|
||||||
|
}: {
|
||||||
|
user: SessionUser;
|
||||||
|
setUser: (u: SessionUser | null) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// --- Account (name / email) ---
|
||||||
|
const [fullName, setFullName] = useState(user.fullName ?? "");
|
||||||
|
const [email, setEmail] = useState(user.email ?? "");
|
||||||
|
const [accountMsg, setAccountMsg] = useState<string | null>(null);
|
||||||
|
const [savingAccount, setSavingAccount] = useState(false);
|
||||||
|
|
||||||
|
async function saveAccount() {
|
||||||
|
setAccountMsg(null);
|
||||||
|
setSavingAccount(true);
|
||||||
|
try {
|
||||||
|
const next = await updateMyProfile({ fullName, email });
|
||||||
|
// Keep the router-context user in sync so the header reflects the change.
|
||||||
|
setUser(next);
|
||||||
|
setFullName(next.fullName ?? "");
|
||||||
|
setEmail(next.email ?? "");
|
||||||
|
setAccountMsg(t("profile.profileSaved"));
|
||||||
|
} catch (e) {
|
||||||
|
setAccountMsg((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSavingAccount(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Password ---
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||||||
|
const [savingPw, setSavingPw] = useState(false);
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
setPwMsg(null);
|
||||||
|
if (next.length < MIN_PASSWORD) {
|
||||||
|
setPwMsg(t("profile.passwordTooShort", { min: MIN_PASSWORD }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next !== confirm) {
|
||||||
|
setPwMsg(t("profile.passwordsDontMatch"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingPw(true);
|
||||||
|
try {
|
||||||
|
await changeMyPassword(current, next);
|
||||||
|
setCurrent("");
|
||||||
|
setNext("");
|
||||||
|
setConfirm("");
|
||||||
|
setPwMsg(t("profile.passwordChanged"));
|
||||||
|
} catch (e) {
|
||||||
|
setPwMsg((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSavingPw(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-xl flex-col gap-6">
|
||||||
|
<h1 className="text-lg text-term-text">{t("profile.title")}</h1>
|
||||||
|
|
||||||
|
{/* Account: display name + email (username + role are read-only — admin-managed). */}
|
||||||
|
<section className="card flex flex-col gap-3 p-4">
|
||||||
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
|
{t("profile.accountSection")}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
|
||||||
|
<div>
|
||||||
|
<span className="block">{t("profile.username")}</span>
|
||||||
|
<span className="text-sm text-term-text">{user.username}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="block">{t("profile.role")}</span>
|
||||||
|
<span className="text-sm text-term-text">{user.roleName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.fullName")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={fullName}
|
||||||
|
placeholder={t("profile.fullNamePh")}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.email")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
placeholder={t("profile.emailPh")}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
||||||
|
{t("profile.saveProfile")}
|
||||||
|
</button>
|
||||||
|
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Password: requires the current one (server enforces). */}
|
||||||
|
<section className="card flex flex-col gap-3 p-4">
|
||||||
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
|
{t("profile.passwordSection")}
|
||||||
|
</h2>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.currentPassword")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.newPassword")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.confirmPassword")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={changePassword}
|
||||||
|
disabled={savingPw || !current || !next || !confirm}
|
||||||
|
>
|
||||||
|
{t("profile.changePassword")}
|
||||||
|
</button>
|
||||||
|
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,6 +75,8 @@ export interface SessionUser {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
/** Optional display name (profile metadata); null if unset. */
|
/** Optional display name (profile metadata); null if unset. */
|
||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
|
/** Optional contact email (profile metadata); null if unset. */
|
||||||
|
email: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this session grant the permission? Central authz check for the SPA. */
|
/** Does this session grant the permission? Central authz check for the SPA. */
|
||||||
@@ -103,6 +105,29 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
|
|||||||
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Edit MY own profile (display name / email). Returns the refreshed session.
|
||||||
|
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
||||||
|
export function updateMyProfile(patch: {
|
||||||
|
fullName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}): Promise<SessionUser> {
|
||||||
|
return apiFetch<SessionUser>("/api/auth/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change MY own password — proves the current one first (server enforces). */
|
||||||
|
export function changeMyPassword(
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<{ ok: boolean }> {
|
||||||
|
return apiFetch("/api/auth/password", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns the current user, or null if not authenticated. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
@@ -1085,6 +1110,13 @@ export function paySession(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event with
|
||||||
|
* the operator + a required reason; the entry itself is never edited (append-only).
|
||||||
|
* Refuses a subscription / already-exited / already-voided / paid ticket (409). */
|
||||||
|
export function voidTicket(identity: string, reason: string): Promise<{ ok: boolean; identity?: string }> {
|
||||||
|
return apiFetch("/api/tickets/void", { method: "POST", body: JSON.stringify({ identity, reason }) });
|
||||||
|
}
|
||||||
|
|
||||||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||||
* open (payment stands; operator opens manually). */
|
* open (payment stands; operator opens manually). */
|
||||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ export const en: Catalog = {
|
|||||||
reports: "Reports",
|
reports: "Reports",
|
||||||
recycleBin: "Recycle bin",
|
recycleBin: "Recycle bin",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
|
profile: "Profile",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: "My profile",
|
||||||
|
accountSection: "Account",
|
||||||
|
fullName: "Full name",
|
||||||
|
fullNamePh: "First and last name",
|
||||||
|
email: "Email",
|
||||||
|
emailPh: "you@example.com",
|
||||||
|
username: "Username",
|
||||||
|
role: "Role",
|
||||||
|
saveProfile: "Save profile",
|
||||||
|
profileSaved: "Profile saved.",
|
||||||
|
passwordSection: "Change password",
|
||||||
|
currentPassword: "Current password",
|
||||||
|
newPassword: "New password",
|
||||||
|
confirmPassword: "Confirm password",
|
||||||
|
changePassword: "Change password",
|
||||||
|
passwordChanged: "Password changed.",
|
||||||
|
passwordsDontMatch: "Passwords don't match.",
|
||||||
|
passwordTooShort: "Password must be at least {{min}} characters.",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
live: "LIVE",
|
live: "LIVE",
|
||||||
@@ -156,6 +177,7 @@ export const en: Catalog = {
|
|||||||
evtCashIn: "PAY-IN",
|
evtCashIn: "PAY-IN",
|
||||||
evtCashOut: "PAY-OUT",
|
evtCashOut: "PAY-OUT",
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
|
evtRefused: "REFUSED",
|
||||||
// live-feed event detail line + classification badges (computed from payload)
|
// live-feed event detail line + classification badges (computed from payload)
|
||||||
evtNoReason: "no reason recorded",
|
evtNoReason: "no reason recorded",
|
||||||
badgeEntryRefused: "entry refused",
|
badgeEntryRefused: "entry refused",
|
||||||
@@ -224,6 +246,7 @@ export const en: Catalog = {
|
|||||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||||
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
||||||
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
||||||
|
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tariff",
|
title: "Tariff",
|
||||||
@@ -799,6 +822,18 @@ export const en: Catalog = {
|
|||||||
receiptReprinted: "Receipt reprinted on {{printer}}.",
|
receiptReprinted: "Receipt reprinted on {{printer}}.",
|
||||||
reprintReceipt: "Reprint receipt",
|
reprintReceipt: "Reprint receipt",
|
||||||
reprinting: "printing…",
|
reprinting: "printing…",
|
||||||
|
cancelTicket: "Cancel ticket",
|
||||||
|
cancelTicketTitle: "Cancel this ticket",
|
||||||
|
cancelTicketHint: "Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.",
|
||||||
|
cancelReason: {
|
||||||
|
misprint: "Misprint",
|
||||||
|
test: "Test",
|
||||||
|
wrongVehicle: "Wrong vehicle",
|
||||||
|
},
|
||||||
|
cancelReasonPlaceholder: "Reason for cancelling (required)…",
|
||||||
|
confirmCancelTicket: "Confirm cancellation",
|
||||||
|
cancelling: "cancelling…",
|
||||||
|
ticketCancelled: "Ticket cancelled.",
|
||||||
noSnapshots: "no snapshots",
|
noSnapshots: "no snapshots",
|
||||||
loadingSnapshots: "loading snapshots…",
|
loadingSnapshots: "loading snapshots…",
|
||||||
snapEntry: "entry",
|
snapEntry: "entry",
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ export const sq = {
|
|||||||
reports: "Raportet",
|
reports: "Raportet",
|
||||||
recycleBin: "Koshi",
|
recycleBin: "Koshi",
|
||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
|
profile: "Profili",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: "Profili im",
|
||||||
|
accountSection: "Llogaria",
|
||||||
|
fullName: "Emri i plotë",
|
||||||
|
fullNamePh: "Emri dhe mbiemri",
|
||||||
|
email: "Email",
|
||||||
|
emailPh: "ti@shembull.com",
|
||||||
|
username: "Përdoruesi",
|
||||||
|
role: "Roli",
|
||||||
|
saveProfile: "Ruaj profilin",
|
||||||
|
profileSaved: "Profili u ruajt.",
|
||||||
|
passwordSection: "Ndrysho fjalëkalimin",
|
||||||
|
currentPassword: "Fjalëkalimi aktual",
|
||||||
|
newPassword: "Fjalëkalimi i ri",
|
||||||
|
confirmPassword: "Konfirmo fjalëkalimin",
|
||||||
|
changePassword: "Ndrysho fjalëkalimin",
|
||||||
|
passwordChanged: "Fjalëkalimi u ndryshua.",
|
||||||
|
passwordsDontMatch: "Fjalëkalimet nuk përputhen.",
|
||||||
|
passwordTooShort: "Fjalëkalimi duhet të jetë të paktën {{min}} karaktere.",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
live: "LIVE",
|
live: "LIVE",
|
||||||
@@ -160,6 +181,7 @@ export const sq = {
|
|||||||
evtCashIn: "ARKËTIM",
|
evtCashIn: "ARKËTIM",
|
||||||
evtCashOut: "PAGESË",
|
evtCashOut: "PAGESË",
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
|
evtRefused: "REFUZUAR",
|
||||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||||
evtNoReason: "pa arsye të regjistruar",
|
evtNoReason: "pa arsye të regjistruar",
|
||||||
badgeEntryRefused: "hyrje e refuzuar",
|
badgeEntryRefused: "hyrje e refuzuar",
|
||||||
@@ -227,6 +249,7 @@ export const sq = {
|
|||||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||||
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
||||||
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
||||||
|
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tarifa",
|
title: "Tarifa",
|
||||||
@@ -813,6 +836,18 @@ export const sq = {
|
|||||||
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
||||||
reprintReceipt: "Riprinto faturën",
|
reprintReceipt: "Riprinto faturën",
|
||||||
reprinting: "duke printuar…",
|
reprinting: "duke printuar…",
|
||||||
|
cancelTicket: "Anulo biletën",
|
||||||
|
cancelTicketTitle: "Anulo këtë biletë",
|
||||||
|
cancelTicketHint: "Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.",
|
||||||
|
cancelReason: {
|
||||||
|
misprint: "Printim i gabuar",
|
||||||
|
test: "Test",
|
||||||
|
wrongVehicle: "Automjet i gabuar",
|
||||||
|
},
|
||||||
|
cancelReasonPlaceholder: "Arsyeja e anulimit (e detyrueshme)…",
|
||||||
|
confirmCancelTicket: "Konfirmo anulimin",
|
||||||
|
cancelling: "duke anuluar…",
|
||||||
|
ticketCancelled: "Bileta u anulua.",
|
||||||
// snapshots
|
// snapshots
|
||||||
noSnapshots: "asnjë foto",
|
noSnapshots: "asnjë foto",
|
||||||
loadingSnapshots: "duke ngarkuar fotot…",
|
loadingSnapshots: "duke ngarkuar fotot…",
|
||||||
|
|||||||
+26
-4
@@ -31,6 +31,7 @@ import { RolesManager } from "./RolesManager.js";
|
|||||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
// initial bundle and only downloads when an admin opens /setup/reports.
|
// initial bundle and only downloads when an admin opens /setup/reports.
|
||||||
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
|
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
|
||||||
@@ -45,7 +46,9 @@ export interface RouterContext {
|
|||||||
setUser: (u: SessionUser | null) => void;
|
setUser: (u: SessionUser | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
// Exported so a deep component (e.g. the booth pay modal) can read the signed-in user
|
||||||
|
// from route context without prop-threading through every layer.
|
||||||
|
export const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -397,9 +400,15 @@ function RootLayout() {
|
|||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
<StatusDot />
|
||||||
<span className="text-[11px] text-term-muted">
|
{user && (
|
||||||
{user?.username} · {user?.roleName}
|
<Link
|
||||||
</span>
|
to="/profile"
|
||||||
|
title={t("nav.profile")}
|
||||||
|
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
|
||||||
|
>
|
||||||
|
{user.username} · {user.roleName}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
@@ -633,10 +642,23 @@ const logsRoute = createRoute({
|
|||||||
component: LogsViewer,
|
component: LogsViewer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
|
||||||
|
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
|
||||||
|
const profileRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "profile",
|
||||||
|
component: function ProfileRoute() {
|
||||||
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
|
if (!user) return null;
|
||||||
|
return <Profile user={user} setUser={setUser} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
boothRoute,
|
boothRoute,
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
reportsRoute,
|
reportsRoute,
|
||||||
subscriptionsRoute.addChildren([
|
subscriptionsRoute.addChildren([
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A refused-ACTION event is a benign WARNING, not a red-flag anomaly. The ledger type is
|
||||||
|
* `anomaly` for both (immutable history), but a refused exit / refused subscription /
|
||||||
|
* refused entry (e.g. a double card-scan, an at-capacity subscriber, an already-closed
|
||||||
|
* session) is an EXPECTED outcome — not fraud. We classify it from the payload flags the
|
||||||
|
* flows already sign (`exitRefused` / `entryRefused` / `permitRefused`) and show it as an
|
||||||
|
* amber "REFUZUAR / REFUSED" warning, reserving red "ANOMALI" for genuine anomalies
|
||||||
|
* (barrier-open failure, opened-without-ticket, …). Display-only — no ledger change.
|
||||||
|
*/
|
||||||
|
export function isRefusedWarning(e: LedgerEvent): boolean {
|
||||||
|
if (e.type !== "anomaly") return false;
|
||||||
|
const p = e.payload;
|
||||||
|
return !!(p && (p.exitRefused || p.entryRefused || p.permitRefused));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label key + colour to render for an event, applying the refused-warning split. */
|
||||||
|
export function eventStyleFor(e: LedgerEvent): { labelKey: string; color: string } {
|
||||||
|
if (isRefusedWarning(e)) return { labelKey: "booth.evtRefused", color: "text-term-amber" };
|
||||||
|
return EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
|
||||||
|
}
|
||||||
|
|
||||||
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
||||||
function hhmmss(iso: string): string {
|
function hhmmss(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -79,9 +100,12 @@ export function displayIdentity(e: LedgerEvent): string {
|
|||||||
* its own row, indented under the identity column. */
|
* its own row, indented under the identity column. */
|
||||||
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const style = EVENT_STYLE[e.type];
|
const style = eventStyleFor(e);
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
||||||
const isAnomaly = e.type === "anomaly";
|
// A refused-action event is a benign WARNING (amber), distinct from a genuine red
|
||||||
|
// anomaly. Only true anomalies get the red row tint + the "no reason" fallback.
|
||||||
|
const refusedWarning = isRefusedWarning(e);
|
||||||
|
const isAnomaly = e.type === "anomaly" && !refusedWarning;
|
||||||
const p = e.payload;
|
const p = e.payload;
|
||||||
const reason = renderReason(p, t);
|
const reason = renderReason(p, t);
|
||||||
const amount = paymentSummary(p);
|
const amount = paymentSummary(p);
|
||||||
@@ -95,11 +119,11 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(e)}
|
onClick={() => onOpen(e)}
|
||||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||||
isAnomaly ? "bg-term-red/5" : ""
|
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||||
{e.plate && (
|
{e.plate && (
|
||||||
@@ -152,12 +176,12 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
|
|||||||
* this only DISPLAYS the signed record. */
|
* this only DISPLAYS the signed record. */
|
||||||
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const style = EVENT_STYLE[e.type];
|
const style = eventStyleFor(e);
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
||||||
const p = e.payload;
|
const p = e.payload;
|
||||||
const reason = renderReason(p, t);
|
const reason = renderReason(p, t);
|
||||||
const badges = eventBadges(p);
|
const badges = eventBadges(p);
|
||||||
const isAnomaly = e.type === "anomaly";
|
const isAnomaly = e.type === "anomaly" && !isRefusedWarning(e);
|
||||||
|
|
||||||
// Pretty money for any minor-unit amount in the payload.
|
// Pretty money for any minor-unit amount in the payload.
|
||||||
const money =
|
const money =
|
||||||
@@ -177,7 +201,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
||||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||||
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
||||||
{(reason || money) && (
|
{(reason || money) && (
|
||||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||||
{reason ?? money}
|
{reason ?? money}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# DEV override: build the images locally from the Dockerfiles, expose both ports, run the
|
||||||
|
# stub recognizer (no model load), and verbose logging. Use with the base file:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/server/Dockerfile
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: debug
|
||||||
|
# Dev convenience: seed an admin on first boot (set ADMIN_PASS to enable).
|
||||||
|
SEED_ADMIN: ${SEED_ADMIN:-0}
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASS: ${ADMIN_PASS:-}
|
||||||
|
# 32+ chars and must NOT contain dev-only/insecure/change-me (auth.ts rejects those).
|
||||||
|
# This is a fixed LOCAL-DEV value only; prod injects a real `openssl rand -hex 32`.
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-localdevsecret0123456789abcdef0123}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: apps/vision
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
VISION_RECOGNIZER: stub
|
||||||
|
ports:
|
||||||
|
- "8089:8089"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# PROD override: pull pinned registry images (no local build), restart always, real
|
||||||
|
# recognizer, and a CADDY reverse proxy in front so operators reach the booth on a clean
|
||||||
|
# port-80 URL (no :3000) — and a path to real TLS later. Server + vision stay INTERNAL
|
||||||
|
# (only Caddy publishes a port). Use with the base file and pin TAG to the branch you deploy:
|
||||||
|
# REGISTRY=git.infra.msai.al/mca/parking_solution TAG=main \
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
# See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
|
||||||
|
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
|
||||||
|
# site's real hostname later enables automatic HTTPS. The booth is reached at
|
||||||
|
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
|
||||||
|
proxy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
networks:
|
||||||
|
- parking
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
server:
|
||||||
|
restart: always
|
||||||
|
# No published port — only the proxy reaches the server, over the private network.
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
vision:
|
||||||
|
restart: always
|
||||||
|
# The real ANPR engine. The image baked the model weights at build (offline-first).
|
||||||
|
environment:
|
||||||
|
VISION_RECOGNIZER: fast_alpr
|
||||||
|
# No published ports — vision is reached only by the server over the private network.
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Base stack: the parking SERVER (API + SPA) + the VISION (ANPR) service. Branch-aware via
|
||||||
|
# ${REGISTRY}/${TAG} — a deploy on `dev` pulls :dev, on `main` pulls :main. Use an env
|
||||||
|
# override file for the environment: docker-compose.dev.yml (build locally, stub recognizer)
|
||||||
|
# or docker-compose.prod.yml (pull pinned images, fast_alpr). See
|
||||||
|
# wiki/decisions/container-deployment.md.
|
||||||
|
#
|
||||||
|
# local dev : docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
# prod : REGISTRY=… TAG=main docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-server:${TAG:-dev}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: /data/parking.sqlite
|
||||||
|
# Reach the vision service over the private compose network by service name.
|
||||||
|
VISION_URL: http://vision:8089
|
||||||
|
VISION_ENABLED: ${VISION_ENABLED:-1}
|
||||||
|
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
|
||||||
|
# Dedicated ledger-signing key. Falls back to JWT_SECRET (with a warning) if empty;
|
||||||
|
# set a distinct one in prod. See apps/server/.env.example + local-jwt-auth.
|
||||||
|
EVENT_SIGNING_KEY: ${EVENT_SIGNING_KEY:-}
|
||||||
|
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
|
||||||
|
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
|
||||||
|
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-0}
|
||||||
|
# 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:-}
|
||||||
|
volumes:
|
||||||
|
- parking-data:/data
|
||||||
|
depends_on:
|
||||||
|
vision:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- parking
|
||||||
|
|
||||||
|
vision:
|
||||||
|
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-vision:${TAG:-dev}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Engine: stub (no models) by default; prod override sets fast_alpr.
|
||||||
|
VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub}
|
||||||
|
networks:
|
||||||
|
- parking
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
parking-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
parking:
|
||||||
|
driver: bridge
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate"
|
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate",
|
||||||
|
"db:migrate:runtime": "node scripts/migrate-runtime.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Apply Drizzle migrations against the DATABASE_URL sqlite file using the runtime
|
||||||
|
// migrator (drizzle-orm/better-sqlite3/migrator) — NOT drizzle-kit. This lets the
|
||||||
|
// container run migrations on boot with only runtime deps installed (drizzle-kit is a
|
||||||
|
// devDep, pruned out of the production image). Same migration set + folder the test
|
||||||
|
// helper uses (packages/db/src/testing.ts), so the schema matches production exactly.
|
||||||
|
//
|
||||||
|
// Usage: DATABASE_URL=/data/parking.sqlite node scripts/migrate-runtime.mjs
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("[migrate] DATABASE_URL is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrations folder ships beside this package (packages/db/drizzle); from scripts/ that's ../drizzle.
|
||||||
|
const migrationsFolder = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle");
|
||||||
|
|
||||||
|
// Ensure the DB's parent dir exists (a fresh mounted volume may be empty).
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(resolve(url)), { recursive: true });
|
||||||
|
} catch {
|
||||||
|
/* dir already exists (or url has no dir) — fine */
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(url);
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
const db = drizzle(sqlite);
|
||||||
|
|
||||||
|
console.log(`[migrate] applying migrations from ${migrationsFolder} → ${url}`);
|
||||||
|
migrate(db, { migrationsFolder });
|
||||||
|
sqlite.close();
|
||||||
|
console.log("[migrate] done");
|
||||||
@@ -347,6 +347,8 @@ export const REASON_CODES = [
|
|||||||
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
|
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
|
||||||
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
|
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
|
||||||
"sub.refused.unpaidWindow",
|
"sub.refused.unpaidWindow",
|
||||||
|
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
|
||||||
|
"void.ticketCancelled",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ReasonCode = (typeof REASON_CODES)[number];
|
export type ReasonCode = (typeof REASON_CODES)[number];
|
||||||
@@ -375,6 +377,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
|||||||
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
||||||
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
||||||
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
||||||
|
"void.ticketCancelled": "ticket cancelled — {reason}",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,9 @@
|
|||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"lint": {},
|
"lint": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": ["^build"]
|
"dependsOn": ["^build"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ With LUKS in place, **SQLCipher becomes optional** defence-in-depth rather than
|
|||||||
layer. (The custom controller adds its own: ESP32 flash encryption + secure boot — see
|
layer. (The custom controller adds its own: ESP32 flash encryption + secure boot — see
|
||||||
[[esp32-custom-controller]].)
|
[[esp32-custom-controller]].)
|
||||||
|
|
||||||
|
> **Step-by-step OS install + TPM-seal procedure** (BIOS → encrypted install → manual PCR-7 TPM
|
||||||
|
> seal, with the Dell-7070-specific `dbt` workaround) lives in [[appliance-provisioning]] — written
|
||||||
|
> from the first real provisioning (2026-06-23) and verified on hardware. **OS hardening on the first
|
||||||
|
> unit is COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7, unattended) + Secure Boot (Deployed) + GRUB
|
||||||
|
> edit-lock** (the GRUB password is the specific countermeasure to the `init=/bin/bash` root-shell
|
||||||
|
> hole that PCR-7 sealing does NOT cover). Resolves the implementation half of [[open-questions]] #12
|
||||||
|
> for unit 1.
|
||||||
|
|
||||||
## Deploy-time server configuration (runbook)
|
## Deploy-time server configuration (runbook)
|
||||||
|
|
||||||
Env in `apps/server/.env` on the appliance (see `apps/server/.env.example`). The security-load-bearing ones:
|
Env in `apps/server/.env` on the appliance (see `apps/server/.env.example`). The security-load-bearing ones:
|
||||||
|
|||||||
@@ -73,6 +73,34 @@ States, as derived from events:
|
|||||||
|
|
||||||
Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
|
Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
|
||||||
|
|
||||||
|
### Cancel a wrongly-printed ticket — BUILT (2026-06-22)
|
||||||
|
|
||||||
|
A ticket printed in error (misprint, test press, wrong vehicle) is cancelled by appending a **signed
|
||||||
|
`void`** event — the `vehicle_entry` is NEVER edited or deleted (append-only; [[append-only-event-chain]]).
|
||||||
|
`apps/server/src/void-flow.ts` (`VoidFlow`) appends `{ type:"void", identity, payload:{ sessionRef,
|
||||||
|
voidedEntryRef:<entry id>, voidReason, operator, reasonCode:"void.ticketCancelled" } }`. Traceable: the
|
||||||
|
operator (from the JWT) + a **REQUIRED reason** are signed in. Route `POST /api/tickets/void` gated on
|
||||||
|
`event:void` + an open shift. **No barrier action** — a misprinted ticket's car never entered.
|
||||||
|
|
||||||
|
- **Refused** for: a subscription occurrence (closed via its own flow), an already-exited session, an
|
||||||
|
already-voided ticket, or a **paid** ticket (a refund is a separate, out-of-scope action) → 409.
|
||||||
|
- **The void folds the session CLOSED everywhere it's counted** — this is the correctness crux. A
|
||||||
|
`void` decrements like a `vehicle_exit` in `occupancy.ts` (count + reserved-spots), and reads as
|
||||||
|
closed in `pay-station.ts` (`lookup`/`activeSessions`) and `exit-flow.ts` (`#sessionFor`), and is
|
||||||
|
excluded from the `reports.ts` entries stat. So a voided car stops occupying a spot, can't be
|
||||||
|
paid/exited, and doesn't inflate "cars entered". The booth surfaces it in the pay/exit lookup modal
|
||||||
|
(transient + unpaid + open only).
|
||||||
|
|
||||||
|
### Live-feed display: refused-action WARNING vs. genuine ANOMALY
|
||||||
|
|
||||||
|
The signed ledger `type:"anomaly"` is overloaded: it carries both benign **refused-action** events
|
||||||
|
(`exitRefused` / `entryRefused` / `permitRefused` — e.g. a double card-scan, an at-capacity
|
||||||
|
subscriber, an exit on an already-closed session) AND genuine red-flags (barrier-open failure,
|
||||||
|
opened-without-ticket). The booth feed now classifies from those existing payload flags
|
||||||
|
(`event-detail.tsx isRefusedWarning`) and shows the refused ones as an amber **REFUZUAR / REFUSED**
|
||||||
|
warning, reserving red **ANOMALI** for true anomalies. **Display-only** — no ledger type/data change,
|
||||||
|
so historical events reclassify correctly too.
|
||||||
|
|
||||||
## Edge cases the model must name (not yet designed in full)
|
## Edge cases the model must name (not yet designed in full)
|
||||||
|
|
||||||
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
|
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-23
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Appliance provisioning runbook (booth PC)
|
||||||
|
|
||||||
|
Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running
|
||||||
|
parking appliance. Written from the **first real provisioning, 2026-06-23**, on the actual hardware
|
||||||
|
below — every command here was run and verified on that machine, including the firmware-specific
|
||||||
|
workaround. Companion to [[disk-os-hardening]] (the *why*), [[tpm]] (TPM analysis), and
|
||||||
|
[[container-deployment]] (the images this runs).
|
||||||
|
|
||||||
|
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
|
||||||
|
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
|
||||||
|
> encryption + Secure Boot raise the cost of offline tamper, they don't replace reconciliation.
|
||||||
|
|
||||||
|
## Reference hardware (first unit, 2026-06-23)
|
||||||
|
|
||||||
|
- **Dell OptiPlex 7070**, **Intel Core i5-8500** (Coffee Lake), 238 GB SATA SSD (`/dev/sda`).
|
||||||
|
- **TPM 2.0 — discrete Nuvoton** (`Get-Tpm` → `ManufacturerIdTxt NTC`, fw 7.2.1.0). NOT Intel
|
||||||
|
PTT/fTPM. Discrete ⇒ an external LPC/SPI bus exists (bus-sniff is a theoretical physical attack on
|
||||||
|
PCR-only sealing — accepted; see [[tpm]]). Used PC — previous owner irrelevant.
|
||||||
|
- Shipped Windows 11; formatted to **Ubuntu 26.04 LTS** (the decided platform — [[desktop-shell-tauri]]).
|
||||||
|
|
||||||
|
## 1. BIOS (F2 at the Dell logo)
|
||||||
|
|
||||||
|
- **TPM**: leave **On**. Used PC → **Clear the TPM once** (Security → TPM → Clear) so the prior
|
||||||
|
owner's keys are wiped before LUKS enrollment. (PPI "Bypass for Clear" was unchecked → it asks for
|
||||||
|
physical confirmation at next boot; that's normal.)
|
||||||
|
- **Secure Boot**: **Enabled**, **Deployed Mode** (not Audit). **"Enable Custom Mode" UNCHECKED** =
|
||||||
|
Standard Mode with stock Microsoft keys — this is what Ubuntu's signed shim needs. Do NOT touch
|
||||||
|
PK/KEK/db/dbx. NB: the 7070's Expert Key Management is **edit-only** (Save/Replace/Append/Delete —
|
||||||
|
no read-only "View Key"), so you **cannot inspect db from BIOS**; verify via the live USB instead
|
||||||
|
(step 2).
|
||||||
|
- **Boot**: UEFI only (no CSM/Legacy — a Legacy install has no Secure Boot / TPM-seal path).
|
||||||
|
- Set a **BIOS admin password**.
|
||||||
|
|
||||||
|
## 2. Boot the Ubuntu 26.04 USB (Secure Boot ON)
|
||||||
|
|
||||||
|
- **Flash the ISO DIRECTLY** (Rufus GPT/UEFI, Etcher, or `dd`). **NOT Ventoy** — Ventoy's own
|
||||||
|
bootloader isn't in `db`, so Secure Boot rejects it with **`Verification failed: (0x1A) Security
|
||||||
|
Violation`** (this is Secure Boot working correctly, not a fault). A directly-flashed Ubuntu USB
|
||||||
|
boots the Microsoft-signed shim, which stock `db` trusts.
|
||||||
|
- **F12** at the Dell logo → pick the USB under **UEFI BOOT**.
|
||||||
|
- Reaching the installer with Secure Boot ON = positive proof the MS third-party UEFI CA is in `db`
|
||||||
|
(the verification the BIOS couldn't show us).
|
||||||
|
|
||||||
|
## 3. Encrypted install — the firmware workaround (IMPORTANT)
|
||||||
|
|
||||||
|
The 26.04 installer disk page offers: No Encryption / **Encrypt with a passphrase** / **Use
|
||||||
|
hardware-backed encryption** (+ advanced LVM/ZFS, both ZFS experimental).
|
||||||
|
|
||||||
|
- **"Use hardware-backed encryption" FAILS on this 7070** with:
|
||||||
|
`PCR_UNUSABLE … error with secure boot policy (PCR7) measurements: generating secure boot profiles
|
||||||
|
for systems with timestamp revocation (dbt) support is currently not supported.`
|
||||||
|
→ Ubuntu's *automated* FDE profiler can't model PCR7 on Dell firmware carrying a `dbt` (UEFI
|
||||||
|
timestamp revocation list). It is NOT a TPM or Secure-Boot fault — both are fine.
|
||||||
|
- **So: choose "Encrypt with a passphrase".** Set a strong passphrase and **SAVE IT OFF-MACHINE**
|
||||||
|
(phone / password manager). It is both the boot unlock (until TPM sealing) AND the permanent
|
||||||
|
recovery slot. Finish the install.
|
||||||
|
- Result (verify with `lsblk`): `sda1` vfat `/boot/efi`, `sda2` ext4 `/boot`, `sda3` `crypto_LUKS`
|
||||||
|
→ `dm_crypt-0` (LVM2) → `ubuntu--vg-ubuntu--lv` ext4 `/`.
|
||||||
|
|
||||||
|
## 4. Seal LUKS to the TPM (manual — PCR 7 only)
|
||||||
|
|
||||||
|
Do this AFTER first boot. Manual enrollment sidesteps the installer's dbt profiler and lets us pick
|
||||||
|
PCRs. **Bind to PCR 7 only** (Secure Boot state): it catches the attack that matters (disabling
|
||||||
|
Secure Boot to boot a tampered kernel) WITHOUT breaking on routine kernel/GRUB updates (which churn
|
||||||
|
PCRs 4/8/9 → would otherwise drop every boot to the passphrase). Firmware-only PCR 0 is the fallback
|
||||||
|
if PCR 7 ever errors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt install -y tpm2-tools
|
||||||
|
sudo tpm2_pcrread sha256 # sanity: PCRs 0-10 populated, PCR 7 has a real value
|
||||||
|
|
||||||
|
# Enroll the TPM (prompts for the EXISTING install passphrase to authorize the new slot):
|
||||||
|
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3
|
||||||
|
|
||||||
|
# Verify TWO slots — keep BOTH (slot 0 password = recovery, slot 1 tpm2 = auto-unlock):
|
||||||
|
sudo systemd-cryptenroll /dev/sda3
|
||||||
|
# SLOT TYPE
|
||||||
|
# 0 password
|
||||||
|
# 1 tpm2
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it into boot (back up first; the mapping is `dm_crypt-0`, the LUKS UUID is in `/etc/crypttab`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /etc/crypttab /etc/crypttab.bak
|
||||||
|
sudo sed -i 's/none luks$/none luks,tpm2-device=auto/' /etc/crypttab
|
||||||
|
cat /etc/crypttab # → dm_crypt-0 UUID=… none luks,tpm2-device=auto
|
||||||
|
sudo update-initramfs -u
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Boots straight to login, no passphrase prompt** = ✅ TPM auto-unlock works (unattended reboot
|
||||||
|
achieved — VERIFIED on this unit 2026-06-23).
|
||||||
|
- Still prompts = PCR mismatch; type the passphrase (NOT locked out), then retry with
|
||||||
|
`--tpm2-pcrs=0`. The `password` slot + `crypttab.bak` make this fully reversible.
|
||||||
|
|
||||||
|
> **Re-seal runbook:** a BIOS update / Secure Boot change alters PCR 7 → the TPM refuses → boot
|
||||||
|
> falls back to the passphrase prompt (not a brick). After such a change, re-run step 4's
|
||||||
|
> `systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` to re-bind.
|
||||||
|
|
||||||
|
## 5. GRUB password — EDIT-ONLY (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
|
Closes the `init=/bin/bash` / `systemd.unit=rescue.target` local-root hole: without it, anyone at
|
||||||
|
the keyboard presses `e` at the GRUB menu, edits the kernel cmdline, and boots to a **root shell with
|
||||||
|
no login**. **The PCR-7 TPM seal does NOT cover this** — editing the GRUB cmdline doesn't change
|
||||||
|
PCR 7 (Secure Boot policy), so the TPM still releases the key and the attacker lands on the decrypted
|
||||||
|
disk. This is the specific countermeasure for the [[threat-model|operator-at-the-booth]]. Use
|
||||||
|
**edit-only** mode (`--unrestricted`) so the box still boots UNATTENDED — the password is required
|
||||||
|
only to EDIT entries, never to boot.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grub-mkpasswd-pbkdf2 # enter a password (twice) → copy the grub.pbkdf2.sha512.* hash
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the superuser (paste YOUR hash) to the end of `/etc/grub.d/40_custom`:
|
||||||
|
```
|
||||||
|
set superusers="admin"
|
||||||
|
password_pbkdf2 admin grub.pbkdf2.sha512.10000.<YOUR_HASH>
|
||||||
|
```
|
||||||
|
|
||||||
|
Make menu entries bootable WITHOUT the password (edit-only) — in `/etc/grub.d/10_linux`, set the
|
||||||
|
active `CLASS=` line to include `--unrestricted`:
|
||||||
|
```
|
||||||
|
CLASS="--class gnu-linux --class gnu --class os --unrestricted"
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerate + VERIFY BOTH HALVES landed in the real config BEFORE rebooting (a GRUB misconfig means a
|
||||||
|
rescue-USB recovery):
|
||||||
|
```bash
|
||||||
|
sudo update-grub
|
||||||
|
sudo grep -c "password_pbkdf2" /boot/grub/grub.cfg # want ≥1 (password present)
|
||||||
|
sudo grep -c "unrestricted" /boot/grub/grub.cfg # want ≥1 (entries bootable w/o password)
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ VERIFIED on this unit: boots straight to login (no GRUB prompt, TPM still auto-unlocks) AND
|
||||||
|
pressing `e` at the menu prompts for `admin` + password. Store the GRUB password off-machine
|
||||||
|
(alongside the LUKS passphrase).
|
||||||
|
|
||||||
|
> OS hardening on the first unit is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7) + Secure Boot
|
||||||
|
> (Deployed) + GRUB edit-lock.
|
||||||
|
|
||||||
|
## 5c. OS user model — admin vs operator (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
|
The OS has TWO roles and they must be different identities ([[threat-model]]: the operator is the
|
||||||
|
adversary). Create a dedicated **admin** (real password, sudo, NO auto-login) and keep the
|
||||||
|
**operator** as an auto-login, UNPRIVILEGED account.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser admin && sudo usermod -aG sudo admin
|
||||||
|
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
|
||||||
|
sudo deluser <operator> sudo # demote the auto-login operator
|
||||||
|
groups <operator> # confirm: no 'sudo'
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
|
||||||
|
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
|
||||||
|
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
||||||
|
|
||||||
|
> Strip latent escalation groups from the operator: **`sudo deluser <operator> lxd`** (lxd group =
|
||||||
|
> launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
|
||||||
|
> `lpadmin` (printer admin, unneeded). And NEVER add the operator to `docker` (also root-equivalent).
|
||||||
|
|
||||||
|
## 5b. Further hardening (TODO — not yet done)
|
||||||
|
|
||||||
|
- **Key-based SSH only** (disable password auth) if SSH is enabled at all.
|
||||||
|
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
|
||||||
|
([[desktop-shell-tauri]]).
|
||||||
|
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
|
||||||
|
- `sudo apt autoremove` the leftover old kernel once the new one is proven.
|
||||||
|
|
||||||
|
## 6. Runtime — Docker stack (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
|
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
|
||||||
|
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
|
||||||
|
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
|
||||||
|
|
||||||
|
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`:
|
||||||
|
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env`
|
||||||
|
(chmod 600). The `.env` (driven into the containers by the base compose):
|
||||||
|
|
||||||
|
```
|
||||||
|
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
|
||||||
|
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32>
|
||||||
|
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
|
||||||
|
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes)
|
||||||
|
VISION_ENABLED=1
|
||||||
|
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.infra.msai.al # a read-only package token, not the account password
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent):
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
|
||||||
|
-e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' server node scripts/seed-admin.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked
|
||||||
|
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
|
||||||
|
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
|
||||||
|
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
|
||||||
|
|
||||||
|
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
|
||||||
|
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
|
||||||
|
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
|
||||||
|
`hosts`/DNS ON-SITE, never an image rebuild.
|
||||||
|
|
||||||
|
## Quick-reference: the gotchas, in order they bit us
|
||||||
|
|
||||||
|
1. Ventoy USB → `0x1A` Security Violation under Secure Boot → flash the ISO directly instead.
|
||||||
|
2. 7070 BIOS has no "View Key" → can't inspect db; the live-USB boot IS the verification.
|
||||||
|
3. Installer "hardware-backed encryption" → `PCR_UNUSABLE`/dbt → use passphrase LUKS + manual seal.
|
||||||
|
4. Bind TPM to **PCR 7 only**, not a multi-PCR set (kernel updates churn 4/8/9 → passphrase every boot).
|
||||||
|
5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key).
|
||||||
|
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
|
||||||
|
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, deployment, docker, ci, offline-first]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-22
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Container deployment (Docker images for the non-desktop apps)
|
||||||
|
|
||||||
|
How the parking system's runtime apps are packaged as containers, tagged, and published.
|
||||||
|
Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service
|
||||||
|
into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle).
|
||||||
|
|
||||||
|
## Two images (the desktop app is NOT containerized)
|
||||||
|
|
||||||
|
- **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both:
|
||||||
|
Fastify serves `apps/web/dist` via `@fastify/static` (wired in `apps/server/src/static-spa.ts`),
|
||||||
|
with an SPA fallback to `index.html` for client routing. This matches [[offline-first]] — the
|
||||||
|
booth appliance is one box, not a web host + an API host. `@fastify/web` static serving is a
|
||||||
|
**no-op in dev** (no build dir → the Vite dev server serves the UI), so local DX is unchanged.
|
||||||
|
- **`parking-vision`** — the Python/uv ANPR service ([[opencv-anpr-service]]). Ships WITH the
|
||||||
|
`alpr` extra (real fast-alpr/onnxruntime stack); the engine is env-selected
|
||||||
|
(`VISION_RECOGNIZER=stub|fast_alpr`, default `stub` so it boots anywhere). Model weights are
|
||||||
|
**pre-warmed at build** (best-effort) so the appliance's first scan needs no network.
|
||||||
|
|
||||||
|
The **desktop** app stays on its own tag-only `release.yml` (Tauri installers), not these images.
|
||||||
|
|
||||||
|
## Branch-aware (the user's hard requirement)
|
||||||
|
|
||||||
|
- **Image tags = branch + short SHA.** A push to `dev` builds `…/parking-server:dev` +
|
||||||
|
`…/parking-server:dev-<sha>`; `main` builds `:main` + `:main-<sha>`. The moving branch tag is the
|
||||||
|
deploy pointer; the branch-SHA tag is the immutable record. Same for `parking-vision`.
|
||||||
|
- **Per-env compose.** A base `docker-compose.yml` + overrides: `docker-compose.dev.yml` (build
|
||||||
|
locally, expose ports, `stub` recognizer) and `docker-compose.prod.yml` (pull pinned images,
|
||||||
|
`restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy
|
||||||
|
on a branch pulls that branch's image — the branch→environment mapping IS the override file.
|
||||||
|
|
||||||
|
## Registry + CI
|
||||||
|
|
||||||
|
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
|
||||||
|
parking-vision}`. Login via `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` secrets.
|
||||||
|
- New workflow **`.gitea/workflows/build-images.yml`** (separate from the checks-only `ci.yml` and the
|
||||||
|
tag-only `release.yml`): on push to `dev`/`main`, run the full `turbo build lint test` first (don't
|
||||||
|
ship a broken image), then buildx + `docker/build-push-action` for both images with branch+SHA tags
|
||||||
|
and a registry build cache. An optional Komodo redeploy webhook is guarded behind a `KOMODO_ENABLED`
|
||||||
|
var (mirrors the house `trm/processor` pattern). The vision checks need `uv` (the `astral-sh/setup-uv`
|
||||||
|
step), same as `ci.yml`.
|
||||||
|
|
||||||
|
## Build specifics that bit us (record so they don't recur)
|
||||||
|
|
||||||
|
- **`pnpm deploy --legacy --prod`, NOT `pnpm prune --prod`.** It's a pnpm/turbo monorepo; pruning at
|
||||||
|
the root leaves `packages/db/node_modules` empty, so the native **`better-sqlite3`** binding can't
|
||||||
|
resolve at runtime. `pnpm deploy` produces a self-contained, hoisted bundle (the workspace packages'
|
||||||
|
built `dist` + their native deps) — a single `COPY --from=build /deploy ./`. pnpm 10 needs `--legacy`
|
||||||
|
(or `inject-workspace-packages`).
|
||||||
|
- **Native modules**: Alpine build stage needs `python3 make g++` (node-gyp for better-sqlite3);
|
||||||
|
runtime needs `libstdc++`. `bcrypt` ships a `linux-x64/musl` prebuild, so it works on Alpine as-is.
|
||||||
|
- **`pnpm prune`/deploy refuse to run without a TTY** unless `CI=true` (or `ENV CI=true`) is set in
|
||||||
|
the build stage.
|
||||||
|
- **Migrations at boot, not at build.** The DB lives on a mounted volume (`/data`), so the entrypoint
|
||||||
|
runs them against the live file via a **drizzle-kit-free** runtime migrator
|
||||||
|
(`packages/db/scripts/migrate-runtime.mjs`, using `drizzle-orm/.../migrator` — drizzle-kit is a
|
||||||
|
devDep, pruned from the prod bundle). Idempotent: a restart re-applies nothing.
|
||||||
|
- **JWT_SECRET** must be a real value at deploy — `auth.ts` rejects anything `<32` chars or matching
|
||||||
|
`change.?me|insecure|dev-only`, so the dev compose default is a benign 32-char string, not a
|
||||||
|
"dev-only…" placeholder (which would crash boot).
|
||||||
|
- **Vision model pre-warm must run AS the runtime user.** fast-alpr's `open-image-models` caches
|
||||||
|
weights under `$HOME/.cache/open-image-models` keyed to `$HOME` — it ignores `HF_HOME`/
|
||||||
|
`XDG_CACHE_HOME`. A first attempt pre-warmed as root (`/root/.cache`), so the non-root runtime
|
||||||
|
re-downloaded at boot (offline-first BROKEN). Fix: create the `vision` user first, `USER vision`,
|
||||||
|
THEN run `python -c "from fast_alpr import ALPR; ALPR()"` so weights land in `/home/vision/.cache`
|
||||||
|
— exactly where the runtime reads. Verify the boot log shows NO "Downloading …onnx".
|
||||||
|
|
||||||
|
## Web access — relative API + Caddy proxy (2026-06-23)
|
||||||
|
|
||||||
|
- **The server-image SPA uses a RELATIVE `/api` base** (no baked origin), so the UI works loaded
|
||||||
|
from any hostname/IP. The Dockerfile empties `VITE_API_BASE` via `apps/web/.env.production.local`
|
||||||
|
before the web build — because Vite auto-loads `apps/web/.env.production`, which sets
|
||||||
|
`VITE_API_BASE=http://127.0.0.1:3000` for the **Tauri desktop** build only. Without the override
|
||||||
|
the browser bundle baked `127.0.0.1:3000` and failed Same-Origin Policy from any other host. **Do
|
||||||
|
NOT bake the domain via a build var** — relative means naming is controlled by hosts/DNS at deploy,
|
||||||
|
never a rebuild.
|
||||||
|
- **A Caddy reverse proxy** (prod override) publishes `:80` → `server:3000` (server is `expose`-only,
|
||||||
|
internal); `/api/ws` upgrades pass through. `Caddyfile` binds `:80` so it matches ANY host — booth
|
||||||
|
IP, localhost, or `parksystems.msai.al` (pointed at the booth IP via hosts/DNS on-site). TLS later:
|
||||||
|
swap `:80` for the real hostname + uncomment Caddy `:443` → auto-HTTPS.
|
||||||
|
- `WS_ALLOWED_ORIGINS` (env) must list any REMOTE origin admins use (same-origin always passes).
|
||||||
|
|
||||||
|
## Invariants (must hold)
|
||||||
|
|
||||||
|
- **Never bake the live DB.** `.dockerignore` excludes `**/parking.sqlite*` (incl. `-wal`/`-shm`/
|
||||||
|
`.bak-*`) — `pnpm deploy` copies the package dir's files ignoring `.gitignore`, so the
|
||||||
|
`.dockerignore` (which gates the build CONTEXT) is what keeps the signed ledger out of the image.
|
||||||
|
The DB is a host-volume asset ([[append-only-event-chain]], [[threat-model]]).
|
||||||
|
- **SPA serving must not shadow the API** — the fallback is GET-only and excludes `/api`, `/health`;
|
||||||
|
a missing `/api/*` still 404s as JSON, not the HTML shell.
|
||||||
|
- **Offline-first** — both images boot + serve with no network (vision default `stub`; `fast_alpr`
|
||||||
|
weights pre-warmed into the image layer).
|
||||||
|
- **Non-root runtime**, minimal final image (deploy bundle only; build toolchain dropped).
|
||||||
|
|
||||||
|
## Verified on hardware (2026-06-22)
|
||||||
|
|
||||||
|
Both images built + smoke-tested locally (Docker 29, buildx):
|
||||||
|
|
||||||
|
- **server**: build → run → entrypoint migrates `/data/parking.sqlite`, SPA static serving enabled,
|
||||||
|
server listens; `/health` 200, `/` + `/booth` serve the SPA (text/html), `/api/nope` → JSON 404;
|
||||||
|
no `parking.sqlite*` anywhere outside `/data` in the image.
|
||||||
|
- **vision** (1.8 GB, `--extra alpr`): build pre-warms the YOLOv9 + CCT weights into the image
|
||||||
|
(`/home/vision/.cache`); run as `fast_alpr` → `ready:true` with **0 downloads at boot** (offline-
|
||||||
|
first confirmed); `stub` mode also boots clean.
|
||||||
|
- **compose** (`docker-compose.yml` + `.dev.yml`): both containers come up healthy and the server
|
||||||
|
reaches the vision service over the private network (`wget http://vision:8089/health` from the
|
||||||
|
server container → 200).
|
||||||
@@ -167,3 +167,34 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
||||||
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
||||||
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
||||||
|
|
||||||
|
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
|
||||||
|
|
||||||
|
The desktop bundle now runs in CI under **two distinct workflows** — keep the split clear:
|
||||||
|
|
||||||
|
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
|
||||||
|
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
|
||||||
|
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
|
||||||
|
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
|
||||||
|
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
|
||||||
|
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
|
||||||
|
no `TAURI_SIGNING_*`, no `latest.json` — so it must NEVER be wired to the updater (an unsigned
|
||||||
|
artifact would be rejected anyway). It exists so each branch push yields a downloadable installer
|
||||||
|
for manual testing of the native shell, and catches a broken Tauri/Rust build early. Same
|
||||||
|
system-deps + cargo cache as `release.yml`. The container images (`build-images.yml`) and the
|
||||||
|
desktop installers are deliberately separate pipelines — the desktop app is **not** containerized
|
||||||
|
([[container-deployment]]).
|
||||||
|
- **Delivery: a rolling pre-release, NOT `actions/upload-artifact`.** That action's artifact
|
||||||
|
backend isn't reliable on the Gitea runner (the *Upload installers* step failed). Instead the
|
||||||
|
workflow mirrors `release.yml`'s proven path — plain `curl` + the built-in `GITHUB_TOKEN` to the
|
||||||
|
**Releases API**. It DELETEs any existing `desktop-<branch>` release + tag, recreates it against
|
||||||
|
the new commit as a **prerelease**, and uploads the two installers (renamed space-free,
|
||||||
|
`parking-desktop-<branch>-<sha>.{deb,AppImage}`). So `desktop-dev` always holds the newest dev
|
||||||
|
build; `v*` tags remain the only *signed* releases.
|
||||||
|
- **Gotcha (the unsigned build still demands the key).** `tauri.conf.json` sets
|
||||||
|
`bundle.createUpdaterArtifacts: true` (so `release.yml` produces the `.sig` updater signatures).
|
||||||
|
With that on, `tauri build` **fails** if `TAURI_SIGNING_PRIVATE_KEY` is absent — *"A public key
|
||||||
|
has been found, but no private key"* — even though the `.deb`/`.AppImage` themselves built fine.
|
||||||
|
The unsigned CI build therefore overrides it off with
|
||||||
|
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
|
||||||
|
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
|
||||||
|
|||||||
@@ -110,3 +110,7 @@ The skeleton is **built and wired** (no recognizer models yet):
|
|||||||
weights out of the build entirely (baked into the Docker image instead).
|
weights out of the build entirely (baked into the Docker image instead).
|
||||||
- Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail,
|
- Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail,
|
||||||
defer to the install/hardening pass.
|
defer to the install/hardening pass.
|
||||||
|
|
||||||
|
> **Resolved 2026-06-22 → [[container-deployment]]:** the vision service now ships as the
|
||||||
|
> `parking-vision` Docker image (uv base, `--extra alpr`), model weights **pre-warmed into the image
|
||||||
|
> layer** at build (offline-first), and runs under **docker-compose** (base + per-env override).
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ The SPA never sees the JWT. Login (`POST /api/auth/login`) verifies bcrypt and s
|
|||||||
requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt.
|
requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt.
|
||||||
|
|
||||||
Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev
|
Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev
|
||||||
|
|
||||||
|
**Self-service profile (added 2026-06-24).** Alongside the admin user-manager (`routes/users.ts`,
|
||||||
|
gated on `user:*`), any signed-in user has two **self-only** routes (no permission needed — they
|
||||||
|
act solely on `req.user.sub`):
|
||||||
|
- `PUT /api/auth/profile` — edit own `fullName` / `email` (`""` clears → null). Returns the
|
||||||
|
refreshed session (so the SPA header updates). **Cannot** touch `username` or `role` — those stay
|
||||||
|
admin-only, so this is not a privilege-escalation surface.
|
||||||
|
- `PUT /api/auth/password` — change own password, but **must prove the current one** first
|
||||||
|
(`bcrypt.compare`) → defends a walked-up, already-logged-in booth from a silent re-key. New
|
||||||
|
password ≥ 8 chars. Distinct from the admin reset (`PUT /api/users/:id/password`), which needs no
|
||||||
|
current password but DOES need `user:update` + the no-escalation guard.
|
||||||
|
Both are still CSRF-guarded (mutations). The SPA surfaces them at `/profile` (`apps/web/src/Profile.tsx`),
|
||||||
|
reachable from the header username chip. Covered by `apps/server/src/routes/profile.test.ts`.
|
||||||
|
|
||||||
|
The dev
|
||||||
[[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API
|
[[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API
|
||||||
**same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only
|
**same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only
|
||||||
`SETUP_AUTH_BYPASS` shim, now removed.)
|
`SETUP_AUTH_BYPASS` shim, now removed.)
|
||||||
|
|||||||
@@ -121,3 +121,5 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
||||||
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
||||||
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
|
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
|
||||||
|
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
||||||
|
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
||||||
|
|||||||
+106
@@ -1446,3 +1446,109 @@ read flows are constructed before the hik-alarm registration. New env: `VISION_E
|
|||||||
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
|
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
|
||||||
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
|
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
|
||||||
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).
|
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).
|
||||||
|
|
||||||
|
## [2026-06-22] build | Cancel (void) a wrongly-printed ticket + refused-vs-anomaly display split
|
||||||
|
Operator need: cancel a misprinted/test/wrong-vehicle ticket, traceably. Built it as a SIGNED `void`
|
||||||
|
(append-only — the vehicle_entry is never touched): new `apps/server/src/void-flow.ts` (`VoidFlow`)
|
||||||
|
appends void{ voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
|
||||||
|
POST /api/tickets/void gated event:void + open shift; operator from JWT, reason REQUIRED. Refuses a
|
||||||
|
subscription / already-exited / already-voided / PAID ticket (refund = out of scope). The CRUX: a
|
||||||
|
void must fold the session CLOSED everywhere it's counted — done in occupancy.ts (count +
|
||||||
|
reserved-spots, −1 like an exit), pay-station.ts (lookup/activeSessions), exit-flow.ts (#sessionFor),
|
||||||
|
and reports.ts (excluded from the entries stat). No barrier action (the car never entered). Booth UI:
|
||||||
|
"Cancel ticket" in the pay/exit lookup modal (transient + unpaid + open; gated on event:void) with a
|
||||||
|
preset-or-free reason prompt. Part 2 (display-only): the Live feed mislabeled benign refused-action
|
||||||
|
events (exitRefused/entryRefused/permitRefused — e.g. a double card-scan) as red ANOMALI; now
|
||||||
|
classified via event-detail.tsx isRefusedWarning and shown as amber REFUZUAR/REFUSED, reserving red
|
||||||
|
ANOMALI for genuine red-flags. No ledger change → historical events reclassify too. New reason code
|
||||||
|
void.ticketCancelled (shared + both web catalogs). Tests: void-flow.test.ts (8) + occupancy void fold;
|
||||||
|
141 server + 87 shared green; build+lint (TS + i18n parity) green. Updated [[parking-session]].
|
||||||
|
|
||||||
|
## [2026-06-22] build | Docker images for non-desktop apps (server+SPA, vision) + branch-aware build pipeline
|
||||||
|
Containerized the two runtime apps. parking-server = Fastify API + the bundled React SPA (wired
|
||||||
|
@fastify/static in new static-spa.ts — serves apps/web/dist with an SPA index.html fallback, GET-only
|
||||||
|
and excluding /api + /health so it never shadows the backend; a NO-OP in dev where no dist exists).
|
||||||
|
parking-vision = the Python/uv ANPR service, ships --extra alpr with model weights pre-warmed into the
|
||||||
|
image (offline-first), engine env-selected (VISION_RECOGNIZER stub|fast_alpr). Branch-aware per the
|
||||||
|
user: images tagged branch + branch-<sha>; base docker-compose.yml + docker-compose.dev.yml (build
|
||||||
|
local, stub, ports) / docker-compose.prod.yml (pull pinned, fast_alpr, vision internal, restart
|
||||||
|
always). New .gitea/workflows/build-images.yml pushes both to git.infra.msai.al/mca/parking_solution
|
||||||
|
on push to dev/main, after a full turbo build+lint+test gate (mirrors trm/processor; optional Komodo
|
||||||
|
webhook behind KOMODO_ENABLED). KEY build lessons: use `pnpm deploy --legacy --prod` NOT
|
||||||
|
`pnpm prune` (monorepo: prune leaves the native better-sqlite3 unresolved); Alpine needs
|
||||||
|
python3/make/g++ (build) + libstdc++ (runtime); set CI=true so pnpm wipes node_modules; migrate at
|
||||||
|
BOOT via a drizzle-kit-free runtime migrator (packages/db/scripts/migrate-runtime.mjs) against the
|
||||||
|
mounted /data volume; .dockerignore must exclude **/parking.sqlite* (deploy ignores .gitignore) so the
|
||||||
|
signed ledger is NEVER baked. JWT_SECRET must be a real >=32-char value (auth.ts rejects dev-only/
|
||||||
|
insecure/change-me). VERIFIED: server image builds + runs — migrates, SPA serving on, /health 200,
|
||||||
|
/ + /booth serve HTML, /api/nope JSON 404, no sqlite outside /data. Vision image build + smoke in
|
||||||
|
progress. New page [[container-deployment]]; updated [[vision-service-packaging]] (resolved its two
|
||||||
|
open Qs), index. Server tests stay 141 green (SPA serving guarded on dist existence).
|
||||||
|
|
||||||
|
## [2026-06-23] provision | First booth appliance — Dell OptiPlex 7070, Win11 → Ubuntu 26.04 LTS, encrypted + TPM-sealed
|
||||||
|
Provisioned the first real booth PC. Hardware: Dell OptiPlex 7070, i5-8500, 238GB SSD, discrete
|
||||||
|
Nuvoton TPM 2.0 (NOT Intel PTT — Get-Tpm ManufacturerIdTxt NTC). Formatted Win11 → Ubuntu 26.04 LTS
|
||||||
|
(the decided platform). Gotchas hit + resolved, in order: (1) Ventoy USB → 0x1A Security Violation
|
||||||
|
under Secure Boot (Ventoy's loader not in db) → flash the ISO directly; (2) the 7070 BIOS Expert Key
|
||||||
|
Management is edit-only, no "View Key" → can't inspect db, so the live-USB boot IS the verification
|
||||||
|
(it reached the installer with Secure Boot ON → MS 3rd-party UEFI CA confirmed present); (3) the
|
||||||
|
installer's "Use hardware-backed encryption" FAILED with PCR_UNUSABLE / "secure boot policy (PCR7) …
|
||||||
|
timestamp revocation (dbt) … not supported" — Ubuntu's automated FDE profiler can't model PCR7 on
|
||||||
|
Dell firmware with a dbt; NOT a TPM/SB fault. Workaround: "Encrypt with a passphrase" (plain LUKS) +
|
||||||
|
MANUAL TPM seal after boot via systemd-cryptenroll --tpm2-pcrs=7 /dev/sda3 (PCR 7 only — avoids
|
||||||
|
kernel-churned 4/8/9 that would drop every boot to the passphrase). Two LUKS slots kept (0 password =
|
||||||
|
recovery, 1 tpm2 = auto-unlock); crypttab gets tpm2-device=auto; update-initramfs; reboot → BOOTS
|
||||||
|
STRAIGHT TO LOGIN, no passphrase → TPM auto-unlock VERIFIED (unattended reboot achieved). New runbook
|
||||||
|
page [[appliance-provisioning]] (every command verified on hardware); cross-linked from
|
||||||
|
[[disk-os-hardening]] (resolves impl half of open-questions #12 for unit 1) + index. REMAINING on the
|
||||||
|
box: GRUB password, Docker install, run the parking-server/parking-vision stack.
|
||||||
|
|
||||||
|
## [2026-06-23] provision | First booth appliance — GRUB edit-lock added; OS hardening COMPLETE
|
||||||
|
Added the GRUB password (edit-only mode via --unrestricted) to the first booth unit. WHY it matters
|
||||||
|
specifically: the PCR-7 TPM seal does NOT cover the GRUB-cmdline attack (editing the kernel line to
|
||||||
|
init=/bin/bash doesn't change PCR 7, so the TPM still releases the LUKS key → root shell on the
|
||||||
|
decrypted disk). Edit-only mode keeps unattended boot (the box still boots password-free; the
|
||||||
|
password is required only to EDIT entries / open the GRUB shell) — the right config for an unattended
|
||||||
|
booth. Verified BOTH halves in /boot/grub/grub.cfg before rebooting (password_pbkdf2 ≥1, unrestricted
|
||||||
|
≥1) and on reboot: boots straight to login (no GRUB prompt, TPM auto-unlock intact) AND pressing `e`
|
||||||
|
prompts for admin+password. OS hardening on unit 1 is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7)
|
||||||
|
+ Secure Boot (Deployed) + GRUB edit-lock. Updated [[appliance-provisioning]] (§5 GRUB now a verified
|
||||||
|
step, §5b further-hardening TODO: SSH key-only, kiosk lockdown, signing key→TPM, autoremove old
|
||||||
|
kernel) + [[disk-os-hardening]]. STILL TODO on the box: Docker install + run the parking stack (needs
|
||||||
|
the images pushed — dev push + registry secrets pending).
|
||||||
|
|
||||||
|
## [2026-06-23] deploy | First booth GO-LIVE — Docker stack running + web-access fixes (CI uv, compose env, relative /api, Caddy)
|
||||||
|
Deployed the two images onto the hardened booth (Dell 7070, Ubuntu 26.04) and worked through the
|
||||||
|
real-world bring-up issues. (1) Operator/admin OS user split: created a dedicated sudo `admin` user,
|
||||||
|
removed the auto-login operator from `sudo` (and should drop `lxd`/`lpadmin` — lxd is a root-escape
|
||||||
|
path); admin is the only sudo, operator auto-logs in unprivileged. (2) Docker 29.6 installed; deploy
|
||||||
|
dir /opt/parking_solution with hand-copied compose + .env; registry login to git.infra.msai.al; the
|
||||||
|
stack came up clean — vision fast_alpr loaded from the BAKED cache (0 downloads → offline-first
|
||||||
|
confirmed on real hardware), server migrated /data, both healthy. (3) Seeded the first admin via
|
||||||
|
`docker compose exec server node scripts/seed-admin.mjs` (bcrypt, writes users table — NOT the signed
|
||||||
|
ledger). FIXES committed this session: CI `astral-sh/setup-uv` action failed on the Gitea runner →
|
||||||
|
install uv via its official curl script instead (both ci.yml + build-images.yml) [0a22eab]; the base
|
||||||
|
compose only forwarded JWT_SECRET/DATABASE_URL/VISION_URL → added COOKIE_SECURE (CRITICAL on plain-
|
||||||
|
http or login cookies never send), WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED [1092316];
|
||||||
|
the SPA had VITE_API_BASE=http://127.0.0.1:3000 baked in (leaked from apps/web/.env.production, which
|
||||||
|
is for the TAURI build but Vite auto-loads it for every build) → server Dockerfile now empties it via
|
||||||
|
.env.production.local so the SPA uses RELATIVE /api and works from ANY host [77b2acb]; added a CADDY
|
||||||
|
reverse proxy (prod override) so the booth is reached on a clean port-80 URL, server goes internal,
|
||||||
|
Caddyfile binds :80 to match any hostname incl. parksystems.msai.al [c637b27]. NET RESULT: no domain
|
||||||
|
baked into any image — naming controlled by hosts/DNS on-site; admin can reach it from another LAN PC.
|
||||||
|
Verified the relative-/api + Caddy fix end-to-end locally (Host: parksystems.msai.al through :80 →
|
||||||
|
SPA + /api/auth/login reach the server, no CORS). See [[container-deployment]] "Web access",
|
||||||
|
[[appliance-provisioning]]. REMAINING on the box: push dev so CI rebuilds parking-server:dev with the
|
||||||
|
relative-/api fix, then pull on the booth; kiosk autostart; operator user lxd/lpadmin cleanup.
|
||||||
|
|
||||||
|
## [2026-06-24] build | Self-service user profile + desktop installers in CI
|
||||||
|
Two app-side additions. (1) **Self-service profile** — any signed-in user can now edit their OWN
|
||||||
|
`fullName`/`email` and change their OWN password (proving the current one), without any `user:*`
|
||||||
|
permission. New routes `PUT /api/auth/profile` + `PUT /api/auth/password` (act only on `req.user.sub`;
|
||||||
|
cannot touch username/role; CSRF-guarded), SPA screen `apps/web/src/Profile.tsx` at `/profile` (header
|
||||||
|
username chip links to it), `email` added to the session view + `SessionUser`. 7 new tests
|
||||||
|
(`routes/profile.test.ts`); server 148/148 green. Distinct from the admin user-manager (`routes/users.ts`,
|
||||||
|
`user:*`-gated). See [[local-jwt-auth]]. (2) **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); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
||||||
|
[[desktop-shell-tauri]] "Desktop in CI".
|
||||||
|
|||||||
Reference in New Issue
Block a user