Compare commits
35 Commits
c52a42dad2
..
v0.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -92,6 +92,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: apps/server/Dockerfile
|
file: apps/server/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
|||||||
+183
-26
@@ -1,12 +1,24 @@
|
|||||||
name: Release desktop
|
name: Release desktop
|
||||||
|
|
||||||
# Build the signed Tauri desktop installers on a version tag and publish them as
|
# 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)
|
# a Gitea Release — TWICE: once on this (private, source) repo for our own
|
||||||
# fetches these; latest.json + each installer + its .sig are what it needs.
|
# 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
|
# 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
|
# with the updater key (Gitea secrets), assembles latest.json pointing at the
|
||||||
# everything to the Release for that tag.
|
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -63,6 +75,27 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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
|
- name: Build + sign desktop bundle
|
||||||
env:
|
env:
|
||||||
# Updater signing key (Gitea repo/org secrets). Without these the
|
# Updater signing key (Gitea repo/org secrets). Without these the
|
||||||
@@ -73,43 +106,88 @@ jobs:
|
|||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
id: collect
|
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: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||||
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
-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/
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
- name: Assemble latest.json
|
- name: Assemble latest.json
|
||||||
# The Tauri updater fetches a manifest describing the newest version, its
|
# The Tauri updater fetches a manifest describing the newest version, its
|
||||||
# notes, and per-target {signature, url}. We point the AppImage target at
|
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||||
# this release's asset URL. Adjust the platform keys you actually ship.
|
# 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:
|
env:
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
REPO: ${{ github.repository }}
|
MIRROR_REPO: mca/public_releases
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
ASSET_BASE="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest"
|
||||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
cat > /tmp/latest.js <<'JS'
|
||||||
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
|
const fs = require("fs");
|
||||||
cat > dist/latest.json <<JSON
|
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}",
|
version,
|
||||||
"notes": "Parking System ${TAG}",
|
notes: `Parking System ${tag}`,
|
||||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
||||||
"platforms": {
|
platforms,
|
||||||
"linux-x86_64": {
|
},
|
||||||
"signature": "${SIG}",
|
null,
|
||||||
"url": "${ASSET_URL}"
|
2,
|
||||||
}
|
) + "\n",
|
||||||
}
|
);
|
||||||
}
|
JS
|
||||||
JSON
|
node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}"
|
||||||
echo "latest.json:"; cat dist/latest.json
|
echo "latest.json:"; cat dist/latest.json
|
||||||
|
|
||||||
- name: Create release + upload assets (Gitea API)
|
- name: Create release + upload assets (Gitea API)
|
||||||
@@ -129,12 +207,12 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
"${API}/repos/${REPO}/releases" || true)
|
"${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
|
if [ -z "$REL_ID" ]; then
|
||||||
# Release may already exist for this tag — look it up by tag.
|
# Release may already exist for this tag — look it up by tag.
|
||||||
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
"${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
|
fi
|
||||||
echo "release id: ${REL_ID}"
|
echo "release id: ${REL_ID}"
|
||||||
for f in dist/*; do
|
for f in dist/*; do
|
||||||
@@ -147,3 +225,82 @@ jobs:
|
|||||||
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
done
|
done
|
||||||
echo "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,4 @@ dist/
|
|||||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
parking.sqlite*.bak-*
|
parking.sqlite*.bak-*
|
||||||
|
questions.txt
|
||||||
|
|||||||
+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
|
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).
|
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)
|
## Not here (deliberately)
|
||||||
|
|
||||||
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
|
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||||
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
|
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
|
||||||
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -346,10 +363,39 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -399,6 +445,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -506,6 +561,18 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "dbus"
|
name = "dbus"
|
||||||
version = "0.9.11"
|
version = "0.9.11"
|
||||||
@@ -635,6 +702,15 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "dom_query"
|
name = "dom_query"
|
||||||
version = "0.27.0"
|
version = "0.27.0"
|
||||||
@@ -721,6 +797,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1209,6 +1299,25 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1298,6 +1407,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1321,6 +1431,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1341,9 +1452,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1744,6 +1857,12 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1759,6 +1878,12 @@ version = "0.4.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2178,8 +2303,11 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tauri-plugin-websocket",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2330,6 +2458,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
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]]
|
[[package]]
|
||||||
name = "precomputed-hash"
|
name = "precomputed-hash"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2398,6 +2535,22 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -2407,6 +2560,62 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -2428,6 +2637,61 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2503,6 +2767,49 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -2616,6 +2923,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2625,7 +2933,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
@@ -2663,6 +2971,12 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -2745,7 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@@ -2885,6 +3199,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.21.0"
|
||||||
@@ -2948,6 +3274,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -2955,7 +3292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3137,6 +3474,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "sync_wrapper"
|
name = "sync_wrapper"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -3157,6 +3505,27 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3178,7 +3547,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -3268,7 +3637,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -3367,6 +3736,54 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-process"
|
name = "tauri-plugin-process"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
@@ -3377,6 +3794,22 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -3393,7 +3826,7 @@ dependencies = [
|
|||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"osakit",
|
"osakit",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"rustls",
|
"rustls",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3410,6 +3843,26 @@ dependencies = [
|
|||||||
"zip",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -3639,9 +4092,21 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -3652,6 +4117,22 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -3837,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
@@ -3877,6 +4370,24 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4141,6 +4652,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -4206,6 +4727,24 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -4391,6 +4930,17 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -4820,6 +5370,26 @@ dependencies = [
|
|||||||
"synstructure",
|
"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]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ serde_json = "1"
|
|||||||
# Auto-update: prompt the operator, download a signed update, relaunch.
|
# Auto-update: prompt the operator, download a signed update, relaunch.
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "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]
|
[features]
|
||||||
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"updater: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
|
// 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.
|
// 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
|
// 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
|
// the backend over HTTP to a Fastify server (address operator-configured at
|
||||||
// keeps the shell a thin presentation wrapper with a deny-by-default native
|
// runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
|
||||||
// surface (see wiki/decisions/desktop-shell-tauri.md).
|
// 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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -16,6 +17,16 @@ pub fn run() {
|
|||||||
// endpoint + signing pubkey live in tauri.conf.json.
|
// endpoint + signing pubkey live in tauri.conf.json.
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_process::init())
|
.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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running the Parking System desktop shell");
|
.expect("error while running the Parking System desktop shell");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Parking System",
|
"productName": "Parking System",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.parking.desktop",
|
"identifier": "com.parking.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"bundle": {
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"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": [
|
"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"
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
|||||||
# ---- runtime: slim, non-root ----
|
# ---- runtime: slim, non-root ----
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
WORKDIR /app
|
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
|
ENV NODE_ENV=production
|
||||||
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||||
RUN addgroup -S app && adduser -S -G app app
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,12 @@ import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRete
|
|||||||
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
// 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
|
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||||
// wiki/concepts/backup-recovery.md.
|
// 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. */
|
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||||
export function backupKeyFromEnv(): string {
|
export function backupKeyFromEnv(): string {
|
||||||
@@ -65,16 +71,33 @@ export class BackupService {
|
|||||||
readonly #logger?: FastifyBaseLogger;
|
readonly #logger?: FastifyBaseLogger;
|
||||||
|
|
||||||
#running = false;
|
#running = false;
|
||||||
#lastSuccessAt: string | null = null;
|
|
||||||
#lastResult: BackupResult | null = null;
|
|
||||||
#lastErrorAt: string | null = null;
|
|
||||||
#lastError: string | null = null;
|
|
||||||
|
|
||||||
constructor(db: Db, logger?: FastifyBaseLogger) {
|
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#logger = logger;
|
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. */
|
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||||
targetDir(): string | null {
|
targetDir(): string | null {
|
||||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
@@ -104,6 +127,15 @@ export class BackupService {
|
|||||||
|
|
||||||
status(): BackupStatus {
|
status(): BackupStatus {
|
||||||
const r = this.retention();
|
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 {
|
return {
|
||||||
configured: this.configured,
|
configured: this.configured,
|
||||||
targetDir: this.targetDir(),
|
targetDir: this.targetDir(),
|
||||||
@@ -111,12 +143,10 @@ export class BackupService {
|
|||||||
keepDailyDays: r.keepDailyDays,
|
keepDailyDays: r.keepDailyDays,
|
||||||
keyPresent: this.keyPresent,
|
keyPresent: this.keyPresent,
|
||||||
running: this.#running,
|
running: this.#running,
|
||||||
lastSuccessAt: this.#lastSuccessAt,
|
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||||
lastResult: this.#lastResult
|
lastResult,
|
||||||
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
|
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||||
: null,
|
lastError: row?.backupLastError ?? null,
|
||||||
lastErrorAt: this.#lastErrorAt,
|
|
||||||
lastError: this.#lastError,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +169,17 @@ export class BackupService {
|
|||||||
try {
|
try {
|
||||||
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||||
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||||
this.#lastResult = res;
|
this.#persist({
|
||||||
this.#lastSuccessAt = new Date().toISOString();
|
backupLastSuccessAt: new Date().toISOString(),
|
||||||
this.#lastError = null;
|
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||||
|
backupLastErrorAt: null,
|
||||||
|
backupLastError: null,
|
||||||
|
});
|
||||||
return res;
|
return res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#lastError = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
this.#lastErrorAt = new Date().toISOString();
|
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||||
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
|
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
this.#running = false;
|
this.#running = false;
|
||||||
@@ -156,13 +189,34 @@ export class BackupService {
|
|||||||
return this.#inflight;
|
return this.#inflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
|
/**
|
||||||
|
* 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> {
|
async runScheduled(): Promise<void> {
|
||||||
if (!this.configured) return; // silent no-op when backups aren't set up
|
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||||
|
if (!this.isDue()) return;
|
||||||
try {
|
try {
|
||||||
await this.run("scheduled");
|
await this.run("scheduled");
|
||||||
} catch {
|
} catch {
|
||||||
/* recorded in last-error; already logged */
|
/* 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ function receiptFigures(
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
tender?: "cash" | "card";
|
tender?: "cash" | "card";
|
||||||
graceExitMin?: number;
|
graceExitMin?: number;
|
||||||
|
grossMinor?: number;
|
||||||
|
validationLines?: { label: string; discountMinor: number }[];
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -89,6 +91,9 @@ function receiptFigures(
|
|||||||
currency: p.currency ?? "ALL",
|
currency: p.currency ?? "ALL",
|
||||||
tender: p.tender === "card" ? "card" : "cash",
|
tender: p.tender === "card" ? "card" : "cash",
|
||||||
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
import { windowOwedBetween } from "./subscription-window.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
|
// 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:
|
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||||
@@ -38,8 +39,17 @@ export interface Quote {
|
|||||||
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
* 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). */
|
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||||
readonly periodStart: string;
|
readonly periodStart: string;
|
||||||
/** Amount owed now: the fee for [periodStart → now]. */
|
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** The pre-validation fee (= amountMinor when no validations apply). */
|
||||||
|
readonly grossMinor: number;
|
||||||
|
/** Total the merchant validations took off (gross − net). */
|
||||||
|
readonly discountMinor: number;
|
||||||
|
/** Per-validation receipt/display lines (empty when none apply). */
|
||||||
|
readonly validationLines: ValidationLine[];
|
||||||
|
/** The validation event ids this quote applied — the payment stamps them as
|
||||||
|
* CONSUMED so an overstay's fresh period never re-applies them. */
|
||||||
|
readonly validationIds: string[];
|
||||||
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||||
readonly overstay: boolean;
|
readonly overstay: boolean;
|
||||||
readonly currency: string;
|
readonly currency: string;
|
||||||
@@ -117,6 +127,12 @@ export interface SessionLookup {
|
|||||||
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||||
* none. Display/audit only — never an access decision. */
|
* none. Display/audit only — never an access decision. */
|
||||||
readonly plate: string | null;
|
readonly plate: string | null;
|
||||||
|
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
|
||||||
|
* fee, the total taken off, and the per-validation lines for the modal/receipt.
|
||||||
|
* grossMinor/discountMinor are null when no quote resolved. */
|
||||||
|
readonly grossMinor: number | null;
|
||||||
|
readonly discountMinor: number | null;
|
||||||
|
readonly validationLines: ValidationLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PayStation {
|
export class PayStation {
|
||||||
@@ -155,19 +171,28 @@ export class PayStation {
|
|||||||
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||||
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||||
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
// 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 last = this.#lastPayment(identity);
|
||||||
|
const validations = liveValidations(this.#db, identity);
|
||||||
const p = priceSession(
|
const p = priceSession(
|
||||||
entry.occurredAt,
|
entry.occurredAt,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
structure,
|
structure,
|
||||||
last ? [last] : [],
|
last ? [last] : [],
|
||||||
category,
|
category,
|
||||||
|
validations,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
periodStart: p.periodStart,
|
periodStart: p.periodStart,
|
||||||
amountMinor: p.amountMinor,
|
amountMinor: p.amountMinor,
|
||||||
|
grossMinor: p.grossMinor,
|
||||||
|
discountMinor: p.discountMinor,
|
||||||
|
validationLines: p.validationLines,
|
||||||
|
validationIds: validations.map((v) => v.eventId),
|
||||||
overstay: p.overstay,
|
overstay: p.overstay,
|
||||||
currency: tv.currency,
|
currency: tv.currency,
|
||||||
tariffVersionId: tv.id,
|
tariffVersionId: tv.id,
|
||||||
@@ -246,6 +271,18 @@ export class PayStation {
|
|||||||
// The exit flow reads graceExitMin off the payment to validate the
|
// The exit flow reads graceExitMin off the payment to validate the
|
||||||
// walk-back window without re-resolving the tariff.
|
// walk-back window without re-resolving the tariff.
|
||||||
graceExitMin: q.graceExitMin,
|
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 } : {}),
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -282,6 +319,7 @@ export class PayStation {
|
|||||||
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||||
withinGrace: false, graceExpiresAt: null,
|
withinGrace: false, graceExpiresAt: null,
|
||||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||||
|
grossMinor: null, discountMinor: null, validationLines: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
@@ -319,11 +357,17 @@ export class PayStation {
|
|||||||
// exit gate clears. See wiki/entities/subscription.md.
|
// exit gate clears. See wiki/entities/subscription.md.
|
||||||
let amountMinor: number | null = null;
|
let amountMinor: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
|
let grossMinor: number | null = null;
|
||||||
|
let discountMinor: number | null = null;
|
||||||
|
let validationLines: ValidationLine[] = [];
|
||||||
if (open && !isSubscription) {
|
if (open && !isSubscription) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(id);
|
const q = this.quote(id);
|
||||||
amountMinor = q.amountMinor;
|
amountMinor = q.amountMinor;
|
||||||
currency = q.currency;
|
currency = q.currency;
|
||||||
|
grossMinor = q.grossMinor;
|
||||||
|
discountMinor = q.discountMinor;
|
||||||
|
validationLines = q.validationLines;
|
||||||
} catch {
|
} catch {
|
||||||
/* no active tariff — leave null; modal shows session without a price */
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
}
|
}
|
||||||
@@ -344,6 +388,7 @@ export class PayStation {
|
|||||||
subscription: isSubscription, subscriptionId,
|
subscription: isSubscription, subscriptionId,
|
||||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||||
|
grossMinor, discountMinor, validationLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
|
|||||||
|
|
||||||
/** The session shape the SPA bootstraps from: identity + role + its permission
|
/** The session shape the SPA bootstraps from: identity + role + its permission
|
||||||
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||||
* permissions are the source of truth. */
|
* permissions are the source of truth.
|
||||||
|
*
|
||||||
|
* `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(
|
function sessionView(
|
||||||
db: Db,
|
db: Db,
|
||||||
user: {
|
user: {
|
||||||
@@ -78,6 +92,7 @@ function sessionView(
|
|||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
},
|
},
|
||||||
|
csrf?: string,
|
||||||
) {
|
) {
|
||||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||||
const permissions = [...permissionsFor(user.roleId)];
|
const permissions = [...permissionsFor(user.roleId)];
|
||||||
@@ -92,6 +107,7 @@ function sessionView(
|
|||||||
fontScale: user.fontScale,
|
fontScale: user.fontScale,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
|
...(csrf ? { csrfToken: csrf } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +142,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
setAuthCookies(reply, token, csrf);
|
setAuthCookies(reply, token, csrf);
|
||||||
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
// `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.
|
// 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) => {
|
app.post("/api/auth/logout", async (_req, reply) => {
|
||||||
@@ -146,7 +162,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
clearAuthCookies(reply);
|
clearAuthCookies(reply);
|
||||||
return reply.code(401).send({ error: "session no longer valid" });
|
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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("health + login", () => {
|
|||||||
it("GET /health is open", async () => {
|
it("GET /health is open", async () => {
|
||||||
const res = await app.inject({ method: "GET", url: "/health" });
|
const res = await app.inject({ method: "GET", url: "/health" });
|
||||||
expect(res.statusCode).toBe(200);
|
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 () => {
|
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", () => {
|
describe("RBAC permission gate", () => {
|
||||||
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||||
const { username, password } = await seedUser(db, {
|
const { username, password } = await seedUser(db, {
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
|
|||||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
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).
|
// Read site config (capacity + park metadata).
|
||||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
|||||||
@@ -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,360 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
isNull,
|
||||||
|
inArray,
|
||||||
|
ledgerEvents,
|
||||||
|
users,
|
||||||
|
validationProgramUsers,
|
||||||
|
validationPrograms,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import { 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);
|
||||||
|
if (!intOrNull(b.minutes)) return "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 === "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> {
|
||||||
|
const siteRead = requirePermission("site:read");
|
||||||
|
const siteWrite = requirePermission("site:update");
|
||||||
|
const applyGuard = 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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session state — an open transient (subscriptions are prepaid; nothing to discount).
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, 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 reply.code(404).send({ error: "no session for ticket" });
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (entryPl.permit === true || entryPl.permitId != null) {
|
||||||
|
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
|
||||||
|
return reply.code(409).send({ error: "session is closed" });
|
||||||
|
}
|
||||||
|
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
|
||||||
|
return reply.code(409).send({ error: "this program is already applied to the ticket" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
|
||||||
|
// appliance runs in site time).
|
||||||
|
if (program.maxPerDay != null) {
|
||||||
|
const midnight = new Date();
|
||||||
|
midnight.setHours(0, 0, 0, 0);
|
||||||
|
const todays = db
|
||||||
|
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "validation"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
|
||||||
|
const voidedIds = new Set(
|
||||||
|
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
|
||||||
|
);
|
||||||
|
const count = todays.filter((r) => {
|
||||||
|
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
|
||||||
|
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
|
||||||
|
}).length;
|
||||||
|
if (count >= program.maxPerDay) {
|
||||||
|
return reply.code(409).send({ error: "daily cap reached for this program" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the values off the program row (frozen into the signed event).
|
||||||
|
let amountMinor: number | undefined;
|
||||||
|
if (program.mode === "fixed") {
|
||||||
|
const a = req.body?.amountMinor;
|
||||||
|
if (a == null || !Number.isInteger(a) || a <= 0) {
|
||||||
|
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
|
||||||
|
}
|
||||||
|
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
|
||||||
|
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
|
||||||
|
}
|
||||||
|
amountMinor = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = await eventLog.append({
|
||||||
|
type: "validation",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
programId,
|
||||||
|
programLabel: program.name,
|
||||||
|
mode: program.mode,
|
||||||
|
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
|
||||||
|
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
|
||||||
|
...(amountMinor != null ? { amountMinor } : {}),
|
||||||
|
operator: req.user.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send({
|
||||||
|
ok: true,
|
||||||
|
eventId: ev.id,
|
||||||
|
programId,
|
||||||
|
label: program.name,
|
||||||
|
mode: program.mode,
|
||||||
|
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
|
||||||
|
percent: program.mode === "percent" ? program.percent : undefined,
|
||||||
|
amountMinor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { requireAuth, roleHasPermissions } from "../auth.js";
|
||||||
import {
|
import {
|
||||||
deviceEvents,
|
deviceEvents,
|
||||||
type LaneStatusEvent,
|
type LaneStatusEvent,
|
||||||
@@ -31,11 +32,57 @@ import { getOccupancy } from "../occupancy.js";
|
|||||||
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
// 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.
|
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
||||||
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
// 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 +
|
/** Permission required to watch the live feed (a read-only stream of ledger +
|
||||||
* device status). Any role granted `report:read` may watch. */
|
* device status). Any role granted `report:read` may watch. */
|
||||||
const WATCH_PERMISSION = "report:read" as const;
|
const WATCH_PERMISSION = "report:read" as const;
|
||||||
|
|
||||||
|
/** 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
|
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
||||||
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
|
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
|
||||||
@@ -80,19 +127,43 @@ export async function wsRoutes(
|
|||||||
laneStatus: LaneStatus,
|
laneStatus: LaneStatus,
|
||||||
lanePresence: LanePresence,
|
lanePresence: LanePresence,
|
||||||
): Promise<void> {
|
): 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(
|
app.get(
|
||||||
"/api/ws",
|
"/api/ws",
|
||||||
{
|
{
|
||||||
websocket: true,
|
websocket: true,
|
||||||
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
|
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN
|
||||||
// role. Reject a cross/absent origin before touching the token, so a hijack
|
// session (JWT cookie, or a desktop WS ticket) THEN role. Reject a
|
||||||
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
|
// cross/absent origin before touching either credential, so a hijack
|
||||||
|
// attempt never reaches an authenticated socket.
|
||||||
preHandler: async (req) => {
|
preHandler: async (req) => {
|
||||||
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
||||||
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
await req.jwtVerify();
|
const rawTicket = req.headers[WS_TICKET_HEADER];
|
||||||
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
|
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 = req.user.roleId;
|
||||||
|
}
|
||||||
|
if (!roleHasPermissions(roleId, [WATCH_PERMISSION])) {
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
|
|||||||
import { drawerRoutes } from "./routes/drawer.js";
|
import { drawerRoutes } from "./routes/drawer.js";
|
||||||
import { entryRoutes } from "./routes/entry.js";
|
import { entryRoutes } from "./routes/entry.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
|
import { validationRoutes } from "./routes/validations.js";
|
||||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
@@ -105,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
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.
|
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||||
await authRoutes(app, db);
|
await authRoutes(app, db);
|
||||||
@@ -292,6 +296,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
await siteRoutes(app, db, eventLog);
|
await siteRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Merchant validations (bar / lavazh): setup panel config + the merchant user's
|
||||||
|
// scan-and-apply. The booth settlement folds the applied validations into its
|
||||||
|
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md.
|
||||||
|
await validationRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
// 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.
|
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||||
await logRoutes(app, logService);
|
await logRoutes(app, logService);
|
||||||
@@ -330,12 +339,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
void runSnapPrune(); // once at startup
|
void runSnapPrune(); // once at startup
|
||||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||||
|
|
||||||
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
|
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
|
||||||
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
|
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
|
||||||
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
|
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
|
||||||
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
|
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
|
||||||
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
|
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
|
||||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
|
// 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();
|
backupTimer.unref();
|
||||||
app.addHook("onClose", async () => clearInterval(backupTimer));
|
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||||
if (backupService.configured) {
|
if (backupService.configured) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface ShiftSummary {
|
|||||||
readonly subscriptionTotalMinor: number;
|
readonly subscriptionTotalMinor: number;
|
||||||
readonly subscriptionSalesMinor: number;
|
readonly subscriptionSalesMinor: number;
|
||||||
readonly subscriptionWindowMinor: number;
|
readonly subscriptionWindowMinor: number;
|
||||||
|
readonly discountTotalMinor: number;
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
readonly cashAddedMinor: number;
|
readonly cashAddedMinor: number;
|
||||||
readonly cashRemovedMinor: number;
|
readonly cashRemovedMinor: number;
|
||||||
@@ -78,6 +79,9 @@ export interface ShiftReport {
|
|||||||
readonly subscriptionSalesMinor: number;
|
readonly subscriptionSalesMinor: number;
|
||||||
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
||||||
readonly subscriptionWindowMinor: number;
|
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;
|
||||||
// --- Drawer (physical cash till; carries across shifts) ---
|
// --- Drawer (physical cash till; carries across shifts) ---
|
||||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
@@ -220,6 +224,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor?: number;
|
subscriptionTotalMinor?: number;
|
||||||
subscriptionSalesMinor?: number;
|
subscriptionSalesMinor?: number;
|
||||||
subscriptionWindowMinor?: number;
|
subscriptionWindowMinor?: number;
|
||||||
|
discountTotalMinor?: number;
|
||||||
openingFloatMinor?: number;
|
openingFloatMinor?: number;
|
||||||
cashAddedMinor?: number;
|
cashAddedMinor?: number;
|
||||||
cashRemovedMinor?: number;
|
cashRemovedMinor?: number;
|
||||||
@@ -250,6 +255,8 @@ export class ShiftService {
|
|||||||
ticketTotalMinor:
|
ticketTotalMinor:
|
||||||
pl.ticketTotalMinor ??
|
pl.ticketTotalMinor ??
|
||||||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
(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,
|
||||||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||||
@@ -522,6 +529,9 @@ export class ShiftService {
|
|||||||
// the subscription sale path).
|
// the subscription sale path).
|
||||||
let subscriptionSalesMinor = 0;
|
let subscriptionSalesMinor = 0;
|
||||||
let subscriptionWindowMinor = 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;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
for (const p of payments) {
|
for (const p of payments) {
|
||||||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||||
@@ -534,6 +544,7 @@ export class ShiftService {
|
|||||||
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
||||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||||
// (else → transient ticket; derived below as total − subscription)
|
// (else → transient ticket; derived below as total − subscription)
|
||||||
|
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||||
@@ -589,6 +600,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -627,6 +639,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -649,6 +662,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -692,6 +706,9 @@ export class ShiftService {
|
|||||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||||
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${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 --",
|
"-- Arka --",
|
||||||
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { eq, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import type { SessionValidation, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
|
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
|
||||||
|
// session (never a mutable flag): payload carries the RESOLVED values (programId,
|
||||||
|
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
|
||||||
|
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
|
||||||
|
// which validations it CONSUMED (so an overstay's fresh period never re-applies
|
||||||
|
// them). See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
/** A validation event folded with its lifecycle state. */
|
||||||
|
export interface AppliedValidation extends SessionValidation {
|
||||||
|
readonly eventId: string;
|
||||||
|
readonly occurredAt: string;
|
||||||
|
/** The merchant username who applied it. */
|
||||||
|
readonly operator: string | null;
|
||||||
|
/** Voided by a later validation event referencing it. */
|
||||||
|
readonly voided: boolean;
|
||||||
|
/** The payment event id that consumed it, if settled. */
|
||||||
|
readonly consumedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All validations ever applied to a session (newest last), with voided/consumed
|
||||||
|
* state folded from the chain. One identity-scoped ledger scan. */
|
||||||
|
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: ledgerEvents.id,
|
||||||
|
type: ledgerEvents.type,
|
||||||
|
occurredAt: ledgerEvents.occurredAt,
|
||||||
|
payload: ledgerEvents.payload,
|
||||||
|
})
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const voided = new Set<string>();
|
||||||
|
const consumedBy = new Map<string, string>();
|
||||||
|
const applies: AppliedValidation[] = [];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const p = (r.payload ?? {}) as {
|
||||||
|
refId?: string;
|
||||||
|
programId?: string;
|
||||||
|
programLabel?: string;
|
||||||
|
mode?: ValidationMode;
|
||||||
|
minutes?: number;
|
||||||
|
amountMinor?: number;
|
||||||
|
percent?: number;
|
||||||
|
operator?: string;
|
||||||
|
validationIds?: string[];
|
||||||
|
};
|
||||||
|
if (r.type === "validation") {
|
||||||
|
if (p.refId) {
|
||||||
|
voided.add(p.refId);
|
||||||
|
} else if (p.programId && p.mode) {
|
||||||
|
applies.push({
|
||||||
|
eventId: r.id,
|
||||||
|
occurredAt: r.occurredAt,
|
||||||
|
programId: p.programId,
|
||||||
|
label: p.programLabel ?? p.programId,
|
||||||
|
mode: p.mode,
|
||||||
|
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
|
||||||
|
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
|
||||||
|
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
|
||||||
|
operator: p.operator ?? null,
|
||||||
|
voided: false,
|
||||||
|
consumedBy: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
|
||||||
|
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applies.map((a) => ({
|
||||||
|
...a,
|
||||||
|
voided: voided.has(a.eventId),
|
||||||
|
consumedBy: consumedBy.get(a.eventId) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
|
||||||
|
* payment. This is exactly what `priceSession(..., validations)` expects. */
|
||||||
|
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
|
||||||
|
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
|
||||||
|
}
|
||||||
@@ -18,8 +18,12 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.15",
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-store": "^2.4.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-websocket": "^2.3.0",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
|
|||||||
@@ -3,25 +3,42 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { RouterProvider } from "@tanstack/react-router";
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
import { fetchMe, type SessionUser } from "./api.js";
|
import { fetchMe, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
|
import { ConnectScreen } from "./ConnectScreen.js";
|
||||||
import { queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
import { initApiBase, inTauri } from "./lib/origin.js";
|
||||||
|
|
||||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
// off to TanStack Router inside the QueryClient provider. The router renders the
|
// off to TanStack Router inside the QueryClient provider. The router renders the
|
||||||
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
||||||
// the signed-in user flows into the router context for role-based route guards.
|
// the signed-in user flows into the router context for role-based route guards.
|
||||||
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
||||||
|
//
|
||||||
|
// Desktop shell only: BEFORE any of that, the backend origin itself must be
|
||||||
|
// known — the same installer is used at every booth (see lib/origin.ts /
|
||||||
|
// backend-config.ts), so on first launch (or after the operator clears it)
|
||||||
|
// there is no server to call fetchMe() against yet. ConnectScreen gates that;
|
||||||
|
// a browser build always has a same-origin backend, so `needsConnect` is
|
||||||
|
// always false there and this is skipped entirely.
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [user, setUser] = useState<SessionUser | null>(null);
|
const [user, setUser] = useState<SessionUser | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [needsConnect, setNeedsConnect] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
initApiBase().then((saved) => {
|
||||||
|
if (inTauri() && !saved) {
|
||||||
|
setNeedsConnect(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchMe()
|
fetchMe()
|
||||||
.then(setUser)
|
.then(setUser)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||||
@@ -41,6 +58,19 @@ export function App() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
||||||
}
|
}
|
||||||
|
if (needsConnect) {
|
||||||
|
return (
|
||||||
|
<ConnectScreen
|
||||||
|
onConnected={() => {
|
||||||
|
setNeedsConnect(false);
|
||||||
|
setLoading(true);
|
||||||
|
fetchMe()
|
||||||
|
.then(setUser)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { rootRoute } from "./router.js";
|
import { rootRoute } from "./router.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||||
import { Spinner } from "./ui/Spinner.js";
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
@@ -310,12 +310,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||||||
<>
|
<>
|
||||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
|
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
value={
|
value={
|
||||||
@@ -335,11 +335,15 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
<>
|
<>
|
||||||
{/* Session figures */}
|
{/* Session figures */}
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||||
<Row
|
<Row
|
||||||
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||||
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
|
value={formatRelativeDateTime(
|
||||||
|
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
|
||||||
|
t,
|
||||||
|
{ seconds: true },
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
@@ -379,6 +383,30 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Merchant validations (bar/lavazh): the gross fee + one line per
|
||||||
|
discount — the Total below is the NET the customer pays. The lines
|
||||||
|
ride the quote (SessionLookup.validationLines) and reprint on the
|
||||||
|
receipt. See wiki/concepts/validation-discounts.md. */}
|
||||||
|
{!isSubscription &&
|
||||||
|
(s.validationLines ?? []).length > 0 &&
|
||||||
|
s.currency != null &&
|
||||||
|
s.amountMinor != null && (
|
||||||
|
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||||
|
<div className="flex justify-between text-term-text">
|
||||||
|
<span>{t("val.gross")}</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(s.validationLines ?? []).map((v, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-term-green">
|
||||||
|
<span>{v.label}</span>
|
||||||
|
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||||
out-of-window window charge; then show that amount. For an overstay the
|
out-of-window window charge; then show that amount. For an overstay the
|
||||||
amount is the TOP-UP delta, not the whole stay. */}
|
amount is the TOP-UP delta, not the whole stay. */}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { setApiBase } from "./lib/origin.js";
|
||||||
|
|
||||||
|
// Desktop-only gate shown BEFORE Login whenever no backend has been
|
||||||
|
// configured yet (first launch of a generic .deb/.AppImage install, or after
|
||||||
|
// the operator clears it from Settings). Same installer works at any booth —
|
||||||
|
// see backend-config.ts for why this can't be a build-time value.
|
||||||
|
//
|
||||||
|
// backend-config.ts is imported dynamically (not at module top-level) purely
|
||||||
|
// to keep bundling consistent with origin.ts/router.tsx's other Tauri-only
|
||||||
|
// imports — this component itself only ever renders inside Tauri anyway, so
|
||||||
|
// it's not a functional requirement, just avoids an INEFFECTIVE_DYNAMIC_IMPORT
|
||||||
|
// warning from Vite (a static import here would defeat those other dynamic
|
||||||
|
// imports' chunk-splitting intent).
|
||||||
|
|
||||||
|
function normalizeHost(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectScreen({ onConnected }: { onConnected: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [result, setResult] = useState<"ok" | "unreachable" | "bad_response" | null>(null);
|
||||||
|
const [detail, setDetail] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const url = normalizeHost(host);
|
||||||
|
const canSubmit = url.length > 0 && !testing && !saving;
|
||||||
|
|
||||||
|
async function handleTest(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setTesting(true);
|
||||||
|
setResult(null);
|
||||||
|
setDetail(undefined);
|
||||||
|
try {
|
||||||
|
const { testBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
const check = await testBackendUrl(url);
|
||||||
|
setResult(check.ok ? "ok" : (check.reason ?? "unreachable"));
|
||||||
|
setDetail(check.detail);
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { saveBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
await saveBackendUrl(url);
|
||||||
|
setApiBase(url);
|
||||||
|
onConnected();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
|
||||||
|
<form onSubmit={handleTest} className="card w-full max-w-sm p-6">
|
||||||
|
<h1 className="mb-1 text-h5 font-semibold uppercase tracking-widest text-term-amber">
|
||||||
|
{t("connect.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-5 text-[0.75rem] text-term-muted">{t("connect.hint")}</p>
|
||||||
|
|
||||||
|
<div className="field mb-3">
|
||||||
|
<label className="label">{t("connect.serverAddress")}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHost(e.target.value);
|
||||||
|
setResult(null);
|
||||||
|
}}
|
||||||
|
placeholder="192.168.1.50:3000"
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result === "ok" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-green">{t("connect.testOk")}</p>
|
||||||
|
)}
|
||||||
|
{result === "unreachable" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-red">
|
||||||
|
{t("connect.testUnreachable")}
|
||||||
|
{detail ? ` (${detail})` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result === "bad_response" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-red">{t("connect.testBadResponse")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="btn flex-1" disabled={!canSubmit}>
|
||||||
|
{testing ? t("connect.testing") : t("connect.test")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
disabled={!canSubmit || result !== "ok"}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{saving ? t("connect.saving") : t("connect.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
import {
|
||||||
|
fetchOccupancy,
|
||||||
|
fetchSiteConfig,
|
||||||
|
fetchValidationPrograms,
|
||||||
|
saveSiteConfig,
|
||||||
|
saveValidationProgram,
|
||||||
|
type Occupancy,
|
||||||
|
type SiteConfig,
|
||||||
|
type ValidationProgramView,
|
||||||
|
} from "./api.js";
|
||||||
|
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
|
||||||
|
|
||||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||||
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||||
@@ -28,12 +38,21 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
const [reserveSubs, setReserveSubs] = useState(false);
|
const [reserveSubs, setReserveSubs] = useState(false);
|
||||||
const [anprEntry, setAnprEntry] = useState(true);
|
const [anprEntry, setAnprEntry] = useState(true);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
|
||||||
|
// station's `active` (persisted at once — each flip signs a config_change); the
|
||||||
|
// right-column panel edits the enabled stations. See validation-discounts.md.
|
||||||
|
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
fetchOccupancy().then(setOcc).catch(() => {});
|
fetchOccupancy().then(setOcc).catch(() => {});
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
|
if (canEdit) {
|
||||||
|
fetchValidationPrograms()
|
||||||
|
.then((r) => setPrograms(r.programs))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
fetchSiteConfig()
|
fetchSiteConfig()
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
setMeta(m);
|
setMeta(m);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, [canEdit]);
|
||||||
|
|
||||||
|
/** Flip a merchant station's checkbox: persist `active` at once (a signed
|
||||||
|
* config_change server-side), creating the well-known row with comp defaults on
|
||||||
|
* the first enable. Config details are edited in the right-column panel. */
|
||||||
|
async function toggleStation(id: StationId, active: boolean) {
|
||||||
|
const existing = programs.find((p) => p.id === id);
|
||||||
|
const body = existing
|
||||||
|
? { ...existing, active }
|
||||||
|
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
|
||||||
|
try {
|
||||||
|
const saved = await saveValidationProgram(id, body);
|
||||||
|
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -68,7 +103,8 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card mt-6 max-w-md p-4">
|
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||||
|
<section className="card w-full max-w-md p-4">
|
||||||
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
||||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||||
{occ == null ? (
|
{occ == null ? (
|
||||||
@@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
{t("val.sectionTitle")}
|
||||||
|
</div>
|
||||||
|
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{STATIONS.map((id) => (
|
||||||
|
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={programs.find((p) => p.id === id)?.active ?? false}
|
||||||
|
onChange={(e) => toggleStation(id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{t("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{canEdit && (
|
||||||
|
<ValidationStationsPanel
|
||||||
|
programs={programs}
|
||||||
|
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
applyValidation,
|
||||||
|
fetchMyValidationPrograms,
|
||||||
|
fetchValidationSession,
|
||||||
|
voidValidation,
|
||||||
|
type SessionUser,
|
||||||
|
type ValidationProgramView,
|
||||||
|
type ValidationSessionView,
|
||||||
|
} from "./api.js";
|
||||||
|
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
|
||||||
|
// The MERCHANT screen (/validate): the bar/lavazh user's ENTIRE surface. Scan or key
|
||||||
|
// the customer's ticket → see the session (deliberately NO money data — the booth
|
||||||
|
// settles) → apply the bound program → done. Mobile-friendly: a phone/tablet on the
|
||||||
|
// site LAN, or a booth-style USB HID scanner (it types digits + Enter into the
|
||||||
|
// focused input). A mistake can be voided while UNUSED (append-only, signed).
|
||||||
|
// Gated by validation:create + the server-side program↔user binding.
|
||||||
|
// See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
type Program = Omit<ValidationProgramView, "userIds">;
|
||||||
|
|
||||||
|
/** Human line for what a program grants (the params live on the program row). */
|
||||||
|
function programSummary(p: Program, t: (k: string, o?: Record<string, unknown>) => string): string {
|
||||||
|
if (p.mode === "comp") return t("val.modeComp");
|
||||||
|
if (p.mode === "timeCredit") return `${t("val.modeTimeCredit")}: ${p.minutes ?? 0} min`;
|
||||||
|
if (p.mode === "percent") return `${t("val.modePercent")}: ${p.percent ?? 0}%`;
|
||||||
|
return t("val.modeFixed");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValidateScreen({ user }: { user: SessionUser }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [programs, setPrograms] = useState<Program[] | null>(null);
|
||||||
|
const [programId, setProgramId] = useState<string | null>(null);
|
||||||
|
const [ticket, setTicket] = useState("");
|
||||||
|
const [view, setView] = useState<ValidationSessionView | null>(null);
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyValidationPrograms()
|
||||||
|
.then((r) => {
|
||||||
|
setPrograms(r.programs);
|
||||||
|
if (r.programs.length === 1) setProgramId(r.programs[0]!.id);
|
||||||
|
})
|
||||||
|
.catch(() => setPrograms([]));
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const program = programs?.find((p) => p.id === programId) ?? null;
|
||||||
|
|
||||||
|
async function lookup(id?: string) {
|
||||||
|
const identity = (id ?? ticket).trim();
|
||||||
|
if (!identity) return;
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
setView(await fetchValidationSession(identity));
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ kind: "err", text: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
if (!view || !program) return;
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const body: { identity: string; programId: string; amountMinor?: number } = {
|
||||||
|
identity: view.identity,
|
||||||
|
programId: program.id,
|
||||||
|
};
|
||||||
|
if (program.mode === "fixed") {
|
||||||
|
const n = Number(amount);
|
||||||
|
body.amountMinor = Number.isFinite(n) ? Math.round(n * 100) : 0;
|
||||||
|
}
|
||||||
|
await applyValidation(body);
|
||||||
|
setMsg({ kind: "ok", text: t("val.applied") });
|
||||||
|
setAmount("");
|
||||||
|
await lookup(view.identity);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ kind: "err", text: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function voidOne(eventId: string) {
|
||||||
|
if (!view) return;
|
||||||
|
if (!window.confirm(t("val.confirmVoid"))) return;
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await voidValidation({ eventId, identity: view.identity });
|
||||||
|
await lookup(view.identity);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ kind: "err", text: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session's blocking condition, if any (not found / closed / subscriber).
|
||||||
|
const blocked =
|
||||||
|
view == null
|
||||||
|
? null
|
||||||
|
: !view.found
|
||||||
|
? t("val.notFound")
|
||||||
|
: view.subscription
|
||||||
|
? t("val.subscription")
|
||||||
|
: !view.open
|
||||||
|
? t("val.closed")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const alreadyApplied =
|
||||||
|
view != null &&
|
||||||
|
program != null &&
|
||||||
|
view.validations.some((v) => v.programId === program.id && !v.voided && v.consumedBy == null);
|
||||||
|
|
||||||
|
const fixedAmountOk =
|
||||||
|
program?.mode !== "fixed" ||
|
||||||
|
(Number(amount) > 0 &&
|
||||||
|
(program.maxAmountMinor == null || Math.round(Number(amount) * 100) <= program.maxAmountMinor));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-6 w-full max-w-md">
|
||||||
|
<section className="card p-4">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.title")}</div>
|
||||||
|
|
||||||
|
{programs != null && programs.length === 0 && (
|
||||||
|
<p className="mt-3 text-[0.8125rem] text-term-red">{t("val.noPrograms")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{programs != null && programs.length > 1 && (
|
||||||
|
<div className="mt-3 flex gap-1">
|
||||||
|
{programs.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${p.id === programId ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => setProgramId(p.id)}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{program && <p className="mt-1 text-[0.75rem] text-term-muted">{program.name} — {programSummary(program, t)}</p>}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-3 flex gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void lookup();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="input flex-1 tabular-nums"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={ticket}
|
||||||
|
onChange={(e) => setTicket(e.target.value)}
|
||||||
|
placeholder={t("val.scanPrompt")}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm">{t("val.lookup")}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<p className={`mt-2 text-[0.8125rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||||
|
{msg.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view && (
|
||||||
|
<div className="mt-3 border-t border-term-border pt-3">
|
||||||
|
{blocked ? (
|
||||||
|
<p className="text-[0.8125rem] text-term-red">{blocked}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-baseline justify-between text-[0.8125rem]">
|
||||||
|
<span className="font-semibold tabular-nums text-term-text">{view.identity}</span>
|
||||||
|
<span className="text-term-muted">
|
||||||
|
{t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)}
|
||||||
|
{view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}</>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{program && !alreadyApplied && (
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
{program.mode === "fixed" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">
|
||||||
|
{t("val.amountLabel")}
|
||||||
|
{program.maxAmountMinor != null && (
|
||||||
|
<span className="hint ml-2">
|
||||||
|
{t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input w-40 tabular-nums"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy || !fixedAmountOk}
|
||||||
|
onClick={apply}
|
||||||
|
>
|
||||||
|
{t("val.apply")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.validations.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="label">{t("val.existing")}</div>
|
||||||
|
<ul className="mt-1 grid gap-1">
|
||||||
|
{view.validations.map((v) => (
|
||||||
|
<li key={v.eventId} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
|
<span>{v.label}</span>
|
||||||
|
{v.amountMinor != null && <span className="tabular-nums">−{formatMoney(v.amountMinor, "")}</span>}
|
||||||
|
{v.minutes != null && <span>{v.minutes} min</span>}
|
||||||
|
{v.percent != null && <span>{v.percent}%</span>}
|
||||||
|
{v.voided ? (
|
||||||
|
<span className="text-term-muted">({t("val.voided")})</span>
|
||||||
|
) : v.consumedBy != null ? (
|
||||||
|
<span className="text-term-muted">({t("val.used")})</span>
|
||||||
|
) : (
|
||||||
|
v.operator === user.username && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm ml-auto" onClick={() => voidOne(v.eventId)}>
|
||||||
|
{t("val.void")}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
fetchUsers,
|
||||||
|
saveValidationProgram,
|
||||||
|
type ManagedUser,
|
||||||
|
type ValidationMode,
|
||||||
|
type ValidationProgramView,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// The /setup/site RIGHT panel: per-station merchant-validation config (Bar / Lavazh).
|
||||||
|
// The checkboxes on the left card toggle a station's `active`; this panel edits the
|
||||||
|
// enabled stations' programs — one panel, tabs when both are on. Storage is generic
|
||||||
|
// (validation_programs rows keyed "bar"/"lavazh"); the UI is deliberately these two
|
||||||
|
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
|
||||||
|
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
/** The two well-known stations the checkboxes toggle. */
|
||||||
|
export const STATIONS = ["bar", "lavazh"] as const;
|
||||||
|
export type StationId = (typeof STATIONS)[number];
|
||||||
|
|
||||||
|
/** A blank program draft for a station enabled for the first time. */
|
||||||
|
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
|
||||||
|
return {
|
||||||
|
name: label,
|
||||||
|
mode: "comp",
|
||||||
|
minutes: null,
|
||||||
|
percent: null,
|
||||||
|
maxAmountMinor: null,
|
||||||
|
maxPerDay: null,
|
||||||
|
active: true,
|
||||||
|
userIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinor = (s: string): number | null => {
|
||||||
|
const v = s.trim();
|
||||||
|
if (v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
|
||||||
|
};
|
||||||
|
const fromMinor = (m: number | null): string => (m == null ? "" : String(m / 100));
|
||||||
|
const toInt = (s: string): number | null => {
|
||||||
|
const v = s.trim();
|
||||||
|
if (v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StationForm({
|
||||||
|
program,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
program: ValidationProgramView;
|
||||||
|
onSaved: (p: ValidationProgramView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [name, setName] = useState(program.name);
|
||||||
|
const [mode, setMode] = useState<ValidationMode>(program.mode);
|
||||||
|
const [minutes, setMinutes] = useState(program.minutes == null ? "" : String(program.minutes));
|
||||||
|
const [percent, setPercent] = useState(program.percent == null ? "" : String(program.percent));
|
||||||
|
const [maxAmount, setMaxAmount] = useState(fromMinor(program.maxAmountMinor));
|
||||||
|
const [maxPerDay, setMaxPerDay] = useState(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
||||||
|
const [userIds, setUserIds] = useState<Set<string>>(new Set(program.userIds));
|
||||||
|
const [users, setUsers] = useState<ManagedUser[] | null>(null);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset the form when the tab switches to another station.
|
||||||
|
useEffect(() => {
|
||||||
|
setName(program.name);
|
||||||
|
setMode(program.mode);
|
||||||
|
setMinutes(program.minutes == null ? "" : String(program.minutes));
|
||||||
|
setPercent(program.percent == null ? "" : String(program.percent));
|
||||||
|
setMaxAmount(fromMinor(program.maxAmountMinor));
|
||||||
|
setMaxPerDay(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
||||||
|
setUserIds(new Set(program.userIds));
|
||||||
|
setMsg(null);
|
||||||
|
}, [program.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
.then((r) => setUsers(r.users))
|
||||||
|
.catch(() => setUsers([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const valid = useMemo(() => {
|
||||||
|
if (!name.trim()) return false;
|
||||||
|
if (mode === "timeCredit") return toInt(minutes) != null;
|
||||||
|
if (mode === "percent") {
|
||||||
|
const p = toInt(percent);
|
||||||
|
return p != null && p <= 100;
|
||||||
|
}
|
||||||
|
if (mode === "fixed") return toMinor(maxAmount) != null;
|
||||||
|
return true;
|
||||||
|
}, [name, mode, minutes, percent, maxAmount]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const saved = await saveValidationProgram(program.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
mode,
|
||||||
|
minutes: mode === "timeCredit" ? toInt(minutes) : null,
|
||||||
|
percent: mode === "percent" ? toInt(percent) : null,
|
||||||
|
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
|
||||||
|
maxPerDay: toInt(maxPerDay),
|
||||||
|
active: program.active,
|
||||||
|
userIds: [...userIds],
|
||||||
|
});
|
||||||
|
onSaved(saved);
|
||||||
|
setMsg(t("val.saved"));
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleUser = (id: string) =>
|
||||||
|
setUserIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.labelName")}</span>
|
||||||
|
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("val.labelNamePh")} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.mode")}</span>
|
||||||
|
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
||||||
|
<option value="comp">{t("val.modeComp")}</option>
|
||||||
|
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
|
||||||
|
<option value="fixed">{t("val.modeFixed")}</option>
|
||||||
|
<option value="percent">{t("val.modePercent")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{mode === "timeCredit" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.minutes")}</span>
|
||||||
|
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "percent" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.percent")}</span>
|
||||||
|
<input className="input w-32" value={percent} onChange={(e) => setPercent(e.target.value)} placeholder="100" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "fixed" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.maxAmount")}</span>
|
||||||
|
<input className="input w-32" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.maxPerDay")}</span>
|
||||||
|
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="label">{t("val.users")}</div>
|
||||||
|
<span className="hint block">{t("val.usersHint")}</span>
|
||||||
|
<div className="mt-1 grid gap-1">
|
||||||
|
{users == null ? (
|
||||||
|
<span className="text-term-muted">…</span>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<span className="text-[0.75rem] text-term-muted">{t("val.noUsers")}</span>
|
||||||
|
) : (
|
||||||
|
users.map((u) => (
|
||||||
|
<label key={u.id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={userIds.has(u.id)}
|
||||||
|
onChange={() => toggleUser(u.id)}
|
||||||
|
/>
|
||||||
|
{u.username}
|
||||||
|
{u.fullName && <span className="text-term-muted">({u.fullName})</span>}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
||||||
|
{t("site.save")}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The right-column panel: tabs across the ENABLED stations, one form each. */
|
||||||
|
export function ValidationStationsPanel({
|
||||||
|
programs,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
programs: ValidationProgramView[];
|
||||||
|
onSaved: (p: ValidationProgramView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const enabled = STATIONS.map((id) => programs.find((p) => p.id === id)).filter(
|
||||||
|
(p): p is ValidationProgramView => p != null && p.active,
|
||||||
|
);
|
||||||
|
const [tab, setTab] = useState<string | null>(null);
|
||||||
|
const current = enabled.find((p) => p.id === tab) ?? enabled[0];
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card w-full max-w-md p-4">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.sectionTitle")}</div>
|
||||||
|
{enabled.length > 1 && (
|
||||||
|
<div className="mt-2 flex gap-1">
|
||||||
|
{enabled.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => setTab(p.id)}
|
||||||
|
>
|
||||||
|
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<StationForm program={current} onSaved={onSaved} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+146
-12
@@ -1,13 +1,22 @@
|
|||||||
// Thin API client for the operator/admin UI.
|
// Thin API client for the operator/admin UI.
|
||||||
//
|
//
|
||||||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
// Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically
|
||||||
// automatically (credentials: 'include'). For mutations we echo the readable
|
// (credentials: 'include'). For mutations we echo the readable CSRF cookie
|
||||||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
// back in the X-CSRF-Token header (double-submit). See
|
||||||
// wiki/entities/local-jwt-auth.md.
|
// wiki/entities/local-jwt-auth.md.
|
||||||
|
//
|
||||||
|
// Desktop shell exception: document.cookie can't see the CSRF cookie there
|
||||||
|
// (reqwest's separate jar — see lib/desktop-csrf.ts), so the server also
|
||||||
|
// echoes the token in the login/me response BODY (sessionView's csrfToken —
|
||||||
|
// see routes/auth.ts) and setSessionUser() (called wherever a SessionUser is
|
||||||
|
// received) stashes it via setDesktopCsrfToken(). The browser path is
|
||||||
|
// untouched — it still reads document.cookie.
|
||||||
|
|
||||||
|
import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js";
|
||||||
import { logFailedRequest } from "./lib/logger.js";
|
import { logFailedRequest } from "./lib/logger.js";
|
||||||
import { apiUrl } from "./lib/origin.js";
|
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||||
import type { AppLogRecord } from "@parking/shared";
|
import { inTauri } from "./lib/tauri-env.js";
|
||||||
|
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
const CSRF_COOKIE = "parking_csrf";
|
const CSRF_COOKIE = "parking_csrf";
|
||||||
const CSRF_HEADER = "X-CSRF-Token";
|
const CSRF_HEADER = "X-CSRF-Token";
|
||||||
@@ -17,6 +26,13 @@ function readCookie(name: string): string | null {
|
|||||||
return m ? decodeURIComponent(m[1]!) : null;
|
return m ? decodeURIComponent(m[1]!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update the desktop CSRF stash. Called wherever a SessionUser is received
|
||||||
|
* (login, fetchMe). No-op / cheap in the browser (the value just goes
|
||||||
|
* unused there — reads still come from document.cookie). */
|
||||||
|
function setSessionUser(user: SessionUser): void {
|
||||||
|
if (user.csrfToken) setDesktopCsrfToken(user.csrfToken);
|
||||||
|
}
|
||||||
|
|
||||||
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
||||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const method = (init.method ?? "GET").toUpperCase();
|
const method = (init.method ?? "GET").toUpperCase();
|
||||||
@@ -25,10 +41,10 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
headers.set("content-type", "application/json");
|
headers.set("content-type", "application/json");
|
||||||
}
|
}
|
||||||
if (method !== "GET" && method !== "HEAD") {
|
if (method !== "GET" && method !== "HEAD") {
|
||||||
const csrf = readCookie(CSRF_COOKIE);
|
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
|
||||||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
if (csrf) headers.set(CSRF_HEADER, csrf);
|
||||||
}
|
}
|
||||||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||||
const error = msg.error ?? `${path}: ${res.status}`;
|
const error = msg.error ?? `${path}: ${res.status}`;
|
||||||
@@ -82,6 +98,10 @@ export interface SessionUser {
|
|||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
/** Optional contact email (profile metadata); null if unset. */
|
/** Optional contact email (profile metadata); null if unset. */
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
/** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on
|
||||||
|
* desktop) parking_csrf cookie — see the file header. Absent/unused in the
|
||||||
|
* browser build, which reads the cookie directly instead. */
|
||||||
|
csrfToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this session grant the permission? Central authz check for the SPA. */
|
/** Does this session grant the permission? Central authz check for the SPA. */
|
||||||
@@ -89,15 +109,28 @@ export function can(user: SessionUser | null, perm: Permission): boolean {
|
|||||||
return !!user && user.permissions.includes(perm);
|
return !!user && user.permissions.includes(perm);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(username: string, password: string): Promise<SessionUser> {
|
export async function login(username: string, password: string): Promise<SessionUser> {
|
||||||
return apiFetch<SessionUser>("/api/auth/login", {
|
const user = await apiFetch<SessionUser>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
setSessionUser(user);
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout(): Promise<{ ok: boolean }> {
|
export async function logout(): Promise<{ ok: boolean }> {
|
||||||
return apiFetch("/api/auth/logout", { method: "POST" });
|
const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
|
||||||
|
setDesktopCsrfToken(null);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop only: mint a single-use, short-lived ticket that authenticates the
|
||||||
|
* live-feed WebSocket handshake in place of the session cookie — the native WS
|
||||||
|
* plugin has no cookie jar, so the cookie can never ride along (see
|
||||||
|
* routes/ws.ts and lib/platform-ws.ts). Normal cookie + CSRF auth on the way in. */
|
||||||
|
export async function fetchWsTicket(): Promise<string> {
|
||||||
|
const { ticket } = await apiFetch<{ ticket: string }>("/api/ws/ticket", { method: "POST" });
|
||||||
|
return ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the current user's UI language preference (restored on next login). */
|
/** Persist the current user's UI language preference (restored on next login). */
|
||||||
@@ -146,7 +179,9 @@ export function changeMyPassword(
|
|||||||
/** Returns the current user, or null if not authenticated. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<SessionUser>("/api/auth/me");
|
const user = await apiFetch<SessionUser>("/api/auth/me");
|
||||||
|
setSessionUser(user);
|
||||||
|
return user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
||||||
throw e;
|
throw e;
|
||||||
@@ -253,6 +288,15 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
|
|||||||
return apiFetch("/api/backup/status");
|
return apiFetch("/api/backup/status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VersionInfo {
|
||||||
|
/** "<branch>-<short-sha>" baked in at image build time; null on a local/dev build. */
|
||||||
|
buildVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVersion(): Promise<VersionInfo> {
|
||||||
|
return apiFetch("/api/version");
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackupConfigPatch {
|
export interface BackupConfigPatch {
|
||||||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||||||
targetDir?: string | null;
|
targetDir?: string | null;
|
||||||
@@ -1301,6 +1345,11 @@ export interface SessionLookup {
|
|||||||
subscriptionHolder: string | null;
|
subscriptionHolder: string | null;
|
||||||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||||||
plate: string | null;
|
plate: string | null;
|
||||||
|
/** Merchant validations folded into `amountMinor` (which is NET): pre-discount fee,
|
||||||
|
* total taken off, and the per-validation lines. See validation-discounts.md. */
|
||||||
|
grossMinor: number | null;
|
||||||
|
discountMinor: number | null;
|
||||||
|
validationLines: ValidationLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
@@ -1475,3 +1524,88 @@ export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean
|
|||||||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||||
return saveSiteConfig({ capacity });
|
return saveSiteConfig({ capacity });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Merchant validations (bar / lavazh) -----------------------------------
|
||||||
|
// The merchant is VALIDATION-ONLY: they scan the ticket on their device and apply
|
||||||
|
// their program; the booth settles NET of the applied validations and prints the
|
||||||
|
// detailed receipt. Program config lives on /setup/site. See validation-discounts.md.
|
||||||
|
|
||||||
|
export type { ValidationLine, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
|
/** An admin-composed program (mirrors the server row + its bound users). */
|
||||||
|
export interface ValidationProgramView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mode: ValidationMode;
|
||||||
|
minutes: number | null;
|
||||||
|
percent: number | null;
|
||||||
|
maxAmountMinor: number | null;
|
||||||
|
maxPerDay: number | null;
|
||||||
|
active: boolean;
|
||||||
|
userIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A validation applied to a session, with its lifecycle state. */
|
||||||
|
export interface AppliedValidationView {
|
||||||
|
eventId: string;
|
||||||
|
occurredAt: string;
|
||||||
|
programId: string;
|
||||||
|
label: string;
|
||||||
|
mode: ValidationMode;
|
||||||
|
minutes?: number;
|
||||||
|
amountMinor?: number;
|
||||||
|
percent?: number;
|
||||||
|
operator: string | null;
|
||||||
|
voided: boolean;
|
||||||
|
consumedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The merchant screen's minimal session view — deliberately no money data. */
|
||||||
|
export interface ValidationSessionView {
|
||||||
|
identity: string;
|
||||||
|
found: boolean;
|
||||||
|
open: boolean;
|
||||||
|
enteredAt: string | null;
|
||||||
|
subscription: boolean;
|
||||||
|
validations: AppliedValidationView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All programs + bound users (the /setup/site panel). site:read. */
|
||||||
|
export function fetchValidationPrograms(): Promise<{ programs: ValidationProgramView[] }> {
|
||||||
|
return apiFetch("/api/validation/programs");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert a program's config + binding set (site:update; signs a config_change). */
|
||||||
|
export function saveValidationProgram(
|
||||||
|
id: string,
|
||||||
|
body: Omit<ValidationProgramView, "id">,
|
||||||
|
): Promise<ValidationProgramView> {
|
||||||
|
return apiFetch(`/api/validation/programs/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MY bound, active programs (the merchant screen). validation:create. */
|
||||||
|
export function fetchMyValidationPrograms(): Promise<{ programs: Omit<ValidationProgramView, "userIds">[] }> {
|
||||||
|
return apiFetch("/api/validation/mine");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merchant lookup of a scanned ticket (no money data). validation:create. */
|
||||||
|
export function fetchValidationSession(identity: string): Promise<ValidationSessionView> {
|
||||||
|
return apiFetch(`/api/validation/session/${encodeURIComponent(identity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply my program to a ticket (signed, attributed). `amountMinor` only for fixed mode. */
|
||||||
|
export function applyValidation(body: {
|
||||||
|
identity: string;
|
||||||
|
programId: string;
|
||||||
|
amountMinor?: number;
|
||||||
|
}): Promise<{ ok: true; eventId: string; label: string }> {
|
||||||
|
return apiFetch("/api/validation/apply", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Void my own UNUSED validation (append-only correction). */
|
||||||
|
export function voidValidation(body: { eventId: string; identity: string }): Promise<{ ok: true }> {
|
||||||
|
return apiFetch("/api/validation/void", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Desktop-only: the operator-configured backend origin (host:port of the
|
||||||
|
// Fastify server this install talks to), persisted across restarts.
|
||||||
|
//
|
||||||
|
// The desktop shell is a generic .deb/.AppImage — it is NOT built for one
|
||||||
|
// specific booth, so the backend address can't be baked in at build time
|
||||||
|
// (that was the old VITE_API_BASE approach; a rebuild was needed to point the
|
||||||
|
// same installer at a different appliance). Instead the operator enters it
|
||||||
|
// once in the ConnectScreen (shown before login whenever nothing usable is
|
||||||
|
// stored yet) and it's saved to a JSON file in the OS config dir via
|
||||||
|
// tauri-plugin-store, read back on every launch before any API call.
|
||||||
|
//
|
||||||
|
// Browser build: this module is never reached (inTauri() gates every call
|
||||||
|
// site — see origin.ts), so there is no browser equivalent or fallback here.
|
||||||
|
|
||||||
|
import type { Store } from "@tauri-apps/plugin-store";
|
||||||
|
|
||||||
|
const STORE_FILE = "backend-config.json";
|
||||||
|
const KEY = "backendUrl";
|
||||||
|
|
||||||
|
let storeHandle: Store | null = null;
|
||||||
|
async function getStore(): Promise<Store> {
|
||||||
|
if (!storeHandle) {
|
||||||
|
const { load } = await import("@tauri-apps/plugin-store");
|
||||||
|
storeHandle = await load(STORE_FILE, { autoSave: true });
|
||||||
|
}
|
||||||
|
return storeHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The saved backend origin (no trailing slash), or null if never configured.
|
||||||
|
* Desktop only — throws if called from a browser build. */
|
||||||
|
export async function loadBackendUrl(): Promise<string | null> {
|
||||||
|
const store = await getStore();
|
||||||
|
const v = await store.get<string>(KEY);
|
||||||
|
return typeof v === "string" && v.length > 0 ? v.replace(/\/$/, "") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a new backend origin (validated + reachable — call testBackendUrl
|
||||||
|
* first). Takes effect immediately for future platformFetch/wsUrl calls. */
|
||||||
|
export async function saveBackendUrl(url: string): Promise<void> {
|
||||||
|
const store = await getStore();
|
||||||
|
await store.set(KEY, url.replace(/\/$/, ""));
|
||||||
|
await store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the saved backend (forces the ConnectScreen back up next launch). */
|
||||||
|
export async function clearBackendUrl(): Promise<void> {
|
||||||
|
const store = await getStore();
|
||||||
|
await store.delete(KEY);
|
||||||
|
await store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendCheck {
|
||||||
|
ok: boolean;
|
||||||
|
/** "unreachable" (network/DNS/refused) | "bad_response" (reachable, not our API). */
|
||||||
|
reason?: "unreachable" | "bad_response";
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe a candidate origin via GET /health — the server's one unauthenticated
|
||||||
|
* route (server.ts), which answers `{status:"ok", app:"parking-system"}`. We
|
||||||
|
* require BOTH a 2xx and that `app` value: the previous probe hit an
|
||||||
|
* auth-guarded route and accepted 401/403 as "ours", which any password-
|
||||||
|
* protected service on the LAN would also have passed. Uses the same
|
||||||
|
* tauri-plugin-http path platformFetch does (raw fetch from the webview can't
|
||||||
|
* reach an arbitrary LAN host — mixed content, see origin.ts). */
|
||||||
|
export async function testBackendUrl(url: string): Promise<BackendCheck> {
|
||||||
|
const origin = url.replace(/\/$/, "");
|
||||||
|
try {
|
||||||
|
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
||||||
|
const res = await tauriFetch(`${origin}/health`, {
|
||||||
|
method: "GET",
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
const body = (await res.json().catch(() => null)) as { app?: unknown } | null;
|
||||||
|
if (body?.app !== "parking-system") {
|
||||||
|
return { ok: false, reason: "bad_response", detail: "unexpected /health body" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "unreachable",
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Desktop-only in-memory CSRF token stash.
|
||||||
|
//
|
||||||
|
// tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its OWN
|
||||||
|
// cookie jar separate from the webview — document.cookie on tauri://localhost
|
||||||
|
// never sees the parking_csrf cookie the server sets (open upstream bug,
|
||||||
|
// tauri-apps/tauri#13045). The cookie IS still sent to the server by reqwest;
|
||||||
|
// only the client-side READ is broken. So the server echoes the same value in
|
||||||
|
// the login / me response body (sessionView's csrfToken, routes/auth.ts) and
|
||||||
|
// the desktop client keeps it here, echoing THIS in X-CSRF-Token instead of
|
||||||
|
// reading document.cookie.
|
||||||
|
//
|
||||||
|
// One module, no imports, so BOTH echo sites can share it without a cycle:
|
||||||
|
// api.ts (sets it, uses it for apiFetch mutations) and logger.ts (uses it for
|
||||||
|
// the /api/logs flush — which api.ts imports, so it can't import api.ts back).
|
||||||
|
// Never persisted: a fresh launch re-learns it via login or /api/auth/me.
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
|
||||||
|
export function setDesktopCsrfToken(value: string | null): void {
|
||||||
|
token = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopCsrfToken(): string | null {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
@@ -9,11 +9,18 @@
|
|||||||
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
|
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
|
||||||
// network — the appliance is usually offline) is swallowed; updates only happen
|
// network — the appliance is usually offline) is swallowed; updates only happen
|
||||||
// when someone has brought the box online (e.g. a phone hotspot) on purpose.
|
// when someone has brought the box online (e.g. a phone hotspot) on purpose.
|
||||||
|
//
|
||||||
|
// A release build's console.error is invisible with no way to attach devtools
|
||||||
|
// in the field (kiosk mode blocks the context menu; this WebKitGTK build's
|
||||||
|
// remote inspector doesn't answer standard discovery endpoints either — both
|
||||||
|
// confirmed dead ends 2026-09-03). logClient() ships straight to the
|
||||||
|
// server-side app_logs store regardless of the client's console-forward log
|
||||||
|
// level (that gate is meant for noisy console chatter, not this), so a real
|
||||||
|
// post-accept install failure is visible via wiki/concepts/app-logs.md /
|
||||||
|
// LogsViewer.tsx without needing a terminal or devtools at all.
|
||||||
|
|
||||||
/** True when running inside the Tauri webview (not a normal browser). */
|
import { logClient } from "./logger.js";
|
||||||
function inTauri(): boolean {
|
import { inTauri } from "./tauri-env.js";
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePrompt {
|
export interface UpdatePrompt {
|
||||||
/** Newer version string offered by the server. */
|
/** Newer version string offered by the server. */
|
||||||
@@ -41,11 +48,39 @@ export async function checkForDesktopUpdate(
|
|||||||
|
|
||||||
// Download + install the signed update (signature verified against the
|
// Download + install the signed update (signature verified against the
|
||||||
// pubkey in tauri.conf.json), then relaunch into the new version.
|
// pubkey in tauri.conf.json), then relaunch into the new version.
|
||||||
await update.downloadAndInstall();
|
try {
|
||||||
|
await update.downloadAndInstall((progress) => {
|
||||||
|
logClient({
|
||||||
|
level: "info",
|
||||||
|
message: `desktop update download progress: ${progress.event}`,
|
||||||
|
context: { kind: "desktop_update_progress", version: update.version, event: progress.event },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// A real update WAS found and accepted — this is a genuine install
|
||||||
|
// failure (bad signature, corrupted download, disk/permission issue),
|
||||||
|
// not "offline". Surface it instead of silently reverting to the old
|
||||||
|
// version with no explanation.
|
||||||
|
logClient({
|
||||||
|
level: "error",
|
||||||
|
message: `desktop update download/install failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
|
context: { kind: "desktop_update_install_failed", version: update.version },
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||||
await relaunch();
|
await relaunch();
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
||||||
// keeps running on the current version; checking again next launch.
|
// keeps running on the current version; checking again next launch. Still
|
||||||
|
// log it (info, not error — this path is expected/normal far more often
|
||||||
|
// than it's a real problem) so a real install failure (rethrown above,
|
||||||
|
// logged as error) isn't lost among routine offline checks.
|
||||||
|
logClient({
|
||||||
|
level: "info",
|
||||||
|
message: `desktop update check/apply skipped: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
context: { kind: "desktop_update_skipped" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js";
|
import { formatMoney, formatDuration, formatRelativeDateTime, type TFn } from "./format.js";
|
||||||
|
|
||||||
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
||||||
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
||||||
@@ -35,16 +35,6 @@ describe("formatDuration", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatTime", () => {
|
|
||||||
it("returns an em dash for null/invalid", () => {
|
|
||||||
expect(formatTime(null)).toBe("—");
|
|
||||||
expect(formatTime("not-a-date")).toBe("—");
|
|
||||||
});
|
|
||||||
it("renders HH:MM:SS local time", () => {
|
|
||||||
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatRelativeDateTime", () => {
|
describe("formatRelativeDateTime", () => {
|
||||||
// A tiny fake t(): today/yesterday words + the month-name array.
|
// A tiny fake t(): today/yesterday words + the month-name array.
|
||||||
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
||||||
@@ -61,6 +51,12 @@ describe("formatRelativeDateTime", () => {
|
|||||||
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("appends :ss with the seconds option (entry/exit rows read alike)", () => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(19, 25, 44, 0);
|
||||||
|
expect(formatRelativeDateTime(now.toISOString(), t, { seconds: true })).toMatch(/^Sot \d{2}:\d{2}:44$/);
|
||||||
|
});
|
||||||
|
|
||||||
it("labels yesterday with the localized word", () => {
|
it("labels yesterday with the localized word", () => {
|
||||||
const y = new Date();
|
const y = new Date();
|
||||||
y.setDate(y.getDate() - 1);
|
y.setDate(y.getDate() - 1);
|
||||||
|
|||||||
+13
-16
@@ -46,13 +46,6 @@ export function formatMinutes(mins: number): string {
|
|||||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local time-of-day HH:MM:SS from an ISO string. */
|
|
||||||
export function formatTime(iso: string | null): string {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
||||||
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
||||||
function dayDiff(d: Date, ref: Date): number {
|
function dayDiff(d: Date, ref: Date): number {
|
||||||
@@ -61,10 +54,11 @@ function dayDiff(d: Date, ref: Date): number {
|
|||||||
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HH:MM (local, 24h) for the relative-day labels. */
|
/** HH:MM (local, 24h) for the relative-day labels; ":ss" appended when `seconds`. */
|
||||||
function hhmm(d: Date): string {
|
function hhmm(d: Date, seconds = false): string {
|
||||||
const p = (n: number) => String(n).padStart(2, "0");
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
const base = `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
return seconds ? `${base}:${p(d.getSeconds())}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
||||||
@@ -118,8 +112,7 @@ export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: bo
|
|||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
const sec = opts?.seconds ? `:${String(d.getSeconds()).padStart(2, "0")}` : "";
|
return `${formatDate(iso, t)} ${hhmm(d, opts?.seconds)}`;
|
||||||
return `${formatDate(iso, t)} ${hhmm(d)}${sec}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,14 +123,18 @@ export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: bo
|
|||||||
*
|
*
|
||||||
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
||||||
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
|
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
|
||||||
|
*
|
||||||
|
* `seconds` appends ":ss" — use it where a timestamp sits next to another that shows
|
||||||
|
* seconds (e.g. the booth pay modal's entry vs. exit rows), so the two read alike.
|
||||||
*/
|
*/
|
||||||
export function formatRelativeDateTime(iso: string | null, t: TFn): string {
|
export function formatRelativeDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
const time = hhmm(d, opts?.seconds);
|
||||||
const diff = dayDiff(d, new Date());
|
const diff = dayDiff(d, new Date());
|
||||||
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
if (diff === 0) return `${t("common.today")} ${time}`;
|
||||||
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
if (diff === 1) return `${t("common.yesterday")} ${time}`;
|
||||||
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
|
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
|
||||||
return `${formatDate(iso, t)} ${hhmm(d)}`;
|
return `${formatDate(iso, t)} ${time}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const en: Catalog = {
|
|||||||
signIn: "Sign in",
|
signIn: "Sign in",
|
||||||
signingIn: "Signing in…",
|
signingIn: "Signing in…",
|
||||||
},
|
},
|
||||||
|
connect: {
|
||||||
|
title: "Connect to server",
|
||||||
|
hint: "Enter the address of the parking system server for this booth.",
|
||||||
|
serverAddress: "Server address",
|
||||||
|
test: "Test",
|
||||||
|
testing: "Testing…",
|
||||||
|
save: "Save & continue",
|
||||||
|
saving: "Saving…",
|
||||||
|
testOk: "Reachable — this looks like a Parking System server.",
|
||||||
|
testUnreachable: "Could not reach this address.",
|
||||||
|
testBadResponse: "Reachable, but this doesn't look like a Parking System server.",
|
||||||
|
changeServer: "Change server",
|
||||||
|
changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?",
|
||||||
|
},
|
||||||
update: {
|
update: {
|
||||||
available: "Update available",
|
available: "Update available",
|
||||||
prompt: "Version {{version}} is available. Install now and restart?",
|
prompt: "Version {{version}} is available. Install now and restart?",
|
||||||
@@ -65,6 +79,7 @@ export const en: Catalog = {
|
|||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
backup: "Backup",
|
backup: "Backup",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
|
validate: "Validations",
|
||||||
},
|
},
|
||||||
drawer: {
|
drawer: {
|
||||||
stateTitle: "Drawer now",
|
stateTitle: "Drawer now",
|
||||||
@@ -230,6 +245,7 @@ export const en: Catalog = {
|
|||||||
evtCashOut: "PAY-OUT",
|
evtCashOut: "PAY-OUT",
|
||||||
evtCashReview: "REVIEW",
|
evtCashReview: "REVIEW",
|
||||||
evtConfigChange: "CONFIG",
|
evtConfigChange: "CONFIG",
|
||||||
|
evtValidation: "VALIDATION",
|
||||||
decision: { authorize: "authorized", deny: "denied" },
|
decision: { authorize: "authorized", deny: "denied" },
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
evtRefused: "REFUSED",
|
evtRefused: "REFUSED",
|
||||||
@@ -735,6 +751,51 @@ export const en: Catalog = {
|
|||||||
fieldPhone: "Phone",
|
fieldPhone: "Phone",
|
||||||
fieldEmail: "Email",
|
fieldEmail: "Email",
|
||||||
},
|
},
|
||||||
|
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
|
||||||
|
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
|
||||||
|
val: {
|
||||||
|
// /setup/site
|
||||||
|
sectionTitle: "Merchant validations",
|
||||||
|
sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
|
||||||
|
enableBar: "Bar",
|
||||||
|
enableLavazh: "Car wash",
|
||||||
|
labelName: "Receipt label",
|
||||||
|
labelNamePh: "e.g. Car wash — first hour free",
|
||||||
|
mode: "Discount type",
|
||||||
|
modeComp: "Parking fully free",
|
||||||
|
modeTimeCredit: "First minutes free",
|
||||||
|
modeFixed: "Amount off (typed at scan)",
|
||||||
|
modePercent: "Percent off",
|
||||||
|
minutes: "Free minutes",
|
||||||
|
percent: "Percent (%)",
|
||||||
|
maxAmount: "Cap per validation",
|
||||||
|
maxPerDay: "Max validations per day (blank = unlimited)",
|
||||||
|
users: "Validating users",
|
||||||
|
usersHint: "Only the selected users (whose role grants validation:create) can apply this program from their device.",
|
||||||
|
noUsers: "No users in the system — create one under Users.",
|
||||||
|
saved: "Saved.",
|
||||||
|
// /validate (the merchant screen)
|
||||||
|
title: "Ticket validation",
|
||||||
|
scanPrompt: "Scan or type the ticket number",
|
||||||
|
lookup: "Look up",
|
||||||
|
entry: "Entry:",
|
||||||
|
notFound: "No ticket found with this number.",
|
||||||
|
closed: "The ticket is closed (exited or voided).",
|
||||||
|
subscription: "This is a subscriber entry — not validatable.",
|
||||||
|
amountLabel: "Discount amount",
|
||||||
|
amountHint: "max {{max}}",
|
||||||
|
apply: "Apply validation",
|
||||||
|
applied: "Validation applied.",
|
||||||
|
existing: "Validations on this ticket",
|
||||||
|
voided: "voided",
|
||||||
|
used: "used in a payment",
|
||||||
|
void: "Void",
|
||||||
|
confirmVoid: "Void this validation?",
|
||||||
|
noPrograms: "You have no validation program bound to you — contact the administrator.",
|
||||||
|
// booth pay modal / receipts
|
||||||
|
gross: "Fee",
|
||||||
|
discount: "Discount",
|
||||||
|
},
|
||||||
users: {
|
users: {
|
||||||
title: "Users",
|
title: "Users",
|
||||||
add: "+ Add user",
|
add: "+ Add user",
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ export const sq = {
|
|||||||
signIn: "Hyr",
|
signIn: "Hyr",
|
||||||
signingIn: "Duke hyrë…",
|
signingIn: "Duke hyrë…",
|
||||||
},
|
},
|
||||||
|
connect: {
|
||||||
|
title: "Lidhu me serverin",
|
||||||
|
hint: "Vendos adresën e serverit të sistemit të parkimit për këtë kabinë.",
|
||||||
|
serverAddress: "Adresa e serverit",
|
||||||
|
test: "Testo",
|
||||||
|
testing: "Duke testuar…",
|
||||||
|
save: "Ruaj & vazhdo",
|
||||||
|
saving: "Duke ruajtur…",
|
||||||
|
testOk: "I arritshëm — duket si server i Sistemit të Parkimit.",
|
||||||
|
testUnreachable: "Nuk u arrit kjo adresë.",
|
||||||
|
testBadResponse: "I arritshëm, por nuk duket si server i Sistemit të Parkimit.",
|
||||||
|
changeServer: "Ndrysho serverin",
|
||||||
|
changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?",
|
||||||
|
},
|
||||||
update: {
|
update: {
|
||||||
available: "Përditësim i disponueshëm",
|
available: "Përditësim i disponueshëm",
|
||||||
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
|
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
|
||||||
@@ -68,6 +82,7 @@ export const sq = {
|
|||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
backup: "Kopje rezervë",
|
backup: "Kopje rezervë",
|
||||||
profile: "Profili",
|
profile: "Profili",
|
||||||
|
validate: "Validime",
|
||||||
},
|
},
|
||||||
drawer: {
|
drawer: {
|
||||||
stateTitle: "Arka tani",
|
stateTitle: "Arka tani",
|
||||||
@@ -235,6 +250,7 @@ export const sq = {
|
|||||||
evtCashOut: "PAGESË",
|
evtCashOut: "PAGESË",
|
||||||
evtCashReview: "SHQYRTIM",
|
evtCashReview: "SHQYRTIM",
|
||||||
evtConfigChange: "KONFIG",
|
evtConfigChange: "KONFIG",
|
||||||
|
evtValidation: "VALIDIM",
|
||||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
evtRefused: "REFUZUAR",
|
evtRefused: "REFUZUAR",
|
||||||
@@ -748,6 +764,51 @@ export const sq = {
|
|||||||
fieldPhone: "Telefoni",
|
fieldPhone: "Telefoni",
|
||||||
fieldEmail: "Email",
|
fieldEmail: "Email",
|
||||||
},
|
},
|
||||||
|
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
|
||||||
|
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
|
||||||
|
val: {
|
||||||
|
// /setup/site
|
||||||
|
sectionTitle: "Validime tregtare",
|
||||||
|
sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.",
|
||||||
|
enableBar: "Bar",
|
||||||
|
enableLavazh: "Lavazh",
|
||||||
|
labelName: "Etiketa në faturë",
|
||||||
|
labelNamePh: "p.sh. Lavazh — 1 orë falas",
|
||||||
|
mode: "Lloji i zbritjes",
|
||||||
|
modeComp: "Parkimi falas plotësisht",
|
||||||
|
modeTimeCredit: "Minutat e para falas",
|
||||||
|
modeFixed: "Zbritje shume (shkruhet në skanim)",
|
||||||
|
modePercent: "Zbritje në përqindje",
|
||||||
|
minutes: "Minuta falas",
|
||||||
|
percent: "Përqindja (%)",
|
||||||
|
maxAmount: "Tavani i zbritjes për validim",
|
||||||
|
maxPerDay: "Maks. validime në ditë (bosh = pa kufi)",
|
||||||
|
users: "Përdoruesit që validojnë",
|
||||||
|
usersHint: "Vetëm përdoruesit e zgjedhur (me lejen validation:create në rolin e tyre) mund të aplikojnë këtë program nga pajisja e tyre.",
|
||||||
|
noUsers: "Asnjë përdorues në sistem — krijojeni te Përdoruesit.",
|
||||||
|
saved: "U ruajt.",
|
||||||
|
// /validate (the merchant screen)
|
||||||
|
title: "Validim biletash",
|
||||||
|
scanPrompt: "Skanoni ose shkruani numrin e biletës",
|
||||||
|
lookup: "Kërko",
|
||||||
|
entry: "Hyrja:",
|
||||||
|
notFound: "Nuk u gjet biletë me këtë numër.",
|
||||||
|
closed: "Bileta është e mbyllur (ka dalë ose është anuluar).",
|
||||||
|
subscription: "Kjo është hyrje abonenti — nuk validohet.",
|
||||||
|
amountLabel: "Shuma e zbritjes",
|
||||||
|
amountHint: "maks. {{max}}",
|
||||||
|
apply: "Apliko validimin",
|
||||||
|
applied: "Validimi u aplikua.",
|
||||||
|
existing: "Validime në këtë biletë",
|
||||||
|
voided: "anuluar",
|
||||||
|
used: "përdorur në pagesë",
|
||||||
|
void: "Anulo",
|
||||||
|
confirmVoid: "Të anulohet ky validim?",
|
||||||
|
noPrograms: "Nuk keni asnjë program validimi të lidhur me ju — kontaktoni administratorin.",
|
||||||
|
// booth pay modal / receipts
|
||||||
|
gross: "Tarifa",
|
||||||
|
discount: "Zbritje",
|
||||||
|
},
|
||||||
users: {
|
users: {
|
||||||
title: "Përdoruesit",
|
title: "Përdoruesit",
|
||||||
add: "+ Shto përdorues",
|
add: "+ Shto përdorues",
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
// high-signal sources (failed requests, uncaught errors) are always captured.
|
// high-signal sources (failed requests, uncaught errors) are always captured.
|
||||||
|
|
||||||
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
||||||
|
import { getDesktopCsrfToken } from "./desktop-csrf.js";
|
||||||
|
import { apiUrl, platformFetch } from "./origin.js";
|
||||||
|
import { inTauri } from "./tauri-env.js";
|
||||||
|
|
||||||
const ENDPOINT = "/api/logs";
|
const ENDPOINT = "/api/logs";
|
||||||
const FLUSH_MS = 4000;
|
const FLUSH_MS = 4000;
|
||||||
@@ -74,9 +77,13 @@ async function flush(): Promise<void> {
|
|||||||
flushing = true;
|
flushing = true;
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
const csrf = readCookie(CSRF_COOKIE);
|
// /api/logs is behind requireAuth → assertCsrf on POST. On desktop the
|
||||||
|
// cookie is unreadable (see desktop-csrf.ts) — without this branch every
|
||||||
|
// desktop flush 403'd and was dropped here, silently, by design (found
|
||||||
|
// 2026-09-04: no desktop client log had EVER reached app_logs).
|
||||||
|
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
|
||||||
if (csrf) headers[CSRF_HEADER] = csrf;
|
if (csrf) headers[CSRF_HEADER] = csrf;
|
||||||
await fetch(ENDPOINT, {
|
await platformFetch(apiUrl(ENDPOINT), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -90,7 +97,10 @@ async function flush(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
|
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). Browser
|
||||||
|
* only — sendBeacon is a native browser API with no Tauri-HTTP-plugin equivalent,
|
||||||
|
* so this drops silently in the desktop shell (unload is rare there; the regular
|
||||||
|
* 4s-interval flush above covers the common case). */
|
||||||
function flushBeacon(): void {
|
function flushBeacon(): void {
|
||||||
if (queue.length === 0) return;
|
if (queue.length === 0) return;
|
||||||
const entries = queue.splice(0, queue.length);
|
const entries = queue.splice(0, queue.length);
|
||||||
@@ -99,7 +109,7 @@ function flushBeacon(): void {
|
|||||||
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
||||||
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
||||||
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
||||||
navigator.sendBeacon(ENDPOINT, blob);
|
navigator.sendBeacon(apiUrl(ENDPOINT), blob);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,49 @@
|
|||||||
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
|
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
|
||||||
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
|
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
|
||||||
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
|
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
|
||||||
// SPA from `tauri://localhost`, which has no backend and no proxy; there we set
|
// SPA from `tauri://localhost`, which has no backend and no proxy; there the
|
||||||
// VITE_API_BASE to the appliance's Fastify origin (e.g. http://127.0.0.1:3000)
|
// operator enters the appliance's Fastify origin (e.g. http://192.168.1.50:3000)
|
||||||
// at build time so /api and the live WS feed resolve to the real server.
|
// once in the ConnectScreen and it's persisted via tauri-plugin-store (see
|
||||||
|
// backend-config.ts) — a RUNTIME value, not a build-time one, since the same
|
||||||
|
// installer is used across every booth and the backend can move (new box, new
|
||||||
|
// IP) without a rebuild. main.tsx calls initApiBase() before the app mounts.
|
||||||
//
|
//
|
||||||
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
|
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
|
||||||
// WebSocket both read it, so the web app and the desktop shell stay identical
|
// WebSocket both read it, so the web app and the desktop shell stay identical
|
||||||
// except for this one build-time value.
|
// except for this one runtime value.
|
||||||
|
//
|
||||||
|
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
|
||||||
|
// plain http://192.168.1.50:3000 fetch() from inside it is blocked as mixed
|
||||||
|
// content (a WebKit limitation — CSP's connect-src does NOT override this;
|
||||||
|
// found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we
|
||||||
|
// dynamically import @tauri-apps/plugin-http's fetch, which routes the request
|
||||||
|
// through Tauri's native side instead of the webview's own fetch, sidestepping
|
||||||
|
// the check entirely. Browser build never imports the plugin (dynamic import,
|
||||||
|
// same pattern as desktop-updater.ts).
|
||||||
|
|
||||||
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */
|
import { inTauri } from "./tauri-env.js";
|
||||||
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
|
|
||||||
|
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative
|
||||||
|
* (browser) or not-yet-configured (desktop, before the ConnectScreen runs). */
|
||||||
|
export let API_BASE: string = "";
|
||||||
|
|
||||||
|
/** Desktop only: load the persisted backend URL (if any) before the app
|
||||||
|
* mounts, so the very first fetchMe() call already has the right origin.
|
||||||
|
* No-op in the browser. Returns the loaded value (null = not configured yet,
|
||||||
|
* meaning main.tsx should show the ConnectScreen instead of the normal app). */
|
||||||
|
export async function initApiBase(): Promise<string | null> {
|
||||||
|
if (!inTauri()) return null;
|
||||||
|
const { loadBackendUrl } = await import("./backend-config.js");
|
||||||
|
const saved = await loadBackendUrl();
|
||||||
|
if (saved) API_BASE = saved;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop only: change the backend origin at runtime (after the operator
|
||||||
|
* saves a new one in Settings) without requiring a full app restart. */
|
||||||
|
export function setApiBase(url: string): void {
|
||||||
|
API_BASE = url.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
|
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
|
||||||
export function apiUrl(path: string): string {
|
export function apiUrl(path: string): string {
|
||||||
@@ -28,3 +61,19 @@ export function wsUrl(path: string): string {
|
|||||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
return `${proto}//${window.location.host}${path}`;
|
return `${proto}//${window.location.host}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { inTauri };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
|
||||||
|
* shell (see the file header for why the webview's own fetch can't reach
|
||||||
|
* the local backend). Same signature as the global fetch; a plain pass-
|
||||||
|
* through in the browser.
|
||||||
|
*/
|
||||||
|
export async function platformFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||||
|
if (inTauri()) {
|
||||||
|
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
||||||
|
return tauriFetch(input, init);
|
||||||
|
}
|
||||||
|
return fetch(input, init);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Desktop-only WebSocket adapter.
|
||||||
|
//
|
||||||
|
// WebKitGTK treats tauri://localhost as a SECURE origin, so a plain
|
||||||
|
// ws://127.0.0.1:3000 connection from inside it is blocked as mixed content —
|
||||||
|
// same root cause as the HTTP fetch() issue (see origin.ts's platformFetch),
|
||||||
|
// but WS is a separate browser check with its own plugin
|
||||||
|
// (@tauri-apps/plugin-websocket), which routes the connection through Tauri's
|
||||||
|
// native side instead of the webview's own WebSocket.
|
||||||
|
//
|
||||||
|
// That plugin's API is async/listener-based, not the synchronous
|
||||||
|
// onopen/onmessage/onclose event surface use-live-feed.ts is written against
|
||||||
|
// (and has already been hardened for — reconnect backoff, StrictMode
|
||||||
|
// double-invoke, cleanup). Rather than rewrite that hook around a different
|
||||||
|
// API shape, this adapter presents the same native-WebSocket-like interface
|
||||||
|
// use-live-feed.ts already expects, so that hook needs no changes at all.
|
||||||
|
//
|
||||||
|
// Browser build: plain pass-through to the real WebSocket (this file's
|
||||||
|
// createPlatformSocket is only called from inside inTauri() callers).
|
||||||
|
|
||||||
|
import { fetchWsTicket } from "../api.js";
|
||||||
|
import { logClient } from "./logger.js";
|
||||||
|
import { inTauri } from "./tauri-env.js";
|
||||||
|
|
||||||
|
/** Rate-limit the "connect failed" log: use-live-feed reconnects every ≤10s
|
||||||
|
* forever, and each attempt is a fresh adapter, so without this an outage
|
||||||
|
* would write six near-identical app_logs rows a minute. */
|
||||||
|
const CONNECT_FAIL_LOG_INTERVAL_MS = 60_000;
|
||||||
|
let lastConnectFailLogAt = 0;
|
||||||
|
|
||||||
|
export interface PlatformSocket {
|
||||||
|
onopen: (() => void) | null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null;
|
||||||
|
onclose: (() => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NativeSocketAdapter implements PlatformSocket {
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
#sock: WebSocket;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.#sock = new WebSocket(url);
|
||||||
|
this.#sock.onopen = () => this.onopen?.();
|
||||||
|
this.#sock.onmessage = (ev) => this.onmessage?.({ data: ev.data as string });
|
||||||
|
this.#sock.onclose = () => this.onclose?.();
|
||||||
|
this.#sock.onerror = () => this.onerror?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.#sock.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TauriSocketAdapter implements PlatformSocket {
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
#closed = false;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
#conn: any = null;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
void this.#connect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #connect(url: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket");
|
||||||
|
if (this.#closed) return; // close() called before connect resolved
|
||||||
|
// The native WS plugin is a bare tungstenite client: no page context AND
|
||||||
|
// no cookie jar. Two consequences, both handled via explicit headers:
|
||||||
|
// - Origin: nothing auto-attaches `Origin: tauri://localhost` the way a
|
||||||
|
// browser WebSocket would, and routes/ws.ts's anti-CSWSH check rejects a
|
||||||
|
// missing/mismatched Origin — so set it to match WS_ALLOWED_ORIGINS.
|
||||||
|
// - Session: the HttpOnly JWT cookie lives in tauri-plugin-http's reqwest
|
||||||
|
// jar and can't ride on this handshake, so jwtVerify() would 401 every
|
||||||
|
// connect (the 2026-09-04 "reconnects every 10s forever" bug). Instead,
|
||||||
|
// mint a single-use ticket over normal HTTP auth and present it in the
|
||||||
|
// x-ws-ticket header (see routes/ws.ts).
|
||||||
|
const ticket = await fetchWsTicket();
|
||||||
|
if (this.#closed) return;
|
||||||
|
const conn = await TauriWebSocket.connect(url, {
|
||||||
|
headers: { Origin: "tauri://localhost", "x-ws-ticket": ticket },
|
||||||
|
});
|
||||||
|
if (this.#closed) {
|
||||||
|
void conn.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#conn = conn;
|
||||||
|
conn.addListener((msg: { type: string; data: unknown }) => {
|
||||||
|
if (msg.type === "Text") {
|
||||||
|
this.onmessage?.({ data: msg.data as string });
|
||||||
|
} else if (msg.type === "Close") {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
// Binary/Ping/Pong: the server protocol here is text-JSON only (see
|
||||||
|
// routes/ws.ts) — nothing else is expected.
|
||||||
|
});
|
||||||
|
this.onopen?.();
|
||||||
|
} catch (err) {
|
||||||
|
// logClient, not console.error: console output only reaches app_logs at
|
||||||
|
// debug/trace level, which is how the ticket-less 401 stayed invisible
|
||||||
|
// for a full day. A closed-before-connect race isn't a failure.
|
||||||
|
if (!this.#closed && Date.now() - lastConnectFailLogAt > CONNECT_FAIL_LOG_INTERVAL_MS) {
|
||||||
|
lastConnectFailLogAt = Date.now();
|
||||||
|
logClient({
|
||||||
|
level: "error",
|
||||||
|
message: `desktop live-feed connect failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
context: { kind: "desktop_ws_connect_failed", url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.onerror?.();
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.#closed = true;
|
||||||
|
void this.#conn?.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the
|
||||||
|
* desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
|
||||||
|
export function createPlatformSocket(url: string): PlatformSocket {
|
||||||
|
return inTauri() ? new TauriSocketAdapter(url) : new NativeSocketAdapter(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** True when running inside the Tauri webview (not a normal browser). Single
|
||||||
|
* source for this check — origin.ts, platform-ws.ts, desktop-updater.ts, and
|
||||||
|
* backend-config.ts all gate their Tauri-only code paths on it. */
|
||||||
|
export function inTauri(): boolean {
|
||||||
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
|||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||||
import { wsUrl } from "./origin.js";
|
import { wsUrl } from "./origin.js";
|
||||||
|
import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js";
|
||||||
|
|
||||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||||
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
||||||
@@ -23,23 +24,34 @@ type WsMessage =
|
|||||||
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
||||||
|
|
||||||
|
|
||||||
export function useLiveFeed(): void {
|
/**
|
||||||
|
* @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's
|
||||||
|
* WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator
|
||||||
|
* with only `validation:create`) must not attempt the socket at all: the server
|
||||||
|
* 403s the upgrade and the capped-backoff reconnect would otherwise hammer it
|
||||||
|
* forever, filling the server log with a 403 every few seconds.
|
||||||
|
*/
|
||||||
|
export function useLiveFeed(enabled: boolean = true): void {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||||
useLiveStore();
|
useLiveStore();
|
||||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<PlatformSocket | null>(null);
|
||||||
const retryRef = useRef(0);
|
const retryRef = useRef(0);
|
||||||
const closedRef = useRef(false);
|
const closedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setStatus("closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
closedRef.current = false;
|
closedRef.current = false;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (closedRef.current) return;
|
if (closedRef.current) return;
|
||||||
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
||||||
const sock = new WebSocket(wsUrl("/api/ws"));
|
const sock = createPlatformSocket(wsUrl("/api/ws"));
|
||||||
sockRef.current = sock;
|
sockRef.current = sock;
|
||||||
|
|
||||||
sock.onopen = () => {
|
sock.onopen = () => {
|
||||||
@@ -117,7 +129,8 @@ export function useLiveFeed(): void {
|
|||||||
sockRef.current?.close();
|
sockRef.current?.close();
|
||||||
sockRef.current = null;
|
sockRef.current = null;
|
||||||
};
|
};
|
||||||
// qc / store setters are stable; run once on mount.
|
// qc / store setters are stable; re-run only if the permission gate flips
|
||||||
|
// (login as a different role without a full reload).
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [enabled]);
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-10
@@ -6,7 +6,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
redirect,
|
redirect,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
can,
|
can,
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
|
fetchVersion,
|
||||||
logout,
|
logout,
|
||||||
openShift,
|
openShift,
|
||||||
setLanguagePref,
|
setLanguagePref,
|
||||||
@@ -29,6 +30,7 @@ import { Spinner } from "./ui/Spinner.js";
|
|||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
|
import { inTauri } from "./lib/origin.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||||
import { StatusDot } from "./ui/StatusDot.js";
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
@@ -46,6 +48,7 @@ import { DrawerManager } from "./DrawerManager.js";
|
|||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
import { BackupSettings } from "./BackupSettings.js";
|
import { BackupSettings } from "./BackupSettings.js";
|
||||||
|
import { ValidateScreen } from "./ValidateScreen.js";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
import { Profile } from "./Profile.js";
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
@@ -93,6 +96,89 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The running deploy's "<branch>-<short-sha>" (matches the Komodo Stack's TAG in
|
||||||
|
* komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the
|
||||||
|
* same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a
|
||||||
|
* local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */
|
||||||
|
function VersionBadge() {
|
||||||
|
const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity });
|
||||||
|
const version = q.data?.buildVersion;
|
||||||
|
if (!version) return null;
|
||||||
|
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The installed Tauri app's own "vX.Y.Z" (from tauri.conf.json, synced to the git tag by
|
||||||
|
* release.yml — see wiki/decisions/desktop-shell-tauri.md) — the client's version, distinct
|
||||||
|
* from VersionBadge's SERVER build. No-op / renders nothing in a browser (there's no Tauri
|
||||||
|
* API to call). Was invisible before this: an operator had no way to tell which desktop
|
||||||
|
* build was actually installed short of reading the update-available prompt. */
|
||||||
|
function DesktopVersionBadge() {
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inTauri()) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void import("@tauri-apps/api/app").then(({ getVersion }) =>
|
||||||
|
getVersion().then((v) => {
|
||||||
|
if (!cancelled) setVersion(v);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
if (!version) return null;
|
||||||
|
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop-only "change which server this install talks to" control. No-op /
|
||||||
|
* renders nothing in a browser (the concept doesn't apply — same-origin).
|
||||||
|
* Simplest correct action: clear the saved backend URL and reload, which
|
||||||
|
* drops the app back to ConnectScreen (see App.tsx) to re-enter it — this
|
||||||
|
* mirrors clearing the session (logout → back to Login), not an inline
|
||||||
|
* editor, since repointing the app is a rare, deliberate admin action. */
|
||||||
|
function DesktopServerButton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!inTauri()) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm ml-2"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>
|
||||||
|
{t("connect.changeServer")}
|
||||||
|
</button>
|
||||||
|
{confirming && (
|
||||||
|
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
|
||||||
|
<div className="text-[0.8125rem]">
|
||||||
|
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
|
||||||
|
{t("subs.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
const { clearBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
await clearBackendUrl();
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? <Spinner /> : t("connect.changeServer")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||||
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
||||||
* deep links and the back button work and a denied tab redirects to the booth. */
|
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||||
@@ -111,6 +197,9 @@ function SetupLayout() {
|
|||||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||||
|
{show("site:read") && <VersionBadge />}
|
||||||
|
<DesktopVersionBadge />
|
||||||
|
<DesktopServerButton />
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -447,19 +536,28 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri
|
|||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { user, setUser } = rootRoute.useRouteContext();
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// One app-wide WebSocket for the live feed (booth + any live widget).
|
|
||||||
useLiveFeed();
|
|
||||||
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
|
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
|
||||||
// the permission its screen needs (the route guards enforce the same server-side).
|
// the permission its screen needs (the route guards enforce the same server-side).
|
||||||
const show = (perm: Permission) => can(user, perm);
|
const show = (perm: Permission) => can(user, perm);
|
||||||
|
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
||||||
|
// for roles the server would accept (routes/ws.ts gates on report:read). A
|
||||||
|
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
||||||
|
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
||||||
|
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
||||||
|
// DeviceFooter → device:read).
|
||||||
|
const canWatch = show("report:read");
|
||||||
|
useLiveFeed(canWatch);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||||
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
||||||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<NavLink to="/booth" label={t("nav.booth")} />
|
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
||||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
|
||||||
|
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
|
||||||
|
grants ONLY validation:create, so this is often their whole nav. */}
|
||||||
|
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
|
||||||
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||||
user can do either. See wiki/concepts/shift.md. */}
|
user can do either. See wiki/concepts/shift.md. */}
|
||||||
{(show("drawer:create") || show("drawer:review")) && (
|
{(show("drawer:create") || show("drawer:review")) && (
|
||||||
@@ -485,11 +583,11 @@ function RootLayout() {
|
|||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{user && <ShiftButton />}
|
{user && show("shift:read") && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
{canWatch && <StatusDot />}
|
||||||
{user && (
|
{user && (
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
@@ -514,8 +612,10 @@ function RootLayout() {
|
|||||||
<main className="min-h-0 flex-1 overflow-auto p-3">
|
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
|
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
|
||||||
{user && <DeviceFooter />}
|
seed needs device:read (and its live updates ride the report:read WS), so
|
||||||
|
it's hidden for roles without device visibility (e.g. merchant validators). */}
|
||||||
|
{user && show("device:read") && <DeviceFooter />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -523,7 +623,12 @@ function RootLayout() {
|
|||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/",
|
path: "/",
|
||||||
beforeLoad: () => {
|
beforeLoad: ({ context }) => {
|
||||||
|
// A merchant-only user (validation:create without the booth's session:read)
|
||||||
|
// lands on their scan-and-validate screen; everyone else on the booth.
|
||||||
|
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
|
||||||
|
throw redirect({ to: "/validate" });
|
||||||
|
}
|
||||||
throw redirect({ to: "/booth" });
|
throw redirect({ to: "/booth" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -534,6 +639,20 @@ const boothRoute = createRoute({
|
|||||||
component: BoothScreen,
|
component: BoothScreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
|
||||||
|
// merchant user's role can reach. The server enforces the program↔user binding on
|
||||||
|
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
|
||||||
|
const validateRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/validate",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
|
||||||
|
component: function ValidateRoute() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
if (!user) return null;
|
||||||
|
return <ValidateScreen user={user} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
||||||
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
||||||
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
||||||
@@ -779,6 +898,7 @@ const profileRoute = createRoute({
|
|||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
boothRoute,
|
boothRoute,
|
||||||
|
validateRoute,
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||||
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
||||||
|
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+38
-38
@@ -30,42 +30,6 @@
|
|||||||
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
# park-lab — the LAB bench box (hardware/dev testing, no real traffic). Chases
|
|
||||||
# the dev tier: compose files from `dev`, MOVING image tag `dev` (labs may
|
|
||||||
# float; real booths pin). Secrets are its own park_lab_* refs — per-box blast
|
|
||||||
# radius, never shared with a real booth even in the lab.
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
[[stack]]
|
|
||||||
name = "park-lab"
|
|
||||||
[stack.config]
|
|
||||||
server = "park-lab"
|
|
||||||
git_provider = "git.infra.msai.al"
|
|
||||||
git_account = "komodo"
|
|
||||||
repo = "mca/parking_solution"
|
|
||||||
branch = "dev"
|
|
||||||
file_paths = [
|
|
||||||
"docker-compose.yml",
|
|
||||||
"docker-compose.prod.yml"
|
|
||||||
]
|
|
||||||
registry_provider = "git.infra.msai.al"
|
|
||||||
registry_account = "komodo"
|
|
||||||
environment = """
|
|
||||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
|
||||||
# Lab tier: the MOVING dev tag — redeploy pulls the latest dev build. Pin to a
|
|
||||||
# dev-<sha> only when reproducing a specific state.
|
|
||||||
TAG=dev
|
|
||||||
COOKIE_SECURE=0
|
|
||||||
VISION_ENABLED=1
|
|
||||||
WS_ALLOWED_ORIGINS=
|
|
||||||
JWT_SECRET=[[park_lab_jwt_secret]]
|
|
||||||
EVENT_SIGNING_KEY=[[park_lab_event_signing_key]]
|
|
||||||
BACKUP_KEY=[[park_lab_backup_key]]
|
|
||||||
"""
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
[[stack]]
|
[[stack]]
|
||||||
name = "park-buzi"
|
name = "park-buzi"
|
||||||
[stack.config]
|
[stack.config]
|
||||||
@@ -85,11 +49,47 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-22544ec
|
TAG=stage-8fa66c9
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
WS_ALLOWED_ORIGINS=
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
|
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||||
|
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||||
|
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||||
JWT_SECRET=[[park_buzi_jwt_secret]]
|
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
BACKUP_KEY=[[park_buzi_backup_key]]
|
BACKUP_KEY=[[park_buzi_backup_key]]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
[[stack]]
|
||||||
|
name = "park-2"
|
||||||
|
[stack.config]
|
||||||
|
server = "park-2"
|
||||||
|
git_provider = "git.infra.msai.al"
|
||||||
|
git_account = "komodo"
|
||||||
|
repo = "mca/parking_solution"
|
||||||
|
branch = "stage"
|
||||||
|
file_paths = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.prod.yml"
|
||||||
|
]
|
||||||
|
registry_provider = "git.infra.msai.al"
|
||||||
|
registry_account = "komodo"
|
||||||
|
environment = """
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
|
TAG=stage-8fa66c9
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
VISION_ENABLED=1
|
||||||
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
|
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||||
|
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||||
|
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||||
|
JWT_SECRET=[[park_2_jwt_secret]]
|
||||||
|
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||||
|
BACKUP_KEY=[[park_2_backup_key]]
|
||||||
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Merchant validation programs (2026-07-13). In-park merchants (bar / lavazh) validate a
|
||||||
|
-- customer's ticket so the BOOTH settlement discounts the fee — the merchant only
|
||||||
|
-- validates, all money and paper stay at the booth. The /setup/site checkboxes toggle the
|
||||||
|
-- WELL-KNOWN rows ("bar", "lavazh"); a future merchant is a new row, not a migration.
|
||||||
|
-- Config is plainly MUTABLE (no versioning): the applied validation is a signed ledger
|
||||||
|
-- event carrying the RESOLVED values, so reproducibility never depends on these rows.
|
||||||
|
-- See wiki/concepts/validation-discounts.md.
|
||||||
|
CREATE TABLE `validation_programs` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`mode` text DEFAULT 'comp' NOT NULL,
|
||||||
|
`minutes` integer,
|
||||||
|
`percent` integer,
|
||||||
|
`max_amount_minor` integer,
|
||||||
|
`max_per_day` integer,
|
||||||
|
`active` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`deleted_at` text,
|
||||||
|
`deleted_by` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- WHICH users may apply a program: the apply guard is `validation:create` AND a binding
|
||||||
|
-- row here — a bar user can never apply the lavazh program.
|
||||||
|
CREATE TABLE `validation_program_users` (
|
||||||
|
`program_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
FOREIGN KEY (`program_id`) REFERENCES `validation_programs`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `validation_program_users_program_id_user_id_unique` ON `validation_program_users` (`program_id`,`user_id`);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Last-success/last-error for the encrypted DB backup were previously tracked only as
|
||||||
|
-- in-process fields on BackupService (never written to the DB) — so every server restart
|
||||||
|
-- (deploy/crash/OOM/host reboot, all routine under `restart: always`) silently reset the admin
|
||||||
|
-- UI's "last successful backup" to "Never", even with valid, correctly-rotating backups already
|
||||||
|
-- on disk (2026-08-30 field incident, park-buzi). Four additive, nullable columns; null = no
|
||||||
|
-- run recorded yet (or, for the error pair, no failure since the last success). See
|
||||||
|
-- wiki/concepts/backup-recovery.md.
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_success_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_result_json` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_error_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_error` text;
|
||||||
@@ -169,6 +169,20 @@
|
|||||||
"when": 1781886600000,
|
"when": 1781886600000,
|
||||||
"tag": "0023_driver_id_escpos",
|
"tag": "0023_driver_id_escpos",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1783948800000,
|
||||||
|
"tag": "0024_validation_programs",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788078414270,
|
||||||
|
"tag": "0025_backup_last_status",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,11 @@ const CATEGORIES = {
|
|||||||
"tariff_versions",
|
"tariff_versions",
|
||||||
"tariffs",
|
"tariffs",
|
||||||
"subscription_plans",
|
"subscription_plans",
|
||||||
|
// Merchant validation programs (bar/lavazh) + their user bindings (child first).
|
||||||
|
// A --users reset without --config may orphan a binding row; harmless — a binding
|
||||||
|
// whose user is gone grants nothing.
|
||||||
|
"validation_program_users",
|
||||||
|
"validation_programs",
|
||||||
],
|
],
|
||||||
users: ["sessions", "role_permissions", "users", "roles"],
|
users: ["sessions", "role_permissions", "users", "roles"],
|
||||||
diagnostics: ["app_logs"],
|
diagnostics: ["app_logs"],
|
||||||
|
|||||||
@@ -288,6 +288,20 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
backupKeepLast: integer("backup_keep_last"),
|
backupKeepLast: integer("backup_keep_last"),
|
||||||
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
||||||
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
||||||
|
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
|
||||||
|
* (not just in-process memory) so the admin UI's "last successful backup" survives a
|
||||||
|
* server restart — before this column existed, a restart silently reset that status to
|
||||||
|
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
|
||||||
|
* See wiki/concepts/backup-recovery.md. */
|
||||||
|
backupLastSuccessAt: text("backup_last_success_at"),
|
||||||
|
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
|
||||||
|
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
|
||||||
|
backupLastResultJson: text("backup_last_result_json"),
|
||||||
|
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
|
||||||
|
* reason. null = no failure recorded (or none since the last success). */
|
||||||
|
backupLastErrorAt: text("backup_last_error_at"),
|
||||||
|
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
|
||||||
|
backupLastError: text("backup_last_error"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
@@ -458,6 +472,57 @@ export const subscriptionPlates = sqliteTable("subscription_plates", {
|
|||||||
plate: text("plate").notNull(),
|
plate: text("plate").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Merchant validation programs (bar / lavazh) --------------------------
|
||||||
|
// Admin-composed master data for in-park merchant discounts: the /setup/site
|
||||||
|
// checkboxes toggle the WELL-KNOWN rows ("bar", "lavazh") — a future merchant is a
|
||||||
|
// new row, not a migration. Config is plainly MUTABLE (no versioning): the applied
|
||||||
|
// validation is a signed ledger event carrying the RESOLVED values, so historical
|
||||||
|
// reproducibility never depends on this row. Enabling/saving signs a config_change.
|
||||||
|
// See wiki/concepts/validation-discounts.md.
|
||||||
|
export const validationPrograms = sqliteTable("validation_programs", {
|
||||||
|
// Well-known slug ("bar" | "lavazh"); generic text so future merchants are rows.
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
|
||||||
|
name: text("name").notNull(),
|
||||||
|
// How the program discounts — see @parking/shared ValidationMode.
|
||||||
|
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] })
|
||||||
|
.notNull()
|
||||||
|
.default("comp"),
|
||||||
|
// timeCredit: the free minutes.
|
||||||
|
minutes: integer("minutes"),
|
||||||
|
// percent: 1..100 off the fee.
|
||||||
|
percent: integer("percent"),
|
||||||
|
// fixed: cap on the amount the merchant may type at scan time (minor units).
|
||||||
|
maxAmountMinor: integer("max_amount_minor"),
|
||||||
|
// Anti-abuse cap: max applications per local day (null = unlimited).
|
||||||
|
maxPerDay: integer("max_per_day"),
|
||||||
|
// The /setup/site checkbox. Inactive = merchants can't apply it (row + history kept).
|
||||||
|
active: integer("active", { mode: "boolean" }).notNull().default(false),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
// Soft delete (recycle bin) — see roles.deletedAt.
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The program↔user binding: WHICH users may apply a program (the guard is
|
||||||
|
// `validation:create` AND a binding row — a bar user can never apply lavazh).
|
||||||
|
export const validationProgramUsers = sqliteTable(
|
||||||
|
"validation_program_users",
|
||||||
|
{
|
||||||
|
programId: text("program_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => validationPrograms.id),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniq: unique().on(t.programId, t.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// --- Blocklist (banlist) -------------------------------------------------
|
// --- Blocklist (banlist) -------------------------------------------------
|
||||||
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
||||||
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
||||||
@@ -546,5 +611,7 @@ export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
|
|||||||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||||
|
export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
|
||||||
|
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
|
||||||
export type SessionRow = typeof sessions.$inferSelect;
|
export type SessionRow = typeof sessions.$inferSelect;
|
||||||
export type AppLogRow = typeof appLogs.$inferSelect;
|
export type AppLogRow = typeof appLogs.$inferSelect;
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ const STR = {
|
|||||||
tenderCard: "Kartë",
|
tenderCard: "Kartë",
|
||||||
/** "Paid:" amount label (precedes the large total). */
|
/** "Paid:" amount label (precedes the large total). */
|
||||||
amountLabel: "PAGUAR",
|
amountLabel: "PAGUAR",
|
||||||
|
/** Merchant-validation lines: the pre-discount fee + one line per discount. */
|
||||||
|
gross: (v: string) => `Tarifa: ${v}`,
|
||||||
|
discount: (label: string, v: string) => `${label}: -${v}`,
|
||||||
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
|
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
|
||||||
* 80mm width, so neither wraps mid-word. */
|
* 80mm width, so neither wraps mid-word. */
|
||||||
graceLines: (min: number): readonly string[] => [
|
graceLines: (min: number): readonly string[] => [
|
||||||
@@ -413,6 +416,16 @@ export function renderReceipt(data: ReceiptData): Buffer {
|
|||||||
line(
|
line(
|
||||||
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
|
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
|
||||||
),
|
),
|
||||||
|
// Merchant validations: gross fee + one line per discount, so the customer sees
|
||||||
|
// the full gross → discounts → net story (the big amount below is the NET).
|
||||||
|
...(data.validationLines?.length
|
||||||
|
? [
|
||||||
|
line(STR.gross(money(data.grossMinor ?? data.amountMinor, data.currency))),
|
||||||
|
...data.validationLines.map((v) =>
|
||||||
|
line(STR.discount(v.label, money(v.discountMinor, data.currency))),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
line(),
|
line(),
|
||||||
// The amount, large and centred.
|
// The amount, large and centred.
|
||||||
ALIGN_CENTER,
|
ALIGN_CENTER,
|
||||||
|
|||||||
@@ -275,6 +275,11 @@ export interface ReceiptData {
|
|||||||
readonly voucher: boolean;
|
readonly voucher: boolean;
|
||||||
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
|
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
|
||||||
readonly graceExitMin?: number | null;
|
readonly graceExitMin?: number | null;
|
||||||
|
/** Merchant validations (bar/lavazh): the PRE-discount fee and the per-validation
|
||||||
|
* lines. When present, `amountMinor` is the NET actually paid and the receipt
|
||||||
|
* shows the full gross → discounts → net story. See validation-discounts.md. */
|
||||||
|
readonly grossMinor?: number | null;
|
||||||
|
readonly validationLines?: readonly { label: string; discountMinor: number }[];
|
||||||
readonly header?: TicketHeader;
|
readonly header?: TicketHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const RESOURCES = [
|
|||||||
"tariff", // read / publish a new version
|
"tariff", // read / publish a new version
|
||||||
"subscription", // the subscription registry
|
"subscription", // the subscription registry
|
||||||
"site", // site_config + device setup/assign
|
"site", // site_config + device setup/assign
|
||||||
|
"validation", // merchant validations: apply a discount to a session (bar/lavazh)
|
||||||
"device", // device status / printers / snapshots / catalog
|
"device", // device status / printers / snapshots / catalog
|
||||||
"shift", // open/close own shift
|
"shift", // open/close own shift
|
||||||
"drawer", // record cash receipts/disbursements (operator); review them (admin)
|
"drawer", // record cash receipts/disbursements (operator); review them (admin)
|
||||||
@@ -54,6 +55,13 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
|
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
|
||||||
|
|
||||||
"site:read", "site:update",
|
"site:read", "site:update",
|
||||||
|
// Merchant validations (bar/lavazh): create = APPLY a validation to a session (the
|
||||||
|
// merchant user's one permission — guarded further by the program↔user binding, so a
|
||||||
|
// bar user can never apply the lavazh program) + void their OWN unused validation;
|
||||||
|
// read = see applied validations (reports/history). Program COMPOSITION needs no new
|
||||||
|
// permission — it lives on /setup/site behind site:update. See
|
||||||
|
// wiki/concepts/validation-discounts.md.
|
||||||
|
"validation:create", "validation:read",
|
||||||
"device:read",
|
"device:read",
|
||||||
"shift:read", "shift:create", "shift:cash",
|
"shift:read", "shift:create", "shift:cash",
|
||||||
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
|
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
|
||||||
@@ -272,6 +280,14 @@ export type LedgerEventType =
|
|||||||
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be
|
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be
|
||||||
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
|
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
|
||||||
| "config_change"
|
| "config_change"
|
||||||
|
// A merchant validation applied to (or voided from) a transient session: the bar/
|
||||||
|
// lavazh user scanned the customer's ticket, so the booth settlement discounts the
|
||||||
|
// fee. Payload carries the RESOLVED values (programId, label, mode, minutes/
|
||||||
|
// amountMinor/percent) — reproducible even if the program config later changes —
|
||||||
|
// plus `operator` (the merchant username). A payload with `refId` set is a VOID of
|
||||||
|
// the referenced validation event (append-only correction, mirrors cash_review).
|
||||||
|
// See wiki/concepts/validation-discounts.md.
|
||||||
|
| "validation"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
/** How money was tendered (for payment events + the shift Z-report). */
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
@@ -291,9 +307,25 @@ export interface LedgerPayload {
|
|||||||
readonly tender?: Tender;
|
readonly tender?: Tender;
|
||||||
/** payment: which tariff_version priced it (reproducible repricing). */
|
/** payment: which tariff_version priced it (reproducible repricing). */
|
||||||
readonly tariffVersionId?: string;
|
readonly tariffVersionId?: string;
|
||||||
/** payment: gross/discount/net split when a validation applied. */
|
/** payment: gross/discount/net split when a validation applied. `amountMinor` is the
|
||||||
|
* NET collected; grossMinor the pre-discount fee; discountMinor what validations took
|
||||||
|
* off. `validationIds` = the validation event ids this payment CONSUMED (so an
|
||||||
|
* overstay's fresh period never re-applies them). */
|
||||||
readonly grossMinor?: number;
|
readonly grossMinor?: number;
|
||||||
readonly discountMinor?: number;
|
readonly discountMinor?: number;
|
||||||
|
readonly validationIds?: string[];
|
||||||
|
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
|
||||||
|
* stamped so the printed receipt reproduces without re-deriving the fold. */
|
||||||
|
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
|
||||||
|
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
|
||||||
|
readonly programId?: string;
|
||||||
|
readonly programLabel?: string;
|
||||||
|
/** validation: resolved values by mode — timeCredit's free minutes / percent off.
|
||||||
|
* A fixed amount rides the shared `amountMinor`. */
|
||||||
|
readonly minutes?: number;
|
||||||
|
readonly percent?: number;
|
||||||
|
/** validation / cash vouchers: the username of the user who recorded it. */
|
||||||
|
readonly operator?: string;
|
||||||
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
||||||
readonly fxRate?: number | null;
|
readonly fxRate?: number | null;
|
||||||
/** void / anomaly / override: a human-readable English sentence, signed as the
|
/** void / anomaly / override: a human-readable English sentence, signed as the
|
||||||
@@ -326,7 +358,8 @@ export interface LedgerPayload {
|
|||||||
* cash_review event, so new movements do NOT carry this. Kept so historical events
|
* cash_review event, so new movements do NOT carry this. Kept so historical events
|
||||||
* still verify + display. See wiki/concepts/shift.md. */
|
* still verify + display. See wiki/concepts/shift.md. */
|
||||||
readonly authorizedBy?: string;
|
readonly authorizedBy?: string;
|
||||||
/** cash_review: the id of the cash_in/cash_out event this review decides on. */
|
/** cash_review: the id of the cash_in/cash_out event this review decides on.
|
||||||
|
* validation: set = this event VOIDS the referenced validation event. */
|
||||||
readonly refId?: string;
|
readonly refId?: string;
|
||||||
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
||||||
* neither value moves cash or touches the drawer balance. */
|
* neither value moves cash or touches the drawer balance. */
|
||||||
@@ -702,6 +735,58 @@ export interface SessionPayment {
|
|||||||
readonly graceExitMin: number | null;
|
readonly graceExitMin: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Merchant validations (bar / lavazh discounts) ---------------------------
|
||||||
|
// An in-park merchant validates a customer's ticket so the BOOTH settlement charges
|
||||||
|
// less or nothing. The program is admin-composed MUTABLE master data (no versioning:
|
||||||
|
// the applied validation is a signed ledger event carrying the RESOLVED values, so
|
||||||
|
// reproducibility never depends on the row). All money stays at the booth — the
|
||||||
|
// merchant only validates. See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
|
||||||
|
* by the merchant at scan time, capped) / a percentage off. */
|
||||||
|
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent";
|
||||||
|
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
|
||||||
|
|
||||||
|
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
|
||||||
|
* are the well-known ids the /setup/site checkboxes toggle). */
|
||||||
|
export interface ValidationProgram {
|
||||||
|
readonly id: string; // well-known slug ("bar" | "lavazh"); generic for future merchants
|
||||||
|
/** Receipt label, e.g. "Lavazh — 1 orë falas". Printed on the booth receipt line. */
|
||||||
|
readonly name: string;
|
||||||
|
readonly mode: ValidationMode;
|
||||||
|
/** timeCredit: the free minutes. */
|
||||||
|
readonly minutes: number | null;
|
||||||
|
/** percent: 1..100 off the fee. */
|
||||||
|
readonly percent: number | null;
|
||||||
|
/** fixed: cap on the amount the merchant may type at scan time (minor units). */
|
||||||
|
readonly maxAmountMinor: number | null;
|
||||||
|
/** Cap: max applications of this program per local day (null = unlimited). */
|
||||||
|
readonly maxPerDay: number | null;
|
||||||
|
readonly active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An APPLIED validation as pricing cares about it — the RESOLVED values folded off
|
||||||
|
* the signed validation event (never the mutable program row). */
|
||||||
|
export interface SessionValidation {
|
||||||
|
/** The validation event id (payments record which ids they consumed). */
|
||||||
|
readonly eventId?: string;
|
||||||
|
readonly programId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly mode: ValidationMode;
|
||||||
|
readonly minutes?: number; // timeCredit
|
||||||
|
readonly amountMinor?: number; // fixed
|
||||||
|
readonly percent?: number; // percent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One receipt/display line: what a validation actually saved on this settlement. */
|
||||||
|
export interface ValidationLine {
|
||||||
|
readonly programId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly mode: ValidationMode;
|
||||||
|
/** The (positive) amount this line took off the fee. */
|
||||||
|
readonly discountMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** The full pricing outcome for a session at a moment in time — what the booth's
|
/** The full pricing outcome for a session at a moment in time — what the booth's
|
||||||
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
|
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
|
||||||
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
|
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
|
||||||
@@ -709,8 +794,14 @@ export interface SessionPricing {
|
|||||||
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
|
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
|
||||||
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
|
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
|
||||||
readonly periodStart: string;
|
readonly periodStart: string;
|
||||||
/** Fee for [periodStart, asOf]. */
|
/** Amount DUE for [periodStart, asOf] — NET of any merchant validations. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** The pre-validation fee for the same period (= amountMinor when no validations). */
|
||||||
|
readonly grossMinor: number;
|
||||||
|
/** Total the validations took off (grossMinor − amountMinor). */
|
||||||
|
readonly discountMinor: number;
|
||||||
|
/** Per-validation receipt lines, in the canonical application order. */
|
||||||
|
readonly validationLines: ValidationLine[];
|
||||||
/** True when the latest payment's grace has lapsed (overstay = new period). */
|
/** True when the latest payment's grace has lapsed (overstay = new period). */
|
||||||
readonly overstay: boolean;
|
readonly overstay: boolean;
|
||||||
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
|
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
|
||||||
@@ -732,6 +823,15 @@ export interface SessionPricing {
|
|||||||
* `payments` is the session's payment history (only the LATEST matters for grace);
|
* `payments` is the session's payment history (only the LATEST matters for grace);
|
||||||
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
|
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
|
||||||
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
|
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
|
||||||
|
*
|
||||||
|
* `validations` are the UNCONSUMED merchant validations on the session (the caller
|
||||||
|
* filters out ids already recorded on a prior payment's `validationIds`, so an
|
||||||
|
* overstay's fresh period never re-applies them). Canonical application order —
|
||||||
|
* deterministic regardless of scan order: timeCredit (shifts the billed period's
|
||||||
|
* start forward, so "first hour free" is literal and windowed/stepped cards price
|
||||||
|
* the remainder correctly) → percent (of the remaining fee) → fixed amounts
|
||||||
|
* (clamped to the remainder) → comp (zeroes whatever is left). Net never goes
|
||||||
|
* below 0. See wiki/concepts/validation-discounts.md.
|
||||||
*/
|
*/
|
||||||
export function priceSession(
|
export function priceSession(
|
||||||
enteredAt: string,
|
enteredAt: string,
|
||||||
@@ -739,6 +839,7 @@ export function priceSession(
|
|||||||
tariff: TariffStructure,
|
tariff: TariffStructure,
|
||||||
payments: readonly SessionPayment[] = [],
|
payments: readonly SessionPayment[] = [],
|
||||||
category?: string,
|
category?: string,
|
||||||
|
validations: readonly SessionValidation[] = [],
|
||||||
): SessionPricing {
|
): SessionPricing {
|
||||||
const last = payments.length ? payments[payments.length - 1] : null;
|
const last = payments.length ? payments[payments.length - 1] : null;
|
||||||
const graceExpiryMs =
|
const graceExpiryMs =
|
||||||
@@ -748,10 +849,50 @@ export function priceSession(
|
|||||||
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
|
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
|
||||||
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
|
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
|
||||||
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
|
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
|
||||||
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
|
const grossMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
|
||||||
|
|
||||||
|
// Fold the validations (nothing to discount on a settled session or a zero fee is
|
||||||
|
// still folded so the receipt can show "Lavazh — falas" even when gross is 0-adjacent).
|
||||||
|
const lines: ValidationLine[] = [];
|
||||||
|
let net = grossMinor;
|
||||||
|
if (!withinGrace && validations.length) {
|
||||||
|
const byMode = (m: ValidationMode) => validations.filter((v) => v.mode === m);
|
||||||
|
// 1. Time credits: bill as if the period started later (clamped at asOf). The
|
||||||
|
// marginal saving of each credit is its line amount.
|
||||||
|
let startMs = Date.parse(periodStart);
|
||||||
|
for (const v of byMode("timeCredit")) {
|
||||||
|
const minutes = v.minutes ?? 0;
|
||||||
|
const shiftedMs = Math.min(startMs + minutes * 60_000, asOfMs);
|
||||||
|
const newFee = computeFee(new Date(shiftedMs).toISOString(), asOf, tariff, category);
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net - newFee });
|
||||||
|
startMs = shiftedMs;
|
||||||
|
net = newFee;
|
||||||
|
}
|
||||||
|
// 2. Percent of the remaining fee (floor — integer minor units).
|
||||||
|
for (const v of byMode("percent")) {
|
||||||
|
const off = Math.floor((net * Math.min(Math.max(v.percent ?? 0, 0), 100)) / 100);
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
|
||||||
|
net -= off;
|
||||||
|
}
|
||||||
|
// 3. Fixed amounts, clamped to the remainder so Σ lines ≡ gross − net.
|
||||||
|
for (const v of byMode("fixed")) {
|
||||||
|
const off = Math.min(Math.max(v.amountMinor ?? 0, 0), net);
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
|
||||||
|
net -= off;
|
||||||
|
}
|
||||||
|
// 4. Comp: zero whatever is left.
|
||||||
|
for (const v of byMode("comp")) {
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net });
|
||||||
|
net = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
periodStart,
|
periodStart,
|
||||||
amountMinor,
|
amountMinor: net,
|
||||||
|
grossMinor,
|
||||||
|
discountMinor: grossMinor - net,
|
||||||
|
validationLines: lines,
|
||||||
overstay,
|
overstay,
|
||||||
withinGrace,
|
withinGrace,
|
||||||
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
|
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
|
||||||
|
|||||||
@@ -532,3 +532,104 @@ describe("explainFee — the breakdown IS the fee (2026-07-06)", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// (i) Merchant validations — the priceSession discount fold (2026-07-13).
|
||||||
|
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
|
||||||
|
// daily cap 100000, exit grace 5 min. See wiki/concepts/validation-discounts.md.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("priceSession merchant validations", () => {
|
||||||
|
const val = (
|
||||||
|
mode: "comp" | "timeCredit" | "fixed" | "percent",
|
||||||
|
over: Partial<import("./index.js").SessionValidation> = {},
|
||||||
|
): import("./index.js").SessionValidation => ({
|
||||||
|
programId: "bar",
|
||||||
|
label: "Bar",
|
||||||
|
mode,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no validations → gross == net, no lines (back-compat)", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, []);
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(30000);
|
||||||
|
expect(r.discountMinor).toBe(0);
|
||||||
|
expect(r.validationLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comp zeroes the fee and the line carries the whole gross", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("comp")]);
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
expect(r.discountMinor).toBe(30000);
|
||||||
|
expect(r.validationLines).toEqual([{ programId: "bar", label: "Bar", mode: "comp", discountMinor: 30000 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fixed subtracts, floors at 0, and clamps the line to the remainder", () => {
|
||||||
|
// 2h → 30000 gross; 300-off style: fixed 20000 → net 10000.
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 20000 })]);
|
||||||
|
expect(r.amountMinor).toBe(10000);
|
||||||
|
expect(r.discountMinor).toBe(20000);
|
||||||
|
// Bigger than the fee → net 0, line clamped to the gross (Σ lines ≡ gross − net).
|
||||||
|
const r2 = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 99999 })]);
|
||||||
|
expect(r2.amountMinor).toBe(0);
|
||||||
|
expect(r2.validationLines[0]!.discountMinor).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("timeCredit prices as if entered later — 'first hour free' is literal", () => {
|
||||||
|
// 2h stay, 60 free minutes → bill the remaining 1h at the FIRST block (20000),
|
||||||
|
// exactly what a 1h stay costs.
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("timeCredit", { minutes: 60 })]);
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(computeFee(at(60), at(120), liveV1));
|
||||||
|
expect(r.amountMinor).toBe(20000);
|
||||||
|
expect(r.validationLines[0]!.discountMinor).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("timeCredit covering the whole stay → net 0", () => {
|
||||||
|
const r = priceSession(entered, at(50), liveV1, [], undefined, [val("timeCredit", { minutes: 120 })]);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
expect(r.discountMinor).toBe(r.grossMinor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("percent takes a floor'd share of the remaining fee", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("percent", { percent: 50 })]);
|
||||||
|
expect(r.amountMinor).toBe(15000);
|
||||||
|
expect(r.discountMinor).toBe(15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacking is canonical-order (timeCredit → percent → fixed → comp) and Σ lines ≡ gross − net", () => {
|
||||||
|
// Scan order deliberately reversed; the fold must still do time first.
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [
|
||||||
|
val("fixed", { amountMinor: 5000, programId: "bar" }),
|
||||||
|
val("timeCredit", { minutes: 60, programId: "lavazh", label: "Lavazh" }),
|
||||||
|
]);
|
||||||
|
// gross 30000 → time credit leaves 20000 → fixed 5000 → net 15000.
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(15000);
|
||||||
|
const sum = r.validationLines.reduce((a, l) => a + l.discountMinor, 0);
|
||||||
|
expect(sum).toBe(r.discountMinor);
|
||||||
|
expect(r.validationLines.map((l) => l.mode)).toEqual(["timeCredit", "fixed"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a settled (paid + within grace) session ignores validations", () => {
|
||||||
|
const r = priceSession(entered, at(123), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
|
||||||
|
val("comp"),
|
||||||
|
]);
|
||||||
|
expect(r.withinGrace).toBe(true);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
expect(r.validationLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an overstay period applies (unconsumed) validations to the FRESH period", () => {
|
||||||
|
// Paid at 120, grace 5 → overstay period starts at 125. A 60-min credit eats the
|
||||||
|
// overstay's first hour: net = fee(185→245 from period start) = the 1h price… i.e.
|
||||||
|
// fee of (245−125−60)=60 min from the ladder start.
|
||||||
|
const r = priceSession(entered, at(245), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
|
||||||
|
val("timeCredit", { minutes: 60 }),
|
||||||
|
]);
|
||||||
|
expect(r.overstay).toBe(true);
|
||||||
|
expect(r.grossMinor).toBe(computeFee(at(125), at(245), liveV1));
|
||||||
|
expect(r.amountMinor).toBe(computeFee(at(185), at(245), liveV1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Generated
+33
@@ -108,12 +108,24 @@ importers:
|
|||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
specifier: ^1.170.16
|
specifier: ^1.170.16
|
||||||
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@tauri-apps/api':
|
||||||
|
specifier: ^2.11.1
|
||||||
|
version: 2.11.1
|
||||||
|
'@tauri-apps/plugin-http':
|
||||||
|
specifier: ^2.5.2
|
||||||
|
version: 2.6.0
|
||||||
'@tauri-apps/plugin-process':
|
'@tauri-apps/plugin-process':
|
||||||
specifier: ^2.3.1
|
specifier: ^2.3.1
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
|
'@tauri-apps/plugin-store':
|
||||||
|
specifier: ^2.4.0
|
||||||
|
version: 2.4.4
|
||||||
'@tauri-apps/plugin-updater':
|
'@tauri-apps/plugin-updater':
|
||||||
specifier: ^2.10.1
|
specifier: ^2.10.1
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
|
'@tauri-apps/plugin-websocket':
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.4.3
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.3.1
|
specifier: ^26.3.1
|
||||||
version: 26.3.1(typescript@6.0.3)
|
version: 26.3.1(typescript@6.0.3)
|
||||||
@@ -1577,12 +1589,21 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-http@2.6.0':
|
||||||
|
resolution: {integrity: sha512-QYXwbGb4hQ9/8Riv/ejU/kPFFnbBIrBcWwV1LIXv2xBKfoj8lkWfGkd9pkCSsBI/pljPtz+IPqfrE3t3bVl3mg==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-process@2.3.1':
|
'@tauri-apps/plugin-process@2.3.1':
|
||||||
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.4':
|
||||||
|
resolution: {integrity: sha512-oxSMaj/QpVfJcBMYX5aOQV94fWvga0MwQMfD6TLlbK2dh+ShPWAzefd8HWXhvOKjPRJdGVAkW7ZGO76JzzjaDA==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.10.1':
|
'@tauri-apps/plugin-updater@2.10.1':
|
||||||
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-websocket@2.4.3':
|
||||||
|
resolution: {integrity: sha512-c85ykljg6AzY6Zw4KpYsEBaLirjPIs6m8xxC6hZcdwAchckaBU368US+oSsa5B43PjSLukjwD5vOOqzYYnswWA==}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4089,14 +4110,26 @@ snapshots:
|
|||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.3
|
'@tauri-apps/cli-win32-ia32-msvc': 2.11.3
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.3
|
'@tauri-apps/cli-win32-x64-msvc': 2.11.3
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-http@2.6.0':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-process@2.3.1':
|
'@tauri-apps/plugin-process@2.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.4':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.10.1':
|
'@tauri-apps/plugin-updater@2.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-websocket@2.4.3':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, durability, backup, recovery, security, crypto]
|
tags: [parking, durability, backup, recovery, security, crypto]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-29
|
updated: 2026-08-30
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backup & Disaster Recovery
|
# Backup & Disaster Recovery
|
||||||
@@ -207,12 +207,68 @@ timer + the manual route**. What landed:
|
|||||||
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
||||||
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
||||||
|
|
||||||
|
## Field bug — "last successful backup: Never" despite valid, rotating backups on disk (found + fixed 2026-08-30)
|
||||||
|
|
||||||
|
**Symptom (park-buzi):** the admin noticed the backup directory held 7 real, correctly-sized,
|
||||||
|
correctly-rotating encrypted backups (`parking-backup-*.sqlite.enc`, retention working exactly as
|
||||||
|
designed) — yet the Backup screen's "Kopja e fundit e suksesshme" (last successful backup) showed
|
||||||
|
**"Asnjëherë" (Never)**. Separately, the most recent file was 2 days old rather than ~1.
|
||||||
|
|
||||||
|
**Root cause — two independent, disconnected code paths, both traced to `setInterval`-since-
|
||||||
|
process-start:**
|
||||||
|
|
||||||
|
1. **Status was never persisted.** `BackupService` tracked `lastSuccessAt`/`lastResult`/
|
||||||
|
`lastErrorAt`/`lastError` as **plain in-process private fields** — set only inside `run()`,
|
||||||
|
read only by `status()` on the *same running instance*. Nothing wrote them to `site_config` or
|
||||||
|
anywhere else durable. The actual backup-writing engine (`backup.ts`: consistent copy → encrypt
|
||||||
|
→ `pruneOldBackups`) is a completely separate code path that only touches the filesystem and
|
||||||
|
has no notion of this status object. So "7 valid files on disk" and "status says Never" were
|
||||||
|
never contradictory — they were two unrelated signals, and **any** server restart (deploy,
|
||||||
|
crash, OOM, host reboot — all routine under `restart: always` in `docker-compose.prod.yml`)
|
||||||
|
silently reset the in-memory fields to `null` regardless of what had actually happened on disk.
|
||||||
|
2. **The schedule was measured from process start, not from the last real backup.** The daily
|
||||||
|
timer was `setInterval(() => backupService.runScheduled(), 24h)` — a fixed 24h period counted
|
||||||
|
from whenever the *process* last started, not from wall-clock time or from when a backup last
|
||||||
|
actually succeeded. The exact same restart that wiped the in-memory status also reset this
|
||||||
|
countdown, which is why the cadence can silently drift or skip past a day with no error ever
|
||||||
|
surfacing anywhere.
|
||||||
|
|
||||||
|
Both symptoms are one cause: **the server process restarted after the Aug 28 backup, and nothing
|
||||||
|
about this design was built to survive that.**
|
||||||
|
|
||||||
|
### Fix (2026-08-30)
|
||||||
|
|
||||||
|
- **`packages/db/src/schema.ts`** / migration `0025_backup_last_status.sql` — four new nullable
|
||||||
|
`site_config` columns: `backup_last_success_at`, `backup_last_result_json`,
|
||||||
|
`backup_last_error_at`, `backup_last_error`. Same table, same upsert pattern as
|
||||||
|
`backup_target_dir`/`backup_keep_last`/`backup_keep_daily_days` (migrations 0016/0017).
|
||||||
|
- **`backup-service.ts`** — `run()` now writes success/error outcomes to these columns (via a
|
||||||
|
`#persist` upsert helper) instead of private fields; `status()` reads them fresh from the DB on
|
||||||
|
every call. A brand-new `BackupService` instance (i.e. a fresh process) now sees exactly what
|
||||||
|
the previous instance last recorded — no more restart amnesia.
|
||||||
|
- **New `isDue(now, intervalMs = 24h)`** method: due iff `now - backupLastSuccessAt >= 24h` (or
|
||||||
|
immediately due if no success was ever recorded), computed from the **persisted** timestamp —
|
||||||
|
never from process uptime.
|
||||||
|
- **`server.ts`** — the daily `setInterval` was replaced with a **15-minute poll** calling
|
||||||
|
`runScheduled()`, which now itself no-ops unless `isDue()` is true. This makes the actual backup
|
||||||
|
cadence immune to restart timing entirely: however often the process happens to restart, the
|
||||||
|
next backup fires within 15 minutes of 24h having genuinely elapsed since the last real success
|
||||||
|
— not 24h after whatever moment the process most recently came back up.
|
||||||
|
- Covered by a new `backup-service.test.ts`: a fresh `BackupService` over the same DB handle
|
||||||
|
(simulating a restart) sees the prior instance's last success/error and its cleared-on-success
|
||||||
|
behavior; `isDue()` is exercised directly against injected timestamps rather than real sleeps.
|
||||||
|
|
||||||
|
No change to the `BackupStatus` shape returned by `GET /api/backup/status` or to
|
||||||
|
`BackupSettings.tsx` — this was purely a durability fix underneath the same contract.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
||||||
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
||||||
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
||||||
`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first
|
`BACKUP_KEY` stays a server secret. **Last-success/last-error status + the scheduling cadence are
|
||||||
build slices; records the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and
|
now restart-durable (migration 0025, 2026-08-30)** — see field bug above. Resolves the *design*
|
||||||
#10 (snapshots bloat backups → future exclude toggle). See [[append-only-event-chain]],
|
half of [[open-questions]] #5 and the first build slices; records the key-custody stance that bears
|
||||||
[[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]], [[reconciliation]].
|
on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat backups → future exclude
|
||||||
|
toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]],
|
||||||
|
[[reconciliation]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-06
|
updated: 2026-08-30
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,5 +140,70 @@ hint. The transport option label no longer hardcodes lp0.
|
|||||||
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
|
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
|
||||||
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
|
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
|
||||||
|
|
||||||
|
## Field bug — cover-open re-enumeration wedges the container's `/dev/usb` view; only `docker restart`, not a host reboot, clears it (investigated 2026-08-30, unconfirmed root cause)
|
||||||
|
|
||||||
|
**Symptom (park-buzi, unknown/"Generic" USB printer, model not yet identified — see below):** every
|
||||||
|
time the booth operator opens the printer's paper-roll cover to reload paper, the printer's status
|
||||||
|
goes `offline`/faulty in the app and **never self-recovers** — not after the cover closes, not after
|
||||||
|
a full appliance reboot. The only fix found so far is SSH in and `docker restart server`.
|
||||||
|
|
||||||
|
**Ruled out at the application layer.** Traced `sendRawUsb`/`probeUsb` in `printer-escpos.ts`: every
|
||||||
|
print AND every poll tick (`device-monitor.ts` 8s / `printer-monitor.ts` 5s) does a fresh
|
||||||
|
`open()` → write/probe → `close()` against the configured `devicePath`. **No fd, socket, or driver
|
||||||
|
instance is held across calls** — `driver.create(config)` is a throwaway object with no persistent
|
||||||
|
handle. So a naive "stale Node file descriptor" explanation does not fit this codebase; the
|
||||||
|
app-layer retry-by-fresh-open-every-poll should self-heal within one poll cycle if the kernel's view
|
||||||
|
of the device node is current.
|
||||||
|
|
||||||
|
**Leading hypothesis: the container's bind-mount of `/dev/usb`, not the Node process, holds the
|
||||||
|
stale state.** Docker Compose wires the printer in as a **directory bind-mount**
|
||||||
|
(`docker-compose.prod.yml`, `volumes: - /dev/usb:/dev/usb`), chosen deliberately (per its own
|
||||||
|
comment) so the app survives the printer renumbering to a different `lpN`. But many USB thermal
|
||||||
|
printers cut power to their own USB interface board when the cover-open microswitch trips (a
|
||||||
|
hardware safety/power feature, not just a status flag) — the printer drops off the bus and
|
||||||
|
re-enumerates, potentially as a new device node, when the cover closes. The **host** kernel picks
|
||||||
|
this up fine; the **container's mount namespace**, once established, is a known Docker/OverlayFS
|
||||||
|
sharp edge for `/dev` subtree bind-mounts — it can keep resolving the old node until the mount
|
||||||
|
itself is redone.
|
||||||
|
|
||||||
|
- `docker restart server` recreates the container's mount namespace → the `/dev/usb` bind-mount is
|
||||||
|
redone against current host state → the new node is picked up → fixed.
|
||||||
|
- A full host reboot restarts the container too (`restart: always`), but as a boot-time race: if the
|
||||||
|
container starts before the USB subsystem finishes settling, or the printer re-enumerated some
|
||||||
|
time *before* the reboot and Docker doesn't necessarily redo an already-satisfied bind-mount
|
||||||
|
target on a policy-driven restart, the container can come back up still bound to the pre-incident
|
||||||
|
view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does).
|
||||||
|
|
||||||
|
**Not yet confirmed on hardware** — this is the leading theory, not a verified root cause. To
|
||||||
|
confirm at the next occurrence, BEFORE restarting anything:
|
||||||
|
```bash
|
||||||
|
# host:
|
||||||
|
ls -la /dev/usb/ && stat /dev/usb/lp1
|
||||||
|
# container:
|
||||||
|
docker exec server ls -la /dev/usb/ && docker exec server stat /dev/usb/lp1
|
||||||
|
```
|
||||||
|
A major:minor or inode mismatch between host and container is the smoking gun. Also worth
|
||||||
|
capturing on the lab RONGTA (different printer, but same cover-open mechanism is plausible):
|
||||||
|
`watch -n1 lsusb` + `sudo dmesg -w | grep -i -E 'usb|disconnect'` while cycling the cover, to see
|
||||||
|
whether the Bus/Device number changes.
|
||||||
|
|
||||||
|
**Candidate fixes, not yet implemented** (ranked cheapest-to-most-invasive):
|
||||||
|
1. A host-side watchdog/udev rule that detects re-enumeration of this printer (match vendor:product
|
||||||
|
ID) and runs `docker restart server` automatically — turns the manual SSH fix into a self-healing
|
||||||
|
one without touching app code.
|
||||||
|
2. Same idea but event-driven via a udev rule or systemd path unit watching `/dev/usb`, rather than
|
||||||
|
polling.
|
||||||
|
3. Switch the compose device wiring from the directory bind-mount to a specific `--device=` cgroup
|
||||||
|
passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility
|
||||||
|
the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead.
|
||||||
|
|
||||||
|
**Open sub-question — printer identity.** The park-buzi unit shows as "Generic (unknown)" in the
|
||||||
|
app; not yet identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not
|
||||||
|
the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only
|
||||||
|
validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next
|
||||||
|
time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a
|
||||||
|
-n /dev/usb/lp1`. This mirrors the same discovery gap already noted above under "Device discovery"
|
||||||
|
(sysfs `ieee1284_id` enrichment) — once identified, fold the model into that mechanism's coverage.
|
||||||
|
|
||||||
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
||||||
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, pricing, revenue]
|
tags: [parking, domain, business, pricing, revenue]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-15
|
updated: 2026-07-13
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,6 +11,137 @@ status: open
|
|||||||
A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing —
|
A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing —
|
||||||
a common revenue/retention feature that modifies what a [[parking-session]] owes.
|
a common revenue/retention feature that modifies what a [[parking-session]] owes.
|
||||||
|
|
||||||
|
## Driving cases (owner requirements, 2026-07-13)
|
||||||
|
|
||||||
|
The feature moved from "industry gap" to **asked-for**: the park may contain an in-park
|
||||||
|
**car-wash (al. "lavazh")** and/or a **bar**, and the owner wants their customers discharged
|
||||||
|
(fully or partly) for the parking stay:
|
||||||
|
|
||||||
|
- **Car-wash**: parking free entirely, **or** free for an owner-set duration (30 min / 1 h / 2 h …)
|
||||||
|
after which the stay prices like any transient → `comp` or `time-credit`.
|
||||||
|
- **Bar**: subtract the bar consumption from the parking fee (consumed 300 ALL, park fee 500 ALL →
|
||||||
|
pay 200 ALL) → `fixed` with a **per-use variable amount**; or parking free for bar customers → `comp`.
|
||||||
|
- These must be **admin-composable at runtime like tariffs/subscription plans** — the owner
|
||||||
|
defines the programs and their parameters; nothing hard-coded.
|
||||||
|
|
||||||
|
**Refined the same day (settled): the merchant is a VALIDATION-ONLY system user; ALL money and
|
||||||
|
paper stay at the booth.** Ownership is immaterial and the [[validation-sponsorship]]
|
||||||
|
sponsor/settlement layer is **not needed** for this. The model:
|
||||||
|
|
||||||
|
- A **merchant user** (the "bar user", "lavazh user") logs into the system on their own device and
|
||||||
|
**scans the customer's ticket** there — the scan-and-apply *is* the validation, a signed event
|
||||||
|
attributed to that user (accountability sits with the merchant, not the booth operator). That is
|
||||||
|
the merchant's ENTIRE surface: no payment collection, no printer, no shift.
|
||||||
|
- **Every car still checks in at the booth to settle** — even a fully-comped one. The booth quote
|
||||||
|
applies the session's validation events (`gross − discounts`, floor 0); the operator collects the
|
||||||
|
**net** (possibly 0 — a zero-amount settlement is still a signed `payment` event so grace/exit
|
||||||
|
work unchanged) and **prints the detailed receipt there** (gross fee, each validation line, net
|
||||||
|
paid).
|
||||||
|
- Exit is the unchanged [[booth-exit-flow]] (immediate exit or voucher self-exit at the reader).
|
||||||
|
|
||||||
|
This DISSOLVES the two consequences flagged by the earlier merchant-collects variant (rejected
|
||||||
|
2026-07-13, same conversation): the [[shift]] site-wide single-open invariant and single till stay
|
||||||
|
as built (Z/X-reports just gain gross/discount/net lines so cash reconciles to net), and the exit
|
||||||
|
reader needs no live due=0 branch (the booth settlement covers the zero-due case; time-credit is
|
||||||
|
priced at booth check-in, inside the normal walk-back-grace flow).
|
||||||
|
|
||||||
|
## Settled design (2026-07-13) — setup UX, storage, RBAC
|
||||||
|
|
||||||
|
- **Setup lives on `/setup/site`** (gated by the page's existing `site:update`): the left card
|
||||||
|
gains **Bar** and **Lavazh** checkboxes; the empty right column renders the enabled station's
|
||||||
|
config panel (tabs when both). Panel per station: **mode** (comp / time-credit N-min / fixed
|
||||||
|
amount-typed-at-scan with a max cap / percent), **caps** (max per validation, max per day,
|
||||||
|
one-per-session default), **receipt label**, **bound users**.
|
||||||
|
- **Fixed UI, generic storage**: a `validation_programs` table (+ user binding) where Bar and
|
||||||
|
Lavazh are two **well-known rows** created on first enable — a third merchant later is a data
|
||||||
|
row, not a migration (honours the "composable like tariffs" requirement). Config is plainly
|
||||||
|
**mutable, no versioning**: the applied validation is a signed ledger event carrying the
|
||||||
|
RESOLVED values (minutes/amountMinor + programId), so reproducibility never depends on the row.
|
||||||
|
Enabling/saving signs a `config_change` ([[entry-presence-bypass]] precedent).
|
||||||
|
- **RBAC**: new `validation` resource in the code-defined grid — `validation:create` (apply; the
|
||||||
|
merchant's only permission) + `validation:read` (reports/history). Guard = permission **AND**
|
||||||
|
station binding (data), so a bar user can never apply the lavazh program. Merchant users land on
|
||||||
|
a new **`/validate`** screen (scan → session → apply); the permission-driven nav shows them
|
||||||
|
nothing else. Program composition needs no new permission (`site:update`).
|
||||||
|
- **Mistake handling**: a merchant may **void their own validation while unused** (before it
|
||||||
|
entered a payment) — a signed void event, never a delete. Booth/admin can void via the normal
|
||||||
|
event-void path.
|
||||||
|
- Open (non-blocking): per-customer mode choice (v1 = one mode per station); merchant scan
|
||||||
|
hardware — lean: also print a **QR** of the ticket id so any phone camera works
|
||||||
|
([[ticket-encoding]]).
|
||||||
|
|
||||||
|
## As-built (2026-07-13)
|
||||||
|
|
||||||
|
- **Shared (`@parking/shared`)**: `validation` resource (`validation:create`/`read`) in the
|
||||||
|
permission grid; `ValidationMode`/`ValidationProgram`/`SessionValidation`/`ValidationLine`;
|
||||||
|
`priceSession(…, validations[])` folds the discounts in a **canonical order** — timeCredit
|
||||||
|
(shifts the billed period's start forward, so grace/steps/windowed cards price the remainder
|
||||||
|
correctly) → percent (of the remainder) → fixed (clamped) → comp — net floors at 0 and
|
||||||
|
**Σ lines ≡ gross − net** by construction. Unit-tested (incl. overstay + settled cases).
|
||||||
|
- **Ledger**: new `validation` event type — payload carries the **resolved** values
|
||||||
|
(`programId`, `programLabel`, `mode`, `minutes`/`amountMinor`/`percent`) + `operator` (the
|
||||||
|
merchant username); `refId` set = a VOID of the referenced validation (append-only, mirrors
|
||||||
|
`cash_review`). The settling `payment` records `grossMinor`/`discountMinor`/`validationIds`
|
||||||
|
(**consumption** — an overstay's fresh period never re-applies them) + `validationLines`
|
||||||
|
(receipt reproducibility).
|
||||||
|
- **DB**: `validation_programs` + `validation_program_users` (migration `0024`; both in
|
||||||
|
reset-db's `config` category). Mutable master data, soft-deletable.
|
||||||
|
- **Server**: `routes/validations.ts` — programs GET/PUT (`site:read`/`site:update`, signed
|
||||||
|
`config_change` on real change only), `/mine`, `/session/:identity` (deliberately no money
|
||||||
|
data), `/apply` (guards in order: program live+active → user **bound** → open **transient** →
|
||||||
|
no live duplicate of the program → `maxPerDay` → fixed-amount bounds), `/void` (own +
|
||||||
|
unconsumed only). `PayStation.quote/lookup/pay` fold `liveValidations` (applied − voided −
|
||||||
|
consumed); `activeSessions` amounts are net automatically. Receipt (`renderReceipt`) prints
|
||||||
|
gross (`Tarifa`) + one line per discount; the big amount is the NET. Z/X-report gained
|
||||||
|
`discountTotalMinor` (leakage; takings stay net) — printed as `Zbritje (validime)` only when
|
||||||
|
non-zero, so old slips stay byte-identical.
|
||||||
|
- **Web**: `/setup/site` is two-column — Bar/Lavazh checkboxes on the left card (a flip persists
|
||||||
|
`active` at once = signed config change), `ValidationSetup.tsx` panel on the right (tabs when
|
||||||
|
both; mode/params/caps/receipt-label/bound-users). `/validate` (`ValidateScreen.tsx`) is the
|
||||||
|
merchant's whole surface (scan/key → apply → void own unused), mobile-friendly, autofocused
|
||||||
|
input works with HID scanners; merchant-only users (no `session:read`) land there on login and
|
||||||
|
the permission-gated nav shows them nothing else. The app SHELL also degrades by permission
|
||||||
|
(2026-07-13 follow-up): the live-feed WebSocket connects only with `report:read` (the server's
|
||||||
|
WS guard — a merchant's socket would 403 and the capped-backoff reconnect would spam the server
|
||||||
|
log forever), and the StatusDot / ShiftButton / DeviceFooter widgets render only with their
|
||||||
|
backing permissions (`report:read` / `shift:read` / `device:read`). Booth pay modal shows gross → lines → net;
|
||||||
|
the zero-net comp settles through the normal pay path (grace starts, voucher/exit unchanged).
|
||||||
|
Feed label `VALIDIM`/`VALIDATION`. RolesManager picks the new resource up generically.
|
||||||
|
- **Verified**: 8 route-level integration tests (guards, signed events, money cycle, void locks,
|
||||||
|
per-day cap) + the shared fold suite; whole-workspace build/typecheck/test green; migration
|
||||||
|
applied to the dev DB.
|
||||||
|
- **Remaining polish (not blocking)**: show `discountTotalMinor` in the X-report/close-modal/
|
||||||
|
shift-history UI (it's already in the signed payload + printed Z); a validations/leakage
|
||||||
|
**report** (per program/user/day) under [[reporting-analytics]].
|
||||||
|
|
||||||
|
## Merchant scan input — DECIDED 2026-07-13: barcode scanner on the web/desktop app; camera paths POSTPONED
|
||||||
|
|
||||||
|
**v1 (in force):** the merchant scans with a **USB/HID barcode scanner** into the `/validate`
|
||||||
|
screen on the web (or desktop) app — the scanner types the 11-digit id + Enter into the
|
||||||
|
autofocused input, exactly like the booth. Hand-keying is the zero-hardware fallback; the
|
||||||
|
[[ticket-encoding|Luhn check digit]] catches typos. The park site is expected to equip the
|
||||||
|
bar/lavazh station accordingly — no phone-camera path for now.
|
||||||
|
|
||||||
|
**Postponed (evaluated 2026-07-13, both viable, deliberately deferred):**
|
||||||
|
|
||||||
|
1. **Web camera scanning** — `BarcodeDetector` (Chromium/Android native) + the `barcode-detector`
|
||||||
|
polyfill on **zxing-wasm** (Apache/MIT — license-clean, bundles offline). Two prerequisites
|
||||||
|
killed it for now: (a) `getUserMedia` needs a **secure context** — a merchant phone on
|
||||||
|
`http://<booth-ip>` gets NO camera, so the appliance needs a TLS story (realistically a
|
||||||
|
self-signed CA minted on the booth + one-time cert install per device — fold into the
|
||||||
|
[[booth-deploy-networking|reverse-proxy]] plan); (b) Code128 via phone camera on thermal
|
||||||
|
paper decodes poorly — would want the **QR-of-ticket-id** addition first (the ESC/POS driver
|
||||||
|
already has `qrCode()`; `renderTicket` is a one-line change — still a good idea whenever any
|
||||||
|
camera path revives).
|
||||||
|
2. **Tauri Android merchant app** (a SECOND small Tauri target, e.g. `apps/validator` — NOT an
|
||||||
|
extension of [[desktop-shell-tauri|apps/desktop]], which is a booth kiosk hardwired to
|
||||||
|
localhost:3000): Tauri v2 mobile + the official `barcode-scanner` plugin (ML Kit — reads
|
||||||
|
Code128 well natively, and the tauri:// origin is secure so the TLS problem vanishes).
|
||||||
|
Costs that drove the postponement: Android SDK/NDK + Rust-target build infra (+CI), APK
|
||||||
|
sideload distribution/updates to merchant devices, effectively Android-only (iOS needs a
|
||||||
|
paid signing account), and it needs the configurable-server-URL work the desktop shell also
|
||||||
|
wants. Revisit if the owner issues dedicated Android tablets to merchants.
|
||||||
|
|
||||||
## Model: a discount is a signed event, applied at fee time
|
## Model: a discount is a signed event, applied at fee time
|
||||||
|
|
||||||
A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason
|
A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ Mirrored networking is necessary but **not sufficient** — these still bit us:
|
|||||||
- **`localhost` → IPv6 first.** `localhost` resolves to `::1`, but the backend binds IPv4
|
- **`localhost` → IPv6 first.** `localhost` resolves to `::1`, but the backend binds IPv4
|
||||||
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
|
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
|
||||||
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
|
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
|
||||||
|
- **Windows-side listeners collide with WSL binds — INVISIBLY (2026-07-13).** Under mirrored
|
||||||
|
mode, a process listening on the WINDOWS side makes the same port `EADDRINUSE` inside WSL,
|
||||||
|
but it never appears in Linux `ss`/`lsof` — the port looks free yet won't bind. Bit us as
|
||||||
|
"tauri dev: Could not connect to http://localhost:5173 after 180s": a DIFFERENT React app's
|
||||||
|
dev server running on the Windows side held `::1:5173`, so the WSL Vite silently
|
||||||
|
auto-incremented to 5174 while Tauri's `devUrl` is the FIXED string `http://localhost:5173`
|
||||||
|
in `tauri.conf.json` (it cannot follow the auto-increment). Diagnose from WSL with
|
||||||
|
`powershell.exe -NoProfile -Command "Get-NetTCPConnection -LocalPort 5173 -State Listen"`
|
||||||
|
(then `Get-Process -Id <OwningProcess>`); kill with `taskkill.exe /PID <pid> /F`. Guard:
|
||||||
|
`strictPort: true` in the web `vite.config` so the mismatch fails in a second with a clear
|
||||||
|
error instead of a 3-minute hang on the wrong port.
|
||||||
|
|
||||||
## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug)
|
## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: reference
|
type: reference
|
||||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-06
|
updated: 2026-09-02
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -282,24 +282,38 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
||||||
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one.
|
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one. **Get this
|
||||||
|
right in the command itself** — it's a plain field in `periphery.config.toml` on the host, so a
|
||||||
|
typo/placeholder here needs a config edit + agent restart to fix, NOT a rename in Core's UI
|
||||||
|
(which only relabels Core's record, not the agent's real identity — gotcha #12 below).
|
||||||
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
|
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
|
||||||
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
||||||
the proxy. (Gotcha #7 below.)
|
the proxy. (Gotcha #7 below.)
|
||||||
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
||||||
(singular); `root_directory` must be a path `admin` can write. **⚠ VERIFY THIS after install —
|
(singular).
|
||||||
Periphery v2.2.0's installer writes `root_directory = "/etc/komodo"` even with `--user`**
|
|
||||||
(bit the lab box 2026-07-07: panic `Failed to write private key pem to "/etc/komodo/keys/
|
|
||||||
periphery.key" … Permission denied`, crash-loop until systemd gives up). Fix + restart:
|
|
||||||
```bash
|
|
||||||
sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
|
|
||||||
systemctl --user reset-failed periphery && systemctl --user restart periphery
|
|
||||||
```
|
|
||||||
NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
|
|
||||||
`systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
|
|
||||||
|
|
||||||
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
|
> ⚠ **ALWAYS CHECK THIS — every install so far has hit it (lab box 2026-07-07, booth `park-2`
|
||||||
**OK/green** in Core → Servers. Then **delete the onboarding key**.
|
> 2026-09-02).** `root_directory` must be a path `admin` can write, but **Periphery's installer
|
||||||
|
> writes `root_directory = "/etc/komodo"` even with `--user`** (still true as of v2.3.3). Result:
|
||||||
|
> panic `Failed to write private key pem to "/etc/komodo/keys/periphery.key" … Permission denied`,
|
||||||
|
> crash-loop until systemd gives up (`Start request repeated too quickly`).
|
||||||
|
>
|
||||||
|
> **Fix + restart:**
|
||||||
|
> ```bash
|
||||||
|
> sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
|
||||||
|
> systemctl --user reset-failed periphery && systemctl --user restart periphery
|
||||||
|
> ```
|
||||||
|
> NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
|
||||||
|
> `systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
|
||||||
|
>
|
||||||
|
> **➜ Do not stop here once it's green.** This fix only gets Periphery *running* — the Stack still
|
||||||
|
> isn't deployed. Immediately continue to **verify below, then §7b**.
|
||||||
|
|
||||||
|
**Verify:** `systemctl --user status periphery` → active; the server **`park-buzi`** appears and
|
||||||
|
goes **OK/green** in Core → Servers. Then **delete the onboarding key**.
|
||||||
|
|
||||||
|
**➜ Next step is §7b below — the Stack itself is not deployed yet.** A green Server in Core just
|
||||||
|
means the agent connected; it runs nothing until you add the Registry/Git accounts and deploy.
|
||||||
|
|
||||||
### 7b. Deploy the Stack (in Core — by hand once, then code)
|
### 7b. Deploy the Stack (in Core — by hand once, then code)
|
||||||
|
|
||||||
@@ -482,8 +496,36 @@ works; the desktop app is a separate workstream.
|
|||||||
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
|
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
|
||||||
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
|
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
|
||||||
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
|
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
|
||||||
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`.
|
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`. Hit
|
||||||
|
on every install so far (lab box 2026-07-07, booth `park-2` 2026-09-02, still on v2.3.3) —
|
||||||
|
**check this first** whenever a fresh Periphery install crash-loops; see the boxed callout in
|
||||||
|
§7a for the fix. Easy to fix-and-move-on without realizing the Stack still isn't deployed —
|
||||||
|
§7a's fix only starts the agent, §7b deploys the Stack.
|
||||||
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
|
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
|
||||||
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
|
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
|
||||||
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
|
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
|
||||||
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
|
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
|
||||||
|
12. **Renaming a Server in Core's UI does NOT change the agent's actual identity.**
|
||||||
|
`connect_as` is a plain field persisted in the agent's own
|
||||||
|
`~/.config/komodo/periphery.config.toml` — Core's UI rename only relabels Core's *record*,
|
||||||
|
the agent keeps re-announcing under its original `connect_as` on every reconnect. Symptom (hit
|
||||||
|
2026-08-30, lab box): a server named via a leftover template placeholder in the install
|
||||||
|
command kept reappearing in Core no matter how many times it was renamed there, while the
|
||||||
|
intended name sat permanently NOT OK (nothing was ever checking in as that name). **Fix: edit
|
||||||
|
`connect_as` directly in `periphery.config.toml` on the host, then `systemctl --user restart
|
||||||
|
periphery`** — no reinstall/re-onboarding needed. Delete the stray old-name Server record in
|
||||||
|
Core afterward. Lesson: always double-check `--connect-as` is a REAL name (never leave a
|
||||||
|
template placeholder like `<new-server-name>` in a copy-pasted install command) — Core will
|
||||||
|
happily create a server with that literal string.
|
||||||
|
13. **Upgrading an already-installed Periphery is: re-run the same installer, unchanged
|
||||||
|
`--connect-as`.** No separate update mechanism, no update-only flag. The installer script
|
||||||
|
explicitly skips rewriting `periphery.config.toml` if one already exists ("Config already
|
||||||
|
exists, skipping...") — it only stops the service, replaces the binary, and restarts — so a
|
||||||
|
re-run is **config-preserving** and a fresh/dummy `--onboarding-key` value on that re-run is
|
||||||
|
simply unused (confirmed against Komodo's own `setup-periphery.py` source, 2026-08-30; no
|
||||||
|
Periphery-specific breaking changes between v2.2.0 and v2.3.2 per Komodo's release notes).
|
||||||
|
Verified end-to-end on `art-docker-station` (lab, dry run) then `park-buzi` (live booth,
|
||||||
|
2026-08-30): same command as §7a step 2, same `--connect-as`, app containers untouched
|
||||||
|
throughout (Periphery restarting itself never touches the already-running compose stack).
|
||||||
|
**Always dry-run a version bump on a lab/dev box before a live booth**, even with a clean
|
||||||
|
release-notes check — this project only had one lab box to test against and used it first.
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, cloud, saas, multi-tenant, monitoring, netbird, threat-model, offline-first]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-07-13
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cloud service — multi-tenant SaaS for fleet monitoring & control
|
||||||
|
|
||||||
|
> **Status: postponed (2026-07-13).** Captured as context, not a commitment. This records an
|
||||||
|
> early requirements/architecture discussion so it isn't re-derived from scratch later. No app
|
||||||
|
> code, no schema. Two of the initial requirements were **corrected in-discussion** (see
|
||||||
|
> "Corrections" below) — read those before treating any first-pass answer as settled.
|
||||||
|
|
||||||
|
## The idea
|
||||||
|
|
||||||
|
The offline backup model we ship today is the right **tradeoff for offline sites** and stays.
|
||||||
|
On top of it, the user wants an **online, multi-tenant SaaS** — the "**cloud service**" — that
|
||||||
|
subscribing park sites connect to for **real-time (link-up) monitoring**: the signed ledger,
|
||||||
|
device status, financial reports, and whatever else a site reports. One **admin owns more than
|
||||||
|
one site** (a portfolio). The cloud also **custodies per-site secrets**. Business model: recurring
|
||||||
|
per-site monthly/yearly fee — a revenue line the offline appliance alone can't produce.
|
||||||
|
|
||||||
|
This is the customer-facing evolution of the off-site control plane that [[fleet-deployment-komodo]]
|
||||||
|
already stood up (**Komodo Core**, **NetBird** mesh, **Gitea** registry). Much of the transport and
|
||||||
|
Tier-0 reasoning there carries over directly; this page is about turning that internal ops plane into
|
||||||
|
a **multi-tenant product**.
|
||||||
|
|
||||||
|
## The four hard tensions (what makes a naïve SaaS wrong here)
|
||||||
|
|
||||||
|
The booth's two governing forces ([[offline-first]], [[threat-model]]) plus the signed ledger
|
||||||
|
([[append-only-event-chain]]) make the "obvious" SaaS shape wrong. Four tensions dominate:
|
||||||
|
|
||||||
|
1. **Offline-first vs. real-time monitoring.** The cloud must **never be in the critical path** of
|
||||||
|
entry/exit/payment/barrier ([[offline-first]]). It is a **read-mostly mirror + control-plane**, fed
|
||||||
|
by the booth when the link is up, tolerant of hours/days offline, and unable to block booth
|
||||||
|
operation by being down. "Real-time" = *near*-real-time when up, **gracefully stale** when not —
|
||||||
|
and the UI must show staleness **honestly** (last-seen everywhere), never paint a dark site green.
|
||||||
|
|
||||||
|
2. **The signed ledger must stay *verifiable* in the cloud, not merely displayed.** If subscribers
|
||||||
|
see "their ledger" in the cloud, the cloud copy must be **re-verified server-side** — re-check the
|
||||||
|
hash chain + signatures on ingest, flag gaps/breaks/forks loudly. The [[threat-model|operator-as-
|
||||||
|
adversary]] extends upward: an operator may want the cloud *not* to see certain events, so the sync
|
||||||
|
must be **gap-evident** (sequence continuity). This is both the anti-tamper mechanism **and** a
|
||||||
|
headline feature — *"we can prove your revenue record wasn't altered, even by your own night
|
||||||
|
shift."* See [[reconciliation]] (this is reconciliation, productised).
|
||||||
|
|
||||||
|
3. **Secrets for every site — the scariest requirement.** A central secret store for hundreds of
|
||||||
|
sites is a single juicy target. The custody boundary must be deliberate — see "Secrets boundary".
|
||||||
|
|
||||||
|
4. **Multi-tenancy under operator-as-adversary — now at two levels.** One admin, many sites ⇒ a new
|
||||||
|
**portfolio-owner** role *above* the existing per-site roles ([[local-jwt-auth]] admin/operator/
|
||||||
|
cashier/readonly). Row-level tenant isolation must be **airtight** — a bug now leaks *another
|
||||||
|
company's* revenue, not just an intra-site escalation. Every row carries `tenant_id` + `site_id`,
|
||||||
|
non-optional in the query path (not a filter someone can forget). **Cloud identity is separate from
|
||||||
|
booth-local auth** — the booth keeps its offline JWT/bcrypt login untouched; a site never
|
||||||
|
authenticates its *users* against the cloud (that would break [[offline-first]]).
|
||||||
|
|
||||||
|
## Secrets boundary (settled-in-principle 2026-07-13)
|
||||||
|
|
||||||
|
User confirmed the cloud custodies **three** classes — and **not** the crown jewel:
|
||||||
|
|
||||||
|
| Class | Cloud custodies? | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sync/connection creds + ledger **public** (verify) key | ✅ yes | Per-site uplink credential + the public half to *verify* signatures. Smallest blast radius. |
|
||||||
|
| **Device/controller passwords** (Dingtian `relay_pw`, camera creds, push tokens) | ✅ yes, as **escrow** | Solves the real pain: lost `relay_pw` after a DB reset ([[dingtian-relay]]). See escrow rules below. |
|
||||||
|
| App/admin identity (portfolio login) | ✅ yes | Cloud-side identity for portfolio admins. Separate from booth-local auth. |
|
||||||
|
| Ledger **signing** key / [[atecc608\|ATECC608]] private key, LUKS/TPM material | ❌ **never** | Centralising the signer **kills the anti-fraud model** ([[append-only-event-chain]], [[hardware-signer-options]]). The user did **not** pick this. |
|
||||||
|
|
||||||
|
**How device-password escrow must work (so it earns its keep instead of becoming the breach):**
|
||||||
|
|
||||||
|
- **Envelope encryption, per-tenant DEK**, DEKs wrapped by a KMS master key; a DB dump is ciphertext,
|
||||||
|
every decrypt is KMS-audited.
|
||||||
|
- The cloud is an **escrow, not an operational credential store**. Its job is "**give the booth back
|
||||||
|
its `relay_pw`** after a wipe," *not* "the cloud logs into the Dingtian." Decryption happens **at the
|
||||||
|
booth** (booth fetches its own wrapped blob, unwraps locally); ideally the cloud never holds
|
||||||
|
plaintext device secrets in memory. This keeps the [[dingtian-http-api-unauthenticated|unauthenticated-
|
||||||
|
CGI]] exposure host-local.
|
||||||
|
- **The booth threat model applies upward:** writes to escrow are append/version ops the operator
|
||||||
|
can't silently rewrite; reads are logged where the operator can't scrub them.
|
||||||
|
- Sellable as: *"your device credentials survive any wipe, encrypted so even we can't read them in
|
||||||
|
bulk."*
|
||||||
|
|
||||||
|
## Corrections made in-discussion (2026-07-13) — read these
|
||||||
|
|
||||||
|
The first pass argued *against* the user's two boldest choices ("cloud reaches into the booth";
|
||||||
|
implicitly, "no remote barrier open"). **The user corrected both, and the corrections stand.**
|
||||||
|
|
||||||
|
### Correction 1 — NetBird already solves the isolation objection
|
||||||
|
|
||||||
|
Initial worry: a cloud tunnel *into* the booth is a new inbound attack surface on every site. **But
|
||||||
|
park-buzi is already monitored remotely over a NetBird private mesh** (WireGuard) — the same
|
||||||
|
mesh [[fleet-deployment-komodo]] uses. The booth **dials out** to join the overlay; **nothing is
|
||||||
|
exposed** on the booth PC. So "cloud reaches booth" is the booth-dialed reverse-channel pattern
|
||||||
|
**already in production**, not a new hole. The objection is **withdrawn.** What it *shifts* rather than
|
||||||
|
removes:
|
||||||
|
|
||||||
|
- Trust moves to the **overlay's identity/ACL layer**: "cloud can reach the booth" now means "any
|
||||||
|
peer the mesh authorizes can reach the booth host." **Mesh ACLs must enforce the same tenant
|
||||||
|
isolation as the app layer** — site A's admin never gets a route to site B's booth. Multi-tenant
|
||||||
|
isolation in a different hat.
|
||||||
|
- **The access-controller VLAN still holds:** the mesh terminates at the **host**, not the controller
|
||||||
|
segment. A cloud peer talks to the booth API; the **booth** talks to the Dingtian/UHPPOTE
|
||||||
|
([[network-isolation]], [[access-direction-is-per-relay]]). The cloud never gets an L3 route to the
|
||||||
|
UDP relay.
|
||||||
|
- **NetBird's control plane joins the trust base** (self-hosted = another service to harden; their
|
||||||
|
SaaS = a third party who can authorize peers). A conscious call, not an architecture change.
|
||||||
|
|
||||||
|
### Correction 2 — remote barrier-open is *compatible* with barrier-not-a-door, and the unmanned future *requires* it
|
||||||
|
|
||||||
|
Initial worry: the cloud must never open a barrier. **The user's driver is the [[autonomous-direction|
|
||||||
|
unmanned-site]] future** — no operator on-site; if the exit reader or payment dies, *someone* must open
|
||||||
|
the barrier remotely rather than trap people ("we can't take hostages because a stupid device is not
|
||||||
|
responsive"). This is **right**, and it does **not** violate [[barrier-not-a-door]]:
|
||||||
|
|
||||||
|
- That rule was **never** "no remote open." It forbids driving the barrier as a **timed auto-close**
|
||||||
|
("open for N ms"); physical safety (loop-detector, anti-crush reversal) lives in the **barrier
|
||||||
|
firmware**. A remote human pressing "open" is an **intent expression** — exactly `pulseOpen`. It's
|
||||||
|
the [[fail-state-safety|exit-fails-open]] value, triggered by a remote human instead of a power-loss.
|
||||||
|
- Constrain the **how**, not the whether (this is the command where [[threat-model|operator-as-
|
||||||
|
adversary]] bites hardest — a remote "let this car out free" is the classic fraud):
|
||||||
|
- **Every remote open is a first-class signed ledger event** ([[append-only-event-chain]]): appended,
|
||||||
|
hash-chained, signed, with **actor** (which cloud identity), **reason code**, and **site/relay**.
|
||||||
|
Control power and audit come as a **pair** — the same discipline [[setup-relay-test]] and
|
||||||
|
[[booth-exit-flow|audited re-open]] already apply locally.
|
||||||
|
- A **distinct, high-privilege capability**, not bundled into "monitoring" — a readonly portfolio
|
||||||
|
viewer can't open barriers.
|
||||||
|
- **The booth stays the enforcer:** cloud sends *intent*; the booth validates (for-me? authorized
|
||||||
|
peer? signed?) and issues `pulseOpen` to its own relay. Cloud never touches the relay.
|
||||||
|
- **Cloud can't be the *sole* egress path.** A fully unattended site needs a **local fail-open on
|
||||||
|
host-loss** + physical override too — offline-first means the cloud is a *convenience* remote-open
|
||||||
|
path, not the *only* one, or you've recreated "device down = hostages" one layer up.
|
||||||
|
|
||||||
|
> **Emergent tenet:** an unattended site is a **higher** safety bar than an attended one, not a lower
|
||||||
|
> one. Every local failure mode (barrier stuck, payment dead, network down) needs an answer that
|
||||||
|
> **doesn't require the cloud**; the cloud makes resolution *nicer*, not *possible*. Fold into
|
||||||
|
> [[autonomous-direction]] and [[fail-state-safety]] when this is picked up.
|
||||||
|
|
||||||
|
## What looks straightforward (agreed quickly)
|
||||||
|
|
||||||
|
- **Transport:** the existing **NetBird overlay** (booth-dialed, nothing exposed) — not a bespoke
|
||||||
|
channel. Reuses [[fleet-deployment-komodo]].
|
||||||
|
- **Sync:** **booth-push, verify-on-ingest** — booth streams ledger + device telemetry
|
||||||
|
([[device-events]]) + snapshot metadata + financial data outbound; cloud **re-verifies the chain +
|
||||||
|
signatures** and flags gaps.
|
||||||
|
- **Staleness first-class in the UI:** every site tile shows last-seen; a dark site is visibly stale.
|
||||||
|
- **DB:** almost certainly **PostgreSQL** — already the named deferred sync target ([[drizzle-orm]],
|
||||||
|
[[technology-stack]]); the Drizzle schemas are meant to port to it.
|
||||||
|
|
||||||
|
## The genuinely open questions (postponed — pick up here)
|
||||||
|
|
||||||
|
1. **What does "real-time" mean to the buyer?** Live-ish (seconds, streaming uplink → **heavier
|
||||||
|
booth**) vs. every-few-minutes rollups (cheap, still sells "monitoring"). This gap is **most of the
|
||||||
|
engineering cost** and drives how heavy the booth-side uplink must be.
|
||||||
|
2. **Financial reports computed where?** Cloud **re-derives** revenue from the verified ledger →
|
||||||
|
independently trustworthy (*"we don't take the booth's word for it"*) but the cloud must implement
|
||||||
|
the [[tariff]] pricing logic. Vs. booth sends **pre-computed rollups** (cheaper, but trusts the
|
||||||
|
booth's math). Lean: **cloud re-derives** — the whole point of [[threat-model|operator-adversary]]
|
||||||
|
is not to trust the site's self-report ([[reporting-analytics]] is already "projections over the
|
||||||
|
signed log").
|
||||||
|
3. **Hosting + licensing.** The booth stack is deliberately all-MIT/Apache/BSD ([[technology-stack]]);
|
||||||
|
a SaaS the user **hosts** has more freedom (like the [[fleet-deployment-komodo|Komodo GPL]] /
|
||||||
|
[[vision-service|AGPL]] self-host exceptions) — but anything that ever ships **on-premise** re-binds
|
||||||
|
the constraint.
|
||||||
|
4. **Custodianship is leverage *and* liability.** Holding other companies' financial records + device
|
||||||
|
secrets is what makes the subscription **sticky** — and what pulls in **backups, retention policy,
|
||||||
|
breach disclosure, data-residency**. A deliberate "yes, we want to be the custodian" call, with the
|
||||||
|
obligations that implies. (Cloud/Core is a **Tier-0 asset** for the whole fleet — the same bar
|
||||||
|
[[fleet-deployment-komodo]] already sets for Core.)
|
||||||
|
|
||||||
|
## Relates
|
||||||
|
|
||||||
|
- [[fleet-deployment-komodo]] — the off-site control plane (Komodo Core + NetBird) this productises;
|
||||||
|
Core-as-Tier-0 reasoning carries over.
|
||||||
|
- [[autonomous-direction]] — the unmanned future that *drives* remote barrier-open (Correction 2).
|
||||||
|
- [[reconciliation]] — the cloud *is* reconciliation, productised (verify-on-ingest, gap-evidence).
|
||||||
|
- [[append-only-event-chain]] / [[hardware-signer-options]] — why the **signing** key stays on the
|
||||||
|
booth even as everything else centralises.
|
||||||
|
- [[threat-model]] / [[offline-first]] — the two forces every tension above traces back to.
|
||||||
|
- [[network-isolation]] / [[access-direction-is-per-relay]] — why the mesh terminates at the host.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, decisions, desktop, frontend]
|
tags: [parking, decisions, desktop, frontend]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-21
|
updated: 2026-09-04
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,20 +140,80 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
|
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
|
||||||
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
|
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
|
||||||
prod build and the desktop build (same SPA).
|
prod build and the desktop build (same SPA).
|
||||||
- **`VITE_API_BASE` wired to the environment:** `apps/web/.env.production` (committed, non-secret,
|
- **Mixed content blocks http(s)/ws(s) from the webview — fixed 2026-09-03.** Even with
|
||||||
allow-listed in `.gitignore`) sets `VITE_API_BASE=http://127.0.0.1:3000`, auto-loaded by
|
`VITE_API_BASE` correctly set (below), login still failed with WebKit's generic `"Load failed"`.
|
||||||
`vite build` (which the desktop bundle runs). So the desktop build targets Fastify with no manual
|
Root cause is a separate, deeper issue: WebKitGTK treats `tauri://localhost` as a **secure
|
||||||
export; the browser-served-by-Fastify build should override to `""`.
|
origin**, so a plain `http://127.0.0.1:3000` `fetch()` — or a `ws://127.0.0.1:3000` WebSocket —
|
||||||
|
from inside it is blocked as **mixed content**. (Nearest upstream ticket:
|
||||||
|
[bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934) — note that one is
|
||||||
|
specifically about *loopback* addresses from https pages; a LAN IP such as `192.168.1.50:3000`
|
||||||
|
would stay mixed content even if it were fixed, so the plugin route below is the right
|
||||||
|
architecture for a remote booth regardless, not a stopgap.) `connect-src` in the
|
||||||
|
CSP does **not** override this — it's a different browser security layer entirely, so the request
|
||||||
|
never even reaches the network layer to be diagnosable via server logs. **Fix:** two Tauri plugins
|
||||||
|
route the SPA's traffic through Tauri's native (Rust) side instead of the webview's own
|
||||||
|
fetch/WebSocket, which sidesteps the check entirely:
|
||||||
|
- **`tauri-plugin-http`** — `apps/web/src/lib/origin.ts`'s `platformFetch()` dynamically imports
|
||||||
|
`@tauri-apps/plugin-http`'s `fetch` (a genuine drop-in for the standard Fetch API) inside Tauri,
|
||||||
|
plain `fetch` in the browser. `api.ts` and `logger.ts` both call `platformFetch` instead of the
|
||||||
|
global `fetch` now.
|
||||||
|
- **`tauri-plugin-websocket`** — NOT a drop-in (async `connect()`/listener-callback API, not
|
||||||
|
`onopen`/`onmessage`/sync `send`/`close`). `apps/web/src/lib/platform-ws.ts` adapts it behind
|
||||||
|
the same native-`WebSocket`-shaped interface `use-live-feed.ts` already expects (hardened for
|
||||||
|
reconnect backoff + StrictMode double-invoke), so that hook needed zero changes.
|
||||||
|
- Capability grants: `apps/desktop/src-tauri/capabilities/default.json` adds `websocket:default`
|
||||||
|
and a scoped `http:default` (`allow: [{url: "http://127.0.0.1:3000"}, {url:
|
||||||
|
"http://localhost:3000"}]`) — deny-by-default, matching the CSP's existing allowlist.
|
||||||
|
- `logger.ts`'s `flushBeacon()` (page-hide `navigator.sendBeacon`) is a native browser API with no
|
||||||
|
Tauri equivalent — it still drops silently in the desktop shell on unload. Accepted: the regular
|
||||||
|
4s-interval flush (now fixed, routes through `platformFetch`) covers the common case.
|
||||||
|
- **Gotcha (found immediately after shipping the above): the native WS plugin sends no `Origin`
|
||||||
|
header.** `tauri-plugin-websocket`'s `connect()` runs on Tauri's Rust side, not inside the
|
||||||
|
webview page — there's no page context to auto-attach `Origin: tauri://localhost` the way a real
|
||||||
|
browser `WebSocket` would. (The **HTTP** plugin, by contrast, *does* attach that Origin itself —
|
||||||
|
`tauri-plugin-http/src/commands.rs`, "ensure we have an Origin header set" — so only the WS
|
||||||
|
path needs the explicit header.) The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`)
|
||||||
|
treats a missing Origin as untrusted and 403s the handshake before touching auth — the live feed
|
||||||
|
showed **"JASHTË LINJË"** (offline) in the desktop app while the browser showed **"LIVE"**, same
|
||||||
|
server, same moment. **Fix (two parts, both needed):** `platform-ws.ts`'s `connect()` call now
|
||||||
|
passes `{ headers: { Origin: "tauri://localhost" } }` explicitly; separately, `komodo/
|
||||||
|
resources.toml`'s booth Stacks had `WS_ALLOWED_ORIGINS=` **empty** in production (despite
|
||||||
|
`.env.example` documenting `tauri://localhost,http://tauri.localhost` as required) — even a
|
||||||
|
correct Origin header is useless if the server's allowlist doesn't include it. Both fixed
|
||||||
|
together; a `resources.toml` change still needs a Komodo sync + Stack redeploy to take effect on
|
||||||
|
a live booth, it isn't automatic from a git push alone — and see [[fleet-deployment-komodo]] for
|
||||||
|
a real ResourceSync-branch gotcha this exact fix ran into.
|
||||||
|
- **No way to see the installed app's own version (found + fixed 2026-09-03).** `VersionBadge` in
|
||||||
|
`router.tsx` shows the *server's* `<branch>-<sha>` (from `/api/version`, gated `site:read`) — but
|
||||||
|
nothing showed the *desktop client's* own version. An operator debugging a stuck update had no way
|
||||||
|
to confirm which build was actually installed short of reading the update-available prompt's
|
||||||
|
target version and inferring backwards. Fixed with `DesktopVersionBadge`, next to `VersionBadge`:
|
||||||
|
calls `@tauri-apps/api/app`'s `getVersion()` (the real running app's version, baked in from
|
||||||
|
`tauri.conf.json` — synced to the git tag by `release.yml`, see the version-drift gotcha above),
|
||||||
|
no-ops/renders nothing in a browser (`inTauri()` guard, now exported from `origin.ts` instead of
|
||||||
|
redefined a 4th time). `@tauri-apps/api` added as an explicit dependency (was only ever transitive
|
||||||
|
via the plugins).
|
||||||
|
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
|
||||||
|
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
|
||||||
|
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
|
||||||
|
`96fd97e` (2026-06-27), but that same change silently broke the **desktop** build, which was never
|
||||||
|
given its own override. Result: the desktop shell's `apiUrl()` returned a bare relative path
|
||||||
|
(`/api/auth/login`) to `fetch()` from a page loaded at `tauri://localhost` — WebKitGTK has no base
|
||||||
|
to resolve a relative URL against from a non-`http(s)` origin, and threw `DOMException: "The
|
||||||
|
string did not match the expected pattern."` on the first authenticated request (login). Login
|
||||||
|
worked fine in the browser (same-origin, no absolute URL needed) the whole time, which is what
|
||||||
|
made this easy to miss. **Fix:** `tauri.conf.json`'s `build.beforeBuildCommand` now sets
|
||||||
|
`VITE_API_BASE=http://127.0.0.1:3000` inline (`VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter
|
||||||
|
@parking/web build`) — process env vars override `.env.production` in Vite's load order, so this
|
||||||
|
overrides the shared file for the desktop build only, without touching it (the browser/booth build
|
||||||
|
still gets the empty value, unaffected). Verified: rebuilding with the override bakes
|
||||||
|
`127.0.0.1:3000` into the bundle; rebuilding without it stays clean/relative.
|
||||||
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
|
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
|
||||||
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
|
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
|
||||||
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
|
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
|
||||||
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
|
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
|
||||||
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
|
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
|
||||||
operation; updates are out-of-band). Endpoint is the **self-hosted Gitea** "latest release"
|
operation; updates are out-of-band). **WS origin:** the desktop window's origin
|
||||||
path — `https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json`
|
|
||||||
— which redirects to the newest tag's `latest.json` (published by `.gitea/workflows/release.yml`).
|
|
||||||
The updater GETs it (200 + manifest, or 204 = up-to-date), reads `platforms.linux-x86_64.
|
|
||||||
{signature,url}`, and downloads the signed installer. **WS origin:** the desktop window's origin
|
|
||||||
is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's
|
is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's
|
||||||
`WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in
|
`WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in
|
||||||
`apps/server/.env.example`).
|
`apps/server/.env.example`).
|
||||||
@@ -165,8 +225,27 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
produced `.deb`/`.rpm`/`.AppImage` **plus their `.sig` updater signatures**; full `turbo run build
|
produced `.deb`/`.rpm`/`.AppImage` **plus their `.sig` updater signatures**; full `turbo run build
|
||||||
lint` 14/14 green. *(This is the **updater** signing — distinct from OS-installer signing for
|
lint` 14/14 green. *(This is the **updater** signing — distinct from OS-installer signing for
|
||||||
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
||||||
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
- **Update-hosting endpoint (found broken, fixed 2026-09-03):** the endpoint originally pointed at
|
||||||
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
the **source repo's own** Gitea "latest release" redirect
|
||||||
|
(`.../mca/parking_solution/releases/latest/download/latest.json`) — but `mca/parking_solution` is
|
||||||
|
**private**, and the updater runs on offline-first field appliances with **no Gitea credentials**.
|
||||||
|
Every deployed update check was silently failing (swallowed by a `try/catch` in
|
||||||
|
`desktop-updater.ts`) — this was never field-verified, and it couldn't have worked as configured.
|
||||||
|
**Fix:** signed installers are now mirrored to a separate **public**, releases-only repo,
|
||||||
|
`mca/public_releases` (shared across apps in the org — see [[fleet-deployment-komodo]] sibling
|
||||||
|
infra), holding **only compiled installers, no source**. `tauri.conf.json`'s endpoint now points
|
||||||
|
there at a fixed `desktop-latest` tag (NOT that repo's generic "latest release" redirect, since
|
||||||
|
other apps publishing there would shadow ours — see the `desktop-latest` vs `desktop-<TAG>`
|
||||||
|
split below). `.gitea/workflows/release.yml` pushes to both repos: the private source repo (own
|
||||||
|
record) and the public mirror (what the updater and any human downloader actually use).
|
||||||
|
**Rejected alternative:** embedding a `read:repository` Gitea token in `tauri.conf.json`'s
|
||||||
|
updater `headers` so it could read the private repo directly — ruled out because that token would
|
||||||
|
ship inside every installed binary in the field, and this appliance's own threat model names the
|
||||||
|
**booth operator as the primary adversary** (see root `CLAUDE.md`); a leaked token scoped to the
|
||||||
|
whole private repo, with no cheap way to rotate it across appliances already in the field, was
|
||||||
|
judged worse than publishing installers-only.
|
||||||
|
- **Still deferred:** OS-level installer signing (Windows/macOS publisher trust) and the Windows
|
||||||
|
kiosk-browser fallback path.
|
||||||
|
|
||||||
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
|
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
|
||||||
|
|
||||||
@@ -174,7 +253,15 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
|
|||||||
|
|
||||||
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
|
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
|
||||||
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
|
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
|
||||||
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
|
and publishes a Gitea Release **on `mca/parking_solution` (source, own record) AND mirrors it to
|
||||||
|
`mca/public_releases`** (public, installers-only — see the update-hosting-endpoint entry above for
|
||||||
|
why). The mirror step uses a second token, `RELEASES_MIRROR_TOKEN`
|
||||||
|
(`write:repository`, scoped for pushing into `public_releases` only — a CI-side secret, never
|
||||||
|
shipped to any client, distinct from the embedded updater *pubkey*). It publishes two tags there:
|
||||||
|
`desktop-<TAG>` (versioned, permanent, for audit/rollback) and `desktop-latest` (moving — existing
|
||||||
|
assets deleted then re-uploaded each release, since Gitea has no per-app "latest" concept and this
|
||||||
|
repo is shared across apps). `latest.json`'s asset URL and `tauri.conf.json`'s updater endpoint
|
||||||
|
both point at `desktop-latest`. This is what the auto-updater actually consumes.
|
||||||
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
|
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
|
||||||
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
|
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
|
||||||
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
|
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
|
||||||
@@ -198,3 +285,173 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
|
|||||||
The unsigned CI build therefore overrides it off with
|
The unsigned CI build therefore overrides it off with
|
||||||
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
|
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
|
||||||
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
|
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
|
||||||
|
- **Gotcha (tag ≠ tauri.conf.json version — found + fixed 2026-09-03, v0.1.1).** The git tag
|
||||||
|
(`v0.1.1`) and `tauri.conf.json`'s own `"version"` field are two independent values with nothing
|
||||||
|
syncing them. Tauri bakes `"version"` into the bundle filename, the app's internal version, AND
|
||||||
|
what the updater compares against — NOT the git tag. Bumping only the tag (as the release
|
||||||
|
procedure implied) left the file at the prior `0.1.0`: the signed binary was built and named as
|
||||||
|
`0.1.0`, `latest.json` (built from `TAG`) correctly claimed `0.1.1`, and the updater found an
|
||||||
|
"update," downloaded it, then failed signature verification against a manifest that didn't
|
||||||
|
actually describe the file it pointed at. Compounded by a second bug (below) that made this
|
||||||
|
failure completely invisible to the operator. **Fix:** `release.yml` now has a "Sync
|
||||||
|
tauri.conf.json version to the git tag" step (`sed`-patches `"version"` from `TAG` right before
|
||||||
|
`tauri build`) — the checked-in value is now only a placeholder for local dev builds; every real
|
||||||
|
release derives its version from the tag automatically.
|
||||||
|
- **Gotcha (silent updater failure — found + fixed 2026-09-03).** `desktop-updater.ts`'s
|
||||||
|
`checkForDesktopUpdate` wrapped the ENTIRE check-download-install-relaunch sequence in one
|
||||||
|
`catch {}` that swallowed everything, by design, for the offline/no-server case. But that meant
|
||||||
|
a REAL failure after the operator already accepted the prompt (bad signature, corrupted
|
||||||
|
download, disk/permission error) failed exactly the same way as "endpoint unreachable" — no
|
||||||
|
error, no log, the app just silently reverted to the old version and re-showed the same "update
|
||||||
|
available" prompt on next launch, forever. This is what actually surfaced the tag-sync bug
|
||||||
|
above (download traffic visible, then nothing). Fixed by nesting `downloadAndInstall()` in its
|
||||||
|
own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch),
|
||||||
|
but a failure *after* the operator accepted now logs to the console instead of vanishing.
|
||||||
|
|
||||||
|
### Runtime-configurable backend origin — no more one-install-per-booth builds (2026-09-04)
|
||||||
|
|
||||||
|
**Problem:** `VITE_API_BASE` was a **build-time** Vite env var (`tauri.conf.json`'s
|
||||||
|
`beforeBuildCommand`), hardcoded to `http://127.0.0.1:3000`. The desktop shell is a single
|
||||||
|
generic `.deb`/`.AppImage` distributed via [[fleet-deployment-komodo|mca/public_releases]] — it is
|
||||||
|
**not** built per-booth — so a build-time backend address meant the installer could only ever talk
|
||||||
|
to a server on the same machine, and pointing an install at any other host (a remote appliance, a
|
||||||
|
different port) needed a full rebuild. **Fix:** the backend origin is now an **operator-entered,
|
||||||
|
runtime-persisted** value.
|
||||||
|
|
||||||
|
- **`ConnectScreen.tsx`** — shown by `App.tsx` BEFORE `fetchMe()`/`Login` whenever running inside
|
||||||
|
Tauri (`inTauri()`) and no backend URL is saved yet (first launch, or after "Change server").
|
||||||
|
Operator types a host, hits **Test** (`backend-config.ts`'s `testBackendUrl`, an unauthenticated-
|
||||||
|
from-the-client's-perspective `GET /api/version` probe — see the CSRF gotcha below for why that
|
||||||
|
route isn't actually public), then **Save & continue**.
|
||||||
|
- **`tauri-plugin-store`** persists the value (`backend-config.json` in the OS config dir,
|
||||||
|
`autoSave: true`) — survives restarts, is NOT `localStorage` (deliberately; matches the existing
|
||||||
|
server-persisted-preference pattern elsewhere in this app, and a real file is easier to inspect/
|
||||||
|
back up on an appliance). `origin.ts`'s `API_BASE` changed from a `const` to a `let`, set once via
|
||||||
|
`initApiBase()` (called by `App.tsx` before mount) and again via `setApiBase()` after the
|
||||||
|
ConnectScreen saves — no restart required to start using it.
|
||||||
|
- **CSP had to loosen, deliberately, to a narrower real boundary.** `connect-src` was
|
||||||
|
`'self' http://127.0.0.1:3000 ... ws://127.0.0.1:3000 ...`; an operator-chosen arbitrary LAN host
|
||||||
|
can't be named at build time, so it's now **`'self'` only** — meaning a raw `fetch()`/`WebSocket`
|
||||||
|
from the webview is blocked to EVERY origin, not just disallowed ones. This is intentional, not a
|
||||||
|
regression: all backend traffic already went through `tauri-plugin-http`/`tauri-plugin-websocket`
|
||||||
|
anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside**
|
||||||
|
`connect-src`'s jurisdiction entirely. The real access boundary moved to
|
||||||
|
`capabilities/default.json`'s `http:default` scope, which is now wildcarded
|
||||||
|
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed: the scope is a
|
||||||
|
URLPattern, and a pattern with no port matches only the scheme's *default* port, so `http://*`
|
||||||
|
covers `:80` (Caddy) while `http://*:*` is what covers `:3000`). `websocket:
|
||||||
|
default` already had no scope restriction. Net effect: **the app can now reach any host the
|
||||||
|
operator types in, and nothing else** — same shape of guarantee as before, just operator-directed
|
||||||
|
instead of build-directed.
|
||||||
|
- **"Change server"** — `router.tsx`'s `DesktopServerButton`, in the Setup nav bar next to
|
||||||
|
`DesktopVersionBadge` (both `inTauri()`-gated, invisible in the browser). Confirm-modal (reuses
|
||||||
|
the shared `Modal`, not a bespoke dialog) → `clearBackendUrl()` → reload, which drops back to
|
||||||
|
ConnectScreen. Deliberately not an inline editor: repointing a booth's app is a rare, deliberate
|
||||||
|
admin action, not a frequent setting — same reasoning as why logout is a plain action button with
|
||||||
|
no separate "are you sure" for THAT (this one gets a confirm because it also blows away the
|
||||||
|
session, unlike a normal logout-then-relogin against the same server).
|
||||||
|
- **Gotcha (found via research before shipping, not in the field — worth recording anyway): the
|
||||||
|
CSRF double-submit cookie is invisible to `document.cookie` on desktop.** `tauri-plugin-http`'s
|
||||||
|
`fetch()` doesn't run through the webview — it's dispatched to Tauri's Rust side and executed by
|
||||||
|
`reqwest`, which keeps its **own** cookie jar, entirely separate from WebKitGTK's. `Set-Cookie` on
|
||||||
|
a `tauri-plugin-http` response is stored in that reqwest jar and IS correctly re-sent by
|
||||||
|
reqwest on later requests (so plain session auth — GETs — silently worked) — but it is **never**
|
||||||
|
synced into the webview's own cookie store, so `document.cookie` on the `tauri://localhost` page
|
||||||
|
can never see it. Upstream: [tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045)
|
||||||
|
(open — asks for exactly this jar→webview sync) and
|
||||||
|
[#11518](https://github.com/tauri-apps/tauri/issues/11518) (closed, without adding a sync) — not
|
||||||
|
something fixable on our side by changing how/when we read the cookie. The reqwest jar itself
|
||||||
|
IS persisted (`.cookies` in the app cache dir), so a desktop session survives an app restart
|
||||||
|
just like the browser's 30-day cookie does. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf`
|
||||||
|
cookie via `document.cookie` to echo it in `X-CSRF-Token` (double-submit — see
|
||||||
|
[[local-jwt-auth]]), this meant **every mutating request from the desktop app was silently sending
|
||||||
|
no CSRF header at all**, pre-dating this runtime-URL change (it was equally true against the old
|
||||||
|
hardcoded `127.0.0.1:3000`) — caught now because widening the backend to "any host" was the
|
||||||
|
occasion to actually trace the desktop auth path end-to-end. **Fix, without touching
|
||||||
|
`assertCsrf()`'s verification logic at all:** the server's `sessionView()` (`routes/auth.ts`,
|
||||||
|
shared by `login` and `me`) now optionally echoes the CSRF token value in the JSON response body
|
||||||
|
(`csrfToken`) — the SAME value already set as the cookie, just a second channel to learn it. The
|
||||||
|
desktop client (`api.ts`) stashes that value in an in-memory-only variable (`desktopCsrfToken`,
|
||||||
|
never persisted — a fresh launch always re-learns it via login or `/api/auth/me`) and echoes THAT
|
||||||
|
instead of reading `document.cookie` when `inTauri()`. The actual cookie is still what
|
||||||
|
`assertCsrf()` checks server-side (and reqwest still sends it correctly, per above) — this only
|
||||||
|
fixes how the desktop *client* learns what value to put in the header, so browser behavior and
|
||||||
|
server verification are both completely unchanged.
|
||||||
|
|
||||||
|
### Live feed needs a WS *ticket*, not the cookie — and desktop logs never reached the server (2026-09-04, v0.1.6)
|
||||||
|
|
||||||
|
A retrospective of the 2026-09-03/04 run found that v0.1.4's Origin fix cleared only the **first**
|
||||||
|
of two gates in `routes/ws.ts`'s preHandler, and that the diagnostic channel everyone was staring
|
||||||
|
at was itself broken on desktop. Booth evidence: `docker logs park-2-server-1 | grep /api/ws` showed
|
||||||
|
a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connect rejected.
|
||||||
|
|
||||||
|
- **Gate two: `req.jwtVerify()` reads the HttpOnly `parking_token` cookie — which the WebSocket
|
||||||
|
plugin cannot send.** `tauri-plugin-websocket` is a bare tokio-tungstenite client with **no
|
||||||
|
cookie jar at all** (its source has no cookie handling); the cookie lives in
|
||||||
|
`tauri-plugin-http`'s reqwest jar and is HttpOnly besides, so JS can't copy it across either.
|
||||||
|
Origin OK + no cookie → 401 → reconnect forever. **Fix: a single-use WS ticket.** The desktop
|
||||||
|
client `POST`s `/api/ws/ticket` over normal HTTP auth (cookie + CSRF, which it CAN do) and gets
|
||||||
|
a 32-byte random ticket bound to its user, valid 30 s, single-use, in-memory only; it presents
|
||||||
|
it in an `x-ws-ticket` header on the handshake (`platform-ws.ts`), and the preHandler accepts
|
||||||
|
ticket-or-cookie *after* the Origin check, then does the same `report:read` role check for both.
|
||||||
|
A browser page can't set custom WebSocket headers, so the ticket path is unreachable from a
|
||||||
|
browser and adds no CSWSH surface. **Rejected:** echoing the JWT in the login body and sending
|
||||||
|
it as `Authorization: Bearer` (fastify-jwt would accept it) — that puts the session token in JS,
|
||||||
|
which HttpOnly exists to prevent; the ticket keeps it out. Verified locally with an 11-case
|
||||||
|
handshake script: ticket/no-cookie → 101 + hello; reused/bogus/absent → 401; ticket + bad Origin
|
||||||
|
→ 403; cookie path unchanged. **Field-verified 2026-09-04:** v0.1.6 on the park-2 booth against
|
||||||
|
image `stage-8fa66c9` shows **LIVE** — the first desktop build to do so.
|
||||||
|
- **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token
|
||||||
|
from `document.cookie` (null on desktop — the same jar split as above), so every
|
||||||
|
`POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops
|
||||||
|
failures by design (loop safety). Consequences: the 2026-09-03 "route update-failure logging
|
||||||
|
through logClient" fix wrote to a dead channel, and the v0.1.5 CSRF fix patched `api.ts` but not
|
||||||
|
`logger.ts`. **Fix:** the stash moved to a dependency-free `lib/desktop-csrf.ts` (so `logger.ts`
|
||||||
|
can read it without importing `api.ts`, which imports `logger.ts`), and the flush uses it when
|
||||||
|
`inTauri()`. `platform-ws.ts`'s connect failure now goes through `logClient` too (rate-limited
|
||||||
|
to one row/min — reconnects are every ≤10 s), instead of `console.error`, which only forwards at
|
||||||
|
debug/trace.
|
||||||
|
- **ConnectScreen probe now hits `/health`.** The v0.1.5 probe hit an auth-guarded route and
|
||||||
|
treated 401/403 as "ours" — any password-protected service on the LAN would have passed it, and
|
||||||
|
the comment claiming no unauthenticated route existed was wrong (`/health` has been there all
|
||||||
|
along). `/health` now also returns `app: "parking-system"`, and the probe requires both a 2xx
|
||||||
|
and that value.
|
||||||
|
- **Why every one of these was found in the field:** `tauri dev` loads `http://localhost:5173`,
|
||||||
|
not `tauri://localhost`, so the relative-URL error, mixed content, the missing Origin, and the
|
||||||
|
cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle
|
||||||
|
locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation,
|
||||||
|
and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`).
|
||||||
|
|
||||||
|
### In-app update never worked: the manifest only described the AppImage, the booths run the .deb (2026-09-04)
|
||||||
|
|
||||||
|
Every self-update attempt from v0.1.0 through v0.1.6 ended the same way — prompt, download
|
||||||
|
traffic, then nothing, and the same prompt again next launch. The version-sync (v0.1.2) and
|
||||||
|
error-logging fixes were real but not the cause. **Root cause:** `tauri-plugin-updater` resolves
|
||||||
|
the download target as `{os}-{arch}-{installer}` **first** (`linux-x86_64-deb` here — the
|
||||||
|
bundler stamps `__TAURI_BUNDLE_TYPE_VAR_DEB` into the `.deb`'s binary, verified with `strings`
|
||||||
|
on a local build), then falls back to bare `{os}-{arch}`. `release.yml`'s `latest.json` carried
|
||||||
|
**only** `linux-x86_64`, pointing at the **AppImage**. So a `.deb` install found the update,
|
||||||
|
downloaded the AppImage, verified its signature (which was correct — for the AppImage), then
|
||||||
|
handed the bytes to `install_deb()`, whose first line checks `infer::archive::is_deb(bytes)` and
|
||||||
|
returns `InvalidUpdaterFormat`. Before v0.1.6 that error never reached the server (the desktop
|
||||||
|
log channel was itself broken — see the previous section), so it looked like a silent no-op.
|
||||||
|
Sources: `tauri-plugin-updater-2.10.1/src/updater.rs` (`get_urls`, `install_inner`,
|
||||||
|
`install_deb`), `tauri-utils/src/platform.rs` (`bundle_type`).
|
||||||
|
|
||||||
|
- **Fix:** `latest.json` now carries one signed entry per installer — `linux-x86_64-deb`,
|
||||||
|
`linux-x86_64-rpm` (when built), and bare `linux-x86_64` for the AppImage — assembled by a
|
||||||
|
small Node script in the workflow (the `.sig` files for `.deb`/`.rpm` were already being
|
||||||
|
produced and uploaded, just never referenced).
|
||||||
|
- **What a booth update now looks like:** prompt → download → **polkit password dialog**
|
||||||
|
(`pkexec dpkg -i`) → relaunch into the new version. The prompt is deliberate, not a wart: the
|
||||||
|
package is root-owned in `/usr/bin`, and under the [[threat-model]] the operator must not be
|
||||||
|
able to replace the app silently; whoever brings the box online for an update is the admin.
|
||||||
|
Cancelling the dialog leaves the old version running and logs
|
||||||
|
`desktop_update_install_failed` to `app_logs`.
|
||||||
|
- **Rejected:** switching booths to the AppImage so updates need no privilege. It would work
|
||||||
|
(the updater rewrites the AppImage in place), but the binary would then be operator-writable,
|
||||||
|
it needs FUSE on the appliance image, and launcher/autostart integration becomes manual —
|
||||||
|
three regressions to avoid one password prompt.
|
||||||
|
- **Judgment note for the retrospective:** three fixes were shipped against this symptom
|
||||||
|
without reading the updater's install path once. The whole chain is ~60 lines of vendored
|
||||||
|
Rust in `~/.cargo/registry`; it names the exact failure (`InvalidUpdaterFormat`).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-07
|
updated: 2026-09-03
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -192,3 +192,32 @@ FILE is read from, each stack's `branch` picks its compose files, `TAG` picks th
|
|||||||
secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the
|
secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the
|
||||||
USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0
|
USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0
|
||||||
root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a).
|
root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a).
|
||||||
|
|
||||||
|
## ResourceSync branch drift — the exact gotcha this page already warned about (2026-09-03)
|
||||||
|
|
||||||
|
This page's own §"park-lab" note (2026-07-07) already spelled it out: *"the ResourceSync's own
|
||||||
|
branch only governs where the FILE is read from"* — independent of any `[[stack]]`'s own `branch`
|
||||||
|
field. It bit anyway. `resource-sync-park-systems` in Komodo Core was pointed at **`dev`**, while
|
||||||
|
`park-buzi` and `park-2` are `stage`-tier Stacks (`branch = "stage"`, pinned `TAG=stage-<sha>`, per
|
||||||
|
the promotion-tiers model above). `resources.toml` had been byte-identical on `dev` and `stage`
|
||||||
|
since park-buzi's Stack was first written, so this had **zero observable effect for months** — until
|
||||||
|
a desktop-app debugging session (see [[desktop-shell-tauri]]) landed 9 real commits on `dev`
|
||||||
|
(including a `WS_ALLOWED_ORIGINS` fix) that were never merged to `stage`, creating the first genuine
|
||||||
|
divergence between the two branches.
|
||||||
|
|
||||||
|
**Symptom:** merged `dev` → `stage`, pushed, bumped `TAG` in `resources.toml` on `stage`, committed,
|
||||||
|
pushed — then destroyed + recreated the `park-2` Stack in Komodo Core and it STILL came back running
|
||||||
|
the old image. Every sync was silently re-reading `resources.toml` from `dev` (which still had the
|
||||||
|
stale `TAG`), overwriting the correct value just committed on `stage`. No error, no warning — the
|
||||||
|
sync just quietly did what it was configured to do, from the wrong branch.
|
||||||
|
|
||||||
|
**Fix:** pointed `resource-sync-park-systems` at `stage` in Komodo Core's UI (Sync config → branch
|
||||||
|
field), then re-synced + redeployed `park-2` — confirmed via `/api/version` (previously 404,
|
||||||
|
proving a stale image; correctly 401-auth-gated after the fix, proving the new image + route exist).
|
||||||
|
|
||||||
|
**Standing lesson, now written twice:** a `[[stack]]`'s promotion tier (which branch its own
|
||||||
|
`branch`/`TAG` fields track) and the ResourceSync resource's own git branch are **two independently
|
||||||
|
configured settings in Komodo Core — nothing enforces they agree**, and a mismatch is invisible
|
||||||
|
until the two branches' `resources.toml` actually diverge. **Check this FIRST** whenever a
|
||||||
|
redeploy doesn't pick up an expected `resources.toml` change, before assuming the change itself,
|
||||||
|
the CI build, or the deploy step is broken.
|
||||||
|
|||||||
+108
-2
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, readers, offline-first]
|
tags: [parking, hardware, readers, offline-first]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture, DS-2CD1047G3H-LIU]
|
||||||
updated: 2026-07-07
|
updated: 2026-08-23
|
||||||
---
|
---
|
||||||
|
|
||||||
# LPR Camera
|
# LPR Camera
|
||||||
@@ -110,6 +110,112 @@ Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the
|
|||||||
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
||||||
saturated main stream rather than hiding it behind a retry).
|
saturated main stream rather than hiding it behind a retry).
|
||||||
|
|
||||||
|
### Main-stream ISAPI snapshot 503 is model-specific, not config — and RTSP routes around it (2026-08-23)
|
||||||
|
|
||||||
|
Live comparison, same site (park-buzi), same day, both cameras reachable via the site's port
|
||||||
|
forwards (`park-buzi.msai.al:8081` / `:8082`) and directly on the LAN (`10.0.10.13` = the `:8082`
|
||||||
|
unit):
|
||||||
|
|
||||||
|
| | `:8081` (works) | `:8082` = `10.0.10.13` (503s) |
|
||||||
|
|---|---|---|
|
||||||
|
| Model | **DS-2CD1043G2-LIU** | **DS-2CD1047G3H-LIU** |
|
||||||
|
| Firmware | V5.8.10 | V5.8.11 |
|
||||||
|
| Channel 101 config | 2560×1440, VBR, 6144 Kbps cap, 20fps | **identical** — 2560×1440, VBR, 6144 Kbps cap, 20fps |
|
||||||
|
| `SmartCodec` | disabled | disabled |
|
||||||
|
| `GET /ISAPI/Streaming/channels/101/picture` | **200**, valid JPEG | **503**, `statusCode 2 / deviceBusy` (3/3 retries, instant) |
|
||||||
|
| `GET /ISAPI/Streaming/channels/102/picture` (sub) | — | **200**, valid JPEG |
|
||||||
|
|
||||||
|
Channel-101 config is **byte-identical** between the two units (bitrate, resolution, frame rate,
|
||||||
|
SmartCodec) — this rules out "misconfigured over some ceiling" definitively; the only things that
|
||||||
|
differ are model + firmware. Combined with the [[#Source: HIKVISION DS-2CD1047G3H-LIU-F datasheet|
|
||||||
|
vendor datasheet]] fact that the G3H's **main stream has no MJPEG option** (sub-stream does), the
|
||||||
|
working theory is that this SKU's snapshot codepath has to transcode a live H.264/H.265 frame into
|
||||||
|
JPEG on demand for main, and its firmware/encoder can't do that reliably at this resolution —
|
||||||
|
while sub can serve JPEG more natively. **Treat this as a `DS-2CD1047G3H-LIU`-model limitation
|
||||||
|
(this firmware line), not a config or ISAPI-usage bug** — matches every earlier finding on this
|
||||||
|
same unit (`10.0.10.13`) in the sections below, now cross-confirmed against a working sibling model
|
||||||
|
on the same network with identical settings.
|
||||||
|
|
||||||
|
**RTSP main-stream frame-grab works and routes around it entirely**, confirmed live against
|
||||||
|
`10.0.10.13`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -rtsp_transport tcp -y \
|
||||||
|
-i "rtsp://admin:<pw>@10.0.10.13:554/Streaming/Channels/101" \
|
||||||
|
-frames:v 1 -update 1 snapshot.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Returned a valid 2560×1440 JPEG (94 KB) on the first try — same camera, same main-stream
|
||||||
|
resolution the ISAPI endpoint 503s on. This makes sense mechanically: RTSP just taps the H.264
|
||||||
|
stream the encoder is **already producing continuously** for live-view/recording; there's no
|
||||||
|
on-demand "pause and re-encode as standalone JPEG" step for the firmware to choke on, unlike the
|
||||||
|
ISAPI snapshot path. Port 554 is open on the LAN (`10.0.10.13`) but **not** forwarded through the
|
||||||
|
site's public port-forward (`park-buzi.msai.al` only exposes the HTTP/ISAPI ports, consistent with
|
||||||
|
[[network-isolation|the camera/controller network staying LAN-only]] — RTSP was only reachable
|
||||||
|
from inside the site network, never tested through the public forward).
|
||||||
|
|
||||||
|
**Not yet built**: `packages/devices/src/drivers/camera.ts` is HTTP-Digest/ISAPI only today: no
|
||||||
|
RTSP client, no `ffmpeg` child-process dependency. Adding an RTSP fallback (or RTSP-first path for
|
||||||
|
cameras that report persistent `deviceBusy` on ISAPI main) is a real architectural addition — new
|
||||||
|
process-spawn dependency, RTSP auth handling, transport selection (TCP confirmed working; UDP
|
||||||
|
untested) — not implemented as of this writing.
|
||||||
|
|
||||||
|
**Escalated from "nice to have" to a real requirement (2026-08-23): the sub-stream (768×432) is
|
||||||
|
too weak for reliable plate reads** — it fails to read plates "from time to time" in practice, so
|
||||||
|
sub-stream-only is not an acceptable permanent mitigation for this camera; RTSP-for-main is needed
|
||||||
|
for ANPR accuracy, not just for a higher-res evidence photo.
|
||||||
|
|
||||||
|
**Firmware update tested and RULED OUT as the fix (2026-08-23).** Before building the RTSP path,
|
||||||
|
checked whether this was simply a day-one bug: the camera shipped on `V5.8.11` build 250415 —
|
||||||
|
confirmed via the official Hikvision release note to be **the very first H13U firmware build that
|
||||||
|
added support for the DS-2CD1XX7G3H-LIU family at all** ("Newly add 1 series 4MP fixed-focus
|
||||||
|
cameras: DS-2CD1XX7G3H-LIU"), a plausible day-one-bug candidate. Upgraded live to **`V5.11.0` build
|
||||||
|
260701** (over a year of firmware progress, incl. an intermediate `V5.8.21_SP1` release explicitly
|
||||||
|
noting "Fix network and image potential bugs to enhance device stability"). **Result: NO CHANGE.**
|
||||||
|
Post-upgrade, `channels/101/picture` still returns `HTTP 503 / statusCode 2 / deviceBusy`, 5/5
|
||||||
|
consecutive attempts, byte-identical error body to pre-upgrade. Sub-stream (`102`) still healthy
|
||||||
|
(200, ~15KB) — camera is fine post-upgrade, just this one limitation persists.
|
||||||
|
|
||||||
|
**Superseded finding, below: "durable hardware/encoder ceiling" was the wrong framing.** At this
|
||||||
|
point in the investigation it looked like a real capacity limit (reproduced across ~15 months of
|
||||||
|
firmware). The channel-sweep test below shows that's not what's actually happening.
|
||||||
|
|
||||||
|
### The real cause: a broken/incomplete ISAPI snapshot handler, not "busy" (2026-08-23)
|
||||||
|
|
||||||
|
`deviceBusy` never meant "busy." Swept every channel/stream ID against the snapshot endpoint in
|
||||||
|
one sitting, including IDs that don't exist on this camera at all:
|
||||||
|
|
||||||
|
| channel | `GET .../channels/<id>/picture` |
|
||||||
|
|---|---|
|
||||||
|
| 1 | 503 `deviceBusy` |
|
||||||
|
| 100 | 503 `deviceBusy` |
|
||||||
|
| **101** (real main) | **503 `deviceBusy`** |
|
||||||
|
| **102** (real sub) | **200 OK** |
|
||||||
|
| 103 | 503 `deviceBusy` |
|
||||||
|
| 201 (channel 2 doesn't exist — single-channel camera) | 503 `deviceBusy` |
|
||||||
|
| 999 (garbage) | 503 `deviceBusy` |
|
||||||
|
|
||||||
|
**Every ID fails identically except exactly `102`.** A genuinely busy/saturated encoder would not
|
||||||
|
succeed on one specific value and fail the same way on nonexistent channel IDs — a real resource
|
||||||
|
contention error would 404 or behave differently on garbage input, not return the identical `Device
|
||||||
|
Busy` XML body regardless of whether the target exists. This is the signature of a **generic
|
||||||
|
fallback error path**: the firmware's snapshot handler appears to only be correctly wired for
|
||||||
|
`102` (the one channel/stream combination Hikvision evidently tested for this SKU) and returns a
|
||||||
|
stock, misleading `deviceBusy` for every other case — valid main-stream `101` included. It is a
|
||||||
|
**firmware bug that mislabels itself as resource contention**, not a real capacity ceiling — which
|
||||||
|
also fits the firmware-upgrade non-result above (a wrong-code-path bug doesn't get fixed by
|
||||||
|
"more capacity," so no firmware version fixing it would be surprising).
|
||||||
|
|
||||||
|
**Decision (2026-08-23): replace this camera line rather than build around it.** RTSP main-stream
|
||||||
|
capture is proven to work (see above) and could still be built as a `camera.ts` addition, but given
|
||||||
|
the ISAPI snapshot path is flatly broken for anything but one hardcoded channel value, and the
|
||||||
|
site's actual need (reliable plate reads — sub-stream alone isn't accurate enough) requires
|
||||||
|
full-resolution captures, the owner chose to swap out the `DS-2CD1047G3H-LIU` units rather than
|
||||||
|
carry a `ffmpeg`/RTSP dependency to route around a vendor firmware bug. The working `DS-2CD1043G2-
|
||||||
|
LIU` (`:8081` in the comparison above) has no such issue — ISAPI main-stream snapshot works
|
||||||
|
natively — and is the reference model for replacements. RTSP-frame-grab remains documented above
|
||||||
|
as a viable fallback if a `G3H`-family camera is ever unavoidable.
|
||||||
|
|
||||||
## Clock sync — the 1970 power-cut reset (built 2026-07-07)
|
## Clock sync — the 1970 power-cut reset (built 2026-07-07)
|
||||||
|
|
||||||
Field observation (park-buzi): after a power cut these cameras come back with their clock at the
|
Field observation (park-buzi): after a power cut these cameras come back with their clock at the
|
||||||
|
|||||||
+8
-6
@@ -7,7 +7,7 @@ updated: 2026-07-02
|
|||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||||
Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
||||||
|
|
||||||
## Overview & navigation
|
## Overview & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[overview]] — the top-level synthesis and entry point.
|
||||||
@@ -19,6 +19,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[dingtian-dt008]] — Dingtian DT-008 product page: QR/RFID access reader (QR/barcode + ID/IC/NFC; Wiegand/TCP-IP/USB/RS485; HTTP-GET push).
|
- [[dingtian-dt008]] — Dingtian DT-008 product page: QR/RFID access reader (QR/barcode + ID/IC/NFC; Wiegand/TCP-IP/USB/RS485; HTTP-GET push).
|
||||||
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
||||||
- [[parksql2017-legacy-schema]] — predecessor SQL Server schema (Albanian market): legacy tariff/discount/membership/shift/fiscal model; confirms blocks, adds time-windows + categories, lacks postpaid sponsors.
|
- [[parksql2017-legacy-schema]] — predecessor SQL Server schema (Albanian market): legacy tariff/discount/membership/shift/fiscal model; confirms blocks, adds time-windows + categories, lacks postpaid sponsors.
|
||||||
|
- [[ds-2cd1047g3h-liu]] — HIKVISION DS-2CD1047G3H-LIU-F datasheet: 4MP, main stream has no MJPEG option (sub does) — likely explains the model's persistent ISAPI snapshot 503 on main; confirms ISAPI/RTSP both fully in-spec.
|
||||||
|
|
||||||
## Entities — technology stack
|
## Entities — technology stack
|
||||||
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
||||||
@@ -39,7 +40,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
|
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
|
||||||
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
||||||
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
||||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source; DS-2CD1047G3H-LIU main-stream ISAPI snapshot 503 is a firmware bug (only channel 102 ever works, ALL other IDs incl. garbage 503 identically) — firmware update tested, no fix; owner decided to replace the camera line rather than build an RTSP workaround.
|
||||||
- [[dingtian-dt008-reader]] — Dingtian DT-008 QR/RFID reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
- [[dingtian-dt008-reader]] — Dingtian DT-008 QR/RFID reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
||||||
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
||||||
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
|
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
|
||||||
@@ -57,7 +58,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
|
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
|
||||||
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
||||||
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
||||||
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only.
|
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug).
|
||||||
|
|
||||||
## Concepts — device architecture & safety
|
## Concepts — device architecture & safety
|
||||||
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
||||||
@@ -68,7 +69,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||||
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
||||||
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
||||||
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14).
|
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14); park-buzi cover-open-wedges-USB-status bug (docker restart-only fix) under investigation.
|
||||||
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
||||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||||
@@ -99,7 +100,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||||
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
||||||
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
||||||
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
|
- [[validation-discounts]] — BUILT (2026-07-13): in-park merchant (bar/lavazh) users scan-and-validate on their device (signed event, program↔user binding); booth settles NET + prints gross/discount/net; comp/time-credit/fixed/percent, caps, /setup/site panel, /validate screen.
|
||||||
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit.
|
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit.
|
||||||
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
||||||
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
|
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
|
||||||
@@ -127,12 +128,13 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
|
- [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
|
||||||
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
|
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
|
||||||
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
|
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
|
||||||
|
- [[cloud-service-saas]] — 📌 POSTPONED: multi-tenant SaaS for fleet monitoring/control; productises the NetBird/Komodo control plane. Four tensions (offline-first vs real-time, verifiable-ledger-in-cloud, secrets custody, two-level tenancy); signing key stays on the booth; NetBird already solves isolation; remote barrier-open is `pulseOpen`+signed (the unmanned driver).
|
||||||
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
|
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
|
||||||
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
|
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
|
||||||
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
|
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
|
||||||
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
||||||
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
||||||
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
|
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
|
||||||
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
||||||
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
|
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
|
||||||
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
||||||
|
|||||||
+333
@@ -2540,3 +2540,336 @@ to no reset-db category, silently surviving even `--all`. Added `--diagnostics`
|
|||||||
tariff_drafts under `--config`, and a drift guard that refuses to run when any table is
|
tariff_drafts under `--config`, and a drift guard that refuses to run when any table is
|
||||||
uncategorized ([[local-dev-workflow]], [[appliance-provisioning]] §7d). 8 new tests
|
uncategorized ([[local-dev-workflow]], [[appliance-provisioning]] §7d). 8 new tests
|
||||||
(3 button-light backoff, 5 coalescing); guard + both new wipes verified on a scratch DB.
|
(3 button-light backoff, 5 coalescing); guard + both new wipes verified on a scratch DB.
|
||||||
|
|
||||||
|
## [2026-07-13] decision | Cloud service — multi-tenant SaaS (postponed, context captured)
|
||||||
|
From a design conversation, not a source. The user floated an online, multi-tenant SaaS (the
|
||||||
|
"cloud service") on TOP of the offline backup model (which stays, as the offline-site tradeoff):
|
||||||
|
subscribing park sites get real-time (link-up) monitoring of the signed ledger, device status,
|
||||||
|
and financial reports; one admin owns many sites; the cloud custodies per-site secrets; recurring
|
||||||
|
per-site fee = a revenue line. Recorded as [[cloud-service-saas]] (status: open, POSTPONED per the
|
||||||
|
user) so it isn't re-derived later. It productises the off-site control plane already stood up in
|
||||||
|
[[fleet-deployment-komodo]] (Komodo Core + NetBird). Captured: the four hard tensions (offline-first
|
||||||
|
vs real-time; the ledger must be VERIFIABLE not just displayed in the cloud; central secret custody;
|
||||||
|
two-level tenancy under operator-as-adversary), the secrets boundary the user confirmed (sync creds
|
||||||
|
+ device-password ESCROW + app identity — but NOT the signing/ATECC608 key, which stays on the
|
||||||
|
booth), and TWO in-discussion corrections that stand: (1) NetBird already solves the "cloud reaches
|
||||||
|
booth" isolation objection — park-buzi is monitored that way today, booth-dialed, nothing exposed;
|
||||||
|
(2) remote barrier-open is COMPATIBLE with [[barrier-not-a-door]] (it's `pulseOpen`/intent, never
|
||||||
|
timed-close) and is DRIVEN by the [[autonomous-direction]] unmanned future — gated as a distinct
|
||||||
|
privilege + a signed ledger event with actor+reason, with the booth as enforcer and a local
|
||||||
|
fail-open that can't depend on the cloud. Four open questions parked (real-time definition, where
|
||||||
|
reports are computed, hosting/licensing, custodianship-as-liability). Cross-linked; index count
|
||||||
|
7→8 decisions.
|
||||||
|
|
||||||
|
## [2026-07-13] update | Owner requirement — in-park merchant validations (car-wash "lavazh", bar)
|
||||||
|
|
||||||
|
The [[validation-discounts]] feature is now asked-for, not just an industry-survey gap: the park
|
||||||
|
may host an in-park car-wash and/or bar whose customers the owner wants discharged for the stay —
|
||||||
|
full comp, free-first-N-minutes (`time-credit`), or consumption-offset (`fixed`, variable amount:
|
||||||
|
300 ALL consumed vs 500 ALL fee → pay 200). Must be admin-composable at runtime like
|
||||||
|
tariffs/subscription plans. Driving-cases section added to [[validation-discounts]]. Open: merchant
|
||||||
|
ownership (owner-run → pure discount; tenant → [[validation-sponsorship]] settlement), who applies
|
||||||
|
(operator vs merchant code/portal), stacking rules, caps.
|
||||||
|
|
||||||
|
## [2026-07-13] update | Merchant validations refined — merchant STATIONS (users), not sponsors
|
||||||
|
|
||||||
|
Second pass on the [[validation-discounts]] requirement: ownership immaterial, sponsor layer
|
||||||
|
dropped. Merchant = a system user on their own device who scans the ticket to validate (signed,
|
||||||
|
attributed); admin checkbox per station = may collect parking payments (then shift + till +
|
||||||
|
Z-report apply to them like the booth); paid/zero-due tickets self-exit at the reader.
|
||||||
|
Consequences: per-station shifts/drawers (breaks the site-wide single-open invariant), exit-reader
|
||||||
|
live due=0 branch. Details on [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-07-13] update | Merchant validations settled — validation-only merchants, all money at the booth
|
||||||
|
|
||||||
|
Third pass, settled: the merchant-collects-payments variant is REJECTED. Merchant users only scan
|
||||||
|
+ validate (signed, attributed); every car checks in at the booth to settle (net may be 0 — still
|
||||||
|
a signed payment) and gets the detailed gross/discount/net receipt there. Per-station
|
||||||
|
shifts/drawers and the exit-reader due=0 branch are no longer needed — shift/drawer/exit flows
|
||||||
|
stay as built; Z/X-reports gain discount lines. Build surface: validation_programs master data,
|
||||||
|
signed validation event, priceSession validations[] extension, merchant scan page, booth
|
||||||
|
quote/receipt/Z-report lines. Details on [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-07-13] decision | Merchant validations — design SETTLED, build started
|
||||||
|
|
||||||
|
Setup UX on /setup/site (Bar/Lavazh checkboxes → right-column config panel, tabs when both);
|
||||||
|
fixed UI over generic storage (validation_programs + user binding, well-known bar/lavazh rows,
|
||||||
|
mutable config — the signed validation event carries resolved values); RBAC = new `validation`
|
||||||
|
resource (create/read), guard = permission AND station binding; merchant-only users land on
|
||||||
|
/validate; merchants may void their own unused validation. See [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-07-13] update | Merchant validations BUILT end-to-end (bar / lavazh)
|
||||||
|
|
||||||
|
Shipped the settled design: `validation` permission + ledger event (resolved values, refId-void),
|
||||||
|
priceSession validations[] canonical fold (timeCredit→percent→fixed→comp, Σ lines ≡ gross−net),
|
||||||
|
validation_programs(+users) tables (migration 0024, reset-db config category), routes/validations.ts
|
||||||
|
(programs PUT signs config_change; apply guards: binding → open transient → no dup → maxPerDay →
|
||||||
|
amount cap; void own-unused-only), PayStation quote/pay/lookup net folding + payment consumption
|
||||||
|
(grossMinor/discountMinor/validationIds/validationLines), receipt gross+discount lines, Z/X-report
|
||||||
|
discountTotalMinor ("Zbritje (validime)", printed only when >0), /setup/site two-column Bar/Lavazh
|
||||||
|
checkboxes + config panel (tabs), /validate merchant screen (merchant-only users land there),
|
||||||
|
booth-modal gross→lines→net, feed label VALIDIM. 8 new route integration tests + shared fold suite;
|
||||||
|
workspace build/typecheck/test green. As-built + remaining polish on [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-07-13] decision | Merchant scan input: HID barcode scanner on web/desktop; camera paths postponed
|
||||||
|
|
||||||
|
The bar/lavazh stations use a USB/HID scanner (or hand-keying + Luhn) into /validate on the
|
||||||
|
web/desktop app. Two evaluated camera alternatives deliberately POSTPONED: web getUserMedia
|
||||||
|
scanning (blocked on secure-context TLS for LAN phones + weak Code128-via-camera — would want
|
||||||
|
QR-on-ticket first) and a Tauri v2 Android merchant app (native ML Kit scanning via the official
|
||||||
|
barcode-scanner plugin; deferred over Android build/distribution overhead + the
|
||||||
|
configurable-server-URL prerequisite). Full analysis on [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-08-23] ingest | HIKVISION DS-2CD1047G3H-LIU-F datasheet
|
||||||
|
|
||||||
|
Vendor datasheet dropped in `raw/DS-2CD1047G3H-LIU.md`. Key new fact: main stream on this model
|
||||||
|
supports H.265+/H.265/H.264+/H.264 only — **no MJPEG**; sub-stream adds MJPEG. Likely mechanical
|
||||||
|
explanation for the persistent ISAPI main-stream snapshot 503 (`deviceBusy`) already logged on this
|
||||||
|
model in [[lpr-camera]] (2026-06-26/27): the on-demand JPEG snapshot has no native path on main,
|
||||||
|
so it has to transcode from H.264/H.265 live, which this SKU's firmware apparently can't do
|
||||||
|
reliably at 2560×1440. Bitrate spec (32Kbps–16Mbps) also confirms the site's main-stream config
|
||||||
|
(6144–12288Kbps) was never out of range — rules out misconfiguration definitively.
|
||||||
|
|
||||||
|
## [2026-08-23] query | Main-stream 503: model-specific or config? RTSP as a workaround?
|
||||||
|
|
||||||
|
Live-compared two Hikvision units at park-buzi via their public port forwards
|
||||||
|
(`park-buzi.msai.al:8081`/`:8082`) plus the `:8082` unit directly on the LAN (`10.0.10.13`).
|
||||||
|
Channel-101 (main) config is **byte-identical** between a working `DS-2CD1043G2-LIU` (8081, 200 OK)
|
||||||
|
and the failing `DS-2CD1047G3H-LIU` (8082 / `10.0.10.13`, persistent 503 `deviceBusy`, 3/3 retries
|
||||||
|
instant) — same resolution/bitrate/framerate/SmartCodec state. Rules out config as the cause;
|
||||||
|
confirms it's model/firmware-specific (matches the datasheet finding above). Then tested RTSP
|
||||||
|
(`rtsp://…@10.0.10.13:554/Streaming/Channels/101` via ffmpeg, TCP transport) against the SAME
|
||||||
|
failing camera: returned a valid 2560×1440 JPEG on the first try. RTSP taps the continuously-
|
||||||
|
running encode rather than asking for an on-demand re-encoded JPEG, so it sidesteps whatever the
|
||||||
|
ISAPI snapshot path chokes on. Not yet built into `camera.ts` (would add an `ffmpeg` child-process
|
||||||
|
dependency + RTSP auth/transport handling) — filed as a viable, proven fallback if full-resolution
|
||||||
|
main-stream stills are ever needed; sub-stream ISAPI snapshot remains sufficient for current ANPR
|
||||||
|
use. Full comparison table + RTSP command on [[lpr-camera]].
|
||||||
|
|
||||||
|
## [2026-08-23] update | Firmware update tested and ruled out; sub-stream confirmed too weak for ANPR — RTSP is now required
|
||||||
|
|
||||||
|
Two developments on the DS-2CD1047G3H-LIU (`10.0.10.13`) main-stream 503: (1) the owner reports the
|
||||||
|
sub-stream (768×432) **fails to read plates "from time to time"** in real use — sub-stream-only is
|
||||||
|
no longer an acceptable mitigation, it's an accuracy problem. (2) Before building RTSP, tested
|
||||||
|
whether this was a day-one firmware bug: the camera's original `V5.8.11`/250415 build was confirmed
|
||||||
|
(via Hikvision's own release note) to be the FIRST H13U firmware to support this camera family at
|
||||||
|
all. Upgraded live to `V5.11.0`/260701 (~15 months newer, spanning an intermediate release that
|
||||||
|
explicitly claimed "image stability" fixes). Result: **no change** — identical `deviceBusy` 503,
|
||||||
|
5/5 attempts, post-upgrade. Firmware is now a ruled-out cause, not a theory; this looks like a real
|
||||||
|
encoder/hardware ceiling on this SKU. Next step: build the RTSP-based main-stream capture path into
|
||||||
|
`packages/devices/src/drivers/camera.ts` (not yet started). Full detail on [[lpr-camera]].
|
||||||
|
|
||||||
|
## [2026-08-23] update | Root cause nailed down: broken ISAPI handler, not "busy" — decision to REPLACE the camera line
|
||||||
|
|
||||||
|
Final test on the DS-2CD1047G3H-LIU snapshot 503: swept every channel/stream ID against
|
||||||
|
`GET .../channels/<id>/picture`, including nonexistent ones (1, 100, 103, 201, 999). Every single
|
||||||
|
ID returns the identical `503 deviceBusy` body EXCEPT exactly `102` (the real sub-stream), which is
|
||||||
|
always 200. A real busy/saturated encoder would not succeed on one specific value while failing
|
||||||
|
garbage IDs identically — this is a generic fallback error: the firmware's snapshot handler is only
|
||||||
|
correctly wired for channel 102, and everything else (valid main-stream 101 included) falls through
|
||||||
|
to a stock, mislabeled "Device Busy" response. Confirms the firmware-upgrade non-result from
|
||||||
|
earlier today (a wrong-code-path bug wouldn't be fixed by more capacity). Owner's decision: replace
|
||||||
|
the DS-2CD1047G3H-LIU units rather than carry an RTSP/ffmpeg workaround dependency — the sibling
|
||||||
|
DS-2CD1043G2-LIU (no such bug, ISAPI main-stream snapshot works natively) is the reference model
|
||||||
|
going forward. RTSP main-stream capture remains documented as a proven, viable fallback if a G3H
|
||||||
|
camera is ever unavoidable, but is not being built. Full sweep table + reasoning on [[lpr-camera]].
|
||||||
|
|
||||||
|
## [2026-08-30] update | Booth USB printer cover-open bug: leading theory is a stale container bind-mount, not a stale app-layer handle
|
||||||
|
|
||||||
|
Live troubleshooting request (park-buzi): opening the printer's paper-roll cover reliably wedges its
|
||||||
|
status to offline/faulty, surviving a full appliance reboot; only `docker restart server` clears it.
|
||||||
|
Traced `sendRawUsb`/`probeUsb` end-to-end in `printer-escpos.ts` plus both poll loops
|
||||||
|
(`device-monitor.ts`, `printer-monitor.ts`): every print AND every poll does a fresh
|
||||||
|
open→write/probe→close with no persistent fd/socket/driver instance anywhere — ruling out a naive
|
||||||
|
"stale Node handle" explanation. Leading hypothesis instead: the cover-open microswitch cuts power
|
||||||
|
to the printer's USB interface board, causing a real bus re-enumeration; the container's directory
|
||||||
|
bind-mount of `/dev/usb` (chosen specifically to survive `lpN` renumbering) can retain a stale view
|
||||||
|
of the old device node until the container's mount namespace is recreated — which `docker restart`
|
||||||
|
does and a policy-driven reboot-time restart may not (boot-order race). Not yet confirmed on
|
||||||
|
hardware (host-vs-container `stat`/inode comparison at the next occurrence is the next step); lab
|
||||||
|
repro is blocked because the lab has a RONGTA, not the park-buzi unit's actual (still unidentified,
|
||||||
|
"Generic (unknown)") model. Full writeup, confirmation commands, and candidate fixes on
|
||||||
|
[[printer-usb-transport]].
|
||||||
|
|
||||||
|
## [2026-08-30] update | Backup status "Never" despite valid rotating backups — restart amnesia in BackupService, fixed
|
||||||
|
|
||||||
|
Admin noticed park-buzi's Backup screen showed "last successful backup: Never" despite 7 real,
|
||||||
|
correctly-rotating encrypted backup files on disk, plus a 2-day gap since the last file. Traced
|
||||||
|
both symptoms to the same cause: `BackupService` tracked last-success/last-error as PLAIN
|
||||||
|
IN-PROCESS FIELDS (never written to the DB), and the daily schedule was a `setInterval(...,24h)`
|
||||||
|
measured from PROCESS START, not wall-clock time since the last real backup — so any server
|
||||||
|
restart (routine under `restart: always`: deploy/crash/OOM/host reboot) simultaneously wiped the
|
||||||
|
visible status back to "Never" and reset the 24h countdown, independent of the actual
|
||||||
|
file-writing/retention engine (`backup.ts`), which was working correctly the whole time and
|
||||||
|
explains why files existed on disk despite the UI's contradictory-seeming status. Fix: four new
|
||||||
|
nullable `site_config` columns (migration `0025_backup_last_status.sql`) persist last-success/
|
||||||
|
error there instead of in memory; `BackupService.status()` reads them fresh each call so a new
|
||||||
|
instance (= a restart) sees the prior instance's outcome; a new `isDue()` method computes
|
||||||
|
schedule-due-ness from the persisted last-success timestamp; `server.ts`'s scheduler is now a
|
||||||
|
15-minute poll gated by `isDue()` instead of a 24h `setInterval`, making the real cadence immune
|
||||||
|
to restart timing. New test file `backup-service.test.ts` (6 tests) covers restart-durability and
|
||||||
|
`isDue()` directly; full existing suite (319 tests) still green. No API/UI contract change. Not
|
||||||
|
yet committed (holding per instruction). Full writeup on [[backup-recovery]].
|
||||||
|
|
||||||
|
## [2026-08-30] update | Two Komodo Periphery gotchas: connect_as renaming, agent upgrade procedure
|
||||||
|
|
||||||
|
Two real incidents this session, both closed out as new gotchas (#12, #13) on
|
||||||
|
[[appliance-provisioning]] §7: (1) a lab box installed with a leftover template placeholder
|
||||||
|
left in `--connect-as` kept reappearing under that name in Core no matter how many times it was
|
||||||
|
renamed in the UI — because `connect_as` is a plain field in the agent's own
|
||||||
|
`periphery.config.toml`, and a Core-UI rename never touches it; fixed by editing the field
|
||||||
|
directly on the host + `systemctl --user restart periphery`, no reinstall needed. (2) Upgrading
|
||||||
|
Periphery from a version-mismatch (Core bumped to v2.3.2, an agent still on v2.2.0) has no
|
||||||
|
separate update mechanism — confirmed against Komodo's own `setup-periphery.py` source that
|
||||||
|
re-running the same installer with unchanged `--connect-as` is config-preserving (it explicitly
|
||||||
|
skips rewriting an existing config) and safe; verified dry-run on `art-docker-station` (lab) then
|
||||||
|
applied to `park-buzi` (live booth) with no disruption to the running app containers. Full detail
|
||||||
|
+ exact commands on [[appliance-provisioning]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop updater endpoint was unreachable — pointed at a private repo
|
||||||
|
|
||||||
|
The Tauri auto-updater ([[desktop-shell-tauri]]) was fully implemented — signed builds, keypair,
|
||||||
|
`latest.json`, `release.yml` — but its endpoint pointed at `mca/parking_solution`'s own Gitea
|
||||||
|
"latest release" redirect, and that repo is **private**. Field appliances have no Gitea
|
||||||
|
credentials, so every update check was silently failing (caught by a `try/catch`); this was never
|
||||||
|
actually field-verified end to end. Fix: signed installers now mirror to a new public,
|
||||||
|
installers-only repo `mca/public_releases` (org-shared, not parking-specific), published to a fixed
|
||||||
|
`desktop-latest` tag so other apps releasing there later can't shadow ours. Considered and rejected
|
||||||
|
embedding a `read:repository` token in the app instead — ruled out given the appliance's own threat
|
||||||
|
model (booth operator as primary adversary) makes an extractable, hard-to-rotate credential in every
|
||||||
|
deployed binary worse than just publishing installers publicly. `release.yml`,
|
||||||
|
`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on
|
||||||
|
[[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop login broken by a VITE_API_BASE regression from the booth same-origin fix
|
||||||
|
|
||||||
|
The 2026-06-27 booth fix (commit 96fd97e) correctly blanked `apps/web/.env.production`'s
|
||||||
|
`VITE_API_BASE` for the browser/booth same-origin case, but the desktop build shares that same
|
||||||
|
file and was never given its own override — the desktop shell has been building with an empty
|
||||||
|
API base since that commit, unnoticed until now. Symptom: login threw `DOMException: "The string
|
||||||
|
did not match the expected pattern."` — WebKitGTK rejecting a relative `fetch()` URL with no base
|
||||||
|
to resolve against, since the desktop window's origin is `tauri://localhost`. Browser login was
|
||||||
|
unaffected (same-origin, no absolute URL needed), which is why this went unnoticed through the CI
|
||||||
|
mirror-repo debugging session. Fixed by setting `VITE_API_BASE=http://127.0.0.1:3000` inline in
|
||||||
|
`tauri.conf.json`'s `beforeBuildCommand`, overriding the shared `.env.production` for the desktop
|
||||||
|
build only (process env wins in Vite's load order) — verified both builds independently. Full
|
||||||
|
detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop updater silently failed: tag/version drift + swallowed install errors
|
||||||
|
|
||||||
|
Two compounding bugs, both closed out on [[desktop-shell-tauri]] §"Desktop in CI": (1) the v0.1.1
|
||||||
|
release bumped only the git tag — tauri.conf.json's own "version" field (what Tauri actually bakes
|
||||||
|
into the bundle filename and internal version) stayed at 0.1.0, so the signed binary didn't match
|
||||||
|
what latest.json claimed to describe, and signature verification failed on every download; (2)
|
||||||
|
desktop-updater.ts's single blanket try/catch swallowed that failure identically to "offline/no
|
||||||
|
update," so the operator saw the prompt, watched it download, then nothing — repeating forever with
|
||||||
|
zero diagnostic trail. Fixed release.yml to sed-patch tauri.conf.json's version from the git tag
|
||||||
|
right before building (checked-in value is now dev-only, never hand-maintained for releases), and
|
||||||
|
split desktop-updater.ts's catch so a real post-accept failure logs instead of vanishing. Full
|
||||||
|
detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop login "Load failed": WebKit mixed-content, not CORS/CSP
|
||||||
|
|
||||||
|
After fixing VITE_API_BASE, login still failed with WebKit's generic "Load failed" — a raw browser
|
||||||
|
fetch() rejection with no server-side trace, since the request never reached the network. Root
|
||||||
|
cause: WebKitGTK treats tauri://localhost as a secure origin, so http://127.0.0.1:3000 (and
|
||||||
|
ws://127.0.0.1:3000) from inside it is blocked as mixed content — a known WebKit limitation, NOT
|
||||||
|
fixable via CSP connect-src. Fixed by routing both through Tauri plugins that use the native (Rust)
|
||||||
|
HTTP/WS client instead of the webview's own: tauri-plugin-http (a genuine fetch() drop-in, wired
|
||||||
|
into api.ts/logger.ts via a new platformFetch() in origin.ts) and tauri-plugin-websocket (NOT a
|
||||||
|
drop-in — async/listener API — adapted behind a native-WebSocket-shaped interface in the new
|
||||||
|
platform-ws.ts so use-live-feed.ts needed no changes). Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop live feed offline: native WS plugin sends no Origin, prod allowlist was empty
|
||||||
|
|
||||||
|
Login worked after the mixed-content fix, but the live feed showed offline in the desktop app while
|
||||||
|
the browser showed LIVE, same server. tauri-plugin-websocket's connect() runs on Tauri's Rust side,
|
||||||
|
not inside the webview page, so it never auto-attaches an Origin header — routes/ws.ts's anti-CSWSH
|
||||||
|
check treats a missing Origin as untrusted and 403s before auth. Compounded by a second, independent
|
||||||
|
gap: komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in production, despite
|
||||||
|
.env.example documenting tauri://localhost as required for the desktop app. Fixed both: platform-ws.ts
|
||||||
|
now passes Origin: tauri://localhost explicitly in connect()'s headers; resources.toml's two Stacks
|
||||||
|
get the real allowlist. Needs a Komodo sync + redeploy to reach a live booth, not just a git push.
|
||||||
|
Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2)
|
||||||
|
self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content
|
||||||
|
commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Update failures were invisible: console-forward gate blocked the error logging
|
||||||
|
|
||||||
|
The desktop-updater.ts error logging added earlier this session used console.error/console.warn,
|
||||||
|
but logger.ts only forwards console output to the server when the client log level is debug/trace
|
||||||
|
(default: info) — so the "fix" never actually surfaced anything, and a real v0.1.3→v0.1.4 update
|
||||||
|
failure showed zero logs anywhere, sending debugging in circles (a WebKit remote-inspector attempt
|
||||||
|
via WEBKIT_INSPECTOR_SERVER also dead-ended — this build doesn't answer standard discovery
|
||||||
|
endpoints). Fixed by calling logClient() directly in desktop-updater.ts, unconditionally, bypassing
|
||||||
|
the console-forward gate entirely — a genuine post-accept install failure now always reaches
|
||||||
|
app_logs regardless of client log level. Also added download-progress logging. Separately: found
|
||||||
|
and fixed a real, pre-existing Komodo ResourceSync misconfig (resource-sync-park-systems pointed at
|
||||||
|
`dev`, not `stage`, silently reading resources.toml from the wrong branch for months with zero
|
||||||
|
effect until dev/stage first diverged today) — full writeup on [[fleet-deployment-komodo]], which
|
||||||
|
had already warned about exactly this gotcha back in 2026-07-07 and it happened anyway.
|
||||||
|
|
||||||
|
## [2026-09-03] feat | Desktop app version now visible in the UI (was invisible)
|
||||||
|
|
||||||
|
There was no way to see which desktop build was actually installed anywhere in the app — an
|
||||||
|
operator debugging a stuck update had to infer it backwards from the update prompt's target
|
||||||
|
version ("it's offering v0.1.4, so I must be on v0.1.3"). Added DesktopVersionBadge next to the
|
||||||
|
existing server-side VersionBadge in router.tsx, using @tauri-apps/api's getVersion() (the real
|
||||||
|
running app version, synced to the git tag at build time by release.yml). No-ops in a browser.
|
||||||
|
Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-04] feat | Desktop backend origin is now runtime-configurable (was build-time)
|
||||||
|
|
||||||
|
The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases — not built
|
||||||
|
per-booth — but VITE_API_BASE was a build-time env var hardcoded to http://127.0.0.1:3000, so the
|
||||||
|
same installer could only ever talk to a server on its own machine. Added ConnectScreen (shown
|
||||||
|
before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the
|
||||||
|
operator-entered URL across restarts; origin.ts's API_BASE became a runtime-settable `let`. CSP's
|
||||||
|
connect-src tightened to 'self' only (all backend traffic already went through
|
||||||
|
tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway); the
|
||||||
|
real boundary moved to capabilities/default.json's http:default scope, wildcarded to any host so
|
||||||
|
the operator-chosen address is actually reachable. Added a "Change server" control (Setup nav,
|
||||||
|
desktop-only) that clears the saved URL and reloads back to ConnectScreen.
|
||||||
|
|
||||||
|
While tracing the desktop auth path for this, found a pre-existing (not newly introduced) bug:
|
||||||
|
tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate
|
||||||
|
from the webview — document.cookie on tauri://localhost never sees the parking_csrf cookie the
|
||||||
|
server sets (open upstream bug, tauri-apps/tauri#13045/#11518), so the desktop app has likely been
|
||||||
|
silently sending no CSRF header on every mutation since the shell was first built, regardless of
|
||||||
|
which host it targeted. Fixed by having sessionView() (routes/auth.ts) also echo the same csrf
|
||||||
|
value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of
|
||||||
|
reading document.cookie. assertCsrf() itself is untouched — the cookie is still what's verified,
|
||||||
|
and reqwest was already sending it correctly; this only fixes how the desktop client *learns* the
|
||||||
|
value. Full detail (including the exact CSP/capability tradeoffs) on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-04] fix | Desktop live feed: WS handshake can't carry the cookie → single-use ticket; desktop logs never reached app_logs
|
||||||
|
|
||||||
|
Retrospective of the 2026-09-03/04 desktop run (six releases in 26 h) found the v0.1.4 Origin fix
|
||||||
|
cleared only gate one of two in routes/ws.ts: gate two is req.jwtVerify() reading the HttpOnly
|
||||||
|
cookie, and tauri-plugin-websocket has no cookie jar at all — so every desktop handshake 401'd and
|
||||||
|
use-live-feed reconnected every 10 s (confirmed in the park-2 server log). Fixed with a 30-second,
|
||||||
|
single-use, in-memory WS ticket minted by POST /api/ws/ticket over normal cookie+CSRF auth and
|
||||||
|
presented in an x-ws-ticket header; Origin check still runs first, browser path unchanged, JWT
|
||||||
|
stays out of JS. Second finding: logger.ts read the CSRF cookie via document.cookie, null on
|
||||||
|
desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had
|
||||||
|
EVER reached app_logs, which is why "no logs whatsoever" kept happening and why yesterday's
|
||||||
|
logClient fix couldn't help. Stash moved to lib/desktop-csrf.ts, shared by api.ts and logger.ts;
|
||||||
|
WS connect failures now go through logClient (rate-limited). Third: the ConnectScreen probe now
|
||||||
|
uses the unauthenticated /health (extended with app: "parking-system") instead of accepting any
|
||||||
|
401. Also corrected four wiki citations (WebKit 171934 scope, tauri#11518 is closed, the HTTP
|
||||||
|
plugin does set Origin itself, the http-scope "quirk" is URLPattern default-port semantics) and
|
||||||
|
added a local-AppImage pre-tag gate to the desktop README, since tauri dev cannot reproduce any
|
||||||
|
of these origin-dependent bugs. Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-04] fix | Desktop in-app update never worked: latest.json described only the AppImage, booths run the .deb
|
||||||
|
|
||||||
|
tauri-plugin-updater looks up `{os}-{arch}-{installer}` first (linux-x86_64-deb — the bundler
|
||||||
|
stamps the installer type into the binary; verified with strings on a local .deb) and only then
|
||||||
|
bare linux-x86_64. release.yml's latest.json carried only the bare key → the AppImage, so every
|
||||||
|
.deb install downloaded the AppImage, passed signature verification, then failed install_deb()'s
|
||||||
|
is_deb check with InvalidUpdaterFormat — invisible until v0.1.6 fixed the desktop log channel.
|
||||||
|
This, not version drift or swallowed errors, is why v0.1.0→…→v0.1.6 never self-updated.
|
||||||
|
latest.json now has one signed entry per installer (deb, rpm, AppImage); a .deb update ends in a
|
||||||
|
polkit password prompt (pkexec dpkg -i), which is the intended admin gate on a root-installed
|
||||||
|
package. README + [[desktop-shell-tauri]] updated. First real test: tag v0.1.7 and accept the
|
||||||
|
prompt on the v0.1.6 booth.
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#### HIKVISION DS-2CD1047G3H-LIU-F 4 MP ColorVu 3.0 Fixed Bullet Network Camera
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### FEATURE
|
||||||
|
|
||||||
|
- HikAI-ISP for excellent noise reduction effect
|
||||||
|
- Super clear 24/7 colorful imaging with ColorVu 3.0 technology
|
||||||
|
- Motion Detection 3.0, more accurate on person and vehicle classification
|
||||||
|
- Strobe Light & Audio Alarm to deter intruders (optional)
|
||||||
|
- Two-Way Audio via camera and Hik-Connect
|
||||||
|
- Smart Hybrid Light: integrates IR and white lights, 3 supplemental lighting modes
|
||||||
|
- On-board storage with SD card up to 512 GB (optional)
|
||||||
|
- Water and dust resistant (IP67)
|
||||||
|
|
||||||
|
#### Specification
|
||||||
|
|
||||||
|
<table><tbody><tr><td colspan="2" width="681">n <strong>Specification</strong></td></tr><tr><td colspan="2" width="681"><strong>Camera</strong></td></tr><tr><td width="194">Max. Resolution</td><td width="487">2560 × 1440</td></tr><tr><td width="194">Min. Illumination</td><td width="487">Color: 0.0001 Lux @ (F1.0, AGC ON)</td></tr><tr><td width="194">Shutter Time</td><td width="487">1 s to 1/100,000 s</td></tr><tr><td width="194">Day & Night</td><td width="487">IR cut filter</td></tr><tr><td width="194">Angle Adjustment</td><td width="487">Pan: 0° to 360°,tilt: 0° to 90°,rotate: 0° to 360°</td></tr><tr><td colspan="2" width="681"><strong>Lens</strong></td></tr><tr><td width="194">Lens Type</td><td width="487">Fixed focal lens, 2.8 and 4 mm optional</td></tr><tr><td width="194">Focal Length & FOV</td><td width="487">2.8 mm, horizontal FOV 104°, vertical FOV 54.4°, diagonal FOV 126.7°<p>4 mm, horizontal FOV 89.3°, vertical FOV 48.2°, diagonal FOV 106.5°</p></td></tr><tr><td width="194">Lens Mount</td><td width="487">M16</td></tr><tr><td width="194">Iris Type</td><td width="487">Fixed</td></tr><tr><td width="194">Aperture</td><td width="487">F1.0</td></tr><tr><td width="194">Depth of Field</td><td width="487">2.8 mm: 2.5 m to ∞<p>4 mm: 2.8 m to ∞</p></td></tr><tr><td colspan="2" width="681"><strong>DORI</strong></td></tr><tr><td width="194">DORI</td><td width="487">2.8 mm, D: 61 m, O: 24 m, R: 12 m, I: 6 m<p>4 mm, D: 68 m, O: 27 m, R: 13 m, I: 6 m</p></td></tr><tr><td colspan="2" width="681"><strong>Illuminator</strong></td></tr><tr><td width="194">Supplement Light Type</td><td width="487">IR,White Light</td></tr><tr><td width="194">Supplement Light Range</td><td width="487">IR: up to 30 m<p>White Light: up to 20 m</p></td></tr><tr><td width="194">Smart Supplement Light</td><td width="487">Yes</td></tr><tr><td width="194">IR Wavelength</td><td width="487">850 nm</td></tr><tr><td colspan="2" width="681"><strong>Video</strong></td></tr><tr><td width="194"><strong> </strong><p><strong> </strong></p><p>Main Stream</p></td><td width="487">50 Hz:<p>25 fps (2560 × 1440, 1920 × 1080, 1280 × 720)</p><p>60 Hz:</p><p>24 fps (2560 × 1440, 1920 × 1080, 1280 × 720)</p></td></tr><tr><td width="194">Sub-Stream</td><td width="487">50 Hz: 25 fps (768 × 432, 640 × 360)<p>60 Hz: 24 fps (768 × 432, 640 × 360)</p></td></tr><tr><td width="194">Video Compression</td><td width="487">Main stream: H.265+/H.265/H.264+/H.264,<p>Sub-stream: H.265/H.264/MJPEG</p></td></tr><tr><td width="194">Video Bit Rate</td><td width="487">32 Kbps to 16 Mbps</td></tr><tr><td width="194">H.264 Type</td><td width="487">Baseline Profile,Main Profile,High Profile</td></tr><tr><td width="194">H.265 Type</td><td width="487">Main Profile</td></tr><tr><td width="194">Bit Rate Control</td><td width="487">CBR,VBR</td></tr><tr><td width="194">Scalable Video Coding (SVC)</td><td width="487">H.264 and H.265 encoding</td></tr><tr><td width="194">Region of Interest (ROI)</td><td width="487">1 fixed region for main stream</td></tr><tr><td colspan="2" width="681"><strong>Audio</strong></td></tr><tr><td width="194">Audio Type</td><td width="487">Mono sound</td></tr><tr><td width="194">Audio Compression</td><td width="487">G.711/G.722.1/G.726/MP2L2/PCM/MP3/AAC-LC</td></tr><tr><td width="194">Audio Bit Rate</td><td width="487">64 Kbps (G.711)/16 Kbps (G.722.1)/16 Kbps (G.726)/32 to 160 Kbps (MP2L2)/16 to 64<p>Kbps (AAC-LC)</p></td></tr><tr><td width="194">Audio Sampling Rate</td><td width="487">8 kHz/16 kHz</td></tr></tbody></table>
|
||||||
|
|
||||||
|
<table><tbody><tr><td width="199">Environment Noise Filtering</td><td width="482">Yes</td></tr><tr><td colspan="2" width="681"><strong>Network</strong></td></tr><tr><td width="199">Protocols</td><td width="482">TCP/IP, ICMP, DHCP, DNS, HTTP, RTP, RTSP, RTCP, NTP, IPv4, IPv6, IGMP, UDP, QoS,<p>FTP, SMTP</p></td></tr><tr><td width="199">Simultaneous Live View</td><td width="482">Up to 6 channels</td></tr><tr><td width="199">API</td><td width="482">ONVIF (Profile S, Profile G),ISAPI,SDK</td></tr><tr><td width="199">User/Host</td><td width="482">Up to 32 users<p>3 user levels: administrator, operator, and user</p></td></tr><tr><td width="199"><strong> </strong><p>Security</p></td><td width="482">Password protection, complicated password, watermark, basic and digest<p>authentication for HTTP, WSSE and digest authentication for Open Network Video</p><p>Interface, security audit log, host authentication (MAC address)</p></td></tr><tr><td width="199">Client</td><td width="482">iVMS-4200,Hik-Connect</td></tr><tr><td width="199">Web Browser</td><td width="482">Plug-in required live view: Chrome 80+, Firefox 80+, Edge 89+, Safari 13+,<p>Plug-in free live view: Chrome 80+, Firefox 80+, Edge 89+</p></td></tr><tr><td colspan="2" width="681"><strong>Image</strong></td></tr><tr><td width="199">Image Settings</td><td width="482">Rotate mode,saturation,brightness,contrast,sharpness,gain,white balance,adjustable<p>by client software or web browser</p></td></tr><tr><td width="199">Day/Night Switch</td><td width="482">Day,Night,Auto,Schedule</td></tr><tr><td width="199">Wide Dynamic Range (WDR)</td><td width="482">120 dB</td></tr><tr><td width="199">SNR</td><td width="482">≥ 52 dB</td></tr><tr><td width="199">Image Enhancement</td><td width="482">BLC,HLC,3D DNR</td></tr><tr><td width="199">Privacy Mask</td><td width="482">4 programmable polygon privacy masks</td></tr><tr><td colspan="2" width="681"><strong>Interface</strong></td></tr><tr><td width="199">Ethernet Interface</td><td width="482">1 RJ45 10 M/100 M self-adaptive Ethernet port</td></tr><tr><td width="199"><strong> </strong><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p>On-Board Storage</p></td><td width="482">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB/LIUF: Yes<p>-LIU: NA</p></td></tr><tr><td width="199">Built-in Microphone</td><td width="482">Yes,1 built-in microphone</td></tr></tbody></table>
|
||||||
|
|
||||||
|
| Built-in Speaker | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB: Yes \-LIU/LIUF: NA |
|
||||||
|
| --- | --- |
|
||||||
|
| Audio | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: NA |
|
||||||
|
| Alarm | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: NA |
|
||||||
|
| Reset Key | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB/LIUF: Yes, \-LIU: NA |
|
||||||
|
|
||||||
|
<table><tbody><tr><td colspan="2" width="681"><strong>Event</strong></td></tr><tr><td width="192">Basic Event</td><td width="489">Motion detection (support alarm triggering by specified target types (human and<p>vehicle)),video tampering alarm,exception</p></td></tr><tr><td width="192">Linkage</td><td width="489">Upload to FTP,notify surveillance center,send email,trigger recording,trigger capture</td></tr><tr><td colspan="2" width="681"><strong>General</strong></td></tr><tr><td width="192">Power</td><td width="489">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: 12 VDC ± 25%, 0.58 A, max. 7 W, Ø5.5 mm<p>coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 9 W LIUF: 12 VDC ± 25%, 0.67 A, max. 8 W, Ø5.5 mm coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 10 W</p><p>-SL/SRB: 12 VDC ± 25%, 0.84 A, max. 10.1 W, Ø5.5 mm coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 12.1 W</p><p> </p></td></tr><tr><td width="192"><strong> </strong><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p>Dimension</p></td><td width="489">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: 69.1 mm × 66.9 mm × 172.9 mm (2.7″ × 2.6″ ×<p>6.8″)</p><p>-LIUF/SL/SRB: 69.1 mm × 67.4 mm × 179 mm (2.7″ × 2.7″ × 7.1″)</p><p> </p></td></tr><tr><td width="192">Package Dimension</td><td width="489">210 mm × 116 mm × 106 mm (8.3″ × 4.6″ × 4.2″)</td></tr></tbody></table>
|
||||||
|
|
||||||
|
| Weight | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: Approx. 310 g (0.7 lb.) \-LIUF: Approx. 315 g (0.7 lb.) \-SL/SRB: Approx. 325 g (0.7 lb.) |
|
||||||
|
| --- | --- |
|
||||||
|
| With Package Weight | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: Approx. 465 g (1.0 lb.) \-LIUF: Approx. 470 g (1.0 lb.) \-SL/SRB: Approx. 495 g (1.1 lb.) |
|
||||||
|
| Storage Conditions | \-30 °C to 60 °C (-22 °F to 140 °F). Humidity 95% or less (non-condensing) |
|
||||||
|
| Startup and Operating Conditions | \-30 °C to 60 °C (-22 °F to 140 °F). Humidity 95% or less (non-condensing) |
|
||||||
|
| Language | English, Russian, Ukrainian, Arabic, Spanish, French, Portuguese, Turkish, Polish, German |
|
||||||
|
| General Function | Heartbeat,mirror,flash log,password reset via email,password protection,anti -banding |
|
||||||
|
| Flashing Light | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB: Yes \-LIU/LIUF: NA |
|
||||||
|
|
||||||
|
<table><tbody><tr><td colspan="2" width="681"><strong>Approval</strong></td></tr><tr><td width="194">EMC</td><td width="487">CE-EMC: EN 55032:2015+A1:2020, EN 50130-4:2011+A1:2014, EN IEC<p>61000-3-2:2019+A1:2021, EN 61000-3-3:2013+A1:2019+A2:2021</p></td></tr><tr><td width="194">Safety</td><td width="487">CB: IEC 62368-1: 2014+A11,<p>CE-LVD: EN 62368-1: 2014/A11: 2017</p></td></tr><tr><td width="194">Environment</td><td width="487">CE-RoHS: 2011/65/EU,<p>WEEE: 2012/19/EU</p></td></tr><tr><td width="194">Protection</td><td width="487">IP67: IEC 60529-2013</td></tr></tbody></table>
|
||||||
|
|
||||||
|
#### Available Model
|
||||||
|
|
||||||
|
- DS-2CD1047G3H-LIUF/SRB(2.8mm)
|
||||||
|
- DS-2CD1047G3H-LIUF/SRB(4mm)
|
||||||
|
- DS-2CD1047G3H-LIU(2.8mm)
|
||||||
|
- DS-2CD1047G3H-LIU(4mm)
|
||||||
|
- DS-2CD1047G3H-LIUF(2.8mm)
|
||||||
|
- DS-2CD1047G3H-LIUF(4mm)
|
||||||
|
- DS-2CD1047G3H-LIUF/SL(2.8mm)
|
||||||
|
- DS-2CD1047G3H-LIUF/SL(4mm)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
type: source
|
||||||
|
tags: [parking, hardware, cameras, hikvision, datasheet]
|
||||||
|
sources: [DS-2CD1047G3H-LIU]
|
||||||
|
updated: 2026-08-23
|
||||||
|
---
|
||||||
|
|
||||||
|
# Source: HIKVISION DS-2CD1047G3H-LIU-F datasheet
|
||||||
|
|
||||||
|
Vendor spec sheet (`raw/DS-2CD1047G3H-LIU.md`) for the **DS-2CD1047G3H-LIU** — the exit-lane
|
||||||
|
camera at `10.0.10.13` (park-buzi) already covered extensively in [[lpr-camera]] for its
|
||||||
|
persistent main-stream ISAPI 503 (`deviceBusy`) and its 2026-06-27 config-DB-corruption incident.
|
||||||
|
This is the first *vendor-sourced* spec data for the model; everything before it was field-derived.
|
||||||
|
|
||||||
|
## Key takeaways
|
||||||
|
|
||||||
|
- **4 MP, max resolution 2560×1440** (ColorVu 3.0 — color night imaging, not IR-only).
|
||||||
|
- **Main stream: H.265+/H.265/H.264+/H.264 only — no MJPEG option.** **Sub-stream: H.265/H.264/
|
||||||
|
MJPEG** (MJPEG is sub-only). This is a concrete, spec-level asymmetry that plausibly explains
|
||||||
|
*why* the ISAPI on-demand JPEG snapshot is reliable on sub but structurally broken on main: sub
|
||||||
|
can serve a snapshot natively, main has to transcode out of H.264/H.265 on demand. Consistent
|
||||||
|
with — and a likely root cause for — the persistent `deviceBusy` behavior already logged in
|
||||||
|
[[lpr-camera]].
|
||||||
|
- **Bit rate range 32 Kbps–16 Mbps.** The site's main-stream config (6144–12288 Kbps, confirmed
|
||||||
|
live 2026-08-23) is well inside spec — rules out "misconfigured over the camera's own ceiling"
|
||||||
|
as a cause, confirming what live testing already showed.
|
||||||
|
- **ROI: 1 fixed region for main stream** — another main-only constraint/asymmetry vs. sub.
|
||||||
|
- **API: ONVIF (Profile S, Profile G), ISAPI, SDK.** ISAPI is fully in-spec for this model — the
|
||||||
|
503 is a real firmware/hardware limitation, not a case of using an unsupported API.
|
||||||
|
- **Protocols include RTSP** (alongside HTTP/ONVIF/etc.) — confirms RTSP is a documented, supported
|
||||||
|
surface on this model, not a workaround outside its design.
|
||||||
|
- Simultaneous live view: up to 6 channels. Available in `-LIU`/`-LIUF`/`-LIUF/SL`/`-LIUF/SRB`
|
||||||
|
variants at 2.8mm/4mm focal lengths; the deployed unit is the base `-LIU`.
|
||||||
|
|
||||||
|
## How this changes the picture
|
||||||
|
|
||||||
|
Confirms rather than overturns [[lpr-camera]]'s existing conclusion (sub-stream-only for ISAPI
|
||||||
|
snapshots on this model) — but gives it a concrete mechanical explanation (main has no MJPEG path)
|
||||||
|
instead of just an empirically-observed limitation. See [[lpr-camera]] § "Main-stream ISAPI
|
||||||
|
snapshot 503 vs. RTSP" for the 2026-08-23 live comparison against a `DS-2CD1043G2-LIU` sibling
|
||||||
|
(main-stream ISAPI snapshot works fine there) and the confirmed RTSP frame-grab workaround.
|
||||||
Reference in New Issue
Block a user