Compare commits
300 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 276b048fa9 | |||
| faa3265e49 | |||
| 21bfdce27a | |||
| d3288e29eb | |||
| baf7a4a99d | |||
| 885b410e48 | |||
| a1f3103a76 | |||
| 0fd66b261a | |||
| dfc5a07c10 | |||
| 5aabd7a791 | |||
| 0e9b9f5d82 | |||
| 642c5f4f70 | |||
| cb9f4d4979 | |||
| ea8fe22969 | |||
| 2910672b5a | |||
| 3a176c5cc8 | |||
| 19dff97c74 | |||
| 0ed43239c3 | |||
| 28bd838696 | |||
| 692dff5f89 | |||
| ba7538aeb5 | |||
| bb365b5d6e | |||
| c52a42dad2 | |||
| 22544ecf63 | |||
| ba5b4b1f4e | |||
| 51b160bfc9 | |||
| e2d5105da2 | |||
| 5287be5278 | |||
| 3a85483e6c | |||
| 6ceaadfbf2 | |||
| 7f42805e8d | |||
| cd3b534e51 | |||
| 011fe5a4c4 | |||
| 6f3f6ca596 | |||
| 5443b910c6 | |||
| a02957034d | |||
| ee61c24bb9 | |||
| 3a186d29df | |||
| 827445d514 | |||
| f9887c2a76 | |||
| 7649b897c4 | |||
| ab968eb25e | |||
| 5e1a885dcb | |||
| 6cf3492bff | |||
| ffe8c13a1c | |||
| fcea992e1e | |||
| 81bc2e357c | |||
| 7ef332999e | |||
| a2e102f3dd | |||
| 14638c2e13 | |||
| 4f902d869e | |||
| a5e54a8b93 | |||
| 0180394c45 | |||
| d905dd19b4 | |||
| c5ed3f1308 | |||
| 0b7eb28dfa | |||
| d5ff2097bd | |||
| 1de209be48 | |||
| dc2cdc0a91 | |||
| fd9885e9ec | |||
| 52a89bfa56 | |||
| d9e6c13831 | |||
| 493210bbb0 | |||
| a9f18be700 | |||
| 72ad504b8d | |||
| 5cdf8f227b | |||
| 365b648282 | |||
| c03ef2a34b | |||
| d9829eb61f | |||
| bafa3282c7 | |||
| 93f9ebea05 | |||
| c21babf293 | |||
| 44f34d68c4 | |||
| 43c1f45e29 | |||
| 35e593ab63 | |||
| b4f1418858 | |||
| 9d73561855 | |||
| 094e963e5e | |||
| 6505a4a73b | |||
| 8b65e199a3 | |||
| f486dcbbfc | |||
| c142166972 | |||
| 306d136a08 | |||
| 61b9955160 | |||
| b7e4037fbe | |||
| d2ab2e022e | |||
| 33c4ea1e91 | |||
| 114a32e6f2 | |||
| 018328a877 | |||
| 266e9b0027 | |||
| d92b8d1e6a | |||
| 61de1fe772 | |||
| cfac14e09e | |||
| 1b86750b0d | |||
| 9c6741a485 | |||
| d0b609e375 | |||
| 8f32d90d28 | |||
| 07295f8063 | |||
| 652d6599d3 | |||
| 39c778fbac | |||
| e16bccc2f5 | |||
| 2ab001054d | |||
| 381046190b | |||
| 84f00db48b | |||
| d5e41500a8 | |||
| 0c218179c4 | |||
| 9e442586af | |||
| 11567a417f | |||
| f6e35bbebf | |||
| 96acd6b662 | |||
| cce99aadfd | |||
| f706726eeb | |||
| 6734e9815e | |||
| 38481f105f | |||
| 4418594af0 | |||
| 25a72ff20a | |||
| c2a861208f | |||
| a888125eca | |||
| 96fd97efa9 | |||
| 2a13b95da6 | |||
| 513566c89e | |||
| f77ed11782 | |||
| e4a17efd97 | |||
| 6d32e0fc0f | |||
| 3a60367232 | |||
| 045892bc94 | |||
| 916c147b4d | |||
| 1ea1aa4189 | |||
| 9c20faf8de | |||
| a68dc23393 | |||
| c87dcb2253 | |||
| 7eadf71a0b | |||
| 9918f278b2 | |||
| 83298bc0c5 | |||
| 898cf1953a | |||
| dd0f6e483a | |||
| 40de8a7467 | |||
| f0fd15bb88 | |||
| 40ffa90dac | |||
| b3cb67188e | |||
| b1c4109045 | |||
| 50dd554b43 | |||
| 6d7682ab4a | |||
| 793b8d83ee | |||
| 7366ad19cb | |||
| 5a5fedf4f4 | |||
| 830993bcb8 | |||
| fd15988a73 | |||
| 420542ce10 | |||
| 2915d141aa | |||
| 215a3ac405 | |||
| e0cfeb5e71 | |||
| 8129b63a8c | |||
| f9bd586265 | |||
| aa546235fb | |||
| c637b2783c | |||
| 77b2acb1ca | |||
| 10923164ad | |||
| 0a22eab4a8 | |||
| 9d65099d9b | |||
| 8155ff456b | |||
| 492a08a079 | |||
| 8a437d0c4b | |||
| 65328b8c11 | |||
| 411572511d | |||
| a2bdf99db2 | |||
| 89542d4ab6 | |||
| e0b9442acc | |||
| 6f4e390c05 | |||
| df6a1ca63a | |||
| 547061edf9 | |||
| b7300ec080 | |||
| 461275521d | |||
| 3db8f517d3 | |||
| 6133923094 | |||
| 7680d9a0ed | |||
| 3527f48d76 | |||
| 5a5f5c554b | |||
| 742653aefb | |||
| 66c1291578 | |||
| 7629d5d7b1 | |||
| 2fb947e908 | |||
| cae900afd2 | |||
| 7e912e193b | |||
| 352c643009 | |||
| 5e9be16f65 | |||
| 0985b86fa7 | |||
| 3ed785c33e | |||
| 35c10a7310 | |||
| 2a9e6846a1 | |||
| 051b440627 | |||
| eb47016ae3 | |||
| 78d1f6808a | |||
| 31f116a068 | |||
| 663bf0e925 | |||
| 8acef0464c | |||
| df5caf8d87 | |||
| 0cbae94842 | |||
| ae5c122980 | |||
| d0536da3d7 | |||
| ae736a9e3e | |||
| 1b54775b4d | |||
| f2734641b2 | |||
| de858e91f4 | |||
| 294ca85ded | |||
| eafbc3ddbb | |||
| 36f30d39ff | |||
| 488dcb5e4e | |||
| c64457020f | |||
| ff04ec10be | |||
| e0e218fa61 | |||
| 21bd0f6227 | |||
| 53e1e7b25c | |||
| fd4608a8f1 | |||
| 052da8c3a7 | |||
| cb68cbafdb | |||
| 2835f78635 | |||
| a20400c2c5 | |||
| cdb55a8652 | |||
| b0c9ba0f8c | |||
| 9a1feeeb20 | |||
| cc507f490f | |||
| 3d02134711 | |||
| a4712774ab | |||
| 918f76fbef | |||
| 9ec644811a | |||
| ecaaefd899 | |||
| 4af8b56dda | |||
| 540b333b06 | |||
| 7e086ff0d7 | |||
| 236cbfecab | |||
| 17fdf3d482 | |||
| 4833b4373d | |||
| 5cedcaefe1 | |||
| 6933406ae3 | |||
| ee28b7302f | |||
| e4827c9651 | |||
| c0a775818b | |||
| 30e7fe85de | |||
| bfb6ab0b36 | |||
| 0074e82a2a | |||
| bbf61c48df | |||
| 00f3d141b6 | |||
| f31e57b4ae | |||
| 040c0ff4ca | |||
| 8444bf34c3 | |||
| 808fb26ab6 | |||
| ef0ecadff9 | |||
| d0841c8601 | |||
| d71ba82999 | |||
| 9c9f777784 | |||
| 486f8deae6 | |||
| 3e6773a6d5 | |||
| cf1ff5676d | |||
| 91cc79b14e | |||
| dfa76346d6 | |||
| c9a2ef81a9 | |||
| b8ddda86e7 | |||
| bba988c4e8 | |||
| 5697137c52 | |||
| ca8c7f2fa2 | |||
| f87e4c0d6b | |||
| 4e2e4feedb | |||
| 48660d3ec8 | |||
| 14c83e182a | |||
| 445bca0bf6 | |||
| 062feeae2f | |||
| 50a3095ef3 | |||
| eb3dc18e67 | |||
| 06dab1e790 | |||
| 9956488fd5 | |||
| 49df2015c8 | |||
| c2f06a5d2a | |||
| 58d8f06ba0 | |||
| 71aaad03b9 | |||
| 727c62da90 | |||
| 1efa77bf56 | |||
| 15d3e1ba08 | |||
| ff3b011fe0 | |||
| 5705098054 | |||
| 68d61f2d99 | |||
| 04135b27cf | |||
| 392d44d842 | |||
| f67c1ead87 | |||
| bf37106c5c | |||
| e579fe5b6e | |||
| 644bfa1462 | |||
| 3429642edb | |||
| c24d99b0f4 | |||
| b4d0dfadd6 | |||
| f18e28eeca | |||
| a8c6d6e714 | |||
| 2a36830880 | |||
| 2696d281ce | |||
| 648d3254d6 | |||
| 8c2cf93067 | |||
| 9a4c7ee27b | |||
| 8a8e74561d | |||
| 2ab5a39a57 | |||
| fa65b2df86 |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": []
|
||||
}
|
||||
}
|
||||
@@ -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,37 @@
|
||||
# Booth deploy env — copy to `.env` and fill in, then run ./scripts/booth.sh up
|
||||
# (prod). Consumed by docker-compose.yml + the prod override via --env-file.
|
||||
# See wiki/decisions/container-deployment.md. Do NOT commit the filled-in .env.
|
||||
|
||||
# --- image source (prod pulls from the house Gitea registry) ------------------
|
||||
# The registry namespace; combined with the image name + TAG below.
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Image tag to deploy. CI publishes TWO tags per build: a MOVING branch tag
|
||||
# (`dev`, and `main` once that branch is built) republished on every push, and an
|
||||
# IMMUTABLE per-commit `dev-<sha>` (e.g. dev-830993b). Use the moving tag for a
|
||||
# self-updating booth (`booth.sh update` pulls the latest); pin the `<branch>-<sha>`
|
||||
# form for a reproducible, deterministic deploy. NOTE: `main` images only exist once
|
||||
# something is built on main — until then deploy from `dev`.
|
||||
TAG=dev
|
||||
|
||||
# --- secrets (NO safe defaults — the server refuses to boot without a real one) -
|
||||
# JWT signing secret. Generate yourself, never share it: openssl rand -hex 32
|
||||
# Must be 32+ chars and must NOT contain change-me / insecure / dev-only.
|
||||
JWT_SECRET=
|
||||
|
||||
# Ledger-signing key for the append-only signed event chain. Set a DISTINCT value
|
||||
# in prod (don't reuse JWT_SECRET). openssl rand -hex 32
|
||||
EVENT_SIGNING_KEY=
|
||||
|
||||
# --- booth LAN specifics ------------------------------------------------------
|
||||
# Auth cookie is HTTPS-only by default; the booth is plain HTTP behind Caddy on
|
||||
# :80, so this MUST stay 0 or operators cannot log in. Set to 1 only behind TLS.
|
||||
COOKIE_SECURE=0
|
||||
|
||||
# Remote origins the live WS feed must accept (same-origin always passes). Add any
|
||||
# address admins hit the UI from beyond the booth itself, comma-separated, e.g.
|
||||
# http://parksystems.msai.al (leave blank if only the local booth URL is used).
|
||||
WS_ALLOWED_ORIGINS=
|
||||
|
||||
# Vision/ANPR. Prod override already forces the fast_alpr engine; leave VISION_ENABLED=1
|
||||
# unless you are running without the camera. (Set 0 to disable the vision call entirely.)
|
||||
VISION_ENABLED=1
|
||||
@@ -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,129 @@
|
||||
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, stage→:stage,
|
||||
# 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 and fleet-deployment-komodo.md (dev→stage→main tiers).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, stage, 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'
|
||||
# Deploy/IaC changes (compose above, plus the Komodo Stack defs) also rebuild — so a
|
||||
# promotion or a Stack tweak gets the same build+checks sanity pass before it reaches a
|
||||
# booth, and a komodo-only push to `stage` still produces a :stage image.
|
||||
- 'komodo/**'
|
||||
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
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
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"
|
||||
@@ -0,0 +1,57 @@
|
||||
name: CI
|
||||
|
||||
# Lint/typecheck/test the whole Turborepo on every push/PR to dev. Mirrors the
|
||||
# house pattern (cf. trm/processor): setup-node + corepack pnpm + frozen install.
|
||||
# No Docker, no signing — pure checks. The desktop bundle is a separate, tag-only
|
||||
# pipeline (see release.yml).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [dev, main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
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
|
||||
# Pin to the repo's packageManager version (pnpm 10), not latest.
|
||||
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
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)
|
||||
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
|
||||
# key fails the build), AND the vision service's ruff lint via uv.
|
||||
run: pnpm turbo run build lint
|
||||
|
||||
- name: Test
|
||||
run: pnpm turbo run test
|
||||
@@ -0,0 +1,272 @@
|
||||
name: Release desktop
|
||||
|
||||
# Build the signed Tauri desktop installers on a version tag and publish them as
|
||||
# a Gitea Release — TWICE: once on this (private, source) repo for our own
|
||||
# records/history, and once mirrored to mca/public_releases, which is what the
|
||||
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
|
||||
#
|
||||
# WHY a separate public repo: the updater runs on offline-first field appliances
|
||||
# with no Gitea credentials, so its endpoint + installer downloads must be
|
||||
# reachable unauthenticated. Mirroring compiled installers to a public
|
||||
# releases-only repo avoids embedding any read token in the shipped app (which
|
||||
# would leak the moment a booth PC is compromised — this box's threat model
|
||||
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
|
||||
# stays private; only signed installers become public, same as most desktop
|
||||
# software. mca/public_releases is shared across apps in the org, not
|
||||
# parking-specific — namespace release tags/asset names accordingly if another
|
||||
# app starts publishing there too.
|
||||
#
|
||||
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
||||
# with the updater key (Gitea secrets), assembles latest.json pointing at the
|
||||
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
bundle:
|
||||
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
|
||||
# ubuntu-latest runner has no GUI/webkit libs by default. These are the
|
||||
# exact deps a Tauri v2 Linux build needs (verified locally): WebKitGTK
|
||||
# 4.1 + libsoup-3 + the GTK/appindicator/rsvg stack + AppImage tooling.
|
||||
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: Sync tauri.conf.json version to the git tag
|
||||
# tauri.conf.json's own "version" field is what Tauri bakes into the
|
||||
# bundle filename, the app's internal version, AND the updater's
|
||||
# "current vs. new" comparison — it is NOT derived from the git tag
|
||||
# automatically. Hit in v0.1.1: the tag was bumped but this file
|
||||
# wasn't, so the signed binary + its .sig were still built (and
|
||||
# named) as 0.1.0 while latest.json (built from TAG below) claimed
|
||||
# 0.1.1 — the updater found the "update", downloaded a file whose
|
||||
# signature didn't match what the manifest claimed to sign, and
|
||||
# silently failed (a separate bug in desktop-updater.ts's error
|
||||
# handling made this invisible — also fixed). Patch it here so the
|
||||
# checked-in value is only ever a placeholder for local dev builds;
|
||||
# a real release's version is always driven by the tag.
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${TAG#v}"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
|
||||
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
|
||||
- name: Build + sign desktop bundle
|
||||
env:
|
||||
# Updater signing key (Gitea repo/org secrets). Without these the
|
||||
# bundle is unsigned and the updater would reject it.
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: pnpm --filter @parking/desktop bundle
|
||||
|
||||
- name: Collect artifacts
|
||||
id: collect
|
||||
# Gather the installers + their .sig into a flat dist/ for upload, spaces
|
||||
# stripped from filenames. productName is "Parking System" (a space), so
|
||||
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
|
||||
# unescaped space in a filename breaks the later curl asset-upload URL
|
||||
# ("URL rejected: Malformed input to a URL function", hit on the very
|
||||
# first v0.1.0 release) AND would land in latest.json's asset url, which
|
||||
# the updater's plain HTTP GET can't handle either. Rename on copy.
|
||||
run: |
|
||||
set -e
|
||||
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||
mkdir -p dist
|
||||
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
||||
-print0 | while IFS= read -r -d '' f; do
|
||||
name=$(basename "$f" | tr ' ' '-')
|
||||
cp "$f" "dist/${name}"
|
||||
done
|
||||
echo "Artifacts:"; ls -la dist/
|
||||
|
||||
- name: Assemble latest.json
|
||||
# The Tauri updater fetches a manifest describing the newest version, its
|
||||
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||
# appliances actually reach; see the workflow header for why. Adjust the
|
||||
# platform keys you actually ship.
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
MIRROR_REPO: mca/public_releases
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${TAG#v}"
|
||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
||||
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
|
||||
cat > dist/latest.json <<JSON
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "Parking System ${TAG}",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"linux-x86_64": {
|
||||
"signature": "${SIG}",
|
||||
"url": "${ASSET_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
echo "latest.json:"; cat dist/latest.json
|
||||
|
||||
- name: Create release + upload assets (Gitea API)
|
||||
# Uses the built-in token; no marketplace release action required. Creates
|
||||
# the release for this tag (idempotent-ish: ignores "already exists") and
|
||||
# uploads every file in dist/ as an asset.
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.api_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
# Create the release (capture id; tolerate an existing one).
|
||||
REL=$(curl -sS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"${API}/repos/${REPO}/releases" || true)
|
||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
if [ -z "$REL_ID" ]; then
|
||||
# Release may already exist for this tag — look it up by tag.
|
||||
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
fi
|
||||
echo "release id: ${REL_ID}"
|
||||
for f in dist/*; do
|
||||
name=$(basename "$f")
|
||||
echo "uploading ${name}"
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${f}" \
|
||||
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||
done
|
||||
echo "done"
|
||||
|
||||
- name: Mirror release to mca/public_releases (Gitea API)
|
||||
# This is the release the updater and any human downloader actually use —
|
||||
# public_releases has no source, only installers, so it can be public
|
||||
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
|
||||
# token scoped for pushing releases into that repo (Gitea's org secrets,
|
||||
# not exposed to any deployed client).
|
||||
#
|
||||
# Publishes to TWO tags there, since public_releases is shared across
|
||||
# apps in the org and Gitea's "latest release" redirect resolves by
|
||||
# newest tag on the WHOLE repo (would break the moment another app
|
||||
# publishes something newer):
|
||||
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
|
||||
# - desktop-latest moving — assets deleted + re-uploaded each release.
|
||||
# This is the fixed URL tauri.conf.json's updater endpoint points at
|
||||
# (a stable name every appliance can always resolve, regardless of
|
||||
# what else gets released in this repo meanwhile).
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
|
||||
API: ${{ github.api_url }}
|
||||
MIRROR_REPO: mca/public_releases
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
create_or_get_release() {
|
||||
local mirror_tag="$1" prerelease="$2"
|
||||
REL=$(curl -sS -w '\n%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases" || true)
|
||||
echo "create response (${mirror_tag}): ${REL}"
|
||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
if [ -z "$REL_ID" ]; then
|
||||
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
|
||||
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
|
||||
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
fi
|
||||
if [ -z "$REL_ID" ]; then
|
||||
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
upload_assets() {
|
||||
local rel_id="$1"
|
||||
for f in dist/*; do
|
||||
name=$(basename "$f")
|
||||
echo "mirroring ${name} -> release ${rel_id}"
|
||||
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${f}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
|
||||
if [ "$HTTP_CODE" -ge 300 ]; then
|
||||
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 1. Versioned, permanent.
|
||||
create_or_get_release "desktop-${TAG}" false
|
||||
echo "versioned mirror release id: ${REL_ID}"
|
||||
upload_assets "${REL_ID}"
|
||||
|
||||
# 2. Moving desktop-latest — delete existing assets first (re-upload
|
||||
# with the same name 409s otherwise), then re-upload.
|
||||
create_or_get_release "desktop-latest" false
|
||||
LATEST_REL_ID="${REL_ID}"
|
||||
echo "latest mirror release id: ${LATEST_REL_ID}"
|
||||
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
|
||||
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
|
||||
done || true
|
||||
upload_assets "${LATEST_REL_ID}"
|
||||
echo "done"
|
||||
@@ -11,6 +11,8 @@ dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# Committed (non-secret): the desktop/prod build's backend origin — see apps/web/.env.production
|
||||
!.env.production
|
||||
|
||||
# Editor/OS
|
||||
.DS_Store
|
||||
@@ -20,3 +22,9 @@ dist/
|
||||
/*.png
|
||||
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
||||
/dingtian/
|
||||
/QRCode_sdk*/
|
||||
|
||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||
graphify-out/
|
||||
parking.sqlite*.bak-*
|
||||
questions.txt
|
||||
|
||||
@@ -17,7 +17,8 @@ parking-system/
|
||||
├── turbo.json
|
||||
├── apps/
|
||||
│ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA
|
||||
│ └── web/ # React + Vite SPA (operator UI)
|
||||
│ ├── web/ # React + Vite SPA (operator UI)
|
||||
│ └── vision/ # Python/FastAPI ANPR service (planned; separate process, Turbo shim — see wiki/decisions/vision-service-packaging.md)
|
||||
├── packages/
|
||||
│ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target)
|
||||
│ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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
|
||||
# Host network (prod): the server runs on the host's net namespace (to reach the booth LAN /
|
||||
# device VLAN), so reach it over loopback, not the compose service name `server`.
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Desktop (Tauri) build — the @parking/web SPA needs to know where Fastify is.
|
||||
#
|
||||
# In a BROWSER (dev via the Vite proxy, or prod where Fastify serves the SPA),
|
||||
# leave VITE_API_BASE UNSET — requests stay relative/same-origin.
|
||||
#
|
||||
# For the DESKTOP build, the bundled SPA loads from tauri://localhost and has no
|
||||
# proxy, so point it at the appliance's Fastify origin. This is read at WEB build
|
||||
# time, so export it before `pnpm --filter @parking/desktop build` (or put it in
|
||||
# apps/web/.env.production).
|
||||
VITE_API_BASE=http://127.0.0.1:3000
|
||||
@@ -0,0 +1,3 @@
|
||||
# Rust / Tauri build artifacts
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
@@ -0,0 +1,50 @@
|
||||
# @parking/desktop — Tauri v2 kiosk shell
|
||||
|
||||
A **thin native desktop window** over the `@parking/web` SPA. It contains **no UI and no business
|
||||
logic** of its own: the window renders the *same* web app the browser does, so the desktop and the
|
||||
browser stay identical and never drift. Device/auth/ledger logic stays in `@parking/server`. See
|
||||
`wiki/decisions/desktop-shell-tauri.md`.
|
||||
|
||||
## How the "same look & functionality" guarantee works
|
||||
|
||||
| | Source of the UI |
|
||||
| --- | --- |
|
||||
| **Dev** (`tauri dev`) | the window loads `http://localhost:5173` — the **`@parking/web` Vite dev server**. Edit a component in `apps/web` → HMR updates the desktop window live. |
|
||||
| **Prod** (`tauri build`) | the window bundles `apps/web`'s built `dist/`. `beforeBuildCommand` rebuilds the SPA first. |
|
||||
|
||||
There is only one UI codebase (`apps/web`); this package just wraps it.
|
||||
|
||||
## Backend connection
|
||||
|
||||
The SPA talks to Fastify over HTTP/WS. In a browser that's same-origin (relative `/api`). In the
|
||||
desktop build the bundled assets load from `tauri://localhost`, so set **`VITE_API_BASE`** (read at
|
||||
web build time — see `.env.example`) to the appliance's Fastify origin, e.g.
|
||||
`http://127.0.0.1:3000`. The CSP `connect-src` in `tauri.conf.json` is already allowed for that
|
||||
origin, and the backend must include the Tauri origin in `WS_ALLOWED_ORIGINS` for the live feed.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm --filter @parking/desktop dev # native window over the web dev server (HMR)
|
||||
pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app (.deb/.rpm/.AppImage)
|
||||
```
|
||||
|
||||
> `build` is a **no-op** in this package so `turbo run build` stays fast — the real desktop bundle
|
||||
> (compiles Rust, minutes long) is the explicit `bundle` script above.
|
||||
|
||||
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
||||
window needs a display (WSLg or an X server).
|
||||
|
||||
## Auto-update
|
||||
|
||||
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
|
||||
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
|
||||
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
|
||||
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
|
||||
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
||||
committed.
|
||||
|
||||
## Not here (deliberately)
|
||||
|
||||
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@parking/desktop",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"//": "Tauri v2 desktop shell — a THIN native window over the @parking/web SPA. No business logic lives here (device/auth/ledger stay in @parking/server); see wiki/decisions/desktop-shell-tauri.md. Dev loads the web dev server (HMR); build bundles the web app's dist/, so the desktop UI and the browser UI are the SAME codebase and never drift.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"build": "echo 'no-op in the Turbo graph — the real desktop bundle is a deliberate `pnpm --filter @parking/desktop bundle` (compiles Rust + packages installers, minutes long)'",
|
||||
"bundle": "tauri build",
|
||||
"tauri": "tauri",
|
||||
"lint": "echo 'no JS lint (Tauri shell; Rust checked via cargo)'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "parking-desktop"
|
||||
version = "0.0.0"
|
||||
description = "Parking System — desktop kiosk shell"
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
# Thin Tauri v2 shell. Deliberately holds NO business logic — it loads the
|
||||
# @parking/web SPA and lets it talk to the local Fastify server. Device/auth/
|
||||
# ledger stay server-side. See wiki/decisions/desktop-shell-tauri.md.
|
||||
|
||||
[lib]
|
||||
name = "parking_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde_json = "1"
|
||||
# Auto-update: prompt the operator, download a signed update, relaunch.
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
|
||||
[features]
|
||||
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Minimal capability set for the kiosk shell. The window only needs to render the SPA; it is granted NOTHING that touches the filesystem, shell, or devices — those stay server-side. Add a named permission here only when a concrete need arises (deny-by-default). See wiki/decisions/desktop-shell-tauri.md.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"updater:default",
|
||||
"process:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 953 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 552 B |
|
After Width: | Height: | Size: 745 B |
|
After Width: | Height: | Size: 891 B |
|
After Width: | Height: | Size: 1016 B |
|
After Width: | Height: | Size: 997 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 562 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 643 B |
|
After Width: | Height: | Size: 748 B |
|
After Width: | Height: | Size: 838 B |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,21 @@
|
||||
// Parking System desktop shell — entry point.
|
||||
//
|
||||
// Intentionally minimal: build the default Tauri app and run it. The window
|
||||
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
|
||||
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
|
||||
// the backend over HTTP to the local Fastify server, NOT through Tauri IPC. This
|
||||
// keeps the shell a thin presentation wrapper with a deny-by-default native
|
||||
// surface (see wiki/decisions/desktop-shell-tauri.md).
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
// Auto-update: the JS side (apps/web) checks on launch, prompts the
|
||||
// operator, and installs + relaunches on confirm. These plugins expose
|
||||
// the update check/install and the relaunch to that flow. The updater
|
||||
// endpoint + signing pubkey live in tauri.conf.json.
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running the Parking System desktop shell");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents an extra console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
parking_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Parking System",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.parking.desktop",
|
||||
"build": {
|
||||
"devUrl": "http://localhost:5173",
|
||||
"frontendDist": "../../web/dist",
|
||||
"beforeDevCommand": "pnpm --filter @parking/web dev",
|
||||
"beforeBuildCommand": "VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Parking System",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 1024,
|
||||
"minHeight": 640,
|
||||
"resizable": true,
|
||||
"maximized": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||
"endpoints": [
|
||||
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"extends": ["//"],
|
||||
"//": "Tauri shell as a first-class Turbo node. build outputs [] so `turbo run build` doesn't try to cache/compile the Rust bundle on every pass (a real desktop bundle is a deliberate `pnpm --filter @parking/desktop build`).",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,72 @@
|
||||
# Generate one with: openssl rand -hex 32
|
||||
JWT_SECRET=
|
||||
|
||||
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
|
||||
# Generate with: openssl rand -hex 32
|
||||
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
|
||||
# dev, but set a dedicated key before production. Events store the key that signed
|
||||
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
||||
EVENT_SIGNING_KEY=
|
||||
|
||||
# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin
|
||||
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
|
||||
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
|
||||
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
|
||||
# not a console call. See wiki/concepts/backup-recovery.md.
|
||||
#
|
||||
# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can
|
||||
# rotate without fracturing the signed chain. Generate with: openssl rand -hex 32
|
||||
# Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever
|
||||
# stored inside the backup it unlocks. Backups stay a no-op until BOTH this key and an in-UI
|
||||
# target directory are set. The target directory AND retention (keep-last / keep-daily) are
|
||||
# admin-chosen in the UI (Setup → Backup), NOT env — only this key is an env secret.
|
||||
# BACKUP_KEY=
|
||||
|
||||
# Optional ----------------------------------------------------------------
|
||||
# PORT=3000
|
||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||
# LOG_LEVEL=info
|
||||
# DATABASE_URL=./parking.sqlite
|
||||
# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only)
|
||||
#
|
||||
# Auth-cookie Secure flag. FAIL-SAFE: cookies are Secure (HTTPS-only) BY DEFAULT —
|
||||
# you only ever opt OUT, never in. Set COOKIE_SECURE=0 for a plain-HTTP deployment
|
||||
# (e.g. the LAN appliance serving the SPA same-origin over http, where a Secure
|
||||
# cookie would never be sent and would lock operators out). Local dev over
|
||||
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
|
||||
# COOKIE_SECURE=0
|
||||
|
||||
# Recycle bin retention: a soft-deleted user/role/subscription/plan/tariff is auto-purged
|
||||
# this many days after deletion (a 6-hourly sweep). Default 30. Set 0 to keep deleted
|
||||
# items forever (manual purge only). See wiki/concepts/soft-delete.md.
|
||||
# RECYCLE_BIN_RETENTION_DAYS=30
|
||||
|
||||
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
||||
# ADMIN_USER=admin
|
||||
# ADMIN_PASS=
|
||||
|
||||
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
|
||||
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
|
||||
# The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send
|
||||
# http://tauri.localhost), which is NOT same-origin with the backend — add both
|
||||
# so the desktop app's live feed connects. See apps/desktop.
|
||||
# To open the dev SPA from another LAN device (phone over wifi), Vite must bind
|
||||
# 0.0.0.0 (vite.config.ts) AND the host's LAN origin must be listed here, e.g.
|
||||
# http://10.0.10.203:5173 — the WS handshake's Origin is that LAN address.
|
||||
WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost
|
||||
|
||||
# Vision / ANPR (optional) -------------------------------------------------
|
||||
# OFF by default. The Node SERVER's view of the vision microservice (apps/vision),
|
||||
# which runs as a separate process with its OWN apps/vision/.env. Both sides share the
|
||||
# VISION_ prefix but are different processes — keep the two .env files separate.
|
||||
# See wiki/entities/opencv-anpr-service.md "Configuration".
|
||||
# ANPR rides the entry/exit snapshot (button / QR / RFID triggers it) — no polling.
|
||||
# VISION_ENABLED=1 # master switch — nothing runs without it
|
||||
# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT
|
||||
# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
|
||||
# VISION_MIN_CONFIDENCE=0.5 # advisory confidence floor; keep in sync with the service
|
||||
#
|
||||
# ANPR subscriber-entry bridge (anpr-entry.ts): a subscriber's plate, read off a lane
|
||||
# camera's vehicle detection, admits them through the gated SubscriptionFlow. Opt-in per
|
||||
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
|
||||
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
|
||||
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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
|
||||
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
|
||||
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
|
||||
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
|
||||
ARG BUILD_VERSION=""
|
||||
ENV BUILD_VERSION=$BUILD_VERSION
|
||||
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"]
|
||||
@@ -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 "$@"
|
||||
@@ -9,24 +9,28 @@
|
||||
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/jwt": "10.1.0",
|
||||
"@fastify/static": "9.1.3",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@parking/db": "workspace:*",
|
||||
"@parking/devices": "workspace:*",
|
||||
"@parking/shared": "workspace:*",
|
||||
"bcrypt": "6.0.0",
|
||||
"fastify": "5.8.5",
|
||||
"fastify-plugin": "6.0.0"
|
||||
"fastify-plugin": "6.0.0",
|
||||
"sharp": "^0.35.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@types/node": "25.9.3",
|
||||
"tsx": "4.22.4",
|
||||
"typescript": "6.0.3"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const bcrypt = require("bcrypt");
|
||||
const { createDb, users, eq } = require("@parking/db");
|
||||
const { createDb, users, roles, eq } = require("@parking/db");
|
||||
|
||||
const DEFAULT_USERNAME = "admin";
|
||||
|
||||
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
|
||||
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
|
||||
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
|
||||
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
|
||||
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
|
||||
// the row alone is all the FK needs.
|
||||
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
|
||||
|
||||
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
||||
if (existing && process.env.FORCE !== "1") {
|
||||
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
||||
@@ -62,15 +70,41 @@ if (existing && process.env.FORCE !== "1") {
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
if (existing) {
|
||||
await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id));
|
||||
await db.update(users).set({ passwordHash, roleId: "admin" }).where(eq(users.id, existing.id));
|
||||
console.log(`reset password for admin "${username}"`);
|
||||
} else {
|
||||
await db.insert(users).values({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
roleId: "admin",
|
||||
});
|
||||
console.log(`created admin "${username}"`);
|
||||
}
|
||||
|
||||
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
|
||||
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
|
||||
// fact (the chain is the audit record; whoever holds root can reset a password, they
|
||||
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
|
||||
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
|
||||
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
|
||||
// admin out to protect an audit line would invert the priority.
|
||||
try {
|
||||
const { EventLog } = await import("../dist/event-log.js");
|
||||
const { buildSigner } = await import("../dist/signer.js");
|
||||
const log = new EventLog(db, buildSigner());
|
||||
await log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: `user:${username}`,
|
||||
payload: {
|
||||
setting: existing ? "admin.passwordReset" : "admin.seeded",
|
||||
username,
|
||||
operator: "console:seed-admin",
|
||||
},
|
||||
});
|
||||
console.log("recorded to the signed ledger (config_change)");
|
||||
} catch (err) {
|
||||
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
|
||||
}
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
import type { VisionClient, VisionResult } from "./vision-client.js";
|
||||
import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js";
|
||||
|
||||
// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a
|
||||
// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real
|
||||
// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's
|
||||
// own logic only. See anpr-entry.ts.
|
||||
|
||||
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
||||
// (no registry, no network). The factory returns a fresh shot each call.
|
||||
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
||||
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
|
||||
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
|
||||
// suite stays focused on the bridge's own match/debounce/emit logic.
|
||||
vi.mock("./snapshot.js", () => ({
|
||||
buildCamera: () => ({ captureSnapshot }),
|
||||
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
|
||||
camera.captureSnapshot(ctx as never),
|
||||
}));
|
||||
|
||||
// Import AFTER the mock is registered.
|
||||
const { AnprBridge } = await import("./anpr-entry.js");
|
||||
|
||||
let db: Db;
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
captureSnapshot.mockClear();
|
||||
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||
delete process.env.ANPR_DEBOUNCE_MS;
|
||||
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
||||
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
||||
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
||||
process.env.ANPR_POLL_MS = "1";
|
||||
process.env.ANPR_POLL_WINDOW_MS = "5";
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.ANPR_POLL_MS;
|
||||
delete process.env.ANPR_POLL_WINDOW_MS;
|
||||
delete process.env.ANPR_POLL_MAX_MS;
|
||||
});
|
||||
|
||||
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
|
||||
* per-camera auto-open gate (absent ⇒ defaults on). */
|
||||
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
|
||||
const controllerId = randomUUID();
|
||||
db.insert(devices).values({
|
||||
id: controllerId,
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] },
|
||||
enabled: true,
|
||||
}).run();
|
||||
const camId = randomUUID();
|
||||
db.insert(devices).values({
|
||||
id: camId,
|
||||
category: "camera",
|
||||
driverId: "hikvision",
|
||||
config: {
|
||||
host: "10.0.0.9",
|
||||
controllerId,
|
||||
relay: 1,
|
||||
...(opts.anpr ? { anpr: true } : {}),
|
||||
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
return camId;
|
||||
}
|
||||
|
||||
/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */
|
||||
function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient {
|
||||
const enabled = opts.enabled ?? true;
|
||||
const result: VisionResult | null =
|
||||
opts.plate == null
|
||||
? null
|
||||
: {
|
||||
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
};
|
||||
return {
|
||||
enabled,
|
||||
analyze: vi.fn(async () => (enabled ? result : null)),
|
||||
} as unknown as VisionClient;
|
||||
}
|
||||
|
||||
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||
function fakeSubFlow(
|
||||
match: SubscriptionMatch | null,
|
||||
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
|
||||
// credential closing an occurrence mid-poll → count changes).
|
||||
openCounts: number | number[] = 1,
|
||||
): SubscriptionFlow {
|
||||
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
|
||||
return {
|
||||
match: vi.fn(() => match),
|
||||
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
|
||||
} as unknown as SubscriptionFlow;
|
||||
}
|
||||
|
||||
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||
|
||||
/** Capture read events emitted during `fn` (async). */
|
||||
async function captureReads(fn: () => Promise<void>): Promise<DeviceReadEvent[]> {
|
||||
const got: DeviceReadEvent[] = [];
|
||||
const off = deviceEvents.onRead((e) => got.push(e));
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
describe("AnprBridge", () => {
|
||||
it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => {
|
||||
const cam = seedCamera({ anpr: false });
|
||||
const vision = fakeVision({ plate: "AA111BB" });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toEqual([]);
|
||||
expect(vision.analyze).not.toHaveBeenCalled();
|
||||
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
|
||||
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
|
||||
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
|
||||
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toEqual([]);
|
||||
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
|
||||
});
|
||||
|
||||
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(reads[0]).toMatchObject({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" });
|
||||
});
|
||||
|
||||
it("ignores a plate below the entry confidence floor", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
||||
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
||||
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
||||
const cam = seedCamera({ anpr: true });
|
||||
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
||||
const confs = [0.2, 0.2, 0.97];
|
||||
let i = 0;
|
||||
const vision = {
|
||||
enabled: true,
|
||||
analyze: vi.fn(async () => ({
|
||||
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
})),
|
||||
} as unknown as VisionClient;
|
||||
// Generous window so all 3 escalation attempts run deterministically under suite load
|
||||
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
|
||||
process.env.ANPR_POLL_MS = "1";
|
||||
process.env.ANPR_POLL_WINDOW_MS = "2000";
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
||||
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||
});
|
||||
|
||||
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
||||
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
||||
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
||||
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
||||
// the start bumps the deadline so attempt 5's confident read still lands.
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
||||
let i = 0;
|
||||
const vision = {
|
||||
enabled: true,
|
||||
analyze: vi.fn(async () => ({
|
||||
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
})),
|
||||
} as unknown as VisionClient;
|
||||
process.env.ANPR_POLL_MS = "5";
|
||||
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
||||
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(async () => {
|
||||
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
||||
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
||||
for (let k = 0; k < 5; k++) {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
||||
}
|
||||
await loop;
|
||||
});
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
||||
});
|
||||
|
||||
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
||||
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
||||
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
|
||||
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
|
||||
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
|
||||
const bridge = new AnprBridge(db, vision, sub, silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toEqual([]); // aborted — the credential already handled it
|
||||
});
|
||||
|
||||
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toEqual([]);
|
||||
|
||||
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
||||
expect(skips).toHaveLength(1);
|
||||
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
||||
});
|
||||
|
||||
it("analyzes AT LEAST ONE frame even if the poll window already elapsed (loaded host)", async () => {
|
||||
// Regression for a CI flake (2026-07-04): with a plain `while`, a window that lapsed
|
||||
// between deadline-set and loop-entry (slow runner; here forced with a 0ms window)
|
||||
// meant ZERO analyze attempts — the detection was silently dropped ("gave up") and no
|
||||
// skip was recorded. The do-while guarantees one frame per detection regardless of load.
|
||||
process.env.ANPR_POLL_WINDOW_MS = "0";
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
||||
|
||||
await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(captureSnapshot).toHaveBeenCalledTimes(1); // the guaranteed first attempt
|
||||
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
||||
expect(skips).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(async () => {
|
||||
await bridge.onVehicleDetected(cam);
|
||||
await bridge.onVehicleDetected(cam); // within the 12s window → suppressed
|
||||
});
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot
|
||||
});
|
||||
|
||||
it("is a no-op (no throw) when vision is disabled or reads nothing", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(async () => {
|
||||
await disabled.onVehicleDetected(cam);
|
||||
await noPlate.onVehicleDetected(cam);
|
||||
});
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
it("never throws on an unknown device id", async () => {
|
||||
const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run();
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toEqual([]);
|
||||
// The flag is checked FIRST — no snapshot, no analyze, no match attempt.
|
||||
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||
expect(vision.analyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run();
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||
import { directionOf, type FlowDirection } from "./device-resolve.js";
|
||||
import { buildCamera } from "./snapshot.js";
|
||||
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
|
||||
// The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through
|
||||
// the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between
|
||||
// the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service.
|
||||
//
|
||||
// On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge:
|
||||
// pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the
|
||||
// plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched.
|
||||
// The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow
|
||||
// (active / window / blocklist / car-count), which signs the entry/exit and opens the relay.
|
||||
//
|
||||
// INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md):
|
||||
// - Advisory, never sole authority: the bridge only emitRead()s — the signed decision +
|
||||
// barrier open stay inside the existing flow. A spoofed printed plate is just another
|
||||
// credential through the same gate.
|
||||
// - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the
|
||||
// transient plate-as-ticket exit flow.
|
||||
// - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path;
|
||||
// never throws into the push handler, never awaited on the camera's 200 response.
|
||||
// - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits).
|
||||
|
||||
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
||||
interface CameraConfig {
|
||||
readonly anpr?: boolean;
|
||||
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
|
||||
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
|
||||
* shared entry/exit lane. */
|
||||
readonly anprAutoTrigger?: boolean;
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss
|
||||
* read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit.
|
||||
* Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */
|
||||
function entryMinConfidence(): number {
|
||||
const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 0.85;
|
||||
}
|
||||
|
||||
/** Same plate/camera within this window = ONE credential presentation. The camera re-fires
|
||||
* ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet
|
||||
* sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */
|
||||
function debounceMs(): number {
|
||||
const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||
}
|
||||
|
||||
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
|
||||
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
|
||||
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
|
||||
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
|
||||
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
|
||||
function pollMs(): number {
|
||||
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
|
||||
}
|
||||
|
||||
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
|
||||
* push for the camera extends the deadline by this much from now — so a loop started by a
|
||||
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
|
||||
* barrier (the loop tracks "whoever is here now", not the car that started it). */
|
||||
function pollWindowMs(): number {
|
||||
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
|
||||
}
|
||||
|
||||
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
|
||||
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
|
||||
function pollMaxMs(): number {
|
||||
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||
* subscriber by plate during the poll loop without re-building the whole event. */
|
||||
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
|
||||
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export class AnprBridge {
|
||||
readonly #db: Db;
|
||||
readonly #vision: VisionClient | null;
|
||||
readonly #subscription: SubscriptionFlow;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #entryMinConfidence: number;
|
||||
readonly #debounceMs: number;
|
||||
readonly #pollMs: number;
|
||||
readonly #pollWindowMs: number;
|
||||
readonly #pollMaxMs: number;
|
||||
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||
readonly #lastFire = new Map<string, number>();
|
||||
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
|
||||
* while the car sits) must NOT start a second concurrent loop on the same camera. */
|
||||
readonly #polling = new Set<string>();
|
||||
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
|
||||
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
|
||||
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
|
||||
readonly #pollDeadline = new Map<string, number>();
|
||||
|
||||
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#vision = vision;
|
||||
this.#subscription = subscription;
|
||||
this.#logger = logger;
|
||||
this.#entryMinConfidence = entryMinConfidence();
|
||||
this.#debounceMs = debounceMs();
|
||||
this.#pollMs = pollMs();
|
||||
this.#pollWindowMs = pollWindowMs();
|
||||
this.#pollMaxMs = pollMaxMs();
|
||||
}
|
||||
|
||||
/**
|
||||
* A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the
|
||||
* plate, and — only if it matches a subscription — emit a plate read onto the bus.
|
||||
* Fire-and-forget; fail-soft. Never throws (the push handler must always 200).
|
||||
*/
|
||||
async onVehicleDetected(deviceId: string): Promise<void> {
|
||||
try {
|
||||
if (!this.#vision?.enabled) return; // no recognizer configured
|
||||
// Admin master switch (read LIVE so toggling in Site Settings takes effect with no
|
||||
// restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane
|
||||
// busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default).
|
||||
const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (site && site.anprEntryEnabled === false) return;
|
||||
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||
if (!row || !row.enabled || row.category !== "camera") return;
|
||||
const cfg = row.config as CameraConfig;
|
||||
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
|
||||
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
|
||||
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
|
||||
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
|
||||
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
|
||||
if (cfg.anprAutoTrigger === false) return;
|
||||
|
||||
// Post-success debounce: once we've emitted a read for this camera, ignore the
|
||||
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
|
||||
// window is a new presentation and may start a new poll loop.
|
||||
if (this.#debounced(deviceId)) return;
|
||||
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
|
||||
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
|
||||
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
|
||||
const now = Date.now();
|
||||
if (this.#polling.has(deviceId)) {
|
||||
const cur = this.#pollDeadline.get(deviceId) ?? now;
|
||||
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
|
||||
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
|
||||
return;
|
||||
}
|
||||
this.#polling.add(deviceId);
|
||||
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
|
||||
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
|
||||
|
||||
const camera = buildCamera(row);
|
||||
if (!camera) {
|
||||
this.#polling.delete(deviceId);
|
||||
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||
return;
|
||||
}
|
||||
|
||||
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
||||
// the gated flow infers the verb from the camera's bound relay direction).
|
||||
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||
|
||||
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
|
||||
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
|
||||
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
|
||||
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
|
||||
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
|
||||
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
|
||||
// While polling, watch whether THIS subscriber transacts by another credential
|
||||
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
|
||||
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
|
||||
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
|
||||
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
|
||||
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
|
||||
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||
let watchedSubId: string | null = null;
|
||||
let baselineOpen = 0;
|
||||
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
|
||||
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
|
||||
const hardCap = Date.now() + this.#pollMaxMs;
|
||||
let attempts = 0;
|
||||
try {
|
||||
// DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the
|
||||
// host — a plain while could zero-iterate if the window elapsed between setting
|
||||
// the deadline and reaching the loop (seen as a CI flake with the tests' 5ms
|
||||
// window; on a busy booth it would silently drop a real car's detection). Exit
|
||||
// is via the breaks below (confident read, or next tick would pass the deadline).
|
||||
do {
|
||||
attempts++;
|
||||
const shot = await camera.captureSnapshot({ direction });
|
||||
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||
|
||||
// Identify the subscriber from ANY readable plate (even below the barrier floor),
|
||||
// and baseline their open count once — so we can detect a credential beating us.
|
||||
if (r?.plate?.text) {
|
||||
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
|
||||
if (m0 && watchedSubId == null) {
|
||||
watchedSubId = m0.subscriptionId;
|
||||
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
|
||||
}
|
||||
}
|
||||
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
|
||||
// they already transacted; stop polling and do NOT emit.
|
||||
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
|
||||
this.#logger.info(
|
||||
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
|
||||
result = r;
|
||||
break;
|
||||
}
|
||||
if (r?.plate) {
|
||||
this.#logger.info(
|
||||
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
|
||||
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
||||
);
|
||||
}
|
||||
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
|
||||
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
|
||||
if (Date.now() + this.#pollMs >= effDeadline) break;
|
||||
await sleep(this.#pollMs);
|
||||
} while (true);
|
||||
} finally {
|
||||
this.#polling.delete(deviceId);
|
||||
this.#pollDeadline.delete(deviceId);
|
||||
}
|
||||
|
||||
if (!result || !result.plate) {
|
||||
this.#logger.info(
|
||||
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
|
||||
`in ${this.#pollWindowMs}ms — gave up`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const plate = result.plate.text.trim().toUpperCase();
|
||||
if (!plate) return;
|
||||
|
||||
const e: DeviceReadEvent = {
|
||||
driverId: row.driverId,
|
||||
deviceId,
|
||||
value: plate,
|
||||
kind: "plate",
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory
|
||||
// telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow.
|
||||
const match = this.#subscription.match(e);
|
||||
if (!match) {
|
||||
this.#recordSkip(deviceId, plate, result.plate.confidence);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final guard against the credential-mid-poll race: if the subscriber transacted between
|
||||
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
|
||||
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
|
||||
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||
// camera-level gate re-emitting the SAME plate.
|
||||
const plateKey = `${deviceId}:${plate}`;
|
||||
if (this.#debounced(plateKey)) return;
|
||||
this.#stamp(plateKey);
|
||||
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
|
||||
// re-fires (and any new poll loop) for #debounceMs.
|
||||
this.#stamp(deviceId);
|
||||
|
||||
this.#logger.info(
|
||||
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||
);
|
||||
deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow
|
||||
} catch (err) {
|
||||
// Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane.
|
||||
this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
#debounced(key: string): boolean {
|
||||
const last = this.#lastFire.get(key);
|
||||
return last != null && Date.now() - last < this.#debounceMs;
|
||||
}
|
||||
|
||||
#stamp(key: string): void {
|
||||
this.#lastFire.set(key, Date.now());
|
||||
}
|
||||
|
||||
/** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a
|
||||
* read on the bus — just a breadcrumb so the operator can see ANPR is working. */
|
||||
#recordSkip(deviceId: string, plate: string, confidence: number): void {
|
||||
this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`);
|
||||
try {
|
||||
this.#db
|
||||
.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId,
|
||||
category: "camera",
|
||||
kind: "anpr-skip",
|
||||
detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" },
|
||||
occurredAt: new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceRow is re-exported for the test's seed typing convenience.
|
||||
export type { DeviceRow };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { secureCookies } from "./auth.js";
|
||||
|
||||
// The auth/CSRF cookies' Secure flag must be FAIL-SAFE: Secure by default, dropped only
|
||||
// on a deliberate opt-out. The old behaviour (Secure iff NODE_ENV==="production") leaked
|
||||
// cookies over plain HTTP on an appliance that forgot to set NODE_ENV — this pins the
|
||||
// corrected matrix.
|
||||
|
||||
let savedCookieSecure: string | undefined;
|
||||
let savedNodeEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedCookieSecure = process.env.COOKIE_SECURE;
|
||||
savedNodeEnv = process.env.NODE_ENV;
|
||||
delete process.env.COOKIE_SECURE;
|
||||
delete process.env.NODE_ENV;
|
||||
});
|
||||
afterEach(() => {
|
||||
restore("COOKIE_SECURE", savedCookieSecure);
|
||||
restore("NODE_ENV", savedNodeEnv);
|
||||
});
|
||||
function restore(key: string, val: string | undefined) {
|
||||
if (val === undefined) delete process.env[key];
|
||||
else process.env[key] = val;
|
||||
}
|
||||
|
||||
describe("secureCookies — fail-safe Secure flag", () => {
|
||||
it("defaults to Secure when nothing is set (the appliance-forgot-NODE_ENV case)", () => {
|
||||
expect(secureCookies()).toBe(true);
|
||||
});
|
||||
|
||||
it("stays Secure in production", () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
expect(secureCookies()).toBe(true);
|
||||
});
|
||||
|
||||
it("drops Secure only for an explicit local-dev NODE_ENV", () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
expect(secureCookies()).toBe(false);
|
||||
});
|
||||
|
||||
it("COOKIE_SECURE override wins: falsey values opt OUT", () => {
|
||||
for (const v of ["0", "false", "no", "off", "FALSE", " Off "]) {
|
||||
process.env.COOKIE_SECURE = v;
|
||||
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("COOKIE_SECURE override wins: any other value opts IN (even in dev)", () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
for (const v of ["1", "true", "yes", "on", ""]) {
|
||||
process.env.COOKIE_SECURE = v;
|
||||
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,22 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Role } from "@parking/shared";
|
||||
import { eq, rolePermissions, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
|
||||
// Local JWT auth helpers — fully local, no external identity provider
|
||||
// (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it);
|
||||
// a separate readable CSRF cookie + matching header defends mutations
|
||||
// (double-submit). See wiki/entities/local-jwt-auth.md.
|
||||
//
|
||||
// Authorization is DYNAMIC RBAC: the token carries the user's `roleId`, and each
|
||||
// guarded route resolves that role's PERMISSION SET (cached in memory) and checks
|
||||
// the permission it requires. Editing a role takes effect on the next request —
|
||||
// no re-login, no token bloat, no stale perms. See @parking/shared PERMISSIONS.
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: { sub: string; username: string; role: Role; csrf: string };
|
||||
user: { sub: string; username: string; role: Role; csrf: string };
|
||||
payload: { sub: string; username: string; roleId: string; csrf: string };
|
||||
user: { sub: string; username: string; roleId: string; csrf: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +24,15 @@ export const TOKEN_COOKIE = "parking_token";
|
||||
export const CSRF_COOKIE = "parking_csrf";
|
||||
export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
/** Token lifetime, also used as the cookie maxAge. */
|
||||
export const TOKEN_TTL = "8h";
|
||||
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
|
||||
// Session lifetime: the JWT has NO expiry — a login is valid until explicit
|
||||
// logout. Booth reality breaks any fixed clock (relief late/absent, forced double
|
||||
// shifts), and a shift is a separate explicit boundary, not the token's lifetime.
|
||||
// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md.
|
||||
//
|
||||
// The cookie still needs a maxAge so it survives a browser restart (a session
|
||||
// cookie would log out an active operator on browser close — the opposite of
|
||||
// "until logout"). Use a long fixed window; the server clears it on logout.
|
||||
export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||
|
||||
/**
|
||||
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||
@@ -38,9 +50,28 @@ export function requireJwtSecret(): string {
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** Cookies are secure in production; relaxed for local http dev. */
|
||||
function secureCookies(): boolean {
|
||||
return process.env.NODE_ENV === "production";
|
||||
/**
|
||||
* Whether to set the `Secure` flag on the auth/CSRF cookies. FAIL-SAFE: default is
|
||||
* `true` (Secure) — a misconfigured/forgotten env can only ever make cookies MORE
|
||||
* restrictive, never silently drop the flag.
|
||||
*
|
||||
* The previous gate keyed off `NODE_ENV === "production"`, which meant an appliance
|
||||
* deployed without that var leaked cookies over plain HTTP. Now `Secure` is the
|
||||
* default and is dropped ONLY for an explicit, deliberate opt-out — `COOKIE_SECURE`
|
||||
* set to a falsey value (`0/false/no/off`), or the legacy `NODE_ENV !== production`
|
||||
* signal kept as a fallback so existing dev setups still work over http://localhost.
|
||||
*
|
||||
* The parking appliance often serves the SPA same-origin over the LAN with no TLS;
|
||||
* THAT box sets `COOKIE_SECURE=0` on purpose (a Secure cookie would never be sent
|
||||
* over its http origin and would lock operators out). Everything else stays secure.
|
||||
*/
|
||||
export function secureCookies(): boolean {
|
||||
const override = process.env.COOKIE_SECURE;
|
||||
if (override !== undefined) {
|
||||
return !/^(0|false|no|off)$/i.test(override.trim());
|
||||
}
|
||||
// No explicit override: secure unless this is an obvious local-dev run.
|
||||
return process.env.NODE_ENV !== "development";
|
||||
}
|
||||
|
||||
export function newCsrfToken(): string {
|
||||
@@ -55,7 +86,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: TOKEN_TTL_SECONDS,
|
||||
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||
});
|
||||
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
||||
reply.setCookie(CSRF_COOKIE, csrf, {
|
||||
@@ -63,7 +94,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
||||
sameSite: "strict",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: TOKEN_TTL_SECONDS,
|
||||
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,17 +121,84 @@ function assertCsrf(req: FastifyRequest): void {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Permission resolution + cache -------------------------------------------
|
||||
// A role's permission set is read from `role_permissions` and cached in memory.
|
||||
// SQLite is single-writer/single-process here, so a module-level Map is a correct
|
||||
// cache: every role / role-permission mutation calls bumpPermsCache() to clear it,
|
||||
// and the next request re-reads. The built-in `admin` role always resolves to the
|
||||
// FULL permission set in code (never trusts the DB rows for it), so administration
|
||||
// can't be accidentally narrowed.
|
||||
|
||||
const ADMIN_PERMS: ReadonlySet<Permission> = new Set(PERMISSIONS);
|
||||
const permsCache = new Map<string, ReadonlySet<Permission>>();
|
||||
|
||||
// The DB handle the permission resolver reads from. Set ONCE at startup via
|
||||
// initAuth() so route guards don't each have to thread `db` (several route
|
||||
// modules only receive a monitor/service, not the db). Single-process server.
|
||||
let authDb: Db | null = null;
|
||||
|
||||
/** Wire the permission resolver to the app's DB. Call once in buildServer(). */
|
||||
export function initAuth(db: Db): void {
|
||||
authDb = db;
|
||||
permsCache.clear();
|
||||
}
|
||||
|
||||
/** Clear the permission cache. Call after ANY write to roles / role_permissions
|
||||
* (or a user's roleId) so the change takes effect on the next request. */
|
||||
export function bumpPermsCache(): void {
|
||||
permsCache.clear();
|
||||
}
|
||||
|
||||
/** The permission set for a role id, cached. `admin` is always the full set. */
|
||||
export function permissionsFor(roleId: string): ReadonlySet<Permission> {
|
||||
if (roleId === ADMIN_ROLE_ID) return ADMIN_PERMS;
|
||||
const hit = permsCache.get(roleId);
|
||||
if (hit) return hit;
|
||||
if (!authDb) throw new Error("auth not initialised (call initAuth)");
|
||||
const rows = authDb
|
||||
.select({ permission: rolePermissions.permission })
|
||||
.from(rolePermissions)
|
||||
.where(eq(rolePermissions.roleId, roleId))
|
||||
.all();
|
||||
const set = new Set(rows.map((r) => r.permission as Permission));
|
||||
permsCache.set(roleId, set);
|
||||
return set;
|
||||
}
|
||||
|
||||
/** True if the role grants every listed permission. */
|
||||
export function roleHasPermissions(
|
||||
roleId: string,
|
||||
required: readonly Permission[],
|
||||
): boolean {
|
||||
const granted = permissionsFor(roleId);
|
||||
return required.every((p) => granted.has(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces
|
||||
* CSRF on mutations, then checks the role. Authorization is a simple per-route
|
||||
* role check — no Casbin/RBAC engine needed at this scale.
|
||||
* preHandler permission guard. Verifies the JWT (from the HttpOnly cookie),
|
||||
* enforces CSRF on mutations, then requires the user's role to grant ALL of the
|
||||
* listed permissions. Authorization is a per-route permission check against the
|
||||
* dynamic, admin-composed role grid — no Casbin/RBAC engine needed at this scale.
|
||||
*/
|
||||
export function requireRole(...allowed: Role[]) {
|
||||
export function requirePermission(...required: Permission[]) {
|
||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||
assertCsrf(req);
|
||||
if (!req.user || !allowed.includes(req.user.role)) {
|
||||
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler that requires a valid signed-in session but NO specific permission —
|
||||
* for "about me" routes (/me, change own language) every authenticated user may
|
||||
* call regardless of role. Still enforces CSRF on mutations.
|
||||
*/
|
||||
export async function requireAuth(
|
||||
req: FastifyRequest,
|
||||
_reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
await req.jwtVerify();
|
||||
assertCsrf(req);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq, siteConfig } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BackupService } from "./backup-service.js";
|
||||
|
||||
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
||||
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
||||
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
||||
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
||||
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
||||
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
||||
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
||||
// See wiki/concepts/backup-recovery.md.
|
||||
|
||||
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||
|
||||
let workDir: string;
|
||||
let target: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
||||
target = join(workDir, "target");
|
||||
process.env.BACKUP_KEY = KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
delete process.env.BACKUP_KEY;
|
||||
});
|
||||
|
||||
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
||||
}
|
||||
}
|
||||
|
||||
describe("BackupService — persisted status survives a restart", () => {
|
||||
it("a fresh instance sees the previous instance's last success", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
expect(first.status().lastSuccessAt).toBeNull();
|
||||
const result = await first.run("manual");
|
||||
|
||||
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
||||
// production this would be a fresh process re-opening the same sqlite file).
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastSuccessAt).not.toBeNull();
|
||||
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
||||
expect(status.lastError).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
||||
const t = createTestDb();
|
||||
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
||||
// throw, giving us a real, deterministic failure without needing to mock anything.
|
||||
const badTarget = join(workDir, "not-a-dir");
|
||||
writeFileSync(badTarget, "x");
|
||||
setTargetDir(t.db, badTarget);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
await expect(first.run("manual")).rejects.toThrow();
|
||||
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastError).not.toBeNull();
|
||||
expect(status.lastErrorAt).not.toBeNull();
|
||||
expect(status.lastSuccessAt).toBeNull();
|
||||
|
||||
// Now point at a real directory and succeed — the persisted error must clear.
|
||||
setTargetDir(t.db, target);
|
||||
await second.run("manual");
|
||||
const third = new BackupService(t.db);
|
||||
const finalStatus = third.status();
|
||||
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
||||
expect(finalStatus.lastError).toBeNull();
|
||||
expect(finalStatus.lastErrorAt).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
||||
it("is due immediately when no success has ever been recorded", () => {
|
||||
const t = createTestDb();
|
||||
const svc = new BackupService(t.db);
|
||||
expect(svc.isDue()).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const first = new BackupService(t.db);
|
||||
await first.run("manual");
|
||||
|
||||
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
||||
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
||||
const second = new BackupService(t.db);
|
||||
expect(second.isDue()).toBe(false);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is due once the persisted last-success timestamp is old enough", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
|
||||
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
||||
expect(svc.isDue(almostADayLater)).toBe(false);
|
||||
|
||||
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
||||
expect(svc.isDue(overADayLater)).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
const afterFirst = svc.status().lastSuccessAt;
|
||||
|
||||
await svc.runScheduled(); // not due yet — must not run again
|
||||
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRetention } from "./backup.js";
|
||||
|
||||
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
|
||||
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
|
||||
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
|
||||
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||
// wiki/concepts/backup-recovery.md.
|
||||
//
|
||||
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
|
||||
// memory — an earlier version tracked these as plain in-process fields only, so every server
|
||||
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
|
||||
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
|
||||
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
|
||||
|
||||
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||
export function backupKeyFromEnv(): string {
|
||||
return process.env.BACKUP_KEY ?? "";
|
||||
}
|
||||
|
||||
export interface TargetCheck {
|
||||
readonly ok: boolean;
|
||||
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface BackupStatus {
|
||||
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
|
||||
readonly configured: boolean;
|
||||
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
|
||||
readonly targetDir: string | null;
|
||||
/** Admin-tuned retention (resolved: DB value or code default) — surfaced for the UI form. */
|
||||
readonly keepLast: number;
|
||||
readonly keepDailyDays: number;
|
||||
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
|
||||
readonly keyPresent: boolean;
|
||||
readonly running: boolean;
|
||||
readonly lastSuccessAt: string | null;
|
||||
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
||||
readonly lastErrorAt: string | null;
|
||||
readonly lastError: string | null;
|
||||
}
|
||||
|
||||
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
|
||||
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed) return { ok: false, reason: "empty" };
|
||||
const path = resolve(trimmed);
|
||||
let st: Awaited<ReturnType<typeof stat>>;
|
||||
try {
|
||||
st = await stat(path);
|
||||
} catch {
|
||||
return { ok: false, reason: "missing" };
|
||||
}
|
||||
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
|
||||
try {
|
||||
await access(path, constants.W_OK);
|
||||
} catch {
|
||||
return { ok: false, reason: "not_writable" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
readonly #db: Db;
|
||||
readonly #logger?: FastifyBaseLogger;
|
||||
|
||||
#running = false;
|
||||
|
||||
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
|
||||
* or reset on restart). */
|
||||
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
|
||||
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
}
|
||||
|
||||
#persist(patch: {
|
||||
backupLastSuccessAt?: string | null;
|
||||
backupLastResultJson?: string | null;
|
||||
backupLastErrorAt?: string | null;
|
||||
backupLastError?: string | null;
|
||||
}): void {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const existing = this.#row();
|
||||
if (existing) {
|
||||
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||
targetDir(): string | null {
|
||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const dir = row?.backupTargetDir?.trim();
|
||||
return dir ? dir : null;
|
||||
}
|
||||
|
||||
/** Resolved retention from site_config, falling back to the code default per field. Read fresh. */
|
||||
retention(): BackupRetention {
|
||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const keepLast = row?.backupKeepLast;
|
||||
const keepDailyDays = row?.backupKeepDailyDays;
|
||||
return {
|
||||
keepLast: keepLast != null && keepLast >= 0 ? keepLast : DEFAULT_BACKUP_RETENTION.keepLast,
|
||||
keepDailyDays:
|
||||
keepDailyDays != null && keepDailyDays >= 0 ? keepDailyDays : DEFAULT_BACKUP_RETENTION.keepDailyDays,
|
||||
};
|
||||
}
|
||||
|
||||
get keyPresent(): boolean {
|
||||
return backupKeyFromEnv().length >= 16;
|
||||
}
|
||||
|
||||
get configured(): boolean {
|
||||
return this.targetDir() !== null && this.keyPresent;
|
||||
}
|
||||
|
||||
status(): BackupStatus {
|
||||
const r = this.retention();
|
||||
const row = this.#row();
|
||||
let lastResult: BackupStatus["lastResult"] = null;
|
||||
if (row?.backupLastResultJson) {
|
||||
try {
|
||||
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
|
||||
} catch {
|
||||
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
|
||||
}
|
||||
}
|
||||
return {
|
||||
configured: this.configured,
|
||||
targetDir: this.targetDir(),
|
||||
keepLast: r.keepLast,
|
||||
keepDailyDays: r.keepDailyDays,
|
||||
keyPresent: this.keyPresent,
|
||||
running: this.#running,
|
||||
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||
lastResult,
|
||||
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||
lastError: row?.backupLastError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
|
||||
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
|
||||
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
|
||||
* it; the scheduled timer wraps + swallows.
|
||||
*/
|
||||
#inflight: Promise<BackupResult> | null = null;
|
||||
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
|
||||
if (this.#inflight) return this.#inflight;
|
||||
const targetDir = this.targetDir();
|
||||
const key = backupKeyFromEnv();
|
||||
if (!targetDir) throw new Error("backup: no target directory configured");
|
||||
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||
|
||||
this.#running = true;
|
||||
this.#inflight = (async () => {
|
||||
try {
|
||||
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||
this.#persist({
|
||||
backupLastSuccessAt: new Date().toISOString(),
|
||||
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||
backupLastErrorAt: null,
|
||||
backupLastError: null,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
this.#running = false;
|
||||
this.#inflight = null;
|
||||
}
|
||||
})();
|
||||
return this.#inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
|
||||
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
|
||||
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
|
||||
* frequent backups.
|
||||
*/
|
||||
async runScheduled(): Promise<void> {
|
||||
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||
if (!this.isDue()) return;
|
||||
try {
|
||||
await this.run("scheduled");
|
||||
} catch {
|
||||
/* recorded in last-error; already logged */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
|
||||
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
|
||||
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
|
||||
* (or skips a whole day) across every restart, since the countdown restarts from zero each
|
||||
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
|
||||
*/
|
||||
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
|
||||
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
|
||||
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
|
||||
const last = new Date(lastSuccessAt).getTime();
|
||||
if (Number.isNaN(last)) return true;
|
||||
return now.getTime() - last >= intervalMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createTestDb, openRawDb } from "@parking/db/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
parseBackupStamp,
|
||||
pruneOldBackups,
|
||||
runBackup,
|
||||
} from "./backup.js";
|
||||
|
||||
// Mirror of the engine's header layout, so the test decrypts independently (a real restore
|
||||
// tool would do exactly this) rather than trusting the engine to also decrypt.
|
||||
const MAGIC = Buffer.from("PKBK", "ascii");
|
||||
const SALT_LEN = 16;
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
function decryptBackup(enc: Buffer, key: string): Buffer {
|
||||
expect(enc.subarray(0, 4)).toEqual(MAGIC);
|
||||
expect(enc[4]).toBe(1); // format version
|
||||
let off = 5;
|
||||
const salt = enc.subarray(off, (off += SALT_LEN));
|
||||
const iv = enc.subarray(off, (off += IV_LEN));
|
||||
const tag = enc.subarray(enc.length - TAG_LEN);
|
||||
const ciphertext = enc.subarray(off, enc.length - TAG_LEN);
|
||||
const derived = scryptSync(key, salt, 32);
|
||||
const decipher = createDecipheriv("aes-256-gcm", derived, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
let workDir: string;
|
||||
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "pk-backup-test-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("runBackup — round-trip", () => {
|
||||
it("produces an encrypted backup that decrypts to a byte-identical, queryable DB", async () => {
|
||||
// A real on-disk DB so the engine's better-sqlite3 .backup() runs for real.
|
||||
const dbPath = join(workDir, "source.sqlite");
|
||||
const t = createTestDb(dbPath);
|
||||
// Put some recognizable data in.
|
||||
t.sqlite.exec("CREATE TABLE marker (k TEXT PRIMARY KEY, v TEXT)");
|
||||
t.sqlite.prepare("INSERT INTO marker (k, v) VALUES (?, ?)").run("hello", "world");
|
||||
|
||||
const targetDir = join(workDir, "target");
|
||||
const res = await runBackup(t.db, { targetDir, key: KEY });
|
||||
t.close();
|
||||
|
||||
expect(res.bytes).toBeGreaterThan(0);
|
||||
expect(res.path).toMatch(/parking-backup-\d{8}T\d{6}Z\.sqlite\.enc$/);
|
||||
|
||||
// Decrypt independently and open the recovered DB raw (no migrations — verify as-written).
|
||||
const plain = decryptBackup(readFileSync(res.path), KEY);
|
||||
const restoredPath = join(workDir, "restored.sqlite");
|
||||
writeFileSync(restoredPath, plain);
|
||||
const restored = openRawDb(restoredPath);
|
||||
const row = restored.prepare("SELECT v FROM marker WHERE k = ?").get("hello") as { v: string };
|
||||
expect(row.v).toBe("world");
|
||||
restored.close();
|
||||
});
|
||||
|
||||
it("rejects a missing/short key before touching the filesystem", async () => {
|
||||
const t = createTestDb();
|
||||
await expect(runBackup(t.db, { targetDir: join(workDir, "t"), key: "short" })).rejects.toThrow(
|
||||
/BACKUP_KEY/,
|
||||
);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("removes the plaintext scratch copy after a successful run", async () => {
|
||||
const scratchDir = join(workDir, "scratch");
|
||||
const t = createTestDb();
|
||||
await runBackup(t.db, {
|
||||
targetDir: join(workDir, "target"),
|
||||
key: KEY,
|
||||
scratchDir,
|
||||
// Stub the copy so we don't need a file-backed handle here.
|
||||
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "PRAGMA;"),
|
||||
});
|
||||
t.close();
|
||||
// The only thing left in scratch must NOT be a .sqlite plaintext.
|
||||
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||
expect(left).toEqual([]);
|
||||
});
|
||||
|
||||
it("wipes the plaintext scratch copy even when the copy step fails", async () => {
|
||||
const scratchDir = join(workDir, "scratch");
|
||||
mkdirSync(scratchDir, { recursive: true });
|
||||
const t = createTestDb();
|
||||
// Force a failure: the copy step writes the plaintext, then throws (mid-pipeline). The
|
||||
// finally{} must still remove the plaintext it left behind.
|
||||
await expect(
|
||||
runBackup(t.db, {
|
||||
targetDir: join(workDir, "target"),
|
||||
key: KEY,
|
||||
scratchDir,
|
||||
makeConsistentCopy: async (_db, dest) => {
|
||||
writeFileSync(dest, "PRAGMA;"); // leave a plaintext intermediate…
|
||||
throw new Error("simulated copy failure"); // …then fail
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/simulated copy failure/);
|
||||
t.close();
|
||||
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||
expect(left).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backup encryption — tamper evidence (AES-256-GCM)", () => {
|
||||
it("a flipped ciphertext byte fails authentication on decrypt", async () => {
|
||||
const t = createTestDb();
|
||||
const targetDir = join(workDir, "target");
|
||||
const res = await runBackup(t.db, {
|
||||
targetDir,
|
||||
key: KEY,
|
||||
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "the quick brown fox".repeat(100)),
|
||||
});
|
||||
t.close();
|
||||
|
||||
const enc = readFileSync(res.path);
|
||||
// Flip a byte in the ciphertext region (after the header, before the tag).
|
||||
enc[5 + SALT_LEN + IV_LEN + 3] ^= 0xff;
|
||||
expect(() => decryptBackup(enc, KEY)).toThrow();
|
||||
});
|
||||
|
||||
it("the wrong key fails authentication", async () => {
|
||||
const t = createTestDb();
|
||||
const res = await runBackup(t.db, {
|
||||
targetDir: join(workDir, "target"),
|
||||
key: KEY,
|
||||
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "payload".repeat(50)),
|
||||
});
|
||||
t.close();
|
||||
expect(() => decryptBackup(readFileSync(res.path), "a-different-but-also-long-key-xx")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBackupStamp", () => {
|
||||
it("round-trips a stamped name and rejects non-backups", () => {
|
||||
const d = parseBackupStamp("parking-backup-20260629T141503Z.sqlite.enc");
|
||||
expect(d?.toISOString()).toBe("2026-06-29T14:15:03.000Z");
|
||||
expect(parseBackupStamp("random.txt")).toBeNull();
|
||||
expect(parseBackupStamp("parking-backup-not-a-date.sqlite.enc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneOldBackups — keep-last-N + dailies", () => {
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const now = new Date("2026-06-29T12:00:00Z");
|
||||
|
||||
function seed(stamps: string[]) {
|
||||
const dir = join(workDir, "retain");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const s of stamps) writeFileSync(join(dir, `parking-backup-${s}.sqlite.enc`), "x");
|
||||
return dir;
|
||||
}
|
||||
const stamp = (ms: number) =>
|
||||
new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
|
||||
it("keeps the keepLast newest regardless of age", async () => {
|
||||
// 5 backups within the last hour; keepLast=3 → 2 pruned, even though all are recent.
|
||||
const t = now.getTime();
|
||||
const dir = seed([0, 1, 2, 3, 4].map((i) => stamp(t - i * 60 * 1000)));
|
||||
const pruned = await pruneOldBackups(dir, { keepLast: 3, keepDailyDays: 0 }, now);
|
||||
expect(pruned).toBe(2);
|
||||
expect(readdirSync(dir).length).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps one-per-day within the daily window and drops older", async () => {
|
||||
const t = now.getTime();
|
||||
// Two backups today, one 5 days ago, one 40 days ago. keepLast=1, keepDailyDays=30.
|
||||
const dir = seed([
|
||||
stamp(t), // today A (newest → kept by keepLast)
|
||||
stamp(t - 60 * 1000), // today B (same day as the kept one → pruned)
|
||||
stamp(t - 5 * day), // 5 days ago (kept: within window, unique day)
|
||||
stamp(t - 40 * day), // 40 days ago (pruned: outside the window)
|
||||
]);
|
||||
const pruned = await pruneOldBackups(dir, { keepLast: 1, keepDailyDays: 30 }, now);
|
||||
expect(pruned).toBe(2);
|
||||
const left = readdirSync(dir);
|
||||
expect(left.length).toBe(2);
|
||||
});
|
||||
|
||||
it("is a no-op on a missing target dir", async () => {
|
||||
const pruned = await pruneOldBackups(join(workDir, "does-not-exist"), DEFAULT_BACKUP_RETENTION, now);
|
||||
expect(pruned).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { createCipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
// On-site encrypted DB backup — the durability half of the anti-fraud design. The SQLite
|
||||
// DB *is* the signed append-only ledger, so a disk failure / stolen-or-destroyed PC means
|
||||
// total revenue-history loss. This produces a consistent, encrypted, restore-to-a-fresh-
|
||||
// appliance copy. See wiki/concepts/backup-recovery.md.
|
||||
//
|
||||
// Two load-bearing properties:
|
||||
// 1. CONSISTENT copy of a LIVE WAL-mode DB — via better-sqlite3's online .backup() (NOT a
|
||||
// raw file copy, which can capture a torn WAL). The result must still verifyChain.
|
||||
// 2. Encrypted with a DEDICATED key (BACKUP_KEY / park_buzi_backup_key), SEPARATE from
|
||||
// EVENT_SIGNING_KEY — so the backup key can rotate without fracturing the signed chain,
|
||||
// and a backup target never exposes the signing key. The key is NEVER written into the
|
||||
// backup it unlocks.
|
||||
//
|
||||
// This module is the engine (consistent copy → encrypt → retention). Targets beyond a local/
|
||||
// mounted path (SMB/NFS are just mount paths; SFTP) and the manual button/route are layered on
|
||||
// top. RESTORE is intentionally NOT here — it's an out-of-band runbook action on a fresh box.
|
||||
|
||||
/** AES-256-GCM with a scrypt-derived key. Self-describing header so a restore tool needs only
|
||||
* the key + the file. Layout: magic | version | salt(16) | iv(12) | ciphertext… | authTag(16). */
|
||||
const MAGIC = Buffer.from("PKBK", "ascii"); // ParKing BacKup
|
||||
const FORMAT_VERSION = 1;
|
||||
const SALT_LEN = 16;
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
const SCRYPT_KEYLEN = 32; // AES-256
|
||||
|
||||
export interface BackupRetention {
|
||||
/** Keep at least this many most-recent backups regardless of age. */
|
||||
readonly keepLast: number;
|
||||
/** Beyond keepLast, keep one backup per day for this many days; older ones are pruned. */
|
||||
readonly keepDailyDays: number;
|
||||
}
|
||||
|
||||
// Code defaults — the fallback when the admin hasn't set a value in site_config (the source of
|
||||
// truth). NOT env-driven: retention is operational policy tuned from the Backup screen.
|
||||
export const DEFAULT_BACKUP_RETENTION: BackupRetention = {
|
||||
keepLast: 7,
|
||||
keepDailyDays: 30,
|
||||
};
|
||||
|
||||
export interface BackupOptions {
|
||||
/** Directory the encrypted backup is written to (a mounted local/USB/SATA/SMB/NFS path). */
|
||||
readonly targetDir: string;
|
||||
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). ≥16 chars enforced. */
|
||||
readonly key: string;
|
||||
readonly retention?: BackupRetention;
|
||||
/** Override the consistent-copy step (tests inject a fake to avoid a real sqlite handle). */
|
||||
readonly makeConsistentCopy?: (db: Db, destPath: string) => Promise<void>;
|
||||
/** Override "now" for deterministic filenames/retention in tests. */
|
||||
readonly now?: () => Date;
|
||||
/** Scratch dir for the intermediate plaintext copy (default os.tmpdir()). */
|
||||
readonly scratchDir?: string;
|
||||
}
|
||||
|
||||
export interface BackupResult {
|
||||
/** Absolute path of the encrypted backup written. */
|
||||
readonly path: string;
|
||||
/** Size of the encrypted file in bytes. */
|
||||
readonly bytes: number;
|
||||
/** Backups pruned by the retention policy this run. */
|
||||
readonly prunedFiles: number;
|
||||
}
|
||||
|
||||
/** Filename convention: parking-backup-YYYYMMDDTHHMMSSZ.sqlite.enc — sortable, UTC, parseable. */
|
||||
const FILE_PREFIX = "parking-backup-";
|
||||
const FILE_SUFFIX = ".sqlite.enc";
|
||||
|
||||
function stampFor(d: Date): string {
|
||||
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
/** Parse the UTC instant back out of a backup filename, or null if it doesn't match. */
|
||||
export function parseBackupStamp(name: string): Date | null {
|
||||
const base = basename(name);
|
||||
if (!base.startsWith(FILE_PREFIX) || !base.endsWith(FILE_SUFFIX)) return null;
|
||||
const stamp = base.slice(FILE_PREFIX.length, -FILE_SUFFIX.length);
|
||||
// 20260629T141503Z → 2026-06-29T14:15:03Z
|
||||
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
|
||||
if (!m) return null;
|
||||
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
||||
const dt = new Date(iso);
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}
|
||||
|
||||
/** Consistent online copy of the live WAL-mode DB via better-sqlite3's native backup(). */
|
||||
async function defaultConsistentCopy(db: Db, destPath: string): Promise<void> {
|
||||
// db.$client is the raw better-sqlite3 Database; .backup() returns a promise and copies a
|
||||
// transactionally-consistent snapshot even while the source is being written.
|
||||
const client = db.$client as { backup: (dest: string) => Promise<unknown> };
|
||||
await client.backup(destPath);
|
||||
}
|
||||
|
||||
/** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */
|
||||
async function encryptFile(srcPath: string, destPath: string, key: string): Promise<void> {
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const derived = scryptSync(key, salt, SCRYPT_KEYLEN);
|
||||
const cipher = createCipheriv("aes-256-gcm", derived, iv);
|
||||
|
||||
const out = createWriteStream(destPath);
|
||||
const header = Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION]), salt, iv]);
|
||||
out.write(header);
|
||||
|
||||
await pipeline(createReadStream(srcPath), cipher, out, { end: false });
|
||||
// GCM auth tag is available only after the cipher has flushed; append it, then close.
|
||||
const tag = cipher.getAuthTag();
|
||||
await new Promise<void>((res, rej) => {
|
||||
out.end(tag, () => res());
|
||||
out.on("error", rej);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one backup: consistent copy → encrypt → prune old backups by retention.
|
||||
* Best-effort caller-facing: throws on real failure (so a manual run surfaces the error),
|
||||
* but the scheduled timer wraps it and logs.
|
||||
*/
|
||||
export async function runBackup(
|
||||
db: Db,
|
||||
opts: BackupOptions,
|
||||
logger?: FastifyBaseLogger,
|
||||
): Promise<BackupResult> {
|
||||
if (!opts.key || opts.key.length < 16) {
|
||||
throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||
}
|
||||
const now = opts.now ?? (() => new Date());
|
||||
const retention = opts.retention ?? DEFAULT_BACKUP_RETENTION;
|
||||
const targetDir = resolve(opts.targetDir);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
const stamp = stampFor(now());
|
||||
const finalPath = join(targetDir, `${FILE_PREFIX}${stamp}${FILE_SUFFIX}`);
|
||||
|
||||
// Intermediate plaintext copy in scratch (NOT the target dir — the target may be a network
|
||||
// share / removable disk; keep the plaintext local and short-lived, then wipe it).
|
||||
const scratch = opts.scratchDir ?? tmpdir();
|
||||
await mkdir(scratch, { recursive: true });
|
||||
const plainPath = join(scratch, `${FILE_PREFIX}${stamp}.sqlite`);
|
||||
|
||||
try {
|
||||
const copy = opts.makeConsistentCopy ?? defaultConsistentCopy;
|
||||
await copy(db, plainPath);
|
||||
await encryptFile(plainPath, finalPath, opts.key);
|
||||
} finally {
|
||||
// Always wipe the plaintext intermediate, success or fail — it's the unencrypted ledger.
|
||||
await rm(plainPath, { force: true }).catch((err) =>
|
||||
logger?.warn(`backup: failed to remove plaintext scratch copy: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
const { size } = await stat(finalPath);
|
||||
const prunedFiles = await pruneOldBackups(targetDir, retention, now());
|
||||
logger?.info(
|
||||
`backup: wrote ${basename(finalPath)} (${(size / 1048576).toFixed(1)} MB)` +
|
||||
(prunedFiles > 0 ? `, pruned ${prunedFiles} old` : ""),
|
||||
);
|
||||
return { path: finalPath, bytes: size, prunedFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention: keep the `keepLast` most-recent backups always; beyond those, keep at most one
|
||||
* backup per UTC day for `keepDailyDays` days; delete anything older or any extra same-day
|
||||
* duplicates outside the keepLast window. Returns the count deleted.
|
||||
*/
|
||||
export async function pruneOldBackups(
|
||||
targetDir: string,
|
||||
retention: BackupRetention,
|
||||
now: Date,
|
||||
): Promise<number> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(targetDir);
|
||||
} catch {
|
||||
return 0; // target gone/unmounted — nothing to prune (the write would have failed first)
|
||||
}
|
||||
|
||||
const backups = names
|
||||
.map((n) => ({ name: n, at: parseBackupStamp(n) }))
|
||||
.filter((b): b is { name: string; at: Date } => b.at !== null)
|
||||
.sort((a, b) => b.at.getTime() - a.at.getTime()); // newest first
|
||||
|
||||
const keep = new Set<string>();
|
||||
// 1. Always keep the keepLast newest.
|
||||
for (const b of backups.slice(0, Math.max(0, retention.keepLast))) keep.add(b.name);
|
||||
|
||||
// 2. Beyond that, keep the newest per UTC day within the keepDailyDays window.
|
||||
const cutoff = now.getTime() - retention.keepDailyDays * 24 * 60 * 60 * 1000;
|
||||
const seenDays = new Set<string>();
|
||||
for (const b of backups) {
|
||||
if (keep.has(b.name)) {
|
||||
seenDays.add(b.at.toISOString().slice(0, 10));
|
||||
continue;
|
||||
}
|
||||
if (b.at.getTime() < cutoff) continue; // too old → not kept
|
||||
const day = b.at.toISOString().slice(0, 10);
|
||||
if (seenDays.has(day)) continue; // already have a backup for this day → prune the extra
|
||||
seenDays.add(day);
|
||||
keep.add(b.name);
|
||||
}
|
||||
|
||||
let pruned = 0;
|
||||
for (const b of backups) {
|
||||
if (keep.has(b.name)) continue;
|
||||
await rm(join(targetDir, b.name), { force: true });
|
||||
pruned += 1;
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||
import {
|
||||
printWithFailover,
|
||||
registry,
|
||||
type PrinterDevice,
|
||||
type PrinterInstance,
|
||||
type ReceiptData,
|
||||
type TicketHeader,
|
||||
} from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devicesByDirection } from "./device-resolve.js";
|
||||
|
||||
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
|
||||
// from the exit, the customer pays at the booth and walks a printed voucher to the
|
||||
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
|
||||
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
|
||||
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
|
||||
//
|
||||
// This mirrors the entry flow's printer selection + header build, but prints on the
|
||||
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
|
||||
|
||||
/** Park identity for the voucher header, from site_config (all fields optional). */
|
||||
function ticketHeader(db: Db): TicketHeader | undefined {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
parkName: row.parkName,
|
||||
operatorName: row.operatorName,
|
||||
nius: row.nius,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build live printer instances for failover selection (entry direction covers the
|
||||
* booth-receipt role too — the booth printer is configured on the entry side). */
|
||||
function loadPrinters(db: Db): PrinterInstance[] {
|
||||
const rows = devicesByDirection(db, "printer", "entry");
|
||||
const out: PrinterInstance[] = [];
|
||||
for (const row of rows) {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
role,
|
||||
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||
device: driver.create(cfg as never) as PrinterDevice,
|
||||
});
|
||||
} catch {
|
||||
// skip a printer whose config won't build
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The receipt figures for a paid session, folded from the SIGNED ledger
|
||||
* (authoritative). Null if there's no entry or no payment for this id — the
|
||||
* caller should have validated paid + open before printing. */
|
||||
function receiptFigures(
|
||||
db: Db,
|
||||
ticketId: string,
|
||||
): Omit<ReceiptData, "voucher" | "header"> | null {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, ticketId))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return null;
|
||||
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
|
||||
let payment: (typeof rows)[number] | undefined;
|
||||
for (const r of rows) if (r.type === "payment") payment = r;
|
||||
if (!payment) return null;
|
||||
const p = (payment.payload ?? {}) as {
|
||||
amountMinor?: number;
|
||||
currency?: string;
|
||||
tender?: "cash" | "card";
|
||||
graceExitMin?: number;
|
||||
grossMinor?: number;
|
||||
validationLines?: { label: string; discountMinor: number }[];
|
||||
};
|
||||
return {
|
||||
ticketId,
|
||||
enteredAt: entry.occurredAt,
|
||||
paidAt: payment.occurredAt,
|
||||
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
|
||||
currency: p.currency ?? "ALL",
|
||||
tender: p.tender === "card" ? "card" : "cash",
|
||||
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
||||
// Merchant validations, as settled on the signed payment (gross → lines → net).
|
||||
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
|
||||
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
|
||||
* to the entry dispenser). The receipt is the customer's transparency record:
|
||||
* entry time, payment time, duration, amount + tender — folded from the signed
|
||||
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
|
||||
* walk-back grace, so the one slip both proves payment AND self-exits at a
|
||||
* distant exit reader (this replaces the old barcode-only voucher). In standalone
|
||||
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
|
||||
* at the exit. Returns the id of the printer that printed it.
|
||||
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
|
||||
*/
|
||||
export async function printPaymentReceipt(
|
||||
db: Db,
|
||||
ticketId: string,
|
||||
opts: { voucher: boolean },
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<string> {
|
||||
const figures = receiptFigures(db, ticketId);
|
||||
if (!figures) {
|
||||
throw new Error(`no paid session to receipt for ${ticketId}`);
|
||||
}
|
||||
const printers = loadPrinters(db);
|
||||
const data: ReceiptData = {
|
||||
...figures,
|
||||
voucher: opts.voucher,
|
||||
header: ticketHeader(db),
|
||||
};
|
||||
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
|
||||
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||
d.printReceipt(data),
|
||||
);
|
||||
logger.info(
|
||||
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
|
||||
);
|
||||
return printedBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
|
||||
* a scannable QR of the credential code + holder/validity, so the operator can hand
|
||||
* it to the customer. Used on subscription creation and on a "reprint" action.
|
||||
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
|
||||
*/
|
||||
export async function printSubscriptionCard(
|
||||
db: Db,
|
||||
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<string> {
|
||||
const printers = loadPrinters(db);
|
||||
const data = {
|
||||
code: card.code,
|
||||
holderName: card.holderName ?? null,
|
||||
validFrom: card.validFrom ?? null,
|
||||
validTo: card.validTo ?? null,
|
||||
header: ticketHeader(db),
|
||||
};
|
||||
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||
d.printSubscriptionCard(data),
|
||||
);
|
||||
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
|
||||
return printedBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
|
||||
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
|
||||
* the total is computed at the booth on settlement (early-entry AND any late-exit time
|
||||
* combined). It just gives the subscriber paper proof that a fee is pending against this
|
||||
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
|
||||
* the caller swallows failures so a missing printer never blocks the barrier.
|
||||
*/
|
||||
export async function printWindowChargeNotice(
|
||||
db: Db,
|
||||
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" },
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<string> {
|
||||
const printers = loadPrinters(db);
|
||||
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||
d.printWindowChargeNotice({
|
||||
occurrenceId: notice.occurrenceId,
|
||||
holderName: notice.holderName ?? null,
|
||||
at: notice.at,
|
||||
edge: notice.edge,
|
||||
windowOpensMin: notice.windowOpensMin ?? null,
|
||||
header: ticketHeader(db),
|
||||
}),
|
||||
);
|
||||
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
|
||||
return printedBy;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { AuxOutputDevice } from "@parking/devices";
|
||||
import { ButtonLightController } from "./button-light.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
|
||||
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
|
||||
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
|
||||
// trigger active + lane busy -> SOLID on
|
||||
// trigger active + lane free -> BLINK (~1 Hz)
|
||||
// otherwise -> OFF
|
||||
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
|
||||
// carry several alert relays (each its own row + trigger input), keyed independently.
|
||||
|
||||
let db: Db;
|
||||
const CONTROLLER = "ctl-1";
|
||||
const RADAR_INPUT = 2; // I2
|
||||
const LAMP_RELAY = 3; // spare relay R3
|
||||
|
||||
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||
return {
|
||||
async setAux(channel: number, on: boolean): Promise<void> {
|
||||
if (throwOnce.v) {
|
||||
throwOnce.v = false;
|
||||
throw new Error("UDP down");
|
||||
}
|
||||
record.push({ ch: channel, on });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
vi.useFakeTimers();
|
||||
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||
db.insert(devices).values({
|
||||
id: CONTROLLER,
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** Emit a radar (presence input) edge for the controller. */
|
||||
function radar(present: boolean): void {
|
||||
deviceEvents.emitInput({
|
||||
driverId: "dingtian",
|
||||
deviceId: CONTROLLER,
|
||||
input: RADAR_INPUT,
|
||||
edge: present ? "on" : "off",
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
});
|
||||
}
|
||||
|
||||
/** Emit a lane status (entry busy/free). */
|
||||
function lane(entryBusy: boolean): void {
|
||||
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||
}
|
||||
|
||||
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("ButtonLightController truth table", () => {
|
||||
it("OFF at start (no radar, no car)", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||
ctl.start();
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||
// device is confirmed OFF and at most one call was made.
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("radar present + lane busy -> SOLID on", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
lane(true);
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||
// Solid = no blinking: advancing time produces no further sends.
|
||||
const n = calls.length;
|
||||
vi.advanceTimersByTime(2000);
|
||||
await flush();
|
||||
expect(calls.length).toBe(n);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
radar(true); // lane still free
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||
vi.advanceTimersByTime(500);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||
vi.advanceTimersByTime(500);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
radar(true); // blink
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||
lane(true); // camera confirms
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
// No more toggles (blink torn down) — the device stays ON over time.
|
||||
vi.advanceTimersByTime(2000);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("radar clears -> OFF", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
lane(true);
|
||||
radar(true); // solid
|
||||
await flush();
|
||||
radar(false); // car gone
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
lane(true);
|
||||
radar(true); // solid, on
|
||||
await flush();
|
||||
const n = calls.length;
|
||||
radar(true); // same state — no new edge (present unchanged)
|
||||
lane(true); // same lane — no change
|
||||
await flush();
|
||||
expect(calls.length).toBe(n);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const throwOnce = { v: true };
|
||||
const aux = fakeAux(calls, throwOnce);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
// First write (initial off) throws — must be swallowed.
|
||||
expect(() => ctl.start()).not.toThrow();
|
||||
await flush();
|
||||
// The failure arms a backoff (1s) rather than retrying inline; desired-state
|
||||
// changes during the window just update the target the retry will assert.
|
||||
lane(true);
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBeNull(); // still backing off
|
||||
await vi.advanceTimersByTimeAsync(1000); // retry fires; aux is healthy again
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // converged to solid ON
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("an unreachable controller backs off (1s→30s), not a hot retry loop", async () => {
|
||||
let attempts = 0;
|
||||
const aux: AuxOutputDevice = {
|
||||
async setAux() {
|
||||
attempts += 1;
|
||||
throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||
},
|
||||
};
|
||||
const errors: string[] = [];
|
||||
const logger = silentLogger();
|
||||
(logger as { error: (msg: string) => void }).error = (msg) => errors.push(msg);
|
||||
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||
ctl.start(); // initial OFF write → attempt 1 fails at t=0
|
||||
await flush();
|
||||
expect(attempts).toBe(1); // the old code hot-looped here
|
||||
|
||||
// Failures at t≈0,1,3,7,15,31 (doubling, capped 30s) → 6 attempts in the first
|
||||
// minute instead of thousands.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(attempts).toBeGreaterThanOrEqual(5);
|
||||
expect(attempts).toBeLessThanOrEqual(7);
|
||||
|
||||
// Only the FIRST failure was logged so far; the next log is a ≥60s summary.
|
||||
expect(errors).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(35_000); // t≈95s → the t=61s attempt logged a summary
|
||||
expect(errors.length).toBe(2);
|
||||
expect(errors[1]).toContain("still failing");
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("logs a single recovery line and resets the backoff after success", async () => {
|
||||
let failing = true;
|
||||
let attempts = 0;
|
||||
const aux: AuxOutputDevice = {
|
||||
async setAux() {
|
||||
attempts += 1;
|
||||
if (failing) throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||
},
|
||||
};
|
||||
const infos: string[] = [];
|
||||
const logger = silentLogger();
|
||||
(logger as { info: (msg: string) => void }).info = (msg) => infos.push(msg);
|
||||
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(3_000); // attempts at t=0,1,3 all fail
|
||||
const failed = attempts;
|
||||
expect(failed).toBeGreaterThanOrEqual(3);
|
||||
|
||||
failing = false; // controller reachable again
|
||||
await vi.advanceTimersByTimeAsync(8_000); // next armed retry succeeds
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // OFF asserted on the device
|
||||
expect(infos.filter((m) => m.includes("recovered"))).toHaveLength(1);
|
||||
|
||||
// Backoff reset: a fresh state change sends immediately (no lingering retryAt).
|
||||
const before = attempts;
|
||||
lane(true);
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
expect(attempts).toBe(before + 1);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("ignores controllers without an alert relay", () => {
|
||||
// A second controller, no alert relay.
|
||||
db.insert(devices).values({
|
||||
id: "ctl-2",
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||
enabled: true,
|
||||
}).run();
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||
ctl.start();
|
||||
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
|
||||
// Fresh controller with a radar input but NO alert relay yet.
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
// Replace the seeded controller with one that has the radar but no lamp.
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CONTROLLER))
|
||||
.run();
|
||||
ctl.start();
|
||||
await flush();
|
||||
// No lamp configured → an input does nothing.
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||
expect(calls.length).toBe(0);
|
||||
radar(false);
|
||||
await flush();
|
||||
|
||||
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||
],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CONTROLLER))
|
||||
.run();
|
||||
|
||||
// The very next radar edge reconciles + blinks (lane still free).
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("drives two alert relays on one controller independently", async () => {
|
||||
const R3 = 3;
|
||||
const R4 = 4;
|
||||
const I2 = 2;
|
||||
const I3 = 3;
|
||||
// Controller with two alert lamps, each on its own trigger input.
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
|
||||
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
|
||||
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
|
||||
],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CONTROLLER))
|
||||
.run();
|
||||
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||
const aux = fakeAux(calls);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||
|
||||
// I2 active → only R3 blinks; R4 stays off (different trigger).
|
||||
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||
|
||||
// I3 active → R4 blinks too, independently.
|
||||
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||
|
||||
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
|
||||
lane(true);
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||
|
||||
// I2 clears → R3 off, R4 still solid (its trigger still active).
|
||||
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
|
||||
const R4 = 4;
|
||||
const I5 = 5; // exit radar
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
|
||||
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
|
||||
],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CONTROLLER))
|
||||
.run();
|
||||
const aux = fakeAux([]);
|
||||
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
|
||||
// Exit radar active → blink.
|
||||
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||
|
||||
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
|
||||
deviceEvents.emitLaneStatus({ entry: true, exit: false });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||
|
||||
// EXIT camera busy → SOLID.
|
||||
deviceEvents.emitLaneStatus({ entry: true, exit: true });
|
||||
await flush();
|
||||
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||
ctl.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
|
||||
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
|
||||
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
|
||||
|
||||
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
|
||||
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
|
||||
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
|
||||
// indicator:
|
||||
// trigger active + lane busy (camera confirms a car) → SOLID on
|
||||
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
|
||||
// otherwise → OFF
|
||||
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
|
||||
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
|
||||
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
|
||||
// misleading solid "go". A controller may have several alert relays (each its own row +
|
||||
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
|
||||
|
||||
type LightState = "off" | "solid" | "blink";
|
||||
|
||||
const DEFAULT_BLINK_MS = 500;
|
||||
|
||||
// Failed-send retry backoff: 1s doubling to 30s, reset on success. Without this an
|
||||
// unreachable controller (ENETUNREACH) became a hot loop — the failure re-pump retried
|
||||
// instantly, thousands of sends + error lines per minute (field incident 2026-07-07).
|
||||
const RETRY_BASE_MS = 1_000;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
/** After the first failure of a streak, log at most one summary line per this window. */
|
||||
const FAIL_LOG_EVERY_MS = 60_000;
|
||||
|
||||
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||
interface LampState {
|
||||
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||
readonly controllerId: string;
|
||||
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
|
||||
* place when the admin changes the alert config without a restart. */
|
||||
spec: RelaySpec;
|
||||
/** Is the lamp's trigger input (the radar) currently active? */
|
||||
present: boolean;
|
||||
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||
rendered: LightState | null;
|
||||
/** Active blink timer, if blinking. */
|
||||
blink: ReturnType<typeof setInterval> | null;
|
||||
/** Blink phase (true = currently on). */
|
||||
blinkOn: boolean;
|
||||
/** The output we WANT the relay to be in. The serialized worker drives the device
|
||||
* toward this. The blink timer only flips this flag — it never sends directly. */
|
||||
desiredOn: boolean;
|
||||
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
|
||||
confirmedOn: boolean | null;
|
||||
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||
sending: boolean;
|
||||
/** Consecutive failed sends (0 = healthy). Drives the backoff delay + log summaries. */
|
||||
failCount: number;
|
||||
/** Epoch ms before which #pump must not send (0 = no backoff). The armed retry
|
||||
* timer re-pumps when it elapses; desired-state changes in between just update
|
||||
* `desiredOn` and are picked up by that same retry. */
|
||||
retryAt: number;
|
||||
/** The armed backoff retry, if any. */
|
||||
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||
/** Epoch ms of the last failure line we actually logged (rate-limits the flood). */
|
||||
lastFailLogAt: number;
|
||||
}
|
||||
|
||||
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||
* driver registry; tests inject a spy. Returns null when the controller has no
|
||||
* aux-output capability (or won't build). */
|
||||
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
|
||||
|
||||
export class ButtonLightController {
|
||||
readonly #db: Db;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #resolveAux: AuxResolver;
|
||||
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
|
||||
readonly #lamps = new Map<string, LampState>();
|
||||
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
|
||||
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
|
||||
* exit camera, not the entry one. */
|
||||
#entryBusy = false;
|
||||
#exitBusy = false;
|
||||
/** Controllers we've already warned lack the aux-output capability (warn once). */
|
||||
readonly #warned = new Set<string>();
|
||||
#unsubInput: (() => void) | null = null;
|
||||
#unsubLane: (() => void) | null = null;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
|
||||
}
|
||||
|
||||
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||
start(): void {
|
||||
this.#reconcile();
|
||||
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
||||
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||
|
||||
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
|
||||
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||
}
|
||||
|
||||
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
|
||||
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
|
||||
* before handling each event, so a just-saved lamp takes effect immediately. */
|
||||
#reconcile(): void {
|
||||
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) continue;
|
||||
for (const spec of alertRelaysOf(row)) {
|
||||
const key = lampKey(row.id, spec.relay);
|
||||
seen.add(key);
|
||||
const existing = this.#lamps.get(key);
|
||||
if (existing) {
|
||||
existing.spec = spec; // pick up a changed trigger input / blink cadence
|
||||
} else {
|
||||
this.#lamps.set(key, {
|
||||
controllerId: row.id,
|
||||
spec,
|
||||
present: false,
|
||||
rendered: null,
|
||||
blink: null,
|
||||
blinkOn: false,
|
||||
desiredOn: false,
|
||||
confirmedOn: null,
|
||||
sending: false,
|
||||
failCount: 0,
|
||||
retryAt: 0,
|
||||
retryTimer: null,
|
||||
lastFailLogAt: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||
for (const [key, lamp] of this.#lamps) {
|
||||
if (seen.has(key)) continue;
|
||||
this.#disarm(lamp);
|
||||
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||
this.#lamps.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
||||
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||
#onInput(e: DeviceInputEvent): void {
|
||||
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
|
||||
this.#reconcile();
|
||||
const present = e.edge === "on";
|
||||
for (const lamp of this.#lamps.values()) {
|
||||
if (lamp.controllerId !== e.deviceId) continue;
|
||||
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
|
||||
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
|
||||
// lamp and the one-car-one-ticket gate always agree on "a car is here".
|
||||
const trigger =
|
||||
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
|
||||
if (trigger !== e.input) continue; // not this lamp's trigger terminal
|
||||
if (present === lamp.present) continue;
|
||||
lamp.present = present;
|
||||
this.#apply(lamp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
|
||||
#onLane(s: LaneStatusEvent): void {
|
||||
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
|
||||
this.#entryBusy = s.entry;
|
||||
this.#exitBusy = s.exit;
|
||||
// Re-render every lamp (each picks its own lane's camera in #apply).
|
||||
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||
}
|
||||
|
||||
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
|
||||
* timer/state machine is synchronous; the UDP write resolves on its own). */
|
||||
#apply(lamp: LampState): void {
|
||||
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
|
||||
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
|
||||
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
|
||||
if (target === lamp.rendered) return; // already rendering this state
|
||||
|
||||
// Tear down any running blink before switching states.
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
lamp.rendered = target;
|
||||
|
||||
if (target === "off") {
|
||||
lamp.desiredOn = false;
|
||||
this.#pump(lamp);
|
||||
} else if (target === "solid") {
|
||||
lamp.desiredOn = true;
|
||||
this.#pump(lamp);
|
||||
} else {
|
||||
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
|
||||
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
|
||||
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
|
||||
// relay can't get stuck on a stale packet.
|
||||
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
|
||||
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||
lamp.blinkOn = true;
|
||||
lamp.desiredOn = true;
|
||||
const tick = () => {
|
||||
lamp.blinkOn = !lamp.blinkOn;
|
||||
lamp.desiredOn = lamp.blinkOn;
|
||||
this.#pump(lamp);
|
||||
if (onMs !== offMs && lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||
lamp.blink.unref?.();
|
||||
}
|
||||
};
|
||||
lamp.blink = setInterval(tick, onMs);
|
||||
lamp.blink.unref?.();
|
||||
this.#pump(lamp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||
* so the LAST desired state is always the one finally asserted on the device.
|
||||
*
|
||||
* Failures back off (1s → 30s, reset on success) instead of retrying inline: an
|
||||
* unreachable controller rejects instantly, and an immediate re-pump was a hot loop.
|
||||
* During backoff `desiredOn` keeps tracking the truth table; the armed retry timer
|
||||
* converges to whatever it says when it fires. Only the FIRST failure of a streak is
|
||||
* logged, then one summary per minute, and an info line on recovery. */
|
||||
#pump(lamp: LampState): void {
|
||||
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||
if (Date.now() < lamp.retryAt) return; // backing off — the retry timer will re-pump
|
||||
const aux = this.#resolveAux(lamp.controllerId);
|
||||
if (!aux) return;
|
||||
const target = lamp.desiredOn;
|
||||
lamp.sending = true;
|
||||
void aux
|
||||
.setAux(lamp.spec.relay, target)
|
||||
.then(() => {
|
||||
lamp.confirmedOn = target;
|
||||
if (lamp.failCount > 0) {
|
||||
this.#logger.info(
|
||||
`button-light setAux recovered (${lamp.controllerId} R${lamp.spec.relay}) after ${lamp.failCount} failed attempts`,
|
||||
);
|
||||
}
|
||||
lamp.failCount = 0;
|
||||
lamp.retryAt = 0;
|
||||
lamp.lastFailLogAt = 0;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Leave confirmedOn unchanged so the armed retry re-asserts the (then-current)
|
||||
// desired state. Never escalates — a dead lamp is "no hint", never a fault.
|
||||
lamp.failCount += 1;
|
||||
const delay = Math.min(RETRY_BASE_MS * 2 ** (lamp.failCount - 1), RETRY_MAX_MS);
|
||||
lamp.retryAt = Date.now() + delay;
|
||||
const now = Date.now();
|
||||
if (lamp.failCount === 1 || now - lamp.lastFailLogAt >= FAIL_LOG_EVERY_MS) {
|
||||
lamp.lastFailLogAt = now;
|
||||
const streak =
|
||||
lamp.failCount > 1 ? ` — still failing (attempt ${lamp.failCount}, retrying ≤${RETRY_MAX_MS / 1000}s)` : "";
|
||||
this.#logger.error(
|
||||
`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}${streak}`,
|
||||
);
|
||||
}
|
||||
if (lamp.retryTimer) clearTimeout(lamp.retryTimer);
|
||||
lamp.retryTimer = setTimeout(() => {
|
||||
lamp.retryTimer = null;
|
||||
this.#pump(lamp);
|
||||
}, delay);
|
||||
lamp.retryTimer.unref?.();
|
||||
})
|
||||
.finally(() => {
|
||||
lamp.sending = false;
|
||||
// Desired state may have changed while we were busy — re-pump to converge (the
|
||||
// backoff gate above makes this a no-op right after a failure). This is what
|
||||
// makes the final state authoritative.
|
||||
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
|
||||
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
|
||||
if (!row) return null;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
let device: unknown;
|
||||
try {
|
||||
device = driver.create(row.config as never);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!hasAuxOutput(device)) {
|
||||
if (!this.#warned.has(controllerId)) {
|
||||
this.#warned.add(controllerId);
|
||||
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
|
||||
stop(): void {
|
||||
this.#unsubInput?.();
|
||||
this.#unsubLane?.();
|
||||
this.#unsubInput = null;
|
||||
this.#unsubLane = null;
|
||||
for (const lamp of this.#lamps.values()) {
|
||||
this.#disarm(lamp);
|
||||
// Best-effort fail-OFF on shutdown.
|
||||
this.#finalOff(lamp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a lamp's timers (blink + backoff retry) without touching the device. */
|
||||
#disarm(lamp: LampState): void {
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
if (lamp.retryTimer) {
|
||||
clearTimeout(lamp.retryTimer);
|
||||
lamp.retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||
* in-flight send — it converges to OFF. Any backoff is waived so the last-gasp OFF
|
||||
* gets one immediate try (a lamp mid-backoff may just have recovered). */
|
||||
#finalOff(lamp: LampState): void {
|
||||
lamp.desiredOn = false;
|
||||
lamp.retryAt = 0;
|
||||
this.#pump(lamp);
|
||||
}
|
||||
|
||||
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
|
||||
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
|
||||
stateOf(controllerId: string, relay?: number): LightState | null {
|
||||
return this.#lamp(controllerId, relay)?.rendered ?? null;
|
||||
}
|
||||
|
||||
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
|
||||
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
|
||||
confirmedOf(controllerId: string, relay?: number): boolean | null {
|
||||
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
|
||||
}
|
||||
|
||||
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
|
||||
* controller's single lamp (the common single-alert case); ambiguous if several. */
|
||||
#lamp(controllerId: string, relay?: number): LampState | undefined {
|
||||
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
|
||||
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Composite key for the lamp map (a controller may carry several alert relays). */
|
||||
function lampKey(controllerId: string, relay: number): string {
|
||||
return `${controllerId}:${relay}`;
|
||||
}
|
||||
|
||||
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
const device = driver.create(row.config as never);
|
||||
return hasAuxOutput(device) ? device : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Credential capture ("enroll a card"): lets an operator present a physical RFID
|
||||
// card/chip (or a QR) to ONE chosen reader and have its value captured for a
|
||||
// subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the
|
||||
// chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is
|
||||
// never affected and keeps serving the live entry/exit flow.
|
||||
//
|
||||
// Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next
|
||||
// read from that armed reader is captured (NOT dispatched to the access flow — the
|
||||
// barrier must not open for a card being enrolled) and capture auto-disarms. The
|
||||
// booth form polls result() until the value appears (or it times out / is cancelled).
|
||||
//
|
||||
// In-memory + single-site single-writer (one booth) → no DB, no cross-process
|
||||
// concerns. See wiki/entities/subscription.md.
|
||||
|
||||
const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000);
|
||||
|
||||
export type CaptureState =
|
||||
| { status: "idle" }
|
||||
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
|
||||
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
|
||||
| { status: "expired"; deviceId: string };
|
||||
|
||||
export class CredentialCapture {
|
||||
#armedDeviceId: string | null = null;
|
||||
#expiresAt = 0;
|
||||
#captured: { deviceId: string; value: string; capturedAt: number } | null = null;
|
||||
#lastExpiredDeviceId: string | null = null;
|
||||
|
||||
/** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any
|
||||
* prior arming (only one capture at a time). Clears a stale captured/expired
|
||||
* result so the form starts fresh. */
|
||||
arm(deviceId: string): { expiresAt: number } {
|
||||
this.#armedDeviceId = deviceId;
|
||||
this.#expiresAt = Date.now() + CAPTURE_TTL_MS;
|
||||
this.#captured = null;
|
||||
this.#lastExpiredDeviceId = null;
|
||||
return { expiresAt: this.#expiresAt };
|
||||
}
|
||||
|
||||
/** Cancel any pending arming (operator closed the form / clicked cancel). */
|
||||
cancel(): void {
|
||||
this.#armedDeviceId = null;
|
||||
this.#expiresAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the reader route on EVERY read. If this reader is the armed one (and
|
||||
* not expired), capture the value, disarm, and return true → the caller must NOT
|
||||
* dispatch this read to the access flow. Otherwise false → dispatch normally.
|
||||
*/
|
||||
tryConsume(deviceId: string, value: string): boolean {
|
||||
if (this.#armedDeviceId == null) return false;
|
||||
if (Date.now() > this.#expiresAt) {
|
||||
// Window lapsed before a card was presented — disarm, mark expired.
|
||||
this.#lastExpiredDeviceId = this.#armedDeviceId;
|
||||
this.#armedDeviceId = null;
|
||||
this.#expiresAt = 0;
|
||||
return false;
|
||||
}
|
||||
if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader
|
||||
if (!value) return false;
|
||||
this.#captured = { deviceId, value, capturedAt: Date.now() };
|
||||
this.#armedDeviceId = null; // single-shot
|
||||
this.#expiresAt = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Current state for the booth form's poll. Lazily transitions armed→expired. */
|
||||
state(): CaptureState {
|
||||
if (this.#captured) return { status: "captured", ...this.#captured };
|
||||
if (this.#armedDeviceId != null) {
|
||||
if (Date.now() > this.#expiresAt) {
|
||||
this.#lastExpiredDeviceId = this.#armedDeviceId;
|
||||
this.#armedDeviceId = null;
|
||||
this.#expiresAt = 0;
|
||||
return { status: "expired", deviceId: this.#lastExpiredDeviceId };
|
||||
}
|
||||
return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt };
|
||||
}
|
||||
if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId };
|
||||
return { status: "idle" };
|
||||
}
|
||||
|
||||
/** Clear a consumed/expired result once the form has read it. */
|
||||
clear(): void {
|
||||
this.#captured = null;
|
||||
this.#lastExpiredDeviceId = null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { PrinterStatus } from "@parking/devices";
|
||||
import type { LedgerEventRow } from "@parking/db";
|
||||
|
||||
// Internal event bus for device-originated events (button presses, etc.).
|
||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||
@@ -8,22 +9,112 @@ import type { PrinterStatus } from "@parking/devices";
|
||||
|
||||
export interface DeviceInputEvent {
|
||||
readonly driverId: string; // e.g. "dingtian"
|
||||
readonly deviceId: string; // which configured device (lane_devices id)
|
||||
readonly deviceId: string; // which configured device (devices id)
|
||||
readonly input: number; // 1-based input/channel
|
||||
readonly edge: "on" | "off"; // active / inactive
|
||||
readonly at: string; // ISO-8601 (server receive time)
|
||||
readonly source: "push" | "poll";
|
||||
}
|
||||
|
||||
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
|
||||
// Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
|
||||
// mirrors IdentitySource. See parking-session.md.
|
||||
export interface DeviceReadEvent {
|
||||
readonly driverId: string;
|
||||
readonly deviceId: string; // devices id of the reader/scanner/camera
|
||||
readonly value: string; // the ticket id / plate / card number
|
||||
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||
/** The CONFIRMED physical channel the value arrived on, when the reader tags it
|
||||
* (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by
|
||||
* the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader
|
||||
* with no prefixes configured (channel unknown — flows must not assume). Lets the
|
||||
* subscription match refuse an OPTICAL decode claiming an RF credential (a printed
|
||||
* copy of a card's UID must not clone the card). */
|
||||
readonly channel?: "optical" | "rf";
|
||||
readonly at: string; // ISO-8601
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
||||
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
||||
* device. A fire-and-forget reader simply ignores it. See wiki/entities/dingtian-dt008-reader.md.
|
||||
*/
|
||||
export interface ReadOutcome {
|
||||
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||
readonly accepted: boolean;
|
||||
/** Which way it went, when known (subscription/exit infer this). */
|
||||
readonly direction?: "entry" | "exit";
|
||||
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||
export interface PrinterStatusEvent {
|
||||
readonly deviceId: string; // lane_devices id
|
||||
readonly lane: number;
|
||||
readonly deviceId: string; // devices id
|
||||
readonly driverId: string;
|
||||
readonly role?: string; // entry-dispenser | booth-receipt
|
||||
readonly status: PrinterStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unified live status of ANY configured device — what the booth footer shows.
|
||||
* Every enabled device is polled: printers via their rich `readStatus()`
|
||||
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
|
||||
* reachability probe. `state` is the common traffic-light; `detail` carries the
|
||||
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
|
||||
* and wiki/concepts/device-status-monitoring.md.
|
||||
*/
|
||||
export interface DeviceStatusEvent {
|
||||
readonly deviceId: string; // devices id
|
||||
readonly driverId: string;
|
||||
readonly category: "access" | "reader" | "camera" | "printer" | "vision";
|
||||
/**
|
||||
* The device's ROLE descriptor for the footer label — NOT the vendor. A
|
||||
* direction-style token the client localises and pairs with the category, so the
|
||||
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
|
||||
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
|
||||
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
|
||||
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
|
||||
* - undetermined: null (chip shows the category alone)
|
||||
*/
|
||||
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||||
readonly state: "ready" | "degraded" | "offline";
|
||||
readonly detail?: string;
|
||||
readonly checkedAt: string; // ISO-8601
|
||||
}
|
||||
|
||||
/** Lane occupancy from a camera's vehicle detection — a per-direction "busy/free"
|
||||
* the booth shows as barrier lights. ADVISORY ONLY: a detection is a hint, never a
|
||||
* gate (it never blocks a ticket or opens a barrier). "busy" is set by a vehicle
|
||||
* `active` event; it auto-clears to "free" after a timeout (this camera class sends
|
||||
* no leave/`inactive` signal — see wiki/entities/lpr-camera.md). */
|
||||
export interface LaneStatusEvent {
|
||||
readonly entry: boolean; // true = busy (a vehicle is at the entry vicinity)
|
||||
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
||||
}
|
||||
|
||||
/** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate
|
||||
* recognition is async/advisory (a vision round-trip off the snapshot), so it lands a
|
||||
* moment after the signed event — too late for the event's own WS push to carry it. This
|
||||
* notifies the booth so it can fill in the plate badge on the already-rendered feed row /
|
||||
* active session in place, no refresh. Advisory; never touches the signed ledger. See
|
||||
* snapshot.ts (recognizePlate) + event-enrich.ts. */
|
||||
export interface PlateRecognizedEvent {
|
||||
readonly identity: string; // the session identity the plate is tied to
|
||||
readonly plate: string; // normalized plate text (trimmed, upper)
|
||||
readonly direction: "entry" | "exit";
|
||||
}
|
||||
|
||||
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
|
||||
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
|
||||
export interface LanePresenceEvent {
|
||||
readonly entry: boolean; // true = a presence input on an entry barrier is active
|
||||
readonly exit: boolean; // true = a presence input on an exit barrier is active
|
||||
}
|
||||
|
||||
class DeviceEventBus extends EventEmitter {
|
||||
emitInput(event: DeviceInputEvent): void {
|
||||
this.emit("input", event);
|
||||
@@ -33,6 +124,15 @@ class DeviceEventBus extends EventEmitter {
|
||||
return () => this.off("input", cb);
|
||||
}
|
||||
|
||||
/** A credential read (ticket scan, plate, card). */
|
||||
emitRead(event: DeviceReadEvent): void {
|
||||
this.emit("read", event);
|
||||
}
|
||||
onRead(cb: (event: DeviceReadEvent) => void): () => void {
|
||||
this.on("read", cb);
|
||||
return () => this.off("read", cb);
|
||||
}
|
||||
|
||||
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
||||
emitPrinterStatus(event: PrinterStatusEvent): void {
|
||||
this.emit("printer-status", event);
|
||||
@@ -41,6 +141,62 @@ class DeviceEventBus extends EventEmitter {
|
||||
this.on("printer-status", cb);
|
||||
return () => this.off("printer-status", cb);
|
||||
}
|
||||
|
||||
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
|
||||
* (all categories — relays, readers, cameras, printers). Drives the booth
|
||||
* device-status footer over the WS. */
|
||||
emitDeviceStatus(event: DeviceStatusEvent): void {
|
||||
this.emit("device-status", event);
|
||||
}
|
||||
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
|
||||
this.on("device-status", cb);
|
||||
return () => this.off("device-status", cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
|
||||
* payment, void, …). The payload is the persisted row — business facts only, no
|
||||
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
|
||||
* This is a read-side notification ONLY: it never feeds back into append/sign/
|
||||
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
|
||||
*/
|
||||
emitLedger(event: LedgerEventRow): void {
|
||||
this.emit("ledger", event);
|
||||
}
|
||||
onLedger(cb: (event: LedgerEventRow) => void): () => void {
|
||||
this.on("ledger", cb);
|
||||
return () => this.off("ledger", cb);
|
||||
}
|
||||
|
||||
/** Emitted whenever a lane's busy/free state CHANGES (from camera vehicle
|
||||
* detection). Drives the booth's barrier lights. Advisory only. */
|
||||
emitLaneStatus(event: LaneStatusEvent): void {
|
||||
this.emit("lane-status", event);
|
||||
}
|
||||
onLaneStatus(cb: (event: LaneStatusEvent) => void): () => void {
|
||||
this.on("lane-status", cb);
|
||||
return () => this.off("lane-status", cb);
|
||||
}
|
||||
|
||||
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
|
||||
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
|
||||
emitLanePresence(event: LanePresenceEvent): void {
|
||||
this.emit("lane-presence", event);
|
||||
}
|
||||
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
|
||||
this.on("lane-presence", cb);
|
||||
return () => this.off("lane-presence", cb);
|
||||
}
|
||||
|
||||
/** Emitted when an async plate recognition completes for a session (after its event
|
||||
* already shipped). Lets the booth backfill the plate badge in place. Advisory only. */
|
||||
emitPlateRecognized(event: PlateRecognizedEvent): void {
|
||||
this.emit("plate-recognized", event);
|
||||
}
|
||||
onPlateRecognized(cb: (event: PlateRecognizedEvent) => void): () => void {
|
||||
this.on("plate-recognized", cb);
|
||||
return () => this.off("plate-recognized", cb);
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide device event bus. */
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { localIsoWithOffset } from "./device-monitor.js";
|
||||
|
||||
// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset
|
||||
// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of
|
||||
// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz.
|
||||
|
||||
describe("localIsoWithOffset (camera clock sync payload)", () => {
|
||||
it("Tirane summer = +02:00 (CEST)", () => {
|
||||
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||
"2026-07-07T12:00:00+02:00",
|
||||
);
|
||||
});
|
||||
it("Tirane winter = +01:00 (CET)", () => {
|
||||
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe(
|
||||
"2026-01-15T11:00:00+01:00",
|
||||
);
|
||||
});
|
||||
it("UTC = +00:00", () => {
|
||||
expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||
"2026-07-07T10:00:00+00:00",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||
import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices";
|
||||
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
import { siteTz } from "./subscription-window.js";
|
||||
|
||||
/** Synthetic device id for the vision service in the status footer (it's a service,
|
||||
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
||||
const VISION_STATUS_ID = "vision-service";
|
||||
|
||||
// Unified live DEVICE monitor — the source for the booth's device-status footer.
|
||||
// Every enabled, configured device is probed on an interval, regardless of
|
||||
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
|
||||
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
|
||||
// generic healthCheck() reachability probe every Device implements. The result is
|
||||
// flattened to a common traffic-light (ready | degraded | offline) + a detail
|
||||
// string, cached per device id, and emitted on the bus ONLY when it changes.
|
||||
//
|
||||
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
|
||||
// and read-only — polling a device never drives a relay or mutates the ledger.
|
||||
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
|
||||
|
||||
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
||||
|
||||
// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970
|
||||
// epoch until a human logs into its web UI). The monitor re-syncs from the HOST
|
||||
// clock (the site's offline time authority) at the offline→ready edge — exactly the
|
||||
// power-restored moment — plus a daily backstop; drift under the threshold is left
|
||||
// alone. See wiki/entities/lpr-camera.md (clock sync).
|
||||
const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000;
|
||||
const CLOCK_MAX_DRIFT_SEC = 60;
|
||||
|
||||
/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00)
|
||||
* — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */
|
||||
export function localIsoWithOffset(tz: string, at = new Date()): string {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
|
||||
const wallAsUtcMs = Date.UTC(
|
||||
Number(p.year), Number(p.month) - 1, Number(p.day),
|
||||
Number(p.hour), Number(p.minute), Number(p.second),
|
||||
);
|
||||
const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000);
|
||||
const sign = offMin < 0 ? "-" : "+";
|
||||
const abs = Math.abs(offMin);
|
||||
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||
const mm = String(abs % 60).padStart(2, "0");
|
||||
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||
* tokens the client localises next to the category:
|
||||
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
|
||||
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
|
||||
* than one direction; null if it declares none yet
|
||||
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt)
|
||||
*/
|
||||
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||
switch (row.category) {
|
||||
case "reader":
|
||||
case "camera": {
|
||||
const d = directionOf(db, row); // entry | exit | both
|
||||
return d;
|
||||
}
|
||||
case "access": {
|
||||
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
|
||||
const dirs = new Set(
|
||||
relaysOf(row)
|
||||
.map((r) => r.direction)
|
||||
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
|
||||
);
|
||||
if (dirs.size === 0) return null;
|
||||
if (dirs.size > 1) return "mixed";
|
||||
const only = [...dirs][0]; // entry | exit | both
|
||||
return only ?? null;
|
||||
}
|
||||
case "printer": {
|
||||
const role = (row.config as { role?: string }).role;
|
||||
if (role === "booth-receipt") return "booth";
|
||||
if (role === "entry-dispenser") return "lane";
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceMonitor {
|
||||
readonly #db: Db;
|
||||
readonly #log: FastifyBaseLogger;
|
||||
readonly #pollMs: number;
|
||||
/** Latest unified status per device id. */
|
||||
readonly #latest = new Map<string, DeviceStatusEvent>();
|
||||
#timer: ReturnType<typeof setInterval> | null = null;
|
||||
#ticking = false;
|
||||
|
||||
/** Optional: the vision service client. When present + enabled, the monitor probes
|
||||
* its /health each tick and shows it as a "vision" chip in the footer. */
|
||||
readonly #vision: VisionClient | null;
|
||||
|
||||
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS, vision: VisionClient | null = null) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#pollMs = pollMs;
|
||||
this.#vision = vision;
|
||||
}
|
||||
|
||||
/** Begin polling. Idempotent. */
|
||||
start(): void {
|
||||
if (this.#timer) return;
|
||||
void this.#tick(); // immediate first pass so the footer fills without a wait
|
||||
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
|
||||
this.#timer.unref?.();
|
||||
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.#timer) {
|
||||
clearInterval(this.#timer);
|
||||
this.#timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current snapshot for the API / a freshly-connected WS client. */
|
||||
snapshot(): DeviceStatusEvent[] {
|
||||
return [...this.#latest.values()];
|
||||
}
|
||||
|
||||
async #tick(): Promise<void> {
|
||||
if (this.#ticking) return; // never overlap polls
|
||||
this.#ticking = true;
|
||||
try {
|
||||
// Re-read the device set each tick so a newly-assigned/removed device is
|
||||
// picked up without a restart.
|
||||
const rows = await this.#db.select().from(devices).all();
|
||||
const enabled = rows.filter((r) => r.enabled);
|
||||
const present = new Set(enabled.map((r) => r.id));
|
||||
|
||||
// The vision service is a pseudo-device — keep it in the present set when enabled
|
||||
// so the cleanup below doesn't evict it.
|
||||
if (this.#vision?.enabled) present.add(VISION_STATUS_ID);
|
||||
|
||||
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
|
||||
for (const id of [...this.#latest.keys()]) {
|
||||
if (!present.has(id)) this.#latest.delete(id);
|
||||
}
|
||||
|
||||
await Promise.all([...enabled.map((r) => this.#poll(r)), this.#pollVision()]);
|
||||
} catch (err) {
|
||||
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#ticking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async #poll(row: DeviceRow): Promise<void> {
|
||||
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||
const base = {
|
||||
deviceId: row.id,
|
||||
driverId: row.driverId,
|
||||
category: row.category,
|
||||
roleKind: roleKindOf(this.#db, row),
|
||||
};
|
||||
|
||||
let next: DeviceStatusEvent;
|
||||
let device: Device | null = null;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) {
|
||||
// Configured against a driver that's no longer registered — surface it,
|
||||
// don't silently hide it.
|
||||
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||
} else {
|
||||
try {
|
||||
device = driver.create(cfg as never);
|
||||
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||
if (isMonitorable(device)) {
|
||||
const s = await device.readStatus();
|
||||
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
|
||||
} else {
|
||||
const h = await device.healthCheck();
|
||||
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
} catch (err) {
|
||||
// A probe that throws (build error, timeout) reads as offline — never crash
|
||||
// the tick, and fail toward "there's a problem" rather than false-healthy.
|
||||
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
// Camera clock re-sync at the power-restored edge (prev offline/unknown →
|
||||
// ready) + a daily backstop. Stamped BEFORE the async attempt so a failing
|
||||
// camera is retried at backstop cadence, never every poll.
|
||||
if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) {
|
||||
const prev = this.#latest.get(row.id);
|
||||
const cameBack = !prev || prev.state === "offline";
|
||||
const last = this.#clockSyncedAt.get(row.id) ?? 0;
|
||||
if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) {
|
||||
this.#clockSyncedAt.set(row.id, Date.now());
|
||||
const cam = device;
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC);
|
||||
if (r.synced) {
|
||||
// A large jump is the 1970 power-cut signature — warn (persisted) so
|
||||
// the reboot stays visible; a small correction is routine info.
|
||||
const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`;
|
||||
if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg);
|
||||
else this.#log.info(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
this.#publish(row.id, next);
|
||||
}
|
||||
|
||||
/** Probe the vision service /health and publish it as a "vision" footer chip. Skipped
|
||||
* entirely when no client is wired or it's disabled (no chip then). */
|
||||
async #pollVision(): Promise<void> {
|
||||
if (!this.#vision?.enabled) return;
|
||||
const h = await this.#vision.health();
|
||||
const state: DeviceStatusEvent["state"] = h.ok && h.ready ? "ready" : h.ready ? "degraded" : "offline";
|
||||
this.#publish(VISION_STATUS_ID, {
|
||||
deviceId: VISION_STATUS_ID,
|
||||
driverId: "vision",
|
||||
category: "vision",
|
||||
roleKind: null,
|
||||
state,
|
||||
detail: h.ready ? h.recognizer : (h.detail ?? "not ready"),
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */
|
||||
readonly #clockSyncedAt = new Map<string, number>();
|
||||
|
||||
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
||||
#publish(id: string, next: DeviceStatusEvent): void {
|
||||
const prev = this.#latest.get(id);
|
||||
this.#latest.set(id, next);
|
||||
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
|
||||
this.#log.info(
|
||||
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
|
||||
);
|
||||
deviceEvents.emitDeviceStatus(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
|
||||
|
||||
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
|
||||
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
|
||||
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
|
||||
// either shape, so an exit radar = just another presence row.
|
||||
|
||||
let db: Db;
|
||||
const CTL = "ctl-1";
|
||||
|
||||
function seed(config: Record<string, unknown>): void {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
|
||||
}
|
||||
|
||||
describe("inputsOf back-compat synth", () => {
|
||||
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
|
||||
seed({
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
|
||||
{ relay: 2, direction: "exit" },
|
||||
],
|
||||
});
|
||||
const row = db.select().from(devices).get()!;
|
||||
const inputs = inputsOf(row);
|
||||
expect(inputs).toEqual([
|
||||
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
|
||||
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers an explicit inputs[] over the legacy fields", () => {
|
||||
seed({
|
||||
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
|
||||
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||
});
|
||||
const row = db.select().from(devices).get()!;
|
||||
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relayForButton / relayForPresence", () => {
|
||||
it("resolves a button + presence from inputs[]", () => {
|
||||
seed({
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: 1, role: "button", relay: 1 },
|
||||
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||
],
|
||||
});
|
||||
const byBtn = relayForButton(db, CTL, 1);
|
||||
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
|
||||
const byPres = relayForPresence(db, CTL, 2);
|
||||
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
|
||||
});
|
||||
|
||||
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
|
||||
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
|
||||
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
|
||||
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
|
||||
});
|
||||
|
||||
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
|
||||
seed({
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
],
|
||||
inputs: [
|
||||
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
|
||||
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
|
||||
],
|
||||
});
|
||||
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
|
||||
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
|
||||
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
|
||||
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
|
||||
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
|
||||
});
|
||||
|
||||
it("a button on an exit-only relay is not a transient-entry trigger", () => {
|
||||
seed({
|
||||
relays: [{ relay: 2, direction: "exit" }],
|
||||
inputs: [{ input: 1, role: "button", relay: 2 }],
|
||||
});
|
||||
expect(relayForButton(db, CTL, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { and, eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||
|
||||
// Device resolution for the pool-of-spaces model — NO lane. A parking lot is one
|
||||
// pool with a flexible set of entry/exit points. Direction lives on each RELAY
|
||||
// inside an access controller, and readers/cameras BIND to a (controller, relay).
|
||||
// See wiki/concepts/entry-exit-points.md.
|
||||
|
||||
/** A flow direction. "both" = one relay/barrier serving entry AND exit. */
|
||||
export type Direction = "entry" | "exit" | "both";
|
||||
/** A concrete flow a credential/button drives (never "both"). */
|
||||
export type FlowDirection = "entry" | "exit";
|
||||
|
||||
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
|
||||
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
|
||||
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
|
||||
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
|
||||
export type RelayEvent = Direction | "radarAlert";
|
||||
|
||||
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
|
||||
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
|
||||
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
|
||||
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||
|
||||
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
|
||||
* An exit radar is just another `presence` row serving the exit relay. */
|
||||
export interface InputSpec {
|
||||
/** 1-based input terminal the host reads. */
|
||||
readonly input: number;
|
||||
readonly role: InputRole;
|
||||
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
|
||||
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
|
||||
readonly relay?: number;
|
||||
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
|
||||
readonly kind?: "loop" | "radar";
|
||||
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
|
||||
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
|
||||
readonly activeLow?: boolean;
|
||||
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
|
||||
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
|
||||
readonly cooldownSec?: number;
|
||||
}
|
||||
|
||||
/** One relay on an access controller: the event it reacts to. Input wiring (button,
|
||||
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
|
||||
* (back-compat) but no longer written by the UI. */
|
||||
export interface RelaySpec {
|
||||
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||
readonly relay: number;
|
||||
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
|
||||
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
|
||||
readonly direction: RelayEvent;
|
||||
|
||||
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
|
||||
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
|
||||
readonly button?: number;
|
||||
readonly presenceInput?: number;
|
||||
readonly presenceKind?: "loop" | "radar";
|
||||
readonly presenceActiveLow?: boolean;
|
||||
readonly entryCooldownSec?: number;
|
||||
|
||||
// ── radarAlert-only (direction === "radarAlert") ──
|
||||
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
|
||||
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
|
||||
// camera lane status: blink while the trigger is active + lane free, SOLID once the
|
||||
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
|
||||
/** 1-based input terminal whose active edge starts the blink (the radar). */
|
||||
readonly triggerInput?: number;
|
||||
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
|
||||
* "entry". An exit radar's lamp must lock on the EXIT camera. */
|
||||
readonly lockLane?: FlowDirection;
|
||||
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||
readonly blinkOnMs?: number;
|
||||
readonly blinkOffMs?: number;
|
||||
}
|
||||
|
||||
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
||||
interface AccessConfig {
|
||||
readonly relays?: RelaySpec[];
|
||||
readonly inputs?: InputSpec[];
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Reader/camera config: optional binding to a controller relay. */
|
||||
interface BoundConfig {
|
||||
/** The access `devices.id` this reader/camera sits at. */
|
||||
readonly controllerId?: string;
|
||||
/** The relay on that controller it opens. */
|
||||
readonly relay?: number;
|
||||
/** Fallback direction when not bound to a relay. */
|
||||
readonly direction?: Direction;
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
/** A resolved barrier: the controller row + the specific relay to pulse. Carries the
|
||||
* transient-entry anti-double-press config (presence loop / cooldown) when resolved
|
||||
* from a button press, so the entry flow can enforce one-car-one-ticket. */
|
||||
export interface ResolvedRelay {
|
||||
readonly controller: DeviceRow;
|
||||
readonly relay: number;
|
||||
readonly direction: Direction;
|
||||
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||
readonly presenceInput?: number;
|
||||
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
|
||||
readonly presenceKind?: "loop" | "radar";
|
||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
|
||||
readonly entryCooldownSec?: number;
|
||||
}
|
||||
|
||||
/** All enabled access controller rows. */
|
||||
function accessRows(db: Db): DeviceRow[] {
|
||||
return db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(eq(devices.category, "access"))
|
||||
.all()
|
||||
.filter((r) => r.enabled);
|
||||
}
|
||||
|
||||
/** The relay specs declared on an access controller (defaults to none). */
|
||||
export function relaysOf(row: DeviceRow): RelaySpec[] {
|
||||
const cfg = row.config as AccessConfig;
|
||||
return Array.isArray(cfg.relays) ? cfg.relays : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
|
||||
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
|
||||
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
|
||||
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
|
||||
* inputs goes through here, so the legacy fold lives in exactly one place.
|
||||
*/
|
||||
export function inputsOf(row: DeviceRow): InputSpec[] {
|
||||
const cfg = row.config as AccessConfig;
|
||||
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
|
||||
const synth: InputSpec[] = [];
|
||||
for (const r of relaysOf(row)) {
|
||||
if (typeof r.button === "number") {
|
||||
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||
}
|
||||
if (typeof r.presenceInput === "number") {
|
||||
synth.push({
|
||||
input: r.presenceInput,
|
||||
role: "presence",
|
||||
relay: r.relay,
|
||||
kind: r.presenceKind ?? "loop",
|
||||
activeLow: r.presenceActiveLow,
|
||||
});
|
||||
}
|
||||
}
|
||||
return synth;
|
||||
}
|
||||
|
||||
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
|
||||
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
|
||||
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
|
||||
if (typeof spec.relay !== "number") return null;
|
||||
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
|
||||
if (!relay) return null;
|
||||
if (relay.direction !== "entry" && relay.direction !== "both") return null;
|
||||
return { ...relay, direction: relay.direction };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a button press to the relay it fires: the access controller with this deviceId,
|
||||
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
|
||||
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
|
||||
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
|
||||
* Returns null otherwise.
|
||||
*/
|
||||
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const inputs = inputsOf(row);
|
||||
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
|
||||
if (!btn) return null;
|
||||
const relay = barrierForInput(row, btn);
|
||||
if (!relay) return null;
|
||||
// The presence sensor (if any) serving the SAME relay supplies the gate.
|
||||
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
|
||||
return {
|
||||
controller: row,
|
||||
relay: relay.relay,
|
||||
direction: relay.direction,
|
||||
presenceInput: presence?.input,
|
||||
presenceKind: presence?.kind ?? "loop",
|
||||
entryCooldownSec: btn.cooldownSec,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
|
||||
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
|
||||
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
|
||||
* per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||
*/
|
||||
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||
if (!presence) return null;
|
||||
const relay = barrierForInput(row, presence);
|
||||
if (!relay) return null;
|
||||
return {
|
||||
controller: row,
|
||||
relay: relay.relay,
|
||||
direction: relay.direction,
|
||||
presenceInput: presence.input,
|
||||
presenceKind: presence.kind ?? "loop",
|
||||
};
|
||||
}
|
||||
|
||||
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
|
||||
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
|
||||
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
||||
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
||||
}
|
||||
|
||||
/**
|
||||
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
|
||||
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
|
||||
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
|
||||
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
|
||||
*/
|
||||
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||
if (!presence || typeof presence.relay !== "number") return null;
|
||||
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
|
||||
if (!relay) return null;
|
||||
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
|
||||
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
|
||||
* spec. Fallback (unbound): the device's config.direction + the first relay site-
|
||||
* wide matching that direction — keeps the single-barrier case trivial. Null if
|
||||
* nothing resolves (no barrier to open).
|
||||
*/
|
||||
export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | null {
|
||||
const cfg = deviceRow.config as BoundConfig;
|
||||
|
||||
// Bound: follow controllerId + relay to the exact barrier.
|
||||
if (cfg.controllerId && typeof cfg.relay === "number") {
|
||||
const controller = db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(and(eq(devices.id, cfg.controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (controller && controller.enabled) {
|
||||
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
|
||||
if (spec && spec.direction !== "radarAlert") {
|
||||
return { controller, relay: spec.relay, direction: spec.direction };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unbound: fall back to the device's declared direction + first matching relay.
|
||||
const want = cfg.direction;
|
||||
if (want === "entry" || want === "exit" || want === "both") {
|
||||
return firstRelayByDirection(db, want === "both" ? "entry" : want);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first relay site-wide serving a direction ("both" relays match either).
|
||||
* Used as the unbound fallback and where a flow only needs "an exit barrier".
|
||||
*/
|
||||
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||
for (const controller of accessRows(db)) {
|
||||
const spec = relaysOf(controller).find(
|
||||
(r): r is RelaySpec & { direction: Direction } =>
|
||||
r.direction === direction || r.direction === "both",
|
||||
);
|
||||
if (spec) {
|
||||
// Attach the presence sensor (if any) serving the SAME relay, so callers that gate on
|
||||
// presence (the operator-issued entry) see it. Without this the ResolvedRelay carried
|
||||
// no presenceInput and the presence gate read as "unavailable". Mirrors relayForButton.
|
||||
const presence = inputsOf(controller).find((i) => i.role === "presence" && i.relay === spec.relay);
|
||||
return {
|
||||
controller,
|
||||
relay: spec.relay,
|
||||
direction: spec.direction,
|
||||
presenceInput: presence?.input,
|
||||
presenceKind: presence?.kind ?? "loop",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Enabled devices of a category whose direction matches `want` (or is "both").
|
||||
* Direction is inherited from each device's bound relay, else its config fallback.
|
||||
* Used for snapshots: every entry/exit camera fires on an entry/exit. */
|
||||
export function devicesByDirection(
|
||||
db: Db,
|
||||
category: DeviceRow["category"],
|
||||
want: FlowDirection,
|
||||
): DeviceRow[] {
|
||||
return db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(eq(devices.category, category))
|
||||
.all()
|
||||
.filter((r) => {
|
||||
if (!r.enabled) return false;
|
||||
const d = directionOf(db, r);
|
||||
return d === want || d === "both";
|
||||
});
|
||||
}
|
||||
|
||||
/** The direction a reader/camera operates in (inherited from its bound relay, or
|
||||
* its config fallback). "both" when undetermined → the flow infers. */
|
||||
export function directionOf(db: Db, deviceRow: DeviceRow): Direction {
|
||||
const resolved = relayForDevice(db, deviceRow);
|
||||
if (resolved) return resolved.direction;
|
||||
const cfg = deviceRow.config as BoundConfig;
|
||||
return cfg.direction === "entry" || cfg.direction === "exit" ? cfg.direction : "both";
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { deviceEvents as deviceEventsTable, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { flagDuplicateEntryPlate } from "./snapshot.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// Entry-side duplicate-plate reconciliation (2026-07-04): when ANPR recognizes a plate on
|
||||
// a fresh transient entry and that plate is already OPEN under another RECENT session,
|
||||
// the same car most likely minted a second ticket (a motion radar dropped the stationary
|
||||
// car → the button re-armed). We sign ONE entry.duplicatePlate anomaly for the operator
|
||||
// to void. Post-hoc + advisory: recognition never gates the (already-open) barrier —
|
||||
// exactly the non-blocking role the plate can play here.
|
||||
|
||||
let db: Db;
|
||||
let log: EventLog;
|
||||
|
||||
const PLATE = "AA111BB";
|
||||
const OLD = "11111111111";
|
||||
const NEW = "22222222222";
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
log = makeLog(db);
|
||||
});
|
||||
|
||||
/** Seed the prior entry's unsigned plate-read telemetry (what recognizePlate records). */
|
||||
function seedPriorRead(opts: { identity?: string; plate?: string; direction?: string; agoMs?: number } = {}) {
|
||||
db.insert(deviceEventsTable).values({
|
||||
id: randomUUID(),
|
||||
deviceId: "cam-entry",
|
||||
category: "camera",
|
||||
kind: "read",
|
||||
detail: {
|
||||
identity: opts.identity ?? OLD,
|
||||
direction: opts.direction ?? "entry",
|
||||
plate: opts.plate ?? PLATE,
|
||||
snapshotId: "snap-old",
|
||||
source: "entry-exit-snapshot",
|
||||
},
|
||||
occurredAt: new Date(Date.now() - (opts.agoMs ?? 60_000)).toISOString(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
function seedSession(id: string, state: "open" | "closed") {
|
||||
db.insert(sessions).values({
|
||||
id,
|
||||
identity: id,
|
||||
source: "ticket",
|
||||
enteredAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
state,
|
||||
}).run();
|
||||
}
|
||||
|
||||
const flag = () =>
|
||||
flagDuplicateEntryPlate({ db, log, identity: NEW, plate: PLATE, snapshotId: "snap-new", logger: silentLogger() });
|
||||
|
||||
const anomalies = () =>
|
||||
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||
|
||||
describe("flagDuplicateEntryPlate", () => {
|
||||
it("same plate OPEN under another recent session → signs ONE entry.duplicatePlate anomaly", async () => {
|
||||
seedPriorRead();
|
||||
seedSession(OLD, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(1);
|
||||
const a = anomalies()[0];
|
||||
expect(a.identity).toBe(NEW); // keyed to the NEW (suspect) ticket
|
||||
expect(a.payload).toMatchObject({
|
||||
reasonCode: "entry.duplicatePlate",
|
||||
duplicateEntrySuspected: true,
|
||||
plate: PLATE,
|
||||
otherIdentity: OLD,
|
||||
snapshotId: "snap-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("prior session already CLOSED → no anomaly (that car drove off; a re-visit is legit)", async () => {
|
||||
seedPriorRead();
|
||||
seedSession(OLD, "closed");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("prior read outside the window → no anomaly (stale coincidence, not a double press)", async () => {
|
||||
seedPriorRead({ agoMs: 30 * 60_000 }); // beyond the 15-min default window
|
||||
seedSession(OLD, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("own read (same identity) never flags itself", async () => {
|
||||
seedPriorRead({ identity: NEW });
|
||||
seedSession(NEW, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("different plate / exit-side reads are ignored", async () => {
|
||||
seedPriorRead({ plate: "ZZ999ZZ" });
|
||||
seedPriorRead({ direction: "exit" });
|
||||
seedSession(OLD, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateTicketCode } from "./entry-flow.js";
|
||||
|
||||
// validateTicketCode is the manual-entry typo guard: an all-digit code whose last digit
|
||||
// is the Luhn check of the rest. The booth uses it to reject a mistyped ticket up front
|
||||
// (instead of a confusing "session not found"). The capacity-gate / print-hold / sign-
|
||||
// before-open paths of EntryFlow need device fakes and are exercised in the device +
|
||||
// route phases; here we pin the pure, exported checksum contract.
|
||||
|
||||
describe("validateTicketCode (Luhn)", () => {
|
||||
it("accepts a well-formed 11-digit id", () => {
|
||||
// 10-digit body + its Luhn check digit. 0000000000 → check digit 0.
|
||||
expect(validateTicketCode("00000000000")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a single-digit typo", () => {
|
||||
expect(validateTicketCode("00000000000")).toBe(true);
|
||||
expect(validateTicketCode("00000000010")).toBe(false); // flipped a digit, checksum now wrong
|
||||
});
|
||||
|
||||
it("rejects non-digit and out-of-length strings", () => {
|
||||
expect(validateTicketCode("abc")).toBe(false);
|
||||
expect(validateTicketCode("123")).toBe(false); // too short
|
||||
expect(validateTicketCode("123456789012345")).toBe(false); // too long
|
||||
expect(validateTicketCode("")).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips a generated body+check (Luhn is self-consistent)", () => {
|
||||
// Construct a valid code: pick a body, compute its check the same way the issuer does.
|
||||
const body = "4992739871";
|
||||
// brute the check digit 0..9 — exactly one makes a valid code.
|
||||
const valid = Array.from({ length: 10 }, (_, d) => body + d).filter(validateTicketCode);
|
||||
expect(valid).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts a legacy 13-digit id shape", () => {
|
||||
// 12-digit body 000000000000 → check 0; the validator is length-agnostic in 10..14.
|
||||
expect(validateTicketCode("0000000000000")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,613 @@
|
||||
import { randomInt, randomUUID } from "node:crypto";
|
||||
import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||
import {
|
||||
NoPrinterAvailableError,
|
||||
printWithFailover,
|
||||
registry,
|
||||
type AccessControlDevice,
|
||||
type PrinterDevice,
|
||||
type PrinterInstance,
|
||||
type TicketData,
|
||||
type TicketHeader,
|
||||
} from "@parking/devices";
|
||||
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent, LaneStatusEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
|
||||
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
|
||||
// → open the barrier. The button is wired into an access controller's input; the
|
||||
// admin maps that input terminal to a relay (config.relays[].button), so a press
|
||||
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
|
||||
//
|
||||
// Two invariants from the threat model + safety analysis:
|
||||
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
|
||||
// BEFORE pulseOpen fires; an open with no matching signed event is the fraud
|
||||
// signal (wiki/concepts/append-only-event-chain.md).
|
||||
// 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if
|
||||
// all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket
|
||||
// unprinted) and leave the barrier closed; the operator handles the held car.
|
||||
// Crucially, NO vehicle_entry is written in that case — we never record an
|
||||
// "entered" event for a car that didn't get in (decision 2026-06-15).
|
||||
//
|
||||
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
|
||||
// (fail) sign anomaly, stop.
|
||||
//
|
||||
// ONE CAR = ONE TICKET (anti-double-press). The entry button can be physically held
|
||||
// or mashed; without a guard each press mints a fresh ticket + signed vehicle_entry
|
||||
// (corrupting occupancy and letting a transient shop the cheapest ticket at exit). The
|
||||
// guard is per-relay and CONFIGURED on the relay spec (config.relays[]), chosen by what
|
||||
// barrier feedback exists at the lane:
|
||||
// - PRESENCE loop (preferred): `presenceInput` ties ticketing to a real vehicle. A
|
||||
// press prints only while a car is present, and NO second ticket issues until the
|
||||
// loop CLEARS (car drove in) and a new car re-occupies it. We observe the loop's
|
||||
// input edges to track presence + "armed" per relay.
|
||||
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
||||
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
||||
// When a loop IS wired the cooldown still runs as a BACKSTOP behind it: a motion
|
||||
// radar can drop a STATIONARY car (no doppler return) and spuriously re-arm, and the
|
||||
// cooldown bounds how fast that re-armed press can mint a second ticket.
|
||||
// - CAMERA (when an entry camera is configured): a press is live only while the entry
|
||||
// lane camera confirms a vehicle — the button lamp's SOLID state (button-light.ts).
|
||||
// A radar false-positive (rain, a pedestrian) blinks the lamp but prints nothing.
|
||||
// Camera-less sites keep the radar-only gate; a faulty camera is dropped via the
|
||||
// admin bypass (wiki/concepts/entry-presence-bypass.md).
|
||||
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
||||
// See wiki/concepts/entry-double-press.md.
|
||||
|
||||
/** A presence signal the entry gate can require (or, when a device is faulty, the admin
|
||||
* can bypass): the radar/loop presence input, or the camera vehicle-detection. */
|
||||
export type PresenceSignal = "radar" | "camera";
|
||||
|
||||
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
|
||||
interface RelayGuardState {
|
||||
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
|
||||
lastTicketAt: number;
|
||||
/** PRESENCE mode: is a vehicle currently on the loop? (from loop input edges) */
|
||||
present: boolean;
|
||||
/** PRESENCE mode: ready to issue a ticket for a NEW car. Set false after a ticket
|
||||
* prints; re-armed when the loop CLEARS (the car drove through). */
|
||||
armed: boolean;
|
||||
}
|
||||
|
||||
export class EntryFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
/** Guard against double-fire from the same physical press (on edge only). */
|
||||
readonly #inFlight = new Set<string>();
|
||||
/** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */
|
||||
readonly #guard = new Map<string, RelayGuardState>();
|
||||
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
|
||||
readonly #vision: VisionClient | null;
|
||||
/** Live entry-lane camera state (LaneStatus mirror, fed by onLaneStatus). Gates the
|
||||
* physical press when an entry camera is configured — advisory sensor, but here it
|
||||
* only ever SUPPRESSES a reprint; it never opens a barrier or traps a car. */
|
||||
#entryBusy = false;
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
this.#vision = vision;
|
||||
}
|
||||
|
||||
/** Handle a device input edge. Two kinds of edge matter to this flow:
|
||||
* (1) an ENTRY BUTTON press (rising edge) → run entry, subject to the per-relay
|
||||
* anti-double-press guard; (2) a PRESENCE LOOP edge (either direction) → update
|
||||
* presence state so the guard knows when a car arrives/leaves. The same physical
|
||||
* input is never both, so we resolve each independently. */
|
||||
async onInput(e: DeviceInputEvent): Promise<void> {
|
||||
// Presence-loop edge (both directions matter): keep the per-relay state current.
|
||||
const presence = relayForPresence(this.#db, e.deviceId, e.input);
|
||||
if (presence) {
|
||||
this.#onPresenceEdge(presence, e.edge);
|
||||
return; // a loop input is not a button — nothing else to do
|
||||
}
|
||||
|
||||
if (e.edge !== "on") return; // for buttons, the release edge is just telemetry
|
||||
|
||||
// The firing device must be an access controller, and the pressed input terminal
|
||||
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
|
||||
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
|
||||
const resolved = relayForButton(this.#db, e.deviceId, e.input);
|
||||
if (!resolved) return;
|
||||
|
||||
// ANTI-DOUBLE-PRESS: is this press allowed to issue a ticket? (presence/cooldown)
|
||||
const suppressed = this.#suppressReason(resolved);
|
||||
if (suppressed) {
|
||||
this.#recordSuppressedPress(e, resolved, suppressed);
|
||||
this.#logger.info(`entry press suppressed (${this.#relayKey(resolved)}): ${suppressed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${e.deviceId}:${e.input}`;
|
||||
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
await this.#runEntry(resolved);
|
||||
} catch (err) {
|
||||
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Track the entry lane's camera state (wired to deviceEvents.onLaneStatus in
|
||||
* server.ts). LaneStatus emits on every flip, so this mirror stays current. */
|
||||
onLaneStatus(s: LaneStatusEvent): void {
|
||||
this.#entryBusy = s.entry;
|
||||
}
|
||||
|
||||
/** Stable per-relay key for the guard map. */
|
||||
#relayKey(r: ResolvedRelay): string {
|
||||
return `${r.controller.id}:${r.relay}`;
|
||||
}
|
||||
|
||||
/** Lazily get (or create) the guard state for a relay. New relays start ARMED and
|
||||
* with no car present, so the first press on a fresh lane works immediately. */
|
||||
#guardState(r: ResolvedRelay): RelayGuardState {
|
||||
const key = this.#relayKey(r);
|
||||
let s = this.#guard.get(key);
|
||||
if (!s) {
|
||||
s = { lastTicketAt: 0, present: false, armed: true };
|
||||
this.#guard.set(key, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Apply a presence-loop edge to a relay's state. The car ARRIVING re-arms ticketing;
|
||||
* the car LEAVING the loop (after its entry) re-arms for the NEXT car. */
|
||||
#onPresenceEdge(r: ResolvedRelay, edge: "on" | "off"): void {
|
||||
const s = this.#guardState(r);
|
||||
if (edge === "on") {
|
||||
s.present = true; // a vehicle is at the barrier
|
||||
} else {
|
||||
// Loop cleared: the car drove through (or backed off). Re-arm for the next car —
|
||||
// this is the gate that makes a *new* car necessary before another ticket.
|
||||
s.present = false;
|
||||
s.armed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
||||
* Three layered gates: CAMERA (when an entry camera is configured), PRESENCE
|
||||
* (when a loop is wired), and COOLDOWN — no longer alternatives: the cooldown
|
||||
* runs as a backstop BEHIND presence, because a motion radar can drop a
|
||||
* stationary car and spuriously re-arm one-car-one-ticket. */
|
||||
#suppressReason(r: ResolvedRelay): string | null {
|
||||
const s = this.#guardState(r);
|
||||
const bypass = this.#presenceBypass();
|
||||
|
||||
// CAMERA GATE — the lamp's blink-vs-solid rule, enforced at the press: with an entry
|
||||
// camera configured, a press is live only once the camera confirms a vehicle in the
|
||||
// entry zone (SOLID). Blink (radar-only — rain, a pedestrian, a reflection) prints
|
||||
// nothing. Only ever suppresses a ticket; never opens or traps (advisory rule kept).
|
||||
// A camera-less site skips this; a faulty camera is dropped via the admin bypass.
|
||||
if (!bypass.camera && !this.#entryBusy && this.#entryCameraConfigured()) {
|
||||
return "no camera-confirmed vehicle in the entry zone";
|
||||
}
|
||||
|
||||
// Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints.
|
||||
// A dead loop can't re-arm one-car-one-ticket, so the cooldown below is what stops a
|
||||
// held button minting a burst. If no cooldown is configured there's no anti-double-press
|
||||
// left — that's the admin's accepted tradeoff while bypassed. See
|
||||
// wiki/concepts/entry-presence-bypass.md.
|
||||
if (typeof r.presenceInput === "number" && !bypass.radar) {
|
||||
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
||||
// ticket already issued for this still-present car).
|
||||
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
||||
if (!s.armed) return "ticket already issued for the car at the barrier";
|
||||
// Fall THROUGH to the cooldown backstop: a presence-approved press can still be the
|
||||
// SAME stationary car after a radar dropout re-armed the guard.
|
||||
}
|
||||
|
||||
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
||||
const elapsed = Date.now() - s.lastTicketAt;
|
||||
if (elapsed < r.entryCooldownSec * 1000) {
|
||||
const remain = Math.ceil((r.entryCooldownSec * 1000 - elapsed) / 1000);
|
||||
return `within ${r.entryCooldownSec}s entry cooldown (${remain}s left)`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Is at least one enabled camera bound to the entry lane? The camera gate applies only
|
||||
* then — a site with no entry camera keeps the radar-only press gate. Read live (like
|
||||
* the bypass flags) so adding/removing a camera needs no restart. */
|
||||
#entryCameraConfigured(): boolean {
|
||||
return devicesByDirection(this.#db, "camera", "entry").length > 0;
|
||||
}
|
||||
|
||||
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
||||
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
||||
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
||||
try {
|
||||
this.#db
|
||||
.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId: e.deviceId,
|
||||
category: "access",
|
||||
kind: "input",
|
||||
detail: {
|
||||
driverId: e.driverId,
|
||||
input: e.input,
|
||||
edge: e.edge,
|
||||
entrySuppressed: true,
|
||||
relay: r.relay,
|
||||
reason,
|
||||
},
|
||||
occurredAt: e.at,
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`suppressed-press telemetry insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
||||
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||
// no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
|
||||
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
||||
// they aren't locked out. "Full" is a soft policy seam for valet over-
|
||||
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||
const occ = getOccupancy(this.#db);
|
||||
if (occ.full) {
|
||||
// No ticket id exists for a refused entry, so mint a synthetic ref to key the
|
||||
// anomaly + its evidence snapshot together. The operator wants the photo of WHO
|
||||
// was turned away (a fraud/dispute signal), so we still fire the entry camera.
|
||||
const refusedRef = `REFUSED-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: refusedRef,
|
||||
payload: {
|
||||
...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }),
|
||||
entryRefused: true,
|
||||
full: true,
|
||||
},
|
||||
});
|
||||
this.#fireSnapshot("entry", refusedRef);
|
||||
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#issueTicket(resolved, { source: "ticket" });
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared "issue a transient ticket" sequence used by BOTH the physical button
|
||||
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
|
||||
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
|
||||
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
|
||||
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
|
||||
* signed entry AND append a companion `anomaly` (the operator-adversary path always
|
||||
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
|
||||
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
|
||||
*/
|
||||
async #issueTicket(
|
||||
resolved: ResolvedRelay,
|
||||
opts: {
|
||||
source: "ticket" | "manual";
|
||||
operator?: string;
|
||||
overCapacity?: { count: number; capacity: number | null };
|
||||
/** Presence signals that were BYPASSED (admin dropped them due to faulty hardware).
|
||||
* Recorded on the signed entry so a ticket issued under a weakened gate is auditable. */
|
||||
presenceBypassed?: PresenceSignal[];
|
||||
},
|
||||
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
|
||||
const ticketId = newTicketId();
|
||||
const issuedAt = new Date().toISOString();
|
||||
const printers = this.#loadPrinters();
|
||||
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
|
||||
// + operatorInitiated:true in the payload. The button path is source "ticket".
|
||||
const operatorInitiated = opts.source === "manual";
|
||||
|
||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||
try {
|
||||
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||
d.printTicket(ticket),
|
||||
);
|
||||
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
|
||||
// ONE CAR = ONE TICKET: a ticket is now out for the car at this barrier. Disarm +
|
||||
// stamp the cooldown so a repeat press (held button / mashing) issues no second
|
||||
// ticket. PRESENCE mode re-arms when the loop clears (car drove in); COOLDOWN mode
|
||||
// re-allows after entryCooldownSec. Done on the print success, NOT the open.
|
||||
const guard = this.#guardState(resolved);
|
||||
guard.lastTicketAt = Date.now();
|
||||
guard.armed = false;
|
||||
} catch (err) {
|
||||
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
|
||||
// failed attempt is in the tamper-evident record for the operator.
|
||||
const reason =
|
||||
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: ticketId,
|
||||
payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false },
|
||||
});
|
||||
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||
// later reprices the same way at exit. Today every transient takes the SITE
|
||||
// default category (operator policy, site_config.default_vehicle_category;
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
|
||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const category =
|
||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||
? cfg.defaultVehicleCategory
|
||||
: DEFAULT_VEHICLE_CATEGORY;
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
direction: "entry",
|
||||
source: opts.source,
|
||||
identity: ticketId,
|
||||
payload: {
|
||||
sessionRef: ticketId,
|
||||
ticketPrinted: true,
|
||||
category,
|
||||
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
|
||||
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
|
||||
...(opts.presenceBypassed && opts.presenceBypassed.length > 0
|
||||
? { presenceBypassed: opts.presenceBypassed }
|
||||
: {}),
|
||||
},
|
||||
occurredAt: issuedAt,
|
||||
});
|
||||
|
||||
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
|
||||
// always leaves a red-flag row in the tamper-evident record for reconciliation.
|
||||
if (operatorInitiated) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: ticketId,
|
||||
payload: {
|
||||
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
|
||||
source: "booth",
|
||||
operatorInitiated: true,
|
||||
...(opts.operator ? { operator: opts.operator } : {}),
|
||||
...(opts.overCapacity ? { lotFull: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
let opened = false;
|
||||
if (access) {
|
||||
await access.pulseOpen(resolved.relay);
|
||||
opened = true;
|
||||
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||
|
||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
|
||||
// camera failure must not delay or block the already-open barrier). This is ALSO
|
||||
// what records the plate that plate-reconciliation reads at exit.
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
||||
}
|
||||
return { ok: true, ticketId, opened };
|
||||
}
|
||||
|
||||
/**
|
||||
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
|
||||
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
|
||||
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
|
||||
* presence is this flow's own per-relay guard state. If a site has no presence loop the
|
||||
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
|
||||
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
|
||||
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
|
||||
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
|
||||
* See wiki/concepts/operator-issued-entry.md.
|
||||
*/
|
||||
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
|
||||
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
|
||||
> {
|
||||
const resolved = firstRelayByDirection(this.#db, "entry");
|
||||
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
|
||||
|
||||
// PRESENCE GATE — normally require BOTH radar/loop presence AND camera detection. An
|
||||
// admin may BYPASS a signal when its device is faulty (site_config, signed config_change);
|
||||
// the bypassed signal is dropped as a requirement and RECORDED on the issued ticket.
|
||||
const bypass = this.#presenceBypass();
|
||||
const bypassed: PresenceSignal[] = [];
|
||||
|
||||
// Radar/loop side. A configured loop is only mandatory while radar is still REQUIRED;
|
||||
// if radar is bypassed we skip the loop entirely (a dead loop is exactly why they bypass).
|
||||
const radarRequired = !bypass.radar;
|
||||
let radarPresent: boolean | null = null;
|
||||
if (radarRequired) {
|
||||
if (typeof resolved.presenceInput !== "number") {
|
||||
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable (or bypass radar)" };
|
||||
}
|
||||
radarPresent = this.#guardState(resolved).present;
|
||||
} else {
|
||||
bypassed.push("radar");
|
||||
}
|
||||
|
||||
// Camera side.
|
||||
const cameraRequired = !bypass.camera;
|
||||
if (!cameraRequired) bypassed.push("camera");
|
||||
|
||||
// Refuse only when a STILL-REQUIRED signal fails to confirm a vehicle.
|
||||
const radarOk = !radarRequired || radarPresent === true;
|
||||
const cameraOk = !cameraRequired || cameraBusy;
|
||||
if (!radarOk || !cameraOk) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
||||
payload: {
|
||||
...reasonPayload("entry.issue.noPresence", { operator }),
|
||||
source: "booth",
|
||||
operator,
|
||||
radarPresent,
|
||||
cameraBusy,
|
||||
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||
},
|
||||
});
|
||||
this.#logger.warn(
|
||||
`operator entry refused by ${operator}: no vehicle present (radar=${radarPresent}, camera=${cameraBusy}, bypassed=[${bypassed.join(",")}])`,
|
||||
);
|
||||
return { ok: false, reason: "no vehicle detected at the entry" };
|
||||
}
|
||||
|
||||
const key = `operator-issue:${this.#relayKey(resolved)}`;
|
||||
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
const occ = getOccupancy(this.#db);
|
||||
const res = await this.#issueTicket(resolved, {
|
||||
source: "manual",
|
||||
operator,
|
||||
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
|
||||
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||
});
|
||||
if (!res.ok) return res;
|
||||
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
||||
* held car is exactly when the operator wants the photo. */
|
||||
#fireSnapshot(direction: "entry", identity: string): void {
|
||||
// `log` lets the ANPR ride-along flag a duplicate-plate entry (a signed anomaly) —
|
||||
// still fire-and-forget; recognition never gates the open. See snapshot.ts.
|
||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision, log: this.#log }).catch(
|
||||
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Current admin presence-gate bypass (site_config), read LIVE so a toggle takes effect
|
||||
* with no restart. Default: nothing bypassed (the normal both-required gate). */
|
||||
#presenceBypass(): { radar: boolean; camera: boolean } {
|
||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return { radar: cfg?.bypassPresenceRadar ?? false, camera: cfg?.bypassPresenceCamera ?? false };
|
||||
}
|
||||
|
||||
/** Build a live access adapter from a resolved controller row, or null. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(row.config as never) as AccessControlDevice;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build live ENTRY printer instances (for failover selection). */
|
||||
#loadPrinters(): PrinterInstance[] {
|
||||
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
|
||||
const out: PrinterInstance[] = [];
|
||||
for (const row of rows) {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
role,
|
||||
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||
device: driver.create(cfg as never) as PrinterDevice,
|
||||
});
|
||||
} catch {
|
||||
// skip a printer whose config won't build
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Park identity for the ticket header, from site_config (all fields optional;
|
||||
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
|
||||
#ticketHeader(): TicketHeader | undefined {
|
||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
parkName: row.parkName,
|
||||
operatorName: row.operatorName,
|
||||
nius: row.nius,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
|
||||
*
|
||||
* Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check
|
||||
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
|
||||
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
|
||||
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
|
||||
* — the anti-fraud property the wiki settles.
|
||||
*
|
||||
* Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the
|
||||
* Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN
|
||||
* ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while
|
||||
* being two digits (≈2 barcode modules) narrower than the old 13. Collisions are
|
||||
* negligible at lot scale; the unique constraints on ledger_events.index / sessions.id
|
||||
* are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.)
|
||||
* The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of
|
||||
* failing as "session not found".
|
||||
*/
|
||||
function newTicketId(): string {
|
||||
let body = "";
|
||||
for (let i = 0; i < 10; i += 1) body += String(randomInt(10));
|
||||
return body + luhnCheckDigit(body);
|
||||
}
|
||||
|
||||
/** The Luhn (mod-10) check digit for an all-digit string. */
|
||||
function luhnCheckDigit(digits: string): string {
|
||||
let sum = 0;
|
||||
// Walk right-to-left; the check digit sits at position 0 from the right, so the
|
||||
// last body digit is an "even" position that gets doubled.
|
||||
let double = true;
|
||||
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
||||
let d = digits.charCodeAt(i) - 48;
|
||||
if (double) {
|
||||
d *= 2;
|
||||
if (d > 9) d -= 9;
|
||||
}
|
||||
sum += d;
|
||||
double = !double;
|
||||
}
|
||||
return String((10 - (sum % 10)) % 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
|
||||
* Lets a manual-entry path (operator types the code off the ticket when readers are
|
||||
* down) reject a typo up front. A scanned/looked-up id that predates this format
|
||||
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
|
||||
* never reject an id that already exists in the ledger. See ticket-encoding.md.
|
||||
*/
|
||||
export function validateTicketCode(code: string): boolean {
|
||||
// Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest.
|
||||
// Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation
|
||||
// (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps
|
||||
// a stray short/long string from being mistaken for a ticket. See ticket-encoding.md.
|
||||
if (!/^\d{10,14}$/.test(code)) return false;
|
||||
const body = code.slice(0, -1);
|
||||
return luhnCheckDigit(body) === code[code.length - 1];
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, siteConfig, ledgerEvents, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
|
||||
// The entry presence gate normally requires BOTH radar/loop presence AND camera detection.
|
||||
// An admin may BYPASS a signal when its device is faulty (site_config, set via a signed
|
||||
// endpoint). These tests pin the GATE decision in EntryFlow.issueForOperator under each
|
||||
// bypass combination: a still-required-but-absent signal refuses (+ signs an anomaly); a
|
||||
// bypassed signal is dropped and recorded. We assert the gate outcome via the refuse path
|
||||
// (deterministic, no printer needed); the allow path is proven by getting PAST the gate
|
||||
// (it then fails at printing — a different reason — which is exactly "the gate opened").
|
||||
|
||||
let db: Db;
|
||||
let flow: EntryFlow;
|
||||
|
||||
const CTL = "ctl-entry";
|
||||
const PRESENCE_INPUT = 2;
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
// A controller with an entry barrier (R1), a presence loop on input 2, and an entry button
|
||||
// on input 1 — the shape device-resolve expects (relays[] + inputs[]).
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: {
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: 1, role: "button", relay: 1 },
|
||||
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "loop" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||
});
|
||||
|
||||
function setBypass(patch: { radar?: boolean; camera?: boolean }) {
|
||||
db.insert(siteConfig)
|
||||
.values({ id: 1, bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false })
|
||||
.onConflictDoUpdate({
|
||||
target: siteConfig.id,
|
||||
set: { bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Drive a presence loop edge so the flow's per-relay guard marks a car present/clear. */
|
||||
async function setRadarPresent(present: boolean) {
|
||||
await flow.onInput({
|
||||
driverId: "stub-access",
|
||||
deviceId: CTL,
|
||||
input: PRESENCE_INPUT,
|
||||
edge: present ? "on" : "off",
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
});
|
||||
}
|
||||
|
||||
const anomalies = () =>
|
||||
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||
|
||||
describe("entry presence-gate bypass", () => {
|
||||
it("no bypass + no vehicle → refuses and signs a noPresence anomaly", async () => {
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(anomalies()).toHaveLength(1);
|
||||
expect(anomalies()[0].payload).toMatchObject({ reasonCode: "entry.issue.noPresence" });
|
||||
});
|
||||
|
||||
it("camera bypassed + radar present → gate OPENS (no refuse anomaly)", async () => {
|
||||
setBypass({ camera: true });
|
||||
await setRadarPresent(true);
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false); // camera absent but bypassed
|
||||
// Gate passed: no noPresence refusal. (It then proceeds to print — no printer configured,
|
||||
// so it HOLDS with a print reason, not a presence reason. Either way the gate opened.)
|
||||
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusals).toHaveLength(0);
|
||||
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||
});
|
||||
|
||||
it("radar bypassed + camera busy → gate OPENS even with NO presence loop reading", async () => {
|
||||
setBypass({ radar: true });
|
||||
// radar NOT set present; camera busy=true → radar dropped, camera satisfies.
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusals).toHaveLength(0);
|
||||
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||
});
|
||||
|
||||
it("camera bypassed but radar STILL required and absent → refuses (only the faulty signal is dropped)", async () => {
|
||||
setBypass({ camera: true });
|
||||
await setRadarPresent(false); // radar required (not bypassed) and clear
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||
expect(res.ok).toBe(false);
|
||||
const refusal = anomalies().find((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusal, "the still-required radar gates the button").toBeTruthy();
|
||||
// The refusal records which signal was bypassed (audit).
|
||||
expect(refusal!.payload).toMatchObject({ presenceBypassed: ["camera"] });
|
||||
});
|
||||
|
||||
it("both bypassed → gate OPENS with no radar and no camera (press-to-print)", async () => {
|
||||
setBypass({ radar: true, camera: true });
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusals).toHaveLength(0);
|
||||
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, siteConfig, ledgerEvents, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { registry, type PrinterDevice } from "@parking/devices";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
|
||||
// The PHYSICAL entry button's press gate (#suppressReason), layered (2026-07-04):
|
||||
// CAMERA — with an entry camera configured, a press is live only while the entry lane
|
||||
// camera confirms a vehicle (the button lamp's SOLID state). Blink (radar-only) prints
|
||||
// nothing. Camera-less sites skip this; the admin camera bypass drops it.
|
||||
// PRESENCE — one-car-one-ticket off the loop (unchanged).
|
||||
// COOLDOWN — now a BACKSTOP behind presence, not an alternative: a motion radar drops a
|
||||
// stationary car (no doppler return), spuriously re-arming the guard; the cooldown bounds
|
||||
// how fast that re-armed press can mint a second ticket for the same car.
|
||||
// A suppressed press is unsigned telemetry (entrySuppressed), never a ledger anomaly.
|
||||
|
||||
let db: Db;
|
||||
let flow: EntryFlow;
|
||||
|
||||
const CTL = "ctl-entry";
|
||||
const BUTTON_INPUT = 1;
|
||||
const PRESENCE_INPUT = 2;
|
||||
|
||||
// A no-op printer that always succeeds, so the happy path reaches the signed
|
||||
// vehicle_entry (the real drivers need hardware). Registered once (registry is global).
|
||||
const noopPrinter: PrinterDevice = {
|
||||
driverId: "test-printer-ok",
|
||||
connect: async () => {},
|
||||
disconnect: async () => {},
|
||||
healthCheck: async () => ({ status: "ready" as const }),
|
||||
printTicket: async () => {},
|
||||
printReport: async () => {},
|
||||
printSubscriptionCard: async () => {},
|
||||
printReceipt: async () => {},
|
||||
printWindowChargeNotice: async () => {},
|
||||
};
|
||||
if (!registry.get("test-printer-ok")) {
|
||||
registry.register({
|
||||
id: "test-printer-ok",
|
||||
category: "printer",
|
||||
label: "Test printer",
|
||||
description: "always-succeeds stub for tests",
|
||||
transports: [],
|
||||
configFields: [],
|
||||
create: () => noopPrinter,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: {
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: BUTTON_INPUT, role: "button", relay: 1 },
|
||||
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
db.insert(devices).values({
|
||||
id: "printer-entry",
|
||||
category: "printer",
|
||||
driverId: "test-printer-ok",
|
||||
config: { direction: "entry" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||
});
|
||||
|
||||
/** Add an entry camera row. The driver never builds (unknown id) — only its EXISTENCE
|
||||
* matters to the press gate; snapshot capture failing is the normal fire-and-forget path. */
|
||||
function addEntryCamera() {
|
||||
db.insert(devices).values({
|
||||
id: "cam-entry",
|
||||
category: "camera",
|
||||
driverId: "no-such-camera-driver",
|
||||
config: { direction: "entry" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
}
|
||||
|
||||
function setCameraBypass(on: boolean) {
|
||||
db.insert(siteConfig)
|
||||
.values({ id: 1, bypassPresenceCamera: on })
|
||||
.onConflictDoUpdate({ target: siteConfig.id, set: { bypassPresenceCamera: on } })
|
||||
.run();
|
||||
}
|
||||
|
||||
async function edge(input: number, edge: "on" | "off") {
|
||||
await flow.onInput({
|
||||
driverId: "stub-access",
|
||||
deviceId: CTL,
|
||||
input,
|
||||
edge,
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
});
|
||||
}
|
||||
|
||||
const press = () => edge(BUTTON_INPUT, "on");
|
||||
const radar = (present: boolean) => edge(PRESENCE_INPUT, present ? "on" : "off");
|
||||
|
||||
const entries = () =>
|
||||
db.select().from(ledgerEvents).all().filter((r) => r.type === "vehicle_entry");
|
||||
const suppressed = () =>
|
||||
db.select().from(deviceEventsTable).all()
|
||||
.map((r) => r.detail as { entrySuppressed?: boolean; reason?: string })
|
||||
.filter((d) => d.entrySuppressed === true);
|
||||
|
||||
describe("entry press gate — camera (blink vs solid)", () => {
|
||||
it("BLINK state (radar present, no camera confirmation) → press suppressed, nothing signed", async () => {
|
||||
addEntryCamera();
|
||||
await radar(true); // lamp would blink: radar sees something, camera does not
|
||||
await press();
|
||||
expect(entries()).toHaveLength(0);
|
||||
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // no anomaly either — telemetry only
|
||||
expect(suppressed()).toHaveLength(1);
|
||||
expect(suppressed()[0].reason).toMatch(/camera/);
|
||||
});
|
||||
|
||||
it("SOLID state (radar present + camera busy) → press prints and signs a vehicle_entry", async () => {
|
||||
addEntryCamera();
|
||||
await radar(true);
|
||||
flow.onLaneStatus({ entry: true, exit: false }); // camera confirms → SOLID
|
||||
await press();
|
||||
expect(entries()).toHaveLength(1);
|
||||
expect(suppressed()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("camera-less site → the camera gate does not apply (radar-only, as before)", async () => {
|
||||
await radar(true); // no camera row; lane state irrelevant
|
||||
await press();
|
||||
expect(entries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("camera bypassed (faulty camera) → press prints without camera confirmation", async () => {
|
||||
addEntryCamera();
|
||||
setCameraBypass(true);
|
||||
await radar(true);
|
||||
await press();
|
||||
expect(entries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("no car at all (radar clear too) → suppressed even with the camera bypassed", async () => {
|
||||
addEntryCamera();
|
||||
setCameraBypass(true);
|
||||
await press(); // radar never went on
|
||||
expect(entries()).toHaveLength(0);
|
||||
expect(suppressed()[0].reason).toMatch(/presence loop clear/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry press gate — cooldown backstop behind presence", () => {
|
||||
/** Same lane but the button carries a cooldown, making it a backstop behind the loop. */
|
||||
function setButtonCooldown(sec: number) {
|
||||
db.delete(devices).run();
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: {
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: BUTTON_INPUT, role: "button", relay: 1, cooldownSec: sec },
|
||||
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
db.insert(devices).values({
|
||||
id: "printer-entry",
|
||||
category: "printer",
|
||||
driverId: "test-printer-ok",
|
||||
config: { direction: "entry" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
}
|
||||
|
||||
it("radar dropout re-arm + quick re-press → caught by the cooldown (one ticket)", async () => {
|
||||
setButtonCooldown(60);
|
||||
await radar(true);
|
||||
await press(); // ticket 1 (no camera configured — radar-only site)
|
||||
expect(entries()).toHaveLength(1);
|
||||
// The motion radar loses the STATIONARY car and re-fires: off (re-arms!) then on.
|
||||
await radar(false);
|
||||
await radar(true);
|
||||
await press(); // presence gate says yes (present + re-armed) — the backstop must catch it
|
||||
expect(entries()).toHaveLength(1);
|
||||
expect(suppressed().some((d) => /cooldown/.test(d.reason ?? ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("without a cooldown the dropout re-press mints a second ticket (the documented residual risk)", async () => {
|
||||
await radar(true);
|
||||
await press();
|
||||
await radar(false);
|
||||
await radar(true);
|
||||
await press();
|
||||
expect(entries()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("still-present car re-pressing (no dropout) stays suppressed by one-car-one-ticket", async () => {
|
||||
await radar(true);
|
||||
await press();
|
||||
await press(); // car never left the loop → not re-armed
|
||||
expect(entries()).toHaveLength(1);
|
||||
expect(suppressed().some((d) => /already issued/.test(d.reason ?? ""))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { eq, subscriptions, type Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||
|
||||
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
|
||||
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
|
||||
// We resolve them when serializing an event for the API / WS feed — never on the
|
||||
// signed record itself.
|
||||
//
|
||||
// Today: a subscription occurrence's identity is an opaque `SUBSESS-…` key. The human
|
||||
// who matters is the subscription HOLDER, whose name lives on the subscriptions row
|
||||
// (mutable master data — NOT signed into the event). We resolve payload.permitId →
|
||||
// holder_name so the feed reads "Aqif Kopertoni" rather than "SUBSESS-08cd1c52e219".
|
||||
|
||||
/** Fallback label when a subscription has no holder name (or was deleted). Matches the
|
||||
* i18n key `booth.subscriberFallback`; kept here in English for the API/log layer. */
|
||||
const SUBSCRIBER_FALLBACK = "Subscriber";
|
||||
|
||||
/** Tiny holder-name cache. Single-writer SQLite; a subscription rename is rare and the
|
||||
* feed is not security-sensitive, so a short-lived cache is plenty. Invalidate by
|
||||
* process lifetime — restart picks up renames; for live correctness the lookup is
|
||||
* cheap enough that we just read per miss. */
|
||||
const holderCache = new Map<string, string | null>();
|
||||
|
||||
/** Resolve a subscription id to its holder name (or null), memoized. */
|
||||
function holderName(db: Db, permitId: string): string | null {
|
||||
if (holderCache.has(permitId)) return holderCache.get(permitId) ?? null;
|
||||
const row = db
|
||||
.select({ holderName: subscriptions.holderName })
|
||||
.from(subscriptions)
|
||||
.where(eq(subscriptions.id, permitId))
|
||||
.get();
|
||||
const name = row?.holderName?.trim() || null;
|
||||
holderCache.set(permitId, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Drop a cached holder name (call after a subscription create/update/delete). */
|
||||
export function invalidateHolder(permitId: string): void {
|
||||
holderCache.delete(permitId);
|
||||
}
|
||||
|
||||
/** Clear the whole holder cache (call on bulk subscription changes). */
|
||||
export function clearHolderCache(): void {
|
||||
holderCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach read-time display fields to a raw ledger row before it goes to a client:
|
||||
* - `subscriberLabel` for a subscription occurrence (payload.permitId → holder name);
|
||||
* - `plate` for an entry/exit event whose session has an advisory ANPR read.
|
||||
* Idempotent and cheap; events without either pass through unchanged. Used by the WS
|
||||
* feed (per event). For the bulk feed page prefer `enrichEvents` (one plate scan).
|
||||
*/
|
||||
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
|
||||
let out: T = event;
|
||||
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
|
||||
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
||||
if ((event.type === "vehicle_entry" || event.type === "vehicle_exit") && event.identity) {
|
||||
const p = plateForIdentity(db, event.identity);
|
||||
if (p) out = { ...out, plate: p.plate };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk variant for the feed page: enriches a list of events with subscriber labels AND
|
||||
* plates using a SINGLE device_events scan for all the plates (instead of one per row).
|
||||
* Order preserved.
|
||||
*/
|
||||
export function enrichEvents<T extends LedgerEvent>(db: Db, events: T[]): T[] {
|
||||
// Collect identities of entry/exit events to resolve their plates in one scan.
|
||||
const wanted = new Set<string>();
|
||||
for (const e of events) {
|
||||
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) wanted.add(e.identity);
|
||||
}
|
||||
const plates = wanted.size ? platesForIdentities(db, wanted) : new Map();
|
||||
return events.map((e) => {
|
||||
let out: T = e;
|
||||
const permitId = e.payload && typeof e.payload.permitId === "string" ? e.payload.permitId : null;
|
||||
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
||||
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) {
|
||||
const p = plates.get(e.identity);
|
||||
if (p) out = { ...out, plate: p.plate };
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||
import { EventLog, canonicalize, hashEvent } from "./event-log.js";
|
||||
import { SoftwareSigner, buildVerifier } from "./signer.js";
|
||||
|
||||
// The append-only, hash-chained, signed event log is THE anti-fraud primitive
|
||||
// (threat model: the operator at the booth). These tests pin every integrity rule:
|
||||
// monotonic index, prevHash linkage, payload-in-signature, and that verifyChain()
|
||||
// catches each class of tamper (content edit, reorder, deletion gap, forged sig,
|
||||
// missing key). No live DB is touched — a fresh in-memory SQLite per test.
|
||||
|
||||
const SECRET = "test-event-signing-key-0123456789";
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let log: EventLog;
|
||||
|
||||
beforeEach(() => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
log = new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
|
||||
});
|
||||
|
||||
afterEach(() => close());
|
||||
|
||||
describe("EventLog.append — chain construction", () => {
|
||||
it("assigns a monotonic index starting at 1", async () => {
|
||||
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
|
||||
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
|
||||
expect(a.index).toBe(1);
|
||||
expect(b.index).toBe(2);
|
||||
});
|
||||
|
||||
it("genesis event has a null prevHash; the next chains to it", async () => {
|
||||
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
|
||||
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
|
||||
expect(a.prevHash).toBeNull();
|
||||
expect(b.prevHash).toBe(hashEvent(canonicalize(a)));
|
||||
});
|
||||
|
||||
it("signs each row under the active keyId", async () => {
|
||||
const row = await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 100 } });
|
||||
expect(row.keyId).toBe("sw-hmac-v2");
|
||||
expect(new SoftwareSigner(SECRET).verify(canonicalize(row), row.signature)).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes concurrent appends without index collisions", async () => {
|
||||
const rows = await Promise.all(
|
||||
Array.from({ length: 25 }, (_, i) => log.append({ type: "vehicle_entry", identity: `T${i}` })),
|
||||
);
|
||||
const indices = rows.map((r) => r.index).sort((a, b) => a - b);
|
||||
expect(indices).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventLog.verifyChain — integrity", () => {
|
||||
async function seed() {
|
||||
await log.append({ type: "vehicle_entry", identity: "T1", direction: "entry" });
|
||||
await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 200, tariffVersionId: "tv1" } });
|
||||
await log.append({ type: "vehicle_exit", identity: "T1", direction: "exit" });
|
||||
}
|
||||
|
||||
it("accepts an untampered chain", async () => {
|
||||
await seed();
|
||||
expect(log.verifyChain()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("accepts an empty chain", () => {
|
||||
expect(log.verifyChain()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("detects a tampered payload (the money amount)", async () => {
|
||||
await seed();
|
||||
// Rewrite the payment amount directly in the DB — exactly the booth-operator
|
||||
// fraud the signed payload defends against.
|
||||
db.update(ledgerEvents).set({ payload: { amountMinor: 1, tariffVersionId: "tv1" } }).where(eq(ledgerEvents.index, 2)).run();
|
||||
const r = log.verifyChain();
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.index).toBe(2);
|
||||
expect(r.reason).toMatch(/signature invalid/);
|
||||
}
|
||||
});
|
||||
|
||||
it("detects a deleted row as an index gap", async () => {
|
||||
await seed();
|
||||
db.delete(ledgerEvents).where(eq(ledgerEvents.index, 2)).run();
|
||||
const r = log.verifyChain();
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toMatch(/index gap/);
|
||||
});
|
||||
|
||||
it("detects a broken prevHash link (reordering / re-chaining)", async () => {
|
||||
await seed();
|
||||
db.update(ledgerEvents).set({ prevHash: "0".repeat(64) }).where(eq(ledgerEvents.index, 3)).run();
|
||||
const r = log.verifyChain();
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.index).toBe(3);
|
||||
expect(r.reason).toMatch(/prevHash/);
|
||||
}
|
||||
});
|
||||
|
||||
it("detects an event signed under a key that is no longer configured", async () => {
|
||||
await seed();
|
||||
// Re-sign row 2 under an unknown keyId — buildVerifier can't resolve it.
|
||||
db.update(ledgerEvents).set({ keyId: "atecc608-slot9" }).where(eq(ledgerEvents.index, 2)).run();
|
||||
const r = log.verifyChain();
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toMatch(/no signer for keyId/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalize — byte-stability", () => {
|
||||
it("is independent of payload key order (sorted recursively)", () => {
|
||||
const base = { index: 1, type: "payment", direction: null, source: null, identity: "T1", occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
|
||||
const a = canonicalize({ ...base, payload: { amountMinor: 100, tariffVersionId: "tv1" } });
|
||||
const b = canonicalize({ ...base, payload: { tariffVersionId: "tv1", amountMinor: 100 } });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("changes when any signed field changes", () => {
|
||||
const base = { index: 1, type: "payment" as const, direction: null, source: null, identity: "T1", payload: { amountMinor: 100 }, occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
|
||||
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, payload: { amountMinor: 101 } }));
|
||||
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, identity: "T2" }));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { desc, events, type Db, type EventRow } from "@parking/db";
|
||||
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
|
||||
import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
|
||||
import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
|
||||
|
||||
// The append-only, hash-chained, signed event log — the system's core anti-fraud
|
||||
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
|
||||
@@ -16,11 +16,12 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
|
||||
// so we guard it with an in-process async lock as well.
|
||||
|
||||
export interface AppendInput {
|
||||
readonly type: ParkingEventType;
|
||||
readonly lane: number;
|
||||
readonly type: LedgerEventType;
|
||||
readonly direction?: Direction | null;
|
||||
readonly source?: IdentitySource | null;
|
||||
readonly identity?: string | null;
|
||||
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
||||
readonly payload?: LedgerPayload | null;
|
||||
/** Event time (ISO-8601). Defaults to now. */
|
||||
readonly occurredAt?: string;
|
||||
}
|
||||
@@ -36,9 +37,9 @@ export function canonicalize(e: {
|
||||
index: number;
|
||||
type: string;
|
||||
direction: string | null;
|
||||
lane: number;
|
||||
source: string | null;
|
||||
identity: string | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
occurredAt: string;
|
||||
prevHash: string | null;
|
||||
}): string {
|
||||
@@ -46,57 +47,107 @@ export function canonicalize(e: {
|
||||
e.index,
|
||||
e.type,
|
||||
e.direction ?? null,
|
||||
e.lane,
|
||||
e.source ?? null,
|
||||
e.identity ?? null,
|
||||
// Payload is part of the signed form so business data is tamper-evident.
|
||||
// Serialize with sorted keys for byte-stability (object key order must not
|
||||
// change a signature). null when the event type carries no payload.
|
||||
canonicalPayload(e.payload),
|
||||
e.occurredAt,
|
||||
e.prevHash ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
|
||||
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
|
||||
if (p == null) return null;
|
||||
const sort = (v: unknown): unknown => {
|
||||
if (Array.isArray(v)) return v.map(sort);
|
||||
if (v && typeof v === "object") {
|
||||
return Object.keys(v as Record<string, unknown>)
|
||||
.sort()
|
||||
.reduce<Record<string, unknown>>((o, k) => {
|
||||
o[k] = sort((v as Record<string, unknown>)[k]);
|
||||
return o;
|
||||
}, {});
|
||||
}
|
||||
return v;
|
||||
};
|
||||
return sort(p);
|
||||
}
|
||||
|
||||
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
|
||||
export function hashEvent(canonical: string): string {
|
||||
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
|
||||
* Returns undefined when the key that signed an event is not available. */
|
||||
export type SignerResolver = (keyId: string) => Signer | undefined;
|
||||
|
||||
export class EventLog {
|
||||
readonly #db: Db;
|
||||
readonly #signer: Signer;
|
||||
/** Picks the verifying signer per event keyId; lets a chain span key rotations
|
||||
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
|
||||
* callers that don't pass one (single-key chains, tests). */
|
||||
readonly #resolveVerifier: SignerResolver;
|
||||
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
|
||||
* to fan the event out to live booth clients (WS). It is best-effort and must
|
||||
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
|
||||
readonly #onAppended?: (row: LedgerEventRow) => void;
|
||||
/** Serialize appends: each waits for the previous to finish. */
|
||||
#tail: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(db: Db, signer: Signer) {
|
||||
constructor(
|
||||
db: Db,
|
||||
signer: Signer,
|
||||
resolveVerifier?: SignerResolver,
|
||||
onAppended?: (row: LedgerEventRow) => void,
|
||||
) {
|
||||
this.#db = db;
|
||||
this.#signer = signer;
|
||||
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
||||
this.#onAppended = onAppended;
|
||||
}
|
||||
|
||||
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
||||
append(input: AppendInput): Promise<EventRow> {
|
||||
append(input: AppendInput): Promise<LedgerEventRow> {
|
||||
const run = this.#tail.then(() => this.#appendNow(input));
|
||||
// Keep the chain going even if one append rejects (don't wedge the lock).
|
||||
this.#tail = run.catch(() => undefined);
|
||||
return run;
|
||||
// Read-side notification, AFTER the row is durably written. Wrapped so a
|
||||
// failing sink can never reject the append or break the chain lock above.
|
||||
return run.then((row) => {
|
||||
try {
|
||||
this.#onAppended?.(row);
|
||||
} catch {
|
||||
// best-effort fan-out only — swallow.
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
#appendNow(input: AppendInput): EventRow {
|
||||
#appendNow(input: AppendInput): LedgerEventRow {
|
||||
const prev = this.#db
|
||||
.select()
|
||||
.from(events)
|
||||
.orderBy(desc(events.index))
|
||||
.from(ledgerEvents)
|
||||
.orderBy(desc(ledgerEvents.index))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
const index = (prev?.index ?? 0) + 1;
|
||||
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
||||
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
||||
const payload = input.payload ?? null;
|
||||
|
||||
const canonical = canonicalize({
|
||||
index,
|
||||
type: input.type,
|
||||
direction: input.direction ?? null,
|
||||
lane: input.lane,
|
||||
source: input.source ?? null,
|
||||
identity: input.identity ?? null,
|
||||
payload,
|
||||
occurredAt,
|
||||
prevHash,
|
||||
});
|
||||
@@ -106,26 +157,33 @@ export class EventLog {
|
||||
index,
|
||||
type: input.type,
|
||||
direction: input.direction ?? null,
|
||||
lane: input.lane,
|
||||
source: input.source ?? null,
|
||||
identity: input.identity ?? null,
|
||||
payload,
|
||||
occurredAt,
|
||||
prevHash,
|
||||
signature: this.#signer.sign(canonical),
|
||||
keyId: this.#signer.keyId,
|
||||
};
|
||||
|
||||
this.#db.insert(events).values(row).run();
|
||||
return row as EventRow;
|
||||
this.#db.insert(ledgerEvents).values(row).run();
|
||||
return row as LedgerEventRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
||||
* first detected break, or { ok: true }. This is what reconciliation and an
|
||||
* integrity self-check call. Catches: tampered content, reordering, a deleted
|
||||
* row (index gap), and a forged/invalid signature.
|
||||
* row (index gap), a forged/invalid signature, and an event signed under a key
|
||||
* that is no longer configured.
|
||||
*
|
||||
* Each row is verified against the signer for ITS OWN `keyId`, not the current
|
||||
* append signer — so a chain that spans a key rotation (e.g. early events under
|
||||
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
|
||||
* verifies end to end. See signer.buildVerifier.
|
||||
*/
|
||||
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
||||
const rows = this.#db.select().from(events).orderBy(events.index).all();
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
let expectedIndex = 1;
|
||||
let prevHash: string | null = null;
|
||||
for (const row of rows) {
|
||||
@@ -135,8 +193,16 @@ export class EventLog {
|
||||
if ((row.prevHash ?? null) !== prevHash) {
|
||||
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
|
||||
}
|
||||
const verifier = this.#resolveVerifier(row.keyId);
|
||||
if (!verifier) {
|
||||
return {
|
||||
ok: false,
|
||||
index: row.index,
|
||||
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
|
||||
};
|
||||
}
|
||||
const canonical = canonicalize(row);
|
||||
if (!this.#signer.verify(canonical, row.signature)) {
|
||||
if (!verifier.verify(canonical, row.signature)) {
|
||||
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
|
||||
}
|
||||
prevHash = hashEvent(canonical);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { PayStation } from "./pay-station.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
|
||||
|
||||
// The exit flow is the anti-fraud GATE: no car leaves without a covering payment within
|
||||
// the walk-back grace (the no-unpaid-bypass + no-free-overstay rules), and the booth has
|
||||
// no bypass. With no relay configured a clean exit returns { opened:false } — we assert
|
||||
// the DECISION (refuse vs. sign the exit), not the hardware open.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let log: EventLog;
|
||||
let exit: ExitFlow;
|
||||
let pay: PayStation;
|
||||
|
||||
beforeEach(() => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
log = makeLog(db);
|
||||
exit = new ExitFlow(db, log, silentLogger());
|
||||
pay = new PayStation(db, log, silentLogger());
|
||||
});
|
||||
afterEach(() => close());
|
||||
|
||||
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
|
||||
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
|
||||
}
|
||||
function exitsSigned(identity: string) {
|
||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||
}
|
||||
function anomalies(reason?: string) {
|
||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
|
||||
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
|
||||
}
|
||||
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
|
||||
* plate-reconciliation check can see this identity's plate against open sessions. */
|
||||
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
|
||||
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
|
||||
db.insert(deviceEventsTable).values({
|
||||
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
|
||||
detail: { identity, direction: "entry", plate, confidence },
|
||||
}).run();
|
||||
}
|
||||
|
||||
describe("exitForBooth — refusal gates", () => {
|
||||
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||
const r = await exit.exitForBooth("ghost");
|
||||
expect(r).toMatchObject({ ok: false, status: "no_session" });
|
||||
const anomalies = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all();
|
||||
expect(anomalies).toHaveLength(1);
|
||||
expect(exitsSigned("ghost")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses an UNPAID open session — no exit signed (no-unpaid-bypass)", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
await enter("T1", minutesAgo(90));
|
||||
const r = await exit.exitForBooth("T1");
|
||||
expect(r).toMatchObject({ ok: false, status: "unpaid" });
|
||||
expect(exitsSigned("T1")).toHaveLength(0); // the car did NOT leave
|
||||
});
|
||||
|
||||
it("refuses a paid session whose walk-back grace has EXPIRED (no free overstay)", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("T1", minutesAgo(200));
|
||||
// A payment made 60 min ago → its 15-min walk-back grace lapsed long ago.
|
||||
await log.append({
|
||||
type: "payment", source: "manual", identity: "T1", occurredAt: minutesAgo(60),
|
||||
payload: { sessionRef: "T1", amountMinor: 10000, currency: "ALL", tender: "cash", graceExitMin: 15 },
|
||||
});
|
||||
const r = await exit.exitForBooth("T1");
|
||||
expect(r).toMatchObject({ ok: false, status: "grace_expired" });
|
||||
expect(exitsSigned("T1")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exitForBooth — valid exit signs the vehicle_exit", () => {
|
||||
it("a paid session within grace signs an exit (opened:false — no relay in tests)", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("T1", minutesAgo(90));
|
||||
await pay.pay("T1", "cash"); // fresh payment → within grace
|
||||
const r = await exit.exitForBooth("T1");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.opened).toBe(false); // signed, but no barrier resolves in tests
|
||||
expect(exitsSigned("T1")).toHaveLength(1); // the exit IS on the chain
|
||||
expect(log.verifyChain()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
// NB: a subscriber's normal exit runs through SubscriptionFlow (the reader/credential
|
||||
// path), not exitForBooth — the booth's transient exit has no subscription bypass and
|
||||
// applies the same paid/grace gate to any identity it's handed. Asserting that here so
|
||||
// the boundary is explicit: handing a bare occurrence to exitForBooth is refused, and a
|
||||
// subscriber leaves via reopenBarrier (assist) or the subscription reader flow instead.
|
||||
it("does NOT give the booth transient-exit path a subscription bypass", async () => {
|
||||
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
|
||||
const r = await exit.exitForBooth("SUBSESS-1");
|
||||
expect(r).toMatchObject({ ok: false, status: "unpaid" });
|
||||
expect(exitsSigned("SUBSESS-1")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets a prepaid subscriber out via the assist (reopenBarrier) path", async () => {
|
||||
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
|
||||
const r = await exit.reopenBarrier("SUBSESS-1", "op1");
|
||||
expect(r.ok).toBe(true);
|
||||
expect(exitsSigned("SUBSESS-1")).toHaveLength(1); // assist closes the open occurrence
|
||||
});
|
||||
});
|
||||
|
||||
describe("reopenBarrier — no unpaid re-open", () => {
|
||||
it("refuses to re-open an unpaid transient session", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
await enter("T1", minutesAgo(90));
|
||||
const r = await exit.reopenBarrier("T1", "op1");
|
||||
expect(r.ok).toBe(false);
|
||||
expect(exitsSigned("T1")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("re-opening a paid OPEN session also closes it (signs the exit)", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("T1", minutesAgo(90));
|
||||
await pay.pay("T1", "cash");
|
||||
const r = await exit.reopenBarrier("T1", "op1");
|
||||
expect(r.ok).toBe(true);
|
||||
// The open session is closed by the human-intervention exit so it leaves the list.
|
||||
expect(exitsSigned("T1")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
|
||||
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
|
||||
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
|
||||
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
|
||||
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
|
||||
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(exitsSigned("1237")).toHaveLength(1); // released
|
||||
const ov = anomalies("released a suspected ticket-swap");
|
||||
expect(ov.length).toBe(1);
|
||||
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
|
||||
});
|
||||
|
||||
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r.ok).toBe(true); // no warning — exits normally
|
||||
expect(exitsSigned("1237")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1237", minutesAgo(90));
|
||||
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
|
||||
expect(exitsSigned("1237")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,597 @@
|
||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
|
||||
// the session → validate it is PAID and within the walk-back grace → sign a
|
||||
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
|
||||
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
|
||||
//
|
||||
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
|
||||
// projection cache: find the open vehicle_entry for this identity, then a covering
|
||||
// payment within grace. The cache is updated after, for fast reads.
|
||||
//
|
||||
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
|
||||
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
|
||||
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
|
||||
// is sent back to the pay station, the rejection is logged.
|
||||
//
|
||||
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
|
||||
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
|
||||
// passable once the pay-station + `payment` events land.
|
||||
|
||||
interface SessionView {
|
||||
readonly identity: string;
|
||||
readonly enteredAt: string;
|
||||
readonly open: boolean; // no vehicle_exit yet
|
||||
readonly paidAt: string | null; // latest payment time, if any
|
||||
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
|
||||
* exit / re-open without a `payment`. */
|
||||
readonly subscription: boolean;
|
||||
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
||||
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
|
||||
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
|
||||
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
|
||||
// active tariff resolves (then we fall back to the normal paid check).
|
||||
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
|
||||
}
|
||||
|
||||
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
|
||||
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
|
||||
* the barrier didn't open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult =
|
||||
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
|
||||
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
|
||||
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
|
||||
// See wiki/concepts/plate-reconciliation.md.
|
||||
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
|
||||
| { ok: true; opened: true }
|
||||
| { ok: true; opened: false; reason: string };
|
||||
|
||||
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
|
||||
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
|
||||
* intervention was recorded (signed anomaly) but the relay did not fire. */
|
||||
export type BoothReopenResult =
|
||||
| { ok: false; reason: string }
|
||||
| { ok: true; opened: boolean; reason?: string };
|
||||
|
||||
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
|
||||
* exiting read and the matched open session's entry read. Below this, the read is advisory-
|
||||
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
|
||||
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
|
||||
|
||||
export class ExitFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #inFlight = new Set<string>();
|
||||
/** Optional vision client — passed to snapshotAsync so ANPR runs on the exit image. */
|
||||
readonly #vision: VisionClient | null;
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
this.#vision = vision;
|
||||
}
|
||||
|
||||
/**
|
||||
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
|
||||
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
|
||||
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
|
||||
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
|
||||
* relay site-wide, pulses it, and fires the exit snapshot.
|
||||
*
|
||||
* Returns a discriminated result so the route can react precisely:
|
||||
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
|
||||
* — nothing is signed beyond the existing anomaly; the operator takes payment.
|
||||
* - { ok: true, opened: true } on a clean exit.
|
||||
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
|
||||
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
|
||||
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||
* operator opens manually. Payment is never rolled back.
|
||||
*/
|
||||
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
|
||||
const id = identity.trim();
|
||||
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||
|
||||
const key = `booth:${id}`;
|
||||
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
const view = this.#sessionFor(id);
|
||||
|
||||
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
|
||||
// path) so a booth attempt on a bad ticket is auditable.
|
||||
if (!view || !view.open) {
|
||||
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
|
||||
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||
return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
|
||||
const freeGrace = view.paidAt == null && view.freeGrace != null;
|
||||
const paid = view.paidAt != null;
|
||||
const withinGrace =
|
||||
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||
|
||||
if (!freeGrace && (!paid || !withinGrace)) {
|
||||
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
|
||||
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
|
||||
// (unless they consciously override). A denial here never traps the car — exit fails
|
||||
// open and the operator can override; the anomaly is the control either way.
|
||||
const swap = this.#reconcilePlateAtExit(id);
|
||||
if (swap) {
|
||||
if (!opts?.override) {
|
||||
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
|
||||
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
|
||||
});
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
|
||||
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
|
||||
}
|
||||
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: {
|
||||
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||
source: "booth",
|
||||
plateSwapOverride: true,
|
||||
plate: swap.plate,
|
||||
otherIdentity: swap.otherIdentity,
|
||||
...(opts.operator ? { operator: opts.operator } : {}),
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
|
||||
}
|
||||
|
||||
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||
// reader path does.
|
||||
if (freeGrace && view.freeGrace) {
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
identity: id,
|
||||
payload: {
|
||||
sessionRef: id,
|
||||
amountMinor: 0,
|
||||
currency: view.freeGrace.currency,
|
||||
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||
graceExitMin: view.freeGrace.graceExitMin,
|
||||
...reasonPayload("exit.freeGrace"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
|
||||
const resolved = firstRelayByDirection(this.#db, "exit");
|
||||
|
||||
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
|
||||
// to let the car out has been made and validated. Then attempt the open.
|
||||
await this.#signExit(id);
|
||||
|
||||
if (!resolved) {
|
||||
await this.#openFailedAnomaly(id, "no exit relay configured");
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
|
||||
}
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (!access) {
|
||||
await this.#openFailedAnomaly(id, "exit controller would not build");
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
|
||||
}
|
||||
try {
|
||||
await access.pulseOpen(resolved.relay);
|
||||
} catch (err) {
|
||||
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
|
||||
}
|
||||
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#closeSessionCache(id);
|
||||
return { ok: true, opened: true };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
|
||||
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
|
||||
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
|
||||
* opens the barrier with a signed trace.
|
||||
*
|
||||
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
|
||||
* the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
|
||||
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
|
||||
*
|
||||
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
|
||||
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
|
||||
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
|
||||
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
|
||||
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
|
||||
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
|
||||
* second exit (that would double-count occupancy): anomaly only, as before.
|
||||
* See wiki/concepts/booth-exit-flow.md.
|
||||
*/
|
||||
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
|
||||
const id = identity.trim();
|
||||
if (!id) return { ok: false, reason: "ticket id required" };
|
||||
|
||||
const view = this.#sessionFor(id);
|
||||
if (!view) return { ok: false, reason: "no session for ticket" };
|
||||
// Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case
|
||||
// the operator must assist when the exit reader / card fails) OR a transient whose
|
||||
// payment is STILL WITHIN the walk-back grace window. A stale payment does NOT
|
||||
// authorize a free open: a car that paid once and then sat inside past grace owes a
|
||||
// top-up for the extra time — letting it out on the old payment is the overstay-fraud
|
||||
// path. So we mirror the exit flow's grace check here (not just in the UI): an
|
||||
// unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead.
|
||||
// The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side.
|
||||
const paid = view.paidAt != null;
|
||||
const withinGrace =
|
||||
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||
if (!view.subscription && (!paid || !withinGrace)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment",
|
||||
};
|
||||
}
|
||||
|
||||
const key = `reopen:${id}`;
|
||||
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
const resolved = firstRelayByDirection(this.#db, "exit");
|
||||
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
|
||||
// the physical open succeeds).
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: {
|
||||
...reasonPayload("exit.manualOpen"),
|
||||
source: "booth",
|
||||
barrierReopen: true,
|
||||
...(operator ? { operator } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
|
||||
// session leaves the active list + occupancy settles. Skip when already exited
|
||||
// (no double-count). Recorded as a human-intervention exit for the audit trail.
|
||||
if (view.open) {
|
||||
await this.#signExit(id, "manual");
|
||||
this.#closeSessionCache(id);
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
|
||||
}
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (!access) {
|
||||
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
|
||||
}
|
||||
try {
|
||||
await access.pulseOpen(resolved.relay);
|
||||
} catch (err) {
|
||||
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
|
||||
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
|
||||
}
|
||||
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
|
||||
return { ok: true, opened: true };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
||||
* read dispatcher from the reader's binding, which has ruled out a subscription match). */
|
||||
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
const key = `${e.deviceId}:${e.value}`;
|
||||
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
return await this.#runExit(resolved, e);
|
||||
} catch (err) {
|
||||
this.#logger.error(`exit-flow failed: ${(err as Error).message}`);
|
||||
return { accepted: false, reason: (err as Error).message };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
const view = this.#sessionFor(e.value);
|
||||
|
||||
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||
if (!view || !view.open) {
|
||||
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: e.value,
|
||||
payload: { ...rp, exitRefused: true },
|
||||
});
|
||||
this.#fireExitSnapshot(e.value);
|
||||
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||
}
|
||||
|
||||
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
|
||||
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
|
||||
// its "an exit is covered by a payment" invariant, then fall through to open.
|
||||
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
|
||||
if (view.paidAt == null && view.freeGrace) {
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
|
||||
identity: e.value,
|
||||
payload: {
|
||||
sessionRef: e.value,
|
||||
amountMinor: 0,
|
||||
currency: view.freeGrace.currency,
|
||||
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||
graceExitMin: view.freeGrace.graceExitMin,
|
||||
...reasonPayload("exit.freeGrace"),
|
||||
},
|
||||
});
|
||||
this.#logger.info(`exit free within entry-grace (${e.value})`);
|
||||
return this.#signExitAndOpen(resolved, e);
|
||||
}
|
||||
|
||||
// PAID + within walk-back grace?
|
||||
const paid = view.paidAt != null;
|
||||
const withinGrace =
|
||||
paid &&
|
||||
view.graceExitMin != null &&
|
||||
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||
|
||||
if (!paid || !withinGrace) {
|
||||
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: e.value,
|
||||
payload: { ...rp, exitRefused: true, sessionRef: e.value },
|
||||
});
|
||||
this.#fireExitSnapshot(e.value);
|
||||
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
|
||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
|
||||
// automated lane to make the override decision, and exit fails open for safety, so we
|
||||
// sign the suspicion anomaly (the control here) and still let the car out. The booth
|
||||
// path (operator-mediated) is where the hold + override lives.
|
||||
const swap = this.#reconcilePlateAtExit(e.value);
|
||||
if (swap) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: e.value,
|
||||
payload: {
|
||||
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||
plateSwapSuspected: true,
|
||||
plate: swap.plate,
|
||||
otherIdentity: swap.otherIdentity,
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
|
||||
}
|
||||
|
||||
// Valid (a real payment within walk-back grace): sign + open.
|
||||
return this.#signExitAndOpen(resolved, e);
|
||||
}
|
||||
|
||||
/** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
|
||||
* Shared by the paid-exit and free-entry-grace paths. The caller has already
|
||||
* established the session is allowed out (and, for grace, minted the $0 payment). */
|
||||
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
|
||||
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
||||
|
||||
this.#fireExitSnapshot(e.value);
|
||||
this.#closeSessionCache(e.value);
|
||||
return { accepted: true, direction: "exit" };
|
||||
}
|
||||
|
||||
/** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
|
||||
* or "manual" (a human-intervention barrier re-open that closes an open session —
|
||||
* see reopenBarrier). */
|
||||
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
|
||||
await this.#log.append({
|
||||
type: "vehicle_exit",
|
||||
direction: "exit",
|
||||
source,
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
...(source === "manual" ? reasonPayload("exit.manualOpen") : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
|
||||
#fireExitSnapshot(identity: string): void {
|
||||
void snapshotAsync({
|
||||
db: this.#db,
|
||||
direction: "exit",
|
||||
identity,
|
||||
logger: this.#logger,
|
||||
vision: this.#vision,
|
||||
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
||||
}
|
||||
|
||||
/** Update the (rebuildable) session projection cache to closed. */
|
||||
#closeSessionCache(identity: string): void {
|
||||
try {
|
||||
this.#db
|
||||
.update(sessions)
|
||||
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||
.where(eq(sessions.id, identity))
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
|
||||
* The payment + exit STAND; this tells the operator to open manually. */
|
||||
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity,
|
||||
payload: { ...reasonPayload("exit.open.failed"), detail, source: "booth", exitOpenFailed: true },
|
||||
});
|
||||
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
|
||||
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
|
||||
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
|
||||
* or two tickets were mixed up. We compare the EXITING plate against every open session's
|
||||
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
|
||||
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
|
||||
* or null. See wiki/concepts/plate-reconciliation.md.
|
||||
*/
|
||||
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
|
||||
// The exiting car's plate: prefer its own exit read, else its entry read.
|
||||
const mine = plateForIdentity(this.#db, exitingId);
|
||||
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
|
||||
const wanted = mine.plate.trim().toUpperCase();
|
||||
|
||||
// All currently-open sessions (from the projection cache — a fast read-model; the check
|
||||
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
|
||||
const openIds = this.#db
|
||||
.select({ id: sessions.id })
|
||||
.from(sessions)
|
||||
.where(eq(sessions.state, "open"))
|
||||
.all()
|
||||
.map((r) => r.id)
|
||||
.filter((id) => id !== exitingId);
|
||||
if (openIds.length === 0) return null;
|
||||
|
||||
const plates = platesForIdentities(this.#db, openIds);
|
||||
for (const [otherId, pv] of plates) {
|
||||
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
|
||||
if (pv.plate.trim().toUpperCase() !== wanted) continue;
|
||||
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
|
||||
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
|
||||
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||
#sessionFor(identity: string): SessionView | null {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, identity))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return null;
|
||||
// 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 graceExitMin: number | null = null;
|
||||
for (const r of rows) {
|
||||
if (r.type === "payment") {
|
||||
paidAt = r.occurredAt;
|
||||
const p = (r.payload ?? {}) as LedgerPayload & { graceExitMin?: number };
|
||||
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||
}
|
||||
}
|
||||
|
||||
// Free entry-grace: if the tariff prices entry→now at 0 (a quick in-and-out),
|
||||
// the exit may open at the gate. Resolve against the tariff in force at entry,
|
||||
// same as the pay station. Null when no payment is needed yet and no tariff
|
||||
// resolves — then exit falls back to the normal paid check.
|
||||
let freeGrace: SessionView["freeGrace"] = null;
|
||||
if (!exited && paidAt == null) {
|
||||
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||
if (tv) {
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
// Same frozen-at-entry category the pay station uses, so the free-grace
|
||||
// check agrees with the booth quote for V2 category tariffs.
|
||||
const category = (entry.payload as { category?: string } | null)?.category;
|
||||
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
||||
if (fee === 0) {
|
||||
freeGrace = {
|
||||
tariffVersionId: tv.id,
|
||||
currency: tv.currency,
|
||||
graceExitMin: structure.gracePeriodExitMin,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
const subscription = entryPl.permit === true || entryPl.permitId != null;
|
||||
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
open: !exited,
|
||||
paidAt,
|
||||
subscription,
|
||||
graceExitMin,
|
||||
freeGrace,
|
||||
};
|
||||
}
|
||||
|
||||
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
|
||||
#tariffVersionFor(at: string) {
|
||||
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
if (!tariff) return null;
|
||||
const versions = this.#db
|
||||
.select()
|
||||
.from(tariffVersions)
|
||||
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||
.all();
|
||||
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||
}
|
||||
|
||||
/** Build a live access adapter from a resolved controller row, or null. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(row.config as never) as AccessControlDevice;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { laneDevices, type Db } from "@parking/db";
|
||||
|
||||
// Resolves a device instance id (lane_devices.id) to its lane number.
|
||||
//
|
||||
// Device pushes/events carry the `lane_devices` id (which device fired), not a
|
||||
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
|
||||
// rebuilt from the DB at startup and refreshed whenever assignments change
|
||||
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
|
||||
// every input event, so a cached map beats a per-event DB lookup.
|
||||
export class LaneMap {
|
||||
readonly #db: Db;
|
||||
#byDeviceId = new Map<string, number>();
|
||||
|
||||
constructor(db: Db) {
|
||||
this.#db = db;
|
||||
}
|
||||
|
||||
/** (Re)load the id->lane map from the lane_devices table. */
|
||||
refresh(): void {
|
||||
const rows = this.#db.select().from(laneDevices).all();
|
||||
const next = new Map<string, number>();
|
||||
for (const r of rows) next.set(r.id, r.lane);
|
||||
this.#byDeviceId = next;
|
||||
}
|
||||
|
||||
/** Lane for a device instance id, or null if the device isn't known. */
|
||||
laneFor(deviceId: string): number | null {
|
||||
return this.#byDeviceId.get(deviceId) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { LanePresence } from "./lane-presence.js";
|
||||
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
|
||||
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
|
||||
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
|
||||
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
|
||||
// emits a lane-presence change only when a lane's present/clear state actually flips.
|
||||
|
||||
let db: Db;
|
||||
const CTL = "ctl-1";
|
||||
const ENTRY_RADAR = 2;
|
||||
const EXIT_RADAR = 5;
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
],
|
||||
inputs: [
|
||||
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
|
||||
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
});
|
||||
|
||||
function edge(input: number, on: boolean): void {
|
||||
const e: DeviceInputEvent = {
|
||||
driverId: "dingtian",
|
||||
deviceId: CTL,
|
||||
input,
|
||||
edge: on ? "on" : "off",
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
};
|
||||
deviceEvents.emitInput(e);
|
||||
}
|
||||
|
||||
/** Collect lane-presence emissions while running `fn`. */
|
||||
function capture(fn: () => void): LanePresenceEvent[] {
|
||||
const seen: LanePresenceEvent[] = [];
|
||||
const off = deviceEvents.onLanePresence((p) => seen.push(p));
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("LanePresence", () => {
|
||||
it("starts clear and snapshots clear", () => {
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
|
||||
lp.stop();
|
||||
});
|
||||
|
||||
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
const events = capture(() => {
|
||||
edge(ENTRY_RADAR, true);
|
||||
edge(ENTRY_RADAR, false);
|
||||
});
|
||||
expect(events).toEqual([
|
||||
{ entry: true, exit: false },
|
||||
{ entry: false, exit: false },
|
||||
]);
|
||||
lp.stop();
|
||||
});
|
||||
|
||||
it("an EXIT radar edge marks the exit lane independently", () => {
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
const events = capture(() => {
|
||||
edge(EXIT_RADAR, true);
|
||||
});
|
||||
expect(events).toEqual([{ entry: false, exit: true }]);
|
||||
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
|
||||
lp.stop();
|
||||
});
|
||||
|
||||
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
|
||||
// Two radars both serving the entry lane.
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||
{ input: 3, role: "presence", relay: 1, kind: "radar" },
|
||||
],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CTL))
|
||||
.run();
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
const events = capture(() => {
|
||||
edge(2, true); // entry → present (emit)
|
||||
edge(3, true); // still present (no emit — same lane)
|
||||
edge(2, false); // still present via I3 (no emit)
|
||||
edge(3, false); // now clear (emit)
|
||||
});
|
||||
expect(events).toEqual([
|
||||
{ entry: true, exit: false },
|
||||
{ entry: false, exit: false },
|
||||
]);
|
||||
lp.stop();
|
||||
});
|
||||
|
||||
it("ignores a non-presence input (e.g. a button terminal)", () => {
|
||||
db.update(devices)
|
||||
.set({
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||
},
|
||||
})
|
||||
.where(eq(devices.id, CTL))
|
||||
.run();
|
||||
const lp = new LanePresence(db, silentLogger());
|
||||
lp.start();
|
||||
const events = capture(() => edge(1, true));
|
||||
expect(events).toEqual([]);
|
||||
lp.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||
import { presenceLaneOf } from "./device-resolve.js";
|
||||
|
||||
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
|
||||
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
|
||||
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
|
||||
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
|
||||
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
|
||||
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
|
||||
//
|
||||
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
|
||||
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
|
||||
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
|
||||
|
||||
export class LanePresence {
|
||||
readonly #db: Db;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
|
||||
* serve one lane). A lane is "present" while its set is non-empty. */
|
||||
readonly #entry = new Set<string>();
|
||||
readonly #exit = new Set<string>();
|
||||
#unsub: (() => void) | null = null;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Subscribe to presence input edges. */
|
||||
start(): void {
|
||||
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
|
||||
}
|
||||
|
||||
/** Current snapshot (for the WS hello). */
|
||||
snapshot(): LanePresenceEvent {
|
||||
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
|
||||
}
|
||||
|
||||
#onInput(e: DeviceInputEvent): void {
|
||||
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
|
||||
if (!lane) return; // not a presence terminal on a barrier relay
|
||||
const key = `${e.deviceId}:${e.input}`;
|
||||
const set = lane === "entry" ? this.#entry : this.#exit;
|
||||
const before = set.size > 0;
|
||||
if (e.edge === "on") set.add(key);
|
||||
else set.delete(key);
|
||||
const after = set.size > 0;
|
||||
if (before !== after) {
|
||||
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
|
||||
deviceEvents.emitLanePresence(this.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
/** Unsubscribe on shutdown. */
|
||||
stop(): void {
|
||||
this.#unsub?.();
|
||||
this.#unsub = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { LaneStatus } from "./lane-status.js";
|
||||
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
|
||||
// LaneStatus: a camera's vehicle detection marks its bound lane busy, then auto-clears
|
||||
// after a timeout (this camera class sends no leave signal). Advisory; emits a
|
||||
// lane-status change only when the busy/free state actually flips.
|
||||
|
||||
let db: Db;
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** Seed a controller (relay 1=entry, 2=exit, 3=both) + a camera bound to the relay
|
||||
* whose direction we want, so directionOf resolves from the real bound relay. */
|
||||
function seedCamera(direction: "entry" | "exit" | "both"): string {
|
||||
const controllerId = randomUUID();
|
||||
db.insert(devices).values({
|
||||
id: controllerId,
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: {
|
||||
host: "10.0.0.5",
|
||||
relays: [
|
||||
{ relay: 1, direction: "entry" },
|
||||
{ relay: 2, direction: "exit" },
|
||||
{ relay: 3, direction: "both" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
const relay = direction === "entry" ? 1 : direction === "exit" ? 2 : 3;
|
||||
const camId = randomUUID();
|
||||
db.insert(devices).values({
|
||||
id: camId,
|
||||
category: "camera",
|
||||
driverId: "hikvision",
|
||||
config: { host: "10.0.0.9", controllerId, relay },
|
||||
enabled: true,
|
||||
}).run();
|
||||
return camId;
|
||||
}
|
||||
|
||||
/** Capture lane-status events emitted during `fn`. */
|
||||
function captureEmits(fn: () => void): LaneStatusEvent[] {
|
||||
const got: LaneStatusEvent[] = [];
|
||||
const off = deviceEvents.onLaneStatus((e) => got.push(e));
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
describe("LaneStatus", () => {
|
||||
it("marks the camera's bound lane busy on a vehicle detection, free until then", () => {
|
||||
const cam = seedCamera("entry");
|
||||
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
|
||||
|
||||
const emits = captureEmits(() => lane.vehicleDetected(cam));
|
||||
expect(lane.snapshot()).toEqual({ entry: true, exit: false });
|
||||
expect(emits).toEqual([{ entry: true, exit: false }]); // emitted on the flip
|
||||
});
|
||||
|
||||
it("auto-clears to free after the TTL (no leave signal from the camera)", () => {
|
||||
const cam = seedCamera("entry");
|
||||
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||
lane.vehicleDetected(cam);
|
||||
expect(lane.snapshot().entry).toBe(true);
|
||||
|
||||
const emits = captureEmits(() => vi.advanceTimersByTime(90_001));
|
||||
expect(lane.snapshot().entry).toBe(false);
|
||||
expect(emits).toEqual([{ entry: false, exit: false }]);
|
||||
});
|
||||
|
||||
it("re-arms the timer on each detection (a parked car keeps the lane busy)", () => {
|
||||
const cam = seedCamera("entry");
|
||||
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||
lane.vehicleDetected(cam);
|
||||
// Re-fire just before the TTL — should NOT clear, and should push the clear out.
|
||||
vi.advanceTimersByTime(80_000);
|
||||
lane.vehicleDetected(cam);
|
||||
vi.advanceTimersByTime(80_000); // 160s total, but only 80s since the last detect
|
||||
expect(lane.snapshot().entry).toBe(true);
|
||||
// Now let it lapse fully.
|
||||
vi.advanceTimersByTime(90_001);
|
||||
expect(lane.snapshot().entry).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT re-emit on a repeat detection while already busy (only state flips)", () => {
|
||||
const cam = seedCamera("entry");
|
||||
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||
lane.vehicleDetected(cam); // flip -> emits
|
||||
const emits = captureEmits(() => {
|
||||
lane.vehicleDetected(cam); // already busy -> no emit
|
||||
lane.vehicleDetected(cam);
|
||||
});
|
||||
expect(emits).toEqual([]);
|
||||
});
|
||||
|
||||
it("a 'both'-direction camera marks BOTH lanes busy", () => {
|
||||
const cam = seedCamera("both");
|
||||
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||
lane.vehicleDetected(cam);
|
||||
expect(lane.snapshot()).toEqual({ entry: true, exit: true });
|
||||
});
|
||||
|
||||
it("exit camera marks only the exit lane", () => {
|
||||
const cam = seedCamera("exit");
|
||||
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||
lane.vehicleDetected(cam);
|
||||
expect(lane.snapshot()).toEqual({ entry: false, exit: true });
|
||||
});
|
||||
|
||||
it("ignores an unknown device id", () => {
|
||||
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||
lane.vehicleDetected("nope");
|
||||
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
|
||||
import { directionOf } from "./device-resolve.js";
|
||||
|
||||
// Lane busy/free, driven by a camera's vehicle detection. ADVISORY ONLY — a detection
|
||||
// is a hint the booth shows as barrier lights; it never gates a ticket or opens a
|
||||
// barrier (see wiki/entities/lpr-camera.md, the advisory-only rule).
|
||||
//
|
||||
// A vehicle `active` event on a camera bound to entry/exit marks THAT lane busy and
|
||||
// (re)arms an auto-clear timer. This camera class sends NO leave/`inactive` signal, so
|
||||
// "free" is timeout-driven: the camera re-fires `active` while a car sits in the zone
|
||||
// (each refreshing the timer); once the car leaves, the actives stop and the lane
|
||||
// flips free after BUSY_TTL_MS. A "both"-direction camera marks BOTH lanes.
|
||||
|
||||
/** How long after the last vehicle detection a lane stays "busy" before clearing.
|
||||
* Must exceed the camera's `active` re-fire interval so a still-present car keeps the
|
||||
* lane busy. MEASURED on the test unit (controlled in/out test): the re-fire rate is
|
||||
* MOVEMENT-driven, not a fixed rate — ~1-3s apart while the car moves, but stretching
|
||||
* to ~15-25s when it sits MOTIONLESS in the zone. So the TTL must clear the still-car
|
||||
* gap (~25s) or a parked car flickers free. The camera has ~no dwell lag (it goes
|
||||
* silent within a second of the car leaving), so 30s clears promptly after departure
|
||||
* while keeping a motionless car solidly busy. Override with LANE_BUSY_TTL_MS. */
|
||||
export function busyTtlMs(): number {
|
||||
const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 30_000);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||
}
|
||||
|
||||
export class LaneStatus {
|
||||
readonly #db: Db;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #ttlMs: number;
|
||||
#entry = false;
|
||||
#exit = false;
|
||||
#entryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
#exitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger, ttlMs = busyTtlMs()) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
this.#ttlMs = ttlMs;
|
||||
}
|
||||
|
||||
/** Current snapshot (for the WS hello). */
|
||||
snapshot(): LaneStatusEvent {
|
||||
return { entry: this.#entry, exit: this.#exit };
|
||||
}
|
||||
|
||||
/**
|
||||
* A vehicle was detected by camera `deviceId`. Resolves the camera's bound direction
|
||||
* and marks that lane busy + (re)arms its auto-clear. Best-effort: an unknown camera
|
||||
* or a non-vehicle caller is the caller's concern — this only handles a confirmed
|
||||
* vehicle detection. Emits a lane-status change only when the state actually flips.
|
||||
*/
|
||||
vehicleDetected(deviceId: string): void {
|
||||
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||
if (!row) return;
|
||||
const dir = directionOf(this.#db, row);
|
||||
if (dir === "entry" || dir === "both") this.#mark("entry");
|
||||
if (dir === "exit" || dir === "both") this.#mark("exit");
|
||||
}
|
||||
|
||||
#mark(lane: "entry" | "exit"): void {
|
||||
const was = lane === "entry" ? this.#entry : this.#exit;
|
||||
if (lane === "entry") this.#entry = true;
|
||||
else this.#exit = true;
|
||||
|
||||
// (Re)arm the auto-clear — each detection pushes the free-flip further out.
|
||||
const existing = lane === "entry" ? this.#entryTimer : this.#exitTimer;
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => this.#clear(lane), this.#ttlMs);
|
||||
timer.unref?.(); // never hold the process open
|
||||
if (lane === "entry") this.#entryTimer = timer;
|
||||
else this.#exitTimer = timer;
|
||||
|
||||
if (!was) {
|
||||
this.#logger.info(`lane-status: ${lane} -> busy`);
|
||||
this.#emit();
|
||||
}
|
||||
}
|
||||
|
||||
#clear(lane: "entry" | "exit"): void {
|
||||
if (lane === "entry") {
|
||||
this.#entry = false;
|
||||
this.#entryTimer = null;
|
||||
} else {
|
||||
this.#exit = false;
|
||||
this.#exitTimer = null;
|
||||
}
|
||||
this.#logger.info(`lane-status: ${lane} -> free`);
|
||||
this.#emit();
|
||||
}
|
||||
|
||||
#emit(): void {
|
||||
deviceEvents.emitLaneStatus(this.snapshot());
|
||||
}
|
||||
|
||||
/** Clear timers on shutdown. */
|
||||
stop(): void {
|
||||
if (this.#entryTimer) clearTimeout(this.#entryTimer);
|
||||
if (this.#exitTimer) clearTimeout(this.#exitTimer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appLogs, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { LogService, pinoDbStream } from "./log-service.js";
|
||||
|
||||
// pinoDbStream feeds backend warn+ lines into app_logs. Since 2026-07-04 the logger
|
||||
// emits level NAMES ("warn") instead of pino's numeric codes (40) — for human-readable
|
||||
// container logs — and the stream must accept BOTH encodings (numeric covers any
|
||||
// default-configured pino). A level the tee can't resolve falls back to info → not
|
||||
// persisted, never a crash.
|
||||
|
||||
let db: Db;
|
||||
let stream: { write: (line: string) => void };
|
||||
let teed: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
teed = [];
|
||||
stream = pinoDbStream(new LogService(db), {
|
||||
write: (line: string) => {
|
||||
teed.push(line);
|
||||
return true;
|
||||
},
|
||||
} as unknown as NodeJS.WritableStream);
|
||||
});
|
||||
|
||||
const rows = () => db.select().from(appLogs).all();
|
||||
|
||||
describe("pinoDbStream level encodings", () => {
|
||||
it("persists a LABEL-level warn line (the current logger format)", () => {
|
||||
stream.write(`{"level":"warn","time":"2026-07-04T18:14:11.453Z","msg":"label warn"}\n`);
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({ level: "warn", source: "backend", message: "label warn" });
|
||||
});
|
||||
|
||||
it("still persists a NUMERIC-level error line (legacy/default pino)", () => {
|
||||
stream.write(`{"level":50,"time":1783179038453,"msg":"numeric error"}\n`);
|
||||
expect(rows()[0]).toMatchObject({ level: "error", message: "numeric error" });
|
||||
});
|
||||
|
||||
it("info stays stdout-only in both encodings (teed, not persisted)", () => {
|
||||
stream.write(`{"level":"info","msg":"label info"}\n`);
|
||||
stream.write(`{"level":30,"msg":"numeric info"}\n`);
|
||||
expect(rows()).toHaveLength(0);
|
||||
expect(teed).toHaveLength(2); // stdout tee always happens
|
||||
});
|
||||
|
||||
it("an unresolvable level falls back to info (dropped), never throws", () => {
|
||||
stream.write(`{"level":"loud","msg":"weird"}\n`);
|
||||
stream.write(`not json at all\n`);
|
||||
expect(rows()).toHaveLength(0);
|
||||
expect(teed).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
|
||||
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
|
||||
// context._repeat) instead of inserting — one screaming device can't evict unrelated
|
||||
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
|
||||
// context._firstAt.
|
||||
describe("storm coalescing", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
|
||||
}
|
||||
const all = rows();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].context).toMatchObject({ _repeat: 200 });
|
||||
expect(teed).toHaveLength(200); // stdout still gets every line
|
||||
});
|
||||
|
||||
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
|
||||
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||
const [row] = rows();
|
||||
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
|
||||
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("a different message (or level) breaks the run — separate rows", () => {
|
||||
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||
stream.write(`{"level":"error","msg":"boom B"}\n`);
|
||||
stream.write(`{"level":"warn","msg":"boom B"}\n`);
|
||||
expect(rows()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("an occurrence past the 5-minute window starts a fresh row", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
|
||||
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||
expect(rows()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
|
||||
vi.useFakeTimers();
|
||||
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
vi.setSystemTime(new Date(t));
|
||||
stream.write(`{"level":"error","msg":"storm"}\n`);
|
||||
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
|
||||
}
|
||||
const all = rows();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].context).toMatchObject({ _repeat: 10 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, appLogs, desc, eq, sql, type Db } from "@parking/db";
|
||||
import {
|
||||
LOG_LEVEL_ORDER,
|
||||
type AppLogRecord,
|
||||
type ClientLogInput,
|
||||
type LogLevel,
|
||||
type LogSource,
|
||||
} from "@parking/shared";
|
||||
|
||||
// Application/diagnostic LOG SINK — the host-side store behind the third log stream
|
||||
// (app_logs), distinct from the signed ledger and device telemetry. It persists:
|
||||
// - BACKEND warn/error/fatal, fed by a pino stream (see pinoDbStream) so any
|
||||
// app.log.warn/error lands in the DB without changing call sites.
|
||||
// - FRONTEND errors POSTed to /api/logs (failed requests, uncaught errors).
|
||||
// Everything here is UNSIGNED + prunable. Pruned by age AND a row cap so an offline
|
||||
// appliance with finite disk can't be filled by a log storm. See
|
||||
// wiki/concepts/app-logs.md, decisions/event-streams-split.md.
|
||||
|
||||
/** Only warn and above are persisted from the backend (info/debug stay stdout-only). */
|
||||
const BACKEND_PERSIST_MIN: LogLevel = "warn";
|
||||
|
||||
/** Defensive caps so one runaway log can't bloat a row (chars). */
|
||||
const MAX_MESSAGE = 4_000;
|
||||
const MAX_STACK = 16_000;
|
||||
const MAX_CONTEXT_JSON = 16_000;
|
||||
|
||||
/** Storm coalescing: a line identical to the LAST persisted one (level+source+message+
|
||||
* path) within this window of its previous occurrence UPDATES that row (bumping a
|
||||
* `_repeat` counter in its context) instead of inserting a new one. A continuous storm
|
||||
* keeps refreshing the window, so it stays ONE row however long it rages — repeated
|
||||
* errors can't evict unrelated history or grind the appliance disk (field incident
|
||||
* 2026-07-07: one unreachable controller ≈ hundreds of identical rows/minute). */
|
||||
const COALESCE_WINDOW_MS = 300_000;
|
||||
|
||||
export interface LogRetention {
|
||||
/** Delete logs older than this many days. */
|
||||
readonly maxAgeDays: number;
|
||||
/** Hard cap on total rows — the oldest beyond this are pruned. */
|
||||
readonly maxRows: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_RETENTION: LogRetention = {
|
||||
// 60 days (~2 months) — the operator's chosen diagnostic window (2026-07-04),
|
||||
// matched by the container-log rotation caps in docker-compose.prod.yml. The row
|
||||
// cap below still bounds a burst regardless of age.
|
||||
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 60),
|
||||
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
|
||||
};
|
||||
|
||||
function clamp(s: string | null | undefined, max: number): string | null {
|
||||
if (s == null) return null;
|
||||
return s.length > max ? s.slice(0, max) : s;
|
||||
}
|
||||
|
||||
/** Serialize context to JSON, bounded — never throw on a circular/huge object. */
|
||||
function safeContext(ctx: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
||||
if (ctx == null) return null;
|
||||
try {
|
||||
const json = JSON.stringify(ctx);
|
||||
if (json.length <= MAX_CONTEXT_JSON) return ctx;
|
||||
return { _truncated: true, preview: json.slice(0, MAX_CONTEXT_JSON) };
|
||||
} catch {
|
||||
return { _unserializable: true };
|
||||
}
|
||||
}
|
||||
|
||||
export class LogService {
|
||||
readonly #db: Db;
|
||||
readonly #retention: LogRetention;
|
||||
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
||||
#writing = false;
|
||||
/** The last persisted row, for storm coalescing (in-memory only; a restart just
|
||||
* starts a fresh row — best-effort, like everything in this sink). */
|
||||
#last: {
|
||||
id: string;
|
||||
key: string;
|
||||
count: number;
|
||||
firstAt: string;
|
||||
lastAtMs: number;
|
||||
baseContext: Record<string, unknown> | null;
|
||||
} | null = null;
|
||||
|
||||
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
||||
this.#db = db;
|
||||
this.#retention = retention;
|
||||
}
|
||||
|
||||
/** Low-level insert. Best-effort: a logging failure must never break a request or
|
||||
* recurse (a DB error here would otherwise log → insert → error → log …). */
|
||||
#insert(row: {
|
||||
level: LogLevel;
|
||||
source: LogSource;
|
||||
message: string;
|
||||
context?: Record<string, unknown> | null;
|
||||
httpStatus?: number | null;
|
||||
path?: string | null;
|
||||
stack?: string | null;
|
||||
userId?: string | null;
|
||||
userAgent?: string | null;
|
||||
createdAt?: string;
|
||||
}): void {
|
||||
if (this.#writing) return;
|
||||
this.#writing = true;
|
||||
try {
|
||||
const createdAt = row.createdAt ?? new Date().toISOString();
|
||||
const message = clamp(row.message, MAX_MESSAGE) ?? "";
|
||||
const path = clamp(row.path, 512);
|
||||
const key = `${row.level}|${row.source}|${message}|${path ?? ""}`;
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Storm coalescing: identical to the last persisted row, within the window →
|
||||
// bump that row instead of inserting. createdAt moves to the LATEST occurrence
|
||||
// (keeps the storm visible at the top of the newest-first viewer); the first
|
||||
// occurrence's time is preserved in context._firstAt.
|
||||
const last = this.#last;
|
||||
if (last && last.key === key && nowMs - last.lastAtMs <= COALESCE_WINDOW_MS) {
|
||||
const res = this.#db
|
||||
.update(appLogs)
|
||||
.set({
|
||||
context: { ...(last.baseContext ?? {}), _repeat: last.count + 1, _firstAt: last.firstAt },
|
||||
createdAt,
|
||||
})
|
||||
.where(eq(appLogs.id, last.id))
|
||||
.run();
|
||||
if ((res.changes ?? 0) > 0) {
|
||||
last.count += 1;
|
||||
last.lastAtMs = nowMs;
|
||||
return;
|
||||
}
|
||||
// The row was pruned out from under us — fall through to a fresh insert.
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const baseContext = safeContext(row.context);
|
||||
this.#db
|
||||
.insert(appLogs)
|
||||
.values({
|
||||
id,
|
||||
level: row.level,
|
||||
source: row.source,
|
||||
message,
|
||||
context: baseContext,
|
||||
httpStatus: row.httpStatus ?? null,
|
||||
path,
|
||||
stack: clamp(row.stack, MAX_STACK),
|
||||
userId: row.userId ?? null,
|
||||
userAgent: clamp(row.userAgent, 512),
|
||||
createdAt,
|
||||
})
|
||||
.run();
|
||||
this.#last = { id, key, count: 1, firstAt: createdAt, lastAtMs: nowMs, baseContext };
|
||||
} catch {
|
||||
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
||||
// it; that's the recursion we're guarding against.)
|
||||
} finally {
|
||||
this.#writing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a BACKEND log line (called by the pino stream). Below warn is dropped. */
|
||||
recordBackend(level: LogLevel, message: string, context?: Record<string, unknown> | null): void {
|
||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||
this.#insert({ level, source: "backend", message, context });
|
||||
}
|
||||
|
||||
/** Persist a FRONTEND-reported log (from POST /api/logs). The server stamps the
|
||||
* user + receive time; the client supplies level/message/context. */
|
||||
recordClient(
|
||||
input: ClientLogInput,
|
||||
meta: { userId?: string | null; userAgent?: string | null },
|
||||
): void {
|
||||
this.#insert({
|
||||
level: input.level,
|
||||
source: "frontend",
|
||||
message: input.message,
|
||||
context: input.context ?? null,
|
||||
httpStatus: input.httpStatus ?? null,
|
||||
path: input.path ?? null,
|
||||
stack: input.stack ?? null,
|
||||
userId: meta.userId ?? null,
|
||||
userAgent: meta.userAgent ?? null,
|
||||
// Keep the client's capture time in context for ordering; createdAt is server time.
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Read recent logs, newest first, with optional level/source/since filters. */
|
||||
query(opts: {
|
||||
limit: number;
|
||||
level?: LogLevel;
|
||||
source?: LogSource;
|
||||
since?: string;
|
||||
}): AppLogRecord[] {
|
||||
const conds = [];
|
||||
if (opts.level) conds.push(eq(appLogs.level, opts.level));
|
||||
if (opts.source) conds.push(eq(appLogs.source, opts.source));
|
||||
if (opts.since) conds.push(sql`${appLogs.createdAt} >= ${opts.since}`);
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(appLogs)
|
||||
.where(conds.length ? and(...conds) : undefined)
|
||||
.orderBy(desc(appLogs.createdAt))
|
||||
.limit(opts.limit)
|
||||
.all();
|
||||
return rows as unknown as AppLogRecord[];
|
||||
}
|
||||
|
||||
/** Prune by age then by row cap. Returns how many rows were deleted. Safe to call
|
||||
* on a timer; cheap (indexed on created_at). */
|
||||
prune(): number {
|
||||
let deleted = 0;
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - this.#retention.maxAgeDays * 86_400_000).toISOString();
|
||||
const byAge = this.#db.delete(appLogs).where(sql`${appLogs.createdAt} < ${cutoff}`).run();
|
||||
deleted += byAge.changes ?? 0;
|
||||
|
||||
// Row cap: keep the newest maxRows, delete the rest. One subquery — find the
|
||||
// created_at boundary of the keep-window, delete older.
|
||||
const total = this.#db.select({ c: sql<number>`count(*)` }).from(appLogs).get();
|
||||
const count = total?.c ?? 0;
|
||||
if (count > this.#retention.maxRows) {
|
||||
const boundary = this.#db
|
||||
.select({ createdAt: appLogs.createdAt })
|
||||
.from(appLogs)
|
||||
.orderBy(desc(appLogs.createdAt))
|
||||
.limit(1)
|
||||
.offset(this.#retention.maxRows - 1)
|
||||
.get();
|
||||
if (boundary) {
|
||||
const byCap = this.#db
|
||||
.delete(appLogs)
|
||||
.where(sql`${appLogs.createdAt} < ${boundary.createdAt}`)
|
||||
.run();
|
||||
deleted += byCap.changes ?? 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
|
||||
* Pino writes one JSON object per line to this stream; we parse, resolve the level
|
||||
* (name or numeric encoding), and persist. Returned as `{ write }` so it can be passed
|
||||
* as pino's stream. stdout still receives the same line (we tee), so console logging is
|
||||
* unchanged.
|
||||
*/
|
||||
export function pinoDbStream(
|
||||
service: LogService,
|
||||
tee: NodeJS.WritableStream,
|
||||
): { write: (line: string) => void } {
|
||||
const NUM_TO_LEVEL: Record<number, LogLevel> = {
|
||||
10: "trace",
|
||||
20: "debug",
|
||||
30: "info",
|
||||
40: "warn",
|
||||
50: "error",
|
||||
60: "fatal",
|
||||
};
|
||||
return {
|
||||
write(line: string): void {
|
||||
// Always tee to the original destination first (don't lose stdout logging).
|
||||
try {
|
||||
tee.write(line);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(line) as {
|
||||
level?: number | string;
|
||||
msg?: string;
|
||||
err?: { stack?: string; message?: string };
|
||||
[k: string]: unknown;
|
||||
};
|
||||
// The logger emits level NAMES (formatters.level in server.ts, for human-
|
||||
// readable container logs); a default pino config emits numbers. Accept both.
|
||||
const level: LogLevel =
|
||||
typeof obj.level === "string" && obj.level in LOG_LEVEL_ORDER
|
||||
? (obj.level as LogLevel)
|
||||
: NUM_TO_LEVEL[typeof obj.level === "number" ? obj.level : 30] ?? "info";
|
||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||
// Strip pino's noisy standard fields from the persisted context.
|
||||
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
|
||||
service.recordBackend(level, typeof msg === "string" ? msg : "", rest);
|
||||
} catch {
|
||||
// A non-JSON line (shouldn't happen with pino) — ignore for persistence.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||
import { getOccupancy, occupancyCount, reservedSubscriberSpots } from "./occupancy.js";
|
||||
|
||||
// Occupancy is a FOLD over the signed ledger, never a stored counter. These tests
|
||||
// pin: the entries-minus-exits count, the capacity/full gate, and the reserved-
|
||||
// subscriber-spots model (its trickiest invariant — never double-count a parked
|
||||
// subscriber, and never gate the subscriber's own entry).
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
});
|
||||
afterEach(() => close());
|
||||
|
||||
// Insert a ledger row directly (these fns read raw rows; signing is event-log's job).
|
||||
let idx = 0;
|
||||
function entry(identity: string, payload?: Record<string, unknown>) {
|
||||
idx += 1;
|
||||
db.insert(ledgerEvents).values({
|
||||
id: `e${idx}`, index: idx, type: "vehicle_entry", direction: "entry",
|
||||
identity, payload: payload ?? null, occurredAt: new Date().toISOString(),
|
||||
signature: "x", keyId: "test",
|
||||
}).run();
|
||||
}
|
||||
function exit(identity: string) {
|
||||
idx += 1;
|
||||
db.insert(ledgerEvents).values({
|
||||
id: `e${idx}`, index: idx, type: "vehicle_exit", direction: "exit",
|
||||
identity, payload: null, occurredAt: new Date().toISOString(),
|
||||
signature: "x", keyId: "test",
|
||||
}).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>) {
|
||||
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
||||
}
|
||||
|
||||
describe("occupancyCount", () => {
|
||||
beforeEach(() => { idx = 0; });
|
||||
|
||||
it("is 0 with no events", () => {
|
||||
expect(occupancyCount(db)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts open sessions (entries minus matching exits)", () => {
|
||||
entry("A"); entry("B"); entry("C");
|
||||
exit("B");
|
||||
expect(occupancyCount(db)).toBe(2);
|
||||
});
|
||||
|
||||
it("a re-entry after exit counts again", () => {
|
||||
entry("A"); exit("A"); entry("A");
|
||||
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", () => {
|
||||
beforeEach(() => { idx = 0; });
|
||||
|
||||
it("uncapped: never full, free/effectiveFree null", () => {
|
||||
setSite({ capacity: null });
|
||||
entry("A");
|
||||
const o = getOccupancy(db);
|
||||
expect(o.full).toBe(false);
|
||||
expect(o.free).toBeNull();
|
||||
expect(o.effectiveFree).toBeNull();
|
||||
});
|
||||
|
||||
it("capped: full when count reaches capacity", () => {
|
||||
setSite({ capacity: 2 });
|
||||
entry("A");
|
||||
expect(getOccupancy(db).full).toBe(false);
|
||||
entry("B");
|
||||
const o = getOccupancy(db);
|
||||
expect(o.full).toBe(true);
|
||||
expect(o.free).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reservedSubscriberSpots", () => {
|
||||
beforeEach(() => { idx = 0; });
|
||||
|
||||
function addSub(id: string, opts: Partial<typeof subscriptions.$inferInsert> = {}) {
|
||||
db.insert(subscriptions).values({ id, status: "active", quantity: 1, period: "month", ...opts }).run();
|
||||
}
|
||||
|
||||
it("is 0 when the toggle is off (default)", () => {
|
||||
setSite({ capacity: 10, reserveSubscriberSpots: false });
|
||||
addSub("s1", { quantity: 2 });
|
||||
expect(reservedSubscriberSpots(db)).toBe(0);
|
||||
});
|
||||
|
||||
it("holds quantity spots for an active, not-parked subscription", () => {
|
||||
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||
addSub("s1", { quantity: 2 });
|
||||
expect(reservedSubscriberSpots(db)).toBe(2);
|
||||
});
|
||||
|
||||
it("does NOT double-count a subscriber already parked (holds only the rest)", () => {
|
||||
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||
addSub("s1", { quantity: 2 });
|
||||
// One of the family's two cars is inside (occurrence entry carries permitId = sub id).
|
||||
entry("SUBSESS-1", { permitId: "s1" });
|
||||
expect(reservedSubscriberSpots(db)).toBe(1); // 2 quantity − 1 inside
|
||||
});
|
||||
|
||||
it("ignores suspended/revoked and out-of-window subscriptions", () => {
|
||||
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||
addSub("active", { quantity: 1 });
|
||||
addSub("suspended", { quantity: 5, status: "suspended" });
|
||||
addSub("expired", { quantity: 5, validTo: "2000-01-01T00:00:00.000Z" });
|
||||
expect(reservedSubscriberSpots(db)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOccupancy — reserved tightens the transient gate", () => {
|
||||
beforeEach(() => { idx = 0; });
|
||||
|
||||
it("transient sees full once count + reserved ≥ capacity", () => {
|
||||
setSite({ capacity: 3, reserveSubscriberSpots: true });
|
||||
db.insert(subscriptions).values({ id: "s1", status: "active", quantity: 2, period: "month" }).run();
|
||||
entry("A"); // 1 inside + 2 reserved = 3 ≥ capacity 3
|
||||
const o = getOccupancy(db);
|
||||
expect(o.reserved).toBe(2);
|
||||
expect(o.effectiveFree).toBe(0);
|
||||
expect(o.full).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||
|
||||
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
||||
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
||||
// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md.
|
||||
|
||||
export interface Occupancy {
|
||||
/** Cars currently inside (open sessions). */
|
||||
readonly count: number;
|
||||
/** Spots HELD for active subscribers who are NOT currently parked (when the
|
||||
* reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds
|
||||
* `quantity` spots minus however many of its cars are already inside. */
|
||||
readonly reserved: number;
|
||||
/** Admin-set nominal capacity, or null = no limit. */
|
||||
readonly capacity: number | null;
|
||||
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
||||
readonly free: number | null;
|
||||
/** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */
|
||||
readonly effectiveFree: number | null;
|
||||
/** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity
|
||||
* (always false when uncapped). Subscribers are never gated by this. */
|
||||
readonly full: boolean;
|
||||
}
|
||||
|
||||
/** Count cars inside: entries minus exits, per identity, over the ledger. */
|
||||
export function occupancyCount(db: Db): number {
|
||||
const rows = db
|
||||
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||
.from(ledgerEvents)
|
||||
.all();
|
||||
const balance = new Map<string, number>();
|
||||
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);
|
||||
else if (r.type === "vehicle_exit" || r.type === "void")
|
||||
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||
}
|
||||
let open = 0;
|
||||
for (const v of balance.values()) if (v > 0) open += 1;
|
||||
return open;
|
||||
}
|
||||
|
||||
/** Admin-set capacity (null = uncapped). */
|
||||
export function siteCapacity(db: Db): number | null {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return row?.capacity ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless
|
||||
* `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status
|
||||
* active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that
|
||||
* subscription already inside (so we never double-count a parked subscriber). This is
|
||||
* what makes a transient see "full" sooner while the subscriber's spot is held.
|
||||
*/
|
||||
export function reservedSubscriberSpots(db: Db): number {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (!cfg?.reserveSubscriberSpots) return 0;
|
||||
|
||||
// Cars currently inside per subscription (occurrence entries by permitId, net of exits).
|
||||
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
const insidePerSub = new Map<string, number>();
|
||||
const net = new Map<string, number>(); // occurrence identity → entries−exits
|
||||
const subOf = new Map<string, string>(); // occurrence identity → subscription id
|
||||
for (const r of rows) {
|
||||
const id = r.identity;
|
||||
if (!id) continue;
|
||||
if (r.type === "vehicle_entry") {
|
||||
const pl = (r.payload ?? {}) as { permitId?: string };
|
||||
if (pl.permitId == null) continue; // transient
|
||||
net.set(id, (net.get(id) ?? 0) + 1);
|
||||
subOf.set(id, pl.permitId);
|
||||
} else if (r.type === "vehicle_exit" || r.type === "void") {
|
||||
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
||||
}
|
||||
}
|
||||
for (const [id, n] of net) if (n > 0) {
|
||||
const sub = subOf.get(id)!;
|
||||
insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const subs = db.select().from(subscriptions).all();
|
||||
let reserved = 0;
|
||||
for (const s of subs) {
|
||||
const active =
|
||||
s.status === "active" &&
|
||||
(s.validFrom == null || now >= s.validFrom) &&
|
||||
(s.validTo == null || now <= s.validTo);
|
||||
if (!active) continue;
|
||||
const qty = s.quantity ?? 1;
|
||||
const inside = insidePerSub.get(s.id) ?? 0;
|
||||
reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion
|
||||
}
|
||||
return reserved;
|
||||
}
|
||||
|
||||
export function getOccupancy(db: Db): Occupancy {
|
||||
const count = occupancyCount(db);
|
||||
const capacity = siteCapacity(db);
|
||||
const reserved = reservedSubscriberSpots(db);
|
||||
return {
|
||||
count,
|
||||
reserved,
|
||||
capacity,
|
||||
free: capacity == null ? null : capacity - count,
|
||||
effectiveFree: capacity == null ? null : capacity - count - reserved,
|
||||
// A transient is refused once physical cars + held subscriber spots reach capacity.
|
||||
full: capacity != null && count + reserved >= capacity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||
import { PayStation, NoOpenSessionError, NoTariffError } from "./pay-station.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
|
||||
|
||||
// The pay station prices an open session against the tariff frozen at entry and writes
|
||||
// a SIGNED payment event (never a mutable "paid" flag). These tests pin the quote math,
|
||||
// the signed-payment side effect, the no-session / no-tariff errors, and the lookup
|
||||
// view the booth modal reads.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let log: EventLog;
|
||||
let pay: PayStation;
|
||||
|
||||
beforeEach(() => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
log = makeLog(db);
|
||||
pay = new PayStation(db, log, silentLogger());
|
||||
});
|
||||
afterEach(() => close());
|
||||
|
||||
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
|
||||
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
|
||||
}
|
||||
|
||||
describe("PayStation.quote", () => {
|
||||
it("throws NoOpenSessionError for an unknown ticket", () => {
|
||||
seedTariff(db);
|
||||
expect(() => pay.quote("nope")).toThrow(NoOpenSessionError);
|
||||
});
|
||||
|
||||
it("throws NoTariffError when no site tariff is configured", async () => {
|
||||
await enter("T1", minutesAgo(120));
|
||||
expect(() => pay.quote("T1")).toThrow(NoTariffError);
|
||||
});
|
||||
|
||||
it("prices a stay against the frozen tariff (90min → 2 increments at 100/h = 200)", async () => {
|
||||
// 90 min rounds UP to a 2nd 60-min increment; well clear of the boundary so a few
|
||||
// ms of test runtime can't tip it into a 3rd increment.
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
|
||||
await enter("T1", minutesAgo(90));
|
||||
const q = pay.quote("T1");
|
||||
expect(q.amountMinor).toBe(20000);
|
||||
expect(q.currency).toBe("ALL");
|
||||
expect(q.overstay).toBe(false);
|
||||
});
|
||||
|
||||
it("prices 0 within the entry grace (quick in-and-out)", async () => {
|
||||
seedTariff(db, { gracePeriodEntryMin: 10 });
|
||||
await enter("T1", minutesAgo(5));
|
||||
expect(pay.quote("T1").amountMinor).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayStation.pay — signed payment side effect", () => {
|
||||
it("appends a signed payment event carrying amount, currency, tender, grace", async () => {
|
||||
const { currency } = seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("T1", minutesAgo(90));
|
||||
|
||||
const res = await pay.pay("T1", "cash");
|
||||
expect(res.amountMinor).toBe(20000);
|
||||
expect(res.currency).toBe(currency);
|
||||
|
||||
const payments = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all();
|
||||
expect(payments).toHaveLength(1);
|
||||
const pl = payments[0].payload as Record<string, unknown>;
|
||||
expect(pl.amountMinor).toBe(20000);
|
||||
expect(pl.tender).toBe("cash");
|
||||
expect(pl.graceExitMin).toBe(15);
|
||||
// It must be a real signed chain event.
|
||||
expect(log.verifyChain()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("honours an operator override amount (lost ticket / dispute)", async () => {
|
||||
seedTariff(db);
|
||||
await enter("T1", minutesAgo(120));
|
||||
const res = await pay.pay("T1", "card", 99900);
|
||||
expect(res.amountMinor).toBe(99900);
|
||||
const pl = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all()[0].payload as Record<string, unknown>;
|
||||
expect(pl.amountMinor).toBe(99900);
|
||||
expect(pl.reason).toBe("operator-set amount");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayStation.lookup — booth modal view", () => {
|
||||
it("reports not-found for an unknown ticket", () => {
|
||||
const v = pay.lookup("ghost");
|
||||
expect(v.found).toBe(false);
|
||||
expect(v.open).toBe(false);
|
||||
});
|
||||
|
||||
it("shows an open unpaid transient with the amount owed", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
await enter("T1", minutesAgo(90));
|
||||
const v = pay.lookup("T1");
|
||||
expect(v.found).toBe(true);
|
||||
expect(v.open).toBe(true);
|
||||
expect(v.paidAt).toBeNull();
|
||||
expect(v.amountMinor).toBe(20000);
|
||||
expect(v.subscription).toBe(false);
|
||||
});
|
||||
|
||||
it("after payment shows paid + within grace, amount cleared", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("T1", minutesAgo(120));
|
||||
await pay.pay("T1", "cash");
|
||||
const v = pay.lookup("T1");
|
||||
expect(v.paidAt).not.toBeNull();
|
||||
expect(v.withinGrace).toBe(true);
|
||||
expect(v.overstay).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a subscription occurrence (prepaid — never a transient charge)", async () => {
|
||||
seedTariff(db);
|
||||
await enter("SUBSESS-1", minutesAgo(120), { permit: true, permitId: "sub-1" });
|
||||
const v = pay.lookup("SUBSESS-1");
|
||||
expect(v.subscription).toBe(true);
|
||||
expect(v.subscriptionId).toBe("sub-1");
|
||||
expect(v.amountMinor).toBeNull(); // no timeframes → nothing owed
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayStation.activeSessions", () => {
|
||||
it("lists open sessions newest-first and omits exited-past-grace", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
await enter("OLD", minutesAgo(200));
|
||||
await enter("NEW", minutesAgo(30));
|
||||
const list = pay.activeSessions();
|
||||
expect(list.map((s) => s.identity)).toEqual(["NEW", "OLD"]);
|
||||
expect(list.every((s) => s.open)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,610 @@
|
||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||
import { windowOwedBetween } from "./subscription-window.js";
|
||||
import { liveValidations } from "./validations.js";
|
||||
|
||||
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||
// 1. quote(identity) → look up the open session, price it against the tariff in
|
||||
// force at entry, return the amount due (no side effect).
|
||||
// 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying
|
||||
// the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit
|
||||
// flow can validate paid + within walk-back grace). Payment is a signed ledger
|
||||
// event, never a mutable "paid" flag — an operator can't forge or delete it.
|
||||
// See wiki/concepts/tariff.md, parking-session.md.
|
||||
|
||||
export class NoOpenSessionError extends Error {
|
||||
constructor(identity: string) {
|
||||
super(`no open session for ${identity}`);
|
||||
this.name = "NoOpenSessionError";
|
||||
}
|
||||
}
|
||||
export class NoTariffError extends Error {
|
||||
constructor() {
|
||||
super("no active tariff configured");
|
||||
this.name = "NoTariffError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
readonly identity: string;
|
||||
/** Vehicle entry time (the session's original entry; for display/audit). */
|
||||
readonly enteredAt: string;
|
||||
/** Start of the period being billed RIGHT NOW. For a first payment this is the
|
||||
* entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car
|
||||
* re-parked / a new period began) it is the moment that grace expired: the overstay
|
||||
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
||||
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||
readonly periodStart: string;
|
||||
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
|
||||
readonly amountMinor: number;
|
||||
/** The pre-validation fee (= amountMinor when no validations apply). */
|
||||
readonly grossMinor: number;
|
||||
/** Total the merchant validations took off (gross − net). */
|
||||
readonly discountMinor: number;
|
||||
/** Per-validation receipt/display lines (empty when none apply). */
|
||||
readonly validationLines: ValidationLine[];
|
||||
/** The validation event ids this quote applied — the payment stamps them as
|
||||
* CONSUMED so an overstay's fresh period never re-applies them. */
|
||||
readonly validationIds: string[];
|
||||
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||
readonly overstay: boolean;
|
||||
readonly currency: string;
|
||||
readonly tariffVersionId: string;
|
||||
readonly graceExitMin: number;
|
||||
}
|
||||
|
||||
/** One row in the booth Active Sessions list. A session is "active" while it is
|
||||
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
|
||||
* paid/exited car is presumed possibly-still-present until grace expires. The
|
||||
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
|
||||
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
|
||||
export interface ActiveSession {
|
||||
readonly identity: string;
|
||||
readonly source: string | null;
|
||||
readonly enteredAt: string;
|
||||
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
|
||||
readonly exitedAt: string | null;
|
||||
readonly open: boolean;
|
||||
readonly paidAt: string | null;
|
||||
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
|
||||
readonly amountMinor: number | null;
|
||||
readonly currency: string | null;
|
||||
readonly withinGrace: boolean;
|
||||
readonly graceExpiresAt: string | null;
|
||||
/** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit.
|
||||
* The car either re-parked (a new period began) or is faulty/abandoned — not a system
|
||||
* fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from
|
||||
* grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator
|
||||
* reconciles via a top-up, instead of silently aging it out. No free barrier open.
|
||||
* See wiki/concepts/booth-exit-flow.md. */
|
||||
readonly overstay: boolean;
|
||||
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
|
||||
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
|
||||
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
|
||||
readonly subscription: boolean;
|
||||
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
|
||||
readonly subscriptionId: string | null;
|
||||
/** The subscriber's holder name (for a friendly label instead of the raw key). */
|
||||
readonly subscriptionHolder: string | null;
|
||||
/** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for
|
||||
* at-a-glance identification. Null when no plate was read. Never an access decision. */
|
||||
readonly plate: string | null;
|
||||
}
|
||||
|
||||
/** Booth session view: everything the pay/exit modal needs in one read. */
|
||||
export interface SessionLookup {
|
||||
readonly identity: string;
|
||||
readonly found: boolean;
|
||||
/** Open = entered, no exit yet. */
|
||||
readonly open: boolean;
|
||||
readonly enteredAt: string | null;
|
||||
readonly exitedAt: string | null;
|
||||
/** Latest payment time, if paid. */
|
||||
readonly paidAt: string | null;
|
||||
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||
readonly amountMinor: number | null;
|
||||
readonly currency: string | null;
|
||||
/** Amount actually PAID (from the latest payment event), if any. Distinct from
|
||||
* `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null,
|
||||
* but the operator still wants to see the sum that was collected. */
|
||||
readonly paidMinor: number | null;
|
||||
readonly paidCurrency: string | null;
|
||||
/** True when paid AND still within the walk-back grace window. */
|
||||
readonly withinGrace: boolean;
|
||||
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||
readonly graceExpiresAt: string | null;
|
||||
/** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period
|
||||
* began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */
|
||||
readonly overstay: boolean;
|
||||
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
|
||||
readonly subscription: boolean;
|
||||
readonly subscriptionId: string | null;
|
||||
readonly subscriptionHolder: string | null;
|
||||
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||
* none. Display/audit only — never an access decision. */
|
||||
readonly plate: string | null;
|
||||
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
|
||||
* fee, the total taken off, and the per-validation lines for the modal/receipt.
|
||||
* grossMinor/discountMinor are null when no quote resolved. */
|
||||
readonly grossMinor: number | null;
|
||||
readonly discountMinor: number | null;
|
||||
readonly validationLines: ValidationLine[];
|
||||
}
|
||||
|
||||
export class PayStation {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#log = log;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
|
||||
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
|
||||
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
|
||||
* own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the
|
||||
* whole-stay gross plateaus while prior payments keep pace, so the delta collapses to
|
||||
* 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period
|
||||
* reflects the reality and re-accrues the fee. No side effect. */
|
||||
quote(identity: string): Quote {
|
||||
const entry = this.#openEntry(identity);
|
||||
if (!entry) throw new NoOpenSessionError(identity);
|
||||
|
||||
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
|
||||
// for an overstay period — the customer keeps the rate card they entered under.
|
||||
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||
if (!tv) throw new NoTariffError();
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
|
||||
// Category was frozen in the signed vehicle_entry payload — pricing AND repricing
|
||||
// both read it from there, so a V2 category tariff yields the same amount at the
|
||||
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
||||
const category = (entry.payload as { category?: string } | null)?.category;
|
||||
|
||||
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
||||
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
|
||||
// prior payment) so the quote is NET — the payment then stamps their ids as
|
||||
// consumed. See wiki/concepts/validation-discounts.md.
|
||||
const last = this.#lastPayment(identity);
|
||||
const validations = liveValidations(this.#db, identity);
|
||||
const p = priceSession(
|
||||
entry.occurredAt,
|
||||
new Date().toISOString(),
|
||||
structure,
|
||||
last ? [last] : [],
|
||||
category,
|
||||
validations,
|
||||
);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
periodStart: p.periodStart,
|
||||
amountMinor: p.amountMinor,
|
||||
grossMinor: p.grossMinor,
|
||||
discountMinor: p.discountMinor,
|
||||
validationLines: p.validationLines,
|
||||
validationIds: validations.map((v) => v.eventId),
|
||||
overstay: p.overstay,
|
||||
currency: tv.currency,
|
||||
tariffVersionId: tv.id,
|
||||
graceExitMin: structure.gracePeriodExitMin,
|
||||
};
|
||||
}
|
||||
|
||||
/** The latest signed `payment` for this session (time + the grace window it granted),
|
||||
* or null if never paid. Folds the append-only ledger. */
|
||||
#lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null {
|
||||
const rows = this.#db
|
||||
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, identity))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
let last: { paidAt: string; graceExitMin: number | null } | null = null;
|
||||
for (const r of rows) {
|
||||
if (r.type !== "payment") continue;
|
||||
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
|
||||
last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null };
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
||||
* the moment of payment (the customer pays for time parked SO FAR). For an OVERSTAY
|
||||
* (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`),
|
||||
* and this payment writes a new `graceExitMin` so the walk-back window restarts.
|
||||
* `overrideMinor` lets the operator set an arbitrary amount (lost ticket / dispute) —
|
||||
* recorded as the charged amount.
|
||||
*/
|
||||
async pay(
|
||||
identity: string,
|
||||
tender: Tender,
|
||||
overrideMinor?: number,
|
||||
): Promise<{ amountMinor: number; currency: string }> {
|
||||
// A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here
|
||||
// (not a transient quote — the subscription itself is prepaid). The payment is keyed
|
||||
// to the occurrence so the exit gate (#windowOwed − payments) clears.
|
||||
const subWindow = this.#payableSubscriptionWindow(identity);
|
||||
if (subWindow) {
|
||||
const amountMinor = overrideMinor ?? subWindow.dueMinor;
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
amountMinor,
|
||||
currency: subWindow.currency ?? undefined,
|
||||
tender,
|
||||
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
|
||||
subscriptionWindowCharge: true,
|
||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
|
||||
},
|
||||
});
|
||||
this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`);
|
||||
return { amountMinor, currency: subWindow.currency ?? "" };
|
||||
}
|
||||
|
||||
const q = this.quote(identity);
|
||||
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||
|
||||
await this.#log.append({
|
||||
type: "payment",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
amountMinor,
|
||||
currency: q.currency,
|
||||
tender,
|
||||
tariffVersionId: q.tariffVersionId,
|
||||
// The exit flow reads graceExitMin off the payment to validate the
|
||||
// walk-back window without re-resolving the tariff.
|
||||
graceExitMin: q.graceExitMin,
|
||||
// Merchant validations: record the gross/discount split + CONSUME the applied
|
||||
// validation ids, so reporting sees the leakage and a later overstay period
|
||||
// never re-applies them. A zero-net settlement (full comp) is still a signed
|
||||
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
|
||||
...(q.validationIds.length
|
||||
? {
|
||||
grossMinor: q.grossMinor,
|
||||
discountMinor: q.discountMinor,
|
||||
validationIds: q.validationIds,
|
||||
validationLines: q.validationLines.map((l) => ({ ...l })),
|
||||
}
|
||||
: {}),
|
||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Update the projection cache (rebuildable; not the source of truth).
|
||||
try {
|
||||
this.#db.update(sessions).set({ state: "paid" }).where(eq(sessions.id, identity)).run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
||||
return { amountMinor, currency: q.currency };
|
||||
}
|
||||
|
||||
/**
|
||||
* One-read session view for the booth pay/exit modal: entry/exit times, paid
|
||||
* state, amount owed now, and walk-back-grace status. Read-only — folds the
|
||||
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
|
||||
* rather than throwing, so the modal can still show the session.
|
||||
*/
|
||||
lookup(identity: string): SessionLookup {
|
||||
const id = identity.trim();
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, id))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) {
|
||||
return {
|
||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||
withinGrace: false, graceExpiresAt: null,
|
||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||
grossMinor: null, discountMinor: null, validationLines: [],
|
||||
};
|
||||
}
|
||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
||||
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
|
||||
// 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;
|
||||
|
||||
let paidAt: string | null = null;
|
||||
let graceExitMin: number | null = null;
|
||||
let paidMinor: number | null = null;
|
||||
let paidCurrency: string | null = null;
|
||||
for (const r of rows) {
|
||||
if (r.type === "payment") {
|
||||
paidAt = r.occurredAt;
|
||||
const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string };
|
||||
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||
// Sum payments (overstay top-ups append a second one) so the displayed paid total
|
||||
// reflects everything collected for the session, not just the last slip.
|
||||
if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor;
|
||||
if (typeof p.currency === "string") paidCurrency = p.currency;
|
||||
}
|
||||
}
|
||||
const graceExpiresAt =
|
||||
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
||||
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
|
||||
|
||||
// Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
|
||||
// it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a
|
||||
// time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried
|
||||
// on the entry payload + a live late-exit charge), which the booth must take so the
|
||||
// exit gate clears. See wiki/entities/subscription.md.
|
||||
let amountMinor: number | null = null;
|
||||
let currency: string | null = null;
|
||||
let grossMinor: number | null = null;
|
||||
let discountMinor: number | null = null;
|
||||
let validationLines: ValidationLine[] = [];
|
||||
if (open && !isSubscription) {
|
||||
try {
|
||||
const q = this.quote(id);
|
||||
amountMinor = q.amountMinor;
|
||||
currency = q.currency;
|
||||
grossMinor = q.grossMinor;
|
||||
discountMinor = q.discountMinor;
|
||||
validationLines = q.validationLines;
|
||||
} catch {
|
||||
/* no active tariff — leave null; modal shows session without a price */
|
||||
}
|
||||
} else if (open && isSubscription) {
|
||||
const w = this.#subscriptionWindowDue(id, subscriptionId);
|
||||
if (w && w.dueMinor > 0) {
|
||||
amountMinor = w.dueMinor;
|
||||
currency = w.currency;
|
||||
}
|
||||
}
|
||||
|
||||
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
|
||||
|
||||
return {
|
||||
identity: id, found: true, open,
|
||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||
paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay,
|
||||
subscription: isSubscription, subscriptionId,
|
||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||
grossMinor, discountMinor, validationLines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
|
||||
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
|
||||
* until grace expires). One ledger scan, grouped by identity (cheaper than N
|
||||
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
|
||||
* (authoritative — not the sessions projection cache, which can drift).
|
||||
* See wiki/concepts/booth-exit-flow.md.
|
||||
*/
|
||||
activeSessions(): ActiveSession[] {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
|
||||
// Group the relevant events per identity in one pass.
|
||||
type Acc = {
|
||||
enteredAt?: string;
|
||||
source: string | null;
|
||||
exitedAt?: string;
|
||||
paidAt?: string;
|
||||
graceExitMin?: number;
|
||||
subscriptionId?: string | null;
|
||||
};
|
||||
const byId = new Map<string, Acc>();
|
||||
for (const r of rows) {
|
||||
const id = r.identity;
|
||||
if (!id) continue;
|
||||
if (r.type === "vehicle_entry") {
|
||||
const a = byId.get(id) ?? { source: r.source ?? null };
|
||||
a.enteredAt = r.occurredAt;
|
||||
a.source = r.source ?? a.source;
|
||||
// Subscription occurrence? The entry payload carries permit:true + permitId
|
||||
// (the on-chain field). Mark it so the booth never tries to charge it.
|
||||
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
||||
byId.set(id, a);
|
||||
} 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);
|
||||
if (a) a.exitedAt = r.occurredAt;
|
||||
} else if (r.type === "payment") {
|
||||
const a = byId.get(id);
|
||||
if (a) {
|
||||
a.paidAt = r.occurredAt;
|
||||
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve advisory plates for all candidate identities in ONE device_events scan
|
||||
// (cheaper than one lookup per row).
|
||||
const plates = platesForIdentities(this.#db, byId.keys());
|
||||
|
||||
const now = Date.now();
|
||||
const out: ActiveSession[] = [];
|
||||
for (const [identity, a] of byId) {
|
||||
if (!a.enteredAt) continue; // no entry → not a real session
|
||||
const open = a.exitedAt == null;
|
||||
const graceExpiresAt =
|
||||
a.paidAt && a.graceExitMin != null
|
||||
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
|
||||
: null;
|
||||
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
|
||||
const paid = a.paidAt != null;
|
||||
|
||||
const isSubscription = a.subscriptionId !== undefined;
|
||||
|
||||
// ACTIVE membership:
|
||||
// - exited + within grace → still shown (barrier unconfirmed, may be present);
|
||||
// - exited + past grace → presumed gone, omitted;
|
||||
// - open + UNPAID → always shown (a car owing money never ages out —
|
||||
// it's genuinely still inside until it pays, however long that takes);
|
||||
// - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace
|
||||
// lapsed with no signed vehicle_exit: the car re-parked (a new period) or is
|
||||
// faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy
|
||||
// and owes a fresh period (priced from grace-expiry, see `quote`). We used to
|
||||
// age these out (a silent display filter); now we KEEP them and flag `overstay`
|
||||
// so the operator reconciles via a top-up. The signed log is untouched, and the
|
||||
// barrier never opens for free on these. See booth-exit-flow.md.
|
||||
if (!open && !withinGrace) continue;
|
||||
const overstay =
|
||||
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
|
||||
|
||||
// Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new
|
||||
// period's top-up). A subscription is prepaid — never quote/charge it.
|
||||
let amountMinor: number | null = null;
|
||||
let currency: string | null = null;
|
||||
if (open && !isSubscription && (a.paidAt == null || overstay)) {
|
||||
try {
|
||||
const q = this.quote(identity);
|
||||
amountMinor = q.amountMinor;
|
||||
currency = q.currency;
|
||||
} catch {
|
||||
/* no active tariff — leave null */
|
||||
}
|
||||
}
|
||||
|
||||
out.push({
|
||||
identity,
|
||||
source: a.source,
|
||||
enteredAt: a.enteredAt,
|
||||
exitedAt: a.exitedAt ?? null,
|
||||
open,
|
||||
paidAt: a.paidAt ?? null,
|
||||
amountMinor,
|
||||
currency,
|
||||
withinGrace,
|
||||
graceExpiresAt,
|
||||
overstay,
|
||||
subscription: isSubscription,
|
||||
subscriptionId: a.subscriptionId ?? null,
|
||||
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
||||
plate: plates.get(identity)?.plate ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Newest entry first.
|
||||
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
|
||||
* now: the transient cost of the minutes parked OUTSIDE the plan's window over the WHOLE
|
||||
* stay `[entry, now]` (one computation — covers early entry AND late exit without
|
||||
* double-counting), minus whatever they've already paid against the occurrence. null
|
||||
* when the plan has no timeframes / nothing is owed. Single source of truth shared with
|
||||
* the exit gate so the booth quote and the gate agree.
|
||||
*/
|
||||
#subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null {
|
||||
if (!subscriptionId) return null;
|
||||
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||
if (!sub) return null;
|
||||
|
||||
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
|
||||
const entryRow = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entryRow) return null;
|
||||
|
||||
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
|
||||
if (!owed) return null;
|
||||
|
||||
let paid = 0;
|
||||
for (const r of rows) {
|
||||
if (r.type !== "payment") continue;
|
||||
const pl = (r.payload ?? {}) as { amountMinor?: number };
|
||||
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
|
||||
}
|
||||
|
||||
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
|
||||
}
|
||||
|
||||
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
|
||||
* the due amount + currency + the tariff version that priced the late-exit charge (for
|
||||
* the payment payload), or null when it's transient / nothing owed. */
|
||||
#payableSubscriptionWindow(
|
||||
identity: string,
|
||||
): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null {
|
||||
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return null;
|
||||
const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
if (ep.permit !== true && ep.permitId == null) return null; // transient
|
||||
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
|
||||
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
|
||||
if (!due || due.dueMinor <= 0) return null;
|
||||
// Tariff version for the payment payload = the one that priced the stay (resolved at
|
||||
// entry inside windowOwedBetween).
|
||||
const owed = windowOwedBetween(this.#db, this.#planVersionOf(ep.permitId ?? null), entry.occurredAt, new Date().toISOString());
|
||||
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: owed?.tariffVersionId ?? null };
|
||||
}
|
||||
|
||||
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
|
||||
#planVersionOf(subscriptionId: string | null): string | null {
|
||||
if (!subscriptionId) return null;
|
||||
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||
return row?.planVersionId ?? null;
|
||||
}
|
||||
|
||||
/** The subscriber's holder name for a subscription id (for a friendly UI label),
|
||||
* or null. Best-effort: a deleted subscription just yields null. */
|
||||
#holderOf(subscriptionId: string | null): string | null {
|
||||
if (!subscriptionId) return null;
|
||||
try {
|
||||
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||
return row?.holderName ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||
#openEntry(identity: string) {
|
||||
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 null;
|
||||
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||
* (single, for now) active site tariff. */
|
||||
#tariffVersionFor(at: string) {
|
||||
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
if (!tariff) return null;
|
||||
const versions = this.#db
|
||||
.select()
|
||||
.from(tariffVersions)
|
||||
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||
.all();
|
||||
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||||
|
||||
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
|
||||
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
|
||||
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
|
||||
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
|
||||
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
|
||||
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
|
||||
//
|
||||
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
|
||||
// session); within a direction, the newest read wins. Returns the plate text only —
|
||||
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
|
||||
|
||||
/** The best advisory plate observed for a session, for display. */
|
||||
export interface PlateView {
|
||||
readonly plate: string;
|
||||
readonly confidence: number | null;
|
||||
readonly direction: "entry" | "exit" | null;
|
||||
}
|
||||
|
||||
interface ReadDetail {
|
||||
identity?: string;
|
||||
plate?: string;
|
||||
confidence?: number;
|
||||
direction?: string;
|
||||
}
|
||||
|
||||
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
|
||||
export function plateForIdentity(db: Db, identity: string): PlateView | null {
|
||||
const rows = db
|
||||
.select({ detail: deviceEvents.detail })
|
||||
.from(deviceEvents)
|
||||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||||
.orderBy(desc(deviceEvents.occurredAt))
|
||||
.all();
|
||||
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
|
||||
}
|
||||
|
||||
/** Resolve plates for MANY identities in one device_events scan (used by the active-
|
||||
* sessions list and the feed page, which each carry tens–hundreds of rows). */
|
||||
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
|
||||
const want = new Set(identities);
|
||||
const out = new Map<string, PlateView>();
|
||||
if (want.size === 0) return out;
|
||||
// Newest first so the first acceptable read per (identity,direction) is the freshest.
|
||||
const rows = db
|
||||
.select({ detail: deviceEvents.detail })
|
||||
.from(deviceEvents)
|
||||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||||
.orderBy(desc(deviceEvents.occurredAt))
|
||||
.all();
|
||||
const byId = new Map<string, ReadDetail[]>();
|
||||
for (const r of rows) {
|
||||
const d = (r.detail ?? {}) as ReadDetail;
|
||||
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
|
||||
let list = byId.get(d.identity);
|
||||
if (!list) byId.set(d.identity, (list = []));
|
||||
list.push(d);
|
||||
}
|
||||
for (const [id, reads] of byId) {
|
||||
const best = pickBest(reads, id);
|
||||
if (best) out.set(id, best);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
|
||||
* exit read; otherwise the first (newest) acceptable read wins. */
|
||||
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
|
||||
let fallback: ReadDetail | null = null;
|
||||
for (const d of reads) {
|
||||
if (d.identity !== identity || !d.plate) continue;
|
||||
if (d.direction === "entry") return toView(d);
|
||||
if (!fallback) fallback = d;
|
||||
}
|
||||
return fallback ? toView(fallback) : null;
|
||||
}
|
||||
|
||||
function toView(d: ReadDetail): PlateView {
|
||||
return {
|
||||
plate: d.plate!.trim().toUpperCase(),
|
||||
confidence: typeof d.confidence === "number" ? d.confidence : null,
|
||||
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { eq, laneDevices, type Db } from "@parking/db";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import {
|
||||
isMonitorable,
|
||||
registry,
|
||||
@@ -66,8 +66,8 @@ export class PrinterMonitor {
|
||||
async refreshDevices(): Promise<void> {
|
||||
const rows = await this.#db
|
||||
.select()
|
||||
.from(laneDevices)
|
||||
.where(eq(laneDevices.category, "printer"))
|
||||
.from(devices)
|
||||
.where(eq(devices.category, "printer"))
|
||||
.all();
|
||||
|
||||
const seen = new Set<string>();
|
||||
@@ -89,7 +89,6 @@ export class PrinterMonitor {
|
||||
build: () => driver.create(cfg as never),
|
||||
meta: {
|
||||
deviceId: row.id,
|
||||
lane: row.lane,
|
||||
driverId: row.driverId,
|
||||
role: typeof cfg.role === "string" ? cfg.role : undefined,
|
||||
},
|
||||
@@ -139,7 +138,7 @@ export class PrinterMonitor {
|
||||
|
||||
if (!prev || statusChanged(prev.status, status)) {
|
||||
this.#log.info(
|
||||
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
||||
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
||||
);
|
||||
deviceEvents.emitPrinterStatus(event);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, deviceEvents as deviceEventsTable, ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ReadDispatcher } from "./read-dispatch.js";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import type { DeviceReadEvent } from "./device-events.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
|
||||
// STRUCTURAL FILTER at the dispatcher (2026-07-04): a reader value that matched
|
||||
// nothing AND can't possibly be a credential we issued (no ticket Luhn shape, no
|
||||
// SUB-/SUBSESS- prefix, not a confirmed-RF read) is refused with UNSIGNED telemetry
|
||||
// instead of reaching the exit flow and signing a noSession anomaly. Born from the
|
||||
// park-buzi phantom optical decodes: red "who is exiting?" rows for NOBODY train the
|
||||
// operator to ignore the signed feed. Anything plausibly ours STILL signs normally.
|
||||
|
||||
let db: Db;
|
||||
let dispatcher: ReadDispatcher;
|
||||
|
||||
const READER = "reader-exit";
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({
|
||||
id: "ctl-exit",
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: { relays: [{ relay: 1, direction: "exit" }] },
|
||||
enabled: true,
|
||||
}).run();
|
||||
db.insert(devices).values({
|
||||
id: READER,
|
||||
category: "reader",
|
||||
driverId: "dingtian-qr-reader",
|
||||
config: { serial: "H05MA5B0", direction: "exit" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
const log = makeLog(db);
|
||||
dispatcher = new ReadDispatcher(db, new ExitFlow(db, log, silentLogger()), new SubscriptionFlow(db, log, silentLogger()), silentLogger());
|
||||
});
|
||||
|
||||
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
|
||||
return {
|
||||
driverId: "dingtian-qr-reader",
|
||||
deviceId: READER,
|
||||
value,
|
||||
kind: opts.kind ?? "qr",
|
||||
...(opts.channel ? { channel: opts.channel } : {}),
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const ledger = () => db.select().from(ledgerEvents).all();
|
||||
const unrecognized = () =>
|
||||
db.select().from(deviceEventsTable).all()
|
||||
.map((r) => r.detail as { unrecognizedRead?: boolean; value?: string })
|
||||
.filter((d) => d.unrecognizedRead === true);
|
||||
|
||||
describe("read-dispatch structural filter", () => {
|
||||
it("phantom 6-digit optical decode → refused, telemetry only, NOTHING signed", async () => {
|
||||
const out = await dispatcher.dispatch(read("999459", { channel: "optical" }));
|
||||
expect(out.accepted).toBe(false);
|
||||
expect(out.reason).toMatch(/unrecognized/);
|
||||
expect(ledger()).toHaveLength(0); // the whole point: no red row in the feed
|
||||
expect(unrecognized()).toHaveLength(1);
|
||||
expect(unrecognized()[0].value).toBe("999459");
|
||||
});
|
||||
|
||||
it("legacy untagged garbage ('C') → filtered too (works before prefixes are deployed)", async () => {
|
||||
const out = await dispatcher.dispatch(read("C"));
|
||||
expect(out.accepted).toBe(false);
|
||||
expect(ledger()).toHaveLength(0);
|
||||
expect(unrecognized()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Luhn-valid unknown ticket → NOT filtered: the exit flow signs the noSession anomaly", async () => {
|
||||
const out = await dispatcher.dispatch(read("00000000000")); // valid shape, no session
|
||||
expect(out.accepted).toBe(false);
|
||||
expect(unrecognized()).toHaveLength(0);
|
||||
const anomalies = ledger().filter((r) => r.type === "anomaly");
|
||||
expect(anomalies.length).toBeGreaterThan(0); // a real probe stays in the signed feed
|
||||
});
|
||||
|
||||
it("unknown card on a CONFIRMED RF channel → NOT filtered (a physical card is a real event)", async () => {
|
||||
await dispatcher.dispatch(read("1A86A158", { kind: "card", channel: "rf" }));
|
||||
expect(unrecognized()).toHaveLength(0);
|
||||
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("unknown SUB- code → NOT filtered (our own prefix = an interesting probe)", async () => {
|
||||
await dispatcher.dispatch(read("SUB-DOESNOTEXIST", { channel: "optical" }));
|
||||
expect(unrecognized()).toHaveLength(0);
|
||||
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("an ENROLLED credential is matched BEFORE the filter (never hidden by it)", async () => {
|
||||
// A card UID that would fail every shape test — enrolled, so it must still match.
|
||||
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: "sub-1", kind: "rf", value: "999459" }).run();
|
||||
await dispatcher.dispatch(read("999459")); // legacy untagged read of it
|
||||
expect(unrecognized()).toHaveLength(0); // reached the subscription flow, not the filter
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { devices, deviceEvents as deviceEventsTable, eq, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
import type { ExitFlow } from "./exit-flow.js";
|
||||
import { validateTicketCode } from "./entry-flow.js";
|
||||
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import { relayForDevice } from "./device-resolve.js";
|
||||
|
||||
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||
// can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||
// credential is (decision 2026-06-15):
|
||||
// - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
|
||||
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||
//
|
||||
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
||||
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
||||
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
||||
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
||||
// session state; transient: exit).
|
||||
//
|
||||
// STRUCTURAL FILTER (2026-07-04, operator-requested). The DT-008's scan engine
|
||||
// false-decodes sunlight stripe patterns into short garbage codes (phantom reads —
|
||||
// see wiki/entities/dingtian-dt008-reader.md), and each one was reaching the exit
|
||||
// flow and signing an exit.refused.noSession anomaly: red "who is trying to exit?"
|
||||
// rows for NOBODY, training the operator to ignore the feed (alarm fatigue is the
|
||||
// adversary's friend). So a reader value that matched nothing AND cannot possibly be
|
||||
// a credential we issued is dropped to UNSIGNED telemetry (device_events, still
|
||||
// auditable) instead of the signed ledger. "Possibly ours" stays deliberately wide —
|
||||
// any of these still reaches the flows and signs the normal refusal anomaly:
|
||||
// - a Luhn-valid ticket shape (validateTicketCode — a forged/expired ticket is a
|
||||
// real probe),
|
||||
// - our issued-code prefixes (SUB- / SUBSESS-),
|
||||
// - ANY read on a CONFIRMED RF channel (a physically present card, enrolled or
|
||||
// not, is a real event — RF is never sun noise),
|
||||
// - plates (different population; never shape-filtered here).
|
||||
|
||||
export class ReadDispatcher {
|
||||
readonly #db: Db;
|
||||
readonly #exit: ExitFlow;
|
||||
readonly #subscription: SubscriptionFlow;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#exit = exit;
|
||||
this.#subscription = subscription;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||
const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get();
|
||||
if (!reader || !reader.enabled) {
|
||||
return { accepted: false, reason: "read from unknown/disabled device" };
|
||||
}
|
||||
const resolved = relayForDevice(this.#db, reader);
|
||||
if (!resolved) {
|
||||
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
||||
}
|
||||
|
||||
const sub = this.#subscription.match(e);
|
||||
if (sub) {
|
||||
return this.#subscription.run(resolved, e, sub);
|
||||
}
|
||||
|
||||
// Matched nothing — if the value can't even BE one of ours, it's scanner noise
|
||||
// (phantom optical decode): refuse with unsigned telemetry, keep the signed feed
|
||||
// for events that involve an actual credential or an actual card.
|
||||
if ((e.kind === "qr" || e.kind === "card" || e.kind === "ticket") && !plausibleCredential(e)) {
|
||||
this.#recordUnrecognized(e);
|
||||
this.#logger.info(`read filtered (not a credential shape): '${e.value}' from ${e.deviceId}${e.channel ? ` ch=${e.channel}` : ""}`);
|
||||
return {
|
||||
accepted: false,
|
||||
direction: resolved.direction === "entry" ? "entry" : "exit",
|
||||
reason: "unrecognized code (no credential shape — telemetry only)",
|
||||
};
|
||||
}
|
||||
|
||||
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
||||
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
||||
// rather than treat an entry scan as an exit.
|
||||
if (resolved.direction === "entry") {
|
||||
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||||
}
|
||||
return this.#exit.handleAt(resolved, e);
|
||||
}
|
||||
|
||||
/** Unsigned telemetry for a filtered read — auditable in device_events, out of the
|
||||
* signed feed. Mirrors the entry flow's suppressed-press pattern. */
|
||||
#recordUnrecognized(e: DeviceReadEvent): void {
|
||||
try {
|
||||
this.#db
|
||||
.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId: e.deviceId,
|
||||
category: "reader",
|
||||
kind: "read",
|
||||
detail: {
|
||||
unrecognizedRead: true,
|
||||
value: e.value,
|
||||
readKind: e.kind,
|
||||
...(e.channel ? { channel: e.channel } : {}),
|
||||
reason: "no credential shape (phantom decode / garbage scan)",
|
||||
},
|
||||
occurredAt: e.at,
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`unrecognized-read telemetry insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Could this reader value possibly be a credential WE issued (or a real card)?
|
||||
* Deliberately WIDE — only shapes that can't be anything of ours are filtered. */
|
||||
function plausibleCredential(e: DeviceReadEvent): boolean {
|
||||
if (e.channel === "rf") return true; // a physically present card — never sun noise
|
||||
if (validateTicketCode(e.value)) return true; // ticket shape (10–14 digits + Luhn)
|
||||
if (/^SUB(SESS)?-/.test(e.value)) return true; // our subscription QR / window-slip ids
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
eq,
|
||||
isNull,
|
||||
roles,
|
||||
rolePermissions,
|
||||
subscriptionCredentials,
|
||||
subscriptionPlans,
|
||||
subscriptions,
|
||||
tariffs,
|
||||
users,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import {
|
||||
listRecycleBin,
|
||||
purge,
|
||||
restore,
|
||||
restoreBlockedReason,
|
||||
softDelete,
|
||||
sweepExpired,
|
||||
} from "./recycle-bin.js";
|
||||
|
||||
// Soft delete / recycle bin. Pins: a delete STAMPS (keeps the row), the bin lists
|
||||
// soft-deleted items across kinds, restore brings them back, purge does the real
|
||||
// DELETE (+ children), a restore that would collide with a live row is blocked, and the
|
||||
// retention sweep purges only items past the window.
|
||||
|
||||
let db: Db;
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
});
|
||||
|
||||
function seedUser(username: string): string {
|
||||
const id = randomUUID();
|
||||
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
|
||||
db.insert(users).values({ id, username, passwordHash: "x", roleId: "admin" }).run();
|
||||
return id;
|
||||
}
|
||||
function seedRole(name: string): string {
|
||||
const id = randomUUID();
|
||||
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
||||
db.insert(rolePermissions).values({ roleId: id, permission: "site:read" }).run();
|
||||
return id;
|
||||
}
|
||||
function seedSubscription(holder: string): string {
|
||||
const id = randomUUID();
|
||||
db.insert(subscriptions).values({ id, holderName: holder, period: "month" }).run();
|
||||
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: "qr", value: `qr-${id}` }).run();
|
||||
return id;
|
||||
}
|
||||
function seedPlan(planId: string, versions = 2): void {
|
||||
for (let i = 0; i < versions; i++) {
|
||||
db.insert(subscriptionPlans).values({
|
||||
id: randomUUID(),
|
||||
planId,
|
||||
name: planId,
|
||||
period: "month",
|
||||
pricePerPeriodMinor: 100000,
|
||||
currency: "ALL",
|
||||
effectiveFrom: `2026-0${i + 1}-01T00:00:00.000Z`,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
describe("softDelete + restore + purge", () => {
|
||||
it("stamps the row instead of removing it, and hides it from a live query", () => {
|
||||
const id = seedUser("alice");
|
||||
expect(softDelete(db, "user", id, "admin-1")).toBe(true);
|
||||
|
||||
const row = db.select().from(users).where(eq(users.id, id)).get();
|
||||
expect(row).toBeDefined(); // still there
|
||||
expect(row?.deletedAt).toBeTruthy();
|
||||
expect(row?.deletedBy).toBe("admin-1");
|
||||
// A live-only query no longer sees it.
|
||||
expect(db.select().from(users).where(isNull(users.deletedAt)).all()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("soft-deleting an already-deleted row is a no-op (returns false)", () => {
|
||||
const id = seedUser("bob");
|
||||
expect(softDelete(db, "user", id, "a")).toBe(true);
|
||||
expect(softDelete(db, "user", id, "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("restore clears the stamps and brings the row back to the live set", () => {
|
||||
const id = seedRole("valet");
|
||||
softDelete(db, "role", id, "a");
|
||||
expect(restore(db, "role", id)).toBe(true);
|
||||
const row = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||
expect(row?.deletedAt).toBeNull();
|
||||
expect(db.select().from(roles).where(isNull(roles.deletedAt)).all().map((r) => r.id)).toContain(id);
|
||||
});
|
||||
|
||||
it("purge removes a soft-deleted row + its children; refuses a LIVE row", () => {
|
||||
const id = seedSubscription("carlos");
|
||||
// Cannot purge while live (purge only touches soft-deleted rows).
|
||||
expect(purge(db, "subscription", id)).toBe(false);
|
||||
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeDefined();
|
||||
|
||||
softDelete(db, "subscription", id, "a");
|
||||
expect(purge(db, "subscription", id)).toBe(true);
|
||||
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeUndefined();
|
||||
// Children gone too.
|
||||
expect(db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("versioned plans", () => {
|
||||
it("soft-deletes / restores / purges ALL versions of a planId together", () => {
|
||||
seedPlan("hotel-daily", 3);
|
||||
expect(softDelete(db, "plan", "hotel-daily", "a")).toBe(true);
|
||||
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(0);
|
||||
|
||||
// The bin lists the plan as ONE item, not three.
|
||||
const planItems = listRecycleBin(db).filter((i) => i.kind === "plan");
|
||||
expect(planItems).toHaveLength(1);
|
||||
expect(planItems[0]?.id).toBe("hotel-daily");
|
||||
|
||||
expect(restore(db, "plan", "hotel-daily")).toBe(true);
|
||||
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(3);
|
||||
|
||||
softDelete(db, "plan", "hotel-daily", "a");
|
||||
expect(purge(db, "plan", "hotel-daily")).toBe(true);
|
||||
expect(db.select().from(subscriptionPlans).all()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRecycleBin", () => {
|
||||
it("collects soft-deleted items across every kind, newest-deleted first", () => {
|
||||
const u = seedUser("dora");
|
||||
const r = seedRole("guard");
|
||||
const t = randomUUID();
|
||||
db.insert(tariffs).values({ id: t, scope: "site", name: "Site" }).run();
|
||||
|
||||
softDelete(db, "user", u, "a");
|
||||
softDelete(db, "role", r, "a");
|
||||
softDelete(db, "tariff", t, "a");
|
||||
|
||||
const items = listRecycleBin(db);
|
||||
expect(items.map((i) => i.kind).sort()).toEqual(["role", "tariff", "user"]);
|
||||
// Each carries a human label + the deletedAt stamp.
|
||||
expect(items.find((i) => i.kind === "user")?.label).toBe("dora");
|
||||
expect(items.every((i) => i.deletedAt)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreBlockedReason", () => {
|
||||
// NB: the DB `username`/`name` UNIQUE spans live AND soft-deleted rows, so a live
|
||||
// duplicate can't even be INSERTed while the deleted one exists (the create route
|
||||
// returns a clear 409 instead — see routes/users.ts). restoreBlockedReason is a
|
||||
// belt-and-suspenders guard at restore time; verify it returns null in the normal
|
||||
// case (nothing colliding) so a clean restore is never wrongly blocked.
|
||||
it("does not block a normal restore (no live collision)", () => {
|
||||
const u = seedUser("eve");
|
||||
softDelete(db, "user", u, "a");
|
||||
expect(restoreBlockedReason(db, "user", u)).toBeNull();
|
||||
|
||||
const r = seedRole("cleaner");
|
||||
softDelete(db, "role", r, "a");
|
||||
expect(restoreBlockedReason(db, "role", r)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sweepExpired (retention)", () => {
|
||||
it("purges items deleted longer than the window ago, keeps recent ones", () => {
|
||||
const old = seedUser("old");
|
||||
const fresh = seedUser("fresh");
|
||||
softDelete(db, "user", old, "a");
|
||||
softDelete(db, "user", fresh, "a");
|
||||
// Backdate `old`'s deletion to 40 days ago.
|
||||
const longAgo = new Date(Date.now() - 40 * 86_400_000).toISOString();
|
||||
db.update(users).set({ deletedAt: longAgo }).where(eq(users.id, old)).run();
|
||||
|
||||
const purged = sweepExpired(db, 30);
|
||||
expect(purged.user).toBe(1);
|
||||
expect(db.select().from(users).where(eq(users.id, old)).get()).toBeUndefined();
|
||||
expect(db.select().from(users).where(eq(users.id, fresh)).get()).toBeDefined();
|
||||
});
|
||||
|
||||
it("days <= 0 disables the sweep (keep forever)", () => {
|
||||
const id = seedUser("keeper");
|
||||
softDelete(db, "user", id, "a");
|
||||
db.update(users).set({ deletedAt: new Date(Date.now() - 999 * 86_400_000).toISOString() }).where(eq(users.id, id)).run();
|
||||
const purged = sweepExpired(db, 0);
|
||||
expect(purged.user).toBe(0);
|
||||
expect(db.select().from(users).where(eq(users.id, id)).get()).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
isNotNull,
|
||||
isNull,
|
||||
lte,
|
||||
rolePermissions,
|
||||
roles,
|
||||
subscriptionCredentials,
|
||||
subscriptionPlans,
|
||||
subscriptionPlates,
|
||||
subscriptions,
|
||||
tariffs,
|
||||
users,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
|
||||
// Soft delete + recycle bin. Accidental hard-deletes of master data (a user, role,
|
||||
// subscription, plan, tariff) used to be unrecoverable. Now a DELETE STAMPS the row
|
||||
// (`deleted_at` = now, `deleted_by` = admin) instead of removing it; it disappears from
|
||||
// every catalog (the list queries filter `deleted_at IS NULL`) but survives in the
|
||||
// recycle bin, where an admin can RESTORE it (clear the stamps) or PURGE it (the real
|
||||
// DELETE). A retention sweep auto-purges items deleted longer than the window ago.
|
||||
//
|
||||
// Scope: only the MUTABLE master-data tables below. The signed, append-only ledger is
|
||||
// NOT here — it has no delete path by design. See wiki/concepts/soft-delete.md.
|
||||
|
||||
/** The soft-deletable resource kinds, as they appear in the recycle-bin API. */
|
||||
export type ResourceKind = "user" | "role" | "subscription" | "plan" | "tariff";
|
||||
|
||||
export const RESOURCE_KINDS: ResourceKind[] = ["user", "role", "subscription", "plan", "tariff"];
|
||||
|
||||
/** Default retention window before a soft-deleted item is auto-purged (days). Override
|
||||
* with RECYCLE_BIN_RETENTION_DAYS. 0/negative disables the sweep (keep forever). */
|
||||
export function retentionDays(): number {
|
||||
const raw = Number(process.env.RECYCLE_BIN_RETENTION_DAYS ?? 30);
|
||||
return Number.isFinite(raw) ? raw : 30;
|
||||
}
|
||||
|
||||
/** A row surfaced in the recycle bin (normalised across resource kinds). */
|
||||
export interface RecycleBinItem {
|
||||
readonly kind: ResourceKind;
|
||||
/** The id used to restore/purge. For a versioned PLAN this is the stable planId. */
|
||||
readonly id: string;
|
||||
/** Human label for the list (username, role/plan/tariff name, subscriber holder). */
|
||||
readonly label: string;
|
||||
readonly deletedAt: string;
|
||||
readonly deletedBy: string | null;
|
||||
}
|
||||
|
||||
const NOW = () => new Date().toISOString();
|
||||
|
||||
// --- Per-resource helpers ----------------------------------------------------
|
||||
// Subscriptions/users/roles/tariffs are 1 row per id. PLANS are versioned (N rows per
|
||||
// plan_id) — stamp/clear/delete ALL versions of the plan_id together.
|
||||
|
||||
/** Soft-delete a row by id. Returns false if no live row matched (404). PLAN uses planId. */
|
||||
export function softDelete(db: Db, kind: ResourceKind, id: string, byUserId: string): boolean {
|
||||
const stamp = { deletedAt: NOW(), deletedBy: byUserId };
|
||||
switch (kind) {
|
||||
case "user":
|
||||
return db.update(users).set(stamp).where(and(eq(users.id, id), isNull(users.deletedAt))).run().changes > 0;
|
||||
case "role":
|
||||
return db.update(roles).set(stamp).where(and(eq(roles.id, id), isNull(roles.deletedAt))).run().changes > 0;
|
||||
case "subscription":
|
||||
return db.update(subscriptions).set(stamp).where(and(eq(subscriptions.id, id), isNull(subscriptions.deletedAt))).run().changes > 0;
|
||||
case "plan":
|
||||
return db.update(subscriptionPlans).set(stamp).where(and(eq(subscriptionPlans.planId, id), isNull(subscriptionPlans.deletedAt))).run().changes > 0;
|
||||
case "tariff":
|
||||
return db.update(tariffs).set(stamp).where(and(eq(tariffs.id, id), isNull(tariffs.deletedAt))).run().changes > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a soft-deleted row (clear the stamps). Returns false if nothing was restored. */
|
||||
export function restore(db: Db, kind: ResourceKind, id: string): boolean {
|
||||
const clear = { deletedAt: null, deletedBy: null };
|
||||
switch (kind) {
|
||||
case "user":
|
||||
return db.update(users).set(clear).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
|
||||
case "role":
|
||||
return db.update(roles).set(clear).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
|
||||
case "subscription":
|
||||
return db.update(subscriptions).set(clear).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
|
||||
case "plan":
|
||||
return db.update(subscriptionPlans).set(clear).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
|
||||
case "tariff":
|
||||
return db.update(tariffs).set(clear).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** True if restoring would collide with a LIVE row (e.g. a user with the same username
|
||||
* was re-created after the delete). The caller turns this into a 409 so the admin
|
||||
* understands why restore is blocked. */
|
||||
export function restoreBlockedReason(db: Db, kind: ResourceKind, id: string): string | null {
|
||||
if (kind === "user") {
|
||||
const row = db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (row && db.select().from(users).where(and(eq(users.username, row.username), isNull(users.deletedAt))).get()) {
|
||||
return `a live user named "${row.username}" already exists`;
|
||||
}
|
||||
} else if (kind === "role") {
|
||||
const row = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||
if (row && db.select().from(roles).where(and(eq(roles.name, row.name), isNull(roles.deletedAt))).get()) {
|
||||
return `a live role named "${row.name}" already exists`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Restore ordering note --------------------------------------------------
|
||||
// A restored USER points at a roleId; if that role is itself deleted, the user reappears
|
||||
// with a dangling role. We don't auto-cascade (keep it predictable); the bin lists both
|
||||
// and the admin restores the role too. The role guard already resolves a missing role to
|
||||
// an empty permission set (safe-by-default), so a dangling role never escalates.
|
||||
|
||||
/** Hard-delete (purge) a soft-deleted row + its children. The real DELETE. Returns false
|
||||
* if no soft-deleted row matched (so you can't purge a live row through this path). */
|
||||
export function purge(db: Db, kind: ResourceKind, id: string): boolean {
|
||||
switch (kind) {
|
||||
case "user":
|
||||
return db.delete(users).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
|
||||
case "role": {
|
||||
// Children (role_permissions) only matter once the role row is gone; purge both.
|
||||
const ok = db.delete(roles).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
|
||||
if (ok) deleteRolePermissions(db, id);
|
||||
return ok;
|
||||
}
|
||||
case "subscription": {
|
||||
const ok = db.delete(subscriptions).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
|
||||
if (ok) deleteSubscriptionChildren(db, id);
|
||||
return ok;
|
||||
}
|
||||
case "plan":
|
||||
return db.delete(subscriptionPlans).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
|
||||
case "tariff":
|
||||
return db.delete(tariffs).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Child cleanup on purge (role_permissions / subscription credentials + plates).
|
||||
function deleteRolePermissions(db: Db, roleId: string): void {
|
||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
|
||||
}
|
||||
function deleteSubscriptionChildren(db: Db, id: string): void {
|
||||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
|
||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
|
||||
}
|
||||
|
||||
// --- Listing the bin --------------------------------------------------------
|
||||
|
||||
/** All soft-deleted items across every resource kind, newest-deleted first. */
|
||||
export function listRecycleBin(db: Db): RecycleBinItem[] {
|
||||
const items: RecycleBinItem[] = [];
|
||||
|
||||
for (const r of db.select().from(users).where(isNotNull(users.deletedAt)).all()) {
|
||||
items.push({ kind: "user", id: r.id, label: r.fullName || r.username, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||
}
|
||||
for (const r of db.select().from(roles).where(isNotNull(roles.deletedAt)).all()) {
|
||||
items.push({ kind: "role", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||
}
|
||||
for (const r of db.select().from(subscriptions).where(isNotNull(subscriptions.deletedAt)).all()) {
|
||||
items.push({ kind: "subscription", id: r.id, label: r.holderName || r.id, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||
}
|
||||
// Plans are versioned: collapse to one item per plan_id (the latest version's name).
|
||||
const planSeen = new Set<string>();
|
||||
const planRows = db.select().from(subscriptionPlans).where(isNotNull(subscriptionPlans.deletedAt)).all();
|
||||
planRows.sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom));
|
||||
for (const r of planRows) {
|
||||
if (planSeen.has(r.planId)) continue;
|
||||
planSeen.add(r.planId);
|
||||
items.push({ kind: "plan", id: r.planId, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||
}
|
||||
for (const r of db.select().from(tariffs).where(isNotNull(tariffs.deletedAt)).all()) {
|
||||
items.push({ kind: "tariff", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
}
|
||||
|
||||
// --- Retention sweep --------------------------------------------------------
|
||||
|
||||
/** Purge every soft-deleted row deleted more than `retentionDays()` ago. Returns the
|
||||
* count purged per kind. Safe to call repeatedly (idempotent). */
|
||||
export function sweepExpired(db: Db, days = retentionDays()): Record<ResourceKind, number> {
|
||||
const out: Record<ResourceKind, number> = { user: 0, role: 0, subscription: 0, plan: 0, tariff: 0 };
|
||||
if (!Number.isFinite(days) || days <= 0) return out; // keep-forever
|
||||
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
||||
|
||||
// Collect ids first so children purge through the same path as a manual purge.
|
||||
for (const r of db.select().from(users).where(and(isNotNull(users.deletedAt), lte(users.deletedAt, cutoff))).all()) {
|
||||
if (purge(db, "user", r.id)) out.user++;
|
||||
}
|
||||
for (const r of db.select().from(roles).where(and(isNotNull(roles.deletedAt), lte(roles.deletedAt, cutoff))).all()) {
|
||||
if (purge(db, "role", r.id)) out.role++;
|
||||
}
|
||||
for (const r of db.select().from(subscriptions).where(and(isNotNull(subscriptions.deletedAt), lte(subscriptions.deletedAt, cutoff))).all()) {
|
||||
if (purge(db, "subscription", r.id)) out.subscription++;
|
||||
}
|
||||
const planIds = new Set(
|
||||
db.select().from(subscriptionPlans).where(and(isNotNull(subscriptionPlans.deletedAt), lte(subscriptionPlans.deletedAt, cutoff))).all().map((r) => r.planId),
|
||||
);
|
||||
for (const planId of planIds) if (purge(db, "plan", planId)) out.plan++;
|
||||
for (const r of db.select().from(tariffs).where(and(isNotNull(tariffs.deletedAt), lte(tariffs.deletedAt, cutoff))).all()) {
|
||||
if (purge(db, "tariff", r.id)) out.tariff++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { sessions, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { makeLog } from "./test-helpers.js";
|
||||
import { reportSummary } from "./reports.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are
|
||||
// summed straight from the signed ledger (entry/exit counts + payment money, split the
|
||||
// same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration
|
||||
// stats from the closed-sessions cache and subscription counts as of the range end.
|
||||
|
||||
let db: Db;
|
||||
let log: EventLog;
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
log = makeLog(db);
|
||||
// Fix the site timezone so bucket labels are deterministic regardless of the test host.
|
||||
db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run();
|
||||
});
|
||||
|
||||
/** ISO at a UTC instant, for deterministic bucket assertions. */
|
||||
function at(iso: string): string {
|
||||
return new Date(iso).toISOString();
|
||||
}
|
||||
|
||||
async function entry(occurredAt: string): Promise<void> {
|
||||
await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt });
|
||||
}
|
||||
async function exit(occurredAt: string): Promise<void> {
|
||||
await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt });
|
||||
}
|
||||
async function payment(
|
||||
occurredAt: string,
|
||||
amountMinor: number,
|
||||
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await log.append({
|
||||
type: "payment",
|
||||
occurredAt,
|
||||
payload: {
|
||||
amountMinor,
|
||||
currency: "ALL",
|
||||
tender: opts.tender ?? "cash",
|
||||
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
|
||||
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") };
|
||||
|
||||
describe("reportSummary — ledger-first totals", () => {
|
||||
it("counts entries and exits from the signed ledger", async () => {
|
||||
await entry(at("2026-06-10T08:00:00Z"));
|
||||
await entry(at("2026-06-10T09:00:00Z"));
|
||||
await exit(at("2026-06-10T18:00:00Z"));
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.entries).toBe(2);
|
||||
expect(r.totals.exits).toBe(1);
|
||||
});
|
||||
|
||||
it("excludes events outside [from, to)", async () => {
|
||||
await entry(at("2026-05-31T23:00:00Z")); // before
|
||||
await entry(at("2026-06-15T10:00:00Z")); // inside
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.entries).toBe(1);
|
||||
});
|
||||
|
||||
it("sums payment money and splits cash vs card", async () => {
|
||||
await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" });
|
||||
await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" });
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.payments).toBe(2);
|
||||
expect(r.totals.revenueMinor).toBe(25000);
|
||||
expect(r.totals.cashMinor).toBe(20000);
|
||||
expect(r.totals.cardMinor).toBe(5000);
|
||||
});
|
||||
|
||||
it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => {
|
||||
await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket
|
||||
await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true });
|
||||
await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true });
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.ticketMinor).toBe(10000);
|
||||
expect(r.totals.subscriptionSalesMinor).toBe(30000);
|
||||
expect(r.totals.subscriptionWindowMinor).toBe(1500);
|
||||
// The three add up to the gross revenue.
|
||||
expect(r.totals.revenueMinor).toBe(41500);
|
||||
});
|
||||
|
||||
it("picks up the currency from a payment in range", async () => {
|
||||
await payment(at("2026-06-12T10:00:00Z"), 10000);
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.currency).toBe("ALL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportSummary — time bucketing (site timezone)", () => {
|
||||
it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => {
|
||||
// 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket.
|
||||
await entry(at("2026-06-15T23:30:00Z"));
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
const point = r.series.find((p) => p.entries > 0);
|
||||
expect(point?.bucket).toBe("2026-06-16");
|
||||
});
|
||||
|
||||
it("series points are sorted and carry per-bucket entries/exits/revenue", async () => {
|
||||
await entry(at("2026-06-10T08:00:00Z"));
|
||||
await payment(at("2026-06-10T09:00:00Z"), 7000);
|
||||
await entry(at("2026-06-12T08:00:00Z"));
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
const labels = r.series.map((p) => p.bucket);
|
||||
expect(labels).toEqual([...labels].sort());
|
||||
const d10 = r.series.find((p) => p.bucket === "2026-06-10");
|
||||
expect(d10?.entries).toBe(1);
|
||||
expect(d10?.revenueMinor).toBe(7000);
|
||||
});
|
||||
|
||||
it("entriesByHour is a 24-slot local-hour histogram", async () => {
|
||||
// 06:00Z = 08:00 local (summer) → hour slot 8.
|
||||
await entry(at("2026-06-10T06:00:00Z"));
|
||||
await entry(at("2026-06-11T06:00:00Z"));
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.entriesByHour).toHaveLength(24);
|
||||
expect(r.entriesByHour[8]).toBe(2);
|
||||
expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
||||
it("computes parked-minute stats from closed sessions whose exit fell in range", async () => {
|
||||
// 60-min and 120-min stays → avg 90, median 90.
|
||||
db.insert(sessions).values({
|
||||
id: "s1",
|
||||
identity: "t1",
|
||||
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||
exitedAt: at("2026-06-10T09:00:00Z"),
|
||||
state: "closed",
|
||||
}).run();
|
||||
db.insert(sessions).values({
|
||||
id: "s2",
|
||||
identity: "t2",
|
||||
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||
exitedAt: at("2026-06-10T10:00:00Z"),
|
||||
state: "closed",
|
||||
}).run();
|
||||
// An OPEN session (no exit) must not count.
|
||||
db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run();
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.closedSessions).toBe(2);
|
||||
expect(r.totals.totalParkedMinutes).toBe(180);
|
||||
expect(r.totals.avgParkedMinutes).toBe(90);
|
||||
expect(r.totals.medianParkedMinutes).toBe(90);
|
||||
});
|
||||
|
||||
it("counts subscriptions by status and currently-valid coverage as of `to`", async () => {
|
||||
const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") };
|
||||
// active + valid window covering `to`, quantity 2.
|
||||
db.insert(subscriptions).values({
|
||||
id: "a", status: "active", quantity: 2,
|
||||
validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base,
|
||||
}).run();
|
||||
// active but EXPIRED before `to` → not currently valid.
|
||||
db.insert(subscriptions).values({
|
||||
id: "b", status: "active", quantity: 1,
|
||||
validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base,
|
||||
}).run();
|
||||
// suspended.
|
||||
db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run();
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.subscriptions.active).toBe(2);
|
||||
expect(r.subscriptions.suspended).toBe(1);
|
||||
expect(r.subscriptions.revoked).toBe(0);
|
||||
expect(r.subscriptions.currentlyValid).toBe(1);
|
||||
expect(r.subscriptions.coveredCars).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => {
|
||||
it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => {
|
||||
// Before the range: 3 entries, 1 exit → 2 cars inside when June opens.
|
||||
await entry(at("2026-05-20T08:00:00Z"));
|
||||
await entry(at("2026-05-20T09:00:00Z"));
|
||||
await entry(at("2026-05-21T10:00:00Z"));
|
||||
await exit(at("2026-05-21T12:00:00Z"));
|
||||
// In range: +2 on the 10th, −1 on the 11th.
|
||||
await entry(at("2026-06-10T08:00:00Z"));
|
||||
await entry(at("2026-06-10T09:00:00Z"));
|
||||
await exit(at("2026-06-11T09:00:00Z"));
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.occupancyStart).toBe(2);
|
||||
expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([
|
||||
["2026-06-10", 4],
|
||||
["2026-06-11", 3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("a voided pre-range entry does not inflate occupancyStart", async () => {
|
||||
const id = randomUUID();
|
||||
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") });
|
||||
await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } });
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.occupancyStart).toBe(0);
|
||||
});
|
||||
|
||||
it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => {
|
||||
// 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June).
|
||||
await entry(at("2026-06-10T08:00:00Z"));
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column
|
||||
expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("stay histogram buckets closed sessions; series carries the cash/card split", async () => {
|
||||
db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30
|
||||
db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120
|
||||
db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail
|
||||
await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" });
|
||||
await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" });
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count]));
|
||||
expect(counts["30"]).toBe(1);
|
||||
expect(counts["120"]).toBe(1);
|
||||
expect(counts["null"]).toBe(1);
|
||||
const day = r.series.find((p) => p.bucket === "2026-06-10")!;
|
||||
expect(day.cashMinor).toBe(500);
|
||||
expect(day.cardMinor).toBe(700);
|
||||
});
|
||||
|
||||
it("counts voids and anomalies in range (the look-closer counters)", async () => {
|
||||
const id = randomUUID();
|
||||
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") });
|
||||
await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } });
|
||||
await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } });
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.voids).toBe(1);
|
||||
expect(r.totals.anomalies).toBe(1);
|
||||
expect(r.totals.entries).toBe(0); // the voided entry stays excluded
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ledgerEvents,
|
||||
sessions,
|
||||
siteConfig,
|
||||
subscriptions,
|
||||
tariffVersions,
|
||||
tariffs,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
import { siteTz } from "./subscription-window.js";
|
||||
|
||||
// Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an
|
||||
// admin sees on the Reports page are summed from the SIGNED, hash-chained
|
||||
// ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report
|
||||
// reconciles against — so a chart total always ties out to the drawer. Only the
|
||||
// duration/occupancy view leans on the derived `sessions` cache, where the ledger is
|
||||
// awkward (you'd have to pair every entry with its exit by hand); that's flagged as a
|
||||
// cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md.
|
||||
//
|
||||
// All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local
|
||||
// calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the
|
||||
// peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no
|
||||
// floats (money is integer minor units throughout).
|
||||
|
||||
export type Bucket = "hour" | "day" | "month";
|
||||
|
||||
export interface ReportQuery {
|
||||
/** Inclusive lower bound (ISO instant). */
|
||||
readonly from: string;
|
||||
/** Exclusive upper bound (ISO instant). */
|
||||
readonly to: string;
|
||||
/** Time grain for the series. Default "day". */
|
||||
readonly bucket: Bucket;
|
||||
}
|
||||
|
||||
/** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22"
|
||||
* for a day, "2026-06-22 14" for an hour). */
|
||||
export interface SeriesPoint {
|
||||
readonly bucket: string;
|
||||
readonly entries: number;
|
||||
readonly exits: number;
|
||||
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||
readonly revenueMinor: number;
|
||||
/** Tender split of the bucket's revenue (cash = everything not card). */
|
||||
readonly cashMinor: number;
|
||||
readonly cardMinor: number;
|
||||
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||
readonly payments: number;
|
||||
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
|
||||
readonly occupancyEnd: number;
|
||||
}
|
||||
|
||||
export interface ReportTotals {
|
||||
readonly entries: number;
|
||||
readonly exits: number;
|
||||
readonly payments: number;
|
||||
readonly revenueMinor: number;
|
||||
readonly cashMinor: number;
|
||||
readonly cardMinor: number;
|
||||
/** Revenue split by what was sold. ticket = transient parking; subscriptionSales =
|
||||
* new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */
|
||||
readonly ticketMinor: number;
|
||||
readonly subscriptionSalesMinor: number;
|
||||
readonly subscriptionWindowMinor: number;
|
||||
/** Closed transient sessions in range + their parked-minutes stats (from the cache). */
|
||||
readonly closedSessions: number;
|
||||
readonly totalParkedMinutes: number;
|
||||
readonly avgParkedMinutes: number;
|
||||
readonly medianParkedMinutes: number;
|
||||
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
|
||||
* (the operator at the booth is the threat model's primary adversary). */
|
||||
readonly voids: number;
|
||||
readonly anomalies: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionStats {
|
||||
readonly active: number;
|
||||
readonly suspended: number;
|
||||
readonly revoked: number;
|
||||
/** Active subscriptions whose window covers `to` (the report's "now"). */
|
||||
readonly currentlyValid: number;
|
||||
/** Cars covered by currently-valid subscriptions (Σ quantity). */
|
||||
readonly coveredCars: number;
|
||||
}
|
||||
|
||||
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
|
||||
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
|
||||
export interface StayBucket {
|
||||
readonly uptoMin: number | null;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
readonly bucket: Bucket;
|
||||
readonly tz: string;
|
||||
readonly currency: string | null;
|
||||
readonly totals: ReportTotals;
|
||||
readonly series: SeriesPoint[];
|
||||
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||
readonly entriesByHour: number[];
|
||||
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
|
||||
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
|
||||
readonly entriesByDowHour: number[][];
|
||||
/** Stay-duration histogram over closed sessions in range. */
|
||||
readonly stayHistogram: StayBucket[];
|
||||
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
|
||||
readonly occupancyStart: number;
|
||||
/** Nominal capacity from site config (null = uncapped) — the reference line. */
|
||||
readonly capacity: number | null;
|
||||
readonly subscriptions: SubscriptionStats;
|
||||
}
|
||||
|
||||
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
||||
const fmtCache = new Map<string, Intl.DateTimeFormat>();
|
||||
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
|
||||
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
|
||||
// Cached per tz — this runs once per ledger row in a report.
|
||||
let fmt = fmtCache.get(tz);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hourCycle: "h23",
|
||||
weekday: "short",
|
||||
});
|
||||
fmtCache.set(tz, fmt);
|
||||
}
|
||||
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||
return {
|
||||
y: Number(parts.year),
|
||||
mo: Number(parts.month),
|
||||
d: Number(parts.day),
|
||||
h: Number(parts.hour),
|
||||
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
|
||||
};
|
||||
}
|
||||
|
||||
/** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */
|
||||
function bucketLabel(iso: string, tz: string, bucket: Bucket): string {
|
||||
const p = localParts(iso, tz);
|
||||
const mo = String(p.mo).padStart(2, "0");
|
||||
const d = String(p.d).padStart(2, "0");
|
||||
const h = String(p.h).padStart(2, "0");
|
||||
if (bucket === "month") return `${p.y}-${mo}`;
|
||||
if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`;
|
||||
return `${p.y}-${mo}-${d}`;
|
||||
}
|
||||
|
||||
interface PaymentPayload {
|
||||
amountMinor?: number;
|
||||
currency?: string;
|
||||
tender?: "cash" | "card";
|
||||
subscriptionSale?: boolean;
|
||||
subscriptionWindowCharge?: boolean;
|
||||
}
|
||||
|
||||
function median(sorted: number[]): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const hi = sorted[mid] ?? 0;
|
||||
if (sorted.length % 2) return hi;
|
||||
const lo = sorted[mid - 1] ?? 0;
|
||||
return Math.round((lo + hi) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts
|
||||
* and money are summed from the signed ledger; duration stats from the closed sessions
|
||||
* in range; subscription counts from the subscriptions table as of `to`.
|
||||
*/
|
||||
export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
const tz = siteTz(db);
|
||||
|
||||
// --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order.
|
||||
const rows = db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to)))
|
||||
.orderBy(asc(ledgerEvents.index))
|
||||
.all();
|
||||
|
||||
// Currency for display: money everywhere is { minorUnits, currency }; payments carry
|
||||
// the currency they were taken in, so take it from a payment in range (then fall back
|
||||
// to the active tariff version). Reports never mix currencies (single-currency site).
|
||||
let currency: string | null = null;
|
||||
|
||||
const seriesMap = new Map<string, SeriesPoint>();
|
||||
const entriesByHour = new Array<number>(24).fill(0);
|
||||
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
|
||||
const totals = {
|
||||
entries: 0,
|
||||
exits: 0,
|
||||
payments: 0,
|
||||
revenueMinor: 0,
|
||||
cashMinor: 0,
|
||||
cardMinor: 0,
|
||||
ticketMinor: 0,
|
||||
subscriptionSalesMinor: 0,
|
||||
subscriptionWindowMinor: 0,
|
||||
voids: 0,
|
||||
anomalies: 0,
|
||||
};
|
||||
|
||||
function point(label: string): SeriesPoint {
|
||||
let p = seriesMap.get(label);
|
||||
if (!p) {
|
||||
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
|
||||
seriesMap.set(label, 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) {
|
||||
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
||||
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
||||
if (row.type === "vehicle_entry") {
|
||||
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||
totals.entries++;
|
||||
p.entries++;
|
||||
const lp = localParts(row.occurredAt, tz);
|
||||
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
|
||||
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
|
||||
} else if (row.type === "vehicle_exit") {
|
||||
totals.exits++;
|
||||
p.exits++;
|
||||
} else if (row.type === "void") {
|
||||
totals.voids++;
|
||||
} else if (row.type === "anomaly") {
|
||||
totals.anomalies++;
|
||||
} else if (row.type === "payment") {
|
||||
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
if (!currency && typeof pl.currency === "string") currency = pl.currency;
|
||||
totals.payments++;
|
||||
totals.revenueMinor += amt;
|
||||
p.payments++;
|
||||
p.revenueMinor += amt;
|
||||
if (pl.tender === "card") {
|
||||
totals.cardMinor += amt;
|
||||
p.cardMinor += amt;
|
||||
} else {
|
||||
totals.cashMinor += amt;
|
||||
p.cashMinor += amt;
|
||||
}
|
||||
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||
// (the rest is) transient ticket revenue.
|
||||
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
|
||||
else totals.ticketMinor += amt;
|
||||
}
|
||||
}
|
||||
|
||||
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||
|
||||
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
|
||||
// series. Voided pre-range entries cancel out the same way the in-range pass does.
|
||||
// Sparse buckets (no events) simply carry the previous level — the step line is exact
|
||||
// at every plotted point.
|
||||
const prior = db
|
||||
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||
.from(ledgerEvents)
|
||||
.where(lt(ledgerEvents.occurredAt, q.from))
|
||||
.all();
|
||||
const priorVoided = new Set<string>();
|
||||
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
|
||||
let occupancyStart = 0;
|
||||
for (const r of prior) {
|
||||
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
|
||||
else if (r.type === "vehicle_exit") occupancyStart--;
|
||||
}
|
||||
occupancyStart = Math.max(0, occupancyStart);
|
||||
let running = occupancyStart;
|
||||
for (const p of series) {
|
||||
running = Math.max(0, running + p.entries - p.exits);
|
||||
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
|
||||
}
|
||||
|
||||
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
|
||||
|
||||
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||
// zero-revenue range still labels its money column.
|
||||
if (!currency) {
|
||||
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
if (tariff) {
|
||||
const tv = db
|
||||
.select()
|
||||
.from(tariffVersions)
|
||||
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||
.get();
|
||||
currency = tv?.currency ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged).
|
||||
const closed = db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to)))
|
||||
.all();
|
||||
const durations: number[] = [];
|
||||
for (const s of closed) {
|
||||
if (!s.enteredAt || !s.exitedAt) continue;
|
||||
const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000));
|
||||
durations.push(mins);
|
||||
}
|
||||
durations.sort((a, b) => a - b);
|
||||
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
|
||||
// the 8h working day, the 24h rolling day) so the chart answers "where should the
|
||||
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
|
||||
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
|
||||
const stayHistogram: { uptoMin: number | null; count: number }[] = [
|
||||
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
|
||||
{ uptoMin: null, count: 0 },
|
||||
];
|
||||
for (const mins of durations) {
|
||||
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
|
||||
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
|
||||
}
|
||||
|
||||
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||
const subs = db.select().from(subscriptions).all();
|
||||
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||
for (const s of subs) {
|
||||
if (s.status === "active") subStats.active++;
|
||||
else if (s.status === "suspended") subStats.suspended++;
|
||||
else if (s.status === "revoked") subStats.revoked++;
|
||||
const validNow =
|
||||
s.status === "active" &&
|
||||
(!s.validFrom || s.validFrom <= q.to) &&
|
||||
(!s.validTo || s.validTo >= q.to);
|
||||
if (validNow) {
|
||||
subStats.currentlyValid++;
|
||||
subStats.coveredCars += s.quantity ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: q.from,
|
||||
to: q.to,
|
||||
bucket: q.bucket,
|
||||
tz,
|
||||
currency,
|
||||
totals: {
|
||||
...totals,
|
||||
closedSessions: durations.length,
|
||||
totalParkedMinutes,
|
||||
avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0,
|
||||
medianParkedMinutes: median(durations),
|
||||
},
|
||||
series,
|
||||
entriesByHour,
|
||||
entriesByDowHour,
|
||||
stayHistogram,
|
||||
occupancyStart,
|
||||
capacity,
|
||||
subscriptions: subStats,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import { eq, roles, users, type Db } from "@parking/db";
|
||||
import {
|
||||
TOKEN_TTL,
|
||||
clearAuthCookies,
|
||||
newCsrfToken,
|
||||
requireRole,
|
||||
permissionsFor,
|
||||
requireAuth,
|
||||
setAuthCookies,
|
||||
} from "../auth.js";
|
||||
|
||||
@@ -17,6 +17,84 @@ interface LoginBody {
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LANGS = ["sq", "en"] as const;
|
||||
type Lang = (typeof LANGS)[number];
|
||||
interface LanguageBody {
|
||||
language: Lang;
|
||||
}
|
||||
|
||||
const THEMES = ["dark", "light"] as const;
|
||||
type Theme = (typeof THEMES)[number];
|
||||
interface ThemeBody {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
// UI font scale: percent of base, clamped to [80, 160] in steps of 10. Integer percent.
|
||||
const FONT_SCALE_MIN = 80;
|
||||
const FONT_SCALE_MAX = 160;
|
||||
interface FontScaleBody {
|
||||
fontScale: number;
|
||||
}
|
||||
|
||||
// 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
|
||||
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||
* permissions are the source of truth. */
|
||||
function sessionView(
|
||||
db: Db,
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
roleId: string;
|
||||
language: string;
|
||||
theme: string;
|
||||
fontScale: number;
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
},
|
||||
) {
|
||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||
const permissions = [...permissionsFor(user.roleId)];
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
roleId: user.roleId,
|
||||
roleName: role?.name ?? user.roleId,
|
||||
permissions,
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
fontScale: user.fontScale,
|
||||
fullName: user.fullName ?? null,
|
||||
email: user.email ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
@@ -29,17 +107,26 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Always run a bcrypt compare to avoid leaking which usernames exist (timing).
|
||||
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||
const ok = await bcrypt.compare(password, hash);
|
||||
if (!user || !ok) {
|
||||
// A soft-deleted user (in the recycle bin) cannot log in — treat as invalid, with no
|
||||
// distinct error so a deleted account isn't enumerable.
|
||||
if (!user || !ok || user.deletedAt) {
|
||||
return reply.code(401).send({ error: "invalid credentials" });
|
||||
}
|
||||
|
||||
const csrf = newCsrfToken();
|
||||
const token = await reply.jwtSign(
|
||||
{ sub: user.id, username: user.username, role: user.role, csrf },
|
||||
{ expiresIn: TOKEN_TTL },
|
||||
);
|
||||
// No expiresIn: the token is valid until explicit logout (see auth.ts). The
|
||||
// token carries roleId (not the permission list) — perms resolve per-request,
|
||||
// so a role edit applies immediately with no re-login.
|
||||
const token = await reply.jwtSign({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
roleId: user.roleId,
|
||||
csrf,
|
||||
});
|
||||
setAuthCookies(reply, token, csrf);
|
||||
return { id: user.id, username: user.username, role: user.role };
|
||||
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
||||
// read from the DB, so changing it needs no token refresh.
|
||||
return sessionView(db, user);
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", async (_req, reply) => {
|
||||
@@ -47,13 +134,114 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Who am I — used by the SPA to bootstrap session state on load.
|
||||
// Who am I — used by the SPA to bootstrap session state on load. Reads the live
|
||||
// `language` preference from the DB (not the token).
|
||||
app.get(
|
||||
"/api/auth/me",
|
||||
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
|
||||
async (req) => {
|
||||
const { sub, username, role } = req.user;
|
||||
return { id: sub, username, role };
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||
if (!row) {
|
||||
// The user was deleted while their cookie was still valid — clear it.
|
||||
clearAuthCookies(reply);
|
||||
return reply.code(401).send({ error: "session no longer valid" });
|
||||
}
|
||||
return sessionView(db, row);
|
||||
},
|
||||
);
|
||||
|
||||
// Change MY own UI language preference (any signed-in user). Persisted to the
|
||||
// users row so it's restored on the next login, from any booth. See i18n.md.
|
||||
app.put<{ Body: LanguageBody }>(
|
||||
"/api/auth/language",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const language = req.body?.language;
|
||||
if (!language || !LANGS.includes(language)) {
|
||||
return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` });
|
||||
}
|
||||
await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run();
|
||||
return { language };
|
||||
},
|
||||
);
|
||||
|
||||
// Change MY own UI theme preference (any signed-in user). Persisted to the users
|
||||
// row like `language`, so it's restored on the next login from any booth.
|
||||
app.put<{ Body: ThemeBody }>(
|
||||
"/api/auth/theme",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const theme = req.body?.theme;
|
||||
if (!theme || !THEMES.includes(theme)) {
|
||||
return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` });
|
||||
}
|
||||
await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run();
|
||||
return { theme };
|
||||
},
|
||||
);
|
||||
|
||||
// Change MY own UI font scale (any signed-in user). Percent of base, clamped to
|
||||
// [80, 160] in steps of 10. Persisted like `theme`, restored on the next login.
|
||||
app.put<{ Body: FontScaleBody }>(
|
||||
"/api/auth/font-scale",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const raw = req.body?.fontScale;
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return reply.code(400).send({ error: "fontScale must be a number" });
|
||||
}
|
||||
// Snap to a 10-step and clamp to the allowed band (defensive — the UI already does).
|
||||
const fontScale = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(raw / 10) * 10));
|
||||
await db.update(users).set({ fontScale }).where(eq(users.id, req.user.sub)).run();
|
||||
return { fontScale };
|
||||
},
|
||||
);
|
||||
|
||||
// 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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// HTTP integration for the backup routes — the security seam + the unconfigured-state
|
||||
// behaviour. The booted test app has no BACKUP_TARGET_DIR/BACKUP_KEY, so the service is
|
||||
// "not configured": status reports it, and a manual run is a clean 409 (not a 500).
|
||||
// See wiki/concepts/backup-recovery.md.
|
||||
|
||||
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("GET /api/backup/status", () => {
|
||||
it("401 without a session", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/backup/status" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("403 for a user lacking backup:read", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer", roleId: "viewer", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("an admin sees the (unconfigured) status shape", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body).toMatchObject({
|
||||
configured: false,
|
||||
targetDir: null,
|
||||
keepLast: 7, // code defaults surfaced when unset
|
||||
keepDailyDays: 30,
|
||||
running: false,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/backup/config — admin-chosen target", () => {
|
||||
it("403 for a user lacking backup:update", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer", roleId: "viewer", permissions: ["backup:read"],
|
||||
});
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: "/tmp/x" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("persists the target dir and reflects it in status", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: " /mnt/backup " }, // trimmed server-side
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json()).toMatchObject({ targetDir: "/mnt/backup" });
|
||||
|
||||
const status = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||
expect(status.json().targetDir).toBe("/mnt/backup");
|
||||
});
|
||||
|
||||
it("clears the target dir when given empty/null", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "/mnt/backup" },
|
||||
});
|
||||
const clear = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "" },
|
||||
});
|
||||
expect(clear.json().targetDir).toBeNull();
|
||||
});
|
||||
|
||||
it("persists retention and resets to defaults on null", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const set = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { keepLast: 3, keepDailyDays: 14 },
|
||||
});
|
||||
expect(set.json()).toMatchObject({ keepLast: 3, keepDailyDays: 14 });
|
||||
|
||||
// null resets to the code default.
|
||||
const reset = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { keepLast: null, keepDailyDays: null },
|
||||
});
|
||||
expect(reset.json()).toMatchObject({ keepLast: 7, keepDailyDays: 30 });
|
||||
});
|
||||
|
||||
it("rejects a negative retention value (400)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { keepLast: -1 },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/backup/test — path probe", () => {
|
||||
it("reports ok for a writable directory and a reason for a missing one", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const good = await app.inject({
|
||||
method: "POST", url: "/api/backup/test",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: process.cwd() }, // an existing, writable dir
|
||||
});
|
||||
expect(good.json()).toMatchObject({ ok: true });
|
||||
|
||||
const bad = await app.inject({
|
||||
method: "POST", url: "/api/backup/test",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: "/no/such/path/here-xyz" },
|
||||
});
|
||||
expect(bad.json()).toMatchObject({ ok: false, reason: "missing" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/backup/run", () => {
|
||||
it("403 for a user lacking backup:create", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer", roleId: "viewer", permissions: ["backup:read"], // read but not create
|
||||
});
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "POST", url: "/api/backup/run",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("requires CSRF on the mutation", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "POST", url: "/api/backup/run",
|
||||
headers: { cookie }, // no csrf header
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 409 backup_not_configured when no target/key is set (not a 500)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "POST", url: "/api/backup/run",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json()).toMatchObject({ error: "backup_not_configured" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { checkTargetDir, type BackupService } from "../backup-service.js";
|
||||
|
||||
// On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md.
|
||||
// - GET /api/backup/status : config + last-run success/error. (backup:read)
|
||||
// - PUT /api/backup/config : set the admin-chosen target directory. (backup:update)
|
||||
// - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update)
|
||||
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
|
||||
// The target dir lives in site_config (admin picks it from the UI); the encryption key stays an
|
||||
// env secret. RESTORE is intentionally absent — out-of-band runbook on a fresh appliance.
|
||||
|
||||
interface ConfigBody {
|
||||
targetDir?: string | null;
|
||||
/** Retention: keep this many newest backups. null = reset to the code default. */
|
||||
keepLast?: number | null;
|
||||
/** Retention: keep one-per-day within this many days. null = reset to the code default. */
|
||||
keepDailyDays?: number | null;
|
||||
}
|
||||
interface TestBody {
|
||||
targetDir?: string;
|
||||
}
|
||||
|
||||
export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise<void> {
|
||||
app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () =>
|
||||
backups.status(),
|
||||
);
|
||||
|
||||
// Set (or clear) the target directory. Empty/null clears it (backups become a no-op).
|
||||
app.put<{ Body: ConfigBody }>(
|
||||
"/api/backup/config",
|
||||
{ preHandler: requirePermission("backup:update") },
|
||||
async (req, reply) => {
|
||||
const body = req.body ?? {};
|
||||
const patch: { backupTargetDir?: string | null; backupKeepLast?: number | null; backupKeepDailyDays?: number | null } = {};
|
||||
|
||||
if ("targetDir" in body) {
|
||||
const raw = body.targetDir;
|
||||
if (raw != null && typeof raw !== "string") {
|
||||
return reply.code(400).send({ error: "targetDir must be a string or null" });
|
||||
}
|
||||
patch.backupTargetDir = raw == null ? null : raw.trim() || null;
|
||||
}
|
||||
// Retention: a non-negative integer, or null to reset to the code default.
|
||||
for (const [field, col] of [
|
||||
["keepLast", "backupKeepLast"],
|
||||
["keepDailyDays", "backupKeepDailyDays"],
|
||||
] as const) {
|
||||
if (field in body) {
|
||||
const v = body[field];
|
||||
if (v != null && (!Number.isInteger(v) || v < 0)) {
|
||||
return reply.code(400).send({ error: `${field} must be a non-negative integer or null` });
|
||||
}
|
||||
patch[col] = v ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
// Single-row site_config (id=1): upsert, since a fresh install may not have it yet.
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||
}
|
||||
return backups.status();
|
||||
},
|
||||
);
|
||||
|
||||
// Probe a candidate path before relying on it (the UI "Test target" button).
|
||||
app.post<{ Body: TestBody }>(
|
||||
"/api/backup/test",
|
||||
{ preHandler: requirePermission("backup:update") },
|
||||
async (req) => {
|
||||
const dir = typeof req.body?.targetDir === "string" ? req.body.targetDir : "";
|
||||
return checkTargetDir(dir);
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => {
|
||||
if (!backups.configured) {
|
||||
return reply.code(409).send({ error: "backup_not_configured" });
|
||||
}
|
||||
try {
|
||||
const res = await backups.run("manual");
|
||||
return reply.send({ ok: true, path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles });
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ error: "backup_failed", message: (err as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { DeviceMonitor } from "../device-monitor.js";
|
||||
|
||||
// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all
|
||||
// configured devices (relays/readers/cameras via healthCheck, printers via their
|
||||
// rich readStatus) in the background; this exposes its cache. Live updates ride the
|
||||
// booth WebSocket (kind:"device-status") — this REST route is the initial load /
|
||||
// fallback. Any authenticated role may read (operational, not a setup action).
|
||||
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
|
||||
|
||||
export async function deviceStatusRoutes(
|
||||
app: FastifyInstance,
|
||||
monitor: DeviceMonitor,
|
||||
): Promise<void> {
|
||||
const guard = requirePermission("device:read");
|
||||
|
||||
app.get("/api/devices/status", { preHandler: guard }, async () => ({
|
||||
devices: monitor.snapshot(),
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq, laneDevices, type Db } from "@parking/db";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import { verifyDigest } from "../digest-auth.js";
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
||||
const { deviceId, n, edge } = req.params;
|
||||
|
||||
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
||||
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
||||
|
||||
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||
|
||||
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
||||
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
||||
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
||||
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
|
||||
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
||||
// only their own; reviewers see all + can filter status.
|
||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
|
||||
// payments + vouchers over the whole chain — the
|
||||
// amount that carries across shifts).
|
||||
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
||||
// judgment about the operator settled outside the app, never a cash reversal.
|
||||
|
||||
interface MovementBody {
|
||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||
* cash_out = Mandat Pagese (pay-OUT). */
|
||||
type: "cash_in" | "cash_out";
|
||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
interface ReviewBody {
|
||||
/** The cash_in/cash_out event id being decided on. */
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
/** Optional admin note (e.g. why denied). */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface MovementsQuery {
|
||||
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||
status?: MovementStatus;
|
||||
}
|
||||
|
||||
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||
const createGuard = requirePermission("drawer:create");
|
||||
const reviewGuard = requirePermission("drawer:review");
|
||||
const readGuard = requirePermission("shift:read");
|
||||
|
||||
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
||||
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
|
||||
const b = req.body ?? ({} as MovementBody);
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
try {
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
operator: req.user.username,
|
||||
amountMinor: b.amountMinor,
|
||||
reason: b.reason ?? "",
|
||||
currency: b.currency,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
||||
// reviewer sees ALL and may filter by status (the pending review queue).
|
||||
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
|
||||
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||
const q = req.query ?? {};
|
||||
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||
const movements = shift.movementsWithStatus({
|
||||
operator: canReview ? undefined : req.user.username,
|
||||
status,
|
||||
});
|
||||
return { movements, scope: canReview ? "all" : "self" };
|
||||
});
|
||||
|
||||
// The physical drawer balance now. Same visibility as the open shift's X-report
|
||||
// (shift:read) — the drawer is a single site-wide till, not per-operator data.
|
||||
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
|
||||
|
||||
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
||||
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||
const b = req.body ?? ({} as ReviewBody);
|
||||
if (!b.refId || (b.decision !== "authorize" && b.decision !== "deny")) {
|
||||
return reply.code(400).send({ error: "refId and decision (authorize|deny) are required" });
|
||||
}
|
||||
try {
|
||||
return await shift.reviewMovement({
|
||||
refId: b.refId,
|
||||
decision: b.decision,
|
||||
reviewedBy: req.user.username,
|
||||
note: b.note,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { EntryFlow } from "../entry-flow.js";
|
||||
import type { LaneStatus } from "../lane-status.js";
|
||||
import type { ShiftService } from "../shift-service.js";
|
||||
import { NoShiftOpenError } from "../shift-service.js";
|
||||
|
||||
// Operator-issued entry (2026-07-01). When the physical entry button is broken, an operator
|
||||
// may issue an entry ticket — a FLAGGED mint (vehicle_entry source=manual + operatorInitiated
|
||||
// + a companion anomaly), gated EXACTLY like the physical button: a real vehicle must be
|
||||
// present (radar/loop AND camera). The presence gate is enforced HERE (server-side), so a
|
||||
// direct POST can't bypass a disabled UI button. Money-adjacent → requires an open shift.
|
||||
// See wiki/concepts/operator-issued-entry.md.
|
||||
|
||||
export async function entryRoutes(
|
||||
app: FastifyInstance,
|
||||
entryFlow: EntryFlow,
|
||||
laneStatus: LaneStatus,
|
||||
shift: ShiftService,
|
||||
): Promise<void> {
|
||||
const guard = requirePermission("session:create");
|
||||
|
||||
app.post("/api/entry/issue", { preHandler: guard }, async (req, reply) => {
|
||||
// Gate on an open shift (a minted entry belongs to an accountable operator).
|
||||
if (!shift.currentOpenShift()) {
|
||||
return reply.code(409).send({ error: new NoShiftOpenError().message });
|
||||
}
|
||||
// The camera side of the presence gate = the live entry lane-busy state; the radar/loop
|
||||
// side is checked inside the flow (its per-relay presence guard).
|
||||
const cameraBusy = laneStatus.snapshot().entry;
|
||||
const res = await entryFlow.issueForOperator(req.user.username, cameraBusy);
|
||||
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||
return res;
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, events, type Db } from "@parking/db";
|
||||
import { requireRole } from "../auth.js";
|
||||
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { enrichEvents } from "../event-enrich.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
|
||||
// Read access to the append-only signed event log. NO write/update/delete routes
|
||||
@@ -13,17 +15,38 @@ export async function eventRoutes(
|
||||
db: Db,
|
||||
eventLog: EventLog,
|
||||
): Promise<void> {
|
||||
// Any authenticated role may read the log (it's the audit trail).
|
||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
// Reading the log (the audit trail).
|
||||
const guard = requirePermission("event:read");
|
||||
|
||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||
app.get<{ Querystring: { limit?: string } }>(
|
||||
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes
|
||||
// the current shift's start so the live feed shows ONLY this shift's activity. An
|
||||
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a
|
||||
// selected shift's [start, end] to show just that shift's signed activity log.
|
||||
// (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
||||
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>(
|
||||
"/api/events",
|
||||
{ preHandler: guard },
|
||||
async (req) => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all();
|
||||
return { events: rows };
|
||||
const since = (req.query.since ?? "").trim();
|
||||
const until = (req.query.until ?? "").trim();
|
||||
const bounds = [
|
||||
since ? gte(ledgerEvents.occurredAt, since) : undefined,
|
||||
until ? lte(ledgerEvents.occurredAt, until) : undefined,
|
||||
].filter(Boolean);
|
||||
const rows = db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(bounds.length ? and(...bounds) : undefined)
|
||||
.orderBy(desc(ledgerEvents.index))
|
||||
.limit(limit)
|
||||
.all();
|
||||
// Attach read-time display fields (subscriber name, advisory plate) without
|
||||
// touching the signed record. One plate scan for the whole page (enrichEvents).
|
||||
// The cast bridges the Drizzle row to the shared LedgerEvent.
|
||||
const events = enrichEvents(db, rows as unknown as LedgerEvent[]);
|
||||
return { events };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -32,7 +55,7 @@ export async function eventRoutes(
|
||||
// reconciliation job / "is the log intact?" check calls.
|
||||
app.get(
|
||||
"/api/events/verify",
|
||||
{ preHandler: requireRole("admin") },
|
||||
{ preHandler: requirePermission("event:read") },
|
||||
async () => eventLog.verifyChain(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Fastify, { type FastifyInstance as RawFastify } from "fastify";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { hikvisionAlarmRoutes } from "./hikvision-alarm.js";
|
||||
import type { AnprBridge } from "../anpr-entry.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
|
||||
// detection POST from the camera's configured IP is accepted, summarized (eventType /
|
||||
// target / plate pulled out of the XML), and recorded verbatim as a kind:"alarm"
|
||||
// device_event — while a wrong source IP or a push-disabled device is refused.
|
||||
|
||||
const CAM_IP = "10.0.10.121";
|
||||
const CAM_ID = "cam-1";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
function seedHikCamera(cfg: Record<string, unknown> = {}) {
|
||||
db.insert(devices).values({
|
||||
id: CAM_ID,
|
||||
category: "camera",
|
||||
driverId: "hikvision",
|
||||
config: { host: CAM_IP, alarmPushEnabled: true, ...cfg },
|
||||
enabled: true,
|
||||
}).run();
|
||||
}
|
||||
|
||||
/** A representative Hikvision smart-event POST body (vehicle target). The real firmware
|
||||
* payload may differ; the endpoint stores it verbatim regardless — this asserts the
|
||||
* best-effort summary extraction over a plausible shape. */
|
||||
const VEHICLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<EventNotificationAlert version="2.0" xmlns="http://www.hikvision.com/ver20/XMLSchema">
|
||||
<ipAddress>10.0.10.121</ipAddress>
|
||||
<channelID>1</channelID>
|
||||
<dateTime>2026-06-22T10:15:30+02:00</dateTime>
|
||||
<eventType>fielddetection</eventType>
|
||||
<eventState>active</eventState>
|
||||
<DetectionRegionList>
|
||||
<DetectionRegionEntry><detectionTarget>vehicle</detectionTarget></DetectionRegionEntry>
|
||||
</DetectionRegionList>
|
||||
</EventNotificationAlert>`;
|
||||
|
||||
function alarmEvents(): { detail: Record<string, unknown> }[] {
|
||||
return db
|
||||
.select()
|
||||
.from(deviceEventsTable)
|
||||
.where(and(eq(deviceEventsTable.deviceId, CAM_ID), eq(deviceEventsTable.kind, "alarm")))
|
||||
.all() as { detail: Record<string, unknown> }[];
|
||||
}
|
||||
|
||||
/** Every recorded push for a device — accepted (kind:"alarm") AND rejected
|
||||
* (kind:"alarm-rejected"). */
|
||||
function allRecorded(deviceId: string): { kind: string; detail: Record<string, unknown> }[] {
|
||||
return db
|
||||
.select()
|
||||
.from(deviceEventsTable)
|
||||
.where(and(eq(deviceEventsTable.deviceId, deviceId), inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"])))
|
||||
.all() as { kind: string; detail: Record<string, unknown> }[];
|
||||
}
|
||||
|
||||
describe("Hikvision Alarm Server push", () => {
|
||||
it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => {
|
||||
seedHikCamera();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const events = alarmEvents();
|
||||
expect(events).toHaveLength(1);
|
||||
const d = events[0]!.detail;
|
||||
expect(d.source).toBe("hikvision-alarm-server");
|
||||
expect(d.eventType).toBe("fielddetection");
|
||||
expect(d.target).toBe("vehicle");
|
||||
expect(d.ip).toBe(CAM_IP);
|
||||
// The raw body is kept verbatim for inspection.
|
||||
expect(String(d.rawHead)).toContain("EventNotificationAlert");
|
||||
});
|
||||
|
||||
it("accepts the legacy string \"true\" for alarmPushEnabled (setup form quirk)", async () => {
|
||||
// The setup checkbox historically saved a STRING "true" instead of a boolean; the
|
||||
// guard must coerce it, not silently reject a feature the admin enabled.
|
||||
seedHikCamera({ alarmPushEnabled: "true" });
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(alarmEvents()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("pulls a plate out of an ANPR-style payload when present", async () => {
|
||||
seedHikCamera();
|
||||
const anpr = `<EventNotificationAlert><eventType>ANPR</eventType>
|
||||
<ANPR><plateNumber>AA123BB</plateNumber></ANPR></EventNotificationAlert>`;
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: anpr,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(alarmEvents()[0]!.detail.plate).toBe("AA123BB");
|
||||
});
|
||||
|
||||
it("accepts an unknown/JSON content-type as raw bytes (discovery-first)", async () => {
|
||||
seedHikCamera();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
payload: Buffer.from('{"eventType":"vehicleDetection"}'),
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(alarmEvents()[0]!.detail.eventType).toBe("vehicleDetection");
|
||||
});
|
||||
|
||||
it("accepts a push from ANY source IP when skipSourceIpCheck is set (WSL rewrites it)", async () => {
|
||||
// WSL mirrored mode rewrites the inbound source to the host's own IP, so the camera's
|
||||
// real IP never survives and a strict check rejects every push. With the opt-out, a
|
||||
// push from the 'wrong' IP is accepted.
|
||||
seedHikCamera({ skipSourceIpCheck: true });
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: "10.0.10.203", // the rewritten host IP, NOT the camera's
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(alarmEvents()).toHaveLength(1);
|
||||
expect(alarmEvents()[0]!.detail.target).toBe("vehicle");
|
||||
});
|
||||
|
||||
it("rejects a push from a DIFFERENT source IP (404, nothing recorded)", async () => {
|
||||
seedHikCamera();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: "10.0.10.200", // not the camera
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
// No ACCEPTED alarm...
|
||||
expect(alarmEvents()).toHaveLength(0);
|
||||
// ...but the rejection IS recorded (with the reason), so "nothing arrived" is never
|
||||
// ambiguous — you can see it came in and why it was refused.
|
||||
const recorded = allRecorded(CAM_ID);
|
||||
expect(recorded).toHaveLength(1);
|
||||
expect(recorded[0]!.kind).toBe("alarm-rejected");
|
||||
expect(String(recorded[0]!.detail.reason)).toMatch(/source IP/i);
|
||||
});
|
||||
|
||||
it("rejects when alarm push is disabled on the device", async () => {
|
||||
seedHikCamera({ alarmPushEnabled: false });
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an unknown device id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/nope/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload: VEHICLE_XML,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().reason).toMatch(/unknown device/i);
|
||||
});
|
||||
|
||||
it("GET /api/devices/hikvision/alarms lists accepted AND rejected pushes, newest first", async () => {
|
||||
seedHikCamera();
|
||||
// One accepted (right IP) + one rejected (wrong IP).
|
||||
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: CAM_IP });
|
||||
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: "10.0.10.200" });
|
||||
|
||||
const { username, password } = await seedUser(db, { username: "admin1", roleId: "admin" });
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.count).toBe(2);
|
||||
// Both accepted and rejected appear, with the accepted/reason flags.
|
||||
expect(body.alarms.some((a: { accepted: boolean }) => a.accepted === true)).toBe(true);
|
||||
const rejected = body.alarms.find((a: { accepted: boolean }) => a.accepted === false);
|
||||
expect(rejected.reason).toMatch(/source IP/i);
|
||||
});
|
||||
|
||||
it("the alarms read endpoint is gated (device:read) — 401 without a session", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// The ANPR bridge is handed each vehicle detection (fire-and-forget). We register the
|
||||
// routes on a bare instance with a SPY bridge to assert exactly when it's invoked —
|
||||
// only on a vehicle target that isn't `inactive`. (The bridge's own logic is covered in
|
||||
// anpr-entry.test.ts.)
|
||||
describe("Hikvision Alarm Server → ANPR bridge wiring", () => {
|
||||
let rawApp: RawFastify;
|
||||
let rawDb: Db;
|
||||
let rawClose: () => void;
|
||||
let onVehicleDetected: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
rawDb = t.db;
|
||||
rawClose = t.close;
|
||||
onVehicleDetected = vi.fn(async () => {});
|
||||
const bridge = { onVehicleDetected } as unknown as AnprBridge;
|
||||
rawApp = Fastify();
|
||||
await hikvisionAlarmRoutes(rawApp, rawDb, undefined, bridge);
|
||||
await rawApp.ready();
|
||||
rawDb.insert(devices).values({
|
||||
id: CAM_ID,
|
||||
category: "camera",
|
||||
driverId: "hikvision",
|
||||
config: { host: CAM_IP, alarmPushEnabled: true },
|
||||
enabled: true,
|
||||
}).run();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rawApp.close();
|
||||
rawClose();
|
||||
});
|
||||
|
||||
async function post(payload: string) {
|
||||
return rawApp.inject({
|
||||
method: "POST",
|
||||
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||
headers: { "content-type": "application/xml" },
|
||||
payload,
|
||||
remoteAddress: CAM_IP,
|
||||
});
|
||||
}
|
||||
|
||||
it("hands a vehicle (active) detection to the bridge", async () => {
|
||||
const res = await post(VEHICLE_XML);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(onVehicleDetected).toHaveBeenCalledTimes(1);
|
||||
expect(onVehicleDetected).toHaveBeenCalledWith(CAM_ID);
|
||||
});
|
||||
|
||||
it("does NOT call the bridge for a human target", async () => {
|
||||
const human = VEHICLE_XML.replace("vehicle", "human");
|
||||
await post(human);
|
||||
expect(onVehicleDetected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call the bridge on an `inactive` (leave) vehicle event", async () => {
|
||||
const leave = VEHICLE_XML.replace("<eventState>active</eventState>", "<eventState>inactive</eventState>");
|
||||
await post(leave);
|
||||
expect(onVehicleDetected).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { verifyDigest } from "../digest-auth.js";
|
||||
import type { LaneStatus } from "../lane-status.js";
|
||||
import type { AnprBridge } from "../anpr-entry.js";
|
||||
|
||||
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
|
||||
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
|
||||
// Settings → Alarm Server) HTTP-POST an EventNotificationAlert to a URL we host every
|
||||
// time the chosen target is detected. This is the same machine-call pattern as the
|
||||
// Dingtian Input Link push (routes/devices.ts): source-IP guarded, NOT behind the SPA
|
||||
// cookie/CSRF.
|
||||
//
|
||||
// DISCOVERY-FIRST. Hik's push format varies by model/firmware (event XML, or multipart
|
||||
// with an attached JPEG, or — on some ANPR units — an <ANPR>/<plateNumber> block). So
|
||||
// this endpoint is deliberately PERMISSIVE: it accepts ANY content-type as raw bytes,
|
||||
// records the verbatim body as a `kind:"alarm"` device_event, and best-effort extracts a
|
||||
// summary (eventType / target / plate). The goal of this first cut is to SEE exactly what
|
||||
// a given camera sends — inspect via GET /api/events or the logs — before we wire it into
|
||||
// the read bus / a snapshot trigger. It never opens a barrier (a plate read is advisory,
|
||||
// never the sole reason; see wiki/concepts/append-only-event-chain.md).
|
||||
//
|
||||
// See wiki/entities/lpr-camera.md, wiki/concepts/device-input-flow.md.
|
||||
|
||||
interface HikDeviceConfig {
|
||||
host?: string;
|
||||
alarmPushEnabled?: boolean | string | number;
|
||||
pushUser?: string;
|
||||
pushPassword?: string;
|
||||
/** Skip the source-IP guard for this device's pushes. The source IP is the primary
|
||||
* LAN guard, but it's UNRELIABLE in some environments — notably WSL mirrored mode,
|
||||
* which rewrites an inbound packet's source to the host's OWN address, so the camera's
|
||||
* real IP never survives and a strict check rejects every push. When pushUser/
|
||||
* pushPassword (Digest) are set, that auth is the real guard and source-IP adds little;
|
||||
* this flag lets a deployment opt out. The signed ledger remains the anti-fraud truth. */
|
||||
skipSourceIpCheck?: boolean | string | number;
|
||||
}
|
||||
|
||||
/** Coerce a device-config flag to a boolean. The config is loosely-typed JSON from the
|
||||
* setup form, which has historically stored a checkbox as the STRING "true" (a form-
|
||||
* serialization quirk) — so accept true / "true" / 1 / "1" / "yes" / "on", reject the
|
||||
* rest. Being lenient here means a stray "true" never silently disables a real feature. */
|
||||
function isOn(v: unknown): boolean {
|
||||
if (v === true) return true;
|
||||
if (typeof v === "number") return v === 1;
|
||||
if (typeof v === "string") return /^(1|true|yes|on)$/i.test(v.trim());
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A best-effort summary pulled out of the raw push body (XML or JSON), for the device
|
||||
* event detail + the log line. Absent fields just mean "not found in this firmware's
|
||||
* payload" — the raw body is always stored so nothing is lost. */
|
||||
interface AlarmSummary {
|
||||
eventType?: string;
|
||||
/** `active` (target entered the region) | `inactive` (target left). The edge that
|
||||
* drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */
|
||||
eventState?: string;
|
||||
target?: string;
|
||||
plate?: string;
|
||||
dateTime?: string;
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
function clientIp(req: FastifyRequest): string {
|
||||
return req.ip.replace(/^::ffff:/, "");
|
||||
}
|
||||
|
||||
/** First capture group of `re` in `s`, trimmed, or undefined. */
|
||||
function pick(s: string, re: RegExp): string | undefined {
|
||||
const m = re.exec(s);
|
||||
return m?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort summary extraction. Hikvision event XML uses tags like <eventType>,
|
||||
* <dateTime>, <channelID>; smart/ANPR events add target/plate tags whose exact names
|
||||
* vary by firmware (<detectionTarget>, <targetType>, <plateNumber>, <licensePlate>).
|
||||
* We probe several spellings; whatever doesn't match is simply absent. JSON bodies are
|
||||
* scanned for the same keys.
|
||||
*/
|
||||
function summarize(body: string): AlarmSummary {
|
||||
return {
|
||||
eventType: pick(body, /<eventType>([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i),
|
||||
eventState: pick(body, /<eventState>([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i),
|
||||
target:
|
||||
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
|
||||
pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i),
|
||||
plate:
|
||||
pick(body, /<(?:plateNumber|licensePlate|plateNo)>([^<]+)<\//i) ??
|
||||
pick(body, /"(?:plateNumber|licensePlate|plateNo)"\s*:\s*"([^"]+)"/i),
|
||||
dateTime: pick(body, /<dateTime>([^<]+)<\/dateTime>/i),
|
||||
channelId: pick(body, /<channelID>([^<]+)<\/channelID>/i) ?? pick(body, /<channelId>([^<]+)<\/channelId>/i),
|
||||
};
|
||||
}
|
||||
|
||||
export async function hikvisionAlarmRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
laneStatus?: LaneStatus,
|
||||
anprBridge?: AnprBridge,
|
||||
): Promise<void> {
|
||||
// Accept ANY content-type as a raw Buffer (the camera may POST application/xml,
|
||||
// multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415
|
||||
// or empty these — we want the bytes verbatim. Scoped to THIS app instance via a
|
||||
// wildcard parser; a 10 MB cap covers an event + an attached frame.
|
||||
app.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, (_req, body, done) => {
|
||||
done(null, body);
|
||||
});
|
||||
|
||||
/** Record EVERY push (accepted or rejected) as a device_event so the read endpoint /
|
||||
* DB always shows that SOMETHING arrived — the key fix: a rejected push used to log a
|
||||
* warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */
|
||||
function record(args: {
|
||||
deviceId: string;
|
||||
method: string;
|
||||
accepted: boolean;
|
||||
reason?: string;
|
||||
ip: string;
|
||||
contentType: string;
|
||||
raw: Buffer;
|
||||
summary: AlarmSummary;
|
||||
}): void {
|
||||
try {
|
||||
db.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId: args.deviceId,
|
||||
category: "camera",
|
||||
kind: args.accepted ? "alarm" : "alarm-rejected",
|
||||
detail: {
|
||||
source: "hikvision-alarm-server",
|
||||
accepted: args.accepted,
|
||||
method: args.method,
|
||||
...(args.reason ? { reason: args.reason } : {}),
|
||||
ip: args.ip,
|
||||
contentType: args.contentType,
|
||||
bytes: args.raw.length,
|
||||
...args.summary,
|
||||
// Readable head verbatim (the XML part); truncated to keep the row small.
|
||||
rawHead: args.raw.toString("utf8").slice(0, 8000),
|
||||
},
|
||||
occurredAt: new Date().toISOString(),
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
|
||||
const { deviceId } = req.params;
|
||||
const method = req.method;
|
||||
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||
const cfg = row?.config as HikDeviceConfig | undefined;
|
||||
const ip = clientIp(req);
|
||||
const contentType = String(req.headers["content-type"] ?? "");
|
||||
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
|
||||
const summary = summarize(raw.toString("utf8"));
|
||||
// Log EVERY hit immediately (method + ip + size), before any guard — so even a probe
|
||||
// that gets rejected is visible in the dev log the instant it arrives.
|
||||
app.log.info(`[hik-alarm:${deviceId}] HIT ${method} from ${ip} (${contentType || "no-ct"} ${raw.length}B)`);
|
||||
|
||||
// Guard: must be a known hikvision device with alarm-push enabled, posting from its
|
||||
// configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian).
|
||||
// On rejection we STILL record it (with the precise reason) so a push that reached us
|
||||
// never silently disappears — that's what makes "is it coming?" answerable.
|
||||
// The source-IP check is skipped when the device opts out (skipSourceIpCheck) — needed
|
||||
// where the network rewrites the inbound source IP (e.g. WSL mirrored mode rewrites it
|
||||
// to the host's own address), so a strict match can never pass. Digest auth (when set)
|
||||
// and the signed ledger remain the real guards. See HikDeviceConfig.skipSourceIpCheck.
|
||||
const skipIp = isOn(cfg?.skipSourceIpCheck);
|
||||
let reason: string | null = null;
|
||||
if (!row || !cfg) reason = "unknown device id";
|
||||
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`;
|
||||
else if (!isOn(cfg.alarmPushEnabled)) reason = "alarm push not enabled on this device (tick it in Setup)";
|
||||
else if (!cfg.host) reason = "device has no host IP configured";
|
||||
else if (!skipIp && ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host} (set skipSourceIpCheck if the network rewrites it, e.g. WSL)`;
|
||||
|
||||
if (reason) {
|
||||
app.log.warn(`[hik-alarm:${deviceId}] REJECTED ${method} from ${ip} (${contentType} ${raw.length}B): ${reason}`);
|
||||
record({ deviceId, method, accepted: false, reason, ip, contentType, raw, summary });
|
||||
return reply.code(404).send({ error: "not found", reason });
|
||||
}
|
||||
|
||||
// Optional Digest auth — only when the admin configured push creds (some firmware
|
||||
// can't authenticate the Alarm Server call; then we rely on source-IP alone).
|
||||
if (cfg!.pushUser && cfg!.pushPassword) {
|
||||
if (!verifyDigest(req, reply, { user: cfg!.pushUser, password: cfg!.pushPassword })) {
|
||||
record({ deviceId, method, accepted: false, reason: "digest auth failed/challenge", ip, contentType, raw, summary });
|
||||
return; // 401 challenge already sent
|
||||
}
|
||||
}
|
||||
|
||||
// Loud log so the operator can SEE the payload during testing.
|
||||
app.log.info(
|
||||
`[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` +
|
||||
`event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
|
||||
);
|
||||
record({ deviceId, method, accepted: true, ip, contentType, raw, summary });
|
||||
|
||||
// Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory,
|
||||
// for the booth barrier lights). Only on a vehicle target that's `active` — an
|
||||
// `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a
|
||||
// timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent.
|
||||
const isVehicleActive =
|
||||
(summary.target ?? "").toLowerCase() === "vehicle" &&
|
||||
(summary.eventState ?? "active").toLowerCase() !== "inactive";
|
||||
if (laneStatus && isVehicleActive) {
|
||||
laneStatus.vehicleDetected(deviceId);
|
||||
}
|
||||
|
||||
// ANPR BRIDGE: on a vehicle detection, if this camera opts into ANPR (config.anpr),
|
||||
// pull a snapshot → read the plate → if it matches a SUBSCRIBER, emit a plate read
|
||||
// onto the bus, which the existing gated SubscriptionFlow turns into an entry/exit +
|
||||
// barrier open. Fire-and-forget — NEVER awaited on the 200 path (the camera must get
|
||||
// a prompt ack or it retry-storms), and fail-soft inside the bridge. See anpr-entry.ts.
|
||||
if (anprBridge && isVehicleActive) {
|
||||
void anprBridge.onVehicleDetected(deviceId);
|
||||
}
|
||||
|
||||
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
|
||||
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
|
||||
// entry/exit) is the deliberate next step once we know the real payload.
|
||||
deviceEvents.emitInput({ driverId: "hikvision", deviceId, input: 0, edge: "on", at: new Date().toISOString(), source: "push" });
|
||||
|
||||
// 200 so the camera considers the alarm delivered and doesn't retry-storm.
|
||||
return reply.code(200).send({ ok: true });
|
||||
};
|
||||
|
||||
// Listen for EVERY method on the event path. The camera (and its "Test" button) may
|
||||
// probe with GET/HEAD/OPTIONS/PUT, not just POST — and a method we don't register gets
|
||||
// Fastify's generic 404, which the camera reads as "service available" while our
|
||||
// handler never runs (so nothing is recorded). Registering all methods means ANYTHING
|
||||
// that hits this URL reaches `handle` and is captured (the method is logged + stored),
|
||||
// so we can finally SEE exactly what the camera sends. See wiki/entities/lpr-camera.md.
|
||||
// (HEAD is auto-added by Fastify alongside GET — don't register it explicitly.)
|
||||
for (const method of ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
|
||||
app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle });
|
||||
}
|
||||
|
||||
// Read endpoint: the recent alarm pushes (accepted AND rejected), newest first — so you
|
||||
// can SEE in the browser whether events are arriving and why any were refused, instead
|
||||
// of grepping the dev log or querying SQLite. Gated device:read (admin device view).
|
||||
app.get<{ Querystring: { limit?: string } }>(
|
||||
"/api/devices/hikvision/alarms",
|
||||
{ preHandler: requirePermission("device:read") },
|
||||
async (req) => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
|
||||
const rows = db
|
||||
.select()
|
||||
.from(deviceEventsTable)
|
||||
.where(inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))
|
||||
.orderBy(desc(deviceEventsTable.occurredAt))
|
||||
.limit(limit)
|
||||
.all();
|
||||
const alarms = rows.map((r) => {
|
||||
const d = (r.detail ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
at: r.occurredAt,
|
||||
deviceId: r.deviceId,
|
||||
accepted: d.accepted === true,
|
||||
method: (d.method as string) ?? null,
|
||||
reason: (d.reason as string) ?? null,
|
||||
ip: (d.ip as string) ?? null,
|
||||
contentType: (d.contentType as string) ?? null,
|
||||
bytes: (d.bytes as number) ?? 0,
|
||||
eventType: (d.eventType as string) ?? null,
|
||||
eventState: (d.eventState as string) ?? null,
|
||||
target: (d.target as string) ?? null,
|
||||
plate: (d.plate as string) ?? null,
|
||||
rawHead: (d.rawHead as string) ?? null,
|
||||
};
|
||||
});
|
||||
return { count: alarms.length, alarms };
|
||||
},
|
||||
);
|
||||
}
|
||||