Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b485e9870b | |||
| ec44547122 | |||
| e67f0ccef0 | |||
| 78ca58d264 | |||
| 20a3cb3e80 | |||
| 5e1395db18 | |||
| 50c18405b6 | |||
| e14e31a840 | |||
| ea304bbfd1 | |||
| 2aa1045ddc | |||
| acde3bba5b | |||
| 9a13528611 | |||
| 6f88026d3e | |||
| c481c1e788 | |||
| 3a7c3fae11 | |||
| 55d6242c7d | |||
| a9ccf9e20c | |||
| 23d6379be8 | |||
| db9c3e0e31 | |||
| d86bffa500 | |||
| 9c05f86c86 | |||
| 54e691a4c9 | |||
| 52862db8ad | |||
| 8fa66c9911 | |||
| 70e1e9939f | |||
| 5c6a21e2c3 | |||
| 969bf2b191 | |||
| 7d67934a10 | |||
| 56904422af | |||
| 8bcdea9e4a | |||
| 7804285dec | |||
| 4a7029cea6 | |||
| 7317042e8d | |||
| 439b11d16d | |||
| 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 |
@@ -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
|
||||
@@ -1,17 +1,19 @@
|
||||
name: Build & push images
|
||||
|
||||
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
||||
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, main→:main).
|
||||
# Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle). Mirrors the
|
||||
# house pattern (cf. trm/processor build.yml). See wiki/decisions/container-deployment.md.
|
||||
# Build the SERVER (API + SPA), COLLECTOR (wash review) 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, main]
|
||||
branches: [dev, stage, main]
|
||||
paths:
|
||||
- 'apps/server/**'
|
||||
- 'apps/web/**'
|
||||
- 'apps/vision/**'
|
||||
- 'apps/collector/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
@@ -20,6 +22,10 @@ on:
|
||||
- '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:
|
||||
@@ -87,12 +93,26 @@ jobs:
|
||||
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 COLLECTOR (wash review)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: apps/collector/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}
|
||||
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache,mode=max
|
||||
|
||||
- name: Build & push VISION (ANPR)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
|
||||
+183
-26
@@ -1,12 +1,24 @@
|
||||
name: Release desktop
|
||||
|
||||
# Build the signed Tauri desktop installers on a version tag and publish them as
|
||||
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
|
||||
# fetches these; latest.json + each installer + its .sig are what it needs.
|
||||
# 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, and uploads
|
||||
# everything to the Release for that tag.
|
||||
# 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:
|
||||
@@ -63,6 +75,27 @@ jobs:
|
||||
- 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
|
||||
@@ -73,43 +106,88 @@ jobs:
|
||||
|
||||
- name: Collect artifacts
|
||||
id: collect
|
||||
# Gather the installers + their .sig into a flat dist/ for upload.
|
||||
# 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' \) \
|
||||
-exec cp {} dist/ \;
|
||||
-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}. We point the AppImage target at
|
||||
# this release's asset URL. Adjust the platform keys you actually ship.
|
||||
# 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.
|
||||
#
|
||||
# ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update
|
||||
# v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up
|
||||
# `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage,
|
||||
# from the running app's detected bundle type) and only then the bare
|
||||
# `linux-x86_64`. The booths run the .deb, and the manifest used to
|
||||
# carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the
|
||||
# "update", downloaded the AppImage, verified its signature fine, then
|
||||
# handed the bytes to install_deb(), which checks they're a .deb
|
||||
# (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after
|
||||
# the download, before any relaunch, with the error swallowed client-
|
||||
# side until v0.1.6. Now each installer gets its own signed asset; the
|
||||
# bare key stays for an AppImage install. .deb/.rpm updates run
|
||||
# `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password
|
||||
# prompt — intended: updating a root-installed package IS an admin
|
||||
# action on this box (see wiki/decisions/desktop-shell-tauri.md).
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
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}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
|
||||
cat > dist/latest.json <<JSON
|
||||
ASSET_BASE="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest"
|
||||
cat > /tmp/latest.js <<'JS'
|
||||
const fs = require("fs");
|
||||
const [version, tag, base] = process.argv.slice(2);
|
||||
const files = fs.readdirSync("dist");
|
||||
const pick = (ext) => files.find((f) => f.endsWith(ext));
|
||||
const entry = (f) => ({
|
||||
signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(),
|
||||
url: `${base}/${f}`,
|
||||
});
|
||||
const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage");
|
||||
if (!deb || !appimage) {
|
||||
console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const platforms = {
|
||||
"linux-x86_64-deb": entry(deb),
|
||||
...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}),
|
||||
"linux-x86_64": entry(appimage),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
"dist/latest.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
"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
|
||||
version,
|
||||
notes: `Parking System ${tag}`,
|
||||
pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
||||
platforms,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
JS
|
||||
node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}"
|
||||
echo "latest.json:"; cat dist/latest.json
|
||||
|
||||
- name: Create release + upload assets (Gitea API)
|
||||
@@ -129,12 +207,12 @@ jobs:
|
||||
-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)
|
||||
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)
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
fi
|
||||
echo "release id: ${REL_ID}"
|
||||
for f in dist/*; do
|
||||
@@ -147,3 +225,82 @@ jobs:
|
||||
"${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"
|
||||
|
||||
@@ -27,3 +27,9 @@ dist/
|
||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||
graphify-out/
|
||||
parking.sqlite*.bak-*
|
||||
questions.txt
|
||||
|
||||
# session planning files (planning-with-files skill)
|
||||
task_plan.md
|
||||
findings.md
|
||||
progress.md
|
||||
|
||||
@@ -9,5 +9,7 @@
|
||||
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
||||
:80 {
|
||||
encode gzip
|
||||
reverse_proxy server:3000
|
||||
# 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,16 @@
|
||||
# Car Wash review collector (wiki/concepts/vision-review-outbox.md). Runs on the
|
||||
# reviewer's host (art-docker-station), reachable by the booths ONLY over the Netbird
|
||||
# overlay. Deployed by its own Komodo stack (komodo/resources.toml, "wash-collector").
|
||||
|
||||
# COLLECTOR_HOST=0.0.0.0 # in Docker the compose file binds the published port to the overlay IP
|
||||
# COLLECTOR_PORT=8090
|
||||
# COLLECTOR_DATA_DIR=/data # collector.sqlite + crops/<booth>/<item>.jpg
|
||||
|
||||
# One bearer token per booth: "<boothId>:<token>" pairs, comma- or newline-separated. The
|
||||
# booth id is the pseudonymous CARWASH_REVIEW_BOOTH_ID that booth was deployed with — never
|
||||
# a site name. Generate tokens with: openssl rand -hex 32
|
||||
COLLECTOR_BOOTH_TOKENS=booth-7:REPLACE,booth-9:REPLACE
|
||||
|
||||
# The reviewer's login for the review screen and the export (HTTP Basic over the overlay).
|
||||
COLLECTOR_REVIEWER_USER=reviewer
|
||||
COLLECTOR_REVIEWER_PASS=REPLACE
|
||||
@@ -0,0 +1,48 @@
|
||||
# parking-collector — the Car Wash review collector (wiki/concepts/vision-review-outbox.md).
|
||||
# Built from the monorepo root (context: .) like the server image, so it shares the
|
||||
# lockfile and @parking/shared. Runs on the REVIEWER's host (not a booth), delivered by
|
||||
# its own Komodo stack (docker-compose.collector.yml). Data on /data: collector.sqlite +
|
||||
# crops/<booth>/<item>.jpg — the trainer on the same host reads the crops off that volume.
|
||||
|
||||
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
|
||||
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 apps/collector/package.json apps/collector/
|
||||
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
|
||||
|
||||
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
|
||||
RUN pnpm turbo run build --filter=@parking/collector
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm --filter=@parking/collector --legacy deploy --prod /deploy
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ARG BUILD_VERSION=""
|
||||
ENV BUILD_VERSION=$BUILD_VERSION
|
||||
ENV NODE_ENV=production
|
||||
RUN apk add --no-cache libstdc++ wget # better-sqlite3 native runtime; wget for the healthcheck
|
||||
RUN addgroup -S app && adduser -S -G app app
|
||||
COPY --from=build --chown=app:app /deploy ./
|
||||
ENV COLLECTOR_DATA_DIR=/data
|
||||
ENV COLLECTOR_HOST=0.0.0.0
|
||||
ENV COLLECTOR_PORT=8090
|
||||
RUN mkdir -p /data && chown app:app /data
|
||||
VOLUME ["/data"]
|
||||
USER app
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- "http://localhost:${COLLECTOR_PORT:-8090}/health" >/dev/null 2>&1 || exit 1
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@parking/collector",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Car Wash review collector: receives plate-blurred vehicle crops + the operator's category choice from booths over the private overlay, serves the reviewer's screen, exports labels for training. See wiki/concepts/vision-review-outbox.md.",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
|
||||
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/multipart": "^9.2.1",
|
||||
"@parking/shared": "workspace:*",
|
||||
"better-sqlite3": "12.10.1",
|
||||
"fastify": "5.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/node": "25.9.3",
|
||||
"tsx": "4.22.4",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { buildCollector, type CollectorApp } from "./app.js";
|
||||
import { parseBoothTokens } from "./config.js";
|
||||
|
||||
// The collector: one ingest surface (bearer per booth, idempotent), one review surface
|
||||
// (Basic), one export. Exercised over app.inject with a hand-built multipart body.
|
||||
|
||||
let app: CollectorApp;
|
||||
let dir: string;
|
||||
const TOKENS = new Map([["booth-7", "0123456789abcdef0123456789abcdef"], ["booth-9", "fedcba9876543210fedcba9876543210"]]);
|
||||
const REVIEWER = { user: "julian", pass: "review-pass-123" };
|
||||
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
|
||||
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER }, { dbFile: ":memory:" });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A minimal JPEG-looking blob (SOI marker + padding) — the collector checks the magic only. */
|
||||
const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(200, 1)]);
|
||||
|
||||
function meta(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
v: 1, booth: "booth-7", item: "item-1", order: "o-1", at: "2026-09-06T10:00:00.000Z", operator: "ab12cd34ef56ab12",
|
||||
operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan", "hatchback"] }, service: "Standard",
|
||||
vision: { class: "suv", confidence: 0.91, categoryId: "suv" }, downgraded: true,
|
||||
image: { width: 320, height: 200, plateBlurred: true },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function multipart(fields: Record<string, string>, file: Buffer | null): { body: Buffer; type: string } {
|
||||
const b = "----collector-test";
|
||||
const parts: Buffer[] = [];
|
||||
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||||
if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="image"; filename="x.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`), file, Buffer.from("\r\n"));
|
||||
parts.push(Buffer.from(`--${b}--\r\n`));
|
||||
return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };
|
||||
}
|
||||
|
||||
async function ingest(m: Record<string, unknown>, token = TOKENS.get("booth-7")!, file: Buffer | null = JPEG, extra: Record<string, string> = {}) {
|
||||
const { body, type } = multipart({ meta: JSON.stringify(m) }, file);
|
||||
return app.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${token}`, "content-type": type, ...extra }, payload: body });
|
||||
}
|
||||
|
||||
describe("ingest", () => {
|
||||
it("stores the crop and the decision under the token's booth; retries are idempotent", async () => {
|
||||
const r = await ingest(meta());
|
||||
expect(r.statusCode).toBe(201);
|
||||
const row = app.collectorDb.get("item-1")!;
|
||||
expect(row).toMatchObject({ booth: "booth-7", operatorCategoryName: "Vetura", visionClass: "suv", downgraded: 1, plateBlurred: 1, imagePath: "crops/booth-7/item-1.jpg" });
|
||||
expect(JSON.parse(row.operatorClasses)).toEqual(["car", "sedan", "hatchback"]);
|
||||
const again = await ingest(meta());
|
||||
expect(again.statusCode).toBe(200);
|
||||
expect(again.json()).toEqual({ ok: true, duplicate: true });
|
||||
expect((await app.inject({ method: "GET", url: "/health" })).json()).toMatchObject({ ok: true, booths: 1, pending: 1 });
|
||||
});
|
||||
|
||||
it("refuses a bad token, a booth mismatch, a non-JPEG, and malformed meta", async () => {
|
||||
expect((await ingest(meta(), "nope-nope-nope-nope-nope")).statusCode).toBe(401);
|
||||
expect((await ingest(meta({ booth: "booth-9" }))).statusCode).toBe(422); // token is booth-7's
|
||||
expect((await ingest(meta(), TOKENS.get("booth-7")!, JPEG, { "x-booth-id": "booth-9" })).statusCode).toBe(403);
|
||||
expect((await ingest(meta(), TOKENS.get("booth-7")!, Buffer.alloc(300, 7))).statusCode).toBe(415);
|
||||
expect((await ingest(meta(), TOKENS.get("booth-7")!, null)).statusCode).toBe(400);
|
||||
expect((await ingest(meta({ vision: { class: "spaceship", confidence: 0.5, categoryId: null } }))).statusCode).toBe(422);
|
||||
expect((await ingest(meta({ item: "../../etc/passwd" }))).statusCode).toBe(422);
|
||||
expect((await ingest(meta({ v: 2 }))).statusCode).toBe(422);
|
||||
expect(app.collectorDb.stats().booths).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("review + export", () => {
|
||||
it("the reviewer lists pending items, sees the crop, labels it; stats compare the label with the operator's category; the export lists usable labels only", async () => {
|
||||
await ingest(meta());
|
||||
await ingest(meta({ item: "item-2", operator: "ab12cd34ef56ab12", vision: { class: "car", confidence: 0.8, categoryId: "car" }, downgraded: false }));
|
||||
await ingest(meta({ item: "item-3", booth: "booth-9", operator: "9999999999999999" }), TOKENS.get("booth-9")!);
|
||||
|
||||
// No login → 401 with a challenge; nothing without a configured reviewer is tested in config.
|
||||
const anon = await app.inject({ method: "GET", url: "/api/items" });
|
||||
expect(anon.statusCode).toBe(401);
|
||||
expect(anon.headers["www-authenticate"]).toContain("Basic");
|
||||
expect((await app.inject({ method: "GET", url: "/review", headers: { authorization: basic } })).headers["content-type"]).toContain("text/html");
|
||||
|
||||
const list = (await app.inject({ method: "GET", url: "/api/items?status=pending", headers: { authorization: basic } })).json();
|
||||
expect(list.items.map((i: { id: string }) => i.id)).toEqual(["item-1", "item-2", "item-3"]);
|
||||
expect(list.items[0].imagePath).toBeUndefined();
|
||||
const img = await app.inject({ method: "GET", url: "/api/items/item-1/image", headers: { authorization: basic } });
|
||||
expect(img.statusCode).toBe(200);
|
||||
expect(img.headers["content-type"]).toBe("image/jpeg");
|
||||
expect(img.rawPayload.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
||||
|
||||
// item-1: operator said Vetura (car/sedan/hatchback), reviewer says suv → disagree.
|
||||
// item-2: reviewer says sedan → inside Vetura → agree. item-3: unusable.
|
||||
const post = (id: string, label: string) =>
|
||||
app.inject({ method: "POST", url: `/api/items/${id}/review`, headers: { authorization: basic, "content-type": "application/json" }, payload: { label } });
|
||||
expect((await post("item-1", "suv")).json()).toMatchObject({ reviewLabel: "suv", reviewer: "julian" });
|
||||
expect((await post("item-2", "sedan")).statusCode).toBe(200);
|
||||
expect((await post("item-3", "unusable")).statusCode).toBe(200);
|
||||
expect((await post("item-3", "spaceship")).statusCode).toBe(400);
|
||||
expect((await post("nope", "suv")).statusCode).toBe(404);
|
||||
|
||||
const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
|
||||
expect(stats.booths).toEqual([
|
||||
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2 },
|
||||
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1 },
|
||||
]);
|
||||
expect(stats.operators).toEqual([
|
||||
{ booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 },
|
||||
{ booth: "booth-9", operatorRef: "9999999999999999", reviewed: 1, agree: 0, disagree: 0, unusable: 1 },
|
||||
]);
|
||||
|
||||
const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } });
|
||||
expect(csv.statusCode).toBe(200);
|
||||
const lines = csv.body.trim().split("\n");
|
||||
expect(lines[0]).toBe("item,booth,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at");
|
||||
expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out
|
||||
expect(lines[1]).toContain('"item-1","booth-7","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
|
||||
|
||||
// A booth-supplied name that looks like a spreadsheet formula is neutralised in the export.
|
||||
await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } }));
|
||||
await post("item-4", "car");
|
||||
const csv2 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
|
||||
expect(csv2).toContain(`"'=HYPERLINK(""http://evil"")"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
it("parses booth:token pairs and refuses short tokens", () => {
|
||||
expect([...parseBoothTokens("a:0123456789abcdef, b:fedcba9876543210\nc:0000000000000000").keys()]).toEqual(["a", "b", "c"]);
|
||||
expect(() => parseBoothTokens("a:short")).toThrow(/too short/);
|
||||
expect(() => parseBoothTokens("nocolon")).toThrow(/bad pair/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { isVehicleClass } from "@parking/shared";
|
||||
import type { CollectorConfig } from "./config.js";
|
||||
import { CollectorDb, type ItemRow, type ReviewVerdict } from "./db.js";
|
||||
import { reviewPage } from "./review-page.js";
|
||||
|
||||
// The collector — the far end of the booth's review outbox
|
||||
// (wiki/concepts/vision-review-outbox.md). Three surfaces and nothing else:
|
||||
// POST /ingest one package from one booth (bearer token per booth; idempotent)
|
||||
// /review + /api/* the reviewer's screen (HTTP Basic, one login)
|
||||
// GET /export/labels.csv the training set: reviewed, usable rows (crops sit beside it on
|
||||
// the volume, so the trainer on this host reads them directly)
|
||||
// It deliberately has no fleet features and no path back into a booth.
|
||||
|
||||
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */
|
||||
interface IngestMeta {
|
||||
v: number;
|
||||
booth: string;
|
||||
item: string;
|
||||
order: string;
|
||||
at: string;
|
||||
operator: string;
|
||||
operatorCategory: { id: string; name: string; classes?: string[] };
|
||||
service: string;
|
||||
vision: { class: string; confidence: number; categoryId: string | null };
|
||||
downgraded: boolean;
|
||||
image: { width: number; height: number; plateBlurred: boolean };
|
||||
}
|
||||
|
||||
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function str(v: unknown, max = 200): string | null {
|
||||
return typeof v === "string" && v.length > 0 && v.length <= max ? v : null;
|
||||
}
|
||||
|
||||
/** Validate the meta part; returns a message on the first problem. */
|
||||
function checkMeta(m: unknown, booth: string): { ok: true; meta: IngestMeta } | { ok: false; why: string } {
|
||||
if (!m || typeof m !== "object") return { ok: false, why: "meta must be an object" };
|
||||
const x = m as Record<string, unknown>;
|
||||
if (x.v !== 1) return { ok: false, why: "unsupported meta version" };
|
||||
if (x.booth !== booth) return { ok: false, why: "meta.booth does not match the token's booth" };
|
||||
if (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" };
|
||||
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
|
||||
if (!str(x.at, 40) || Number.isNaN(Date.parse(x.at as string))) return { ok: false, why: "bad timestamp" };
|
||||
if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" };
|
||||
const oc = x.operatorCategory as Record<string, unknown> | undefined;
|
||||
if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" };
|
||||
if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" };
|
||||
if (!str(x.service, 120)) return { ok: false, why: "bad service" };
|
||||
const v = x.vision as Record<string, unknown> | undefined;
|
||||
if (!v || !isVehicleClass(v.class) || typeof v.confidence !== "number" || v.confidence < 0 || v.confidence > 1) return { ok: false, why: "bad vision read" };
|
||||
if (v.categoryId != null && !str(v.categoryId, 64)) return { ok: false, why: "bad vision.categoryId" };
|
||||
if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
|
||||
const im = x.image as Record<string, unknown> | undefined;
|
||||
if (!im || typeof im.width !== "number" || typeof im.height !== "number" || typeof im.plateBlurred !== "boolean") return { ok: false, why: "bad image meta" };
|
||||
return { ok: true, meta: x as unknown as IngestMeta };
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
export interface CollectorApp extends FastifyInstance {
|
||||
collectorDb: CollectorDb;
|
||||
}
|
||||
|
||||
export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: string } = {}): Promise<CollectorApp> {
|
||||
await mkdir(path.join(cfg.dataDir, "crops"), { recursive: true });
|
||||
const db = new CollectorDb(opts.dbFile ?? path.join(cfg.dataDir, "collector.sqlite"));
|
||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, bodyLimit: 64 * 1024 }) as unknown as CollectorApp;
|
||||
app.collectorDb = db;
|
||||
await app.register(multipart, { limits: { fileSize: MAX_IMAGE_BYTES, files: 1, fields: 4, parts: 6 } });
|
||||
app.addHook("onClose", async () => db.close());
|
||||
|
||||
/** Which booth this bearer token belongs to, or null. Constant-time per candidate. */
|
||||
function boothForToken(req: FastifyRequest): string | null {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (!h.startsWith("Bearer ")) return null;
|
||||
const token = h.slice(7).trim();
|
||||
let found: string | null = null;
|
||||
for (const [booth, t] of cfg.boothTokens) if (safeEqual(token, t)) found = booth;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** HTTP Basic for the reviewer. */
|
||||
async function requireReviewer(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
if (!cfg.reviewer) return reply.code(503).send({ error: "reviewer login not configured" });
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (h.startsWith("Basic ")) {
|
||||
const [user, ...rest] = Buffer.from(h.slice(6), "base64").toString("utf8").split(":");
|
||||
const pass = rest.join(":");
|
||||
if (user && safeEqual(user, cfg.reviewer.user) && safeEqual(pass, cfg.reviewer.pass)) return;
|
||||
}
|
||||
return reply.code(401).header("www-authenticate", 'Basic realm="wash review", charset="UTF-8"').send({ error: "unauthorized" });
|
||||
}
|
||||
|
||||
app.get("/health", async () => {
|
||||
const s = db.stats();
|
||||
return { ok: true, booths: s.booths.length, pending: s.booths.reduce((n, b) => n + b.pending, 0) };
|
||||
});
|
||||
|
||||
// --- Ingest (booths) -----------------------------------------------------------------
|
||||
app.post("/ingest", async (req, reply) => {
|
||||
const booth = boothForToken(req);
|
||||
if (!booth) return reply.code(401).send({ error: "unauthorized" });
|
||||
const claimed = req.headers["x-booth-id"];
|
||||
if (typeof claimed === "string" && claimed !== booth) return reply.code(403).send({ error: "booth id does not match the token" });
|
||||
if (!req.isMultipart()) return reply.code(415).send({ error: "multipart/form-data expected" });
|
||||
|
||||
let metaRaw: string | null = null;
|
||||
let image: Buffer | null = null;
|
||||
try {
|
||||
for await (const part of req.parts()) {
|
||||
if (part.type === "file" && part.fieldname === "image") {
|
||||
image = await part.toBuffer();
|
||||
} else if (part.type === "field" && part.fieldname === "meta") {
|
||||
metaRaw = String(part.value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
return reply.code(code === "FST_REQ_FILE_TOO_LARGE" ? 413 : 400).send({ error: (err as Error).message });
|
||||
}
|
||||
if (!metaRaw) return reply.code(400).send({ error: "meta part missing" });
|
||||
if (!image || image.length < 100) return reply.code(400).send({ error: "image part missing" });
|
||||
if (!(image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff)) return reply.code(415).send({ error: "image must be a JPEG" });
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(metaRaw);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: "meta is not JSON" });
|
||||
}
|
||||
const checked = checkMeta(parsed, booth);
|
||||
if (!checked.ok) return reply.code(422).send({ error: checked.why });
|
||||
const meta = checked.meta;
|
||||
|
||||
// Idempotent on the item id: a booth retrying after a lost 2xx must not duplicate.
|
||||
if (db.get(meta.item)) return reply.code(200).send({ ok: true, duplicate: true });
|
||||
|
||||
const rel = path.posix.join("crops", booth, `${meta.item}.jpg`);
|
||||
await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true });
|
||||
await writeFile(path.join(cfg.dataDir, rel), image);
|
||||
db.insert({
|
||||
id: meta.item,
|
||||
booth,
|
||||
orderRef: meta.order,
|
||||
at: meta.at,
|
||||
operatorRef: meta.operator,
|
||||
operatorCategoryId: meta.operatorCategory.id,
|
||||
operatorCategoryName: meta.operatorCategory.name,
|
||||
operatorClasses: JSON.stringify(meta.operatorCategory.classes ?? []),
|
||||
service: meta.service,
|
||||
visionClass: meta.vision.class,
|
||||
visionConfidence: meta.vision.confidence,
|
||||
visionCategoryId: meta.vision.categoryId ?? null,
|
||||
downgraded: meta.downgraded ? 1 : 0,
|
||||
imageWidth: meta.image.width,
|
||||
imageHeight: meta.image.height,
|
||||
plateBlurred: meta.image.plateBlurred ? 1 : 0,
|
||||
imagePath: rel,
|
||||
receivedAt: new Date().toISOString(),
|
||||
});
|
||||
req.log.info(`ingest: ${booth} item ${meta.item} (${meta.vision.class} → ${meta.operatorCategory.name})`);
|
||||
return reply.code(201).send({ ok: true });
|
||||
});
|
||||
|
||||
// --- Review (the trusted person) -----------------------------------------------------
|
||||
const page = reviewPage();
|
||||
app.get("/", { preHandler: requireReviewer }, async (_req, reply) => reply.redirect("/review"));
|
||||
app.get("/review", { preHandler: requireReviewer }, async (_req, reply) => reply.type("text/html; charset=utf-8").send(page));
|
||||
|
||||
app.get<{ Querystring: { status?: string; limit?: string; booth?: string } }>(
|
||||
"/api/items",
|
||||
{ preHandler: requireReviewer },
|
||||
async (req) => {
|
||||
const status = req.query.status === "reviewed" ? "reviewed" : "pending";
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 25, 1), 200);
|
||||
return { items: db.list(status, limit, req.query.booth || undefined).map(publicItem) };
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/items/:id/image", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
const row = db.get(req.params.id);
|
||||
if (!row) return reply.code(404).send({ error: "not found" });
|
||||
return reply.type("image/jpeg").header("cache-control", "private, max-age=3600").send(createReadStream(path.join(cfg.dataDir, row.imagePath)));
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { label?: unknown } }>("/api/items/:id/review", { preHandler: requireReviewer }, async (req, reply) => {
|
||||
const label = req.body?.label;
|
||||
if (label !== "unusable" && !isVehicleClass(label)) return reply.code(400).send({ error: "label must be a vehicle class or 'unusable'" });
|
||||
if (!db.get(req.params.id)) return reply.code(404).send({ error: "not found" });
|
||||
const row = db.review(req.params.id, label as ReviewVerdict, cfg.reviewer!.user);
|
||||
return publicItem(row!);
|
||||
});
|
||||
|
||||
app.get("/api/stats", { preHandler: requireReviewer }, async () => db.stats());
|
||||
|
||||
// --- Export (the training set) --------------------------------------------------------
|
||||
app.get("/export/labels.csv", { preHandler: requireReviewer }, async (_req, reply) => {
|
||||
const rows = db.labelled();
|
||||
// Quote every cell; a cell starting like a spreadsheet formula (=, +, -, @, tab, CR)
|
||||
// gets a leading apostrophe — the category/service names are booth-supplied text and
|
||||
// the reviewer will open this in a spreadsheet (CSV formula injection).
|
||||
const q = (s: string | number | null) => {
|
||||
let v = String(s ?? "");
|
||||
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
|
||||
return `"${v.replace(/"/g, '""')}"`;
|
||||
};
|
||||
const head = "item,booth,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
|
||||
const lines = rows.map((r) =>
|
||||
[r.id, r.booth, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","),
|
||||
);
|
||||
return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n");
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** The row as the review screen sees it (no server paths). */
|
||||
function publicItem(r: ItemRow): Omit<ItemRow, "imagePath"> {
|
||||
const { imagePath: _p, ...rest } = r;
|
||||
return rest;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface CollectorConfig {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly dataDir: string;
|
||||
/** boothId → bearer token. */
|
||||
readonly boothTokens: ReadonlyMap<string, string>;
|
||||
/** The single reviewer login; null = review screen and export refuse (503). */
|
||||
readonly reviewer: { readonly user: string; readonly pass: string } | null;
|
||||
}
|
||||
|
||||
/** "booth-7:abc,booth-9:def" (commas, whitespace or newlines between pairs). */
|
||||
export function parseBoothTokens(raw: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(/[,\s]+/)) {
|
||||
if (!pair) continue;
|
||||
const i = pair.indexOf(":");
|
||||
if (i <= 0) throw new Error(`COLLECTOR_BOOTH_TOKENS: bad pair "${pair}" (want boothId:token)`);
|
||||
const booth = pair.slice(0, i).trim();
|
||||
const token = pair.slice(i + 1).trim();
|
||||
if (!booth || token.length < 16) throw new Error(`COLLECTOR_BOOTH_TOKENS: token for "${booth}" too short (>=16 chars)`);
|
||||
out.set(booth, token);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function configFromEnv(env: NodeJS.ProcessEnv = process.env): CollectorConfig {
|
||||
const user = (env.COLLECTOR_REVIEWER_USER ?? "").trim();
|
||||
const pass = env.COLLECTOR_REVIEWER_PASS ?? "";
|
||||
return {
|
||||
host: env.COLLECTOR_HOST ?? "0.0.0.0",
|
||||
port: Number(env.COLLECTOR_PORT ?? 8090),
|
||||
dataDir: env.COLLECTOR_DATA_DIR ?? "/data",
|
||||
boothTokens: parseBoothTokens(env.COLLECTOR_BOOTH_TOKENS ?? ""),
|
||||
reviewer: user && pass.length >= 8 ? { user, pass } : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Database from "better-sqlite3";
|
||||
import type { VehicleClass } from "@parking/shared";
|
||||
|
||||
// One table. Each row is one booth decision: what the camera saw, what the operator
|
||||
// chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself
|
||||
// lives on disk beside the DB (crops/<booth>/<item>.jpg) so the trainer on the same host
|
||||
// reads it straight off the volume.
|
||||
|
||||
export interface ItemRow {
|
||||
id: string;
|
||||
booth: string;
|
||||
orderRef: string;
|
||||
at: string;
|
||||
operatorRef: string;
|
||||
operatorCategoryId: string;
|
||||
operatorCategoryName: string;
|
||||
/** The vision classes the operator's category covers at that site (its mapping) — what
|
||||
* lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */
|
||||
operatorClasses: string;
|
||||
service: string;
|
||||
visionClass: string;
|
||||
visionConfidence: number;
|
||||
visionCategoryId: string | null;
|
||||
downgraded: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
plateBlurred: number;
|
||||
imagePath: string;
|
||||
receivedAt: string;
|
||||
reviewLabel: string | null; // a VehicleClass, or "unusable"
|
||||
reviewedAt: string | null;
|
||||
reviewer: string | null;
|
||||
}
|
||||
|
||||
export type ReviewVerdict = VehicleClass | "unusable";
|
||||
|
||||
export class CollectorDb {
|
||||
readonly #db: Database.Database;
|
||||
|
||||
constructor(file: string) {
|
||||
this.#db = new Database(file);
|
||||
this.#db.pragma("journal_mode = WAL");
|
||||
this.#db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY,
|
||||
booth TEXT NOT NULL,
|
||||
order_ref TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
operator_ref TEXT NOT NULL,
|
||||
operator_category_id TEXT NOT NULL,
|
||||
operator_category_name TEXT NOT NULL,
|
||||
operator_classes TEXT NOT NULL DEFAULT '[]',
|
||||
service TEXT NOT NULL,
|
||||
vision_class TEXT NOT NULL,
|
||||
vision_confidence REAL NOT NULL,
|
||||
vision_category_id TEXT,
|
||||
downgraded INTEGER NOT NULL DEFAULT 0,
|
||||
image_width INTEGER NOT NULL,
|
||||
image_height INTEGER NOT NULL,
|
||||
plate_blurred INTEGER NOT NULL,
|
||||
image_path TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
review_label TEXT,
|
||||
reviewed_at TEXT,
|
||||
reviewer TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at);
|
||||
CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at);
|
||||
`);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#db.close();
|
||||
}
|
||||
|
||||
static #map(r: Record<string, unknown>): ItemRow {
|
||||
return {
|
||||
id: r.id as string,
|
||||
booth: r.booth as string,
|
||||
orderRef: r.order_ref as string,
|
||||
at: r.at as string,
|
||||
operatorRef: r.operator_ref as string,
|
||||
operatorCategoryId: r.operator_category_id as string,
|
||||
operatorCategoryName: r.operator_category_name as string,
|
||||
operatorClasses: r.operator_classes as string,
|
||||
service: r.service as string,
|
||||
visionClass: r.vision_class as string,
|
||||
visionConfidence: r.vision_confidence as number,
|
||||
visionCategoryId: (r.vision_category_id as string | null) ?? null,
|
||||
downgraded: r.downgraded as number,
|
||||
imageWidth: r.image_width as number,
|
||||
imageHeight: r.image_height as number,
|
||||
plateBlurred: r.plate_blurred as number,
|
||||
imagePath: r.image_path as string,
|
||||
receivedAt: r.received_at as string,
|
||||
reviewLabel: (r.review_label as string | null) ?? null,
|
||||
reviewedAt: (r.reviewed_at as string | null) ?? null,
|
||||
reviewer: (r.reviewer as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
get(id: string): ItemRow | null {
|
||||
const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
||||
return r ? CollectorDb.#map(r) : null;
|
||||
}
|
||||
|
||||
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
|
||||
this.#db
|
||||
.prepare(
|
||||
`INSERT INTO items (id, booth, order_ref, at, operator_ref, operator_category_id, operator_category_name,
|
||||
operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded,
|
||||
image_width, image_height, plate_blurred, image_path, received_at)
|
||||
VALUES (@id, @booth, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
|
||||
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
|
||||
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
|
||||
)
|
||||
.run(row);
|
||||
}
|
||||
|
||||
list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] {
|
||||
const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"];
|
||||
const params: unknown[] = [];
|
||||
if (booth) {
|
||||
where.push("booth = ?");
|
||||
params.push(booth);
|
||||
}
|
||||
const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC";
|
||||
const rows = this.#db
|
||||
.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`)
|
||||
.all(...params, limit) as Record<string, unknown>[];
|
||||
return rows.map((r) => CollectorDb.#map(r));
|
||||
}
|
||||
|
||||
review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null {
|
||||
this.#db
|
||||
.prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?")
|
||||
.run(label, new Date().toISOString(), reviewer, id);
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the
|
||||
* reviewer's class fell inside the operator's chosen category (agree) or outside
|
||||
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */
|
||||
stats(): {
|
||||
booths: { booth: string; received: number; pending: number; reviewed: number }[];
|
||||
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
|
||||
} {
|
||||
const booths = this.#db
|
||||
.prepare(
|
||||
`SELECT booth, COUNT(*) AS received,
|
||||
SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending,
|
||||
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed
|
||||
FROM items GROUP BY booth ORDER BY booth`,
|
||||
)
|
||||
.all() as { booth: string; received: number; pending: number; reviewed: number }[];
|
||||
const reviewed = this.#db
|
||||
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL")
|
||||
.all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[];
|
||||
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
|
||||
for (const r of reviewed) {
|
||||
const key = `${r.booth} ${r.operator_ref}`;
|
||||
let o = ops.get(key);
|
||||
if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 }));
|
||||
o.reviewed += 1;
|
||||
if (r.review_label === "unusable") o.unusable += 1;
|
||||
else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1;
|
||||
else o.disagree += 1;
|
||||
}
|
||||
return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) };
|
||||
}
|
||||
|
||||
/** Reviewed, usable rows — the training set. */
|
||||
labelled(): ItemRow[] {
|
||||
const rows = this.#db
|
||||
.prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at")
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => CollectorDb.#map(r));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { buildCollector } from "./app.js";
|
||||
import { configFromEnv } from "./config.js";
|
||||
|
||||
const cfg = configFromEnv();
|
||||
const app = await buildCollector(cfg);
|
||||
if (cfg.boothTokens.size === 0) app.log.warn("COLLECTOR_BOOTH_TOKENS is empty — no booth can ingest");
|
||||
if (!cfg.reviewer) app.log.warn("COLLECTOR_REVIEWER_USER/PASS not set — the review screen and export refuse");
|
||||
app.log.info(`collector: ${cfg.boothTokens.size} booth token(s), data in ${cfg.dataDir}`);
|
||||
await app.listen({ host: cfg.host, port: cfg.port });
|
||||
|
||||
const stop = async () => {
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", () => void stop());
|
||||
process.on("SIGINT", () => void stop());
|
||||
@@ -0,0 +1,117 @@
|
||||
import { VEHICLE_CLASSES } from "@parking/shared";
|
||||
|
||||
// The reviewer's screen: one pending crop at a time, the operator's pick and the camera's
|
||||
// pick beside it, one button per vocabulary class + "unusable". Served by the collector
|
||||
// itself (no build step, no framework) — this is deliberately the whole UI.
|
||||
|
||||
export function reviewPage(): string {
|
||||
const classes = JSON.stringify(VEHICLE_CLASSES);
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wash review</title>
|
||||
<style>
|
||||
:root { --bg:#111; --panel:#1b1b1b; --text:#e8e8e8; --muted:#9a9a9a; --amber:#e0a030; --green:#4caf50; --red:#e05050; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
|
||||
header { display:flex; justify-content:space-between; align-items:center; padding:.6rem 1rem; border-bottom:1px solid #333; }
|
||||
header b { letter-spacing:.08em; text-transform:uppercase; color:var(--amber); font-size:.75rem; }
|
||||
main { max-width:960px; margin:0 auto; padding:1rem; display:grid; gap:1rem; }
|
||||
.card { background:var(--panel); border:1px solid #333; border-radius:6px; padding:1rem; }
|
||||
img { max-width:100%; max-height:60vh; display:block; margin:0 auto; background:#000; border-radius:4px; }
|
||||
dl { display:grid; grid-template-columns:max-content 1fr; gap:.2rem .8rem; margin:0; font-variant-numeric:tabular-nums; }
|
||||
dt { color:var(--muted); }
|
||||
.buttons { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||||
button { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.5rem .8rem; font:inherit; cursor:pointer; }
|
||||
button:hover { border-color:var(--amber); }
|
||||
button.mono { font-family:ui-monospace, monospace; }
|
||||
button.hint { border-color:var(--amber); }
|
||||
button.unusable { color:var(--red); }
|
||||
button.skip { color:var(--muted); }
|
||||
.muted { color:var(--muted); }
|
||||
.warn { color:var(--amber); }
|
||||
table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; }
|
||||
td, th { text-align:left; padding:.2rem .5rem; border-bottom:1px solid #2a2a2a; }
|
||||
th { color:var(--muted); font-weight:normal; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; }
|
||||
kbd { background:#2a2a2a; border:1px solid #444; border-radius:3px; padding:0 .3rem; font-size:.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><b>Wash review</b><span id="counts" class="muted"></span></header>
|
||||
<main>
|
||||
<section class="card" id="item">
|
||||
<p class="muted">Loading…</p>
|
||||
</section>
|
||||
<section class="card">
|
||||
<table id="stats"><thead><tr><th>booth</th><th>operator</th><th>reviewed</th><th>agree</th><th>disagree</th><th>unusable</th></tr></thead><tbody></tbody></table>
|
||||
</section>
|
||||
<p class="muted">Keys: <kbd>1</kbd>–<kbd>9</kbd>, <kbd>0</kbd> pick a class in order · <kbd>u</kbd> unusable · <kbd>s</kbd> skip. Skipped items come back after a reload. Your verdict is the training label; the operator's pick is only compared against it.</p>
|
||||
</main>
|
||||
<script>
|
||||
const CLASSES = ${classes};
|
||||
const skipped = new Set();
|
||||
let current = null;
|
||||
|
||||
async function api(path, init) {
|
||||
const r = await fetch(path, init);
|
||||
if (!r.ok) throw new Error(path + ' → HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
|
||||
async function loadStats() {
|
||||
const s = await api('/api/stats');
|
||||
const pending = s.booths.reduce((n, b) => n + b.pending, 0);
|
||||
const reviewed = s.booths.reduce((n, b) => n + b.reviewed, 0);
|
||||
document.getElementById('counts').textContent = pending + ' waiting · ' + reviewed + ' reviewed';
|
||||
const tb = document.querySelector('#stats tbody');
|
||||
tb.innerHTML = s.operators.map(o => '<tr><td>' + esc(o.booth) + '</td><td class="mono">' + esc(o.operatorRef) + '</td><td>' + o.reviewed + '</td><td>' + o.agree + '</td><td' + (o.disagree ? ' class="warn"' : '') + '>' + o.disagree + '</td><td>' + o.unusable + '</td></tr>').join('') || '<tr><td colspan="6" class="muted">nothing reviewed yet</td></tr>';
|
||||
}
|
||||
|
||||
async function next() {
|
||||
const { items } = await api('/api/items?status=pending&limit=25');
|
||||
current = items.find(i => !skipped.has(i.id)) || null;
|
||||
const el = document.getElementById('item');
|
||||
if (!current) { el.innerHTML = '<p class="muted">Nothing waiting for review.</p>'; return; }
|
||||
const it = current;
|
||||
const opClasses = JSON.parse(it.operatorClasses || '[]');
|
||||
el.innerHTML =
|
||||
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
|
||||
'<dl style="margin-top:.8rem">' +
|
||||
'<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>' +
|
||||
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
|
||||
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
|
||||
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>' +
|
||||
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
|
||||
'</dl>' +
|
||||
'<div class="buttons" style="margin-top:.8rem">' +
|
||||
CLASSES.map((c, i) => '<button class="mono' + (c === it.visionClass ? ' hint' : '') + '" data-label="' + c + '" title="key ' + ((i + 1) % 10) + '">' + c + '</button>').join('') +
|
||||
'<button class="unusable" data-label="unusable">unusable</button>' +
|
||||
'<button class="skip" data-skip="1">skip</button>' +
|
||||
'</div>';
|
||||
el.querySelectorAll('button[data-label]').forEach(b => b.addEventListener('click', () => verdict(b.dataset.label)));
|
||||
el.querySelector('button[data-skip]').addEventListener('click', skip);
|
||||
}
|
||||
|
||||
async function verdict(label) {
|
||||
if (!current) return;
|
||||
await api('/api/items/' + encodeURIComponent(current.id) + '/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) });
|
||||
await Promise.all([next(), loadStats()]);
|
||||
}
|
||||
function skip() { if (current) { skipped.add(current.id); next(); } }
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target.tagName === 'INPUT') return;
|
||||
if (e.key === 'u') verdict('unusable');
|
||||
else if (e.key === 's') skip();
|
||||
else if (/^[0-9]$/.test(e.key)) { const i = e.key === '0' ? 9 : Number(e.key) - 1; if (CLASSES[i]) verdict(CLASSES[i]); }
|
||||
});
|
||||
|
||||
next().catch(e => { document.getElementById('item').innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; });
|
||||
loadStats().catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"references": [{ "path": "../../packages/shared" }],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: { include: ["src/**/*.test.ts"], env: { LOG_LEVEL: "silent" } },
|
||||
});
|
||||
+40
-3
@@ -35,8 +35,45 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
|
||||
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.
|
||||
|
||||
**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`,
|
||||
and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app
|
||||
was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`,
|
||||
so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected,
|
||||
and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not
|
||||
supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old
|
||||
version; the failure is logged to the server's Logs viewer.
|
||||
|
||||
## Release gate — run the REAL bundle locally before tagging
|
||||
|
||||
`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle
|
||||
loads it from `tauri://localhost`, a *secure* custom-scheme origin — and every desktop-only bug
|
||||
found in the field on 2026-09-03/04 (relative-URL DOMException, mixed content, missing WS
|
||||
`Origin`, the reqwest-vs-webview cookie split, the WS handshake that can't carry the cookie)
|
||||
depends on that difference. **Dev mode cannot reproduce any of them**, so "works in `tauri dev`"
|
||||
carries no information about a release. Before pushing a `vX.Y.Z` tag:
|
||||
|
||||
1. `pnpm --filter @parking/server dev` (local backend; `.env` must have `COOKIE_SECURE=0` and
|
||||
`tauri://localhost` in `WS_ALLOWED_ORIGINS`).
|
||||
2. `pnpm --filter @parking/desktop bundle` and run the produced AppImage from
|
||||
`src-tauri/target/release/bundle/appimage/` (WSLg is enough).
|
||||
3. On the ConnectScreen enter `127.0.0.1:3000`, **Test** must say reachable, then **Save**.
|
||||
4. Log in. The booth header must show **LIVE** (not "JASHTË LINJË") within a few seconds.
|
||||
5. Perform one mutation (e.g. change your UI language) — it must succeed (proves CSRF).
|
||||
6. Open Setup → Logs and confirm a `frontend`-sourced row from this desktop session exists
|
||||
(proves the desktop log channel; historically it was silently 403'd).
|
||||
|
||||
Only then tag. If a release still fails in the field, the gap is in this list — fix the list.
|
||||
|
||||
## Not here (deliberately)
|
||||
|
||||
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, 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.
|
||||
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.
|
||||
|
||||
Generated
+578
-8
@@ -318,6 +318,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -346,10 +363,39 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -399,6 +445,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -506,6 +561,18 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.11"
|
||||
@@ -635,6 +702,15 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -721,6 +797,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1209,6 +1299,25 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1298,6 +1407,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1321,6 +1431,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1341,9 +1452,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1744,6 +1857,12 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1759,6 +1878,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2178,8 +2303,11 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-websocket",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2330,6 +2458,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2398,6 +2535,22 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -2407,6 +2560,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -2428,6 +2637,61 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2503,6 +2767,49 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -2616,6 +2923,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2625,7 +2933,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
@@ -2663,6 +2971,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2745,7 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -2885,6 +3199,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@@ -2948,6 +3274,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -2955,7 +3292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -3137,6 +3474,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -3157,6 +3505,27 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3178,7 +3547,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3268,7 +3637,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -3367,6 +3736,54 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
"data-url",
|
||||
"http",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
@@ -3377,6 +3794,22 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
@@ -3393,7 +3826,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -3410,6 +3843,26 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-websocket"
|
||||
version = "2.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca243c7f0bf935cd81123e07f82188ccb919b19fbfc74518b947eedc4619bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -3639,9 +4092,21 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -3652,6 +4117,22 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3837,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -3877,6 +4370,24 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -4141,6 +4652,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@@ -4206,6 +4727,24 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4391,6 +4930,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4820,6 +5370,26 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
||||
@@ -22,6 +22,21 @@ serde_json = "1"
|
||||
# Auto-update: prompt the operator, download a signed update, relaunch.
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
# HTTP client for the SPA's API/WS calls to the local Fastify server. The window
|
||||
# runs at tauri://localhost, which WebKitGTK treats as a secure origin — a plain
|
||||
# http://127.0.0.1:3000 fetch() from inside it is blocked as mixed content (a
|
||||
# long-standing WebKit limitation, not fixable via CSP). Routing through this
|
||||
# plugin sends the request via Tauri's Rust side instead of the webview's own
|
||||
# fetch, sidestepping the browser mixed-content check entirely.
|
||||
tauri-plugin-http = "2"
|
||||
# Same mixed-content problem as above, but for the live-feed WebSocket
|
||||
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
|
||||
# are separate browser checks, so this needs its own plugin.
|
||||
tauri-plugin-websocket = "2"
|
||||
# Persists the operator-configured backend URL (host:port of the Fastify
|
||||
# server this install talks to) across restarts. Read before any API call —
|
||||
# see apps/web/src/lib/backend-config.ts.
|
||||
tauri-plugin-store = "2"
|
||||
|
||||
[features]
|
||||
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"updater:default",
|
||||
"process:default"
|
||||
"process:default",
|
||||
"websocket:default",
|
||||
"store:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"//": "Backend address is operator-configured at runtime (backend-config.ts) so the exact host:port can't be allow-listed at build time. Wildcarded to any host — the CSP forces ALL backend traffic through this plugin (see tauri.conf.json), so this scope is the real boundary; a compromised/malicious page still can't reach anything the operator hasn't pointed the app at, since the app only ever calls the one configured origin. All 4 forms needed: a known Tauri scope-matching quirk drops http://*:PORT unless both bare and :* variants are listed.",
|
||||
"allow": [
|
||||
{ "url": "http://*" },
|
||||
{ "url": "https://*" },
|
||||
{ "url": "http://*:*" },
|
||||
{ "url": "https://*:*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// 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).
|
||||
// the backend over HTTP to a Fastify server (address operator-configured at
|
||||
// runtime, not baked in — see apps/web/src/lib/backend-config.ts), 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() {
|
||||
@@ -16,6 +17,16 @@ pub fn run() {
|
||||
// endpoint + signing pubkey live in tauri.conf.json.
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
// Routes the SPA's fetch()/WS calls to the operator-configured Fastify
|
||||
// server through Tauri's native HTTP client — see the Cargo.toml
|
||||
// comment on why the webview's own fetch() can't reach it directly.
|
||||
.plugin(tauri_plugin_http::init())
|
||||
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
|
||||
// above, but WS needs its own plugin (separate browser check).
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
// Persists the operator-configured backend URL across restarts (JSON
|
||||
// file in the app's config dir) — see backend-config.ts.
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running the Parking System desktop shell");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Parking System",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.parking.desktop",
|
||||
"build": {
|
||||
"devUrl": "http://localhost:5173",
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -41,9 +41,9 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). 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.",
|
||||
"//": "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/parking_solution/releases/latest/download/latest.json"
|
||||
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,20 @@ JWT_SECRET=
|
||||
# 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.
|
||||
@@ -63,3 +77,25 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
|
||||
# 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)
|
||||
|
||||
# Venue modules --------------------------------------------------------------
|
||||
# Comma-separated ids of the modules this site is ENTITLED to (a vendor/deployment
|
||||
# decision — set in the Komodo stack env, never by a site role). The site admin then
|
||||
# ACTIVATES within this set in Setup → Site; effective = entitled ∩ activated. Unset or
|
||||
# blank = every registered module (parking,validation,carwash) — a DEV convenience. In
|
||||
# Docker, docker-compose.yml forwards it with a default of parking,validation, so a booth
|
||||
# is never entitled to a module its Komodo stack env does not name. Required modules
|
||||
# (parking) are always on. See wiki/decisions/venue-modules.md.
|
||||
#MODULES_ENTITLED=parking,validation
|
||||
|
||||
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) -------------------------
|
||||
# The operator's category choice is a hypothesis: each wash order with a vehicle read queues
|
||||
# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way
|
||||
# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint
|
||||
# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a
|
||||
# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every
|
||||
# item). Set in the Komodo stack env, per booth. Nothing is queued while off.
|
||||
# CARWASH_REVIEW_URL=
|
||||
# CARWASH_REVIEW_TOKEN=
|
||||
# CARWASH_REVIEW_BOOTH_ID=
|
||||
# CARWASH_REVIEW_INTERVAL_SEC=60
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 apps/collector/package.json apps/collector/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/devices/package.json packages/devices/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
@@ -47,6 +48,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
# ---- 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
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"@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",
|
||||
|
||||
@@ -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)`);
|
||||
@@ -73,4 +81,30 @@ if (existing) {
|
||||
});
|
||||
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);
|
||||
|
||||
@@ -15,8 +15,13 @@ import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js
|
||||
// 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.
|
||||
@@ -28,13 +33,22 @@ beforeEach(() => {
|
||||
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 the opt-in flag. */
|
||||
function seedCamera(opts: { anpr?: boolean } = {}): string {
|
||||
/** 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,
|
||||
@@ -48,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
|
||||
id: camId,
|
||||
category: "camera",
|
||||
driverId: "hikvision",
|
||||
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
|
||||
config: {
|
||||
host: "10.0.0.9",
|
||||
controllerId,
|
||||
relay: 1,
|
||||
...(opts.anpr ? { anpr: true } : {}),
|
||||
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
return camId;
|
||||
@@ -64,6 +84,7 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
|
||||
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
vehicle: null,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
};
|
||||
@@ -74,8 +95,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
|
||||
}
|
||||
|
||||
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
|
||||
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
|
||||
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" };
|
||||
@@ -104,6 +134,18 @@ describe("AnprBridge", () => {
|
||||
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 });
|
||||
@@ -123,6 +165,87 @@ describe("AnprBridge", () => {
|
||||
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,
|
||||
vehicle: null,
|
||||
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,
|
||||
vehicle: null,
|
||||
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 });
|
||||
@@ -136,6 +259,22 @@ describe("AnprBridge", () => {
|
||||
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 });
|
||||
|
||||
+156
-12
@@ -30,6 +30,10 @@ import type { VisionClient } from "./vision-client.js";
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -49,6 +53,40 @@ function debounceMs(): number {
|
||||
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;
|
||||
@@ -56,9 +94,19 @@ export class AnprBridge {
|
||||
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;
|
||||
@@ -67,6 +115,9 @@ export class AnprBridge {
|
||||
this.#logger = logger;
|
||||
this.#entryMinConfidence = entryMinConfidence();
|
||||
this.#debounceMs = debounceMs();
|
||||
this.#pollMs = pollMs();
|
||||
this.#pollWindowMs = pollWindowMs();
|
||||
this.#pollMaxMs = pollMaxMs();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,15 +135,35 @@ export class AnprBridge {
|
||||
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;
|
||||
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
|
||||
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;
|
||||
|
||||
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
||||
// snapshot + analyze every second.
|
||||
// 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;
|
||||
this.#stamp(deviceId);
|
||||
// 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;
|
||||
}
|
||||
@@ -100,16 +171,79 @@ export class AnprBridge {
|
||||
// "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";
|
||||
const shot = await camera.captureSnapshot({ direction });
|
||||
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||
if (!result || !result.plate) return; // nothing read
|
||||
|
||||
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
|
||||
// object with its confidence even when its own lowConfidence flag is set).
|
||||
if (result.plate.confidence < this.#entryMinConfidence) {
|
||||
// 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: plate '${result.plate.text}' below entry floor ` +
|
||||
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
||||
`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;
|
||||
}
|
||||
@@ -133,11 +267,21 @@ export class AnprBridge {
|
||||
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`,
|
||||
|
||||
+52
-3
@@ -1,6 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq, rolePermissions, type Db } from "@parking/db";
|
||||
import { eq, rolePermissions, users, 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
|
||||
@@ -143,10 +143,41 @@ export function initAuth(db: Db): void {
|
||||
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. */
|
||||
/** Clear the permission + role caches. Call after ANY write to roles / role_permissions
|
||||
* or to a user's roleId / deletion, so the change takes effect on the next request. */
|
||||
export function bumpPermsCache(): void {
|
||||
permsCache.clear();
|
||||
roleCache.clear();
|
||||
}
|
||||
|
||||
/** userId → CURRENT roleId, cached until bumpPermsCache(). */
|
||||
const roleCache = new Map<string, string | null>();
|
||||
|
||||
/** The user's CURRENT role. The token pins the roleId that was current at LOGIN; an
|
||||
* admin reassigning a user's role (or deleting the user) must take effect on the next
|
||||
* request exactly like editing a role does — otherwise the reassigned user keeps the
|
||||
* old role's rights until they log out (found 2026-09-05: a user moved to a new
|
||||
* wash role kept 403ing on the new role's permissions). null = the user is gone. */
|
||||
export function currentRoleId(sub: string): string | null {
|
||||
if (!authDb) throw new Error("auth not initialised (call initAuth)");
|
||||
const hit = roleCache.get(sub);
|
||||
if (hit !== undefined) return hit;
|
||||
const row = authDb
|
||||
.select({ roleId: users.roleId, deletedAt: users.deletedAt })
|
||||
.from(users)
|
||||
.where(eq(users.id, sub))
|
||||
.get();
|
||||
const roleId = row && row.deletedAt == null ? row.roleId : null;
|
||||
roleCache.set(sub, roleId);
|
||||
return roleId;
|
||||
}
|
||||
|
||||
/** After jwtVerify: replace the token's pinned roleId with the user's current one, or
|
||||
* end the session if the user no longer exists. */
|
||||
function refreshRole(req: FastifyRequest): void {
|
||||
const roleId = currentRoleId(req.user.sub);
|
||||
if (roleId === null) throw Object.assign(new Error("session no longer valid"), { statusCode: 401 });
|
||||
if (roleId !== req.user.roleId) req.user.roleId = roleId;
|
||||
}
|
||||
|
||||
/** The permission set for a role id, cached. `admin` is always the full set. */
|
||||
@@ -184,12 +215,29 @@ export function requirePermission(...required: Permission[]) {
|
||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||
assertCsrf(req);
|
||||
refreshRole(req);
|
||||
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* preHandler guard satisfied by ANY ONE of the listed permissions — for a read that
|
||||
* two jobs legitimately share (a module's master data: the desk that works with it
|
||||
* reads it under the module's own permission, Setup reads it under site:read).
|
||||
*/
|
||||
export function requireAnyPermission(...anyOf: Permission[]) {
|
||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||
await req.jwtVerify();
|
||||
assertCsrf(req);
|
||||
refreshRole(req);
|
||||
if (!req.user || !anyOf.some((p) => roleHasPermissions(req.user!.roleId, [p]))) {
|
||||
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
|
||||
@@ -201,4 +249,5 @@ export async function requireAuth(
|
||||
): Promise<void> {
|
||||
await req.jwtVerify();
|
||||
assertCsrf(req);
|
||||
refreshRole(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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type PrinterInstance,
|
||||
type ReceiptData,
|
||||
type TicketHeader,
|
||||
printerRoleOf,
|
||||
} from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devicesByDirection } from "./device-resolve.js";
|
||||
@@ -41,7 +42,7 @@ function loadPrinters(db: Db): PrinterInstance[] {
|
||||
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";
|
||||
const role = printerRoleOf(cfg);
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
@@ -80,6 +81,8 @@ function receiptFigures(
|
||||
currency?: string;
|
||||
tender?: "cash" | "card";
|
||||
graceExitMin?: number;
|
||||
grossMinor?: number;
|
||||
validationLines?: { label: string; discountMinor: number }[];
|
||||
};
|
||||
return {
|
||||
ticketId,
|
||||
@@ -89,6 +92,9 @@ function receiptFigures(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,20 @@ export interface DeviceReadEvent {
|
||||
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/gee-qr-er80.md.
|
||||
* 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. */
|
||||
@@ -45,7 +52,7 @@ export interface ReadOutcome {
|
||||
export interface PrinterStatusEvent {
|
||||
readonly deviceId: string; // devices id
|
||||
readonly driverId: string;
|
||||
readonly role?: string; // entry-dispenser | booth-receipt
|
||||
readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
|
||||
readonly status: PrinterStatus;
|
||||
}
|
||||
|
||||
@@ -67,10 +74,10 @@ export interface DeviceStatusEvent {
|
||||
* 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)
|
||||
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
|
||||
* - undetermined: null (chip shows the category alone)
|
||||
*/
|
||||
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||||
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
|
||||
readonly state: "ready" | "degraded" | "offline";
|
||||
readonly detail?: string;
|
||||
readonly checkedAt: string; // ISO-8601
|
||||
@@ -86,6 +93,28 @@ export interface LaneStatusEvent {
|
||||
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);
|
||||
@@ -148,6 +177,26 @@ class DeviceEventBus extends EventEmitter {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||
import { isMonitorable, registry } from "@parking/devices";
|
||||
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). */
|
||||
@@ -23,13 +24,47 @@ const VISION_STATUS_ID = "vision-service";
|
||||
|
||||
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)
|
||||
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
|
||||
*/
|
||||
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||
switch (row.category) {
|
||||
@@ -39,7 +74,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||
return d;
|
||||
}
|
||||
case "access": {
|
||||
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
||||
// 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
|
||||
@@ -49,6 +89,7 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||
const role = (row.config as { role?: string }).role;
|
||||
if (role === "booth-receipt") return "booth";
|
||||
if (role === "entry-dispenser") return "lane";
|
||||
if (role === "wash-desk") return "wash";
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
@@ -134,6 +175,7 @@ export class DeviceMonitor {
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -141,7 +183,7 @@ export class DeviceMonitor {
|
||||
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||
} else {
|
||||
try {
|
||||
const device = driver.create(cfg as never);
|
||||
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)) {
|
||||
@@ -158,6 +200,33 @@ export class DeviceMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -178,6 +247,9 @@ export class DeviceMonitor {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -10,35 +10,74 @@ export type Direction = "entry" | "exit" | "both";
|
||||
/** A concrete flow a credential/button drives (never "both"). */
|
||||
export type FlowDirection = "entry" | "exit";
|
||||
|
||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
||||
/** 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;
|
||||
readonly direction: Direction;
|
||||
/** 1-based input terminal of the entry button that fires this relay (transient
|
||||
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
||||
/** 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;
|
||||
/**
|
||||
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
||||
* Two modes, chosen by what barrier feedback exists at this lane:
|
||||
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
||||
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
||||
* controller. 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. This
|
||||
* makes one-car-one-ticket physical.
|
||||
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
||||
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
||||
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
||||
*/
|
||||
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[]` map + connection fields). */
|
||||
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
||||
interface AccessConfig {
|
||||
readonly relays?: RelaySpec[];
|
||||
readonly inputs?: InputSpec[];
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -60,9 +99,11 @@ export interface ResolvedRelay {
|
||||
readonly controller: DeviceRow;
|
||||
readonly relay: number;
|
||||
readonly direction: Direction;
|
||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
||||
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||
readonly presenceInput?: number;
|
||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -83,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a button press to the relay it fires: the access controller with this
|
||||
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
||||
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
||||
* 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
|
||||
@@ -94,23 +175,28 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const spec = relaysOf(row).find((r) => r.button === terminal);
|
||||
if (!spec) return null;
|
||||
if (spec.direction !== "entry" && spec.direction !== "both") 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: spec.relay,
|
||||
direction: spec.direction,
|
||||
presenceInput: spec.presenceInput,
|
||||
entryCooldownSec: spec.entryCooldownSec,
|
||||
relay: relay.relay,
|
||||
direction: relay.direction,
|
||||
presenceInput: presence?.input,
|
||||
presenceKind: presence?.kind ?? "loop",
|
||||
entryCooldownSec: btn.cooldownSec,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
||||
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
||||
* 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.
|
||||
* 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
|
||||
@@ -119,10 +205,43 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||
.get();
|
||||
if (!row || !row.enabled) return null;
|
||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
||||
if (!spec) return null;
|
||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +263,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
||||
.get();
|
||||
if (controller && controller.enabled) {
|
||||
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||
// 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;
|
||||
}
|
||||
@@ -164,9 +286,22 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
||||
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||
for (const controller of accessRows(db)) {
|
||||
const spec = relaysOf(controller).find(
|
||||
(r) => r.direction === direction || r.direction === "both",
|
||||
(r): r is RelaySpec & { direction: Direction } =>
|
||||
r.direction === direction || r.direction === "both",
|
||||
);
|
||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+212
-24
@@ -9,13 +9,14 @@ import {
|
||||
type PrinterInstance,
|
||||
type TicketData,
|
||||
type TicketHeader,
|
||||
printerRoleOf,
|
||||
} from "@parking/devices";
|
||||
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import type { DeviceInputEvent, LaneStatusEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
|
||||
@@ -48,9 +49,21 @@ import type { VisionClient } from "./vision-client.js";
|
||||
// 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. */
|
||||
@@ -72,6 +85,10 @@ export class EntryFlow {
|
||||
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;
|
||||
@@ -121,6 +138,12 @@ export class EntryFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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}`;
|
||||
@@ -153,17 +176,35 @@ export class EntryFlow {
|
||||
}
|
||||
|
||||
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
||||
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
|
||||
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
|
||||
* 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();
|
||||
|
||||
if (typeof r.presenceInput === "number") {
|
||||
// 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";
|
||||
return null;
|
||||
// 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) {
|
||||
@@ -176,6 +217,13 @@ export class EntryFlow {
|
||||
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 {
|
||||
@@ -229,9 +277,36 @@ export class EntryFlow {
|
||||
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() };
|
||||
@@ -260,17 +335,14 @@ export class EntryFlow {
|
||||
// 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;
|
||||
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). Per-relay capture (a
|
||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
||||
// ignores it; only V2 category cards consult it.
|
||||
// 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
|
||||
@@ -279,44 +351,160 @@ export class EntryFlow {
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
direction: "entry",
|
||||
source: "ticket",
|
||||
source: opts.source,
|
||||
identity: ticketId,
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
||||
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);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||
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).
|
||||
// 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; this is
|
||||
// just a fast read-model, never the source of truth).
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
||||
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
|
||||
.run();
|
||||
} catch (err) {
|
||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
||||
// can be rebuilt. Log it; don't fail the (already-open) entry.
|
||||
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 {
|
||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch(
|
||||
// `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);
|
||||
@@ -336,7 +524,7 @@ export class EntryFlow {
|
||||
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";
|
||||
const role = printerRoleOf(cfg);
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||
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";
|
||||
@@ -33,6 +34,19 @@ async function enter(identity: string, enteredAt: string, payload?: Record<strin
|
||||
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 () => {
|
||||
@@ -116,3 +130,63 @@ describe("reopenBarrier — no unpaid re-open", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -47,6 +48,11 @@ interface SessionView {
|
||||
* 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 };
|
||||
|
||||
@@ -57,6 +63,11 @@ 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;
|
||||
@@ -88,7 +99,7 @@ export class ExitFlow {
|
||||
* (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): Promise<BoothExitResult> {
|
||||
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" };
|
||||
|
||||
@@ -122,6 +133,40 @@ export class ExitFlow {
|
||||
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) {
|
||||
@@ -336,6 +381,25 @@ export class ExitFlow {
|
||||
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);
|
||||
}
|
||||
@@ -406,6 +470,43 @@ export class ExitFlow {
|
||||
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
|
||||
|
||||
@@ -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,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 });
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,14 @@ 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;
|
||||
@@ -33,7 +41,10 @@ export interface LogRetention {
|
||||
}
|
||||
|
||||
export const DEFAULT_RETENTION: LogRetention = {
|
||||
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
|
||||
// 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),
|
||||
};
|
||||
|
||||
@@ -59,6 +70,16 @@ export class LogService {
|
||||
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;
|
||||
@@ -82,22 +103,53 @@ export class LogService {
|
||||
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: randomUUID(),
|
||||
id,
|
||||
level: row.level,
|
||||
source: row.source,
|
||||
message: clamp(row.message, MAX_MESSAGE) ?? "",
|
||||
context: safeContext(row.context),
|
||||
message,
|
||||
context: baseContext,
|
||||
httpStatus: row.httpStatus ?? null,
|
||||
path: clamp(row.path, 512),
|
||||
path,
|
||||
stack: clamp(row.stack, MAX_STACK),
|
||||
userId: row.userId ?? null,
|
||||
userAgent: clamp(row.userAgent, 512),
|
||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
||||
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.)
|
||||
@@ -192,9 +244,10 @@ export class LogService {
|
||||
|
||||
/**
|
||||
* 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, map the numeric level
|
||||
* to a name, 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.
|
||||
* 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,
|
||||
@@ -218,12 +271,17 @@ export function pinoDbStream(
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(line) as {
|
||||
level?: number;
|
||||
level?: number | string;
|
||||
msg?: string;
|
||||
err?: { stack?: string; message?: string };
|
||||
[k: string]: unknown;
|
||||
};
|
||||
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
|
||||
// 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;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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";
|
||||
|
||||
// Venue modules — entitled ∩ activated, enforced server-side (wiki/decisions/
|
||||
// venue-modules.md). Boots the real app over an in-memory DB and drives it with
|
||||
// app.inject, like routes.test.ts.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
const savedEnv = process.env.MODULES_ENTITLED;
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
await boot();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
if (savedEnv === undefined) delete process.env.MODULES_ENTITLED;
|
||||
else process.env.MODULES_ENTITLED = savedEnv;
|
||||
});
|
||||
|
||||
async function admin() {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
return login(app, username, password);
|
||||
}
|
||||
|
||||
describe("defaults (no env, nothing activated)", () => {
|
||||
it("every registered module is entitled, activated and effective; /me carries the set", async () => {
|
||||
const { cookie } = await admin();
|
||||
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||
expect(cfg.statusCode).toBe(200);
|
||||
const body = cfg.json();
|
||||
expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]);
|
||||
expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]);
|
||||
expect(body.modules).toEqual(["parking", "validation", "carwash"]);
|
||||
|
||||
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||
expect(me.json().modules).toEqual(["parking", "validation", "carwash"]);
|
||||
|
||||
// A module route answers normally while the module is on.
|
||||
const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
|
||||
expect(programs.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activation (site admin)", () => {
|
||||
it("deactivating validation 403s its routes with module_disabled, signs a config_change, and is reversible", async () => {
|
||||
const { cookie, csrf } = await admin();
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: "/api/site-config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { modules: ["parking"] },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().modules).toEqual(["parking"]);
|
||||
expect(put.json().modulesActivated).toEqual(["parking"]);
|
||||
|
||||
// The merchant scan routes are the module → 403; the PROGRAM routes are core (the
|
||||
// discount engine serves Car Wash too) → still 200 with validation off.
|
||||
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
|
||||
expect(off.statusCode).toBe(403);
|
||||
expect(off.json().code).toBe("module_disabled");
|
||||
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
|
||||
|
||||
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||
expect(me.json().modules).toEqual(["parking"]);
|
||||
|
||||
// The flip is on the signed ledger, attributed.
|
||||
const events = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||
expect(events.statusCode).toBe(200);
|
||||
const list = (events.json().events ?? events.json()) as Array<{ type: string; payload: Record<string, unknown> }>;
|
||||
const flip = list.find((e) => e.type === "config_change" && e.payload?.setting === "modules.validation");
|
||||
expect(flip).toBeTruthy();
|
||||
expect(flip!.payload).toMatchObject({ value: false, prev: true, operator: "boss" });
|
||||
|
||||
// Nothing was deleted: re-enable and the route is back.
|
||||
const back = await app.inject({
|
||||
method: "PUT", url: "/api/site-config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { modules: ["parking", "validation"] },
|
||||
});
|
||||
expect(back.json().modules).toEqual(["parking", "validation"]);
|
||||
const on = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
|
||||
expect(on.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("required modules cannot be deactivated (parking is always included)", async () => {
|
||||
const { cookie, csrf } = await admin();
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: "/api/site-config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { modules: [] },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().modules).toEqual(["parking"]);
|
||||
});
|
||||
|
||||
it("rejects unknown ids with 400", async () => {
|
||||
const { cookie, csrf } = await admin();
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: "/api/site-config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { modules: ["parking", "bar"] },
|
||||
});
|
||||
expect(put.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("carwash runs without the validation module (the discount engine is core)", async () => {
|
||||
const { cookie, csrf } = await admin();
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: "/api/site-config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { modules: ["parking", "carwash"] },
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json().modules).toEqual(["parking", "carwash"]);
|
||||
// The wash's sponsorship program is still composable and readable.
|
||||
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie } })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("dependency rule: a module cannot be on while a module it depends on is off", async () => {
|
||||
const { cookie, csrf } = await admin();
|
||||
// Every non-required module depends on parking, and parking is required — so the rule
|
||||
// is exercised through the effective-set helper directly.
|
||||
const shared = await import("@parking/shared");
|
||||
expect(shared.resolveModuleActivation(["parking", "validation", "carwash"], ["carwash"])).toMatchObject({ ok: true });
|
||||
expect(shared.effectiveModules(["parking", "carwash"], ["parking", "carwash"])).toEqual(["parking", "carwash"]);
|
||||
expect(cookie && csrf).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a no-op resave signs nothing", async () => {
|
||||
const { cookie, csrf } = await admin();
|
||||
const before = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||
const countBefore = ((before.json().events ?? before.json()) as unknown[]).length;
|
||||
await app.inject({
|
||||
method: "PUT", url: "/api/site-config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { modules: ["parking", "validation", "carwash"] },
|
||||
});
|
||||
const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||
expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entitlement (vendor env)", () => {
|
||||
it("MODULES_ENTITLED=parking: validation is neither offered nor activatable, and its routes 403", async () => {
|
||||
await app.close();
|
||||
close();
|
||||
process.env.MODULES_ENTITLED = "parking";
|
||||
await boot();
|
||||
const { cookie, csrf } = await admin();
|
||||
|
||||
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||
expect(cfg.json().modulesEntitled).toEqual(["parking"]);
|
||||
expect(cfg.json().modules).toEqual(["parking"]);
|
||||
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: "/api/site-config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { modules: ["parking", "validation"] },
|
||||
});
|
||||
expect(put.statusCode).toBe(400);
|
||||
expect(put.json().error).toMatch(/not entitled/);
|
||||
|
||||
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
|
||||
expect(off.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("required modules are entitled even when the env omits them; unknown ids are ignored", async () => {
|
||||
await app.close();
|
||||
close();
|
||||
process.env.MODULES_ENTITLED = "validation,bogus";
|
||||
await boot();
|
||||
const { cookie } = await admin();
|
||||
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||
expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]);
|
||||
expect(cfg.json().modules).toEqual(["parking", "validation"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => {
|
||||
const shared = await import("@parking/shared");
|
||||
it("each till is guarded by its own module's permissions", () => {
|
||||
expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" });
|
||||
expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" });
|
||||
const wash = new Set(["carwash:read", "carwash:cash"]);
|
||||
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]);
|
||||
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]);
|
||||
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]);
|
||||
// Module off → its till is not even addressable.
|
||||
expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]);
|
||||
});
|
||||
it("the live feed admits by watch permission and filters ledger events by their module", () => {
|
||||
expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual(
|
||||
expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]),
|
||||
);
|
||||
expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read");
|
||||
expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read");
|
||||
expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read");
|
||||
expect(shared.feedPermissionFor("payment")).toBe("event:read");
|
||||
expect(shared.feedPermissionFor("validation")).toBe("event:read");
|
||||
});
|
||||
it("every job's permissions exist in the grid", () => {
|
||||
for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import {
|
||||
effectiveModules,
|
||||
isModuleId,
|
||||
isTillId,
|
||||
parseEntitledModules,
|
||||
tillGuards,
|
||||
tillsFor,
|
||||
tillsOf,
|
||||
type ModuleId,
|
||||
type TillGuards,
|
||||
type TillId,
|
||||
} from "@parking/shared";
|
||||
import { requireAuth, roleHasPermissions } from "./auth.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
/** Set by requireTill(): the till this request addresses (already authorized). */
|
||||
till?: TillId;
|
||||
}
|
||||
}
|
||||
|
||||
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
|
||||
// @parking/shared; design in wiki/decisions/venue-modules.md).
|
||||
//
|
||||
// entitled MODULES_ENTITLED env (vendor, Komodo stack) — unset = everything.
|
||||
// activated site_config.modules_json (site admin, Setup → Site) — null = everything
|
||||
// entitled.
|
||||
// effective what requireModule() enforces and what /api/auth/me + /api/site-config
|
||||
// hand the SPA so it can hide nav. The web only HIDES; this file ENFORCES.
|
||||
//
|
||||
// Both inputs are re-read per request: one env read and one single-row SELECT on the
|
||||
// site_config singleton — cheap, and it means a change takes effect on the next request
|
||||
// with no cache to invalidate (the same reason the presence-bypass flags aren't cached).
|
||||
|
||||
/** The modules this deployment is entitled to. Unknown ids in the env are ignored
|
||||
* (logged once at boot by registerModules). */
|
||||
export function entitledModules(): ModuleId[] {
|
||||
return parseEntitledModules(process.env.MODULES_ENTITLED).entitled;
|
||||
}
|
||||
|
||||
/** Parse the persisted activation list off a site_config row. null = never set. A
|
||||
* corrupt/unknown value is treated as "never set" rather than locking modules off. */
|
||||
export function activatedModulesOf(row: { modulesJson?: string | null } | undefined): ModuleId[] | null {
|
||||
const raw = row?.modulesJson;
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter(isModuleId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The effective set for this site right now. */
|
||||
export function effectiveModulesFor(db: Db): ModuleId[] {
|
||||
const row = db.select({ modulesJson: siteConfig.modulesJson }).from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return effectiveModules(entitledModules(), activatedModulesOf(row));
|
||||
}
|
||||
|
||||
/** The tills available at this site right now: the booth, plus each effective
|
||||
* money-taking module's own till (registry order). */
|
||||
export function effectiveTillsFor(db: Db): TillId[] {
|
||||
return tillsOf(effectiveModulesFor(db));
|
||||
}
|
||||
|
||||
/** The tills a role may SEE (default) or WORK (`shift` / `cash`) here: the effective
|
||||
* tills whose module guard the role holds (each desk's money is guarded by that desk's
|
||||
* own permissions — venue-modules.md §"Permissions matrix"). */
|
||||
export function tillsReadableBy(db: Db, roleId: string, kind: keyof TillGuards = "read"): TillId[] {
|
||||
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]), kind);
|
||||
}
|
||||
|
||||
/** preHandler factory for the shift/drawer routes: authenticate, parse the `till`
|
||||
* (query on GET, body on POST; absent = booth; 400 `bad_till` when unknown or its
|
||||
* module is off), then require the role to hold THAT TILL's guard for `kind` (403
|
||||
* `till_forbidden`). The authorized till lands on `req.till`. The permission is thus
|
||||
* resolved from the till, never fixed: the booth checks `shift:read`/`shift:create`/
|
||||
* `drawer:create`, the wash `carwash:read`/`carwash:cash`. */
|
||||
export function requireTill(db: Db, kind: keyof TillGuards, from: "query" | "body") {
|
||||
return async (req: FastifyRequest, reply: FastifyReply): Promise<void | FastifyReply> => {
|
||||
await requireAuth(req, reply);
|
||||
const raw = from === "query" ? (req.query as { till?: unknown } | undefined)?.till : (req.body as { till?: unknown } | undefined)?.till;
|
||||
const till = parseTill(db, raw);
|
||||
if (!till) {
|
||||
await reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||
return reply;
|
||||
}
|
||||
if (!roleHasPermissions(req.user.roleId, [tillGuards(till)[kind]])) {
|
||||
await reply
|
||||
.code(403)
|
||||
.send({ error: `your role cannot ${kind === "read" ? "see" : "work"} the ${till} till`, code: "till_forbidden", till });
|
||||
return reply;
|
||||
}
|
||||
req.till = till;
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
|
||||
* whose module is not effective here, → null (the caller answers 400). */
|
||||
export function parseTill(db: Db, raw: unknown): TillId | null {
|
||||
if (raw == null || raw === "") return "booth";
|
||||
if (!isTillId(raw)) return null;
|
||||
return effectiveTillsFor(db).includes(raw) ? raw : null;
|
||||
}
|
||||
|
||||
/** preHandler: reject the call when `id` is not effective at this site. Compose it
|
||||
* BEFORE requirePermission in a preHandler array so a disabled module answers the
|
||||
* same way for every role — 403 with code "module_disabled" — and never reaches
|
||||
* the permission/CSRF path. */
|
||||
export function requireModule(db: Db, id: ModuleId) {
|
||||
return async (_req: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
||||
if (!effectiveModulesFor(db).includes(id)) {
|
||||
throw Object.assign(new Error(`module disabled: ${id}`), { statusCode: 403, code: "module_disabled" });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { deviceEvents, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
|
||||
|
||||
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
|
||||
// settings → intake against an open parking session → done applies the sponsorship
|
||||
// validation → bay payment settles the parking session at zero (what the exit reader
|
||||
// checks) / booth payment carries the wash as a charge line → module off = 403.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
type Auth = { cookie: string; csrf: string };
|
||||
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||
|
||||
async function admin(): Promise<Auth> {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
return login(app, username, password);
|
||||
}
|
||||
|
||||
/** An open transient session that has been parked long enough to owe money. */
|
||||
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
|
||||
await makeLog(db).append({
|
||||
type: "vehicle_entry",
|
||||
source: "manual",
|
||||
identity,
|
||||
occurredAt: minutesAgo(enteredMinutesAgo),
|
||||
payload: { sessionRef: identity, category: "default" },
|
||||
});
|
||||
}
|
||||
|
||||
async function seedSettings(a: Auth) {
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: {
|
||||
categories: [{ name: "Car" }, { name: "SUV" }],
|
||||
services: [{ name: "Standard" }, { name: "Inside" }],
|
||||
prices: [],
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const s = res.json();
|
||||
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
|
||||
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
|
||||
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
|
||||
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
|
||||
const priced = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: {
|
||||
categories: s.categories, services: s.services,
|
||||
prices: [
|
||||
{ categoryId: car, serviceId: std, priceMinor: 50000 },
|
||||
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
|
||||
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(priced.statusCode).toBe(200);
|
||||
expect(priced.json().prices).toHaveLength(3);
|
||||
return { car, suv, std, inside };
|
||||
}
|
||||
|
||||
/** Flip the site's wash-payment policy (Setup → Car wash). */
|
||||
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
|
||||
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().payAt).toBe(payAt);
|
||||
}
|
||||
|
||||
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
|
||||
});
|
||||
expect(res.statusCode).toBeLessThan(300);
|
||||
}
|
||||
|
||||
async function events(a: Auth) {
|
||||
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
describe("settings", () => {
|
||||
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
|
||||
const a = await admin();
|
||||
const ids = await seedSettings(a);
|
||||
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
|
||||
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
|
||||
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
|
||||
const bad = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
|
||||
});
|
||||
expect(bad.statusCode).toBe(400);
|
||||
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
|
||||
expect(flips.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orders", () => {
|
||||
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
const noSession = await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
|
||||
});
|
||||
expect(noSession.statusCode).toBe(404);
|
||||
|
||||
await openSession("T-1");
|
||||
const noPrice = await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
|
||||
});
|
||||
expect(noPrice.statusCode).toBe(409);
|
||||
expect(noPrice.json().code).toBe("no_price");
|
||||
|
||||
const created = await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
|
||||
|
||||
await openSession("T-2");
|
||||
await setPayAt(a, "bay");
|
||||
await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
|
||||
});
|
||||
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
|
||||
|
||||
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
|
||||
});
|
||||
|
||||
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-B");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
|
||||
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||
const s = look.json();
|
||||
expect(s.chargeLines).toHaveLength(1);
|
||||
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
|
||||
expect(s.chargesMinor).toBe(50000);
|
||||
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
|
||||
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
|
||||
expect(pay.statusCode).toBeLessThan(300);
|
||||
|
||||
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
|
||||
expect(payment.payload.chargesMinor).toBe(50000);
|
||||
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
|
||||
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
|
||||
|
||||
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
|
||||
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
|
||||
expect(o.paidAt).toBeTruthy();
|
||||
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
|
||||
expect(o.tender).toBe("cash");
|
||||
// A second lookup no longer carries the line (it's settled).
|
||||
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||
expect(again.json().chargeLines).toEqual([]);
|
||||
|
||||
// The booth's Z-report: the wash money is inside cash (it is in the drawer) but
|
||||
// OUT of the ticket bucket, under its own module — Bileta is parking money only.
|
||||
const parking = payment.payload.parkingMinor as number;
|
||||
const z = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||
expect(z).toMatchObject({ till: "booth", cashTotalMinor: parking + 50000, ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||||
expect(z.ticketTotalMinor + z.subscriptionTotalMinor + 50000).toBe(z.cashTotalMinor + z.cardTotalMinor);
|
||||
const summary = (await app.inject({ method: "GET", url: "/api/shifts", headers: { cookie: a.cookie } })).json().shifts[0];
|
||||
expect(summary).toMatchObject({ till: "booth", ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||||
const signed = (await events(a)).find((e) => e.type === "shift_z_report")!;
|
||||
expect(signed.payload.chargesByModuleMinor).toEqual({ carwash: 50000 });
|
||||
});
|
||||
|
||||
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "comp");
|
||||
await openSession("T-Y");
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
|
||||
})).json();
|
||||
|
||||
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||
expect(noShift.statusCode).toBe(409);
|
||||
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
|
||||
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||
expect(openWash.statusCode).toBe(200);
|
||||
|
||||
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
expect(done.statusCode).toBe(200);
|
||||
expect(done.json().status).toBe("done");
|
||||
expect(done.json().validationEventId).toBeTruthy();
|
||||
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
|
||||
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||
expect(mid.json().amountMinor).toBe(0);
|
||||
expect(mid.json().paidAt).toBeNull();
|
||||
|
||||
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
|
||||
expect(paid.statusCode).toBe(200);
|
||||
expect(paid.json().closed).toBe(true);
|
||||
|
||||
const evs = await events(a);
|
||||
const bay = evs.find((e) => e.type === "carwash_payment")!;
|
||||
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
|
||||
// The wash Z-report carries the bay money; the booth's carries none of it.
|
||||
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
|
||||
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
|
||||
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||
expect(boothZ.till).toBe("booth");
|
||||
expect(boothZ.cardTotalMinor).toBe(0);
|
||||
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
|
||||
// The $0 parking payment exists → the exit reader's paid+grace check passes.
|
||||
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
|
||||
expect(parkingPay).toBeTruthy();
|
||||
expect(parkingPay.payload.amountMinor).toBe(0);
|
||||
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||
expect(after.json().paidAt).toBeTruthy();
|
||||
expect(after.json().withinGrace).toBe(true);
|
||||
|
||||
// The queue is empty (done + paid = closed).
|
||||
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||
expect(queue.json().orders).toEqual([]);
|
||||
});
|
||||
|
||||
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "percent");
|
||||
await openSession("T-P");
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
|
||||
expect(s.paidAt).toBeNull();
|
||||
expect(s.amountMinor).toBeGreaterThan(0);
|
||||
expect(s.discountMinor).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "comp");
|
||||
await openSession("T-V");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||
expect(before.validationLines).toHaveLength(1);
|
||||
|
||||
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
|
||||
expect(voided.statusCode).toBe(200);
|
||||
expect(voided.json().status).toBe("void");
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.validationLines).toEqual([]);
|
||||
expect(after.chargeLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wash-only discount modes", () => {
|
||||
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
|
||||
const a = await admin();
|
||||
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "doneTolerance", 15);
|
||||
await openSession("T-D", 95);
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||
expect(before.amountMinor).toBe(20000);
|
||||
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
|
||||
expect(v.payload.mode).toBe("timeCredit");
|
||||
expect(v.payload.programMode).toBe("doneTolerance");
|
||||
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
|
||||
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
|
||||
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.amountMinor).toBe(20000);
|
||||
expect(after.discountMinor).toBe(0);
|
||||
});
|
||||
|
||||
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "doneTolerance", 120);
|
||||
await openSession("T-D2", 95);
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.amountMinor).toBe(0);
|
||||
});
|
||||
|
||||
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
|
||||
const a = await admin();
|
||||
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
|
||||
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "washPrice");
|
||||
await openSession("T-W", 95);
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.discountMinor).toBe(50000);
|
||||
expect(after.amountMinor).toBe(before.amountMinor - 50000);
|
||||
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
|
||||
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
|
||||
});
|
||||
|
||||
it("a merchant scan cannot apply a wash-only program", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
await seedSponsorship(a, "washPrice");
|
||||
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
|
||||
await app.inject({
|
||||
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
|
||||
});
|
||||
await openSession("T-M");
|
||||
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/car wash order/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module gate", () => {
|
||||
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-G");
|
||||
await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
|
||||
});
|
||||
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
|
||||
expect(off.json().modules).toEqual(["parking", "validation"]);
|
||||
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||
expect(q.statusCode).toBe(403);
|
||||
expect(q.json().code).toBe("module_disabled");
|
||||
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
|
||||
expect(look.chargeLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the money is taken is a SITE setting", () => {
|
||||
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
|
||||
await openSession("T-S1");
|
||||
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||
expect(o1.payAt).toBe("booth");
|
||||
|
||||
await setPayAt(a, "bay");
|
||||
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
|
||||
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
|
||||
await openSession("T-S2");
|
||||
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||
expect(o2.payAt).toBe("bay");
|
||||
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
|
||||
|
||||
// A stale client insisting on the other place is refused, never silently overridden.
|
||||
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
|
||||
expect(stale.statusCode).toBe(409);
|
||||
expect(stale.json().code).toBe("pay_at_policy");
|
||||
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tills are gated by the module permission", () => {
|
||||
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
await seedSettings(a);
|
||||
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
|
||||
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
|
||||
const washer = await seedUser(db, {
|
||||
username: "lavazhier", roleId: "washer",
|
||||
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
|
||||
});
|
||||
const w = await login(app, washer.username, washer.password);
|
||||
// The desk's category/service pickers come from the settings read — the job has no
|
||||
// site:read, so the module permission must open it (found on park dev, 2026-09-06).
|
||||
const list = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: w.cookie } });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json().categories.length).toBeGreaterThan(0);
|
||||
expect((await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(w), payload: { payAt: "bay" } })).statusCode).toBe(403);
|
||||
// What the UI offers: only the wash till.
|
||||
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||||
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||||
// The booth's shift is refused outright (the role holds no shift:*).
|
||||
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
||||
expect(booth.statusCode).toBe(403);
|
||||
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
||||
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
|
||||
expect(boothState.statusCode).toBe(403);
|
||||
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
|
||||
expect(boothCash.statusCode).toBe(403);
|
||||
// The wash till works.
|
||||
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
|
||||
expect(wash.statusCode).toBe(200);
|
||||
expect(wash.json().till).toBe("carwash");
|
||||
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
||||
expect(washCash.statusCode).toBe(200);
|
||||
|
||||
// A wash user who may look (carwash:read) but not work the till (no carwash:cash)
|
||||
// sees the state and gets canWork=false; opening is refused.
|
||||
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||
const l = await login(app, looker.username, looker.password);
|
||||
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
|
||||
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
|
||||
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
|
||||
|
||||
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
|
||||
const booth1 = await seedUser(db, {
|
||||
username: "boothie", roleId: "booth-op",
|
||||
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
||||
});
|
||||
const b = await login(app, booth1.username, booth1.password);
|
||||
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
|
||||
expect(noWash.statusCode).toBe(403);
|
||||
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a role reassignment takes effect without re-login", () => {
|
||||
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-R");
|
||||
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
|
||||
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
|
||||
const l = await login(app, looker.username, looker.password);
|
||||
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||
expect(before.statusCode).toBe(403);
|
||||
|
||||
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
|
||||
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
|
||||
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
|
||||
expect(moved.statusCode).toBe(200);
|
||||
|
||||
// Same cookie, no re-login: the token's pinned role is refreshed per request.
|
||||
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||
expect(after.statusCode).toBe(201);
|
||||
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
|
||||
expect(me.roleId).toBe("wash-op");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a shift's activity log is per till", () => {
|
||||
it("/api/events?till= applies tillOfEvent; a feed-only role reads its module's events and nothing else", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-L");
|
||||
await setPayAt(a, "bay");
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-L", categoryId: ids.suv, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 500, till: "carwash" } });
|
||||
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 700 } });
|
||||
|
||||
const types = async (qs: string, auth: Auth = a) => {
|
||||
const r = await app.inject({ method: "GET", url: `/api/events?limit=200${qs}`, headers: { cookie: auth.cookie } });
|
||||
expect(r.statusCode).toBe(200);
|
||||
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).map((e) => `${e.type}${e.payload?.till ? `@${e.payload.till}` : ""}`);
|
||||
};
|
||||
// The wash till's log: its shift, its order (no money moved, but wash-desk activity),
|
||||
// its bay payment and its voucher — none of the booth's.
|
||||
const wash = await types("&till=carwash");
|
||||
expect(wash).toEqual(expect.arrayContaining(["shift_open@carwash", "carwash_order", "carwash_payment@carwash", "cash_in@carwash"]));
|
||||
expect(wash.some((t) => t.startsWith("vehicle_entry") || t === "shift_open@booth" || t === "cash_in@booth")).toBe(false);
|
||||
// The booth's log: entry, its shift, its voucher — and no wash-desk activity.
|
||||
const booth = await types("&till=booth");
|
||||
expect(booth).toEqual(expect.arrayContaining(["vehicle_entry", "shift_open@booth", "cash_in@booth"]));
|
||||
expect(booth.some((t) => t.startsWith("carwash_") || t.endsWith("@carwash"))).toBe(false);
|
||||
// No till → everything (unchanged).
|
||||
const all = await types("");
|
||||
expect(all.length).toBe(wash.length + booth.length);
|
||||
expect((await app.inject({ method: "GET", url: "/api/events?till=bar", headers: { cookie: a.cookie } })).statusCode).toBe(400);
|
||||
|
||||
// A wash operator holds carwash:read but not event:read: the log opens for them
|
||||
// with ONLY the module's own event types (the live-socket rule, feedPermissionFor).
|
||||
const washer = await seedUser(db, { username: "lavazhier", roleId: "washer", permissions: ["carwash:read", "carwash:cash"] });
|
||||
const w = await login(app, washer.username, washer.password);
|
||||
const mine = await types("&till=carwash", w);
|
||||
expect(mine).toEqual(expect.arrayContaining(["carwash_order", "carwash_payment@carwash"]));
|
||||
expect(mine.every((t) => t.startsWith("carwash_"))).toBe(true);
|
||||
// A role with neither event:read nor any module feed permission reads nothing.
|
||||
const clerk = await seedUser(db, { username: "clerk", roleId: "clerk", permissions: ["session:read"] });
|
||||
const c = await login(app, clerk.username, clerk.password);
|
||||
expect((await app.inject({ method: "GET", url: "/api/events", headers: { cookie: c.cookie } })).statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vision category — advisory, flagged, never authoritative", () => {
|
||||
/** What snapshot.ts records when vision classifies the entry frame. */
|
||||
function seeVehicle(identity: string, bodyType: string, bodyConfidence: number) {
|
||||
db.insert(deviceEvents).values({
|
||||
id: `read-${identity}-${bodyType}`, deviceId: "cam-1", category: "camera", kind: "read",
|
||||
detail: { identity, direction: "entry", bodyType, bodyConfidence, snapshotId: "snap-1", source: "entry-exit-snapshot" },
|
||||
occurredAt: new Date().toISOString(),
|
||||
}).run();
|
||||
}
|
||||
async function mapClasses(a: Auth, ids: { car: string; suv: string }) {
|
||||
const cur = (await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json();
|
||||
const r = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: {
|
||||
categories: cur.categories.map((c: { id: string }) => ({ ...c, visionClasses: c.id === ids.suv ? ["suv", "pickup"] : c.id === ids.car ? ["car", "sedan", "hatchback"] : [] })),
|
||||
visionThreshold: 0.75,
|
||||
},
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
it("Setup maps the vocabulary onto site categories; the lookup suggests the mapped category", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
const saved = await mapClasses(a, ids);
|
||||
expect(saved.categories.find((c: { id: string }) => c.id === ids.suv).visionClasses).toEqual(["suv", "pickup"]);
|
||||
expect(saved.visionThreshold).toBe(0.75);
|
||||
expect((await events(a)).some((e) => e.type === "config_change" && e.payload.setting === "carwash.visionThreshold")).toBe(true);
|
||||
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { categories: [{ id: ids.car, name: "Car", visionClasses: ["spaceship"] }] } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
|
||||
await openSession("T-V1");
|
||||
seeVehicle("T-V1", "suv", 0.91);
|
||||
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
|
||||
expect(look.vision).toMatchObject({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
|
||||
expect(look.suggestedCategoryId).toBe(ids.suv);
|
||||
// Unmapped class → shown, nothing suggested.
|
||||
await openSession("T-V2");
|
||||
seeVehicle("T-V2", "bus", 0.99);
|
||||
const look2 = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V2", headers: { cookie: a.cookie } })).json();
|
||||
expect(look2.vision.bodyType).toBe("bus");
|
||||
expect(look2.suggestedCategoryId).toBeNull();
|
||||
});
|
||||
|
||||
it("a confident downgrade signs an anomaly with both categories and the snapshot; equal, upgrade or unsure reads do not; the order is never blocked", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await mapClasses(a, ids);
|
||||
const order = async (identity: string, categoryId: string) => {
|
||||
const r = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity, categoryId, serviceId: ids.std } });
|
||||
expect(r.statusCode).toBe(201);
|
||||
return r.json();
|
||||
};
|
||||
// Camera: SUV (0.91) — operator picks Car (cheaper) → flagged, recorded, still created.
|
||||
await openSession("T-D1"); seeVehicle("T-D1", "suv", 0.91);
|
||||
const down = await order("T-D1", ids.car);
|
||||
expect(down).toMatchObject({ visionClass: "suv", visionConfidence: 0.91, visionCategoryId: ids.suv, categoryId: ids.car });
|
||||
expect(down.downgradeEventId).toBeTruthy();
|
||||
const flag = (await events(a)).find((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")!;
|
||||
expect(flag).toBeTruthy();
|
||||
expect(flag.payload).toMatchObject({
|
||||
visionClass: "suv", visionCategoryName: "SUV", chosenCategoryName: "Car", operator: "boss",
|
||||
visionPriceMinor: 70000, chosenPriceMinor: 50000, snapshotId: "snap-1",
|
||||
});
|
||||
// Same category as the camera → nothing.
|
||||
await openSession("T-D2"); seeVehicle("T-D2", "suv", 0.91);
|
||||
expect((await order("T-D2", ids.suv)).downgradeEventId).toBeNull();
|
||||
// Upgrade (camera Car, operator SUV) → recorded on the order, no anomaly.
|
||||
await openSession("T-D3"); seeVehicle("T-D3", "sedan", 0.95);
|
||||
const up = await order("T-D3", ids.suv);
|
||||
expect(up).toMatchObject({ visionClass: "sedan", visionCategoryId: ids.car, downgradeEventId: null });
|
||||
// Below the site threshold → shown, never flagged.
|
||||
await openSession("T-D4"); seeVehicle("T-D4", "suv", 0.6);
|
||||
expect((await order("T-D4", ids.car)).downgradeEventId).toBeNull();
|
||||
// No read at all → nulls.
|
||||
await openSession("T-D5");
|
||||
expect(await order("T-D5", ids.car)).toMatchObject({ visionClass: null, visionCategoryId: null, downgradeEventId: null });
|
||||
expect((await events(a)).filter((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ServerModule } from "../index.js";
|
||||
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||
import { carwashRoutes } from "./routes.js";
|
||||
import { CarwashService } from "./service.js";
|
||||
|
||||
// Car Wash — the pilot venue module (wiki/decisions/venue-modules.md). Everything the
|
||||
// module is lives in this folder: its service (master data, the order queue, the bay
|
||||
// payment, the parking sponsorship + settlement), its routes, and the booth charge
|
||||
// provider it registers with the core's PayStation. The core knows it only through the
|
||||
// registry line in ../index.ts and the manifest in @parking/shared.
|
||||
export const carwashModule: ServerModule = {
|
||||
id: "carwash",
|
||||
async register(app, deps) {
|
||||
// The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env
|
||||
// names a collector URL, a per-booth token and a pseudonymous booth id; off = no
|
||||
// queueing at all. One-way, background, never on the intake path.
|
||||
const cfg = reviewUploadConfigFromEnv();
|
||||
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
|
||||
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
|
||||
outbox.start();
|
||||
app.addHook("onClose", async () => outbox.stop());
|
||||
const service = new CarwashService(deps, app.log, outbox);
|
||||
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||
// the core calls back after the payment is signed so the order is marked paid.
|
||||
deps.payStation.registerChargeProvider(service.chargeProvider());
|
||||
await carwashRoutes(app, deps, service, outbox);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
|
||||
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||
|
||||
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
|
||||
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
|
||||
// backoff, never blocking the wash, never naming the site.
|
||||
|
||||
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
|
||||
async function frame(): Promise<Buffer> {
|
||||
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
|
||||
.composite([
|
||||
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
|
||||
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
|
||||
])
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
}
|
||||
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
|
||||
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
|
||||
|
||||
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
|
||||
* blurred plate drops far below 255 as the red bleeds in. */
|
||||
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
|
||||
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
|
||||
let sum = 0;
|
||||
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
|
||||
return sum / (data.length / info.channels);
|
||||
}
|
||||
|
||||
describe("makeReviewCrop", () => {
|
||||
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
|
||||
const shot = await frame();
|
||||
const crop = await makeReviewCrop(shot, CAR, PLATE);
|
||||
expect(crop.plateBlurred).toBe(true);
|
||||
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
|
||||
expect(crop.width).toBeGreaterThanOrEqual(228);
|
||||
expect(crop.width).toBeLessThanOrEqual(236);
|
||||
expect(crop.height).toBeGreaterThanOrEqual(135);
|
||||
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
|
||||
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
|
||||
// frame is white, the review crop is the red bleeding in.
|
||||
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
|
||||
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
|
||||
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
|
||||
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
|
||||
// Without a plate box: same crop, nothing blurred.
|
||||
const noPlate = await makeReviewCrop(shot, CAR, null);
|
||||
expect(noPlate.plateBlurred).toBe(false);
|
||||
// A big frame is capped to the max edge.
|
||||
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
|
||||
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
|
||||
expect(Math.max(capped.width, capped.height)).toBe(640);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config + pseudonyms", () => {
|
||||
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
|
||||
expect(reviewUploadConfigFromEnv({})).toBeNull();
|
||||
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
|
||||
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
|
||||
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
|
||||
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
|
||||
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
|
||||
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("queue + drain", () => {
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
beforeEach(() => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
});
|
||||
afterEach(() => close());
|
||||
|
||||
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60 };
|
||||
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
|
||||
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false };
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||
db.insert(carwashOrders).values({
|
||||
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
|
||||
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
|
||||
}).run();
|
||||
}
|
||||
|
||||
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
|
||||
await seed();
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||
expect(await ob.enqueue(item, read)).toBe(true);
|
||||
const row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row.status).toBe("queued");
|
||||
expect(row.image!.length).toBeGreaterThan(500);
|
||||
expect(row.payload).toMatchObject({ v: 1, booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
|
||||
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
|
||||
|
||||
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.url).toBe(cfg.url);
|
||||
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
|
||||
const form = calls[0]!.init.body as FormData;
|
||||
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
|
||||
expect((form.get("image") as File).type).toBe("image/jpeg");
|
||||
const after = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(after.status).toBe("sent");
|
||||
expect(after.image).toBeNull();
|
||||
expect(after.sentAt).toBeTruthy();
|
||||
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
|
||||
});
|
||||
|
||||
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
|
||||
await seed();
|
||||
let status = 503;
|
||||
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
|
||||
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||
await ob.enqueue(item, read);
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||
let row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
|
||||
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
|
||||
// Not due yet → untouched.
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
|
||||
// Due again: a network error defers too; a 422 abandons.
|
||||
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||
status = 0;
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||
status = 422;
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||
row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
|
||||
expect(row.image).toBeNull();
|
||||
|
||||
// A voided order is not a sample.
|
||||
status = 200;
|
||||
await ob.enqueue({ ...item, orderId: "o-1" }, read);
|
||||
db.update(carwashOrders).set({ status: "void" }).run();
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||
// Expired items are abandoned without a request.
|
||||
await ob.enqueue(item, read);
|
||||
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
|
||||
db.update(carwashOrders).set({ status: "open" }).run();
|
||||
const before = fetchFn.mock.calls.length;
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||
expect(fetchFn.mock.calls.length).toBe(before);
|
||||
expect(ob.status().failed).toBe(3);
|
||||
|
||||
// No vehicle box, no snapshot, or upload off → nothing queued.
|
||||
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
|
||||
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
|
||||
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
import { eq } from "@parking/db";
|
||||
|
||||
describe("through the app", () => {
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
const saved = { ...process.env };
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
|
||||
process.env.CARWASH_REVIEW_TOKEN = "tok";
|
||||
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID"]) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const a = await login(app, username, password);
|
||||
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
|
||||
seedTariff(db);
|
||||
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
|
||||
const cat = s.categories[0].id, svc = s.services[0].id;
|
||||
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
|
||||
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
|
||||
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||
db.insert(deviceEvents).values({
|
||||
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
|
||||
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
|
||||
occurredAt: new Date().toISOString(),
|
||||
}).run();
|
||||
|
||||
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
|
||||
expect(order.statusCode).toBe(201);
|
||||
// Enqueue is fire-and-forget: give the crop a moment.
|
||||
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
|
||||
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
|
||||
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db";
|
||||
import type { NormBox, VehicleRead } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md).
|
||||
//
|
||||
// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model:
|
||||
// the operator may err or cheat). So every wash order that has a vehicle read queues a
|
||||
// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry
|
||||
// snapshot with the plate BLURRED, the operator's choice, and what the camera thought.
|
||||
// The reviewer's verdict becomes the training label for the body-type classifier (phase
|
||||
// B) and, per operator, the honest-mistake / fraud rate.
|
||||
//
|
||||
// Rules that shape this file:
|
||||
// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path;
|
||||
// a background loop drains the queue when the private overlay (Netbird) is up, with
|
||||
// backoff, and gives up loudly after EXPIRE_DAYS.
|
||||
// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The
|
||||
// signed ledger stays the only record of what happened at the wash.
|
||||
// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no
|
||||
// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy,
|
||||
// the operator as a keyed hash. The mapping back to people and places stays with the
|
||||
// reviewer, off the collector.
|
||||
// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the
|
||||
// booth can do nothing at the collector but this one POST.
|
||||
|
||||
export interface ReviewUploadConfig {
|
||||
/** The collector's ingest URL (reachable only over the overlay). */
|
||||
readonly url: string;
|
||||
/** Per-booth bearer token. */
|
||||
readonly token: string;
|
||||
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
|
||||
readonly boothId: string;
|
||||
readonly intervalSec: number;
|
||||
}
|
||||
|
||||
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
|
||||
export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null {
|
||||
const url = (env.CARWASH_REVIEW_URL ?? "").trim();
|
||||
const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim();
|
||||
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
|
||||
if (!url || !token || !boothId) return null;
|
||||
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60);
|
||||
return { url, token, boothId, intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60 };
|
||||
}
|
||||
|
||||
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
|
||||
* enough that a day of washes is a few megabytes. */
|
||||
export const CROP_MAX_EDGE = 640;
|
||||
/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */
|
||||
const CROP_MARGIN = 0.08;
|
||||
/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks
|
||||
* should not resurface a fortnight of crops in one burst. */
|
||||
export const EXPIRE_DAYS = 14;
|
||||
/** Backoff: 1 min · 2^attempts, capped. */
|
||||
const BACKOFF_BASE_MS = 60_000;
|
||||
const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000;
|
||||
const UPLOAD_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** What one order contributes to the package (the service hands this over at intake). */
|
||||
export interface ReviewItemInput {
|
||||
readonly orderId: string;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly categoryId: string;
|
||||
readonly categoryName: string;
|
||||
/** The vision classes the chosen category covers at this site (its mapping) — lets the
|
||||
* reviewer's class be judged against the operator's category without the site's setup. */
|
||||
readonly categoryClasses: readonly string[];
|
||||
readonly serviceName: string;
|
||||
readonly visionCategoryId: string | null;
|
||||
readonly downgraded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions
|
||||
* of the frame, so this works on the stored (downscaled) copy. Returns a JPEG.
|
||||
*/
|
||||
export async function makeReviewCrop(
|
||||
snapshotBytes: Buffer,
|
||||
box: NormBox,
|
||||
plateBox: NormBox | null | undefined,
|
||||
): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> {
|
||||
const img = sharp(snapshotBytes, { failOn: "none" }).rotate();
|
||||
const meta = await img.metadata();
|
||||
const W = meta.width ?? 0;
|
||||
const H = meta.height ?? 0;
|
||||
if (!W || !H) throw new Error("snapshot has no dimensions");
|
||||
const px = (b: NormBox) => ({
|
||||
left: Math.round(b.x1 * W), top: Math.round(b.y1 * H),
|
||||
right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H),
|
||||
});
|
||||
const v = px(box);
|
||||
const mw = Math.round((v.right - v.left) * CROP_MARGIN);
|
||||
const mh = Math.round((v.bottom - v.top) * CROP_MARGIN);
|
||||
const left = Math.max(0, v.left - mw);
|
||||
const top = Math.max(0, v.top - mh);
|
||||
const right = Math.min(W, v.right + mw);
|
||||
const bottom = Math.min(H, v.bottom + mh);
|
||||
const width = right - left;
|
||||
const height = bottom - top;
|
||||
if (width < 8 || height < 8) throw new Error("vehicle box too small to crop");
|
||||
|
||||
let crop = img.clone().extract({ left, top, width, height });
|
||||
let plateBlurred = false;
|
||||
if (plateBox) {
|
||||
// The plate region, in CROP coordinates, padded a little so the blur eats the edges.
|
||||
const p = px(plateBox);
|
||||
const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25);
|
||||
const pl = Math.max(0, p.left - pad - left);
|
||||
const pt = Math.max(0, p.top - pad - top);
|
||||
const pr = Math.min(width, p.right + pad - left);
|
||||
const pb = Math.min(height, p.bottom + pad - top);
|
||||
if (pr - pl >= 2 && pb - pt >= 2) {
|
||||
const region = await sharp(await crop.clone().toBuffer())
|
||||
.extract({ left: pl, top: pt, width: pr - pl, height: pb - pt })
|
||||
.blur(Math.max(6, Math.round((pr - pl) / 6)))
|
||||
.toBuffer();
|
||||
crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]);
|
||||
plateBlurred = true;
|
||||
}
|
||||
}
|
||||
const out = await crop
|
||||
.resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 85, mozjpeg: true })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred };
|
||||
}
|
||||
|
||||
/** The operator as a keyed hash — stable per booth so the reviewer can count per person,
|
||||
* meaningless anywhere else. */
|
||||
export function operatorRef(boothId: string, username: string): string {
|
||||
return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface OutboxStatus {
|
||||
readonly enabled: boolean;
|
||||
readonly boothId: string | null;
|
||||
readonly queued: number;
|
||||
readonly sent: number;
|
||||
readonly failed: number;
|
||||
readonly lastSentAt: string | null;
|
||||
readonly lastError: string | null;
|
||||
}
|
||||
|
||||
export class ReviewOutbox {
|
||||
readonly #db: Db;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #cfg: ReviewUploadConfig | null;
|
||||
readonly #fetch: FetchLike;
|
||||
#timer: NodeJS.Timeout | null = null;
|
||||
#draining = false;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
this.#cfg = cfg;
|
||||
this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.#cfg != null;
|
||||
}
|
||||
|
||||
/** Queue one order's package. Fire-and-forget: the caller does NOT await this on the
|
||||
* intake path; every failure is logged, none is thrown. Skipped when there is no
|
||||
* vehicle box (nothing to crop — a frame without a detected vehicle is no training
|
||||
* sample) or when upload is not configured (an unbounded queue nobody drains). */
|
||||
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
|
||||
if (!this.#cfg) return false;
|
||||
if (!read.box || !read.snapshotId) return false;
|
||||
try {
|
||||
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
|
||||
if (!snap) {
|
||||
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — order ${item.orderId} not queued`);
|
||||
return false;
|
||||
}
|
||||
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
|
||||
const id = randomUUID();
|
||||
const payload = {
|
||||
v: 1,
|
||||
booth: this.#cfg.boothId,
|
||||
item: id,
|
||||
order: item.orderId,
|
||||
at: item.createdAt,
|
||||
operator: operatorRef(this.#cfg.boothId, item.createdBy),
|
||||
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
|
||||
service: item.serviceName,
|
||||
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
|
||||
downgraded: item.downgraded,
|
||||
image: { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred },
|
||||
};
|
||||
this.#db
|
||||
.insert(carwashReviewOutbox)
|
||||
.values({ id, orderId: item.orderId, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
|
||||
.run();
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`carwash review: could not queue order ${item.orderId}: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.#cfg || this.#timer) return;
|
||||
const tick = () => {
|
||||
void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`));
|
||||
};
|
||||
this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000);
|
||||
this.#timer.unref?.();
|
||||
setTimeout(tick, 5_000).unref?.();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.#timer) clearInterval(this.#timer);
|
||||
this.#timer = null;
|
||||
}
|
||||
|
||||
/** Send what is due, oldest first. Returns the tally; never throws for a single item. */
|
||||
async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> {
|
||||
const tally = { sent: 0, failed: 0, deferred: 0 };
|
||||
if (!this.#cfg || this.#draining) return tally;
|
||||
this.#draining = true;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const due = this.#db
|
||||
.select()
|
||||
.from(carwashReviewOutbox)
|
||||
.where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now))))
|
||||
.orderBy(asc(carwashReviewOutbox.createdAt))
|
||||
.limit(limit)
|
||||
.all();
|
||||
for (const row of due) {
|
||||
const outcome = await this.#send(row);
|
||||
tally[outcome] += 1;
|
||||
}
|
||||
if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`);
|
||||
} finally {
|
||||
this.#draining = false;
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> {
|
||||
const cfg = this.#cfg!;
|
||||
const ageMs = Date.now() - Date.parse(row.createdAt);
|
||||
if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`);
|
||||
// A wash voided before delivery is not a sample (and not a decision to review).
|
||||
const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get();
|
||||
if (order?.status === "void") return this.#fail(row, "order voided");
|
||||
if (!row.image) return this.#fail(row, "image missing");
|
||||
|
||||
const form = new FormData();
|
||||
form.set("meta", JSON.stringify(row.payload));
|
||||
form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`);
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await this.#fetch(cfg.url, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId },
|
||||
body: form,
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
this.#db
|
||||
.update(carwashReviewOutbox)
|
||||
.set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 })
|
||||
.where(eq(carwashReviewOutbox.id, row.id))
|
||||
.run();
|
||||
return "sent";
|
||||
}
|
||||
// The collector refused the package itself → no retry will help.
|
||||
if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`);
|
||||
// Everything else (auth not yet fixed, throttled, collector down) → try again later.
|
||||
return this.#defer(row, `HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message);
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
#fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" {
|
||||
this.#db
|
||||
.update(carwashReviewOutbox)
|
||||
.set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 })
|
||||
.where(eq(carwashReviewOutbox.id, row.id))
|
||||
.run();
|
||||
this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`);
|
||||
return "failed";
|
||||
}
|
||||
|
||||
#defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" {
|
||||
const attempts = row.attempts + 1;
|
||||
const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS);
|
||||
this.#db
|
||||
.update(carwashReviewOutbox)
|
||||
.set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() })
|
||||
.where(eq(carwashReviewOutbox.id, row.id))
|
||||
.run();
|
||||
return "deferred";
|
||||
}
|
||||
|
||||
status(): OutboxStatus {
|
||||
const count = (s: "queued" | "sent" | "failed") =>
|
||||
this.#db.select({ n: sql<number>`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0;
|
||||
const lastSent = this.#db.select({ at: sql<string | null>`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null;
|
||||
const lastErr = this.#db
|
||||
.select({ e: carwashReviewOutbox.lastError })
|
||||
.from(carwashReviewOutbox)
|
||||
.where(sql`${carwashReviewOutbox.lastError} is not null`)
|
||||
.orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`)
|
||||
.limit(1)
|
||||
.get()?.e ?? null;
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
boothId: this.#cfg?.boothId ?? null,
|
||||
queued: count("queued"),
|
||||
sent: count("sent"),
|
||||
failed: count("failed"),
|
||||
lastSentAt: lastSent,
|
||||
lastError: lastErr,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { Tender } from "@parking/shared";
|
||||
import { requireAnyPermission, requirePermission } from "../../auth.js";
|
||||
import { requireModule } from "../../modules.js";
|
||||
import { NoShiftOpenError } from "../../shift-service.js";
|
||||
import type { ServerModuleDeps } from "../index.js";
|
||||
import type { ReviewOutbox } from "./review-outbox.js";
|
||||
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
||||
|
||||
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
||||
// FIRST (403 module_disabled), then a permission:
|
||||
// settings (master data) site:read / site:update — the site admin's job
|
||||
// queue / ticket lookup carwash:read — the wash desk
|
||||
// intake carwash:create
|
||||
// done / bay payment / void carwash:update
|
||||
// The sponsorship PROGRAM itself is a validation program row (id "carwash") and is
|
||||
// composed through the existing /api/validation/programs/:id route (site:update).
|
||||
|
||||
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||
if (err instanceof CarwashError) {
|
||||
return reply.code(err.status).send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
||||
}
|
||||
if (err instanceof NoShiftOpenError) {
|
||||
// The bay takes money on the CARWASH till: the wash operator's own shift must be
|
||||
// open (the booth's does not count). The desk shows its shift control on this code.
|
||||
return reply.code(409).send({ error: err.message, code: "no_shift", till: err.till });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise<void> {
|
||||
const moduleOn = requireModule(deps.db, "carwash");
|
||||
// The price list is the desk's working data as much as Setup's: the wash operator
|
||||
// reads it under the module's own permission (the Wash operator job holds no site:*).
|
||||
const settingsRead = [moduleOn, requireAnyPermission("carwash:read", "site:read")];
|
||||
const settingsWrite = [moduleOn, requirePermission("site:update")];
|
||||
const read = [moduleOn, requirePermission("carwash:read")];
|
||||
const create = [moduleOn, requirePermission("carwash:create")];
|
||||
const update = [moduleOn, requirePermission("carwash:update")];
|
||||
|
||||
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
||||
// The review outbox's health (Setup → Car wash): how many decisions wait for the
|
||||
// reviewer, how many went, the last error. Site admin's read.
|
||||
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
|
||||
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null },
|
||||
);
|
||||
|
||||
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||
try {
|
||||
return await service.saveSettings(req.body ?? {}, req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { identity: string } }>("/api/carwash/session/:identity", { preHandler: read }, async (req) =>
|
||||
service.lookup(req.params.identity),
|
||||
);
|
||||
|
||||
app.get<{ Querystring: { scope?: string; limit?: string } }>("/api/carwash/orders", { preHandler: read }, async (req) => {
|
||||
if (req.query.scope === "recent") return { orders: service.recentOrders(Number(req.query.limit) || 100) };
|
||||
return { orders: service.openOrders() };
|
||||
});
|
||||
|
||||
app.post<{ Body: { identity?: string; categoryId?: string; serviceId?: string; payAt?: string } }>(
|
||||
"/api/carwash/orders",
|
||||
{ preHandler: create },
|
||||
async (req, reply) => {
|
||||
const b = req.body ?? {};
|
||||
// payAt is a SITE setting now; the desk no longer sends it. Accept it only when it
|
||||
// matches (the service refuses a mismatch) so a stale client cannot pick the till.
|
||||
if (b.payAt !== undefined && !isPayAt(b.payAt)) return reply.code(400).send({ error: "payAt must be booth|bay" });
|
||||
try {
|
||||
const order = await service.createOrder({
|
||||
identity: String(b.identity ?? ""),
|
||||
categoryId: String(b.categoryId ?? ""),
|
||||
serviceId: String(b.serviceId ?? ""),
|
||||
...(b.payAt !== undefined ? { payAt: b.payAt } : {}),
|
||||
actor: req.user.username,
|
||||
});
|
||||
return reply.code(201).send(order);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>("/api/carwash/orders/:id/done", { preHandler: update }, async (req, reply) => {
|
||||
try {
|
||||
return await service.markDone(req.params.id, req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { tender?: Tender } }>(
|
||||
"/api/carwash/orders/:id/pay",
|
||||
{ preHandler: update },
|
||||
async (req, reply) => {
|
||||
try {
|
||||
return await service.payAtBay(req.params.id, (req.body?.tender ?? "cash") as Tender, req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { reason?: string } }>(
|
||||
"/api/carwash/orders/:id/void",
|
||||
{ preHandler: update },
|
||||
async (req, reply) => {
|
||||
try {
|
||||
return await service.voidOrder(req.params.id, String(req.body?.reason ?? "").trim(), req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
carwashCategories,
|
||||
carwashConfig,
|
||||
carwashOrders,
|
||||
carwashPrices,
|
||||
carwashServices,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
type CarwashOrderRow,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
import {
|
||||
CARWASH_PAY_AT,
|
||||
CARWASH_PAY_AT_DEFAULT,
|
||||
CARWASH_PROGRAM_ID,
|
||||
type CarWashPayAt,
|
||||
type CarwashOrderView,
|
||||
type CarwashSettingsView,
|
||||
type ChargeLine,
|
||||
CARWASH_VISION_THRESHOLD_DEFAULT,
|
||||
isVehicleClass,
|
||||
reasonPayload,
|
||||
type VehicleClass,
|
||||
type VehicleRead,
|
||||
type Tender,
|
||||
type TillId,
|
||||
} from "@parking/shared";
|
||||
import type { EventLog } from "../../event-log.js";
|
||||
import { vehicleForIdentity } from "../../plate-lookup.js";
|
||||
import type { ReviewOutbox } from "./review-outbox.js";
|
||||
import { effectiveModulesFor } from "../../modules.js";
|
||||
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
||||
import type { ShiftService } from "../../shift-service.js";
|
||||
import { applyValidation, liveValidations } from "../../validations.js";
|
||||
import type { ServerModuleDeps } from "../index.js";
|
||||
|
||||
// Car Wash — the module's whole behaviour (wiki/decisions/venue-modules.md, "Car Wash —
|
||||
// the pilot module" + "v1 answers"). Master data is mutable rows; every order freezes
|
||||
// what it sold (names + price) and signs its life onto the ledger; money at the bay is
|
||||
// a signed `carwash_payment`; money at the booth rides the parking `payment` as a
|
||||
// charge line (ChargeProvider below). The parking sponsorship is the site's "carwash"
|
||||
// VALIDATION program, applied through the shared applyValidation() when a wash is done
|
||||
// — the wash never touches parking code, it talks to the core through ServerModuleDeps.
|
||||
|
||||
/** A refusal the route maps to an HTTP status. */
|
||||
/** The till bay money lands on — declared by the module manifest (MODULES). */
|
||||
const CARWASH_TILL: TillId = "carwash";
|
||||
|
||||
export class CarwashError extends Error {
|
||||
constructor(
|
||||
readonly status: 400 | 404 | 409,
|
||||
message: string,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CarwashError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsBody {
|
||||
categories?: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[];
|
||||
services?: { id?: string; name?: string; active?: boolean }[];
|
||||
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
|
||||
/** Where wash money is taken at this site (site-level policy). */
|
||||
payAt?: unknown;
|
||||
visionThreshold?: unknown;
|
||||
}
|
||||
|
||||
export interface CreateOrderInput {
|
||||
identity: string;
|
||||
categoryId: string;
|
||||
serviceId: string;
|
||||
/** Optional — the SITE policy decides; a stale client that sends a different value
|
||||
* is refused (409 pay_at_policy) rather than silently overridden. */
|
||||
payAt?: CarWashPayAt;
|
||||
actor: string;
|
||||
}
|
||||
|
||||
export interface TicketLookup {
|
||||
identity: string;
|
||||
found: boolean;
|
||||
open: boolean;
|
||||
subscription: boolean;
|
||||
plate: string | null;
|
||||
enteredAt: string | null;
|
||||
currency: string | null;
|
||||
orders: CarwashOrderView[];
|
||||
/** What the camera saw at entry (advisory) and the category the site mapping
|
||||
* suggests for it — the desk pre-selects it; the operator may change it. */
|
||||
vision: VehicleRead | null;
|
||||
suggestedCategoryId: string | null;
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
/** Stable slug for a new master-data row: from the name, else a random id. */
|
||||
function slugify(name: string): string {
|
||||
const s = name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
return s || randomUUID();
|
||||
}
|
||||
|
||||
export class CarwashService {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #pay: PayStation;
|
||||
readonly #shift: ShiftService;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #outbox: ReviewOutbox | null;
|
||||
|
||||
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) {
|
||||
this.#db = deps.db;
|
||||
this.#log = deps.eventLog;
|
||||
this.#pay = deps.payStation;
|
||||
this.#shift = deps.shiftService;
|
||||
this.#logger = logger;
|
||||
this.#outbox = outbox;
|
||||
}
|
||||
|
||||
#enabled(): boolean {
|
||||
return effectiveModulesFor(this.#db).includes("carwash");
|
||||
}
|
||||
|
||||
// --- Settings (master data) -------------------------------------------------
|
||||
|
||||
settings(): CarwashSettingsView {
|
||||
const categories = this.#db
|
||||
.select()
|
||||
.from(carwashCategories)
|
||||
.where(isNull(carwashCategories.deletedAt))
|
||||
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
|
||||
.all()
|
||||
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active, visionClasses: r.visionClasses.filter(isVehicleClass) }));
|
||||
const services = this.#db
|
||||
.select()
|
||||
.from(carwashServices)
|
||||
.where(isNull(carwashServices.deletedAt))
|
||||
.orderBy(asc(carwashServices.sortOrder), asc(carwashServices.name))
|
||||
.all()
|
||||
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
||||
const live = new Set([...categories.map((c) => c.id), ...services.map((s) => s.id)]);
|
||||
const prices = this.#db
|
||||
.select()
|
||||
.from(carwashPrices)
|
||||
.all()
|
||||
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
|
||||
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor }));
|
||||
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt(), visionThreshold: this.visionThreshold() };
|
||||
}
|
||||
|
||||
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
|
||||
payAt(): CarWashPayAt {
|
||||
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
|
||||
return row?.payAt ?? CARWASH_PAY_AT_DEFAULT;
|
||||
}
|
||||
|
||||
/** Confidence floor for a vision class to flag a category downgrade (site config). */
|
||||
visionThreshold(): number {
|
||||
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
|
||||
return row?.visionThreshold ?? CARWASH_VISION_THRESHOLD_DEFAULT;
|
||||
}
|
||||
|
||||
/** The category the site mapping suggests for a vision class (first active category
|
||||
* listing it, in display order), or null when unmapped. */
|
||||
#categoryForClass(cls: VehicleClass): { id: string; name: string } | null {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(carwashCategories)
|
||||
.where(isNull(carwashCategories.deletedAt))
|
||||
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
|
||||
.all();
|
||||
const hit = rows.find((r) => r.active && r.visionClasses.includes(cls));
|
||||
return hit ? { id: hit.id, name: hit.name } : null;
|
||||
}
|
||||
|
||||
/** The site's currency = the active tariff's (the wash is priced in the same money
|
||||
* the booth takes). null when no tariff is published yet. */
|
||||
#currency(): string | null {
|
||||
try {
|
||||
// Any open session's quote carries it; without one, fall back to the tariff table.
|
||||
const row = this.#db.select().from(carwashOrders).orderBy(desc(carwashOrders.createdAt)).limit(1).get();
|
||||
if (row) return row.currency;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return this.#pay.activeCurrency();
|
||||
}
|
||||
|
||||
/** Full-replacement save of the three lists. Rows missing from the body are
|
||||
* soft-deleted (orders already reference names + prices by value, so nothing
|
||||
* historical changes). Signs one config_change. */
|
||||
async saveSettings(body: SettingsBody, actor: string): Promise<CarwashSettingsView> {
|
||||
const now = new Date().toISOString();
|
||||
const upsertList = (
|
||||
table: typeof carwashCategories | typeof carwashServices,
|
||||
items: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[] | undefined,
|
||||
label: string,
|
||||
): string[] => {
|
||||
if (items === undefined) {
|
||||
return this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all().map((r) => r.id);
|
||||
}
|
||||
if (!Array.isArray(items)) throw new CarwashError(400, `${label} must be an array`);
|
||||
const keep: string[] = [];
|
||||
let sort = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const it of items) {
|
||||
const name = String(it?.name ?? "").trim();
|
||||
if (!name) throw new CarwashError(400, `${label}: every item needs a name`);
|
||||
let id = typeof it.id === "string" && it.id.trim() ? it.id.trim() : slugify(name);
|
||||
if (!ID_RE.test(id)) throw new CarwashError(400, `${label}: bad id "${id}"`);
|
||||
// Two new items slugging to the same id → disambiguate rather than merge.
|
||||
while (seen.has(id)) id = `${id}-${sort}`;
|
||||
seen.add(id);
|
||||
const active = it.active !== false;
|
||||
// Vision mapping lives on CATEGORIES only; absent = keep what the row has.
|
||||
let visionClasses: string[] | undefined;
|
||||
if (table === carwashCategories && it.visionClasses !== undefined) {
|
||||
if (!Array.isArray(it.visionClasses) || !it.visionClasses.every(isVehicleClass)) {
|
||||
throw new CarwashError(400, `${label}: visionClasses must be an array of vehicle classes`);
|
||||
}
|
||||
visionClasses = [...new Set(it.visionClasses as string[])];
|
||||
}
|
||||
const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
|
||||
if (existing) {
|
||||
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null, ...(visionClasses ? { visionClasses } : {}) }).where(eq(table.id, id)).run();
|
||||
} else {
|
||||
this.#db.insert(table).values({ id, name, sortOrder: sort, active, ...(visionClasses ? { visionClasses } : {}) }).run();
|
||||
}
|
||||
keep.push(id);
|
||||
sort += 1;
|
||||
}
|
||||
const live = this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all();
|
||||
for (const r of live) {
|
||||
if (!keep.includes(r.id)) {
|
||||
this.#db.update(table).set({ deletedAt: now, deletedBy: actor }).where(eq(table.id, r.id)).run();
|
||||
}
|
||||
}
|
||||
return keep;
|
||||
};
|
||||
|
||||
const categoryIds = upsertList(carwashCategories, body.categories, "categories");
|
||||
const serviceIds = upsertList(carwashServices, body.services, "services");
|
||||
|
||||
if (body.prices !== undefined) {
|
||||
if (!Array.isArray(body.prices)) throw new CarwashError(400, "prices must be an array");
|
||||
const rows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
||||
for (const p of body.prices) {
|
||||
const categoryId = String(p?.categoryId ?? "");
|
||||
const serviceId = String(p?.serviceId ?? "");
|
||||
const priceMinor = p?.priceMinor;
|
||||
if (!categoryIds.includes(categoryId)) throw new CarwashError(400, `prices: unknown category "${categoryId}"`);
|
||||
if (!serviceIds.includes(serviceId)) throw new CarwashError(400, `prices: unknown service "${serviceId}"`);
|
||||
if (!Number.isInteger(priceMinor) || (priceMinor as number) < 0) {
|
||||
throw new CarwashError(400, "prices: priceMinor must be a non-negative integer");
|
||||
}
|
||||
rows.push({ categoryId, serviceId, priceMinor: priceMinor as number });
|
||||
}
|
||||
this.#db.delete(carwashPrices).run();
|
||||
for (const r of rows) this.#db.insert(carwashPrices).values(r).run();
|
||||
}
|
||||
|
||||
await this.#log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: "module:carwash",
|
||||
payload: {
|
||||
setting: "carwash.settings",
|
||||
value: { categories: categoryIds.length, services: serviceIds.length, prices: body.prices?.length ?? null },
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
|
||||
// Where the money is taken — a site policy, signed on its own when it flips (it
|
||||
// decides which till the cash lands on and whether the booth barrier or the exit
|
||||
// reader releases the car; fraud-relevant, so it is attributed like other config).
|
||||
if (body.payAt !== undefined) {
|
||||
if (!isPayAt(body.payAt)) throw new CarwashError(400, "payAt must be booth|bay");
|
||||
const prev = this.payAt();
|
||||
if (body.payAt !== prev) {
|
||||
this.#db
|
||||
.insert(carwashConfig)
|
||||
.values({ id: 1, payAt: body.payAt, updatedAt: now, updatedBy: actor })
|
||||
.onConflictDoUpdate({ target: carwashConfig.id, set: { payAt: body.payAt, updatedAt: now, updatedBy: actor } })
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: "module:carwash",
|
||||
payload: { setting: "carwash.payAt", value: body.payAt, prev, operator: actor },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (body.visionThreshold !== undefined) {
|
||||
const v = Number(body.visionThreshold);
|
||||
if (!Number.isFinite(v) || v < 0 || v > 1) throw new CarwashError(400, "visionThreshold must be between 0 and 1");
|
||||
const prev = this.visionThreshold();
|
||||
if (v !== prev) {
|
||||
this.#db
|
||||
.insert(carwashConfig)
|
||||
.values({ id: 1, visionThreshold: v, updatedAt: now, updatedBy: actor })
|
||||
.onConflictDoUpdate({ target: carwashConfig.id, set: { visionThreshold: v, updatedAt: now, updatedBy: actor } })
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: "module:carwash",
|
||||
payload: { setting: "carwash.visionThreshold", value: v, prev, operator: actor },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.settings();
|
||||
}
|
||||
|
||||
// --- Orders ---------------------------------------------------------------------
|
||||
|
||||
#view(r: CarwashOrderRow): CarwashOrderView {
|
||||
return {
|
||||
id: r.id,
|
||||
identity: r.identity,
|
||||
plate: r.plate,
|
||||
categoryId: r.categoryId,
|
||||
categoryName: r.categoryName,
|
||||
serviceId: r.serviceId,
|
||||
serviceName: r.serviceName,
|
||||
priceMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
payAt: r.payAt,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
createdBy: r.createdBy,
|
||||
doneAt: r.doneAt,
|
||||
doneBy: r.doneBy,
|
||||
paidAt: r.paidAt,
|
||||
paidBy: r.paidBy,
|
||||
tender: (r.tender as Tender | null) ?? null,
|
||||
closed: r.status === "void" || (r.status === "done" && r.paidAt != null),
|
||||
validationEventId: r.validationEventId,
|
||||
voidBy: r.voidBy,
|
||||
voidReason: r.voidReason,
|
||||
visionClass: isVehicleClass(r.visionClass) ? r.visionClass : null,
|
||||
visionConfidence: r.visionConfidence,
|
||||
visionCategoryId: r.visionCategoryId,
|
||||
downgradeEventId: r.downgradeEventId,
|
||||
};
|
||||
}
|
||||
|
||||
#row(id: string): CarwashOrderRow {
|
||||
const r = this.#db.select().from(carwashOrders).where(eq(carwashOrders.id, id)).get();
|
||||
if (!r) throw new CarwashError(404, "order not found");
|
||||
return r;
|
||||
}
|
||||
|
||||
/** The desk's queue: every order still needing something, oldest first. */
|
||||
openOrders(): CarwashOrderView[] {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.where(inArray(carwashOrders.status, ["open", "done"]))
|
||||
.orderBy(asc(carwashOrders.createdAt))
|
||||
.all()
|
||||
.map((r) => this.#view(r))
|
||||
.filter((o) => !o.closed);
|
||||
}
|
||||
|
||||
/** Recent history (closed included), newest first. */
|
||||
recentOrders(limit = 100): CarwashOrderView[] {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.orderBy(desc(carwashOrders.createdAt))
|
||||
.limit(Math.min(Math.max(limit, 1), 500))
|
||||
.all()
|
||||
.map((r) => this.#view(r));
|
||||
}
|
||||
|
||||
#ordersFor(identity: string): CarwashOrderView[] {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.where(eq(carwashOrders.identity, identity))
|
||||
.orderBy(asc(carwashOrders.createdAt))
|
||||
.all()
|
||||
.map((r) => this.#view(r));
|
||||
}
|
||||
|
||||
/** Ticket → session facts the desk needs (the parking ticket IS the customer). */
|
||||
lookup(identity: string): TicketLookup {
|
||||
const id = identity.trim();
|
||||
const s = this.#pay.lookup(id);
|
||||
const vision = s.found ? vehicleForIdentity(this.#db, id) : null;
|
||||
return {
|
||||
identity: id,
|
||||
found: s.found,
|
||||
open: s.open,
|
||||
subscription: s.subscription,
|
||||
plate: s.plate,
|
||||
enteredAt: s.enteredAt,
|
||||
currency: s.currency,
|
||||
orders: this.#ordersFor(id),
|
||||
vision,
|
||||
suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async createOrder(input: CreateOrderInput): Promise<CarwashOrderView> {
|
||||
const identity = input.identity.trim();
|
||||
if (!identity) throw new CarwashError(400, "identity (ticket) required");
|
||||
|
||||
const s = this.#pay.lookup(identity);
|
||||
if (!s.found) throw new CarwashError(404, "no session for ticket");
|
||||
if (!s.open) throw new CarwashError(409, "session is closed");
|
||||
if (s.subscription) throw new CarwashError(409, "subscription sessions: order the wash with payAt=bay", "subscription");
|
||||
|
||||
const category = this.#db
|
||||
.select()
|
||||
.from(carwashCategories)
|
||||
.where(and(eq(carwashCategories.id, input.categoryId), isNull(carwashCategories.deletedAt)))
|
||||
.get();
|
||||
if (!category || !category.active) throw new CarwashError(404, "category not found or inactive");
|
||||
const service = this.#db
|
||||
.select()
|
||||
.from(carwashServices)
|
||||
.where(and(eq(carwashServices.id, input.serviceId), isNull(carwashServices.deletedAt)))
|
||||
.get();
|
||||
if (!service || !service.active) throw new CarwashError(404, "service not found or inactive");
|
||||
const price = this.#db
|
||||
.select()
|
||||
.from(carwashPrices)
|
||||
.where(and(eq(carwashPrices.categoryId, category.id), eq(carwashPrices.serviceId, service.id)))
|
||||
.get();
|
||||
if (!price) throw new CarwashError(409, `no price for ${category.name} · ${service.name}`, "no_price");
|
||||
// The SITE decides where wash money is taken (Setup → Car wash); the order freezes
|
||||
// the policy in force. A client that still sends a different value is stale.
|
||||
const payAt = this.payAt();
|
||||
if (input.payAt !== undefined && input.payAt !== payAt) {
|
||||
throw new CarwashError(409, `this site takes wash money at the ${payAt === "bay" ? "bay" : "booth"}`, "pay_at_policy");
|
||||
}
|
||||
const currency = s.currency ?? this.#pay.activeCurrency();
|
||||
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff");
|
||||
|
||||
// Vision, advisory: what the camera saw at entry and the category the site maps it
|
||||
// to. A DOWNGRADE — the operator chose a category that prices LOWER than the mapped
|
||||
// one for this service, with the read above the site threshold — is signed as an
|
||||
// anomaly for the reviewer (both categories, operator, snapshot). Recorded only:
|
||||
// never blocks, no reason prompt (user, 2026-09-06).
|
||||
const vision = vehicleForIdentity(this.#db, identity);
|
||||
const visionCategory = vision ? this.#categoryForClass(vision.bodyType) : null;
|
||||
let downgradeEventId: string | null = null;
|
||||
if (vision && visionCategory && visionCategory.id !== category.id && vision.confidence >= this.visionThreshold()) {
|
||||
const visionPrice = this.#db
|
||||
.select()
|
||||
.from(carwashPrices)
|
||||
.where(and(eq(carwashPrices.categoryId, visionCategory.id), eq(carwashPrices.serviceId, service.id)))
|
||||
.get();
|
||||
if (visionPrice && visionPrice.priceMinor > price.priceMinor) {
|
||||
const ev = await this.#log.append({
|
||||
type: "anomaly",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
...reasonPayload("carwash.categoryDowngrade", {
|
||||
visionClass: vision.bodyType,
|
||||
visionCategory: visionCategory.name,
|
||||
operator: input.actor,
|
||||
chosenCategory: category.name,
|
||||
}),
|
||||
sessionRef: identity,
|
||||
visionClass: vision.bodyType,
|
||||
visionConfidence: vision.confidence,
|
||||
visionCategoryId: visionCategory.id,
|
||||
visionCategoryName: visionCategory.name,
|
||||
chosenCategoryId: category.id,
|
||||
chosenCategoryName: category.name,
|
||||
serviceName: service.name,
|
||||
visionPriceMinor: visionPrice.priceMinor,
|
||||
chosenPriceMinor: price.priceMinor,
|
||||
currency,
|
||||
snapshotId: vision.snapshotId,
|
||||
operator: input.actor,
|
||||
},
|
||||
});
|
||||
downgradeEventId = ev.id;
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const row: CarwashOrderRow = {
|
||||
id: randomUUID(),
|
||||
identity,
|
||||
plate: s.plate,
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
serviceId: service.id,
|
||||
serviceName: service.name,
|
||||
priceMinor: price.priceMinor,
|
||||
currency,
|
||||
payAt,
|
||||
status: "open",
|
||||
createdAt: now,
|
||||
createdBy: input.actor,
|
||||
doneAt: null,
|
||||
doneBy: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
tender: null,
|
||||
paymentEventId: null,
|
||||
validationEventId: null,
|
||||
voidAt: null,
|
||||
voidBy: null,
|
||||
voidReason: null,
|
||||
visionClass: vision?.bodyType ?? null,
|
||||
visionConfidence: vision?.confidence ?? null,
|
||||
visionCategoryId: visionCategory?.id ?? null,
|
||||
downgradeEventId,
|
||||
};
|
||||
this.#db.insert(carwashOrders).values(row).run();
|
||||
// Hand the decision to the remote reviewer (crop + choice), off the intake path.
|
||||
if (vision && this.#outbox?.enabled) {
|
||||
void this.#outbox.enqueue(
|
||||
{
|
||||
orderId: row.id,
|
||||
createdAt: now,
|
||||
createdBy: input.actor,
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
categoryClasses: category.visionClasses,
|
||||
serviceName: service.name,
|
||||
visionCategoryId: visionCategory?.id ?? null,
|
||||
downgraded: downgradeEventId != null,
|
||||
},
|
||||
vision,
|
||||
);
|
||||
}
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
orderId: row.id,
|
||||
action: "created",
|
||||
categoryName: row.categoryName,
|
||||
serviceName: row.serviceName,
|
||||
priceMinor: row.priceMinor,
|
||||
currency,
|
||||
payAt: row.payAt,
|
||||
operator: input.actor,
|
||||
},
|
||||
});
|
||||
return this.#view(row);
|
||||
}
|
||||
|
||||
/** The wash is finished: apply the site's sponsorship program to the parking session
|
||||
* (if one is configured and active), then — for a bay order already paid — settle
|
||||
* the parking session so the exit reader opens. */
|
||||
async markDone(id: string, actor: string): Promise<CarwashOrderView> {
|
||||
const r = this.#row(id);
|
||||
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||
if (r.status === "done") throw new CarwashError(409, "order is already done");
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let validationEventId: string | null = null;
|
||||
// Wash context for the wash-only discount modes: the WASH WINDOW in minutes — from
|
||||
// the order's intake to now (= done) — and the order's frozen price. NOT the time
|
||||
// since entry: a car parked for hours before it asks for a wash still pays for those
|
||||
// hours (found 2026-09-05 on a long-open ticket that would have been fully comped).
|
||||
// The credit lands at the start of the billed period (that is how timeCredit
|
||||
// folds), so for a flat tariff the money is identical; a stepped/daily-cap tariff
|
||||
// may differ by an increment. See applyValidation().
|
||||
const washMinutes = Math.max(0, Math.ceil((Date.now() - Date.parse(r.createdAt)) / 60_000));
|
||||
const applied = await applyValidation(this.#db, this.#log, {
|
||||
programId: CARWASH_PROGRAM_ID,
|
||||
identity: r.identity,
|
||||
actor,
|
||||
wash: { washMinutes, priceMinor: r.priceMinor },
|
||||
});
|
||||
if (applied.ok) validationEventId = applied.eventId;
|
||||
else if (applied.status !== 404 && !/already applied/.test(applied.error)) {
|
||||
// A real refusal (session closed, daily cap …) — the wash is still done; the
|
||||
// customer simply gets no sponsorship. Keep it visible in the log.
|
||||
this.#logger.warn(`carwash sponsorship not applied for ${r.identity}: ${applied.error}`);
|
||||
}
|
||||
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ status: "done", doneAt: now, doneBy: actor, validationEventId })
|
||||
.where(eq(carwashOrders.id, id))
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
orderId: id,
|
||||
action: "done",
|
||||
categoryName: r.categoryName,
|
||||
serviceName: r.serviceName,
|
||||
priceMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
payAt: r.payAt,
|
||||
...(validationEventId ? { validationEventId } : {}),
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
const updated = this.#row(id);
|
||||
if (updated.payAt === "bay" && updated.paidAt != null) await this.#settleParkingIfFree(updated, actor);
|
||||
return this.#view(updated);
|
||||
}
|
||||
|
||||
/** Money taken AT THE BAY. Needs an open CARWASH shift (it is the wash operator's
|
||||
* drawer money, never the booth's — wiki/concepts/shift.md "Tills"); signs a
|
||||
* carwash_payment on that till; then, if the wash is also done, settles the
|
||||
* parking session. */
|
||||
async payAtBay(id: string, tender: Tender, actor: string): Promise<CarwashOrderView> {
|
||||
const r = this.#row(id);
|
||||
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||
if (r.payAt !== "bay") throw new CarwashError(409, "this order is paid at the booth", "pay_at_booth");
|
||||
if (r.paidAt != null) throw new CarwashError(409, "order is already paid");
|
||||
if (tender !== "cash" && tender !== "card") throw new CarwashError(400, "tender must be cash|card");
|
||||
this.#shift.requireOpenShift(CARWASH_TILL);
|
||||
|
||||
const ev = await this.#log.append({
|
||||
type: "carwash_payment",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
orderId: id,
|
||||
amountMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
tender,
|
||||
till: CARWASH_TILL,
|
||||
categoryName: r.categoryName,
|
||||
serviceName: r.serviceName,
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ paidAt: now, paidBy: actor, tender, paymentEventId: ev.id })
|
||||
.where(eq(carwashOrders.id, id))
|
||||
.run();
|
||||
const updated = this.#row(id);
|
||||
if (updated.status === "done") await this.#settleParkingIfFree(updated, actor, tender);
|
||||
return this.#view(updated);
|
||||
}
|
||||
|
||||
/** A bay-paid, done wash: if the sponsorship made the parking session zero-due, sign
|
||||
* the $0 parking payment now — that is what the exit READER checks (a validation
|
||||
* alone opens nothing; see exit-flow.ts). A remaining balance stays for the booth. */
|
||||
async #settleParkingIfFree(r: CarwashOrderRow, actor: string, tender: Tender = "cash"): Promise<void> {
|
||||
try {
|
||||
const s = this.#pay.lookup(r.identity);
|
||||
if (!s.open || s.subscription || s.paidAt != null) return;
|
||||
const q = this.#pay.quote(r.identity);
|
||||
if (q.amountMinor !== 0) return;
|
||||
await this.#pay.pay(r.identity, tender);
|
||||
this.#logger.info(`carwash: parking session ${r.identity} settled at zero after bay payment (by ${actor})`);
|
||||
} catch (err) {
|
||||
this.#logger.warn(`carwash: could not settle parking for ${r.identity}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async voidOrder(id: string, reason: string, actor: string): Promise<CarwashOrderView> {
|
||||
const r = this.#row(id);
|
||||
if (r.status === "void") throw new CarwashError(409, "order is already void");
|
||||
if (r.paidAt != null) throw new CarwashError(409, "a paid order cannot be voided", "paid");
|
||||
const now = new Date().toISOString();
|
||||
// Take back the sponsorship if it is still live (not consumed by a payment).
|
||||
if (r.validationEventId) {
|
||||
const live = liveValidations(this.#db, r.identity).find((v) => v.eventId === r.validationEventId);
|
||||
if (live) {
|
||||
await this.#log.append({
|
||||
type: "validation",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
refId: r.validationEventId,
|
||||
programId: live.programId,
|
||||
programLabel: live.label,
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ status: "void", voidAt: now, voidBy: actor, voidReason: reason || null })
|
||||
.where(eq(carwashOrders.id, id))
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
orderId: id,
|
||||
action: "void",
|
||||
categoryName: r.categoryName,
|
||||
serviceName: r.serviceName,
|
||||
priceMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
payAt: r.payAt,
|
||||
reason: reason || undefined,
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
return this.#view(this.#row(id));
|
||||
}
|
||||
|
||||
// --- Booth settlement hook ------------------------------------------------------
|
||||
|
||||
/** Orders with payAt = "booth" ride the parking payment as charge lines; the core
|
||||
* calls back after the payment is signed so they are marked paid. Off = no lines. */
|
||||
chargeProvider(): ChargeProvider {
|
||||
return {
|
||||
lines: (identity) => {
|
||||
if (!this.#enabled()) return [];
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.where(and(eq(carwashOrders.identity, identity), eq(carwashOrders.payAt, "booth"), isNull(carwashOrders.paidAt)))
|
||||
.all()
|
||||
.filter((r) => r.status !== "void")
|
||||
.map((r) => ({
|
||||
module: "carwash" as const,
|
||||
ref: r.id,
|
||||
label: `Lavazh — ${r.categoryName} · ${r.serviceName}`,
|
||||
amountMinor: r.priceMinor,
|
||||
}));
|
||||
},
|
||||
onPaid: async (_identity, lines, payment) => {
|
||||
const now = new Date().toISOString();
|
||||
for (const l of lines) {
|
||||
if (l.module !== "carwash") continue;
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ paidAt: now, paidBy: payment.operator ?? "booth", tender: payment.tender, paymentEventId: payment.eventId })
|
||||
.where(and(eq(carwashOrders.id, l.ref), isNull(carwashOrders.paidAt)))
|
||||
.run();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Type guard for the void body etc. */
|
||||
export function isPayAt(v: unknown): v is CarWashPayAt {
|
||||
return typeof v === "string" && (CARWASH_PAY_AT as readonly string[]).includes(v);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import type { PayStation } from "../pay-station.js";
|
||||
import type { ShiftService } from "../shift-service.js";
|
||||
import { effectiveModulesFor } from "../modules.js";
|
||||
import { carwashModule } from "./carwash/index.js";
|
||||
import { validationModule } from "./validation/index.js";
|
||||
|
||||
// The server-side module registry. A module's routes live in its own folder
|
||||
// (apps/server/src/modules/<id>/index.ts) and are registered by iterating
|
||||
// @parking/shared's MODULES — so adding a module is one manifest entry + one folder +
|
||||
// one line in SERVER_MODULES below, with nothing else in the core touched
|
||||
// (wiki/decisions/venue-modules.md, "A module = a manifest + three folders").
|
||||
//
|
||||
// `parking` is registered in the manifest but has NO folder yet: its routes are still
|
||||
// the flat list in server.ts. That is deliberate — the seam is drawn, the code moves
|
||||
// across it subsystem by subsystem as each is touched, not in one big move.
|
||||
|
||||
/** What the core hands a module at registration. Modules reach the core ONLY through
|
||||
* these (never by importing another module): the DB, the signed ledger, the booth
|
||||
* settlement (to fold charges in / settle a session — PayStation.registerChargeProvider,
|
||||
* quote, pay) and the shift service (money needs an open shift). */
|
||||
export interface ServerModuleDeps {
|
||||
db: Db;
|
||||
eventLog: EventLog;
|
||||
payStation: PayStation;
|
||||
shiftService: ShiftService;
|
||||
}
|
||||
|
||||
export interface ServerModule {
|
||||
id: ModuleId;
|
||||
register(app: FastifyInstance, deps: ServerModuleDeps): Promise<void>;
|
||||
}
|
||||
|
||||
const SERVER_MODULES: Partial<Record<ModuleId, ServerModule>> = {
|
||||
validation: validationModule,
|
||||
carwash: carwashModule,
|
||||
};
|
||||
|
||||
/** Register every folder-based module in registry order, then log what this site
|
||||
* is entitled to / has effective, so a "why is X missing" question is answerable
|
||||
* from the container log alone. */
|
||||
export async function registerModules(app: FastifyInstance, deps: ServerModuleDeps): Promise<void> {
|
||||
for (const manifest of MODULES) {
|
||||
const impl = SERVER_MODULES[manifest.id];
|
||||
if (impl) {
|
||||
if (impl.id !== manifest.id) throw new Error(`module registry mismatch: ${impl.id} registered under ${manifest.id}`);
|
||||
await impl.register(app, deps);
|
||||
}
|
||||
}
|
||||
const { entitled, unknown } = parseEntitledModules(process.env.MODULES_ENTITLED);
|
||||
if (unknown.length > 0) {
|
||||
app.log.warn({ unknown }, "MODULES_ENTITLED names unknown module ids — ignored");
|
||||
}
|
||||
app.log.info(
|
||||
{ entitled, effective: effectiveModulesFor(deps.db) },
|
||||
"venue modules (entitled = MODULES_ENTITLED env; effective = entitled ∩ site activation)",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { validationRoutes } from "../../routes/validations.js";
|
||||
import type { ServerModule } from "../index.js";
|
||||
|
||||
// Merchant-scan ticket validation as a venue module. Kept for the Bar until a Bar
|
||||
// module absorbs it (wiki/decisions/venue-modules.md, decision 1). The routes
|
||||
// themselves still live in routes/validations.ts (unchanged location, now guarded by
|
||||
// requireModule("validation")); this folder is the registry hook.
|
||||
export const validationModule: ServerModule = {
|
||||
id: "validation",
|
||||
async register(app, { db, eventLog }) {
|
||||
await validationRoutes(app, db, eventLog);
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
||||
import { BOOTH_TILL, priceSession, type ChargeLine, 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:
|
||||
@@ -28,6 +29,18 @@ export class NoTariffError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A module that folds its own charges into a booth settlement (venue-modules.md):
|
||||
* `lines(identity)` returns the open charges for the session (e.g. wash orders with
|
||||
* payAt = "booth"); after the `payment` is signed, `onPaid` lets the module mark them
|
||||
* settled. Registered by the module at boot (registerChargeProvider) — PayStation
|
||||
* never imports a module.
|
||||
*/
|
||||
export interface ChargeProvider {
|
||||
lines(identity: string): ChargeLine[];
|
||||
onPaid(identity: string, lines: ChargeLine[], payment: { eventId: string; tender: Tender; operator?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
readonly identity: string;
|
||||
/** Vehicle entry time (the session's original entry; for display/audit). */
|
||||
@@ -38,8 +51,23 @@ export interface Quote {
|
||||
* 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]. */
|
||||
/** Amount owed now: the parking fee for [periodStart → now] NET of merchant
|
||||
* validations, PLUS any module charge lines (a wash paid at the booth). */
|
||||
readonly amountMinor: number;
|
||||
/** The parking-only net (amountMinor − chargesMinor). */
|
||||
readonly parkingMinor: number;
|
||||
/** Non-parking charges folded in by modules (see ChargeProvider). */
|
||||
readonly chargeLines: ChargeLine[];
|
||||
readonly chargesMinor: 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;
|
||||
@@ -98,6 +126,11 @@ export interface SessionLookup {
|
||||
/** 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. */
|
||||
@@ -112,12 +145,22 @@ export interface SessionLookup {
|
||||
/** 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[];
|
||||
/** Module charge lines folded into `amountMinor` (e.g. a wash paid at the booth). */
|
||||
readonly chargeLines: ChargeLine[];
|
||||
readonly chargesMinor: number | null;
|
||||
}
|
||||
|
||||
export class PayStation {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #chargeProviders: ChargeProvider[] = [];
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
@@ -125,6 +168,30 @@ export class PayStation {
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Let a module fold its charges into booth settlements (see ChargeProvider). */
|
||||
registerChargeProvider(p: ChargeProvider): void {
|
||||
this.#chargeProviders.push(p);
|
||||
}
|
||||
|
||||
/** The currency of the tariff in force right now (null = none published). Modules
|
||||
* price their own goods in the same money the booth takes. */
|
||||
activeCurrency(): string | null {
|
||||
return this.#tariffVersionFor(new Date().toISOString())?.currency ?? null;
|
||||
}
|
||||
|
||||
#chargeLines(identity: string): ChargeLine[] {
|
||||
const out: ChargeLine[] = [];
|
||||
for (const p of this.#chargeProviders) {
|
||||
try {
|
||||
out.push(...p.lines(identity));
|
||||
} catch (err) {
|
||||
// A module's fault must never block a parking settlement — log and price without it.
|
||||
this.#logger.error(`charge provider failed for ${identity}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -150,19 +217,33 @@ export class PayStation {
|
||||
// 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,
|
||||
);
|
||||
const chargeLines = this.#chargeLines(identity);
|
||||
const chargesMinor = chargeLines.reduce((sum, l) => sum + l.amountMinor, 0);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
periodStart: p.periodStart,
|
||||
amountMinor: p.amountMinor,
|
||||
amountMinor: p.amountMinor + chargesMinor,
|
||||
parkingMinor: p.amountMinor,
|
||||
chargeLines,
|
||||
chargesMinor,
|
||||
grossMinor: p.grossMinor,
|
||||
discountMinor: p.discountMinor,
|
||||
validationLines: p.validationLines,
|
||||
validationIds: validations.map((v) => v.eventId),
|
||||
overstay: p.overstay,
|
||||
currency: tv.currency,
|
||||
tariffVersionId: tv.id,
|
||||
@@ -216,6 +297,7 @@ export class PayStation {
|
||||
amountMinor,
|
||||
currency: subWindow.currency ?? undefined,
|
||||
tender,
|
||||
till: BOOTH_TILL,
|
||||
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
|
||||
subscriptionWindowCharge: true,
|
||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
|
||||
@@ -228,7 +310,7 @@ export class PayStation {
|
||||
const q = this.quote(identity);
|
||||
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||
|
||||
await this.#log.append({
|
||||
const paymentEvent = await this.#log.append({
|
||||
type: "payment",
|
||||
source: "manual",
|
||||
identity,
|
||||
@@ -237,10 +319,34 @@ export class PayStation {
|
||||
amountMinor,
|
||||
currency: q.currency,
|
||||
tender,
|
||||
// Parking money is BOOTH money (a wash paid at the booth rides along as
|
||||
// chargeLines, so it is booth money too). See wiki/concepts/shift.md "Tills".
|
||||
till: BOOTH_TILL,
|
||||
tariffVersionId: q.tariffVersionId,
|
||||
// Module charges (e.g. a wash paid at the booth): frozen as lines so the
|
||||
// receipt reproduces and reporting can split parking from the rest.
|
||||
...(q.chargeLines.length
|
||||
? {
|
||||
chargeLines: q.chargeLines.map((l) => ({ ...l })),
|
||||
chargesMinor: q.chargesMinor,
|
||||
parkingMinor: q.parkingMinor,
|
||||
}
|
||||
: {}),
|
||||
// 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 } : {}),
|
||||
},
|
||||
});
|
||||
@@ -252,6 +358,17 @@ export class PayStation {
|
||||
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Let each module mark the charge lines it contributed as settled by this payment.
|
||||
if (q.chargeLines.length) {
|
||||
for (const p of this.#chargeProviders) {
|
||||
try {
|
||||
await p.onPaid(identity, q.chargeLines, { eventId: paymentEvent.id, tender });
|
||||
} catch (err) {
|
||||
this.#logger.error(`charge provider onPaid failed for ${identity}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
||||
return { amountMinor, currency: q.currency };
|
||||
}
|
||||
@@ -274,8 +391,11 @@ export class PayStation {
|
||||
if (!entry) {
|
||||
return {
|
||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: 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: [],
|
||||
chargeLines: [], chargesMinor: null,
|
||||
};
|
||||
}
|
||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||
@@ -289,11 +409,17 @@ export class PayStation {
|
||||
|
||||
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 };
|
||||
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 =
|
||||
@@ -307,11 +433,21 @@ export class PayStation {
|
||||
// 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[] = [];
|
||||
let chargeLines: ChargeLine[] = [];
|
||||
let chargesMinor: number | null = null;
|
||||
if (open && !isSubscription) {
|
||||
try {
|
||||
const q = this.quote(id);
|
||||
amountMinor = q.amountMinor;
|
||||
currency = q.currency;
|
||||
grossMinor = q.grossMinor;
|
||||
discountMinor = q.discountMinor;
|
||||
validationLines = q.validationLines;
|
||||
chargeLines = q.chargeLines;
|
||||
chargesMinor = q.chargesMinor;
|
||||
} catch {
|
||||
/* no active tariff — leave null; modal shows session without a price */
|
||||
}
|
||||
@@ -328,10 +464,12 @@ export class PayStation {
|
||||
return {
|
||||
identity: id, found: true, open,
|
||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
||||
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,
|
||||
chargeLines, chargesMinor,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||||
import { isNormBox, isVehicleClass, type VehicleRead } from "@parking/shared";
|
||||
|
||||
// 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
|
||||
@@ -23,6 +24,38 @@ interface ReadDetail {
|
||||
plate?: string;
|
||||
confidence?: number;
|
||||
direction?: string;
|
||||
bodyType?: string;
|
||||
bodyConfidence?: number;
|
||||
snapshotId?: string;
|
||||
vehicleBox?: unknown;
|
||||
plateBox?: unknown;
|
||||
}
|
||||
|
||||
/** The advisory VEHICLE read (body type) for a session — the same stream and the same
|
||||
* preference as the plate (entry over exit, newest first). Null when vision never
|
||||
* classified the vehicle. See venue-modules.md §Vehicle category from vision. */
|
||||
export function vehicleForIdentity(db: Db, identity: string): VehicleRead | 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();
|
||||
let fallback: VehicleRead | null = null;
|
||||
for (const r of rows) {
|
||||
const d = (r.detail ?? {}) as ReadDetail;
|
||||
if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue;
|
||||
const v: VehicleRead = {
|
||||
bodyType: d.bodyType,
|
||||
confidence: d.bodyConfidence,
|
||||
snapshotId: d.snapshotId ?? null,
|
||||
box: isNormBox(d.vehicleBox) ? d.vehicleBox : null,
|
||||
plateBox: isNormBox(d.plateBox) ? d.plateBox : null,
|
||||
};
|
||||
if (d.direction === "entry") return v;
|
||||
if (!fallback) fallback = v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { devices, eq, type Db } from "@parking/db";
|
||||
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";
|
||||
|
||||
@@ -17,6 +19,22 @@ import { relayForDevice } from "./device-resolve.js";
|
||||
// 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;
|
||||
@@ -45,6 +63,20 @@ export class ReadDispatcher {
|
||||
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.
|
||||
@@ -53,4 +85,39 @@ export class ReadDispatcher {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ledgerEvents,
|
||||
sessions,
|
||||
siteConfig,
|
||||
subscriptions,
|
||||
tariffVersions,
|
||||
tariffs,
|
||||
@@ -46,8 +48,13 @@ export interface SeriesPoint {
|
||||
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 {
|
||||
@@ -67,6 +74,10 @@ export interface ReportTotals {
|
||||
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 {
|
||||
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
|
||||
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;
|
||||
@@ -89,25 +107,43 @@ export interface ReportSummary {
|
||||
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). */
|
||||
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
|
||||
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,
|
||||
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
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, payments: 0 };
|
||||
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
|
||||
seriesMap.set(label, p);
|
||||
}
|
||||
return p;
|
||||
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||
totals.entries++;
|
||||
p.entries++;
|
||||
const h = localParts(row.occurredAt, tz).h;
|
||||
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
||||
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;
|
||||
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
totals.revenueMinor += amt;
|
||||
p.payments++;
|
||||
p.revenueMinor += amt;
|
||||
if (pl.tender === "card") totals.cardMinor += amt;
|
||||
else totals.cashMinor += 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;
|
||||
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
|
||||
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) {
|
||||
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
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 };
|
||||
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
},
|
||||
series,
|
||||
entriesByHour,
|
||||
entriesByDowHour,
|
||||
stayHistogram,
|
||||
occupancyStart,
|
||||
capacity,
|
||||
subscriptions: subStats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, roles, users, type Db } from "@parking/db";
|
||||
import { effectiveModulesFor } from "../modules.js";
|
||||
import {
|
||||
clearAuthCookies,
|
||||
newCsrfToken,
|
||||
@@ -29,6 +30,13 @@ 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
|
||||
@@ -58,7 +66,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
|
||||
|
||||
/** 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. */
|
||||
* permissions are the source of truth.
|
||||
*
|
||||
* `csrf`, when passed, echoes the SAME value already sent as the readable
|
||||
* parking_csrf cookie — not a new secret, just a second channel to learn it.
|
||||
* The desktop shell needs this: tauri-plugin-http's fetch() runs through
|
||||
* Rust's reqwest, which keeps its own cookie jar separate from the webview,
|
||||
* so document.cookie on the tauri://localhost page never sees a cookie set
|
||||
* on a plugin-routed response (open upstream bug, tauri-apps/tauri#13045).
|
||||
* The cookie itself IS still sent back to the server by reqwest on
|
||||
* subsequent requests — only the *client-side read* is broken — so
|
||||
* api.ts's desktop path stashes this body value in memory instead of
|
||||
* reading document.cookie, and echoes it in X-CSRF-Token exactly as the
|
||||
* browser path echoes the cookie. See lib/api.ts and assertCsrf() in
|
||||
* ../auth.ts (unchanged — this never touches verification, only how the
|
||||
* desktop client learns what to send). */
|
||||
function sessionView(
|
||||
db: Db,
|
||||
user: {
|
||||
@@ -67,9 +89,11 @@ function sessionView(
|
||||
roleId: string;
|
||||
language: string;
|
||||
theme: string;
|
||||
fontScale: number;
|
||||
fullName?: string | null;
|
||||
email?: string | null;
|
||||
},
|
||||
csrf?: string,
|
||||
) {
|
||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||
const permissions = [...permissionsFor(user.roleId)];
|
||||
@@ -81,8 +105,13 @@ function sessionView(
|
||||
permissions,
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
fontScale: user.fontScale,
|
||||
fullName: user.fullName ?? null,
|
||||
email: user.email ?? null,
|
||||
// Effective venue modules (entitled ∩ activated) so the SPA can hide nav/routes
|
||||
// on first paint. The server still enforces via requireModule — this is display.
|
||||
modules: effectiveModulesFor(db),
|
||||
...(csrf ? { csrfToken: csrf } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +146,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
setAuthCookies(reply, token, csrf);
|
||||
// `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);
|
||||
return sessionView(db, user, csrf);
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", async (_req, reply) => {
|
||||
@@ -137,7 +166,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
clearAuthCookies(reply);
|
||||
return reply.code(401).send({ error: "session no longer valid" });
|
||||
}
|
||||
return sessionView(db, row);
|
||||
// req.user.csrf is the value bound into the JWT at login (see assertCsrf in
|
||||
// ../auth.ts) — same value as the cookie, re-surfaced for the desktop path.
|
||||
return sessionView(db, row, req.user.csrf);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -171,6 +202,23 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// 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 }>(
|
||||
|
||||
@@ -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,126 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { TillId } from "@parking/shared";
|
||||
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { parseTill, requireTill, tillsReadableBy } from "../modules.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 on a till.
|
||||
// Guard = the till's `cash` (booth drawer:create,
|
||||
// wash carwash:cash).
|
||||
// - GET /api/drawer/movements: list with review status, over the tills the role may
|
||||
// read (own movements); reviewers (drawer:review) see all
|
||||
// tills + all operators and can filter status.
|
||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||
// - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read).
|
||||
// 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.
|
||||
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each
|
||||
// desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions
|
||||
// matrix").
|
||||
|
||||
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;
|
||||
/** Which drawer (default: the booth). */
|
||||
till?: 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;
|
||||
/** Filter to one till; absent = every till the role may read (reviewers: every till). */
|
||||
till?: string;
|
||||
}
|
||||
|
||||
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||
const reviewGuard = requirePermission("drawer:review");
|
||||
|
||||
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
||||
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, 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,
|
||||
till: req.till!,
|
||||
});
|
||||
} 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 on
|
||||
// the tills they may read; a reviewer sees ALL and may filter by status (the pending
|
||||
// review queue).
|
||||
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => {
|
||||
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||
const readable = tillsReadableBy(db, req.user.roleId);
|
||||
if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||
const q = req.query ?? {};
|
||||
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||
let tills: TillId[] | undefined = canReview ? undefined : readable;
|
||||
if (q.till?.trim()) {
|
||||
const parsed = parseTill(db, q.till.trim());
|
||||
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||
if (!canReview && !readable.includes(parsed)) {
|
||||
return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed });
|
||||
}
|
||||
tills = [parsed];
|
||||
}
|
||||
const operator = canReview ? undefined : req.user.username;
|
||||
const movements = tills
|
||||
? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
||||
: shift.movementsWithStatus({ operator, status });
|
||||
return { movements, scope: canReview ? "all" : "self" };
|
||||
});
|
||||
|
||||
// A till's physical drawer balance now. Same visibility as the open shift's X-report
|
||||
// (the till's read guard) — a drawer is a shared till, not per-operator data.
|
||||
app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
|
||||
till: req.till!,
|
||||
...shift.drawerBalance(req.till!),
|
||||
}));
|
||||
|
||||
// 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,7 +1,8 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db";
|
||||
import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared";
|
||||
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { effectiveModulesFor } from "../modules.js";
|
||||
import { enrichEvents } from "../event-enrich.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
|
||||
@@ -15,25 +16,59 @@ export async function eventRoutes(
|
||||
db: Db,
|
||||
eventLog: EventLog,
|
||||
): Promise<void> {
|
||||
// Reading the log (the audit trail).
|
||||
const guard = requirePermission("event:read");
|
||||
// Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it
|
||||
// may still hold a module's feed permission (a wash operator's `carwash:read`) and
|
||||
// then reads ONLY that module's event types — the same rule the live socket applies
|
||||
// (feedPermissionFor; venue-modules.md §Permissions matrix, move 3).
|
||||
|
||||
/** The event types a role may read, or null for "everything" (event:read). Empty =
|
||||
* the role reads nothing → 403 at the route. */
|
||||
function readableTypes(roleId: string): LedgerEventType[] | null {
|
||||
if (roleHasPermissions(roleId, ["event:read"])) return null;
|
||||
const effective = effectiveModulesFor(db);
|
||||
const out: LedgerEventType[] = [];
|
||||
for (const m of MODULES) {
|
||||
if (!m.feedPermission || !effective.includes(m.id)) continue;
|
||||
if (roleHasPermissions(roleId, [m.feedPermission])) out.push(...m.ledgerEventTypes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** SQL form of the shared `tillOfEvent` rule: the payload's `till`, else the till of
|
||||
* the module owning the event type, else the booth. Computed in the query so the
|
||||
* page limit applies AFTER the till filter (a shift's window can hold thousands of
|
||||
* device events). */
|
||||
const tillExpr = (() => {
|
||||
const cases = MODULES.filter((m) => m.till && m.till !== BOOTH_TILL && m.ledgerEventTypes.length > 0).map(
|
||||
(m) => sql`when ${ledgerEvents.type} in (${sql.join(m.ledgerEventTypes.map((t) => sql`${t}`), sql`, `)}) then ${m.till}`,
|
||||
);
|
||||
return sql`coalesce(json_extract(${ledgerEvents.payload}, '$.till'), case ${sql.join(cases, sql` `)} else ${BOOTH_TILL} end)`;
|
||||
})();
|
||||
|
||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||
// 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 } }>(
|
||||
// (logs are per-shift, not all history). An optional `till` keeps only that till's
|
||||
// activity (tillOfEvent) — a booth shift's log no longer shows the wash desk's, and
|
||||
// vice versa. See wiki/concepts/shift.md §Tills.
|
||||
app.get<{ Querystring: { limit?: string; since?: string; until?: string; till?: string } }>(
|
||||
"/api/events",
|
||||
{ preHandler: guard },
|
||||
async (req) => {
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const types = readableTypes(req.user?.roleId ?? "");
|
||||
if (types && types.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||
const since = (req.query.since ?? "").trim();
|
||||
const until = (req.query.until ?? "").trim();
|
||||
const till = (req.query.till ?? "").trim();
|
||||
if (till && !isTillId(till)) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||
const bounds = [
|
||||
since ? gte(ledgerEvents.occurredAt, since) : undefined,
|
||||
until ? lte(ledgerEvents.occurredAt, until) : undefined,
|
||||
till ? sql`${tillExpr} = ${till}` : undefined,
|
||||
types ? inArray(ledgerEvents.type, types) : undefined,
|
||||
].filter(Boolean);
|
||||
const rows = db
|
||||
.select()
|
||||
|
||||
@@ -30,6 +30,9 @@ interface PayBody {
|
||||
}
|
||||
interface ExitBody {
|
||||
identity: string;
|
||||
/** Operator consciously releases a suspected plate-swap exit (re-submit after the
|
||||
* first call returned status "swap_suspected"). Signs an attributed override anomaly. */
|
||||
override?: boolean;
|
||||
}
|
||||
interface VoucherBody {
|
||||
identity: string;
|
||||
@@ -109,8 +112,17 @@ export async function payRoutes(
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
const res = await exitFlow.exitForBooth(identity);
|
||||
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
|
||||
const res = await exitFlow.exitForBooth(identity, {
|
||||
override: req.body?.override === true,
|
||||
operator: req.user?.username,
|
||||
});
|
||||
// A suspected plate-swap returns the full detail so the modal can warn + offer override.
|
||||
if (!res.ok) {
|
||||
if (res.status === "swap_suspected") {
|
||||
return reply.code(409).send({ error: res.reason, status: res.status, plate: res.plate, otherIdentity: res.otherIdentity, otherEnteredAt: res.otherEnteredAt });
|
||||
}
|
||||
return reply.code(409).send({ error: res.reason, status: res.status });
|
||||
}
|
||||
return reply.code(200).send(res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ledgerEvents, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// PUT /api/site-config/presence-bypass toggles the entry presence-gate bypass. It's a
|
||||
// DEDICATED, SIGNED endpoint: each signal that actually changes appends a config_change to
|
||||
// the ledger (attributed), and it persists to site_config. Admin-only.
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
const configChanges = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
|
||||
|
||||
async function put(body: unknown, auth: { cookie: string; csrf: string }) {
|
||||
return app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/site-config/presence-bypass",
|
||||
headers: { cookie: auth.cookie, "x-csrf-token": auth.csrf },
|
||||
payload: body as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
describe("PUT /api/site-config/presence-bypass", () => {
|
||||
it("is admin-only: a non-site:update user is 403", async () => {
|
||||
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||
const auth = await login(app, "op", "pw");
|
||||
const res = await put({ camera: true }, auth);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("enabling a signal persists it AND signs an attributed config_change", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
|
||||
const res = await put({ camera: true }, auth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ bypassPresenceCamera: true, bypassPresenceRadar: false });
|
||||
|
||||
const changes = configChanges();
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].source).toBe("manual");
|
||||
expect(changes[0].signature.length).toBeGreaterThan(0);
|
||||
expect(changes[0].payload).toMatchObject({
|
||||
setting: "entryPresenceBypass.camera",
|
||||
value: true,
|
||||
prev: false,
|
||||
operator: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("a no-op toggle (already in that state) signs nothing", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
await put({ camera: true }, auth); // 1st: on → 1 event
|
||||
await put({ camera: true }, auth); // 2nd: still on → no new event
|
||||
expect(configChanges()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("disabling signs the off transition too (auditable both ways)", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
await put({ radar: true }, auth);
|
||||
await put({ radar: false }, auth);
|
||||
const changes = configChanges();
|
||||
expect(changes).toHaveLength(2);
|
||||
expect(changes[1].payload).toMatchObject({ setting: "entryPresenceBypass.radar", value: false, prev: true });
|
||||
});
|
||||
|
||||
it("rejects a non-boolean and an empty body", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
expect((await put({ camera: "yes" }, auth)).statusCode).toBe(400);
|
||||
expect((await put({}, auth)).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -128,3 +128,57 @@ describe("PUT /api/auth/password (self-service)", () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/font-scale (self-service)", () => {
|
||||
it("persists a valid scale and returns it on the next session", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "f1", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/font-scale",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fontScale: 120 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().fontScale).toBe(120);
|
||||
// Persisted to the caller's row…
|
||||
expect(db.select().from(users).where(eq(users.username, "f1")).get()?.fontScale).toBe(120);
|
||||
// …and surfaced on /me (the session bootstrap).
|
||||
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||
expect(me.json().fontScale).toBe(120);
|
||||
});
|
||||
|
||||
it("clamps + snaps out-of-band / off-step values", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "f2", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const tooBig = await app.inject({
|
||||
method: "PUT", url: "/api/auth/font-scale",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fontScale: 999 },
|
||||
});
|
||||
expect(tooBig.json().fontScale).toBe(160); // clamped to max
|
||||
const offStep = await app.inject({
|
||||
method: "PUT", url: "/api/auth/font-scale",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fontScale: 113 },
|
||||
});
|
||||
expect(offStep.json().fontScale).toBe(110); // snapped to the 10-step
|
||||
});
|
||||
|
||||
it("rejects a non-numeric scale (400)", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "f3", roleId: "viewer", permissions: [] });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/auth/font-scale",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { fontScale: "big" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("defaults to 100 for a fresh user", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "f4", roleId: "viewer", permissions: [] });
|
||||
const { cookie } = await login(app, username, password);
|
||||
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||
expect(me.json().fontScale).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import Fastify from "fastify";
|
||||
import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
||||
import { devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { qrReaderRoutes, splitChannel } from "./qr-reader.js";
|
||||
import { CredentialCapture } from "../credential-capture.js";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "../device-events.js";
|
||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||
|
||||
// CHANNEL TAGGING (2026-07-04): the DT-008's "QRCode Output Prefix" / "Card Output
|
||||
// Prefix" (vendor tool) mark which engine produced a push — Q: = optical, K: = RF.
|
||||
// The route strips the prefix, tags the read's confirmed channel, and enrollment
|
||||
// capture stores the BARE value. Unprefixed reads stay the legacy untagged shape so
|
||||
// an unconfigured reader keeps working. These tests pin the route-side contract;
|
||||
// the match-side enforcement is pinned in ../subscription-channel.test.ts.
|
||||
|
||||
const SERIAL = "H05MA5B0";
|
||||
const READER_ID = "reader-exit";
|
||||
|
||||
let db: Db;
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let capture: CredentialCapture;
|
||||
let seen: DeviceReadEvent[];
|
||||
|
||||
/** Dispatcher stub: records the event the route built, always rejects. */
|
||||
const fakeDispatcher = {
|
||||
dispatch: async (e: DeviceReadEvent): Promise<ReadOutcome> => {
|
||||
seen.push(e);
|
||||
return { accepted: false, reason: "test" };
|
||||
},
|
||||
} as unknown as ReadDispatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({
|
||||
id: READER_ID,
|
||||
category: "reader",
|
||||
driverId: "dingtian-qr-reader",
|
||||
config: { serial: SERIAL },
|
||||
enabled: true,
|
||||
}).run();
|
||||
seen = [];
|
||||
capture = new CredentialCapture();
|
||||
app = Fastify({ logger: false });
|
||||
await qrReaderRoutes(app as never, db, fakeDispatcher, capture);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const scan = (cardid: string) =>
|
||||
app.inject({ method: "GET", url: `/qa/mcardsea.php?cardid=${encodeURIComponent(cardid)}&cjihao=${SERIAL}&mjihao=1&status=10` });
|
||||
|
||||
describe("splitChannel", () => {
|
||||
it("K: prefix → bare value, kind card, channel rf", () => {
|
||||
expect(splitChannel("K:86A158")).toEqual({ value: "86A158", kind: "card", channel: "rf" });
|
||||
});
|
||||
it("Q: prefix → bare value, kind qr, channel optical", () => {
|
||||
expect(splitChannel("Q:12345678901")).toEqual({ value: "12345678901", kind: "qr", channel: "optical" });
|
||||
});
|
||||
it("no prefix → value untouched, legacy untagged qr", () => {
|
||||
expect(splitChannel("86A158")).toEqual({ value: "86A158", kind: "qr" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("qr-reader route channel tagging", () => {
|
||||
it("card-prefixed push dispatches a stripped, rf-tagged read", async () => {
|
||||
const res = await scan("K:86A158");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toMatchObject({ value: "86A158", kind: "card", channel: "rf", deviceId: READER_ID });
|
||||
});
|
||||
|
||||
it("qr-prefixed push dispatches a stripped, optical-tagged read", async () => {
|
||||
await scan("Q:00000000000");
|
||||
expect(seen[0]).toMatchObject({ value: "00000000000", kind: "qr", channel: "optical" });
|
||||
});
|
||||
|
||||
it("unprefixed push stays legacy: kind qr, no channel", async () => {
|
||||
await scan("86A158");
|
||||
expect(seen[0]).toMatchObject({ value: "86A158", kind: "qr" });
|
||||
expect(seen[0].channel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a bare prefix (empty value after strip) dispatches nothing", async () => {
|
||||
await scan("K:");
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("enrollment capture stores the BARE value, not the prefixed one", async () => {
|
||||
capture.arm(READER_ID);
|
||||
const res = await scan("K:86A158");
|
||||
expect(seen).toHaveLength(0); // intercepted — never dispatched to the access flow
|
||||
const state = capture.state();
|
||||
expect(state.status).toBe("captured");
|
||||
if (state.status === "captured") expect(state.value).toBe("86A158");
|
||||
// Beeps "ok" so the operator knows the card was read.
|
||||
expect(res.json().data[0].status).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,10 @@ import type { DeviceReadEvent } from "../device-events.js";
|
||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||
import type { CredentialCapture } from "../credential-capture.js";
|
||||
|
||||
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
||||
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||
// Dingtian DT-008 QR/RFID reader endpoint. The reader is configured (vendor tool) with
|
||||
// our host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
||||
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
|
||||
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/dingtian-dt008-reader.md.
|
||||
//
|
||||
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
||||
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
||||
@@ -27,6 +27,36 @@ interface ReaderQuery {
|
||||
time?: string;
|
||||
}
|
||||
|
||||
// ── CHANNEL TAGGING (2026-07-04) ────────────────────────────────────────────────
|
||||
// The DT-008 push carries one opaque `cardid` whether its OPTICAL engine decoded a
|
||||
// QR/barcode or its RF engine read a card — the server can't tell them apart. That
|
||||
// enabled a cheap clone: print a card's UID (often written on the card face) as a
|
||||
// barcode and the optical decode matches the RF credential. Fix: the vendor tool's
|
||||
// "QRCode Output Prefix" / "Card Output Prefix" are set to the markers below on every
|
||||
// reader; the route strips the prefix and tags the read's confirmed channel, and the
|
||||
// subscription match refuses a channel-mismatched credential. A read with NO prefix
|
||||
// stays the legacy untagged shape (kind "qr", channel undefined) so an unconfigured
|
||||
// reader keeps working — the enforcement only bites where prefixes are deployed.
|
||||
// ⚠️ Prefixes must MATCH the vendor tool; also FREEZE "Card Input format" (6H) — that
|
||||
// setting defines the UID shape we enroll. See wiki/entities/dingtian-dt008-reader.md.
|
||||
const QR_CHANNEL_PREFIX = process.env.READER_QR_PREFIX ?? "Q:";
|
||||
const CARD_CHANNEL_PREFIX = process.env.READER_CARD_PREFIX ?? "K:";
|
||||
|
||||
/** Split a raw pushed `cardid` into its bare value + confirmed channel (if prefixed). */
|
||||
export function splitChannel(raw: string): {
|
||||
value: string;
|
||||
kind: "qr" | "card";
|
||||
channel?: "optical" | "rf";
|
||||
} {
|
||||
if (CARD_CHANNEL_PREFIX.length > 0 && raw.startsWith(CARD_CHANNEL_PREFIX)) {
|
||||
return { value: raw.slice(CARD_CHANNEL_PREFIX.length), kind: "card", channel: "rf" };
|
||||
}
|
||||
if (QR_CHANNEL_PREFIX.length > 0 && raw.startsWith(QR_CHANNEL_PREFIX)) {
|
||||
return { value: raw.slice(QR_CHANNEL_PREFIX.length), kind: "qr", channel: "optical" };
|
||||
}
|
||||
return { value: raw, kind: "qr" }; // legacy: unprefixed reader, channel unknown
|
||||
}
|
||||
|
||||
export async function qrReaderRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
@@ -35,7 +65,7 @@ export async function qrReaderRoutes(
|
||||
): Promise<void> {
|
||||
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
|
||||
// enters when assigning the dingtian-qr-reader. Returns the row id, or null if no
|
||||
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||
const readerRowIdForSerial = (serial: string): string | null => {
|
||||
if (!serial) return null;
|
||||
@@ -52,9 +82,10 @@ export async function qrReaderRoutes(
|
||||
// drive output) once the socket CLOSES — every vendor demo replies
|
||||
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
||||
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
||||
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
|
||||
// See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md.
|
||||
reply.header("connection", "close");
|
||||
const cardid = (q.cardid ?? "").trim();
|
||||
const scan = splitChannel(cardid); // bare value + confirmed channel (if prefixed)
|
||||
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||
const serial = (q.cjihao ?? "").trim();
|
||||
|
||||
@@ -65,35 +96,37 @@ export async function qrReaderRoutes(
|
||||
const deviceId = matchedRowId ?? serial;
|
||||
|
||||
let accepted = false;
|
||||
if (cardid) {
|
||||
if (scan.value) {
|
||||
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
||||
// value for the subscription form and do NOT run the access flow (we must not
|
||||
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
||||
// Reads from the OTHER reader are untouched and dispatch normally below.
|
||||
if (capture.tryConsume(deviceId, cardid)) {
|
||||
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
|
||||
// Captured BARE (prefix stripped) so enrolled values match future stripped reads.
|
||||
if (capture.tryConsume(deviceId, scan.value)) {
|
||||
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""}`);
|
||||
accepted = true; // beep "ok" so the operator knows the card was read
|
||||
} else {
|
||||
const read: DeviceReadEvent = {
|
||||
driverId: "gee-qr-reader",
|
||||
driverId: "dingtian-qr-reader",
|
||||
deviceId,
|
||||
value: cardid,
|
||||
kind: "qr",
|
||||
value: scan.value,
|
||||
kind: scan.kind,
|
||||
...(scan.channel ? { channel: scan.channel } : {}),
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
const outcome = await dispatcher.dispatch(read);
|
||||
accepted = outcome.accepted;
|
||||
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
||||
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
|
||||
// the logs (e.g. an entry-side scan resolving to the exit relay).
|
||||
// it mapped to, the confirmed channel (if prefixed), and the verdict — so a
|
||||
// barrier/serial mismatch or a channel anomaly is visible in the logs.
|
||||
app.log.info(
|
||||
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
||||
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||
`card=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
||||
);
|
||||
} catch (err) {
|
||||
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
||||
app.log.error(`QR dispatch failed for ${scan.value}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
async (req, reply) => {
|
||||
const summary = reportSummary(db, parseQuery(req.query));
|
||||
const lines = [
|
||||
"bucket,entries,exits,payments,revenue",
|
||||
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
|
||||
...summary.series.map((p) =>
|
||||
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
||||
[
|
||||
p.bucket,
|
||||
p.entries,
|
||||
p.exits,
|
||||
p.payments,
|
||||
(p.revenueMinor / 100).toFixed(2),
|
||||
(p.cashMinor / 100).toFixed(2),
|
||||
(p.cardMinor / 100).toFixed(2),
|
||||
p.occupancyEnd,
|
||||
].join(","),
|
||||
),
|
||||
];
|
||||
reply
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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 { jobsBehind } from "@parking/shared";
|
||||
import { buildServer } from "../server.js";
|
||||
import { login, seedUser } from "../test-helpers.js";
|
||||
|
||||
// Roles are data composed from the permission grid (venue-modules.md §Permissions
|
||||
// matrix): every edit is SIGNED as a config_change, and a role remembers the manifest
|
||||
// JOBS it was built from so a grown job can be surfaced and re-applied.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
type Auth = { cookie: string; csrf: string };
|
||||
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||
async function admin(): Promise<Auth> {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
return login(app, username, password);
|
||||
}
|
||||
async function roleChanges(a: Auth) {
|
||||
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).filter(
|
||||
(e) => e.type === "config_change" && String(e.payload.setting).startsWith("role."),
|
||||
);
|
||||
}
|
||||
|
||||
describe("role edits are signed and jobs are remembered", () => {
|
||||
it("create / update / delete each sign one config_change with prev + value + operator; a no-op resave signs nothing", async () => {
|
||||
const a = await admin();
|
||||
const created = await app.inject({
|
||||
method: "POST", url: "/api/roles", headers: hdrs(a),
|
||||
payload: { name: "Lavazh", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const role = created.json();
|
||||
expect(role.jobs).toEqual(["wash-operator"]);
|
||||
let evs = await roleChanges(a);
|
||||
expect(evs).toHaveLength(1);
|
||||
expect(evs[0]!.payload).toMatchObject({
|
||||
setting: `role.${role.id}`, prev: null, operator: "boss",
|
||||
value: { name: "Lavazh", jobs: ["wash-operator"] },
|
||||
});
|
||||
expect((evs[0]!.payload.value as { permissions: string[] }).permissions).toEqual(["carwash:cash", "carwash:create", "carwash:read", "carwash:update"]);
|
||||
|
||||
// Same content again → nothing new on the chain.
|
||||
const same = await app.inject({
|
||||
method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a),
|
||||
payload: { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
||||
});
|
||||
expect(same.statusCode).toBe(200);
|
||||
expect(await roleChanges(a)).toHaveLength(1);
|
||||
|
||||
// A real change: prev is the old shape, value the new.
|
||||
const renamed = await app.inject({ method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a), payload: { name: "Lavazh NEW" } });
|
||||
expect(renamed.statusCode).toBe(200);
|
||||
evs = await roleChanges(a);
|
||||
expect(evs).toHaveLength(2);
|
||||
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh" }, value: { name: "Lavazh NEW" } });
|
||||
|
||||
const gone = await app.inject({ method: "DELETE", url: `/api/roles/${role.id}`, headers: hdrs(a) });
|
||||
expect(gone.statusCode).toBe(200);
|
||||
evs = await roleChanges(a);
|
||||
expect(evs).toHaveLength(3);
|
||||
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh NEW" }, value: null });
|
||||
});
|
||||
|
||||
it("unknown jobs are refused; a role built from a job that later grew reports what it is missing", async () => {
|
||||
const a = await admin();
|
||||
const bad = await app.inject({ method: "POST", url: "/api/roles", headers: hdrs(a), payload: { name: "X", permissions: [], jobs: ["bar-tender"] } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
// Compose "behind": the role follows wash-operator but holds only part of today's bundle
|
||||
// — exactly what an older release's chip would have left once the job grew.
|
||||
const r = (await app.inject({
|
||||
method: "POST", url: "/api/roles", headers: hdrs(a),
|
||||
payload: { name: "Old wash", permissions: ["carwash:read", "carwash:create"], jobs: ["wash-operator"] },
|
||||
})).json();
|
||||
const view = (await app.inject({ method: "GET", url: "/api/roles", headers: { cookie: a.cookie } })).json().roles.find((x: { id: string }) => x.id === r.id);
|
||||
const has = new Set<string>(view.permissions);
|
||||
expect(jobsBehind(view.jobs, (p) => has.has(p))).toEqual([{ job: "wash-operator", missing: ["carwash:update", "carwash:cash"] }]);
|
||||
// Re-apply = the union; then nothing is behind.
|
||||
const fixed = (await app.inject({
|
||||
method: "PUT", url: `/api/roles/${r.id}`, headers: hdrs(a),
|
||||
payload: { permissions: [...has, "carwash:update", "carwash:cash"] },
|
||||
})).json();
|
||||
const has2 = new Set<string>(fixed.permissions);
|
||||
expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared";
|
||||
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
|
||||
// Role management (admin). Roles are DATA: an admin composes a role from the
|
||||
@@ -18,14 +19,30 @@ import { softDelete } from "../recycle-bin.js";
|
||||
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
|
||||
// only put permissions they ALREADY hold onto a role. An admin (full set) is
|
||||
// unrestricted, which is the intended behaviour.
|
||||
//
|
||||
// EVERY role edit is SIGNED on the ledger as a `config_change` (setting `role.<id>`,
|
||||
// value/prev = the role's name + permissions + jobs, operator = who) — a role edit is a
|
||||
// privilege change, and under this threat model the only setting an admin could alter
|
||||
// without a trace. A role also REMEMBERS the manifest JOBS it was composed from
|
||||
// (role_jobs) so a later release that grows a job's bundle can be surfaced and
|
||||
// re-applied — the grid is never expanded silently (venue-modules.md §Permissions matrix).
|
||||
|
||||
interface RoleBody {
|
||||
name: string;
|
||||
permissions: string[];
|
||||
jobs?: string[];
|
||||
}
|
||||
interface UpdateBody {
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
jobs?: string[];
|
||||
}
|
||||
|
||||
/** What a signed role change records (before/after). */
|
||||
interface RoleShape {
|
||||
name: string;
|
||||
permissions: Permission[];
|
||||
jobs: string[];
|
||||
}
|
||||
|
||||
const VALID = new Set<string>(PERMISSIONS);
|
||||
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
|
||||
return { ok: true, perms: [...out] };
|
||||
}
|
||||
|
||||
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
/** Validate + dedupe a requested job list against the registry's job presets. */
|
||||
function cleanJobs(input: unknown): { ok: true; jobs: string[] } | { ok: false; bad: string } {
|
||||
if (input == null) return { ok: true, jobs: [] };
|
||||
if (!Array.isArray(input)) return { ok: false, bad: "jobs must be an array" };
|
||||
const out = new Set<string>();
|
||||
for (const j of input) {
|
||||
if (typeof j !== "string" || !jobById(j)) return { ok: false, bad: `unknown job: ${String(j)}` };
|
||||
out.add(j);
|
||||
}
|
||||
return { ok: true, jobs: [...out] };
|
||||
}
|
||||
|
||||
export async function roleRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog): Promise<void> {
|
||||
const readGuard = requirePermission("role:read");
|
||||
const createGuard = requirePermission("role:create");
|
||||
const updateGuard = requirePermission("role:update");
|
||||
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
name: role.name,
|
||||
builtin: role.builtin === 1,
|
||||
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
|
||||
jobs: jobsOf(roleId),
|
||||
userCount,
|
||||
};
|
||||
}
|
||||
|
||||
function jobsOf(roleId: string): string[] {
|
||||
return db.select({ jobId: roleJobs.jobId }).from(roleJobs).where(eq(roleJobs.roleId, roleId)).all().map((r) => r.jobId).sort();
|
||||
}
|
||||
|
||||
/** The role as the ledger records it (sorted so two identical shapes compare equal). */
|
||||
function shapeOf(roleId: string): RoleShape | null {
|
||||
const v = roleView(roleId);
|
||||
if (!v) return null;
|
||||
return { name: v.name, permissions: [...v.permissions].sort() as Permission[], jobs: v.jobs };
|
||||
}
|
||||
|
||||
/** Replace a role's remembered jobs. */
|
||||
function setJobs(roleId: string, jobs: string[]): void {
|
||||
db.delete(roleJobs).where(eq(roleJobs.roleId, roleId)).run();
|
||||
for (const jobId of jobs) db.insert(roleJobs).values({ roleId, jobId }).run();
|
||||
}
|
||||
|
||||
/** Sign a role change. `prev` null = created; `value` null = deleted. Skipped when
|
||||
* nothing changed (a no-op resave leaves no trace, like the site-config flips). */
|
||||
async function signRoleChange(req: { user?: { username?: string } }, roleId: string, prev: RoleShape | null, value: RoleShape | null): Promise<void> {
|
||||
if (JSON.stringify(prev) === JSON.stringify(value)) return;
|
||||
await eventLog?.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
payload: { setting: `role.${roleId}`, value, prev, operator: req.user?.username ?? "unknown" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace a role's permission rows with `perms` (in a single pass). */
|
||||
function setPermissions(roleId: string, perms: Permission[]): void {
|
||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
|
||||
@@ -104,13 +162,17 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
const cleaned = cleanPermissions(req.body?.permissions ?? []);
|
||||
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
||||
const jobs = cleanJobs(req.body?.jobs);
|
||||
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
|
||||
const over = escalates(req.user.roleId, cleaned.perms);
|
||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||
|
||||
const id = randomUUID();
|
||||
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
||||
setPermissions(id, cleaned.perms);
|
||||
setJobs(id, jobs.jobs);
|
||||
bumpPermsCache();
|
||||
await signRoleChange(req, id, null, shapeOf(id));
|
||||
return reply.code(201).send(roleView(id));
|
||||
});
|
||||
|
||||
@@ -125,6 +187,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
if (role.builtin === 1) {
|
||||
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
|
||||
}
|
||||
const prev = shapeOf(id);
|
||||
|
||||
if (req.body?.name != null) {
|
||||
const name = req.body.name.trim();
|
||||
@@ -140,7 +203,13 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||
setPermissions(id, cleaned.perms);
|
||||
}
|
||||
if (req.body?.jobs != null) {
|
||||
const jobs = cleanJobs(req.body.jobs);
|
||||
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
|
||||
setJobs(id, jobs.jobs);
|
||||
}
|
||||
bumpPermsCache();
|
||||
await signRoleChange(req, id, prev, shapeOf(id));
|
||||
return roleView(id);
|
||||
},
|
||||
);
|
||||
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
if (holders > 0) {
|
||||
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
||||
}
|
||||
const prev = shapeOf(id);
|
||||
softDelete(db, "role", id, req.user.sub);
|
||||
bumpPermsCache();
|
||||
await signRoleChange(req, id, prev, null);
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("health + login", () => {
|
||||
it("GET /health is open", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/health" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ status: "ok" });
|
||||
expect(res.json()).toEqual({ status: "ok", app: "parking-system" });
|
||||
});
|
||||
|
||||
it("login with bad credentials is rejected", async () => {
|
||||
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/version", () => {
|
||||
it("without a session is 401", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/version" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
|
||||
});
|
||||
|
||||
it("reflects a real BUILD_VERSION when the env var is set", async () => {
|
||||
process.env.BUILD_VERSION = "stage-abc1234";
|
||||
try {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
|
||||
} finally {
|
||||
delete process.env.BUILD_VERSION;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("RBAC permission gate", () => {
|
||||
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
@@ -101,3 +132,21 @@ describe("CSRF double-submit on mutations", () => {
|
||||
expect(put.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drawer balance (the till NOW)", () => {
|
||||
it("shift:read gets the balance; a role without it is 403; no auth 401", async () => {
|
||||
const anon = await app.inject({ method: "GET", url: "/api/drawer/balance" });
|
||||
expect(anon.statusCode).toBe(401);
|
||||
|
||||
const viewer = await seedUser(db, { username: "till", roleId: "till", permissions: ["shift:read"] });
|
||||
const { cookie } = await login(app, viewer.username, viewer.password);
|
||||
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
|
||||
expect(ok.statusCode).toBe(200);
|
||||
expect(ok.json()).toEqual({ till: "booth", balanceMinor: 0, currency: null });
|
||||
|
||||
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
||||
const other = await login(app, outsider.username, outsider.password);
|
||||
const denied = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie: other.cookie } });
|
||||
expect(denied.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, ledgerEvents, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// POST /api/setup/test-relay pulses a SAVED controller's barrier relay to prove the
|
||||
// wiring — it physically opens the barrier. Because "a physical open with no matching
|
||||
// signed command is the fraud signal" (append-only-event-chain / reconciliation), the
|
||||
// route must SIGN a barrier_open_command (reason setup.relayTest) BEFORE it fires, and it
|
||||
// must be admin-only. These tests use the `stub-access` controller (pulseOpen only logs —
|
||||
// no real hardware) so they exercise the validate → sign → pulse path safely.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
const CTL = "ctl-stub";
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: { relays: [{ relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }] },
|
||||
enabled: true,
|
||||
}).run();
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
async function pulse(
|
||||
body: unknown,
|
||||
auth?: { cookie: string; csrf: string },
|
||||
) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/setup/test-relay",
|
||||
headers: auth ? { cookie: auth.cookie, "x-csrf-token": auth.csrf } : {},
|
||||
payload: body as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/setup/test-relay", () => {
|
||||
it("is admin-only: a non-site:update user is 403", async () => {
|
||||
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||
const auth = await login(app, "op", "pw");
|
||||
const res = await pulse({ id: CTL, relay: 1 }, auth);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("requires CSRF on the mutation", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const { cookie } = await login(app, "admin", "pw");
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/setup/test-relay",
|
||||
headers: { cookie }, // no x-csrf-token
|
||||
payload: { id: CTL, relay: 1 },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("signs a barrier_open_command (reason setup.relayTest) BEFORE firing, then reports ok", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
|
||||
const res = await pulse({ id: CTL, relay: 2 }, auth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ ok: true });
|
||||
|
||||
// The deliberate open is EXPLAINED in the signed ledger — not an anomaly.
|
||||
const rows = db.select().from(ledgerEvents).all();
|
||||
const testOpen = rows.find((r) => r.type === "barrier_open_command");
|
||||
expect(testOpen, "a barrier_open_command must be signed").toBeTruthy();
|
||||
expect(testOpen!.source).toBe("manual"); // deliberate human action
|
||||
expect(testOpen!.signature.length).toBeGreaterThan(0);
|
||||
const payload = testOpen!.payload as Record<string, unknown>;
|
||||
expect(payload.relayTest).toBe(true);
|
||||
expect(payload.reasonCode).toBe("setup.relayTest");
|
||||
expect(payload.relay).toBe(2);
|
||||
expect(payload.controllerId).toBe(CTL);
|
||||
expect(payload.operator).toBe("admin"); // attributed to the acting admin
|
||||
});
|
||||
|
||||
it("rejects a relay the controller does not declare (400, no ledger row)", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
const res = await pulse({ id: CTL, relay: 9 }, auth);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // nothing signed
|
||||
});
|
||||
|
||||
it("404s an unknown controller id", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
const res = await pulse({ id: "nope", relay: 1 }, auth);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a bad relay value (non-positive-integer)", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
expect((await pulse({ id: CTL, relay: 0 }, auth)).statusCode).toBe(400);
|
||||
expect((await pulse({ id: CTL, relay: -1 }, auth)).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { storedSecrets } from "./setup.js";
|
||||
|
||||
// storedSecrets re-merges a device's machine-only secrets (relayPassword/pushPassword)
|
||||
// into a test/save — but ONLY when the submitted config addresses the SAME device at the
|
||||
// SAME host/port. This guards against a redirected probe exfiltrating the secret to an
|
||||
// attacker host (an admin keeps a real device id but swaps the host). The booth operator
|
||||
// is the threat-model adversary, so an authenticated-admin redirect must NOT leak.
|
||||
|
||||
let db: Db;
|
||||
const ID = "ctl-secret";
|
||||
const HOST = "10.0.10.5";
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({
|
||||
id: ID,
|
||||
category: "access",
|
||||
driverId: "dingtian",
|
||||
config: { host: HOST, binaryPort: 60000, relayPassword: 1996, pushPassword: "p-secret" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
});
|
||||
|
||||
describe("storedSecrets identity guard", () => {
|
||||
it("re-merges secrets when host/port/driver match the stored device", () => {
|
||||
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 60000 });
|
||||
expect(out.relayPassword).toBe(1996);
|
||||
expect(out.pushPassword).toBe("p-secret");
|
||||
});
|
||||
|
||||
it("re-merges when identity fields are OMITTED (fall back to the stored device)", () => {
|
||||
const out = storedSecrets(db, ID, "dingtian", {});
|
||||
expect(out.relayPassword).toBe(1996);
|
||||
});
|
||||
|
||||
it("REFUSES secrets when the host is redirected (exfiltration attempt)", () => {
|
||||
const out = storedSecrets(db, ID, "dingtian", { host: "10.66.66.66", binaryPort: 60000 });
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it("REFUSES secrets when a control port is changed", () => {
|
||||
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 9999 });
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it("REFUSES secrets when the driver doesn't match the stored row", () => {
|
||||
const out = storedSecrets(db, ID, "stub-access", { host: HOST });
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it("returns nothing for an unknown device id", () => {
|
||||
expect(storedSecrets(db, randomUUID(), "dingtian", { host: HOST })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isCamera,
|
||||
isDiscoverable,
|
||||
isHardenable,
|
||||
isPrinter,
|
||||
registerBuiltinDrivers,
|
||||
registry,
|
||||
setDeviceLogSink,
|
||||
@@ -14,7 +15,9 @@ import {
|
||||
type DeviceCategory,
|
||||
type DeviceConfig,
|
||||
} from "@parking/devices";
|
||||
import { reasonPayload } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||
import type { VisionClient } from "../vision-client.js";
|
||||
|
||||
@@ -36,6 +39,11 @@ interface AssignBody {
|
||||
interface TestBody {
|
||||
driverId: string;
|
||||
config: Record<string, string | number | boolean>;
|
||||
/** When editing an EXISTING device, its id — so the test re-merges the stored
|
||||
* machine secrets (relayPassword/pushPassword) the client never received. Without
|
||||
* this, testing an edited device would send no relay password → the device ignores
|
||||
* the probe → a false "offline". Omitted when testing a brand-new device. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
||||
@@ -54,6 +62,47 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Feature-detect the barrier-pulse capability on a built device adapter (the Setup
|
||||
* relay test needs it; a stub/reader/camera won't have it). */
|
||||
function hasPulseOpen(d: unknown): d is { pulseOpen(doorId: number): Promise<void> } {
|
||||
return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function";
|
||||
}
|
||||
|
||||
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
||||
// secret may only be re-merged when these match the stored row — otherwise an admin
|
||||
// could point a test at an attacker host while keeping a real device id and have the
|
||||
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
|
||||
// UDP + CGI targets; serial covers serial-bound readers.
|
||||
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
|
||||
|
||||
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
|
||||
* when the submitted config addresses the SAME device — same driver, and every
|
||||
* connection-identity field (host/port/…) that the submitted config sets equals the
|
||||
* stored value. If the admin redirected the probe (different host/port) or the driver
|
||||
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
|
||||
* redirected test from exfiltrating the secret to an attacker host. */
|
||||
export function storedSecrets(
|
||||
db: Db,
|
||||
id: string,
|
||||
driverId: string,
|
||||
submitted: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||
if (!row || row.driverId !== driverId) return {};
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
// Any identity field the client SENT must equal the stored value. (A field the client
|
||||
// omits falls back to the stored device, so it can't be used to redirect.)
|
||||
for (const k of IDENTITY_KEYS) {
|
||||
const sent = submitted[k];
|
||||
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||
type ConfigureOutcome =
|
||||
@@ -179,6 +228,7 @@ export async function setupRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
vision?: VisionClient | null,
|
||||
eventLog?: EventLog | null,
|
||||
): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
@@ -249,13 +299,30 @@ export async function setupRoutes(
|
||||
"/api/setup/test",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { driverId, config } = req.body;
|
||||
const { driverId, config, id } = req.body;
|
||||
const driver = registry.get(driverId);
|
||||
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||
|
||||
// When editing an existing device, re-merge its stored machine secrets (e.g.
|
||||
// relayPassword) — redacted from the client, so the submitted config omits them.
|
||||
// Submitted values win (an admin can override), but a blank/0 field falls back to
|
||||
// the stored secret so the probe authenticates. Without this, an edited Dingtian
|
||||
// tests with no relay password → false "offline". The submitted-value-wins rule:
|
||||
// only fill a secret from the store when the form didn't send a real one.
|
||||
// Re-merge stored secrets ONLY when this addresses the same device at the same
|
||||
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
|
||||
// the secret to an attacker host. Submitted values still win.
|
||||
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||
if (id) {
|
||||
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||
const sent = merged[k];
|
||||
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||
}
|
||||
}
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(driverId, config);
|
||||
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
@@ -334,6 +401,153 @@ export async function setupRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the
|
||||
// transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds
|
||||
// and the head fires. This pushes a real short slip through the device-agnostic
|
||||
// printReport(), so the admin can physically confirm the printer is live (the USB
|
||||
// /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error
|
||||
// is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited
|
||||
// network printer still authenticates.
|
||||
app.post<{ Body: TestBody }>(
|
||||
"/api/setup/test-print",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { driverId, config, id } = req.body;
|
||||
const driver = registry.get(driverId);
|
||||
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||
if (driver.category !== "printer") {
|
||||
return reply.code(400).send({ error: `driver ${driverId} is not a printer` });
|
||||
}
|
||||
|
||||
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||
if (id) {
|
||||
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||
const sent = merged[k];
|
||||
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||
}
|
||||
}
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
if (!isPrinter(device)) {
|
||||
return reply.code(400).send({ error: `driver ${driverId} cannot print` });
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await device.printReport({
|
||||
title: "TEST PRINT",
|
||||
lines: [
|
||||
"Parking System",
|
||||
"Printer test slip",
|
||||
new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable
|
||||
"",
|
||||
"If you can read this, the",
|
||||
"printer is connected and",
|
||||
"printing correctly.",
|
||||
],
|
||||
});
|
||||
} catch (err) {
|
||||
// The failure we're testing for (paper out, head fault, transport drop) —
|
||||
// report it, don't 500.
|
||||
return reply.send({
|
||||
ok: false,
|
||||
reason: "print-failed",
|
||||
detail: (err as Error).message,
|
||||
tookMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
return reply.send({ ok: true, tookMs: Date.now() - startedAt });
|
||||
},
|
||||
);
|
||||
|
||||
// PULSE a controller's barrier relay from Setup, to test the wiring — WITHOUT any
|
||||
// vehicle/session. This physically opens the barrier, so unlike the other tests it
|
||||
// runs only against a SAVED controller (real id → clean attribution) and it SIGNS a
|
||||
// `barrier_open_command` into the ledger FIRST, with reason `setup.relayTest` + the
|
||||
// admin's identity. That is the whole point of doing it this way: a physical open with
|
||||
// no matching signed command is the fraud signal ([[append-only-event-chain]],
|
||||
// [[reconciliation]]) — a deliberate test must therefore be an EXPLAINED open, not a
|
||||
// silent one. Sign-before-fire mirrors exit-flow's manual re-open: the intervention is
|
||||
// recorded whether or not the physical pulse then succeeds. Admin-only (site:update).
|
||||
app.post<{ Body: { id: string; relay: number } }>(
|
||||
"/api/setup/test-relay",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { id, relay } = req.body;
|
||||
if (typeof id !== "string" || !id) return reply.code(400).send({ error: "missing controller id" });
|
||||
if (!Number.isInteger(relay) || relay < 1) {
|
||||
return reply.code(400).send({ error: "relay must be a 1-based channel number" });
|
||||
}
|
||||
|
||||
// A relay test fires REAL hardware, so it must target a persisted controller — no
|
||||
// firing an unsaved/redirected config (that would let a probe open an arbitrary host's
|
||||
// barrier). Load the saved row and build straight from its stored config (relayPassword
|
||||
// included — it's on the row, never in the request).
|
||||
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||
if (!row) return reply.code(404).send({ error: "controller not found" });
|
||||
if (row.category !== "access") {
|
||||
return reply.code(400).send({ error: `device ${id} is not a controller` });
|
||||
}
|
||||
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as { relay?: number }[]) : [];
|
||||
if (!relays.some((r) => r.relay === relay)) {
|
||||
return reply.code(400).send({ error: `controller ${id} has no relay ${relay}` });
|
||||
}
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(row.driverId, cfg as Record<string, string | number | boolean>);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
if (!hasPulseOpen(device)) {
|
||||
return reply.code(400).send({ error: `driver ${row.driverId} cannot pulse a relay` });
|
||||
}
|
||||
|
||||
// Sign the deliberate open FIRST — recorded whether or not the physical pulse then
|
||||
// succeeds. Skip only if no ledger is wired (test/degraded), in which case we still
|
||||
// refuse rather than fire an unrecorded open.
|
||||
const operator = req.user?.username ?? "unknown";
|
||||
if (!eventLog) {
|
||||
return reply.code(503).send({ error: "ledger unavailable — refusing an unrecorded relay open" });
|
||||
}
|
||||
await eventLog.append({
|
||||
type: "barrier_open_command",
|
||||
// A deliberate human action from the admin console → "manual" (the top-level
|
||||
// IdentitySource). The relayTest marker + reason distinguish it in the payload.
|
||||
source: "manual",
|
||||
identity: `relay-test:${id}:${relay}`,
|
||||
payload: {
|
||||
...reasonPayload("setup.relayTest", { operator, relay, controller: row.driverId }),
|
||||
relayTest: true,
|
||||
controllerId: id,
|
||||
relay,
|
||||
operator,
|
||||
},
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await device.pulseOpen(relay);
|
||||
} catch (err) {
|
||||
// The failure we're testing for (relay unreachable, wrong password). The open is
|
||||
// already signed; report the pulse failure, don't 500.
|
||||
return reply.send({
|
||||
ok: false,
|
||||
reason: "pulse-failed",
|
||||
detail: (err as Error).message,
|
||||
tookMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
return reply.send({ ok: true, firedAt: new Date().toISOString(), tookMs: Date.now() - startedAt });
|
||||
},
|
||||
);
|
||||
|
||||
// Candidate backend IPs the device can push to, for a given device host. The
|
||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||
@@ -346,6 +560,39 @@ export async function setupRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// USB printers PRESENT on the box: enumerate /dev/usb/lpN (the usblp nodes the
|
||||
// container sees via the /dev/usb bind-mount) and enrich each with the printer's
|
||||
// self-reported make/model from sysfs (ieee1284_id — readable through Docker's
|
||||
// default ro /sys). The wizard offers these as a SELECT so the admin never has to
|
||||
// shell in and `ls /dev/usb` to learn the kernel picked lp1 (field friction,
|
||||
// park-buzi 2026-07-07). Empty list = no usblp printer plugged/visible.
|
||||
app.get("/api/setup/usb-printers", { preHandler: adminGuard }, async () => {
|
||||
const { readdir, readFile } = await import("node:fs/promises");
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = (await readdir("/dev/usb")).filter((n) => /^lp\d+$/.test(n)).sort();
|
||||
} catch {
|
||||
return { printers: [] }; // no /dev/usb at all — nothing plugged (or no mount)
|
||||
}
|
||||
const printers = await Promise.all(
|
||||
names.map(async (n) => {
|
||||
// ieee1284_id: "MFG:Xprinter;CMD:ESCPOS;MDL:XP-K200L;…" — best-effort.
|
||||
let description: string | null = null;
|
||||
try {
|
||||
const id = await readFile(`/sys/class/usbmisc/${n}/device/ieee1284_id`, "utf8");
|
||||
const pick = (key: string) => id.match(new RegExp(`(?:^|;)\\s*${key}:([^;]+)`, "i"))?.[1]?.trim();
|
||||
const mfg = pick("MFG") ?? pick("MANUFACTURER");
|
||||
const mdl = pick("MDL") ?? pick("MODEL");
|
||||
description = [mfg, mdl].filter(Boolean).join(" ") || null;
|
||||
} catch {
|
||||
/* sysfs not readable / attribute absent — path alone is still useful */
|
||||
}
|
||||
return { path: `/dev/usb/${n}`, description };
|
||||
}),
|
||||
);
|
||||
return { printers };
|
||||
});
|
||||
|
||||
// Assign a device. Validates the chosen driver + config, configures the device
|
||||
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||
|
||||
@@ -1,27 +1,9 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import {
|
||||
InvalidCashMovementError,
|
||||
NoOpenShiftError,
|
||||
ShiftAlreadyOpenError,
|
||||
type ShiftService,
|
||||
} from "../shift-service.js";
|
||||
|
||||
interface CashVoucherBody {
|
||||
/** 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;
|
||||
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
|
||||
authorizedBy: string;
|
||||
/** That admin's password — re-entered to sign off on the drawer movement. */
|
||||
authorizerPassword: string;
|
||||
}
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import { tillGuards, type TillId } from "@parking/shared";
|
||||
import { requireAuth, roleHasPermissions } from "../auth.js";
|
||||
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
|
||||
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService, type ShiftSummary } from "../shift-service.js";
|
||||
|
||||
interface ShiftsQuery {
|
||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||
@@ -29,123 +11,124 @@ interface ShiftsQuery {
|
||||
/** ISO window over shift START time. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** Filter to one till; absent = every till the role may read. */
|
||||
till?: string;
|
||||
}
|
||||
|
||||
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||
//
|
||||
// TILLS + PERMISSIONS: every endpoint addresses a `till` (query on GET, body on POST;
|
||||
// default booth) and its guard is resolved FROM THE TILL (requireTill): the booth's shift
|
||||
// is `shift:read` / `shift:create`, the wash's is `carwash:read` / `carwash:cash` — each
|
||||
// desk's money is guarded by that desk's own permissions, so a wash role holds no
|
||||
// `shift:*` at all and cannot touch the booth. See venue-modules.md §"Permissions matrix".
|
||||
|
||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
|
||||
// Reading the shift state vs. opening/closing one's own shift.
|
||||
const readGuard = requirePermission("shift:read");
|
||||
const guard = requirePermission("shift:create");
|
||||
|
||||
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
|
||||
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
|
||||
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||
// - open: the open shift { startedAt, operator } or null (site-wide)
|
||||
// - isMine: true iff the open shift belongs to the requesting operator
|
||||
// - operator: the requesting user (for the UI's own identity)
|
||||
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
|
||||
const me = req.user.username;
|
||||
const open = shift.currentOpenShift();
|
||||
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||
const statusOf = (till: TillId, me: string, roleId: string) => {
|
||||
const open = shift.currentOpenShift(till);
|
||||
const heldBy = open?.identity ?? null;
|
||||
const drawer = shift.drawerBalance();
|
||||
const drawer = shift.drawerBalance(till);
|
||||
return {
|
||||
operator: me,
|
||||
till,
|
||||
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
||||
isMine: open != null && heldBy === me,
|
||||
/** May this role open/close this till's shift? (The UI offers the button only then.) */
|
||||
canWork: roleHasPermissions(roleId, [tillGuards(till).shift]),
|
||||
drawerMinor: drawer.balanceMinor,
|
||||
currency: drawer.currency,
|
||||
};
|
||||
};
|
||||
|
||||
// The shift state of ONE till (at most one shift open per till). The UI uses this
|
||||
// to render a till's control: no shift → "Open"; my shift → "Close" (enabled);
|
||||
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||
// - till: which till this describes
|
||||
// - open: the open shift { startedAt, operator } or null
|
||||
// - isMine: true iff the open shift belongs to the requesting operator
|
||||
// - canWork: may this role open/close it
|
||||
// - operator: the requesting user (for the UI's own identity)
|
||||
// - tills: every till THIS ROLE may read — what the UI offers controls for
|
||||
app.get("/api/shift/current", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
|
||||
operator: req.user.username,
|
||||
tills: tillsReadableBy(db, req.user.roleId),
|
||||
...statusOf(req.till!, req.user.username, req.user.roleId),
|
||||
}));
|
||||
|
||||
// The state of every till this role may read, in one read — the shift hub lists
|
||||
// each open shift and offers "start" for the idle ones it may work.
|
||||
app.get("/api/shift/tills", { preHandler: requireAuth }, async (req) => {
|
||||
const me = req.user.username;
|
||||
return { operator: me, tills: tillsReadableBy(db, req.user.roleId).map((t) => statusOf(t, me, req.user.roleId)) };
|
||||
});
|
||||
|
||||
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
||||
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
||||
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
||||
// (the Z-report at close is the signed record). 204 when no shift is open.
|
||||
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
|
||||
const report = shift.currentReport();
|
||||
app.get("/api/shift/report", { preHandler: requireTill(db, "read", "query") }, async (req, reply) => {
|
||||
const report = shift.currentReport(req.till!);
|
||||
if (!report) return reply.code(204).send();
|
||||
return report;
|
||||
});
|
||||
|
||||
// Completed shift history. SCOPED by permission:
|
||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
||||
// - a till's `read` guard (operators) → own shifts only, on the tills they may read;
|
||||
// operator/from/to params ignored.
|
||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||
// `operator` and a `from`/`to` time window over each shift's START.
|
||||
// This keeps one operator from reading another's takings while letting admins
|
||||
// reconcile across the site. The data is the signed shift_z_report chain.
|
||||
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
|
||||
// reconcile across the site. The data is the signed shift_z_report chain. Both
|
||||
// scopes may filter by `till` (must be one the role may read).
|
||||
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: requireAuth }, async (req, reply) => {
|
||||
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
||||
const readable = tillsReadableBy(db, req.user.roleId);
|
||||
if (!canSeeAll && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||
const q = req.query ?? {};
|
||||
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
||||
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||
const shifts = shift.listShifts({ operator, from, to });
|
||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||
let tills: TillId[] = canSeeAll ? [] : readable; // [] = no till filter (admin)
|
||||
if (q.till?.trim()) {
|
||||
const parsed = parseTill(db, q.till.trim());
|
||||
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||
if (!canSeeAll && !readable.includes(parsed)) return badTill(reply, parsed);
|
||||
tills = [parsed];
|
||||
}
|
||||
const shifts: ShiftSummary[] =
|
||||
tills.length === 0
|
||||
? shift.listShifts({ operator, from, to })
|
||||
: tills.flatMap((till) => shift.listShifts({ operator, from, to, till })).sort((a, b) => b.index - a.index);
|
||||
// Admins also get the distinct operator list (unfiltered) for the filter
|
||||
// dropdown — operators don't see other names, so it's scope-gated.
|
||||
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: tillsReadableBy(db, req.user.roleId) };
|
||||
return { shifts, scope: "self", tills: readable };
|
||||
});
|
||||
|
||||
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
||||
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
|
||||
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
|
||||
// (`shift:cash`) who re-enters their password. This keeps the float control —
|
||||
// an operator cannot move the float alone — while letting them raise the slip.
|
||||
// See wiki/concepts/shift.md.
|
||||
app.post<{ Body: CashVoucherBody }>(
|
||||
"/api/cash-voucher",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const b = req.body ?? ({} as CashVoucherBody);
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
const authName = (b.authorizedBy ?? "").trim();
|
||||
if (!authName || !b.authorizerPassword) {
|
||||
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
|
||||
}
|
||||
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
|
||||
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
|
||||
// Always run a bcrypt compare (constant-time wrt whether the user exists).
|
||||
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
|
||||
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
|
||||
if (!authUser || !passwordOk || !isAdminGrade) {
|
||||
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
|
||||
}
|
||||
try {
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
operator: req.user.username, // who RAISED it
|
||||
authorizedBy: authUser.username, // who signed off (canonical case)
|
||||
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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||
|
||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||
app.post("/api/shift/open", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
|
||||
try {
|
||||
return await shift.open(req.user.username);
|
||||
return await shift.open(req.user.username, req.till!);
|
||||
} catch (err) {
|
||||
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
||||
app.post("/api/shift/close", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
|
||||
try {
|
||||
return await shift.close(req.user.username);
|
||||
return await shift.close(req.user.username, req.till!);
|
||||
} catch (err) {
|
||||
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function badTill(reply: FastifyReply, till: TillId): FastifyReply {
|
||||
return reply.code(403).send({ error: `your role cannot see the ${till} till`, code: "till_forbidden", till });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import { MODULES, effectiveModules, isModuleId, resolveModuleActivation, type ModuleId } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import { activatedModulesOf, entitledModules } from "../modules.js";
|
||||
import { getOccupancy } from "../occupancy.js";
|
||||
|
||||
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||
@@ -35,16 +38,29 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
|
||||
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
|
||||
anprEntryEnabled?: boolean;
|
||||
/** Venue modules to ACTIVATE (full desired set). Validated against the entitlement
|
||||
* and the registry's dependency rules; required modules are always included. Each
|
||||
* module that actually flips signs a config_change. See wiki/decisions/venue-modules.md. */
|
||||
modules?: unknown;
|
||||
}
|
||||
|
||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||
* + every metadata field. */
|
||||
* + the entry presence-bypass flags + every metadata field. */
|
||||
type SiteConfig = {
|
||||
capacity: number | null;
|
||||
exitVoucherDefault: boolean;
|
||||
subscriptionMonthlyPriceMinor: number | null;
|
||||
reserveSubscriberSpots: boolean;
|
||||
anprEntryEnabled: boolean;
|
||||
bypassPresenceRadar: boolean;
|
||||
bypassPresenceCamera: boolean;
|
||||
/** Effective venue modules = entitled ∩ activated (what the server enforces). */
|
||||
modules: ModuleId[];
|
||||
/** What this deployment is entitled to (MODULES_ENTITLED env) — the Setup → Site
|
||||
* panel offers exactly these to toggle. */
|
||||
modulesEntitled: ModuleId[];
|
||||
/** What the site admin has activated (null in storage = everything entitled). */
|
||||
modulesActivated: ModuleId[];
|
||||
} & Record<TextField, string | null>;
|
||||
|
||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||
@@ -54,11 +70,24 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
|
||||
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
|
||||
...moduleView(row),
|
||||
} as SiteConfig;
|
||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||
return out;
|
||||
}
|
||||
|
||||
function moduleView(row: typeof siteConfig.$inferSelect | undefined) {
|
||||
const entitled = entitledModules();
|
||||
const activated = activatedModulesOf(row) ?? entitled;
|
||||
return {
|
||||
modules: effectiveModules(entitled, activated),
|
||||
modulesEntitled: entitled,
|
||||
modulesActivated: activated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Trim a text field; empty string becomes null so blank input clears it. */
|
||||
function normText(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
@@ -66,13 +95,22 @@ function normText(v: unknown): string | null {
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog | null): Promise<void> {
|
||||
const readGuard = requirePermission("site:read");
|
||||
const writeGuard = requirePermission("site:update");
|
||||
|
||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||
|
||||
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
|
||||
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
|
||||
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
|
||||
// build with no CI-supplied value. Purely informational (Setup nav display); not
|
||||
// site config, so it isn't stored in site_config.
|
||||
app.get("/api/version", { preHandler: readGuard }, async () => ({
|
||||
buildVersion: process.env.BUILD_VERSION?.trim() || null,
|
||||
}));
|
||||
|
||||
// Read site config (capacity + park metadata).
|
||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
@@ -122,6 +160,40 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
|
||||
// Venue-module activation. The body carries the full DESIRED set; the shared rules
|
||||
// (required always on, must be entitled, dependencies effective) decide, and every
|
||||
// module whose effective state actually flips is signed as a config_change — the
|
||||
// same attribution pattern as the presence-bypass endpoint below. Disabling never
|
||||
// deletes anything: tables/history/grants stay, routes 403, UI hides.
|
||||
if ("modules" in body) {
|
||||
const requested = body.modules;
|
||||
if (!Array.isArray(requested) || !requested.every(isModuleId)) {
|
||||
return reply.code(400).send({
|
||||
error: `modules must be an array of module ids (${MODULES.map((m) => m.id).join(", ")})`,
|
||||
});
|
||||
}
|
||||
const entitled = entitledModules();
|
||||
const result = resolveModuleActivation(entitled, requested);
|
||||
if (!result.ok) return reply.code(400).send({ error: result.error });
|
||||
const prevEffective = new Set(effectiveModules(entitled, activatedModulesOf(existing) ?? entitled));
|
||||
const nextEffective = new Set(effectiveModules(entitled, result.modules));
|
||||
const operator = req.user?.username ?? "unknown";
|
||||
for (const m of MODULES) {
|
||||
const was = prevEffective.has(m.id);
|
||||
const now = nextEffective.has(m.id);
|
||||
if (was !== now) {
|
||||
await eventLog?.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: `module:${m.id}`,
|
||||
payload: { setting: `modules.${m.id}`, value: now, prev: was, operator },
|
||||
});
|
||||
}
|
||||
}
|
||||
patch.modulesJson = JSON.stringify(result.modules);
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
@@ -131,4 +203,64 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return toSiteConfig(row);
|
||||
});
|
||||
|
||||
// Entry presence-gate BYPASS — a DEDICATED, SIGNED endpoint (not the generic PUT above),
|
||||
// because dropping a radar/camera requirement weakens an anti-fraud gate. The admin is not
|
||||
// the adversary (a faulty device blocks legit entry until support fixes it), but the change
|
||||
// must be attributed + auditable: each toggled signal appends a signed `config_change`
|
||||
// {setting, value, prev, operator}. Granular per signal. See wiki/concepts/entry-presence-bypass.md.
|
||||
app.put<{ Body: { radar?: boolean; camera?: boolean } }>(
|
||||
"/api/site-config/presence-bypass",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const body = req.body ?? {};
|
||||
for (const k of ["radar", "camera"] as const) {
|
||||
if (k in body && typeof body[k] !== "boolean") {
|
||||
return reply.code(400).send({ error: `${k} must be a boolean` });
|
||||
}
|
||||
}
|
||||
if (!("radar" in body) && !("camera" in body)) {
|
||||
return reply.code(400).send({ error: "nothing to change (send radar and/or camera)" });
|
||||
}
|
||||
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const prev = {
|
||||
radar: existing?.bypassPresenceRadar ?? false,
|
||||
camera: existing?.bypassPresenceCamera ?? false,
|
||||
};
|
||||
const next = {
|
||||
radar: "radar" in body ? (body.radar as boolean) : prev.radar,
|
||||
camera: "camera" in body ? (body.camera as boolean) : prev.camera,
|
||||
};
|
||||
|
||||
// Sign a config_change for each signal that ACTUALLY changed (before persisting, so the
|
||||
// audit record exists whether or not a later write hiccups). No-op toggles sign nothing.
|
||||
const operator = req.user?.username ?? "unknown";
|
||||
for (const signal of ["radar", "camera"] as const) {
|
||||
if (next[signal] !== prev[signal]) {
|
||||
await eventLog?.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: `presence-bypass:${signal}`,
|
||||
payload: {
|
||||
setting: `entryPresenceBypass.${signal}`,
|
||||
value: next[signal],
|
||||
prev: prev[signal],
|
||||
operator,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const patch = { bypassPresenceRadar: next.radar, bypassPresenceCamera: next.camera, updatedAt };
|
||||
if (existing) {
|
||||
db.update(siteConfig).set(patch).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, ...patch }).run();
|
||||
}
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return toSiteConfig(row);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { cleanType } from "../snapshot.js";
|
||||
|
||||
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
||||
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
||||
@@ -116,7 +117,10 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
|
||||
async (req, reply) => {
|
||||
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
||||
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
||||
reply.header("content-type", row.contentType);
|
||||
// Normalize on the way OUT too: legacy rows stored a camera's malformed
|
||||
// `image/jpeg; charset="UTF-8"`, which browsers refuse to render. cleanType strips
|
||||
// the bogus params back to a bare `image/jpeg` so every stored image displays.
|
||||
reply.header("content-type", cleanType(row.contentType));
|
||||
reply.header("cache-control", "private, max-age=31536000, immutable");
|
||||
return reply.send(row.bytes);
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { NoPrinterAvailableError } from "@parking/devices";
|
||||
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
||||
import { BOOTH_TILL, type SubscriptionPlan, type SubscriptionQuote, type Tender } from "@parking/shared";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
import { invalidateHolder } from "../event-enrich.js";
|
||||
@@ -381,6 +381,7 @@ export async function subscriptionRoutes(
|
||||
amountMinor,
|
||||
currency,
|
||||
tender,
|
||||
till: BOOTH_TILL,
|
||||
operator,
|
||||
// Flags this `payment` as a subscription SALE (not a parking payment) so the
|
||||
// live feed / activity log can label it distinctly. plan + periods for audit
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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";
|
||||
|
||||
// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable
|
||||
// published versions. The contract under test: drafts are validated + tz-stamped on
|
||||
// save exactly like a publish (so "publish this draft" can never fail on a card that
|
||||
// saved fine), mutations need tariff:update, and publishing a draft goes through the
|
||||
// normal immutable-version path untouched.
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
const V1_STRUCTURE = {
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
lostTicketMinor: 2000,
|
||||
gracePeriodExitMin: 10,
|
||||
overstay: "reprice",
|
||||
blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }],
|
||||
dailyCapMinor: null,
|
||||
};
|
||||
|
||||
// A V2 card with a night package — tz left blank on purpose: the server must stamp it.
|
||||
const V2_STRUCTURE = {
|
||||
version: 2,
|
||||
tz: "",
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
lostTicketMinor: 2000,
|
||||
gracePeriodExitMin: 10,
|
||||
overstay: "reprice",
|
||||
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null },
|
||||
windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }],
|
||||
};
|
||||
|
||||
async function editor() {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "editor",
|
||||
roleId: "editor",
|
||||
permissions: ["tariff:read", "tariff:update"],
|
||||
});
|
||||
return login(app, username, password);
|
||||
}
|
||||
|
||||
describe("tariff drafts", () => {
|
||||
it("requires auth", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("a tariff:read-only user can list but not create", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer",
|
||||
roleId: "viewer",
|
||||
permissions: ["tariff:read"],
|
||||
});
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json().drafts).toEqual([]);
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/drafts",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE },
|
||||
});
|
||||
expect(create.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("create → list → update → delete roundtrip", async () => {
|
||||
const { cookie, csrf } = await editor();
|
||||
const headers = { cookie, "x-csrf-token": csrf };
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/drafts",
|
||||
headers,
|
||||
payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE },
|
||||
});
|
||||
expect(create.statusCode).toBe(201);
|
||||
const draft = create.json();
|
||||
expect(draft.name).toBe("Winter proposal");
|
||||
expect(draft.currency).toBe("ALL"); // normalised to upper case
|
||||
expect(draft.createdBy).toBe("editor");
|
||||
|
||||
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||
expect(list.json().drafts).toHaveLength(1);
|
||||
|
||||
const update = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/tariff/drafts/${draft.id}`,
|
||||
headers,
|
||||
payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE },
|
||||
});
|
||||
expect(update.statusCode).toBe(200);
|
||||
expect(update.json().name).toBe("Winter v2");
|
||||
|
||||
const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers });
|
||||
expect(del.statusCode).toBe(204);
|
||||
const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||
expect(after.json().drafts).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an invalid structure with problems (validated like a publish)", async () => {
|
||||
const { cookie, csrf } = await editor();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/drafts",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().problems?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => {
|
||||
const { cookie, csrf } = await editor();
|
||||
const headers = { cookie, "x-csrf-token": csrf };
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/drafts",
|
||||
headers,
|
||||
payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE },
|
||||
});
|
||||
expect(create.statusCode).toBe(201);
|
||||
const draft = create.json();
|
||||
expect(draft.structure.tz).toBe("Europe/Tirane");
|
||||
|
||||
// The lab prices the draft by sending its stored structure inline.
|
||||
const sim = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/simulate",
|
||||
headers,
|
||||
payload: {
|
||||
enteredAt: "2026-07-03T21:00:00.000+02:00",
|
||||
asOf: "2026-07-03T23:00:00.000+02:00",
|
||||
structure: draft.structure,
|
||||
currency: draft.currency,
|
||||
},
|
||||
});
|
||||
expect(sim.statusCode).toBe(200);
|
||||
expect(sim.json().pricing.amountMinor).toBe(40000); // one night package
|
||||
|
||||
// "Publish this draft" = the normal immutable-version path with the draft's card;
|
||||
// the draft's name rides along as the version's optional label.
|
||||
const publish = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/versions",
|
||||
headers,
|
||||
payload: { currency: draft.currency, structure: draft.structure, name: draft.name },
|
||||
});
|
||||
expect(publish.statusCode).toBe(201);
|
||||
const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } });
|
||||
expect(state.json().active?.name).toBe("Night package");
|
||||
expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import {
|
||||
computeFee,
|
||||
explainFee,
|
||||
isTariffV2,
|
||||
priceSession,
|
||||
validateTariffStructure,
|
||||
@@ -25,10 +26,19 @@ interface PublishBody {
|
||||
structure: TariffStructure;
|
||||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||
effectiveFrom?: string;
|
||||
/** Optional human label (e.g. carried from the lab draft being published). */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const SITE_TARIFF_NAME = "Site tariff";
|
||||
|
||||
/** Body for saving a lab draft (create + update share the shape). */
|
||||
interface DraftBody {
|
||||
name: string;
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
}
|
||||
|
||||
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
|
||||
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
||||
* neither (uses the active version). */
|
||||
@@ -80,19 +90,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
"/api/tariff/versions",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
||||
const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody);
|
||||
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||
}
|
||||
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
||||
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
||||
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
||||
let toStore: TariffStructure = structure;
|
||||
if (structure && isTariffV2(structure)) {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||
toStore = { ...structure, tz };
|
||||
}
|
||||
const toStore = stampSiteTz(structure);
|
||||
|
||||
const problems = validateTariffStructure(toStore);
|
||||
if (problems.length) {
|
||||
@@ -128,6 +133,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
const row = {
|
||||
id,
|
||||
tariffId,
|
||||
name: typeof name === "string" && name.trim() ? name.trim() : null,
|
||||
effectiveFrom: effective,
|
||||
currency,
|
||||
structure: toStore as unknown as Record<string, unknown>,
|
||||
@@ -183,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
||||
|
||||
// HOW the amount is produced — the same engine walk with a trace collector
|
||||
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
|
||||
const breakdown = pricing.withinGrace
|
||||
? null
|
||||
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
|
||||
|
||||
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
||||
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||
const enteredMs = Date.parse(b.enteredAt);
|
||||
@@ -191,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
||||
}));
|
||||
|
||||
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||
return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||
});
|
||||
|
||||
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
||||
@@ -230,6 +242,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
},
|
||||
);
|
||||
|
||||
// --- Lab drafts ---------------------------------------------------------------
|
||||
// The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the
|
||||
// schema for why mutability is safe here — a draft prices nothing and signs
|
||||
// nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the
|
||||
// simulator can always price them and "publish this draft" can never surprise the
|
||||
// admin with a card that saved fine but won't go live. Publishing a draft is just
|
||||
// POST /api/tariff/versions with the draft's structure — same guard, same
|
||||
// validation, same immutability.
|
||||
app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => {
|
||||
const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all();
|
||||
return { drafts };
|
||||
});
|
||||
|
||||
app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => {
|
||||
const parsed = parseDraftBody(req.body);
|
||||
if ("error" in parsed) return reply.code(400).send(parsed);
|
||||
const now = new Date().toISOString();
|
||||
const row = {
|
||||
id: randomUUID(),
|
||||
name: parsed.name,
|
||||
currency: parsed.currency,
|
||||
structure: parsed.structure as unknown as Record<string, unknown>,
|
||||
createdBy: req.user?.username ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
db.insert(tariffDrafts).values(row).run();
|
||||
return reply.code(201).send(row);
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string }; Body: DraftBody }>(
|
||||
"/api/tariff/drafts/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "draft not found" });
|
||||
const parsed = parseDraftBody(req.body);
|
||||
if ("error" in parsed) return reply.code(400).send(parsed);
|
||||
const patch = {
|
||||
name: parsed.name,
|
||||
currency: parsed.currency,
|
||||
structure: parsed.structure as unknown as Record<string, unknown>,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run();
|
||||
return { ...existing, ...patch };
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/tariff/drafts/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "draft not found" });
|
||||
db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run();
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
/** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */
|
||||
function parseDraftBody(
|
||||
body: DraftBody | undefined,
|
||||
): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } {
|
||||
const b = body ?? ({} as DraftBody);
|
||||
const name = (b.name ?? "").trim();
|
||||
if (!name) return { error: "name required" };
|
||||
const currency = (b.currency ?? "").trim().toUpperCase();
|
||||
if (currency.length < 3) return { error: "currency (ISO 4217) required" };
|
||||
const structure = stampSiteTz(b.structure);
|
||||
const problems = validateTariffStructure(structure);
|
||||
if (problems.length) return { error: "invalid tariff structure", problems };
|
||||
return { name, currency, structure };
|
||||
}
|
||||
|
||||
/** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the
|
||||
* client); a V1 (bare) structure passes through untouched. */
|
||||
function stampSiteTz(structure: TariffStructure): TariffStructure {
|
||||
if (structure && isTariffV2(structure)) {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||
return { ...structure, tz };
|
||||
}
|
||||
return structure;
|
||||
}
|
||||
|
||||
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
|
||||
function tariffVersionIdFor(whenIso: string): string | null {
|
||||
const tariffId = ensureSiteTariff();
|
||||
|
||||
@@ -3,7 +3,7 @@ import bcrypt from "bcrypt";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID } from "@parking/shared";
|
||||
import { permissionsFor, requirePermission } from "../auth.js";
|
||||
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
|
||||
// User management (admin). Users are created/edited at runtime here — the
|
||||
@@ -202,6 +202,8 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
return reply.code(400).send({ error: "nothing to update" });
|
||||
}
|
||||
db.update(users).set(next).where(eq(users.id, id)).run();
|
||||
// A role reassignment takes effect on the user's NEXT request (auth.ts refreshRole).
|
||||
if (next.roleId) bumpPermsCache();
|
||||
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
||||
},
|
||||
);
|
||||
@@ -251,6 +253,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
return reply.code(409).send({ error: "cannot delete the last admin" });
|
||||
}
|
||||
softDelete(db, "user", id, req.user.sub);
|
||||
bumpPermsCache(); // their live session ends on its next request
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { eq, ledgerEvents, users, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../test-helpers.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
|
||||
// Merchant validations (bar/lavazh): the merchant user scans a ticket and applies
|
||||
// their program (a SIGNED, attributed ledger event); the booth settlement quotes NET
|
||||
// and the payment CONSUMES the validation ids. These tests pin the route guards
|
||||
// (binding, caps, session state), the signed apply/void events, and the money cycle
|
||||
// through /api/pay/quote + /api/pay. See wiki/concepts/validation-discounts.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();
|
||||
});
|
||||
|
||||
type Auth = { cookie: string; csrf: string };
|
||||
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||
|
||||
async function seedMerchant(username = "bari"): Promise<{ auth: Auth; userId: string }> {
|
||||
await seedUser(db, { username, password: "pw123456", roleId: "validues", permissions: ["validation:create"] });
|
||||
const auth = await login(app, username, "pw123456");
|
||||
const row = db.select().from(users).where(eq(users.username, username)).get()!;
|
||||
return { auth, userId: row.id };
|
||||
}
|
||||
|
||||
async function seedAdmin(): Promise<Auth> {
|
||||
await seedUser(db, { username: "admin", password: "pw123456" });
|
||||
return login(app, "admin", "pw123456");
|
||||
}
|
||||
|
||||
/** Admin-upserts the "bar" program bound to the given user. */
|
||||
async function putProgram(auth: Auth, body: Record<string, unknown>, id = "bar") {
|
||||
return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body });
|
||||
}
|
||||
|
||||
const fixedProgram = (userId: string, over: Record<string, unknown> = {}) => ({
|
||||
name: "Bar",
|
||||
mode: "fixed",
|
||||
maxAmountMinor: 100000,
|
||||
active: true,
|
||||
userIds: [userId],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("merchant validations", () => {
|
||||
let log: EventLog;
|
||||
beforeEach(() => {
|
||||
log = makeLog(db);
|
||||
});
|
||||
|
||||
const mint = (identity: string, minAgo: number, payload: Record<string, unknown> | null = null) =>
|
||||
log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: minutesAgo(minAgo), payload });
|
||||
|
||||
it("program upsert is admin-gated and signs a config_change; a no-op save signs nothing", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
|
||||
expect((await putProgram(merchant, fixedProgram(userId))).statusCode).toBe(403);
|
||||
|
||||
const res = await putProgram(admin, fixedProgram(userId));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ id: "bar", mode: "fixed", active: true, userIds: [userId] });
|
||||
|
||||
const changes = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
|
||||
expect(changes()).toHaveLength(1);
|
||||
expect(changes()[0].payload).toMatchObject({ setting: "validationProgram.bar", operator: "admin" });
|
||||
|
||||
// Identical second save → no second config_change.
|
||||
await putProgram(admin, fixedProgram(userId));
|
||||
expect(changes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("per-mode validation: timeCredit needs minutes, percent needs percent, fixed needs a cap", async () => {
|
||||
const admin = await seedAdmin();
|
||||
expect((await putProgram(admin, { name: "X", mode: "timeCredit", active: true })).statusCode).toBe(400);
|
||||
expect((await putProgram(admin, { name: "X", mode: "percent", active: true })).statusCode).toBe(400);
|
||||
expect((await putProgram(admin, { name: "X", mode: "fixed", active: true })).statusCode).toBe(400);
|
||||
expect((await putProgram(admin, { name: "X", mode: "timeCredit", minutes: 60, active: true })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("GET /mine returns only MY bound, active programs", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
await putProgram(admin, fixedProgram(userId));
|
||||
await putProgram(admin, { name: "Lavazh", mode: "comp", active: true, userIds: [] }, "lavazh");
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/api/validation/mine", headers: hdrs(merchant) });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const programs = res.json().programs as { id: string }[];
|
||||
expect(programs.map((p) => p.id)).toEqual(["bar"]);
|
||||
});
|
||||
|
||||
it("apply: binding, session-state, duplicate and amount guards", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
const { auth: other } = await seedMerchant("tjetri");
|
||||
await putProgram(admin, fixedProgram(userId));
|
||||
seedTariff(db);
|
||||
await mint("T1", 120);
|
||||
|
||||
const apply = (auth: Auth, payload: Record<string, unknown>) =>
|
||||
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(auth), payload });
|
||||
|
||||
// Unbound merchant → 403; unknown ticket → 404; missing amount (fixed) → 400;
|
||||
// amount above the cap → 400.
|
||||
expect((await apply(other, { identity: "T1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(403);
|
||||
expect((await apply(merchant, { identity: "NOPE", programId: "bar", amountMinor: 5000 })).statusCode).toBe(404);
|
||||
expect((await apply(merchant, { identity: "T1", programId: "bar" })).statusCode).toBe(400);
|
||||
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 999999 })).statusCode).toBe(400);
|
||||
|
||||
// Subscriber sessions are never validated (prepaid).
|
||||
await mint("SUB1", 60, { permit: true, permitId: "s-1" });
|
||||
expect((await apply(merchant, { identity: "SUB1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(409);
|
||||
|
||||
// Success → a SIGNED validation event with resolved values + the merchant username.
|
||||
const ok = await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 5000 });
|
||||
expect(ok.statusCode).toBe(201);
|
||||
const ev = db.select().from(ledgerEvents).all().find((r) => r.type === "validation")!;
|
||||
expect(ev.payload).toMatchObject({
|
||||
programId: "bar",
|
||||
programLabel: "Bar",
|
||||
mode: "fixed",
|
||||
amountMinor: 5000,
|
||||
operator: "bari",
|
||||
});
|
||||
|
||||
// Same program twice on one ticket → 409.
|
||||
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 1000 })).statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it("the money cycle: quote nets the validation, pay records gross/discount and CONSUMES it", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
await putProgram(admin, fixedProgram(userId));
|
||||
// 100/h flat; 2h → gross 20000.
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
|
||||
await mint("T1", 119);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/apply",
|
||||
headers: hdrs(merchant),
|
||||
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||
});
|
||||
|
||||
const q1 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||
expect(q1.json()).toMatchObject({
|
||||
grossMinor: 20000,
|
||||
discountMinor: 5000,
|
||||
amountMinor: 15000,
|
||||
});
|
||||
expect(q1.json().validationLines).toEqual([
|
||||
{ programId: "bar", label: "Bar", mode: "fixed", discountMinor: 5000 },
|
||||
]);
|
||||
|
||||
// Pay (needs an open shift) → the payment carries the split + consumed ids.
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||
expect(pay.statusCode).toBe(201);
|
||||
expect(pay.json().amountMinor).toBe(15000);
|
||||
|
||||
const payment = db.select().from(ledgerEvents).all().find((r) => r.type === "payment")!;
|
||||
expect(payment.payload).toMatchObject({ amountMinor: 15000, grossMinor: 20000, discountMinor: 5000 });
|
||||
expect((payment.payload as { validationIds?: string[] }).validationIds).toHaveLength(1);
|
||||
|
||||
// Settled: the follow-up quote owes 0 and applies nothing further.
|
||||
const q2 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||
expect(q2.json().amountMinor).toBe(0);
|
||||
expect(q2.json().validationLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("a full comp settles at 0 through the normal pay path (grace starts, chain verifies)", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
await putProgram(admin, { name: "Lavazh falas", mode: "comp", active: true, userIds: [userId] }, "lavazh");
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
await mint("T1", 90);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/apply",
|
||||
headers: hdrs(merchant),
|
||||
payload: { identity: "T1", programId: "lavazh" },
|
||||
});
|
||||
|
||||
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||
expect(q.json().amountMinor).toBe(0);
|
||||
expect(q.json().grossMinor).toBeGreaterThan(0);
|
||||
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||
expect(pay.statusCode).toBe(201);
|
||||
expect(pay.json().amountMinor).toBe(0);
|
||||
|
||||
// The 0-net settlement still grants walk-back grace (the session reads settled).
|
||||
const view = await app.inject({ method: "GET", url: "/api/session/T1", headers: hdrs(admin) });
|
||||
expect(view.json()).toMatchObject({ withinGrace: true, amountMinor: 0 });
|
||||
});
|
||||
|
||||
it("void: own unused only; a consumed validation is locked", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
const { auth: other, userId: otherId } = await seedMerchant("tjetri");
|
||||
await putProgram(admin, fixedProgram(userId, { userIds: [userId, otherId] }));
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
await mint("T1", 90);
|
||||
|
||||
const applied = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/apply",
|
||||
headers: hdrs(merchant),
|
||||
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||
});
|
||||
const eventId = applied.json().eventId as string;
|
||||
|
||||
const voidReq = (auth: Auth) =>
|
||||
app.inject({ method: "POST", url: "/api/validation/void", headers: hdrs(auth), payload: { eventId, identity: "T1" } });
|
||||
|
||||
// Someone else's validation → 403. Own → ok, and the quote returns to gross.
|
||||
expect((await voidReq(other)).statusCode).toBe(403);
|
||||
expect((await voidReq(merchant)).statusCode).toBe(200);
|
||||
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||
expect(q.json().discountMinor).toBe(0);
|
||||
|
||||
// Re-apply (the void freed the per-session slot), consume it with a payment, then
|
||||
// a void must refuse — the settlement already happened.
|
||||
const re = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/apply",
|
||||
headers: hdrs(merchant),
|
||||
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||
});
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||
await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||
const locked = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/void",
|
||||
headers: hdrs(merchant),
|
||||
payload: { eventId: re.json().eventId, identity: "T1" },
|
||||
});
|
||||
expect(locked.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it("maxPerDay caps applications across tickets", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
await putProgram(admin, { name: "Lavazh", mode: "comp", maxPerDay: 1, active: true, userIds: [userId] }, "lavazh");
|
||||
seedTariff(db);
|
||||
await mint("T1", 60);
|
||||
await mint("T2", 30);
|
||||
|
||||
const apply = (identity: string) =>
|
||||
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(merchant), payload: { identity, programId: "lavazh" } });
|
||||
expect((await apply("T1")).statusCode).toBe(201);
|
||||
expect((await apply("T2")).statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
isNull,
|
||||
inArray,
|
||||
ledgerEvents,
|
||||
users,
|
||||
validationProgramUsers,
|
||||
validationPrograms,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
import { MERCHANT_VALIDATION_MODES, VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { requireModule } from "../modules.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import { applyValidation, liveValidations, sessionValidations } from "../validations.js";
|
||||
|
||||
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
|
||||
// customer's ticket on their own device and apply their program — all money and paper
|
||||
// stay at the booth, which settles net of these events. Program config is admin-composed
|
||||
// on /setup/site (site:read/update — no dedicated permission); applying is the merchant
|
||||
// user's `validation:create`, guarded FURTHER by the program↔user binding so a bar user
|
||||
// can never apply the lavazh program. Every apply/void is a signed, attributed ledger
|
||||
// event. See wiki/concepts/validation-discounts.md.
|
||||
// - GET /api/validation/programs : all programs + bound users. (site:read)
|
||||
// - PUT /api/validation/programs/:id : upsert config + bindings; (site:update)
|
||||
// signs a config_change.
|
||||
// - GET /api/validation/mine : my bound ACTIVE programs. (validation:create)
|
||||
// - GET /api/validation/session/:identity : minimal session view for (validation:create)
|
||||
// the merchant screen (no money data).
|
||||
// - POST /api/validation/apply : apply my program (signed). (validation:create)
|
||||
// - POST /api/validation/void : void my OWN unused apply. (validation:create)
|
||||
|
||||
/** Well-formed program ids: kebab slugs ("bar", "lavazh", a future "hotel-2"). */
|
||||
const ID_RE = /^[a-z][a-z0-9-]{1,31}$/;
|
||||
|
||||
interface ProgramBody {
|
||||
name?: string;
|
||||
mode?: ValidationMode;
|
||||
minutes?: number | null;
|
||||
percent?: number | null;
|
||||
maxAmountMinor?: number | null;
|
||||
maxPerDay?: number | null;
|
||||
active?: boolean;
|
||||
/** Full replacement set of bound user ids. */
|
||||
userIds?: string[];
|
||||
}
|
||||
|
||||
interface ApplyBody {
|
||||
identity: string;
|
||||
programId: string;
|
||||
/** fixed mode only: the discount the merchant grants (minor units, ≤ maxAmountMinor). */
|
||||
amountMinor?: number;
|
||||
}
|
||||
|
||||
interface VoidBody {
|
||||
eventId: string;
|
||||
identity: string;
|
||||
}
|
||||
|
||||
/** null when valid, else the 400 message. Checks the per-mode parameter. */
|
||||
function validateProgram(b: ProgramBody): string | null {
|
||||
if (!b.name || !String(b.name).trim()) return "name is required";
|
||||
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
|
||||
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
|
||||
// doneTolerance's minutes is a TOLERANCE — zero is a legitimate "free until done, not a
|
||||
// minute more"; every other minutes use is a positive credit.
|
||||
const minutesOk = b.mode === "doneTolerance"
|
||||
? b.minutes == null || (Number.isInteger(b.minutes) && (b.minutes as number) >= 0)
|
||||
: intOrNull(b.minutes);
|
||||
if (!minutesOk) return b.mode === "doneTolerance" ? "minutes must be a non-negative integer" : "minutes must be a positive integer";
|
||||
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
|
||||
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
|
||||
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
|
||||
return "percent must be 1..100";
|
||||
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
|
||||
if (b.mode === "doneTolerance" && b.minutes == null) return "doneTolerance needs minutes (the tolerance; 0 allowed)";
|
||||
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
|
||||
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
|
||||
// The PROGRAM routes (compose / read discount programs) are CORE: the discount engine
|
||||
// serves every module that grants a parking discount (Car Wash's "carwash" program
|
||||
// rides it), so they are never behind the validation module gate — plain site:read /
|
||||
// site:update. The MERCHANT routes (mine / lookup / apply / void — the scan screen)
|
||||
// are the validation module itself: module gate FIRST (403 module_disabled when the
|
||||
// site has validation off — see ../modules.ts), then the permission.
|
||||
const siteRead = requirePermission("site:read");
|
||||
const siteWrite = requirePermission("site:update");
|
||||
const applyGuard = [requireModule(db, "validation"), requirePermission("validation:create")];
|
||||
|
||||
const liveProgram = (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(validationPrograms)
|
||||
.where(and(eq(validationPrograms.id, id), isNull(validationPrograms.deletedAt)))
|
||||
.get();
|
||||
|
||||
const boundUserIds = (programId: string): string[] =>
|
||||
db
|
||||
.select({ userId: validationProgramUsers.userId })
|
||||
.from(validationProgramUsers)
|
||||
.where(eq(validationProgramUsers.programId, programId))
|
||||
.all()
|
||||
.map((r) => r.userId);
|
||||
|
||||
// The setup panel's read: every live program with its bound users.
|
||||
app.get("/api/validation/programs", { preHandler: siteRead }, async () => {
|
||||
const programs = db.select().from(validationPrograms).where(isNull(validationPrograms.deletedAt)).all();
|
||||
return {
|
||||
programs: programs.map((p) => ({ ...p, userIds: boundUserIds(p.id) })),
|
||||
};
|
||||
});
|
||||
|
||||
// Upsert a program (the /setup/site checkbox + panel). Creates the well-known row on
|
||||
// first enable; replaces the binding set; signs an attributed config_change when
|
||||
// anything actually changed (the entry-presence-bypass precedent — enabling a discount
|
||||
// program is fraud-relevant config).
|
||||
app.put<{ Params: { id: string }; Body: ProgramBody }>(
|
||||
"/api/validation/programs/:id",
|
||||
{ preHandler: siteWrite },
|
||||
async (req, reply) => {
|
||||
const id = (req.params.id ?? "").trim();
|
||||
if (!ID_RE.test(id)) return reply.code(400).send({ error: "invalid program id" });
|
||||
const b = req.body ?? ({} as ProgramBody);
|
||||
const bad = validateProgram(b);
|
||||
if (bad) return reply.code(400).send({ error: bad });
|
||||
|
||||
const userIds = Array.isArray(b.userIds) ? [...new Set(b.userIds)] : [];
|
||||
if (userIds.length) {
|
||||
const found = db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(inArray(users.id, userIds), isNull(users.deletedAt)))
|
||||
.all();
|
||||
if (found.length !== userIds.length) return reply.code(400).send({ error: "unknown user in userIds" });
|
||||
}
|
||||
|
||||
const prev = liveProgram(id);
|
||||
const prevUserIds = prev ? boundUserIds(id).sort() : [];
|
||||
const next = {
|
||||
name: String(b.name).trim(),
|
||||
mode: b.mode as ValidationMode,
|
||||
minutes: b.minutes ?? null,
|
||||
percent: b.percent ?? null,
|
||||
maxAmountMinor: b.maxAmountMinor ?? null,
|
||||
maxPerDay: b.maxPerDay ?? null,
|
||||
active: b.active === true,
|
||||
};
|
||||
|
||||
if (prev) {
|
||||
db.update(validationPrograms).set(next).where(eq(validationPrograms.id, id)).run();
|
||||
} else {
|
||||
db.insert(validationPrograms).values({ id, ...next }).run();
|
||||
}
|
||||
db.delete(validationProgramUsers).where(eq(validationProgramUsers.programId, id)).run();
|
||||
for (const userId of userIds) {
|
||||
db.insert(validationProgramUsers).values({ programId: id, userId }).run();
|
||||
}
|
||||
|
||||
// Sign the change (attributed) — enabling/reshaping a discount program is
|
||||
// fraud-relevant config. Compare against the previous row + binding set so a
|
||||
// no-op save signs nothing.
|
||||
const summary = (row: typeof next, ids: string[]) => JSON.stringify({ ...row, userIds: [...ids].sort() });
|
||||
const prevSummary = prev
|
||||
? summary(
|
||||
{ name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
|
||||
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active },
|
||||
prevUserIds,
|
||||
)
|
||||
: null;
|
||||
if (prevSummary !== summary(next, userIds)) {
|
||||
await eventLog.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: `validation-program:${id}`,
|
||||
payload: {
|
||||
setting: `validationProgram.${id}`,
|
||||
value: { ...next, userCount: userIds.length },
|
||||
prev: prev
|
||||
? { name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
|
||||
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active }
|
||||
: null,
|
||||
operator: req.user?.username ?? "unknown",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const row = liveProgram(id);
|
||||
return { ...row, userIds: boundUserIds(id) };
|
||||
},
|
||||
);
|
||||
|
||||
// The merchant screen's program list: MY bound, active programs.
|
||||
app.get("/api/validation/mine", { preHandler: applyGuard }, async (req) => {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(validationPrograms)
|
||||
.innerJoin(validationProgramUsers, eq(validationProgramUsers.programId, validationPrograms.id))
|
||||
.where(
|
||||
and(
|
||||
eq(validationProgramUsers.userId, req.user.sub),
|
||||
eq(validationPrograms.active, true),
|
||||
isNull(validationPrograms.deletedAt),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
return { programs: rows.map((r) => r.validation_programs) };
|
||||
});
|
||||
|
||||
// Minimal session view for the merchant screen — deliberately NO money data (the
|
||||
// merchant validates; the booth settles): found/open/entry time + the validations
|
||||
// already on the session (so the UI can show "already validated" and offer void).
|
||||
app.get<{ Params: { identity: string } }>(
|
||||
"/api/validation/session/:identity",
|
||||
{ preHandler: applyGuard },
|
||||
async (req, reply) => {
|
||||
const identity = (req.params.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
const rows = db
|
||||
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, identity))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return { identity, found: false, open: false, enteredAt: null, subscription: false, validations: [] };
|
||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
const subscription = entryPl.permit === true || entryPl.permitId != null;
|
||||
const open = !rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
|
||||
return {
|
||||
identity,
|
||||
found: true,
|
||||
open,
|
||||
enteredAt: entry.occurredAt,
|
||||
subscription,
|
||||
validations: sessionValidations(db, identity),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// APPLY: the merchant's one action. Guards, in order: program live+active → the
|
||||
// user is BOUND to it → the session is an OPEN TRANSIENT → not already carrying a
|
||||
// live application of this program → per-day cap → fixed-amount bounds. Appends the
|
||||
// signed validation event with the RESOLVED values.
|
||||
app.post<{ Body: ApplyBody }>("/api/validation/apply", { preHandler: applyGuard }, async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
const programId = (req.body?.programId ?? "").trim();
|
||||
if (!identity || !programId) return reply.code(400).send({ error: "identity and programId required" });
|
||||
|
||||
const program = liveProgram(programId);
|
||||
if (!program || !program.active) return reply.code(404).send({ error: "program not found or inactive" });
|
||||
if (!boundUserIds(programId).includes(req.user.sub)) {
|
||||
return reply.code(403).send({ error: "you are not bound to this program" });
|
||||
}
|
||||
if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) {
|
||||
return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" });
|
||||
}
|
||||
|
||||
// The decision chain + the signed append live in ../validations.ts (applyValidation)
|
||||
// — shared with the Car Wash module, which applies its own sponsorship program with
|
||||
// no user binding. Only the binding check above is merchant-specific.
|
||||
const result = await applyValidation(db, eventLog, {
|
||||
programId,
|
||||
identity,
|
||||
actor: req.user.username,
|
||||
amountMinor: req.body?.amountMinor,
|
||||
});
|
||||
if (!result.ok) return reply.code(result.status).send({ error: result.error });
|
||||
return reply.code(201).send(result);
|
||||
});
|
||||
|
||||
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
|
||||
// a validation event with refId, never a delete. Refused once a payment consumed it
|
||||
// (the settlement already happened — that dispute goes to the booth/admin).
|
||||
app.post<{ Body: VoidBody }>("/api/validation/void", { preHandler: applyGuard }, async (req, reply) => {
|
||||
const eventId = (req.body?.eventId ?? "").trim();
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!eventId || !identity) return reply.code(400).send({ error: "eventId and identity required" });
|
||||
const target = sessionValidations(db, identity).find((v) => v.eventId === eventId);
|
||||
if (!target) return reply.code(404).send({ error: "validation not found" });
|
||||
if (target.operator !== req.user.username) {
|
||||
return reply.code(403).send({ error: "you may only void your own validation" });
|
||||
}
|
||||
if (target.voided) return reply.code(409).send({ error: "already voided" });
|
||||
if (target.consumedBy != null) {
|
||||
return reply.code(409).send({ error: "already used in a payment — ask the booth/admin" });
|
||||
}
|
||||
await eventLog.append({
|
||||
type: "validation",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
refId: eventId,
|
||||
programId: target.programId,
|
||||
programLabel: target.label,
|
||||
operator: req.user.username,
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
+142
-20
@@ -1,11 +1,19 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { roleHasPermissions } from "../auth.js";
|
||||
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
|
||||
import { feedPermissionFor, watchPermissions, type LedgerEvent, type Permission } from "@parking/shared";
|
||||
import { currentRoleId, requireAuth, roleHasPermissions } from "../auth.js";
|
||||
import { effectiveModulesFor } from "../modules.js";
|
||||
import {
|
||||
deviceEvents,
|
||||
type LaneStatusEvent,
|
||||
type LanePresenceEvent,
|
||||
type PlateRecognizedEvent,
|
||||
} from "../device-events.js";
|
||||
import { enrichEvent } from "../event-enrich.js";
|
||||
import type { DeviceMonitor } from "../device-monitor.js";
|
||||
import type { LaneStatus } from "../lane-status.js";
|
||||
import type { LanePresence } from "../lane-presence.js";
|
||||
import { getOccupancy } from "../occupancy.js";
|
||||
|
||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||
@@ -25,10 +33,61 @@ import { getOccupancy } from "../occupancy.js";
|
||||
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
||||
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
||||
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
||||
//
|
||||
// Desktop shell (Tauri) exception — the WS TICKET. The desktop app's HTTP goes
|
||||
// through tauri-plugin-http (reqwest, its own cookie jar) and its WebSocket
|
||||
// through tauri-plugin-websocket (bare tungstenite, NO cookie jar at all), so
|
||||
// the JWT cookie set at login can never ride on the WS handshake — jwtVerify()
|
||||
// would 401 every connect (found 2026-09-04: the desktop live feed reconnected
|
||||
// every 10s forever). The JWT is HttpOnly and must stay out of JS, so instead
|
||||
// the desktop client POSTs /api/ws/ticket (normal cookie + CSRF auth) to get a
|
||||
// single-use, 30-second random ticket bound to its user, and presents it in an
|
||||
// `x-ws-ticket` header on the handshake. A browser page cannot set custom
|
||||
// headers on a WebSocket, so this path is unreachable from a browser and adds
|
||||
// no CSWSH surface; the Origin allowlist still applies to both paths.
|
||||
|
||||
/** Permission required to watch the live feed (a read-only stream of ledger +
|
||||
* device status). Any role granted `report:read` may watch. */
|
||||
const WATCH_PERMISSION = "report:read" as const;
|
||||
// WHO may watch, and WHAT they see (venue-modules.md §"Permissions matrix", move 3):
|
||||
// a role connects if it holds ANY watch permission — the core feed/occupancy/device
|
||||
// ones or an effective module's own (carwash:read) — and every pushed message is then
|
||||
// FILTERED per role: a ledger event needs feedPermissionFor(type) (the owning module's,
|
||||
// else event:read); occupancy + the plate backfill need session:read; device / printer /
|
||||
// lane / radar need device:read. `report:read` is the REPORTS screen, not the socket: the
|
||||
// wash desk gets a live queue without the booth's ledger, the booth a feed without reports.
|
||||
type Viewer = { has: (p: Permission) => boolean };
|
||||
|
||||
/** Handshake header carrying a desktop WS ticket (see file header). */
|
||||
const WS_TICKET_HEADER = "x-ws-ticket";
|
||||
/** A ticket is only good for the connect that immediately follows its issue. */
|
||||
const WS_TICKET_TTL_MS = 30_000;
|
||||
|
||||
interface WsTicket {
|
||||
sub: string;
|
||||
roleId: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** Outstanding tickets. Tiny (one per desktop connect attempt), in-memory only —
|
||||
* a server restart invalidates them, which is fine: the client just asks for
|
||||
* another on its next reconnect. */
|
||||
const tickets = new Map<string, WsTicket>();
|
||||
|
||||
function issueWsTicket(sub: string, roleId: string): string {
|
||||
const now = Date.now();
|
||||
for (const [key, t] of tickets) {
|
||||
if (t.expiresAt <= now) tickets.delete(key);
|
||||
}
|
||||
const ticket = randomBytes(32).toString("hex");
|
||||
tickets.set(ticket, { sub, roleId, expiresAt: now + WS_TICKET_TTL_MS });
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/** Single-use: the ticket is removed whether or not it turns out to be valid. */
|
||||
function consumeWsTicket(ticket: string): WsTicket | null {
|
||||
const t = tickets.get(ticket);
|
||||
if (!t) return null;
|
||||
tickets.delete(ticket);
|
||||
return t.expiresAt > Date.now() ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
||||
@@ -53,36 +112,80 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
||||
}
|
||||
|
||||
type OutMsg =
|
||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
|
||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||
| {
|
||||
kind: "hello";
|
||||
occupancy: ReturnType<typeof getOccupancy> | null;
|
||||
devices: unknown;
|
||||
lanes: LaneStatusEvent | null;
|
||||
radar: LanePresenceEvent | null;
|
||||
}
|
||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> | null }
|
||||
| { kind: "printer-status"; event: unknown }
|
||||
| { kind: "device-status"; event: unknown }
|
||||
| { kind: "lane-status"; lanes: LaneStatusEvent };
|
||||
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
||||
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
/** The role the WS preHandler authenticated (ticket or cookie path) — for the handler's filter. */
|
||||
wsRoleId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export async function wsRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
deviceMonitor: DeviceMonitor,
|
||||
laneStatus: LaneStatus,
|
||||
lanePresence: LanePresence,
|
||||
): Promise<void> {
|
||||
// Desktop-only: mint a WS ticket for the signed-in session (see file header).
|
||||
// Ordinary cookie + CSRF auth — the desktop client CAN do that over HTTP (via
|
||||
// tauri-plugin-http), it just can't carry the cookie onto the WebSocket.
|
||||
app.post("/api/ws/ticket", { preHandler: requireAuth }, async (req) => ({
|
||||
ticket: issueWsTicket(req.user.sub, req.user.roleId),
|
||||
expiresInMs: WS_TICKET_TTL_MS,
|
||||
}));
|
||||
|
||||
app.get(
|
||||
"/api/ws",
|
||||
{
|
||||
websocket: true,
|
||||
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
|
||||
// role. Reject a cross/absent origin before touching the token, so a hijack
|
||||
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
|
||||
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN
|
||||
// session (JWT cookie, or a desktop WS ticket) THEN role. Reject a
|
||||
// cross/absent origin before touching either credential, so a hijack
|
||||
// attempt never reaches an authenticated socket.
|
||||
preHandler: async (req) => {
|
||||
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
||||
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
||||
}
|
||||
await req.jwtVerify();
|
||||
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
|
||||
const rawTicket = req.headers[WS_TICKET_HEADER];
|
||||
const ticket = Array.isArray(rawTicket) ? rawTicket[0] : rawTicket;
|
||||
let roleId: string;
|
||||
if (ticket !== undefined) {
|
||||
const t = consumeWsTicket(ticket);
|
||||
if (!t) {
|
||||
throw Object.assign(new Error("invalid or expired ws ticket"), { statusCode: 401 });
|
||||
}
|
||||
roleId = t.roleId;
|
||||
} else {
|
||||
await req.jwtVerify(); // reads the HttpOnly cookie (browser path)
|
||||
if (!req.user) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
roleId = currentRoleId(req.user.sub) ?? "";
|
||||
}
|
||||
const may = watchPermissions(effectiveModulesFor(db)).some((p) => roleHasPermissions(roleId, [p]));
|
||||
if (!may) throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
req.wsRoleId = roleId;
|
||||
},
|
||||
},
|
||||
(socket) => {
|
||||
(socket, req) => {
|
||||
const roleId = req.wsRoleId ?? req.user?.roleId ?? "";
|
||||
const viewer: Viewer = { has: (p) => roleHasPermissions(roleId, [p]) };
|
||||
const seesOccupancy = viewer.has("session:read");
|
||||
const seesDevices = viewer.has("device:read");
|
||||
const send = (msg: OutMsg) => {
|
||||
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
||||
if (socket.readyState === 1) {
|
||||
@@ -96,26 +199,43 @@ export async function wsRoutes(
|
||||
|
||||
// Initial snapshot so the client renders immediately, before any event:
|
||||
// occupancy AND the current device-status set (for the footer).
|
||||
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
|
||||
// Each part of the snapshot only for a role that may see it (null otherwise).
|
||||
send({
|
||||
kind: "hello",
|
||||
occupancy: seesOccupancy ? getOccupancy(db) : null,
|
||||
devices: seesDevices ? deviceMonitor.snapshot() : null,
|
||||
lanes: seesDevices ? laneStatus.snapshot() : null,
|
||||
radar: seesDevices ? lanePresence.snapshot() : null,
|
||||
});
|
||||
|
||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||
const offLedger = deviceEvents.onLedger((event) => {
|
||||
// Per-role filter: the event type's feed permission (module's own, else event:read).
|
||||
if (!viewer.has(feedPermissionFor((event as { type: LedgerEvent["type"] }).type))) return;
|
||||
// Enrich with read-time display fields (subscriber name) before fan-out.
|
||||
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
|
||||
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
|
||||
send({ kind: "ledger", event: enriched, occupancy: seesOccupancy ? getOccupancy(db) : null });
|
||||
});
|
||||
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
||||
send({ kind: "printer-status", event });
|
||||
if (seesDevices) send({ kind: "printer-status", event });
|
||||
});
|
||||
// Unified device status (all categories) for the booth footer — pushed on
|
||||
// change; the initial set rode the hello above.
|
||||
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
||||
send({ kind: "device-status", event });
|
||||
if (seesDevices) send({ kind: "device-status", event });
|
||||
});
|
||||
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
|
||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||
send({ kind: "lane-status", lanes });
|
||||
if (seesDevices) send({ kind: "lane-status", lanes });
|
||||
});
|
||||
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||
if (seesDevices) send({ kind: "lane-presence", radar });
|
||||
});
|
||||
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
||||
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
||||
if (seesOccupancy) send({ kind: "plate-recognized", plate });
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
@@ -123,6 +243,8 @@ export async function wsRoutes(
|
||||
offPrinter();
|
||||
offDevice();
|
||||
offLane();
|
||||
offPresence();
|
||||
offPlate();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+113
-20
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import { ButtonLightController } from "./button-light.js";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { EventLog } from "./event-log.js";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
@@ -19,6 +20,9 @@ import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { DeviceMonitor } from "./device-monitor.js";
|
||||
import { buildSigner, buildVerifier } from "./signer.js";
|
||||
import { LogService, pinoDbStream } from "./log-service.js";
|
||||
import { pruneSnapshots } from "./snapshot-retention.js";
|
||||
import { BackupService } from "./backup-service.js";
|
||||
import { backupRoutes } from "./routes/backup.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { VisionClient } from "./vision-client.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
@@ -27,6 +31,7 @@ import { roleRoutes } from "./routes/roles.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||
import { LaneStatus } from "./lane-status.js";
|
||||
import { LanePresence } from "./lane-presence.js";
|
||||
import { AnprBridge } from "./anpr-entry.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
import { reportRoutes } from "./routes/reports.js";
|
||||
@@ -37,7 +42,10 @@ import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||
import { shiftRoutes } from "./routes/shift.js";
|
||||
import { drawerRoutes } from "./routes/drawer.js";
|
||||
import { entryRoutes } from "./routes/entry.js";
|
||||
import { siteRoutes } from "./routes/site.js";
|
||||
import { registerModules } from "./modules/index.js";
|
||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
import { printerRoutes } from "./routes/printers.js";
|
||||
@@ -64,7 +72,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
const logService = new LogService(db);
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
// Level knob: trace|debug|info|warn|error|fatal (pino). Default info; a booth
|
||||
// being diagnosed can run LOG_LEVEL=debug without a code change.
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
// Container logs are read by humans (`docker logs` / Komodo), so stamp
|
||||
// ISO-8601 UTC instead of pino's epoch-ms, and level NAMES instead of the
|
||||
// numeric codes (30/40/50). pinoDbStream accepts both encodings.
|
||||
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||
formatters: { level: (label) => ({ level: label }) },
|
||||
stream: pinoDbStream(logService, process.stdout),
|
||||
},
|
||||
});
|
||||
@@ -91,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
// Unauthenticated liveness probe. `app` lets a client (the desktop ConnectScreen
|
||||
// test — apps/web/src/lib/backend-config.ts) tell THIS server apart from any
|
||||
// other service that happens to answer on the address the operator typed.
|
||||
app.get("/health", async () => ({ status: "ok", app: "parking-system" }));
|
||||
|
||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||
await authRoutes(app, db);
|
||||
@@ -99,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// RBAC administration: compose roles (role:*) + manage users (user:*). The
|
||||
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
|
||||
await userRoutes(app, db);
|
||||
await roleRoutes(app, db);
|
||||
|
||||
// Vision (ANPR) client — built early so the device monitor can include the vision
|
||||
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
|
||||
@@ -108,11 +125,25 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
const visionClient = new VisionClient(app.log);
|
||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||
|
||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||
// in device_events. The entry flow turns an input into a signed vehicle_entry once
|
||||
// a ticket prints + the barrier is commanded. See event-streams-split.md.
|
||||
// Constructed HERE (before setupRoutes) so the Setup relay-test can sign its
|
||||
// deliberate barrier open into the ledger; the read routes are wired further down.
|
||||
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||
deviceEvents.emitLedger(row),
|
||||
);
|
||||
|
||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||
await setupRoutes(app, db, visionClient);
|
||||
await setupRoutes(app, db, visionClient, eventLog);
|
||||
await roleRoutes(app, db, eventLog);
|
||||
|
||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||
@@ -124,6 +155,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
const laneStatus = new LaneStatus(db, app.log);
|
||||
app.addHook("onClose", async () => laneStatus.stop());
|
||||
|
||||
// Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the
|
||||
// physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI.
|
||||
const lanePresence = new LanePresence(db, app.log);
|
||||
lanePresence.start();
|
||||
app.addHook("onClose", async () => lanePresence.stop());
|
||||
|
||||
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
||||
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||
@@ -145,17 +182,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
app.addHook("onReady", async () => deviceMonitor.start());
|
||||
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||
|
||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||
// See wiki/decisions/event-streams-split.md.
|
||||
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||
deviceEvents.emitLedger(row),
|
||||
);
|
||||
// Read routes for the signed ledger (constructed above, before setupRoutes).
|
||||
await eventRoutes(app, db, eventLog);
|
||||
|
||||
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||
@@ -168,7 +195,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
|
||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||
await wsRoutes(app, db, deviceMonitor, laneStatus);
|
||||
await wsRoutes(app, db, deviceMonitor, laneStatus, lanePresence);
|
||||
|
||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||
await snapshotRoutes(app, db);
|
||||
@@ -187,6 +214,17 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
void entryFlow.onInput(e);
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeEntry());
|
||||
// The camera press-gate: the entry flow mirrors the entry lane's camera state so a
|
||||
// physical press is live only in the lamp's SOLID state (see entry-flow.ts).
|
||||
const unsubscribeEntryLane = deviceEvents.onLaneStatus((s) => entryFlow.onLaneStatus(s));
|
||||
app.addHook("onClose", async () => unsubscribeEntryLane());
|
||||
|
||||
// Button-light indicator: drives the entry button's lamp on a spare relay from the
|
||||
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
|
||||
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
|
||||
const buttonLight = new ButtonLightController(db, app.log);
|
||||
buttonLight.start();
|
||||
app.addHook("onClose", async () => buttonLight.stop());
|
||||
|
||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||
@@ -218,10 +256,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||
const credentialCapture = new CredentialCapture();
|
||||
|
||||
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||
// Dingtian DT-008 QR/RFID reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
||||
// on an armed reader for enrollment; otherwise the read routes through the
|
||||
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||
// dispatcher. See wiki/entities/dingtian-dt008-reader.md, qrcode-sdk.md.
|
||||
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
||||
|
||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||
@@ -247,17 +285,35 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||
await subscriptionPlanRoutes(app, db);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService, db);
|
||||
// Shift open/close (shiftService constructed above).
|
||||
await shiftRoutes(app, db, shiftService);
|
||||
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||
await drawerRoutes(app, db, shiftService);
|
||||
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
||||
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
||||
|
||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||
await siteRoutes(app, db);
|
||||
await siteRoutes(app, db, eventLog);
|
||||
|
||||
// Venue modules (wiki/decisions/venue-modules.md): folder-based modules register
|
||||
// here by iterating the shared registry — today that is `validation` (merchant
|
||||
// validations for the Bar; the booth settlement folds applied validations into its
|
||||
// quote, pay-station.ts). `parking` is in the registry too but its routes are still
|
||||
// the flat list above; they move behind the seam subsystem by subsystem.
|
||||
await registerModules(app, { db, eventLog, payStation, shiftService });
|
||||
|
||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||
await logRoutes(app, logService);
|
||||
|
||||
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target
|
||||
// directory is admin-chosen (site_config), the key is an env secret; status + a manual "back
|
||||
// up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND
|
||||
// BACKUP_KEY is present. See wiki/concepts/backup-recovery.md.
|
||||
const backupService = new BackupService(db, app.log);
|
||||
await backupRoutes(app, db, backupService);
|
||||
|
||||
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
||||
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
||||
const pruneTimer = setInterval(() => {
|
||||
@@ -268,6 +324,43 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
logService.prune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
||||
|
||||
// Snapshot retention prune — DISK-PRESSURE safety valve: only when the DB's filesystem
|
||||
// crosses the high-water mark do we delete the oldest snapshots + VACUUM. A no-op the rest
|
||||
// of the time. Daily, unref'd, plus once at startup. See snapshot-retention.ts.
|
||||
const runSnapPrune = async () => {
|
||||
const res = await pruneSnapshots(db, {}, app.log);
|
||||
if (res.deletedRows > 0) {
|
||||
app.log.info(
|
||||
`pruned ${res.deletedRows} snapshots, freed ~${(res.freedBytesEst / 1048576).toFixed(0)} MB ` +
|
||||
`(disk was ${res.usedPctBefore.toFixed(0)}% used${res.vacuumed ? ", vacuumed" : ""})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const snapPruneTimer = setInterval(() => void runSnapPrune(), 24 * 60 * 60 * 1000);
|
||||
snapPruneTimer.unref();
|
||||
void runSnapPrune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||
|
||||
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
|
||||
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
|
||||
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
|
||||
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
|
||||
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
|
||||
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
|
||||
// persisted — made the admin UI show "Never" despite valid backups already on disk
|
||||
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
|
||||
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
|
||||
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
|
||||
// unreachable/unmounted target by recording the error and trying again next check. NOT run
|
||||
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
|
||||
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
|
||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
|
||||
backupTimer.unref();
|
||||
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||
if (backupService.configured) {
|
||||
app.log.info("backup: scheduled daily encrypted backup enabled");
|
||||
}
|
||||
|
||||
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
|
||||
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
|
||||
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
||||
|
||||
@@ -117,27 +117,100 @@ describe("drawer carry-forward", () => {
|
||||
expect(next.openingFloatMinor).toBe(25000); // inherited
|
||||
});
|
||||
|
||||
it("cash_in / cash_out vouchers adjust the drawer", async () => {
|
||||
it("cash_in / cash_out movements adjust the drawer", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" });
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: 30000, reason: "bank drop" });
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float load" });
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 30000, reason: "bank drop" });
|
||||
const r = shift.currentReport()!;
|
||||
expect(r.cashAddedMinor).toBe(100000);
|
||||
expect(r.cashRemovedMinor).toBe(30000);
|
||||
expect(r.expectedDrawerMinor).toBe(70000);
|
||||
});
|
||||
|
||||
it("rejects a non-positive voucher amount", async () => {
|
||||
it("rejects a non-positive movement amount", async () => {
|
||||
await shift.open("alice");
|
||||
await expect(
|
||||
shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }),
|
||||
shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 0, reason: "x" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||
await expect(
|
||||
shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }),
|
||||
shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: -5, reason: "x" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drawer review (operator records, admin reviews after)", () => {
|
||||
it("a new movement starts pending; review sets authorized/denied", async () => {
|
||||
await shift.open("alice");
|
||||
const m = await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 5000, reason: "supplies" });
|
||||
// Find the movement's ledger id via the status list.
|
||||
let list = shift.movementsWithStatus({ operator: "alice" });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].status).toBe("pending");
|
||||
expect(list[0].voucherNo).toBe(m.voucherNo);
|
||||
|
||||
await shift.reviewMovement({ refId: list[0].id, decision: "deny", reviewedBy: "admin", note: "not genuine" });
|
||||
list = shift.movementsWithStatus({ operator: "alice" });
|
||||
expect(list[0].status).toBe("denied");
|
||||
expect(list[0].reviewedBy).toBe("admin");
|
||||
expect(list[0].reviewNote).toBe("not genuine");
|
||||
});
|
||||
|
||||
it("DENY is a flag only — it does NOT reverse the movement or touch the drawer", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 10000, reason: "x" });
|
||||
const before = shift.drawerBalance().balanceMinor;
|
||||
expect(before).toBe(-10000); // the disbursement counted immediately
|
||||
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||
// Balance UNCHANGED by the denial — the correction is settled outside the app.
|
||||
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||
});
|
||||
|
||||
it("a denied movement in a CLOSED shift never leaks into the next operator's drawer", async () => {
|
||||
// The regression that motivated the redesign: op1 disburses, shift closes, op2
|
||||
// inherits; op1's disbursement is later DENIED. op2's drawer must be untouched.
|
||||
await shift.open("op1");
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "op1", amountMinor: 10000, reason: "questionable" });
|
||||
const closed = await shift.close("op1");
|
||||
expect(closed.expectedDrawerMinor).toBe(-10000);
|
||||
|
||||
const next = await shift.open("op2");
|
||||
expect(next.openingFloatMinor).toBe(-10000); // op2 inherits the real till balance
|
||||
|
||||
const id = shift.movementsWithStatus({ operator: "op1" })[0].id;
|
||||
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||
|
||||
// op2's drawer is STILL -10000 — the denial added no reversing cash.
|
||||
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||
expect(shift.currentReport()!.openingFloatMinor).toBe(-10000);
|
||||
});
|
||||
|
||||
it("rejects reviewing a non-movement or an already-reviewed movement", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 5000, reason: "x" });
|
||||
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||
await expect(
|
||||
shift.reviewMovement({ refId: "not-a-real-id", decision: "authorize", reviewedBy: "admin" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||
await shift.reviewMovement({ refId: id, decision: "authorize", reviewedBy: "admin" });
|
||||
await expect(
|
||||
shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError); // already reviewed
|
||||
});
|
||||
|
||||
it("scopes movements by operator", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 1000, reason: "a" });
|
||||
await shift.close("alice");
|
||||
await shift.open("bob");
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "bob", amountMinor: 2000, reason: "b" });
|
||||
expect(shift.movementsWithStatus({ operator: "alice" })).toHaveLength(1);
|
||||
expect(shift.movementsWithStatus({ operator: "bob" })).toHaveLength(1);
|
||||
expect(shift.movementsWithStatus()).toHaveLength(2); // reviewer sees all
|
||||
expect(shift.movementsWithStatus({ status: "pending" })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close signs a Z-report; listShifts reads it back", () => {
|
||||
it("a closed shift appears in history with its split figures", async () => {
|
||||
await shift.open("alice");
|
||||
@@ -161,4 +234,101 @@ describe("close signs a Z-report; listShifts reads it back", () => {
|
||||
await shift.open("bob"); await shift.close("bob");
|
||||
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||
});
|
||||
|
||||
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
|
||||
await shift.open("bob"); await shift.close("bob");
|
||||
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
|
||||
await shift.open("alice"); // open, no z-report yet
|
||||
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tills: one shift per till, one drawer per till", () => {
|
||||
/** A bay payment as the Car Wash module signs it (till = carwash). */
|
||||
async function bayPayment(amountMinor: number, tender: "cash" | "card" = "cash") {
|
||||
await log.append({
|
||||
type: "carwash_payment", source: "manual", identity: "T",
|
||||
payload: { sessionRef: "T", orderId: "o1", amountMinor, currency: "ALL", tender, till: "carwash" },
|
||||
});
|
||||
}
|
||||
|
||||
it("the booth and the carwash till can both be open at once, by different operators", async () => {
|
||||
await shift.open("alice");
|
||||
await expect(shift.open("wanda", "carwash")).resolves.toMatchObject({ till: "carwash" });
|
||||
expect(shift.currentOpenShift()?.identity).toBe("alice");
|
||||
expect(shift.currentOpenShift("carwash")?.identity).toBe("wanda");
|
||||
// Each till keeps its own single-open rule.
|
||||
await expect(shift.open("bob", "carwash")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||
});
|
||||
|
||||
it("requireOpenShift is per till: a booth shift does not cover the bay", async () => {
|
||||
await shift.open("alice");
|
||||
expect(() => shift.requireOpenShift("carwash")).toThrow(NoShiftOpenError);
|
||||
await shift.open("wanda", "carwash");
|
||||
expect(shift.requireOpenShift("carwash").identity).toBe("wanda");
|
||||
});
|
||||
|
||||
it("money folds into ITS till only: bay cash is the wash operator's, not the booth's", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.open("wanda", "carwash");
|
||||
await payment(10000); // booth (payment events carry till=booth or nothing)
|
||||
await bayPayment(70000);
|
||||
await bayPayment(20000, "card");
|
||||
|
||||
const booth = shift.currentReport()!;
|
||||
expect(booth.till).toBe("booth");
|
||||
expect(booth.cashTotalMinor).toBe(10000);
|
||||
expect(booth.paymentCount).toBe(1);
|
||||
expect(booth.expectedDrawerMinor).toBe(10000);
|
||||
|
||||
const wash = shift.currentReport("carwash")!;
|
||||
expect(wash.till).toBe("carwash");
|
||||
expect(wash.cashTotalMinor).toBe(70000);
|
||||
expect(wash.cardTotalMinor).toBe(20000);
|
||||
expect(wash.paymentCount).toBe(2);
|
||||
expect(wash.expectedDrawerMinor).toBe(70000);
|
||||
|
||||
expect(shift.drawerBalance().balanceMinor).toBe(10000);
|
||||
expect(shift.drawerBalance("carwash").balanceMinor).toBe(70000);
|
||||
});
|
||||
|
||||
it("vouchers name their till; each till's expected drawer carries forward on its own", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.open("wanda", "carwash");
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "wanda", amountMinor: 5000, reason: "float", till: "carwash" });
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float" });
|
||||
await bayPayment(70000);
|
||||
expect(shift.movementsWithStatus({ till: "carwash" }).map((m) => m.amountMinor)).toEqual([5000]);
|
||||
|
||||
const washZ = await shift.close("wanda", "carwash");
|
||||
expect(washZ).toMatchObject({ till: "carwash", cashAddedMinor: 5000, cashTotalMinor: 70000, expectedDrawerMinor: 75000 });
|
||||
const boothZ = await shift.close("alice");
|
||||
expect(boothZ).toMatchObject({ till: "booth", cashAddedMinor: 100000, cashTotalMinor: 0, expectedDrawerMinor: 100000 });
|
||||
|
||||
// Next shift on each till inherits that till's drawer only.
|
||||
expect((await shift.open("wanda", "carwash")).openingFloatMinor).toBe(75000);
|
||||
expect((await shift.open("bob")).openingFloatMinor).toBe(100000);
|
||||
});
|
||||
|
||||
it("close is per till: closing the booth never closes the wash desk", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.open("alice", "carwash");
|
||||
await shift.close("alice");
|
||||
expect(shift.currentOpenShift()).toBeNull();
|
||||
expect(shift.currentOpenShift("carwash")?.identity).toBe("alice");
|
||||
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
|
||||
});
|
||||
|
||||
it("history lists both tills, filterable; pre-till reports read as booth", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.open("wanda", "carwash");
|
||||
await shift.close("wanda", "carwash");
|
||||
await shift.close("alice");
|
||||
const all = shift.listShifts();
|
||||
expect(all.map((s) => s.till).sort()).toEqual(["booth", "carwash"]);
|
||||
expect(shift.listShifts({ till: "carwash" }).map((s) => s.operator)).toEqual(["wanda"]);
|
||||
expect(shift.listShifts({ till: "booth" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||
expect(shift.listOperators("carwash")).toEqual(["wanda"]);
|
||||
});
|
||||
});
|
||||
|
||||
+405
-131
@@ -1,6 +1,6 @@
|
||||
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
||||
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
||||
import type { LedgerPayload } from "@parking/shared";
|
||||
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
|
||||
import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
|
||||
import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
@@ -8,33 +8,46 @@ import type { EventLog } from "./event-log.js";
|
||||
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
||||
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
||||
// `payment` events taken during the shift by tender and print a Z-report.
|
||||
//
|
||||
// TILLS (2026-09-05): a shift is opened ON A TILL — the booth, or a money-taking
|
||||
// module's own desk (Car Wash → "carwash"). One shift may be open PER TILL, each with
|
||||
// its own operator, opening float, expected drawer and Z-report. Every money event
|
||||
// names its till (`payload.till`; absent = booth, which is what every pre-till event
|
||||
// is), and every fold in this file filters by it. Every public method takes the till,
|
||||
// defaulting to the booth so the parking paths read as they always did.
|
||||
// See wiki/concepts/shift.md.
|
||||
|
||||
export class ShiftAlreadyOpenError extends Error {
|
||||
/** The operator who currently holds the open shift (may be someone else). */
|
||||
readonly heldBy: string;
|
||||
constructor(operator: string, heldBy: string) {
|
||||
constructor(operator: string, heldBy: string, till: TillId = BOOTH_TILL) {
|
||||
super(
|
||||
heldBy === operator
|
||||
? `operator ${operator} already has an open shift`
|
||||
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
|
||||
? `operator ${operator} already has an open ${till} shift`
|
||||
: `another operator (${heldBy}) has an open ${till} shift; only one shift may be open per till`,
|
||||
);
|
||||
this.name = "ShiftAlreadyOpenError";
|
||||
this.heldBy = heldBy;
|
||||
}
|
||||
}
|
||||
export class NoOpenShiftError extends Error {
|
||||
constructor(operator: string) {
|
||||
super(`operator ${operator} has no open shift`);
|
||||
constructor(operator: string, till: TillId = BOOTH_TILL) {
|
||||
super(`operator ${operator} has no open ${till} shift`);
|
||||
this.name = "NoOpenShiftError";
|
||||
}
|
||||
}
|
||||
/** Thrown by the booth money path when NO shift is open site-wide — an operator
|
||||
* must open a shift before any payment/exit can be attributed to a shift. */
|
||||
/** Thrown by a money path when NO shift is open on its till — an operator must open
|
||||
* a shift there before any payment/exit can be attributed to one. */
|
||||
export class NoShiftOpenError extends Error {
|
||||
constructor() {
|
||||
super("no shift is open — open a shift before processing tickets");
|
||||
readonly till: TillId;
|
||||
constructor(till: TillId = BOOTH_TILL) {
|
||||
super(
|
||||
till === BOOTH_TILL
|
||||
? "no shift is open — open a shift before processing tickets"
|
||||
: `no ${till} shift is open — open one before taking money there`,
|
||||
);
|
||||
this.name = "NoShiftOpenError";
|
||||
this.till = till;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +57,8 @@ export class NoShiftOpenError extends Error {
|
||||
export interface ShiftSummary {
|
||||
readonly id: string;
|
||||
readonly index: number;
|
||||
/** The till this shift reconciled (booth for every pre-till report). */
|
||||
readonly till: TillId;
|
||||
readonly operator: string;
|
||||
readonly startedAt: string;
|
||||
readonly endedAt: string;
|
||||
@@ -55,13 +70,22 @@ export interface ShiftSummary {
|
||||
readonly subscriptionTotalMinor: number;
|
||||
readonly subscriptionSalesMinor: number;
|
||||
readonly subscriptionWindowMinor: number;
|
||||
readonly discountTotalMinor: number;
|
||||
readonly chargesByModuleMinor: ChargesByModule;
|
||||
readonly openingFloatMinor: number;
|
||||
readonly cashAddedMinor: number;
|
||||
readonly cashRemovedMinor: number;
|
||||
readonly expectedDrawerMinor: number;
|
||||
}
|
||||
|
||||
/** Module money folded into this till's payments as `chargeLines`, by owning module —
|
||||
* a wash paid on the parking ticket lands here as `{ carwash: <minor> }`. Only modules
|
||||
* that actually charged in the window appear. Cash+card already contain it; it is
|
||||
* broken OUT of the ticket bucket so "Bileta" is parking money only. */
|
||||
export type ChargesByModule = Partial<Record<ModuleId, number>>;
|
||||
|
||||
export interface ShiftReport {
|
||||
readonly till: TillId;
|
||||
readonly operator: string;
|
||||
readonly startedAt: string;
|
||||
readonly endedAt: string;
|
||||
@@ -78,6 +102,11 @@ export interface ShiftReport {
|
||||
readonly subscriptionSalesMinor: number;
|
||||
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
||||
readonly subscriptionWindowMinor: number;
|
||||
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
|
||||
* cash/card figures above are already NET of it). See validation-discounts.md. */
|
||||
readonly discountTotalMinor: number;
|
||||
/** Module charges settled on this till's payments (a booth-paid wash), by module. */
|
||||
readonly chargesByModuleMinor: ChargesByModule;
|
||||
// --- Drawer (physical cash till; carries across shifts) ---
|
||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||
readonly openingFloatMinor: number;
|
||||
@@ -90,6 +119,29 @@ export interface ShiftReport {
|
||||
readonly printed: boolean;
|
||||
}
|
||||
|
||||
/** A drawer movement's admin-review status, derived from its latest `cash_review`. */
|
||||
export type MovementStatus = "pending" | "authorized" | "denied";
|
||||
|
||||
/** One drawer cash movement (cash_in/cash_out) with its review status — the row shape for
|
||||
* the operator's own list and the admin review queue. `status` is derived, not stored. */
|
||||
export interface DrawerMovement {
|
||||
readonly id: string;
|
||||
readonly type: "cash_in" | "cash_out";
|
||||
/** Which drawer the cash moved in/out of. */
|
||||
readonly till: TillId;
|
||||
/** Positive magnitude; direction is the `type`. */
|
||||
readonly amountMinor: number;
|
||||
readonly currency: string | null;
|
||||
readonly reason: string | null;
|
||||
readonly operator: string;
|
||||
readonly voucherNo: string | null;
|
||||
readonly at: string;
|
||||
readonly status: MovementStatus;
|
||||
readonly reviewedBy: string | null;
|
||||
readonly reviewNote: string | null;
|
||||
readonly reviewedAt: string | null;
|
||||
}
|
||||
|
||||
export class InvalidCashMovementError extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
@@ -97,6 +149,18 @@ export class InvalidCashMovementError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
|
||||
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
|
||||
/** The takings line a till's OWN money prints under (the booth sells tickets; the wash
|
||||
* desk sells washes) and the label a module's charge gets when it rides another
|
||||
* till's ticket ("Lavazh (në biletë)"). Printed slips are Albanian (i18n.md). */
|
||||
const TILL_TAKINGS_LABEL: Record<TillId, string> = { booth: "Bileta", carwash: "Lavazh" };
|
||||
const MODULE_PRINT_LABEL: Partial<Record<ModuleId, string>> = { carwash: "Lavazh", validation: "Validime" };
|
||||
/** Which printer a till's slips (Z-report, vouchers) want. The wash desk falls back to
|
||||
* the booth printer when it has none of its own (orderForRole); the booth never falls
|
||||
* back to the desk. See wiki/concepts/printer-roles-failover.md. */
|
||||
const TILL_PRINTER_ROLE: Record<TillId, PrinterRole> = { booth: "booth-receipt", carwash: "wash-desk" };
|
||||
|
||||
export class ShiftService {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
@@ -108,41 +172,42 @@ export class ShiftService {
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Current physical drawer balance (cash payments + cash_movements, by time). For
|
||||
* the UI to show "inherited / in the drawer now". */
|
||||
drawerBalance(): { balanceMinor: number; currency: string | null } {
|
||||
return this.#drawerBalanceAt(new Date().toISOString());
|
||||
/** Current physical drawer balance of a till (cash payments + cash_movements, by
|
||||
* time). For the UI to show "inherited / in the drawer now". */
|
||||
drawerBalance(till: TillId = BOOTH_TILL): { balanceMinor: number; currency: string | null } {
|
||||
return this.#drawerBalanceAt(new Date().toISOString(), till);
|
||||
}
|
||||
|
||||
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
||||
openShiftFor(operator: string) {
|
||||
// Scan shift events for this operator; the shift is open if the most recent
|
||||
// shift event for them is a `shift_open` (not yet closed by a z_report).
|
||||
const rows = this.#db
|
||||
/** The shift-boundary events (shift_open / shift_z_report) of ONE till, chain order. */
|
||||
#shiftEvents(till: TillId) {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, operator))
|
||||
.where(inArray(ledgerEvents.type, ["shift_open", "shift_z_report"]))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all()
|
||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||||
.filter((r) => tillOf(r.payload as LedgerPayload | null) === till);
|
||||
}
|
||||
|
||||
/** Is there an open shift for this operator on this till? Returns the open
|
||||
* `shift_open` row or null. */
|
||||
openShiftFor(operator: string, till: TillId = BOOTH_TILL) {
|
||||
// The shift is open if the operator's most recent shift event on the till is a
|
||||
// `shift_open` (not yet closed by a z_report).
|
||||
const rows = this.#shiftEvents(till).filter((r) => r.identity === operator);
|
||||
const last = rows[rows.length - 1];
|
||||
return last && last.type === "shift_open" ? last : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
|
||||
* period: at most ONE may be open at a time (so booth takings are unambiguously
|
||||
* attributed to one operator). It's open iff the most recent shift event on the
|
||||
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
|
||||
* appended yet). Returns that row so callers can read its operator/startedAt.
|
||||
* The SINGLE open shift of a till, or null. A shift is the till's accountability
|
||||
* period: at most ONE may be open per till at a time (so its takings are
|
||||
* unambiguously attributed to one operator). It's open iff the till's most recent
|
||||
* shift event is a `shift_open` (the matching `shift_z_report` hasn't been appended
|
||||
* yet). Returns that row so callers can read its operator/startedAt.
|
||||
*/
|
||||
currentOpenShift() {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all()
|
||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||||
currentOpenShift(till: TillId = BOOTH_TILL) {
|
||||
const rows = this.#shiftEvents(till);
|
||||
const last = rows[rows.length - 1];
|
||||
return last && last.type === "shift_open" ? last : null;
|
||||
}
|
||||
@@ -156,7 +221,30 @@ export class ShiftService {
|
||||
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
||||
* completed accountability period. Use `currentOpenShift()` for the live one.
|
||||
*/
|
||||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||||
/**
|
||||
* Every operator that HAS a shift (closed z_reports + the open one, if any),
|
||||
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
||||
* for an operator that exists (the filter is an exact username match).
|
||||
*/
|
||||
listOperators(till?: TillId): string[] {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(inArray(ledgerEvents.type, ["shift_z_report", "shift_open"]))
|
||||
.all()
|
||||
.filter((r) => till == null || tillOf(r.payload as LedgerPayload | null) === till);
|
||||
// Every operator with a closed report, plus the holder of each open shift (an
|
||||
// open shift is the last shift_open on its till — but any shift_open's operator
|
||||
// has or had a shift, which is all the dropdown needs).
|
||||
const names = new Set<string>();
|
||||
for (const r of rows) {
|
||||
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
||||
if (op) names.add(op);
|
||||
}
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
listShifts(opts: { operator?: string; from?: string; to?: string; till?: TillId } = {}): ShiftSummary[] {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
@@ -177,6 +265,8 @@ export class ShiftService {
|
||||
subscriptionTotalMinor?: number;
|
||||
subscriptionSalesMinor?: number;
|
||||
subscriptionWindowMinor?: number;
|
||||
discountTotalMinor?: number;
|
||||
chargesByModuleMinor?: ChargesByModule;
|
||||
openingFloatMinor?: number;
|
||||
cashAddedMinor?: number;
|
||||
cashRemovedMinor?: number;
|
||||
@@ -184,12 +274,15 @@ export class ShiftService {
|
||||
};
|
||||
const operator = pl.operator ?? r.identity ?? "?";
|
||||
const startedAt = pl.startedAt ?? r.occurredAt;
|
||||
const till = tillOf(pl);
|
||||
if (opts.till && till !== opts.till) continue;
|
||||
if (opts.operator && operator !== opts.operator) continue;
|
||||
if (opts.from && startedAt < opts.from) continue;
|
||||
if (opts.to && startedAt > opts.to) continue;
|
||||
out.push({
|
||||
id: r.id,
|
||||
index: r.index,
|
||||
till,
|
||||
operator,
|
||||
startedAt,
|
||||
endedAt: pl.endedAt ?? r.occurredAt,
|
||||
@@ -207,6 +300,10 @@ export class ShiftService {
|
||||
ticketTotalMinor:
|
||||
pl.ticketTotalMinor ??
|
||||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
||||
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
|
||||
discountTotalMinor: pl.discountTotalMinor ?? 0,
|
||||
// Module charges on the ticket (added 2026-09-06). Old reports lack it → none.
|
||||
chargesByModuleMinor: pl.chargesByModuleMinor ?? {},
|
||||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||
@@ -217,10 +314,10 @@ export class ShiftService {
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
/** Require an open shift for the booth money path; returns it or throws. */
|
||||
requireOpenShift() {
|
||||
const open = this.currentOpenShift();
|
||||
if (!open) throw new NoShiftOpenError();
|
||||
/** Require an open shift on a till for its money path; returns it or throws. */
|
||||
requireOpenShift(till: TillId = BOOTH_TILL) {
|
||||
const open = this.currentOpenShift(till);
|
||||
if (!open) throw new NoShiftOpenError(till);
|
||||
return open;
|
||||
}
|
||||
|
||||
@@ -233,9 +330,10 @@ export class ShiftService {
|
||||
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
|
||||
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
|
||||
* removal) — historical chain events that still fold in unchanged.
|
||||
* This is what carries across shifts.
|
||||
* This is what carries across shifts. ONE till: every money event is filtered by
|
||||
* `tillOf(payload)` (absent = booth).
|
||||
*/
|
||||
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
||||
#drawerBalanceAt(at: string, till: TillId): { balanceMinor: number; currency: string | null } {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
@@ -245,16 +343,20 @@ export class ShiftService {
|
||||
(r) =>
|
||||
r.occurredAt <= at &&
|
||||
(r.type === "payment" ||
|
||||
// Car Wash module: money taken at the bay (cash adds to the drawer, card
|
||||
// never does — same tender rule as a parking payment).
|
||||
r.type === "carwash_payment" ||
|
||||
r.type === "cash_in" ||
|
||||
r.type === "cash_out" ||
|
||||
r.type === "cash_movement"),
|
||||
r.type === "cash_movement") &&
|
||||
tillOf(r.payload as LedgerPayload | null) === till,
|
||||
);
|
||||
let balanceMinor = 0;
|
||||
let currency: string | null = null;
|
||||
for (const r of rows) {
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
if (r.type === "payment") {
|
||||
if (r.type === "payment" || r.type === "carwash_payment") {
|
||||
// Only CASH enters the till; card settles to the bank.
|
||||
if (pl.tender !== "card") balanceMinor += amt;
|
||||
} else if (r.type === "cash_in") {
|
||||
@@ -281,24 +383,27 @@ export class ShiftService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
|
||||
* an amount (a receipt and a disbursement are different financial documents):
|
||||
* Record a drawer cash MOVEMENT — the direction is the event TYPE, not the sign of an
|
||||
* amount (a receipt and a disbursement are different financial documents):
|
||||
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
||||
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
||||
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
|
||||
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
|
||||
* route). Returns the new drawer balance + the assigned voucher number, and prints
|
||||
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
|
||||
* `amountMinor` is always a POSITIVE magnitude. The movement is OPERATOR-RECORDED FREELY
|
||||
* (no admin sign-off at creation — 2026-07-01); an admin REVIEWS it after the fact via
|
||||
* `reviewMovement` (authorize/deny — a flag that never moves cash). It counts in the
|
||||
* drawer immediately (the cash physically moved). Returns the new drawer balance + the
|
||||
* assigned voucher number, and prints a slip best-effort. See wiki/concepts/shift.md.
|
||||
*/
|
||||
async recordVoucher(args: {
|
||||
type: "cash_in" | "cash_out";
|
||||
operator: string;
|
||||
authorizedBy: string;
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
currency?: string;
|
||||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||
const { type, operator, authorizedBy, reason } = args;
|
||||
/** Which drawer the cash moved in/out of (default: the booth). */
|
||||
till?: TillId;
|
||||
}): Promise<{ type: "cash_in" | "cash_out"; till: TillId; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||
const { type, operator, reason } = args;
|
||||
const till = args.till ?? BOOTH_TILL;
|
||||
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||
}
|
||||
@@ -308,46 +413,148 @@ export class ShiftService {
|
||||
await this.#log.append({
|
||||
type,
|
||||
source: "manual",
|
||||
identity: operator, // who RAISED the voucher (the operator at the booth)
|
||||
identity: operator, // who RECORDED the movement (the operator at the booth)
|
||||
payload: {
|
||||
amountMinor, // positive magnitude — direction is the type
|
||||
...(reason ? { reason } : {}),
|
||||
...(args.currency ? { currency: args.currency } : {}),
|
||||
operator,
|
||||
authorizedBy,
|
||||
voucherNo,
|
||||
till,
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
|
||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now, till);
|
||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now, till });
|
||||
this.#logger.info(
|
||||
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
`${type} ${voucherNo} ${amountMinor} by ${operator} on ${till} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
);
|
||||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||||
return { type, till, amountMinor, voucherNo, balanceMinor, printed };
|
||||
}
|
||||
|
||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||
* inherited from the chain = the drawer balance at the start instant. */
|
||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
|
||||
// operator's own (double-open) or another operator's (handover not done). Only
|
||||
// one accountability period at a time.
|
||||
const current = this.currentOpenShift();
|
||||
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
|
||||
/**
|
||||
* Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Appends a signed `cash_review`
|
||||
* referencing the movement. This is a FLAG ONLY — a `deny` does NOT reverse the movement
|
||||
* and does NOT touch the drawer balance (a denial is a judgment about the operator,
|
||||
* settled outside the app). Rejects an unknown/ non-movement refId, and a movement that
|
||||
* was already decided (one decision per movement; a clean audit trail). Idempotent by
|
||||
* design: the drawer fold never reads `cash_review`. See wiki/concepts/shift.md.
|
||||
*/
|
||||
async reviewMovement(args: {
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
reviewedBy: string;
|
||||
note?: string;
|
||||
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||||
const { refId, decision, reviewedBy } = args;
|
||||
if (decision !== "authorize" && decision !== "deny") {
|
||||
throw new InvalidCashMovementError("decision must be authorize or deny");
|
||||
}
|
||||
const movement = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.id, refId)).get();
|
||||
if (!movement || (movement.type !== "cash_in" && movement.type !== "cash_out")) {
|
||||
throw new InvalidCashMovementError("refId is not a cash movement");
|
||||
}
|
||||
// One decision per movement — reject a re-review so the audit stays unambiguous.
|
||||
const already = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "cash_review"))
|
||||
.all()
|
||||
.some((r) => (r.payload as LedgerPayload | null)?.refId === refId);
|
||||
if (already) throw new InvalidCashMovementError("movement already reviewed");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await this.#log.append({
|
||||
type: "cash_review",
|
||||
source: "manual",
|
||||
identity: reviewedBy, // the admin who decided
|
||||
payload: {
|
||||
refId,
|
||||
decision,
|
||||
reviewedBy,
|
||||
...(args.note ? { note: args.note } : {}),
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
this.#logger.info(`cash_review ${decision} of ${movement.type} ${refId} by ${reviewedBy}`);
|
||||
return { refId, decision, reviewedBy, at: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* All drawer cash movements (cash_in/cash_out) with their review STATUS, newest first.
|
||||
* Status is derived from the latest `cash_review` referencing each movement: none →
|
||||
* `pending`, else `authorized`/`denied`. Powers the operator's own list and the admin
|
||||
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
||||
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
||||
*/
|
||||
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus; till?: TillId }): DrawerMovement[] {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
// Latest review decision per movement id.
|
||||
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||
for (const r of rows) {
|
||||
if (r.type !== "cash_review") continue;
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
if (!pl.refId || (pl.decision !== "authorize" && pl.decision !== "deny")) continue;
|
||||
reviewByRef.set(pl.refId, {
|
||||
decision: pl.decision,
|
||||
reviewedBy: pl.reviewedBy ?? "",
|
||||
...(pl.note ? { note: pl.note } : {}),
|
||||
at: r.occurredAt,
|
||||
});
|
||||
}
|
||||
const out: DrawerMovement[] = [];
|
||||
for (const r of rows) {
|
||||
if (r.type !== "cash_in" && r.type !== "cash_out") continue;
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
||||
if (filter?.operator && operator !== filter.operator) continue;
|
||||
const till = tillOf(pl);
|
||||
if (filter?.till && till !== filter.till) continue;
|
||||
const review = reviewByRef.get(r.id);
|
||||
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
||||
if (filter?.status && status !== filter.status) continue;
|
||||
out.push({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
till,
|
||||
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
||||
currency: pl.currency ?? null,
|
||||
reason: pl.reason ?? null,
|
||||
operator,
|
||||
voucherNo: pl.voucherNo ?? null,
|
||||
at: r.occurredAt,
|
||||
status,
|
||||
reviewedBy: review?.reviewedBy ?? null,
|
||||
reviewNote: review?.note ?? null,
|
||||
reviewedAt: review?.at ?? null,
|
||||
});
|
||||
}
|
||||
// Newest first.
|
||||
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
||||
}
|
||||
|
||||
/** Open a shift for the operator on a till (explicit start). The opening float is
|
||||
* auto-inherited from the chain = that till's drawer balance at the start instant. */
|
||||
async open(operator: string, till: TillId = BOOTH_TILL): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
|
||||
// Single-open-per-till invariant: refuse if a shift is open ON THIS TILL — whether
|
||||
// this operator's own (double-open) or another operator's (handover not done).
|
||||
// One accountability period per drawer at a time. (Another till's shift is
|
||||
// independent: the booth and the wash desk run side by side.)
|
||||
const current = this.currentOpenShift(till);
|
||||
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator, till);
|
||||
const startedAt = new Date().toISOString();
|
||||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt, till);
|
||||
await this.#log.append({
|
||||
type: "shift_open",
|
||||
source: "manual",
|
||||
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||
// Record the inherited opening float on the shift_open so it's reproducible
|
||||
// and the next operator's handover figure is fixed in the chain.
|
||||
payload: { operator, openingFloatMinor },
|
||||
payload: { operator, openingFloatMinor, till },
|
||||
occurredAt: startedAt,
|
||||
});
|
||||
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||
return { startedAt, openingFloatMinor };
|
||||
this.#logger.info(`${till} shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||
return { startedAt, till, openingFloatMinor };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,15 +570,22 @@ export class ShiftService {
|
||||
): Omit<ShiftReport, "printed"> {
|
||||
const operator = open.identity ?? "?";
|
||||
const startedAt = open.occurredAt;
|
||||
const till = tillOf(open.payload as LedgerPayload | null);
|
||||
|
||||
// All payments taken in [startedAt, asOf], summed by tender. Payment time =
|
||||
// the operator who handled the money (decision: sum by payment time).
|
||||
// All payments taken ON THIS TILL in [startedAt, asOf], summed by tender. Payment
|
||||
// time = the operator who handled the money (decision: sum by payment time).
|
||||
const payments = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "payment"))
|
||||
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
|
||||
// parking payment's amount already, as chargeLines). Both fold into the cash/card
|
||||
// tender totals so the expected drawer is right; the booth-paid wash is then
|
||||
// broken OUT of the ticket bucket into chargesByModuleMinor (see below).
|
||||
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
|
||||
.all()
|
||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
|
||||
.filter(
|
||||
(r) => r.occurredAt >= startedAt && r.occurredAt <= asOf && tillOf(r.payload as LedgerPayload | null) === till,
|
||||
);
|
||||
|
||||
let cashTotalMinor = 0;
|
||||
let cardTotalMinor = 0;
|
||||
@@ -382,11 +596,19 @@ export class ShiftService {
|
||||
// the subscription sale path).
|
||||
let subscriptionSalesMinor = 0;
|
||||
let subscriptionWindowMinor = 0;
|
||||
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
|
||||
// tender totals are already NET; this is the "given away" figure beside them.
|
||||
let discountTotalMinor = 0;
|
||||
// Module charges folded into this till's payments (chargeLines on a booth payment),
|
||||
// summed by owning module. Part of cash/card; NOT ticket money.
|
||||
const chargesByModuleMinor: ChargesByModule = {};
|
||||
let chargesTotalMinor = 0;
|
||||
let currency: string | null = null;
|
||||
for (const p of payments) {
|
||||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||
subscriptionSale?: boolean;
|
||||
subscriptionWindowCharge?: boolean;
|
||||
chargeLines?: ChargeLine[];
|
||||
};
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
if (pl.tender === "card") cardTotalMinor += amt;
|
||||
@@ -394,10 +616,18 @@ export class ShiftService {
|
||||
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||
// (else → transient ticket; derived below as total − subscription)
|
||||
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||
for (const l of pl.chargeLines ?? []) {
|
||||
if (typeof l.amountMinor !== "number" || !l.module) continue;
|
||||
chargesByModuleMinor[l.module] = (chargesByModuleMinor[l.module] ?? 0) + l.amountMinor;
|
||||
chargesTotalMinor += l.amountMinor;
|
||||
}
|
||||
if (pl.currency) currency = pl.currency;
|
||||
}
|
||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
|
||||
// Ticket = what is left once subscriber money and module charges are taken out:
|
||||
// ticket + subscriptions + Σcharges = cash + card, always.
|
||||
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor - chargesTotalMinor;
|
||||
|
||||
// --- Drawer figures ---
|
||||
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||
@@ -406,7 +636,7 @@ export class ShiftService {
|
||||
const openingFloatMinor =
|
||||
typeof openPl.openingFloatMinor === "number"
|
||||
? openPl.openingFloatMinor
|
||||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||||
: this.#drawerBalanceAt(startedAt, till).balanceMinor;
|
||||
|
||||
// Drawer movements within the window, split into added (+) and removed (−).
|
||||
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
|
||||
@@ -419,7 +649,8 @@ export class ShiftService {
|
||||
(r) =>
|
||||
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||||
r.occurredAt >= startedAt &&
|
||||
r.occurredAt <= asOf,
|
||||
r.occurredAt <= asOf &&
|
||||
tillOf(r.payload as LedgerPayload | null) === till,
|
||||
);
|
||||
let cashAddedMinor = 0;
|
||||
let cashRemovedMinor = 0;
|
||||
@@ -438,6 +669,7 @@ export class ShiftService {
|
||||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||
|
||||
return {
|
||||
till,
|
||||
operator,
|
||||
startedAt,
|
||||
endedAt: asOf,
|
||||
@@ -449,6 +681,8 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
chargesByModuleMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -463,17 +697,18 @@ export class ShiftService {
|
||||
* projection the Z-report prints, so the operator sees exactly what their close
|
||||
* will show. See wiki/concepts/shift.md.
|
||||
*/
|
||||
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
||||
const open = this.currentOpenShift();
|
||||
currentReport(till: TillId = BOOTH_TILL): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
||||
const open = this.currentOpenShift(till);
|
||||
if (!open) return null;
|
||||
const asOf = new Date().toISOString();
|
||||
return { ...this.#summariseWindow(open, asOf), asOf };
|
||||
}
|
||||
|
||||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||||
async close(operator: string): Promise<ShiftReport> {
|
||||
const open = this.openShiftFor(operator);
|
||||
if (!open) throw new NoOpenShiftError(operator);
|
||||
/** Close the operator's open shift on a till: sum its payments in the window, sign +
|
||||
* print the Z-report. */
|
||||
async close(operator: string, till: TillId = BOOTH_TILL): Promise<ShiftReport> {
|
||||
const open = this.openShiftFor(operator, till);
|
||||
if (!open) throw new NoOpenShiftError(operator, till);
|
||||
const endedAt = new Date().toISOString();
|
||||
|
||||
const report = this.#summariseWindow(open, endedAt);
|
||||
@@ -487,6 +722,8 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
chargesByModuleMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -499,6 +736,7 @@ export class ShiftService {
|
||||
identity: operator,
|
||||
payload: {
|
||||
operator,
|
||||
till,
|
||||
startedAt,
|
||||
endedAt,
|
||||
cashTotalMinor,
|
||||
@@ -509,6 +747,9 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
// Only when a module charged in the window (older slips/payloads stay identical).
|
||||
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -519,25 +760,23 @@ export class ShiftService {
|
||||
const printed = await this.#printZReport(report);
|
||||
|
||||
this.#logger.info(
|
||||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
||||
`${till} shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
||||
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||
);
|
||||
return { ...report, printed };
|
||||
}
|
||||
|
||||
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||||
* is the record — a failed print doesn't undo the close). */
|
||||
/** Print the Z-report on the till's printer (best-effort; the signed event is the
|
||||
* record — a failed print doesn't undo the close). */
|
||||
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
|
||||
const printer = await this.#boothPrinter();
|
||||
if (!printer) {
|
||||
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
|
||||
return false;
|
||||
}
|
||||
const cur = r.currency ?? "";
|
||||
const money = (m: number) => (m / 100).toFixed(2);
|
||||
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
||||
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
||||
const lines = [
|
||||
// Which drawer this report reconciles — only printed off the booth, so booth
|
||||
// slips stay byte-identical to before tills existed.
|
||||
...(r.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[r.till]}`] : []),
|
||||
`Operatori: ${r.operator}`,
|
||||
`Nga: ${zStamp(r.startedAt)}`,
|
||||
`Deri: ${zStamp(r.endedAt)}`,
|
||||
@@ -547,25 +786,35 @@ export class ShiftService {
|
||||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||||
"",
|
||||
"-- Arkëtime sipas burimit --",
|
||||
// The booth prints its three classic lines (byte-identical to before tills); a
|
||||
// module's till prints its own takings under its own name — it sells no tickets
|
||||
// and no subscriptions.
|
||||
...(r.till === BOOTH_TILL
|
||||
? [
|
||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
|
||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
]
|
||||
: [`${TILL_TAKINGS_LABEL[r.till]}: ${money(r.ticketTotalMinor)} ${cur}`]),
|
||||
// Module money that rode this till's tickets (a booth-paid wash) — its own line,
|
||||
// only when any was taken, so the operator sees parking and wash money apart.
|
||||
...Object.entries(r.chargesByModuleMinor)
|
||||
.filter(([, v]) => (v ?? 0) > 0)
|
||||
.map(([m, v]) => `${MODULE_PRINT_LABEL[m as ModuleId] ?? m} (në biletë): ${money(v ?? 0)} ${cur}`),
|
||||
// Merchant-validation leakage — printed only when the shift actually gave any
|
||||
// (older slips stay byte-identical). The takings above are already NET of it.
|
||||
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
|
||||
"",
|
||||
"-- Arka --",
|
||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
|
||||
}
|
||||
|
||||
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
|
||||
@@ -577,49 +826,74 @@ export class ShiftService {
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
operator: string;
|
||||
authorizedBy: string;
|
||||
currency: string | null;
|
||||
at: string;
|
||||
till: TillId;
|
||||
}): Promise<boolean> {
|
||||
const printer = await this.#boothPrinter();
|
||||
if (!printer) {
|
||||
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
const cur = v.currency ?? "";
|
||||
const money = (m: number) => (m / 100).toFixed(2);
|
||||
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
||||
const lines = [
|
||||
`Mandat Nr.: ${v.voucherNo}`,
|
||||
...(v.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[v.till]}`] : []),
|
||||
`Data: ${zStamp(v.at)}`,
|
||||
"",
|
||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||
`Arsyeja: ${v.reason || "-"}`,
|
||||
"",
|
||||
`Hapur nga: ${v.operator}`,
|
||||
`Autorizoi: ${v.authorizedBy}`,
|
||||
`Regjistroi: ${v.operator}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title, lines });
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
|
||||
}
|
||||
|
||||
/** First enabled booth-receipt printer, or any enabled printer. */
|
||||
async #boothPrinter(): Promise<PrinterDevice | null> {
|
||||
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||
const enabled = rows.filter((r) => r.enabled);
|
||||
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
|
||||
if (!booth) return null;
|
||||
const driver = registry.get(booth.driverId);
|
||||
if (!driver) return null;
|
||||
/** Print a till's slip on its printer with failover (wash desk → booth printer;
|
||||
* see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
|
||||
* record — every failure is logged and reported as "not printed", never thrown.
|
||||
* Legacy fallback: a site whose only printer carries no booth role (one unit,
|
||||
* configured as the entry dispenser) still prints its slips on it, as before. */
|
||||
async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
|
||||
const printers = this.#loadPrinters();
|
||||
const want = TILL_PRINTER_ROLE[till];
|
||||
let ordered = orderForRole(printers, want);
|
||||
if (ordered.length === 0 && till === BOOTH_TILL) ordered = printers.slice(0, 1);
|
||||
if (ordered.length === 0) {
|
||||
this.#logger.warn(`no ${want} printer — ${what} not printed (event is recorded)`);
|
||||
return false;
|
||||
}
|
||||
const attempts: string[] = [];
|
||||
for (const p of ordered) {
|
||||
try {
|
||||
return driver.create(booth.config as never) as PrinterDevice;
|
||||
await job(p.device);
|
||||
if (p.role !== want) this.#logger.info(`${what} printed on ${p.id} (${p.role}; no ${want} printer reachable)`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
attempts.push(`${p.id} (${(err as Error).message})`);
|
||||
}
|
||||
}
|
||||
this.#logger.warn(`${what} print failed on every candidate: ${attempts.join(", ")} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Every enabled printer as a live instance (role + rank from its saved config). */
|
||||
#loadPrinters(): PrinterInstance[] {
|
||||
const rows = this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||
const out: PrinterInstance[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) continue;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
role: printerRoleOf(cfg),
|
||||
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||
device: driver.create(cfg as never) as PrinterDevice,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
// skip a printer whose config won't build
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { snapshots, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { pruneSnapshots, type DiskUsage, type SnapshotRetention } from "./snapshot-retention.js";
|
||||
|
||||
// Snapshot retention: DISK-PRESSURE prune. No-op unless the DB's filesystem is over the
|
||||
// high-water mark; then delete the OLDEST until ~freeTargetPct of disk is freed (estimated from
|
||||
// the deleted BLOB sizes), honoring a MIN_KEEP floor, then VACUUM once. Disk usage is injected
|
||||
// so the test controls the trigger without touching the real filesystem.
|
||||
|
||||
let db: Db;
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
});
|
||||
|
||||
/** Insert `n` snapshots, oldest first (s-0 is the oldest), each `bytes` long. */
|
||||
function seed(n: number, bytes = 1000): void {
|
||||
const t0 = Date.now() - n * 1000;
|
||||
for (let i = 0; i < n; i++) {
|
||||
db.insert(snapshots)
|
||||
.values({
|
||||
id: `s-${i}`,
|
||||
direction: "entry",
|
||||
deviceId: "cam",
|
||||
identity: `s-${i}`,
|
||||
contentType: "image/jpeg",
|
||||
bytes: Buffer.alloc(bytes, 1),
|
||||
capturedAt: new Date(t0 + i * 1000).toISOString(), // s-0 oldest … s-(n-1) newest
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
function count(): number {
|
||||
return db.select().from(snapshots).all().length;
|
||||
}
|
||||
function ids(): string[] {
|
||||
return db.select().from(snapshots).all().map((r) => r.id).sort();
|
||||
}
|
||||
|
||||
/** A fake disk at a given used% on a 1 GB volume. */
|
||||
const disk = (usedPct: number, totalBytes = 1_000_000_000): (() => Promise<DiskUsage>) =>
|
||||
() => Promise.resolve({ usedPct, totalBytes });
|
||||
|
||||
const ret = (o: Partial<SnapshotRetention>): SnapshotRetention => ({
|
||||
highPct: 70,
|
||||
freeTargetPct: 10,
|
||||
minKeep: 2,
|
||||
batch: 5,
|
||||
...o,
|
||||
});
|
||||
|
||||
describe("pruneSnapshots (disk-pressure)", () => {
|
||||
it("no-op when disk is below the high-water mark", async () => {
|
||||
seed(10);
|
||||
const res = await pruneSnapshots(db, { retention: ret({}), diskUsage: disk(50) });
|
||||
expect(res.deletedRows).toBe(0);
|
||||
expect(res.vacuumed).toBe(false);
|
||||
expect(count()).toBe(10);
|
||||
});
|
||||
|
||||
it("over the mark: deletes the OLDEST until ~freeTargetPct is freed, then VACUUMs", async () => {
|
||||
// 1 GB disk, target 10% = 100 MB. Each snapshot 20 MB → ~5 deletions reach the target.
|
||||
seed(20, 20 * 1048576);
|
||||
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, batch: 100 }), diskUsage: disk(80) });
|
||||
expect(res.deletedRows).toBeGreaterThanOrEqual(5);
|
||||
expect(res.freedBytesEst).toBeGreaterThanOrEqual(0.1 * 1_000_000_000);
|
||||
expect(res.vacuumed).toBe(true);
|
||||
expect(vacuumSpy).toHaveBeenCalledWith("VACUUM");
|
||||
// The survivors are the NEWEST (oldest went first).
|
||||
const survivors = ids();
|
||||
expect(survivors).toContain(`s-19`); // newest kept
|
||||
expect(survivors).not.toContain(`s-0`); // oldest pruned
|
||||
vacuumSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("honors the MIN_KEEP floor even when still over target", async () => {
|
||||
// Target 10% of 1 GB = 100 MB, but only 3 tiny snapshots exist and minKeep=2 → at most 1 deleted.
|
||||
seed(3, 1000);
|
||||
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, freeTargetPct: 90 }), diskUsage: disk(95) });
|
||||
expect(res.deletedRows).toBe(1); // 3 − minKeep(2)
|
||||
expect(count()).toBe(2);
|
||||
expect(res.floorHitWhileOver).toBe(true); // couldn't reach target without crossing the floor
|
||||
});
|
||||
|
||||
it("skips VACUUM when nothing was deleted", async () => {
|
||||
seed(2); // == minKeep, so nothing to delete even over the mark
|
||||
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2 }), diskUsage: disk(99) });
|
||||
expect(res.deletedRows).toBe(0);
|
||||
expect(res.vacuumed).toBe(false);
|
||||
expect(vacuumSpy).not.toHaveBeenCalled();
|
||||
vacuumSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { statfs } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { asc, snapshots, sql, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
// Snapshot retention — DISK-PRESSURE model. Camera snapshots are unsigned, advisory, prunable
|
||||
// BLOBs (see snapshot.ts); they're referenced by the signed ledger only by id, so pruning an
|
||||
// old image never affects the chain. They no longer dominate the DB day-to-day (captures are
|
||||
// re-encoded small at SNAPSHOT_MAX_EDGE/JPEG_QUALITY), so this is a SAFETY VALVE: only when the
|
||||
// filesystem holding the DB crosses a high-water mark do we delete the OLDEST snapshots and
|
||||
// VACUUM to return disk to the OS.
|
||||
//
|
||||
// Why estimated-bytes, not live disk%: a DELETE only frees SQLite *pages* — the file (and thus
|
||||
// OS disk usage) doesn't shrink until VACUUM. So the prune loop can't watch usedPct fall in real
|
||||
// time. Instead it sums LENGTH(bytes) of the rows it deletes and stops when that estimate reaches
|
||||
// the free-target, then VACUUMs ONCE at the end to realize the space. A MIN_KEEP floor always
|
||||
// wins — we never delete evidence below it, even under pressure (if the disk is full of something
|
||||
// else, that's not ours to fix).
|
||||
|
||||
export interface SnapshotRetention {
|
||||
/** Prune when the DB's filesystem is at least this % used. */
|
||||
readonly highPct: number;
|
||||
/** Try to free roughly this % of the disk per run (the delete target). */
|
||||
readonly freeTargetPct: number;
|
||||
/** Never prune below this many snapshots (the floor). */
|
||||
readonly minKeep: number;
|
||||
/** Delete oldest in batches of this size (re-checks between batches). */
|
||||
readonly batch: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SNAPSHOT_RETENTION: SnapshotRetention = {
|
||||
highPct: Number(process.env.SNAPSHOT_DISK_HIGH_PCT ?? 70),
|
||||
freeTargetPct: Number(process.env.SNAPSHOT_DISK_FREE_TARGET_PCT ?? 10),
|
||||
minKeep: Number(process.env.SNAPSHOT_MIN_KEEP ?? 500),
|
||||
batch: Number(process.env.SNAPSHOT_PRUNE_BATCH ?? 200),
|
||||
};
|
||||
|
||||
/** Disk usage of the filesystem holding the DB. Injectable so tests don't touch the real FS. */
|
||||
export interface DiskUsage {
|
||||
readonly usedPct: number;
|
||||
readonly totalBytes: number;
|
||||
}
|
||||
|
||||
export interface PruneOptions {
|
||||
readonly retention?: SnapshotRetention;
|
||||
/** Override how disk usage is read (tests inject a fake; default = statfs the DB's FS). */
|
||||
readonly diskUsage?: () => Promise<DiskUsage>;
|
||||
}
|
||||
|
||||
export interface PruneResult {
|
||||
readonly deletedRows: number;
|
||||
readonly freedBytesEst: number;
|
||||
readonly vacuumed: boolean;
|
||||
readonly usedPctBefore: number;
|
||||
/** True if we hit the MIN_KEEP floor while the disk was still over the high-water mark. */
|
||||
readonly floorHitWhileOver: boolean;
|
||||
}
|
||||
|
||||
/** Read the used% + total bytes of the filesystem holding the DB file. */
|
||||
async function diskUsageForDb(db: Db): Promise<DiskUsage> {
|
||||
const file = (db.$client as { name?: string }).name ?? process.env.DATABASE_URL ?? "./parking.sqlite";
|
||||
const st = await statfs(dirname(resolve(file)));
|
||||
const total = st.blocks * st.bsize;
|
||||
const avail = st.bavail * st.bsize;
|
||||
const usedPct = total > 0 ? (1 - avail / total) * 100 : 0;
|
||||
return { usedPct, totalBytes: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune snapshots under DISK PRESSURE. No-op unless the DB's filesystem is ≥ highPct used. When
|
||||
* over, deletes the OLDEST snapshots until an estimated freeTargetPct of the disk is freed (or the
|
||||
* minKeep floor is hit, or no rows remain), then VACUUMs once. Best-effort; safe on a timer.
|
||||
*/
|
||||
export async function pruneSnapshots(
|
||||
db: Db,
|
||||
opts: PruneOptions = {},
|
||||
logger?: FastifyBaseLogger,
|
||||
): Promise<PruneResult> {
|
||||
const r = opts.retention ?? DEFAULT_SNAPSHOT_RETENTION;
|
||||
const readDisk = opts.diskUsage ?? (() => diskUsageForDb(db));
|
||||
|
||||
let usedPctBefore = 0;
|
||||
try {
|
||||
const disk = await readDisk();
|
||||
usedPctBefore = disk.usedPct;
|
||||
|
||||
// The overwhelmingly common case: plenty of headroom → do nothing.
|
||||
if (disk.usedPct < r.highPct) {
|
||||
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||
}
|
||||
|
||||
// Target bytes to free this run (≈ freeTargetPct of the whole disk).
|
||||
const targetBytes = (r.freeTargetPct / 100) * disk.totalBytes;
|
||||
|
||||
let freedBytesEst = 0;
|
||||
let deletedRows = 0;
|
||||
let floorHitWhileOver = false;
|
||||
|
||||
// Delete the oldest in batches, summing their BLOB sizes, until we've freed the target — or
|
||||
// we'd cross the MIN_KEEP floor — or there are no more rows.
|
||||
for (;;) {
|
||||
const count = db.select({ c: sql<number>`count(*)` }).from(snapshots).get()?.c ?? 0;
|
||||
if (count <= r.minKeep) {
|
||||
floorHitWhileOver = true; // still over the high-water mark but can't delete below the floor
|
||||
break;
|
||||
}
|
||||
if (freedBytesEst >= targetBytes) break;
|
||||
|
||||
const room = count - r.minKeep; // how many we may still delete before the floor
|
||||
const take = Math.min(r.batch, room);
|
||||
const oldest = db
|
||||
.select({ id: snapshots.id, len: sql<number>`length(${snapshots.bytes})` })
|
||||
.from(snapshots)
|
||||
.orderBy(asc(snapshots.capturedAt))
|
||||
.limit(take)
|
||||
.all();
|
||||
if (oldest.length === 0) break;
|
||||
|
||||
const ids = oldest.map((o) => o.id);
|
||||
db.delete(snapshots).where(sql`${snapshots.id} in (${sql.join(ids, sql`, `)})`).run();
|
||||
deletedRows += oldest.length;
|
||||
freedBytesEst += oldest.reduce((s, o) => s + (o.len ?? 0), 0);
|
||||
}
|
||||
|
||||
// Realize the freed space: VACUUM returns pages to the OS (the file shrinks). Only if we
|
||||
// actually deleted something. Non-fatal on failure — pages are still freed for reuse.
|
||||
let vacuumed = false;
|
||||
if (deletedRows > 0) {
|
||||
try {
|
||||
(db.$client as { exec: (sql: string) => void }).exec("VACUUM");
|
||||
vacuumed = true;
|
||||
} catch (err) {
|
||||
logger?.warn(`snapshot prune: VACUUM failed (pages freed for reuse): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (floorHitWhileOver) {
|
||||
logger?.warn(
|
||||
`snapshot prune: disk ${usedPctBefore.toFixed(0)}% used but hit MIN_KEEP floor (${r.minKeep}) ` +
|
||||
`after deleting ${deletedRows} — disk pressure is not from snapshots`,
|
||||
);
|
||||
}
|
||||
return { deletedRows, freedBytesEst, vacuumed, usedPctBefore, floorHitWhileOver };
|
||||
} catch (err) {
|
||||
logger?.warn(`snapshot prune failed: ${(err as Error).message}`);
|
||||
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user