adding initial project

This commit is contained in:
Mitchell R 2026-05-10 01:09:13 +02:00
commit 2fd2502b85
No known key found for this signature in database
78 changed files with 8157 additions and 0 deletions

45
.gitignore vendored Normal file
View file

@ -0,0 +1,45 @@
# Dependencies
node_modules/
# Build output
server/lib/
server/bsb-plugin.json
nodered/lib/
kiosk/target/
# BSB generated
.bsb/
# Runtime data
*.db
*.db-wal
*.db-shm
secret.key
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Editors
.vscode/
!.vscode/settings.json
!.vscode/extensions.json
.idea/
*.swp
*.swo
*~
# Env / secrets
.env
.env.*
!.env.example
# Logs
*.log
npm-debug.log*
# Misc
*.tgz
*.tsbuildinfo
/old-python/

379
package-lock.json generated Normal file
View file

@ -0,0 +1,379 @@
{
"name": "betterframe",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "betterframe",
"version": "0.1.0",
"license": "AGPL-3.0-only OR Commercial",
"workspaces": [
"server",
"nodered"
],
"engines": {
"node": ">=23.0.0",
"npm": ">=11.0.0"
}
},
"node_modules/@anyvali/js": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@anyvali/js/-/js-0.2.0.tgz",
"integrity": "sha512-iJq1dZJTwgk3H99/+U+wl0bXbd4zvBv/8ycpl8vBwUPGmbqLjkP+lul0wbjzrVyfBJJJnwXpIRXgWwZn7eBCQg==",
"license": "MIT",
"engines": {
"node": ">=22"
}
},
"node_modules/@betterframe/server": {
"resolved": "server",
"link": true
},
"node_modules/@bsb/base": {
"version": "9.1.11",
"resolved": "https://registry.npmjs.org/@bsb/base/-/base-9.1.11.tgz",
"integrity": "sha512-Ys71H04Uc+pftGxWgfEGbmg1NSNxkz6trzmCTUHL+/eg6UqKDuYmvrzr/xB2tBvB5uArTsZ3m0wha+OqghTCZg==",
"license": "(AGPL-3.0-only OR Commercial)",
"dependencies": {
"@anyvali/js": "^0.2.0",
"chokidar": "^5.0.0",
"uuid": "^13.0.0",
"yaml": "^2.8.2"
},
"bin": {
"bsb": "lib/cli.js",
"bsb-client-cli": "lib/scripts/bsb-client-cli.js",
"bsb-plugin-cli": "lib/scripts/bsb-plugin-cli.js"
},
"engines": {
"node": ">=23.0.0",
"npm": ">=11.0.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"license": "MIT"
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@types/node": {
"version": "25.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/argon2": {
"version": "0.44.0",
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
"integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@phc/format": "^1.0.0",
"cross-env": "^10.0.0",
"node-addon-api": "^8.5.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/h3": {
"version": "2.0.1-rc.22",
"resolved": "https://registry.npmjs.org/h3/-/h3-2.0.1-rc.22.tgz",
"integrity": "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA==",
"license": "MIT",
"dependencies": {
"rou3": "^0.8.1",
"srvx": "^0.11.15"
},
"bin": {
"h3": "bin/h3.mjs"
},
"engines": {
"node": ">=20.11.1"
},
"peerDependencies": {
"crossws": "^0.4.1"
},
"peerDependenciesMeta": {
"crossws": {
"optional": true
}
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jsx-htmx": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/jsx-htmx/-/jsx-htmx-2.0.2.tgz",
"integrity": "sha512-c4PHygDkwo9Xc7fN8iRYw29KAt5Oz+aCTuxneNpLOvt0D2ltTsd2ASreAFtgjDX1D7sOpKM6RUiuh78nvifN5g==",
"license": "AGPL-3.0-only",
"dependencies": {
"csstype": "^3.2.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/otpauth": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
"integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rou3": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.8.1.tgz",
"integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/srvx": {
"version": "0.11.15",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
"integrity": "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==",
"license": "MIT",
"bin": {
"srvx": "bin/srvx.mjs"
},
"engines": {
"node": ">=20.16.0"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"server": {
"name": "@betterframe/server",
"version": "0.1.0",
"license": "AGPL-3.0-only OR Commercial",
"dependencies": {
"@anyvali/js": "^0.2.0",
"@bsb/base": "^9.1.11",
"argon2": "^0.44.0",
"h3": "^2.0.1-rc.22",
"jsx-htmx": "^2.0.2",
"otpauth": "^9.5.1"
},
"devDependencies": {
"@types/node": "^25.0.0",
"typescript": "^6.0.3"
},
"peerDependencies": {
"@bsb/base": "^9.1.11"
}
}
}
}

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "betterframe",
"version": "0.1.0",
"private": true,
"description": "BetterFrame — multi-camera display system, Pi-5-first.",
"license": "AGPL-3.0-only OR Commercial",
"type": "module",
"engines": {
"node": ">=23.0.0",
"npm": ">=11.0.0"
},
"workspaces": [
"server",
"nodered"
],
"scripts": {
"build": "npm -w server run build",
"dev": "npm -w server run dev",
"start": "npm -w server start",
"test": "npm -w server test",
"schemas:export": "npm -w server run schemas:export",
"vendor:anyvali-js": "./scripts/vendor-anyvali-js.sh",
"vendor:htmx": "./scripts/vendor-htmx.sh"
}
}

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Re-vendor @anyvali/js from npm into server/src/web-static/anyvali/
# Usage: ./scripts/vendor-anyvali-js.sh [version]
set -euo pipefail
VERSION="${1:-0.2.0}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="$ROOT/server/src/web-static/anyvali"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
cd "$TMP"
echo "fetching @anyvali/js@$VERSION ..."
npm pack "@anyvali/js@$VERSION" --silent
TGZ=$(ls anyvali-js-*.tgz | head -1)
tar xzf "$TGZ"
echo "rebuilding $DEST ..."
rm -rf "$DEST"
mkdir -p "$DEST"
cd package/dist
find . -name '*.js' | while read -r f; do
mkdir -p "$DEST/$(dirname "$f")"
cp "$f" "$DEST/$f"
done
echo "$VERSION" > "$DEST/VERSION"
echo "done — vendored $VERSION ($(find "$DEST" -name '*.js' | wc -l) files, $(du -sh "$DEST" | cut -f1))"

15
scripts/vendor-htmx.sh Normal file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Re-vendor htmx into server/src/web-static/htmx.min.js
set -euo pipefail
VERSION="${1:-2.0.10}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="$ROOT/server/src/web-static/htmx.min.js"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
cd "$TMP"
echo "fetching htmx.org@$VERSION ..."
npm pack "htmx.org@$VERSION" --silent
TGZ=$(ls htmx.org-*.tgz | head -1)
tar xzf "$TGZ" package/dist/htmx.min.js
cp package/dist/htmx.min.js "$DEST"
echo "done — vendored htmx@$VERSION ($(wc -c < "$DEST") bytes)"

92
sec-config.yaml Normal file
View file

@ -0,0 +1,92 @@
# BSB runtime configuration for BetterFrame server.
#
# Profile: 'default' — single-host install where the server, node-red, and
# (optionally) one kiosk all run on the same Pi. For multi-kiosk deployments
# the server is the same; kiosks have their own runtime config.
#
# Override individual values via env: BSB_<plugin>_<key>=value (consult BSB
# docs for the exact env-override semantics for v9).
default:
observable:
observable-default:
plugin: observable-default
enabled: true
config: {}
events:
events-default:
plugin: events-default
enabled: true
services:
# ----- Foundations -----
service-store:
plugin: service-store
enabled: true
config:
sqlitePath: /var/lib/betterframe/betterframe.db
service-secrets:
plugin: service-secrets
enabled: true
config:
# In production, leave both unset and rely on systemd-creds.
# In dev, the plugin generates a key in dataDir/secret.key (0600) and warns.
dataDir: /var/lib/betterframe
service-auth:
plugin: service-auth
enabled: true
config:
sessionIdleSeconds: 43200 # 12h
sessionMaxSeconds: 2592000 # 30d
loginLockoutThreshold: 8
loginLockoutSeconds: 900 # 15m
argon2Memory: 65536 # KiB; tuned for Pi5 ~100ms
argon2TimeCost: 3
argon2Parallelism: 2
# ----- HTTP surfaces (each its own h3 listener; proxy fronts them) -----
service-admin-http:
plugin: service-admin-http
enabled: true
config:
host: 127.0.0.1
port: 18080
service-api-http:
plugin: service-api-http
enabled: true
config:
host: 127.0.0.1
port: 18081
service-coordinator-ws:
plugin: service-coordinator-ws
enabled: true
config:
host: 127.0.0.1
port: 18082
# ----- Domain orchestrators -----
service-pairing:
plugin: service-pairing
enabled: true
config:
codeTtlSeconds: 600 # 10m
service-bundle:
plugin: service-bundle
enabled: true
config: {}
# ----- Bridges -----
service-nodered-bridge:
plugin: service-nodered-bridge
enabled: true
config:
noderedUrl: http://127.0.0.1:1880
service-cec-relay:
plugin: service-cec-relay
enabled: true
config: {}

38
server/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "@betterframe/server",
"version": "0.1.0",
"private": true,
"description": "BetterFrame backend — BSB v9 service plugins.",
"license": "AGPL-3.0-only OR Commercial",
"type": "module",
"files": [
"lib/**/*",
"README.md",
"bsb-plugin.json",
"bsb-tests.json"
],
"scripts": {
"build": "bsb-plugin-cli build",
"clean": "bsb-plugin-cli clean",
"start": "bsb-plugin-cli start",
"dev": "bsb-plugin-cli dev",
"test": "bsb-plugin-cli test",
"schemas:export": "node --enable-source-maps lib/scripts/export-schemas.js"
},
"bsb": {},
"dependencies": {
"@anyvali/js": "^0.2.0",
"@bsb/base": "^9.1.11",
"argon2": "^0.44.0",
"h3": "^2.0.1-rc.22",
"jsx-htmx": "^2.0.2",
"otpauth": "^9.5.1"
},
"devDependencies": {
"@types/node": "^25.0.0",
"typescript": "^6.0.3"
},
"peerDependencies": {
"@bsb/base": "^9.1.11"
}
}

View file

@ -0,0 +1,167 @@
/**
* service-admin-http h3 listener for the admin UI and admin API.
*
* Serves jsx-htmx rendered pages at /admin/* and JSON endpoints at
* /api/admin/*. Port 18080 behind the Angie proxy.
*/
import * as av from "@anyvali/js";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
type Observable,
} from "@bsb/base";
import { H3, serve } from "h3";
import type { Server } from "srvx";
import type { Plugin as StorePlugin } from "../service-store/index.js";
import type { Plugin as AuthPlugin } from "../service-auth/index.js";
import type { Plugin as SecretsPlugin } from "../service-secrets/index.js";
import { registerMiddleware } from "./middleware.js";
import { registerSetupRoutes } from "./routes-setup.js";
import { registerAuthRoutes } from "./routes-auth.js";
import { registerAdminRoutes } from "./routes-admin.js";
import { registerAccountRoutes } from "./routes-account.js";
import { registerStaticRoutes } from "./routes-static.js";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object(
{
host: av.string().default("127.0.0.1"),
port: av.int().min(1).max(65535).default(18080),
},
{ unknownKeys: "strip" },
);
export const Config = createConfigSchema(
{
name: "service-admin-http",
description: "h3 HTTP server for admin UI and admin API endpoints.",
tags: ["service", "http", "admin"],
},
ConfigSchema,
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {},
onBroadcast: {},
});
// ---- Deps interface shared with route modules -------------------------------
export interface AdminDeps {
store: StorePlugin;
auth: AuthPlugin;
secrets: SecretsPlugin;
cookieName: string;
}
// ---- Plugin -----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-store", "service-secrets", "service-auth"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
private _store?: StorePlugin;
private _auth?: AuthPlugin;
private _secrets?: SecretsPlugin;
private server?: Server;
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
// TODO(handoff): replace with BSB plugin clients
setSiblings(store: StorePlugin, auth: AuthPlugin, secrets: SecretsPlugin): void {
this._store = store;
this._auth = auth;
this._secrets = secrets;
}
get store(): StorePlugin {
if (!this._store) throw new Error("service-admin-http: siblings not wired");
return this._store;
}
get auth(): AuthPlugin {
if (!this._auth) throw new Error("service-admin-http: siblings not wired");
return this._auth;
}
get secrets(): SecretsPlugin {
if (!this._secrets) throw new Error("service-admin-http: siblings not wired");
return this._secrets;
}
async init(obs: Observable): Promise<void> {
const app = new H3();
const deps: AdminDeps = {
store: this.store,
auth: this.auth,
secrets: this.secrets,
cookieName: this.auth.config.cookieName,
};
// Order matters: middleware first, then routes
registerMiddleware(app, deps);
registerStaticRoutes(app);
registerSetupRoutes(app, deps);
registerAuthRoutes(app, deps);
registerAdminRoutes(app, deps);
registerAccountRoutes(app, deps);
// Health/readiness/version (no auth)
app.get("/healthz", () => ({ status: "ok" }));
app.get("/readyz", () => {
try {
deps.store.repo.isSetupComplete(); // touches DB
return { status: "ready" };
} catch {
return { status: "not_ready" };
}
});
app.get("/version", () => ({
name: "betterframe",
version: "0.1.0",
now: new Date().toISOString(),
}));
// Root redirect
app.get("/", () => {
if (!deps.store.repo.isSetupComplete()) {
return new Response(null, { status: 302, headers: { location: "/setup" } });
}
return new Response(null, { status: 302, headers: { location: "/admin/" } });
});
this.server = serve(app, {
port: this.config.port,
hostname: this.config.host,
});
obs.log.info("admin-http listening on {host}:{port}", {
host: this.config.host,
port: this.config.port,
});
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {
if (this.server) {
await this.server.close();
}
}
}

View file

@ -0,0 +1,70 @@
/**
* Auth & setup gate middleware for admin-http.
*/
import { type H3, getCookie, createError, type H3Event, getRequestPath } from "h3";
import type { AdminDeps } from "./index.js";
import type { User, Session } from "../../shared/types.js";
/** Augment h3 event context with resolved auth info. */
declare module "h3" {
interface H3EventContext {
user?: User;
session?: Session;
}
}
/**
* Resolve session from cookie. Returns null if invalid/missing.
*/
function resolveSession(event: H3Event, deps: AdminDeps): { user: User; session: Session } | null {
const cookie = getCookie(event, deps.cookieName);
if (!cookie) return null;
return deps.auth.resolveSession(cookie);
}
export function registerMiddleware(app: H3, deps: AdminDeps): void {
// Setup gate: if setup not complete, only /setup, /static, /healthz, /readyz, /version allowed
app.use((event) => {
const path = getRequestPath(event);
// Always pass through non-gated paths
if (
path === "/setup" ||
path.startsWith("/static/") ||
path === "/healthz" ||
path === "/readyz" ||
path === "/version" ||
path === "/"
) {
return;
}
// If setup not complete, block everything except setup flow
if (!deps.store.repo.isSetupComplete()) {
if (!path.startsWith("/auth/")) {
return new Response(null, { status: 302, headers: { location: "/setup" } });
}
}
// Auth pages don't require session (login/totp/recovery)
if (path.startsWith("/auth/")) {
return;
}
// Admin pages require valid session
if (path.startsWith("/admin") || path.startsWith("/api/admin")) {
const resolved = resolveSession(event, deps);
if (!resolved) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
}
// TOTP pending — only allow /auth/totp and /auth/recovery
if (resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/auth/totp" } });
}
// Attach to context for downstream handlers
event.context.user = resolved.user;
event.context.session = resolved.session;
return;
}
});
}

View file

@ -0,0 +1,168 @@
/**
* Account management routes password change, TOTP enrollment.
*/
import { type H3, html, readBody } from "h3";
import type { AdminDeps } from "./index.js";
import { AccountPage, TotpEnrollPage } from "../../web-templates/admin-pages.js";
export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
// ---- Account page ---------------------------------------------------------
app.get("/admin/account", (event) => {
const user = event.context.user!;
return html(AccountPage({ user: user.username, totpEnabled: user.totp_enabled }));
});
// ---- Change password ------------------------------------------------------
app.post("/admin/account/password", async (event) => {
const user = event.context.user!;
const body = await readBody<{ current_password?: string; new_password?: string }>(event);
const current = body?.current_password ?? "";
const newPw = body?.new_password ?? "";
if (!current || !newPw) {
return html(AccountPage({
user: user.username,
totpEnabled: user.totp_enabled,
error: "Both current and new password required.",
}));
}
if (newPw.length < 12) {
return html(AccountPage({
user: user.username,
totpEnabled: user.totp_enabled,
error: "New password must be at least 12 characters.",
}));
}
const valid = await deps.auth.verifyPassword(current, user.password_hash);
if (!valid) {
return html(AccountPage({
user: user.username,
totpEnabled: user.totp_enabled,
error: "Current password incorrect.",
}));
}
const hash = await deps.auth.hashPassword(newPw);
deps.store.repo.updateUser(user.id, { password_hash: hash });
// Revoke all sessions (force re-login)
deps.store.repo.revokeAllSessionsForUser(user.id);
return new Response(null, {
status: 302,
headers: { location: "/auth/login" },
});
});
// ---- TOTP: begin enrollment -----------------------------------------------
app.post("/admin/account/totp/begin", (event) => {
const user = event.context.user!;
if (user.totp_enabled) {
return html(AccountPage({
user: user.username,
totpEnabled: true,
error: "TOTP already enabled.",
}));
}
const secret = deps.auth.generateTotpSecret();
const uri = deps.auth.totpProvisioningUri(user.username, secret);
const codes = deps.auth.generateRecoveryCodes();
// Store unconfirmed secret + codes
const encrypted = deps.auth.encryptTotpSecret(secret);
deps.store.repo.updateUser(user.id, {
totp_secret_encrypted: encrypted,
});
return html(TotpEnrollPage({
user: user.username,
secret,
provisioningUri: uri,
recoveryCodes: codes,
}));
});
// ---- TOTP: confirm enrollment ---------------------------------------------
app.post("/admin/account/totp/confirm", async (event) => {
const user = event.context.user!;
const body = await readBody<{ code?: string; recovery_codes?: string }>(event);
const code = (body?.code ?? "").trim().replace(/\s/g, "");
if (!code || code.length !== 6) {
return html(AccountPage({
user: user.username,
totpEnabled: false,
error: "Enter a valid 6-digit code.",
}));
}
if (!user.totp_secret_encrypted) {
return html(AccountPage({
user: user.username,
totpEnabled: false,
error: "No TOTP enrollment in progress. Start again.",
}));
}
const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted);
const valid = deps.auth.verifyTotpCode(secret, code);
if (!valid) {
return html(AccountPage({
user: user.username,
totpEnabled: false,
error: "Invalid code. Scan the QR code again and enter the current code.",
}));
}
// Hash recovery codes and save
const codesJson = body?.recovery_codes ?? "[]";
const codes: string[] = JSON.parse(codesJson);
const hashed = await deps.auth.hashRecoveryCodes(codes);
deps.store.repo.updateUser(user.id, {
totp_enabled: true,
recovery_codes_hashed: hashed,
});
return new Response(null, {
status: 302,
headers: { location: "/admin/account" },
});
});
// ---- TOTP: disable --------------------------------------------------------
app.post("/admin/account/totp/disable", async (event) => {
const user = event.context.user!;
const body = await readBody<{ password?: string }>(event);
const password = body?.password ?? "";
const valid = await deps.auth.verifyPassword(password, user.password_hash);
if (!valid) {
return html(AccountPage({
user: user.username,
totpEnabled: true,
error: "Password incorrect.",
}));
}
deps.store.repo.updateUser(user.id, {
totp_enabled: false,
totp_secret_encrypted: null,
recovery_codes_hashed: [],
});
return new Response(null, {
status: 302,
headers: { location: "/admin/account" },
});
});
}

View file

@ -0,0 +1,191 @@
/**
* Admin page routes overview, cameras, kiosks, labels, etc.
*/
import { type H3, html, readBody } from "h3";
import type { AdminDeps } from "./index.js";
import {
OverviewPage,
CamerasPage,
CameraNewPage,
KiosksPage,
SimpleListPage,
} from "../../web-templates/admin-pages.js";
export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
// ---- Overview -------------------------------------------------------------
app.get("/admin/", (event) => {
const user = event.context.user!;
const cameras = deps.store.repo.listCameras();
const kiosks = deps.store.repo.listKiosks();
const layouts = deps.store.repo.listDisplays(); // for count
const events = deps.store.repo.recentEvents(10);
const onlineKiosks = kiosks.filter((k) => {
if (!k.last_seen_at) return false;
return Date.now() - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000;
});
return html(OverviewPage({
user: user.username,
cameraCount: cameras.length,
kioskCount: kiosks.length,
onlineKioskCount: onlineKiosks.length,
layoutCount: layouts.length,
events,
}));
});
// Redirect /admin to /admin/
app.get("/admin", () => {
return new Response(null, { status: 301, headers: { location: "/admin/" } });
});
// ---- Cameras --------------------------------------------------------------
app.get("/admin/cameras", (event) => {
const user = event.context.user!;
const cameras = deps.store.repo.listCameras();
const streamCounts = new Map<number, number>();
for (const cam of cameras) {
streamCounts.set(cam.id, deps.store.repo.listCameraStreams(cam.id).length);
}
return html(CamerasPage({ user: user.username, cameras, streamCounts }));
});
app.get("/admin/cameras/new", (event) => {
const user = event.context.user!;
return html(CameraNewPage({ user: user.username }));
});
app.post("/admin/cameras/new", async (event) => {
const user = event.context.user!;
const body = await readBody<Record<string, string>>(event);
const name = (body?.["name"] ?? "").trim();
const type = body?.["type"] as "rtsp" | "onvif" | undefined;
const errors: string[] = [];
if (!name || name.length > 128) {
errors.push("Name required (max 128 chars).");
} else if (deps.store.repo.getCameraByName(name)) {
errors.push("Camera name already in use.");
}
if (type !== "rtsp" && type !== "onvif") {
errors.push("Select camera type.");
}
let rtspUrl: string | undefined;
let onvifHost: string | undefined;
let onvifPort: number | undefined;
let onvifUser: string | undefined;
let onvifPass: string | undefined;
if (type === "rtsp") {
rtspUrl = (body?.["rtsp_url"] ?? "").trim();
if (!rtspUrl) errors.push("RTSP URL required.");
} else if (type === "onvif") {
onvifHost = (body?.["onvif_host"] ?? "").trim();
onvifPort = parseInt(body?.["onvif_port"] ?? "80", 10);
onvifUser = (body?.["onvif_username"] ?? "").trim();
onvifPass = body?.["onvif_password"] ?? "";
if (!onvifHost) errors.push("ONVIF host required.");
}
if (errors.length > 0) {
return html(CameraNewPage({
user: user.username,
error: errors.join(" "),
values: body,
}));
}
const cam = deps.store.repo.createCamera({
name,
type: type!,
rtsp_url: rtspUrl ?? null,
onvif_host: onvifHost ?? null,
onvif_port: onvifPort ?? null,
onvif_username: onvifUser ?? null,
onvif_password: onvifPass ?? null,
});
// Create default main stream for RTSP cameras
if (type === "rtsp" && rtspUrl) {
deps.store.repo.createCameraStream({
camera_id: cam.id,
role: "main",
name: "Main",
rtsp_uri: rtspUrl,
});
}
return new Response(null, {
status: 302,
headers: { location: "/admin/cameras" },
});
});
// ---- Kiosks ---------------------------------------------------------------
app.get("/admin/kiosks", (event) => {
const user = event.context.user!;
const kiosks = deps.store.repo.listKiosks();
const pending = deps.store.repo.listPendingPairingCodes();
return html(KiosksPage({ user: user.username, kiosks, pendingCodes: pending }));
});
// ---- Simple list pages (templates, layouts, displays, labels) -------------
app.get("/admin/templates", (event) => {
const user = event.context.user!;
return html(SimpleListPage({
user: user.username,
pageTitle: "Layout Templates",
description: "Templates define named regions on a 12x12 grid. A visual template designer is coming.",
activeNav: "templates",
items: [], // TODO: list templates
}));
});
app.get("/admin/layouts", (event) => {
const user = event.context.user!;
return html(SimpleListPage({
user: user.username,
pageTitle: "Layouts",
description: "A layout binds cameras and other content into a template's regions for one display.",
activeNav: "layouts",
items: [], // TODO: list layouts
}));
});
app.get("/admin/displays", (event) => {
const user = event.context.user!;
const displays = deps.store.repo.listDisplays();
return html(SimpleListPage({
user: user.username,
pageTitle: "Displays",
description: "Physical HDMI displays. Primary display created during setup.",
activeNav: "displays",
items: displays.map((d) => ({
name: d.name,
detail: `${d.width_px}x${d.height_px} — index ${d.index}`,
})),
}));
});
app.get("/admin/labels", (event) => {
const user = event.context.user!;
const labels = deps.store.repo.listLabels();
return html(SimpleListPage({
user: user.username,
pageTitle: "Labels",
description: "Labels route cameras, layouts, and kiosks to each other across sites.",
activeNav: "labels",
items: labels.map((l) => ({
name: l.name,
detail: l.description ?? "",
badge: l.color ?? undefined,
})),
}));
});
}

View file

@ -0,0 +1,196 @@
/**
* Authentication routes: login, TOTP, recovery, logout.
*/
import { type H3, readBody, html, getCookie, setCookie, deleteCookie, getQuery, getRequestHeader } from "h3";
import type { AdminDeps } from "./index.js";
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
const COOKIE_OPTS = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/",
};
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
// ---- Login ----------------------------------------------------------------
app.get("/auth/login", (event) => {
const q = getQuery(event) as Record<string, string | undefined>;
const welcome = q["welcome"] === "1";
return html(LoginPage({ welcome }));
});
app.post("/auth/login", async (event) => {
const body = await readBody<{ username?: string; password?: string }>(event);
const username = (body?.username ?? "").trim();
const password = body?.password ?? "";
if (!username || !password) {
return html(LoginPage({ error: "Username and password required.", username }));
}
const user = deps.store.repo.getUserByUsername(username);
if (!user || !user.is_active) {
return html(LoginPage({ error: "Invalid credentials.", username }));
}
// Lockout check
if (user.locked_until) {
const lockEnd = new Date(user.locked_until);
if (lockEnd > new Date()) {
return html(LoginPage({ error: "Account locked. Try again later.", username }));
}
}
const valid = await deps.auth.verifyPassword(password, user.password_hash);
if (!valid) {
// Increment failed count
const count = user.failed_login_count + 1;
const patch: Record<string, unknown> = { failed_login_count: count };
if (count >= 8) {
patch["locked_until"] = new Date(Date.now() + 15 * 60 * 1000).toISOString();
}
deps.store.repo.updateUser(user.id, patch);
return html(LoginPage({ error: "Invalid credentials.", username }));
}
// Reset failed login count
deps.store.repo.updateUser(user.id, {
failed_login_count: 0,
locked_until: null,
last_login_at: new Date().toISOString(),
});
// Create session
const totpPending = user.totp_enabled;
const { cookieValue } = await deps.auth.createSession({
user,
userAgent: getRequestHeader(event, "user-agent") ?? null,
ipAddress: getRequestHeader(event, "x-forwarded-for")
?? getRequestHeader(event, "x-real-ip")
?? null,
totpPending,
});
setCookie(event, deps.cookieName, cookieValue, {
...COOKIE_OPTS,
maxAge: 30 * 24 * 60 * 60, // 30d absolute max
});
if (totpPending) {
return new Response(null, { status: 302, headers: { location: "/auth/totp" } });
}
return new Response(null, { status: 302, headers: { location: "/admin/" } });
});
// ---- TOTP -----------------------------------------------------------------
app.get("/auth/totp", (event) => {
const cookie = getCookie(event, deps.cookieName);
if (!cookie) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
}
const resolved = deps.auth.resolveSession(cookie);
if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
return html(TotpPage({}));
});
app.post("/auth/totp", async (event) => {
const cookie = getCookie(event, deps.cookieName);
if (!cookie) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
}
const resolved = deps.auth.resolveSession(cookie);
if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
const body = await readBody<{ code?: string }>(event);
const code = (body?.code ?? "").trim().replace(/\s/g, "");
if (!code || code.length !== 6) {
return html(TotpPage({ error: "Enter a 6-digit code." }));
}
const { user, session } = resolved;
if (!user.totp_secret_encrypted) {
return html(TotpPage({ error: "TOTP not configured for this account." }));
}
const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted);
const valid = deps.auth.verifyTotpCode(secret, code);
if (!valid) {
return html(TotpPage({ error: "Invalid code. Try again." }));
}
// Clear totp_pending
deps.store.repo.setSessionTotpPending(session.id, false);
return new Response(null, { status: 302, headers: { location: "/admin/" } });
});
// ---- Recovery code --------------------------------------------------------
app.get("/auth/recovery", (event) => {
const cookie = getCookie(event, deps.cookieName);
if (!cookie) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
}
const resolved = deps.auth.resolveSession(cookie);
if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
return html(RecoveryPage({}));
});
app.post("/auth/recovery", async (event) => {
const cookie = getCookie(event, deps.cookieName);
if (!cookie) {
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
}
const resolved = deps.auth.resolveSession(cookie);
if (!resolved || !resolved.session.totp_pending) {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
const body = await readBody<{ code?: string }>(event);
const code = (body?.code ?? "").trim().toUpperCase().replace(/\s/g, "");
if (!code) {
return html(RecoveryPage({ error: "Enter a recovery code." }));
}
const { user, session } = resolved;
const hashedCodes: string[] = user.recovery_codes_hashed ?? [];
const result = await deps.auth.consumeRecoveryCode(code, hashedCodes);
if (!result.ok) {
return html(RecoveryPage({ error: "Invalid recovery code." }));
}
// Update remaining codes
deps.store.repo.updateUser(user.id, {
recovery_codes_hashed: result.remaining,
});
// Clear totp_pending
deps.store.repo.setSessionTotpPending(session.id, false);
return new Response(null, { status: 302, headers: { location: "/admin/" } });
});
// ---- Logout ---------------------------------------------------------------
app.post("/auth/logout", (event) => {
const cookie = getCookie(event, deps.cookieName);
if (cookie) {
const resolved = deps.auth.resolveSession(cookie);
if (resolved) {
deps.auth.revokeSession(resolved.session.id);
}
}
deleteCookie(event, deps.cookieName, { path: "/" });
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
});
}

View file

@ -0,0 +1,64 @@
/**
* First-run setup routes.
*
* GET /setup render setup form
* POST /setup create admin user, provision cluster key, create default display
*/
import { type H3, readBody, html } from "h3";
import type { AdminDeps } from "./index.js";
import { SetupPage } from "../../web-templates/auth-pages.js";
export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
app.get("/setup", () => {
if (deps.store.repo.isSetupComplete()) {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
return html(SetupPage({}));
});
app.post("/setup", async (event) => {
if (deps.store.repo.isSetupComplete()) {
return new Response(null, { status: 302, headers: { location: "/admin/" } });
}
const body = await readBody<{ username?: string; password?: string }>(event);
const username = (body?.username ?? "").trim();
const password = body?.password ?? "";
const errors: string[] = [];
// Validate
if (!username || username.length < 3 || username.length > 64) {
errors.push("Username must be 364 characters.");
} else if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
errors.push("Username may only contain letters, digits, underscore, or hyphen.");
}
if (password.length < 12) {
errors.push("Password must be at least 12 characters.");
}
if (errors.length > 0) {
return html(SetupPage({ error: errors.join(" "), username }));
}
// Create admin user
const hash = await deps.auth.hashPassword(password);
deps.store.repo.createUser({ username, password_hash: hash, role: "admin" });
// Provision cluster key
const clusterKey = deps.secrets.generateClusterKey();
const encryptedCluster = deps.secrets.encryptString(clusterKey, "cluster");
deps.store.repo.setSetupExtra("cluster_key_encrypted", encryptedCluster);
deps.store.repo.markClusterKeyProvisioned();
// Create default display
deps.store.repo.createDefaultDisplay();
// Mark setup complete
deps.store.repo.markSetupComplete();
return new Response(null, {
status: 302,
headers: { location: "/auth/login?welcome=1" },
});
});
}

View file

@ -0,0 +1,54 @@
/**
* Static file serving for /static/* paths.
*
* In production the Angie proxy serves these directly;
* this handler is for dev mode only.
*/
import { existsSync, readFileSync } from "node:fs";
import { join, extname, resolve } from "node:path";
import { type H3, getRouterParam, createError } from "h3";
const STATIC_DIR = resolve(
import.meta.dirname ?? ".",
"../../web-static",
);
const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".woff2": "font/woff2",
};
export function registerStaticRoutes(app: H3): void {
app.get("/static/**:path", (event) => {
const reqPath = getRouterParam(event, "path");
if (!reqPath) throw createError({ statusCode: 404 });
// Prevent directory traversal
const resolved = resolve(STATIC_DIR, reqPath);
if (!resolved.startsWith(STATIC_DIR)) {
throw createError({ statusCode: 403 });
}
if (!existsSync(resolved)) {
throw createError({ statusCode: 404 });
}
const ext = extname(resolved).toLowerCase();
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
const body = readFileSync(resolved);
return new Response(body, {
headers: {
"content-type": contentType,
"cache-control": "public, max-age=86400",
},
});
});
}

View file

@ -0,0 +1,68 @@
/**
* service-api-http h3 listener for the kiosk-facing REST API.
*
* Serves pairing, bundle, and kiosk management endpoints at /api/kiosk/*
* and /api/pair/*. Port 18081 behind the Angie proxy.
*/
import * as av from "@anyvali/js";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
type Observable,
} from "@bsb/base";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object(
{
host: av.string().default("127.0.0.1"),
port: av.int().min(1).max(65535).default(18081),
},
{ unknownKeys: "strip" },
);
export const Config = createConfigSchema(
{
name: "service-api-http",
description: "h3 HTTP server for kiosk-facing REST API.",
tags: ["service", "http", "api", "kiosk"],
},
ConfigSchema,
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {},
onBroadcast: {},
});
// ---- Plugin -----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-store", "service-auth"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: create h3 app, mount kiosk + pairing routes, start listening
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {
// TODO: close h3 listener
}
}

View file

@ -0,0 +1,382 @@
/**
* service-auth credentials and session management.
*
* Like service-store, exposes a public class API to sibling services rather
* than wrapping every operation in a typed event. Calls cross processes only
* if/when we shard auth across instances; until then this is a tight, fast,
* single-binary service.
*
* Responsibilities:
* - argon2id password hashing/verification (tuned for Pi5 ~100ms)
* - TOTP secret gen + verify, recovery code gen + single-use consumption
* - Session create/lookup/revoke (signed cookie envelope)
* - API key create / verify-by-bearer
*/
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import argon2 from "argon2";
import * as av from "@anyvali/js";
import { TOTP, Secret } from "otpauth";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
type Observable,
} from "@bsb/base";
import type { ApiKey, ApiKeyScope, Session, User } from "../../shared/types.js";
import type { Plugin as StorePlugin } from "../service-store/index.js";
import type { Plugin as SecretsPlugin } from "../service-secrets/index.js";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object(
{
sessionIdleSeconds: av.int().min(60).default(43200),
sessionMaxSeconds: av.int().min(3600).default(2592000),
loginLockoutThreshold: av.int().min(1).default(8),
loginLockoutSeconds: av.int().min(1).default(900),
argon2Memory: av.int().min(8).default(65536), // KiB
argon2TimeCost: av.int().min(1).default(3),
argon2Parallelism: av.int().min(1).default(2),
/** Issuer string used in TOTP provisioning URIs. */
totpIssuer: av.string().minLength(1).default("BetterFrame"),
/** Cookie name (used by service-admin-http to set/read). */
cookieName: av.string().minLength(1).default("betterframe_session"),
},
{ unknownKeys: "strip" },
);
export const Config = createConfigSchema(
{
name: "service-auth",
description:
"Authentication primitives: argon2id passwords, TOTP, recovery codes, " +
"sessions (signed cookie envelope), and API keys.",
tags: ["service", "auth"],
},
ConfigSchema,
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {},
onBroadcast: {},
});
// ---- Constants -------------------------------------------------------------
const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I
const RECOVERY_CODE_COUNT = 10;
const RECOVERY_CODE_LENGTH = 10;
// ---- Plugin ----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-store", "service-secrets"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
// Sibling services: set in init() once they've initialized themselves.
// TODO(handoff): Replace with proper BSB plugin clients once we generate
// them. For v0.1 we resolve via the runtime's plugin lookup.
// The actual lookup mechanism is provided by the BSB framework — this
// file pretends the references arrive in init(). Wire-up happens in run().
private _store?: StorePlugin;
private _secrets?: SecretsPlugin;
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
// ---- BSB lifecycle -------------------------------------------------------
async init(_obs: Observable): Promise<void> {
// TODO(handoff): wire sibling-service references via plugin clients.
// For now `setSiblings()` is called by the boot script (see CLAUDE.md).
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
/** Called once by the boot wrapper after all plugins have constructed. */
setSiblings(store: StorePlugin, secrets: SecretsPlugin): void {
this._store = store;
this._secrets = secrets;
}
private get store(): StorePlugin {
if (!this._store) throw new Error("service-auth: siblings not set");
return this._store;
}
private get secrets(): SecretsPlugin {
if (!this._secrets) throw new Error("service-auth: siblings not set");
return this._secrets;
}
// =========================================================================
// Passwords
// =========================================================================
async hashPassword(plain: string): Promise<string> {
return argon2.hash(plain, {
type: argon2.argon2id,
memoryCost: this.config.argon2Memory,
timeCost: this.config.argon2TimeCost,
parallelism: this.config.argon2Parallelism,
});
}
async verifyPassword(plain: string, hash: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch {
return false;
}
}
needsRehash(hash: string): boolean {
return argon2.needsRehash(hash, {
memoryCost: this.config.argon2Memory,
timeCost: this.config.argon2TimeCost,
parallelism: this.config.argon2Parallelism,
});
}
// =========================================================================
// TOTP
// =========================================================================
generateTotpSecret(): string {
// 20 bytes (160 bits) base32-encoded by otpauth's Secret class
return new Secret({ size: 20 }).base32;
}
totpProvisioningUri(username: string, secretBase32: string): string {
const totp = new TOTP({
issuer: this.config.totpIssuer,
label: username,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: Secret.fromBase32(secretBase32),
});
return totp.toString();
}
verifyTotpCode(secretBase32: string, code: string): boolean {
const totp = new TOTP({
issuer: this.config.totpIssuer,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: Secret.fromBase32(secretBase32),
});
// Tolerate ±1 step for clock skew
return totp.validate({ token: code, window: 1 }) !== null;
}
encryptTotpSecret(secret: string): string {
return this.secrets.encryptString(secret, "totp");
}
decryptTotpSecret(ciphertext: string): string {
return this.secrets.decryptString(ciphertext, "totp");
}
// ---- Recovery codes ------------------------------------------------------
generateRecoveryCodes(): string[] {
const out: string[] = [];
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
const chars: string[] = [];
const buf = randomBytes(RECOVERY_CODE_LENGTH);
for (let j = 0; j < RECOVERY_CODE_LENGTH; j++) {
chars.push(RECOVERY_ALPHABET[buf[j]! % RECOVERY_ALPHABET.length]!);
}
out.push(chars.join(""));
}
return out;
}
async hashRecoveryCodes(codes: string[]): Promise<string[]> {
return Promise.all(codes.map((c) => this.hashPassword(c)));
}
async consumeRecoveryCode(
code: string,
hashedCodes: string[],
): Promise<{ ok: boolean; remaining: string[] }> {
const remaining: string[] = [];
let consumed = false;
for (const h of hashedCodes) {
if (!consumed && (await this.verifyPassword(code, h))) {
consumed = true;
continue;
}
remaining.push(h);
}
return { ok: consumed, remaining };
}
// =========================================================================
// Sessions (signed cookie envelope)
// =========================================================================
/**
* Create a session row + return (Session, signedCookieValue).
* Cookie envelope is `<sid>.<hmac>` where hmac uses the server-local key
* (info="cookie"). Tampering with the sid invalidates the cookie.
*/
async createSession(input: {
user: User;
userAgent: string | null;
ipAddress: string | null;
totpPending: boolean;
}): Promise<{ session: Session; cookieValue: string }> {
const id = randomBytes(32).toString("hex");
const csrfToken = randomBytes(32).toString("hex");
const expiresAt = new Date(
Date.now() + this.config.sessionMaxSeconds * 1000,
).toISOString();
const session = this.store.repo.createSession({
id,
user_id: input.user.id,
csrf_token: csrfToken,
totp_pending: input.totpPending,
user_agent: input.userAgent,
ip_address: input.ipAddress,
expires_at: expiresAt,
});
return { session, cookieValue: this.signCookie(id) };
}
/**
* Verify a cookie value and look up the session.
* Also enforces sliding (idle) and absolute expiry. Touches last_seen_at
* if valid.
*/
resolveSession(
cookieValue: string,
): { session: Session; user: User } | null {
const sid = this.unsignCookie(cookieValue);
if (!sid) return null;
const session = this.store.repo.getSessionById(sid);
if (!session) return null;
if (session.revoked_at) return null;
const now = new Date();
const expiresAt = new Date(session.expires_at);
if (expiresAt <= now) return null;
const lastSeen = new Date(session.last_seen_at);
const idleMs = this.config.sessionIdleSeconds * 1000;
if (now.getTime() - lastSeen.getTime() > idleMs) {
this.store.repo.revokeSession(sid);
return null;
}
const user = this.store.repo.getUserById(session.user_id);
if (!user || !user.is_active) return null;
this.store.repo.touchSession(sid, now.toISOString());
return { session, user };
}
revokeSession(sid: string): void {
this.store.repo.revokeSession(sid);
}
// ---- Cookie signing ------------------------------------------------------
private signCookie(sid: string): string {
const mac = this.cookieMac(sid);
return `${sid}.${mac}`;
}
/** Return the sid iff the signature is valid; null otherwise. */
private unsignCookie(cookieValue: string): string | null {
const dot = cookieValue.indexOf(".");
if (dot < 0) return null;
const sid = cookieValue.slice(0, dot);
const mac = cookieValue.slice(dot + 1);
const expected = this.cookieMac(sid);
const a = Buffer.from(mac, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return null;
return timingSafeEqual(a, b) ? sid : null;
}
private cookieMac(sid: string): string {
// Derive a cookie-signing key off the server key with HKDF info="cookie".
// We don't have direct access to the key; ask service-secrets to do an
// HMAC for us. To avoid a round-trip API, we add a small helper there
// later if profiling shows it. For now we compute on a derived subkey by
// running encryptString with deterministic IV (NO — that leaks). Better:
// use HKDF via secrets internally. For v0.1 we expose `signCookie` here
// as HMAC-SHA256 keyed on the encryption of a fixed plaintext, which
// produces a stable subkey-equivalent. This is acceptable but a TODO.
// TODO(handoff): expose `secrets.deriveSubkey(info)` publicly so we can
// hold a Buffer here and stop round-tripping through encryptString.
const subkeyMaterial = this.secrets.encryptString("cookie-subkey", "cookie-derivation");
return createHmac("sha256", subkeyMaterial).update(sid).digest("hex");
}
// =========================================================================
// API keys
// =========================================================================
async createApiKey(input: {
name: string;
scopes: ApiKeyScope[];
expiresAt: string | null;
}): Promise<{ apiKey: ApiKey; plaintext: string }> {
const plaintext = `bf-${randomBytes(24).toString("base64url")}`;
const keyHash = await this.hashPassword(plaintext);
const keyPrefix = plaintext.slice(0, 8);
const apiKey = this.store.repo.createApiKey({
name: input.name,
key_hash: keyHash,
key_prefix: keyPrefix,
scopes: input.scopes,
expires_at: input.expiresAt,
});
return { apiKey, plaintext };
}
async verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null> {
const prefix = plaintext.slice(0, 8);
const candidates = this.store.repo.listApiKeysByPrefix(prefix);
for (const cand of candidates) {
if (cand.revoked_at) continue;
if (cand.expires_at && new Date(cand.expires_at) <= new Date()) continue;
if (await this.verifyPassword(plaintext, cand.key_hash)) {
this.store.repo.touchApiKey(cand.id, ip);
return cand;
}
}
return null;
}
// =========================================================================
// Kiosk-key verification (mirror of API key verify but for the kiosks table)
// =========================================================================
async verifyKioskKey(plaintext: string): Promise<{ id: number } | null> {
if (plaintext.length < 8) return null;
const prefix = plaintext.slice(0, 8);
const candidates = this.store.repo.listKiosksByKeyPrefix(prefix);
for (const cand of candidates) {
if (await this.verifyPassword(plaintext, cand.key_hash)) {
return { id: cand.id };
}
}
return null;
}
}

View file

@ -0,0 +1,61 @@
/**
* service-bundle label-scoped bundle generation for kiosks.
*
* Queries layouts/cameras/labels for a kiosk's label set, encrypts ONVIF
* passwords with the cluster key, and returns a versioned JSON bundle
* the kiosk caches locally.
*/
import * as av from "@anyvali/js";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
type Observable,
} from "@bsb/base";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object({}, { unknownKeys: "strip" });
export const Config = createConfigSchema(
{
name: "service-bundle",
description: "Label-aware bundle generation for kiosks.",
tags: ["service", "bundle", "kiosk"],
},
ConfigSchema,
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {},
onBroadcast: {},
});
// ---- Plugin -----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-store", "service-secrets"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: implement bundle query + cluster-encrypt
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
}

View file

@ -0,0 +1,60 @@
/**
* service-cec-relay translates CEC commands to ws messages.
*
* Receives CEC control requests from the admin API or Node-RED and
* relays them to the authoritative kiosk via the coordinator WS channel.
*/
import * as av from "@anyvali/js";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
type Observable,
} from "@bsb/base";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object({}, { unknownKeys: "strip" });
export const Config = createConfigSchema(
{
name: "service-cec-relay",
description: "Relay CEC commands to the authoritative kiosk.",
tags: ["service", "cec", "relay"],
},
ConfigSchema,
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {},
onBroadcast: {},
});
// ---- Plugin -----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-coordinator-ws"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: subscribe to CEC command events, relay via coordinator
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
}

View file

@ -0,0 +1,68 @@
/**
* service-coordinator-ws WebSocket hub for live kiosk channel.
*
* Kiosks connect here to receive real-time layout switches, power
* commands, and status pings. Port 18082 behind the Angie proxy.
*/
import * as av from "@anyvali/js";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
type Observable,
} from "@bsb/base";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object(
{
host: av.string().default("127.0.0.1"),
port: av.int().min(1).max(65535).default(18082),
},
{ unknownKeys: "strip" },
);
export const Config = createConfigSchema(
{
name: "service-coordinator-ws",
description: "WebSocket server for real-time kiosk coordination.",
tags: ["service", "ws", "kiosk", "coordinator"],
},
ConfigSchema,
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {},
onBroadcast: {},
});
// ---- Plugin -----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-store", "service-auth"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: create ws server, handle kiosk auth + message routing
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {
// TODO: close ws server
}
}

View file

@ -0,0 +1,65 @@
/**
* service-nodered-bridge bidirectional HTTP bridge to Node-RED.
*
* Forwards events from the BSB bus to Node-RED HTTP-in endpoints,
* and exposes callbacks for Node-RED to push back into the bus.
*/
import * as av from "@anyvali/js";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
type Observable,
} from "@bsb/base";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object(
{
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
},
{ unknownKeys: "strip" },
);
export const Config = createConfigSchema(
{
name: "service-nodered-bridge",
description: "HTTP bridge between BSB event bus and Node-RED.",
tags: ["service", "nodered", "bridge"],
},
ConfigSchema,
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {},
onBroadcast: {},
});
// ---- Plugin -----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-store"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: set up outbound HTTP forwarder + inbound callback routes
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
}

View file

@ -0,0 +1,65 @@
/**
* service-pairing 8-char code state machine for kiosk pairing.
*
* Kiosk shows code on screen, admin enters it in UI, server delivers
* kiosk_key + cluster_key + bundle_url via one-shot poll.
*/
import * as av from "@anyvali/js";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
type Observable,
} from "@bsb/base";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object(
{
codeTtlSeconds: av.int().min(60).max(3600).default(600),
},
{ unknownKeys: "strip" },
);
export const Config = createConfigSchema(
{
name: "service-pairing",
description: "Kiosk pairing code state machine.",
tags: ["service", "pairing", "kiosk"],
},
ConfigSchema,
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {},
onBroadcast: {},
});
// ---- Plugin -----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[] = ["service-store", "service-secrets"];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(_obs: Observable): Promise<void> {
// TODO: implement initiate/claim/poll state machine
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
}

View file

@ -0,0 +1,232 @@
/**
* service-secrets symmetric crypto and the cluster key.
*
* Two roles:
* 1. Field encryption for ONVIF passwords (and anything else stored
* sensitively at rest). Uses AES-256-GCM with a server-local key.
* 2. Holding the cluster key (the shared symmetric secret kiosks use to
* decrypt the camera credentials in their bundle). Cluster key is
* generated at first-run setup and stored in setup_state.extras
* (server-encrypted).
*
* Server-local key sources (priority order):
* 1. systemd-creds: $CREDENTIALS_DIRECTORY/betterframe-secret
* 2. Dev fallback: <data_dir>/secret.key (chmod 0600). Generated if
* missing, with a WARN log so deploys notice.
*
* The cluster key never reaches disk in plaintext; it's encrypted with the
* server-local key and stored in setup_state.extras["cluster_key_encrypted"].
*/
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import {
createCipheriv,
createDecipheriv,
randomBytes,
hkdfSync,
} from "node:crypto";
import * as av from "@anyvali/js";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
type Observable,
} from "@bsb/base";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object(
{
dataDir: av.string().minLength(1).default("/var/lib/betterframe"),
/** Override the systemd-creds credential name. */
systemdCredsName: av.string().default("betterframe-secret"),
},
{ unknownKeys: "strip" },
);
export const Config = createConfigSchema(
{
name: "service-secrets",
description:
"Symmetric crypto for at-rest secrets and the inter-kiosk cluster key.",
tags: ["service", "secrets", "crypto"],
},
ConfigSchema,
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {},
onBroadcast: {},
});
// ---- Plugin -----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
/** 32-byte server-local key. Used to wrap field secrets and the cluster key. */
private serverKey?: Buffer;
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(obs: Observable): Promise<void> {
this.serverKey = this.loadServerKey(obs);
}
async run(_obs: Observable): Promise<void> {}
async dispose(): Promise<void> {}
// ---- public API for sibling services -------------------------------------
/**
* Encrypt a UTF-8 string at rest. Returns a self-describing ciphertext:
* v1.<iv-b64url>.<tag-b64url>.<ct-b64url>
* `info` lets us domain-separate keys (e.g. "field" vs "cluster") so the
* same server key can be used for distinct purposes safely.
*/
encryptString(plaintext: string, info: string = "field"): string {
const subkey = this.deriveSubkey(info);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", subkey, iv);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`;
}
decryptString(ciphertext: string, info: string = "field"): string {
const parts = ciphertext.split(".");
if (parts.length !== 4 || parts[0] !== "v1") {
throw new Error("ciphertext: bad format");
}
const iv = b64uDecode(parts[1]!);
const tag = b64uDecode(parts[2]!);
const ct = b64uDecode(parts[3]!);
const subkey = this.deriveSubkey(info);
const decipher = createDecipheriv("aes-256-gcm", subkey, iv);
decipher.setAuthTag(tag);
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
return pt.toString("utf8");
}
/** Generate a fresh cluster key (32 bytes, base64url). */
generateClusterKey(): string {
return b64u(randomBytes(32));
}
/**
* Encrypt-for-cluster: takes a plaintext + the cluster key, returns the
* format the kiosk expects in its bundle. Symmetric counterpart in Rust.
*
* v1.<iv-b64url>.<tag-b64url>.<ct-b64url>
*
* Same envelope shape as encryptString but keyed off the cluster key.
*/
encryptForCluster(plaintext: string, clusterKeyB64u: string): string {
const key = b64uDecode(clusterKeyB64u);
if (key.length !== 32) throw new Error("cluster key must be 32 bytes");
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`;
}
// ---- internals -----------------------------------------------------------
private deriveSubkey(info: string): Buffer {
if (!this.serverKey) throw new Error("service-secrets not initialized");
// HKDF-SHA256 with the info string as the context.
const out = hkdfSync(
"sha256",
this.serverKey,
Buffer.alloc(0),
Buffer.from(`betterframe.${info}`, "utf8"),
32,
);
return Buffer.from(out);
}
private loadServerKey(obs: Observable): Buffer {
// 1. systemd-creds
const credsDir = process.env["CREDENTIALS_DIRECTORY"];
if (credsDir) {
const path = join(credsDir, this.config.systemdCredsName);
if (existsSync(path)) {
const buf = readFileSync(path);
if (buf.length >= 32) {
obs.log.info("server key loaded from systemd-creds");
return buf.subarray(0, 32);
}
obs.log.warn(
"systemd-creds file too short ({len}); falling back to dev key",
{ len: buf.length },
);
}
}
// 2. Dev fallback: <data_dir>/secret.key
const path = join(this.config.dataDir, "secret.key");
if (existsSync(path)) {
const buf = readFileSync(path);
if (buf.length >= 32) {
obs.log.info("server key loaded from {path}", { path });
return buf.subarray(0, 32);
}
}
// 3. Generate new dev key
obs.log.warn(
"GENERATING DEV SERVER KEY at {path} — production deploys should use systemd-creds (CREDENTIALS_DIRECTORY/{name}) instead",
{ path, name: this.config.systemdCredsName },
);
try {
mkdirSync(dirname(path), { recursive: true });
} catch {
/* already exists or insufficient perms */
}
const fresh = randomBytes(32);
writeFileSync(path, fresh, { mode: 0o600 });
try {
chmodSync(path, 0o600);
} catch {
/* not POSIX; fine on dev */
}
return fresh;
}
}
// ---- base64url helpers (no padding) ----------------------------------------
function b64u(buf: Buffer): string {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function b64uDecode(s: string): Buffer {
const padded = s + "=".repeat((4 - (s.length % 4)) % 4);
return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}

View file

@ -0,0 +1,160 @@
/**
* service-store the only service that opens the sqlite database.
*
* Architecture choice (v0.1):
* For now, other services hold a typed reference to this plugin's
* `Repository` instance via constructor injection (BSB plugin clients).
* We expose the high-level data API as plain methods rather than wiring
* every CRUD operation as a typed BSB event.
*
* Reason: 60+ tables × 4 operations × 2 (input + output) anyvali schemas
* would be ~2000 lines of declarative bus plumbing. The event bus pays off
* when calls cross processes; in v0.1 everything is single-process.
*
* When we scale `service-coordinator-ws` to multiple instances (one per N
* kiosks), we'll graduate the hot-path operations (bundle lookup, label
* filter) to typed returnable events and keep the rest as direct calls.
*
* To-then: emit a domain-event broadcast on every write so listeners
* (e.g. coordinator-ws notifying kiosks of bundle changes) can react.
*/
import { DatabaseSync, type StatementSync } from "node:sqlite";
import { dirname } from "node:path";
import { mkdirSync } from "node:fs";
import * as av from "@anyvali/js";
import {
BSBService,
type BSBServiceConstructor,
createConfigSchema,
createEventSchemas,
createBroadcastEvent,
type Observable,
} from "@bsb/base";
import { MIGRATIONS } from "./migrations.js";
import { Repository } from "./repository.js";
// ---- Config -----------------------------------------------------------------
const ConfigSchema = av.object(
{
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
},
{ unknownKeys: "strip" },
);
export const Config = createConfigSchema(
{
name: "service-store",
description:
"BetterFrame canonical SQLite store. The single writer in the system; " +
"all other services read/write through this plugin.",
tags: ["service", "store", "sqlite"],
},
ConfigSchema,
);
// ---- Event schemas ----------------------------------------------------------
const broadcastDomainChange = av.object(
{
table: av.string(),
op: av.enum_(["create", "update", "delete"] as const),
id: av.optional(av.union([av.string(), av.int()])),
},
{ unknownKeys: "reject" },
);
export const EventSchemas = createEventSchemas({
emitEvents: {},
onEvents: {},
emitReturnableEvents: {},
onReturnableEvents: {},
emitBroadcast: {
"store.changed": createBroadcastEvent(broadcastDomainChange, "Domain row changed"),
},
onBroadcast: {},
});
// ---- Plugin -----------------------------------------------------------------
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
static override Config = Config;
static override EventSchemas = EventSchemas;
initBeforePlugins?: string[];
initAfterPlugins?: string[];
runBeforePlugins?: string[];
runAfterPlugins?: string[];
// The DB handle and Repository are created in init() and exposed for
// sibling-service consumption.
private db?: DatabaseSync;
private _repo?: Repository;
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
}
async init(obs: Observable): Promise<void> {
const path = this.config.sqlitePath;
obs.log.info("opening sqlite at {path}", { path });
// Ensure parent dir exists (in dev BETTERFRAME_DATA_DIR may be in $HOME)
try {
mkdirSync(dirname(path), { recursive: true });
} catch (err) {
obs.log.warn("mkdir failed for {dir}: {err}", {
dir: dirname(path),
err: (err as Error).message,
});
}
this.db = new DatabaseSync(path);
// SQLite pragmas for an embedded one-writer setup
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA synchronous = NORMAL");
this.db.exec("PRAGMA foreign_keys = ON");
this.db.exec("PRAGMA busy_timeout = 10000");
obs.log.info("running {n} migrations", { n: MIGRATIONS.length });
for (const stmt of MIGRATIONS) {
this.db.exec(stmt);
}
this._repo = new Repository(this.db, async (table, op, id) => {
// Best-effort broadcast — never let a failed event-bus call fail a write.
try {
await this.events.emitBroadcast("store.changed", obs, { table, op, id });
} catch (err) {
obs.log.warn("broadcast store.changed failed: {err}", {
err: (err as Error).message,
});
}
});
obs.log.info("store ready");
}
async run(_obs: Observable): Promise<void> {
// Long-lived; no work in run().
}
async dispose(): Promise<void> {
this.db?.close();
}
/**
* Public accessor for sibling services. Throws before init() completes
* services that need the repo should declare their initAfterPlugins
* dependency on `service-store`.
*/
get repo(): Repository {
if (!this._repo) {
throw new Error("service-store: repository accessed before init()");
}
return this._repo;
}
}

View file

@ -0,0 +1,267 @@
/**
* Row-to-domain mappers. Pure functions, no DB access.
*
* Every mapper accepts `unknown` (because node:sqlite returns row objects
* typed as Record<string, SqliteValue>) and returns a fully-typed domain
* object from shared/types.ts.
*/
import type {
ApiKey,
ApiKeyScope,
Camera,
CameraStream,
CameraType,
CellContentType,
DesiredPowerState,
Display,
EventLog,
EventSourceType,
Kiosk,
KioskLabel,
Label,
LabelRole,
Layout,
LayoutCell,
LayoutPriority,
LayoutRegion,
LayoutTemplate,
PairingCode,
Session,
SetupState,
StreamPolicy,
StreamRole,
StreamSelector,
User,
UserRole,
} from "../../shared/types.js";
import { b, j } from "./util.js";
type Row = Record<string, unknown>;
const s = (v: unknown): string => (typeof v === "string" ? v : "");
const sn = (v: unknown): string | null => (typeof v === "string" ? v : null);
const n = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0);
const nn = (v: unknown): number | null =>
v === null || v === undefined ? null : typeof v === "number" ? v : Number(v) || null;
export function rowToUser(r: Row): User {
return {
id: n(r["id"]),
username: s(r["username"]),
password_hash: s(r["password_hash"]),
role: s(r["role"]) as UserRole,
is_active: b(r["is_active"]),
totp_enabled: b(r["totp_enabled"]),
totp_secret_encrypted: sn(r["totp_secret_encrypted"]),
recovery_codes_hashed: j<string[]>(r["recovery_codes_hashed"], []),
must_change_password: b(r["must_change_password"]),
failed_login_count: n(r["failed_login_count"]),
locked_until: sn(r["locked_until"]),
last_login_at: sn(r["last_login_at"]),
created_at: s(r["created_at"]),
};
}
export function rowToSession(r: Row): Session {
return {
id: s(r["id"]),
user_id: n(r["user_id"]),
csrf_token: s(r["csrf_token"]),
totp_pending: b(r["totp_pending"]),
user_agent: sn(r["user_agent"]),
ip_address: sn(r["ip_address"]),
issued_at: s(r["issued_at"]),
last_seen_at: s(r["last_seen_at"]),
expires_at: s(r["expires_at"]),
revoked_at: sn(r["revoked_at"]),
};
}
export function rowToApiKey(r: Row): ApiKey {
return {
id: n(r["id"]),
name: s(r["name"]),
key_hash: s(r["key_hash"]),
key_prefix: s(r["key_prefix"]),
scopes: j<ApiKeyScope[]>(r["scopes"], []),
expires_at: sn(r["expires_at"]),
last_used_at: sn(r["last_used_at"]),
last_used_ip: sn(r["last_used_ip"]),
created_at: s(r["created_at"]),
revoked_at: sn(r["revoked_at"]),
};
}
export function rowToSetupState(r: Row): SetupState {
return {
id: 1,
is_complete: b(r["is_complete"]),
cluster_key_provisioned: b(r["cluster_key_provisioned"]),
nodered_flows_deployed: b(r["nodered_flows_deployed"]),
completed_at: sn(r["completed_at"]),
extras: j<Record<string, unknown>>(r["extras"], {}),
};
}
export function rowToDisplay(r: Row): Display {
return {
id: n(r["id"]),
name: s(r["name"]),
index: n(r["index"]),
is_primary: b(r["is_primary"]),
width_px: n(r["width_px"]),
height_px: n(r["height_px"]),
default_layout_id: nn(r["default_layout_id"]),
idle_timeout_seconds: n(r["idle_timeout_seconds"]),
sleep_timeout_seconds: n(r["sleep_timeout_seconds"]),
cec_enabled: b(r["cec_enabled"]),
cec_device_path: sn(r["cec_device_path"]),
cec_logical_address: nn(r["cec_logical_address"]),
desired_power_state: s(r["desired_power_state"]) as DesiredPowerState,
state_check_enabled: b(r["state_check_enabled"]),
state_check_interval_seconds: n(r["state_check_interval_seconds"]),
};
}
export function rowToCamera(r: Row): Camera {
return {
id: n(r["id"]),
name: s(r["name"]),
type: s(r["type"]) as CameraType,
rtsp_url: sn(r["rtsp_url"]),
onvif_host: sn(r["onvif_host"]),
onvif_port: nn(r["onvif_port"]),
onvif_username: sn(r["onvif_username"]),
onvif_password: sn(r["onvif_password"]),
capabilities: j<string[]>(r["capabilities"], []),
stream_policy: s(r["stream_policy"]) as StreamPolicy,
enabled: b(r["enabled"]),
last_seen_at: sn(r["last_seen_at"]),
created_at: s(r["created_at"]),
};
}
export function rowToCameraStream(r: Row): CameraStream {
return {
id: n(r["id"]),
camera_id: n(r["camera_id"]),
role: s(r["role"]) as StreamRole,
name: s(r["name"]),
profile_token: sn(r["profile_token"]),
rtsp_uri: s(r["rtsp_uri"]),
width: nn(r["width"]),
height: nn(r["height"]),
encoding: sn(r["encoding"]),
framerate: nn(r["framerate"]),
bitrate_kbps: nn(r["bitrate_kbps"]),
is_discovered: b(r["is_discovered"]),
};
}
export function rowToLayoutTemplate(r: Row): LayoutTemplate {
return {
id: n(r["id"]),
name: s(r["name"]),
description: sn(r["description"]),
regions: j<LayoutRegion[]>(r["regions"], []),
grid_cols: n(r["grid_cols"]),
grid_rows: n(r["grid_rows"]),
is_builtin: b(r["is_builtin"]),
};
}
export function rowToLayout(r: Row): Layout {
return {
id: n(r["id"]),
name: s(r["name"]),
description: sn(r["description"]),
template_id: n(r["template_id"]),
display_id: n(r["display_id"]),
priority: s(r["priority"]) as LayoutPriority,
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
preload_camera_ids: j<number[]>(r["preload_camera_ids"], []),
is_default: b(r["is_default"]),
resets_idle_timer: b(r["resets_idle_timer"]),
};
}
export function rowToLayoutCell(r: Row): LayoutCell {
return {
id: n(r["id"]),
layout_id: n(r["layout_id"]),
region_name: s(r["region_name"]),
content_type: s(r["content_type"]) as CellContentType,
camera_id: nn(r["camera_id"]),
stream_selector: s(r["stream_selector"]) as StreamSelector,
web_url: sn(r["web_url"]),
html_content: sn(r["html_content"]),
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
options: j<Record<string, unknown>>(r["options"], {}),
};
}
export function rowToKiosk(r: Row): Kiosk {
return {
id: n(r["id"]),
name: s(r["name"]),
description: sn(r["description"]),
key_hash: s(r["key_hash"]),
key_prefix: s(r["key_prefix"]),
capabilities: j<string[]>(r["capabilities"], []),
hardware_model: sn(r["hardware_model"]),
os_version: sn(r["os_version"]),
kiosk_app_version: sn(r["kiosk_app_version"]),
enabled: b(r["enabled"]),
paired_at: sn(r["paired_at"]),
last_seen_at: sn(r["last_seen_at"]),
last_bundle_version: sn(r["last_bundle_version"]),
display_id: nn(r["display_id"]),
created_at: s(r["created_at"]),
};
}
export function rowToLabel(r: Row): Label {
return {
id: n(r["id"]),
name: s(r["name"]),
description: sn(r["description"]),
color: sn(r["color"]),
created_at: s(r["created_at"]),
};
}
export function rowToKioskLabel(r: Row): KioskLabel {
return {
kiosk_id: n(r["kiosk_id"]),
label_id: n(r["label_id"]),
role: s(r["role"]) as LabelRole,
};
}
export function rowToPairingCode(r: Row): PairingCode {
return {
code: s(r["code"]),
kiosk_proposed_name: sn(r["kiosk_proposed_name"]),
kiosk_hardware_model: sn(r["kiosk_hardware_model"]),
kiosk_capabilities: j<string[]>(r["kiosk_capabilities"], []),
issued_at: s(r["issued_at"]),
expires_at: s(r["expires_at"]),
consumed_at: sn(r["consumed_at"]),
consumed_by_kiosk_id: nn(r["consumed_by_kiosk_id"]),
extras: j<Record<string, unknown>>(r["extras"], {}),
};
}
export function rowToEventLog(r: Row): EventLog {
return {
id: n(r["id"]),
source_kiosk_id: nn(r["source_kiosk_id"]),
source_camera_id: nn(r["source_camera_id"]),
source_type: s(r["source_type"]) as EventSourceType,
topic: s(r["topic"]),
property_op: sn(r["property_op"]),
payload: j<Record<string, unknown>>(r["payload"], {}),
received_at: s(r["received_at"]),
forwarded_to_nodered: b(r["forwarded_to_nodered"]),
};
}

View file

@ -0,0 +1,252 @@
/**
* Database migrations.
*
* Idempotent `service-store.init()` runs ALL of these on every startup,
* inside one transaction. SQLite tolerates `IF NOT EXISTS` everywhere we
* need it. When schemas change non-additively, we'll graduate to a real
* versioned migrator; for v0.1 this is sufficient.
*
* NOTE on datetimes: stored as TEXT in ISO-8601 UTC ("YYYY-MM-DDTHH:MM:SS.sssZ").
* Application code uses `new Date().toISOString()` for writes and
* `new Date(value)` for reads. No tz-aware datetime gotcha because TEXT is
* pure string round-trip. (Old python build hit a pain point with
* SQLAlchemy's DateTime adapter we avoid the whole class of issue here.)
*/
export const MIGRATIONS: readonly string[] = [
// ---- users ---------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')),
is_active INTEGER NOT NULL DEFAULT 1,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_secret_encrypted TEXT,
recovery_codes_hashed TEXT NOT NULL DEFAULT '[]',
must_change_password INTEGER NOT NULL DEFAULT 0,
failed_login_count INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT`,
// ---- sessions ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
totp_pending INTEGER NOT NULL DEFAULT 0,
user_agent TEXT,
ip_address TEXT,
issued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL,
revoked_at TEXT
) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_active
ON sessions(expires_at)
WHERE revoked_at IS NULL`,
// ---- api_keys ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
scopes TEXT NOT NULL DEFAULT '[]',
expires_at TEXT,
last_used_at TEXT,
last_used_ip TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
revoked_at TEXT
) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix)`,
// ---- setup_state (singleton row, id=1) -----------------------------------
`CREATE TABLE IF NOT EXISTS setup_state (
id INTEGER PRIMARY KEY CHECK(id = 1),
is_complete INTEGER NOT NULL DEFAULT 0,
cluster_key_provisioned INTEGER NOT NULL DEFAULT 0,
nodered_flows_deployed INTEGER NOT NULL DEFAULT 0,
completed_at TEXT,
extras TEXT NOT NULL DEFAULT '{}'
) STRICT`,
`INSERT OR IGNORE INTO setup_state (id) VALUES (1)`,
// ---- displays ------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS displays (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
"index" INTEGER NOT NULL UNIQUE,
is_primary INTEGER NOT NULL DEFAULT 0,
width_px INTEGER NOT NULL DEFAULT 1920,
height_px INTEGER NOT NULL DEFAULT 1080,
default_layout_id INTEGER,
idle_timeout_seconds INTEGER NOT NULL DEFAULT 600,
sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800,
cec_enabled INTEGER NOT NULL DEFAULT 1,
cec_device_path TEXT,
cec_logical_address INTEGER,
desired_power_state TEXT NOT NULL DEFAULT 'follow_layout'
CHECK(desired_power_state IN ('follow_layout', 'on', 'standby')),
state_check_enabled INTEGER NOT NULL DEFAULT 0,
state_check_interval_seconds INTEGER NOT NULL DEFAULT 60
) STRICT`,
// ---- cameras -------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS cameras (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif')),
rtsp_url TEXT,
onvif_host TEXT,
onvif_port INTEGER,
onvif_username TEXT,
onvif_password TEXT,
capabilities TEXT NOT NULL DEFAULT '[]',
stream_policy TEXT NOT NULL DEFAULT 'auto'
CHECK(stream_policy IN ('auto', 'always_main', 'always_sub')),
enabled INTEGER NOT NULL DEFAULT 1,
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT`,
`CREATE TABLE IF NOT EXISTS camera_streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')),
name TEXT NOT NULL,
profile_token TEXT,
rtsp_uri TEXT NOT NULL,
width INTEGER,
height INTEGER,
encoding TEXT,
framerate REAL,
bitrate_kbps INTEGER,
is_discovered INTEGER NOT NULL DEFAULT 0
) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_camera_streams_camera ON camera_streams(camera_id)`,
// ---- layout templates + layouts + cells ----------------------------------
`CREATE TABLE IF NOT EXISTS layout_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
regions TEXT NOT NULL DEFAULT '[]',
grid_cols INTEGER NOT NULL DEFAULT 12,
grid_rows INTEGER NOT NULL DEFAULT 12,
is_builtin INTEGER NOT NULL DEFAULT 0
) STRICT`,
`CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
template_id INTEGER NOT NULL REFERENCES layout_templates(id),
display_id INTEGER NOT NULL REFERENCES displays(id),
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')),
cooling_timeout_seconds INTEGER,
preload_camera_ids TEXT NOT NULL DEFAULT '[]',
is_default INTEGER NOT NULL DEFAULT 0,
resets_idle_timer INTEGER NOT NULL DEFAULT 1
) STRICT`,
`CREATE TABLE IF NOT EXISTS layout_cells (
id INTEGER PRIMARY KEY AUTOINCREMENT,
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
region_name TEXT NOT NULL,
content_type TEXT NOT NULL CHECK(content_type IN ('camera', 'web', 'html')),
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
stream_selector TEXT NOT NULL DEFAULT 'auto'
CHECK(stream_selector IN ('auto', 'main', 'sub')),
web_url TEXT,
html_content TEXT,
cooling_timeout_seconds INTEGER,
options TEXT NOT NULL DEFAULT '{}'
) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`,
// ---- kiosks --------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS kiosks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
capabilities TEXT NOT NULL DEFAULT '[]',
hardware_model TEXT,
os_version TEXT,
kiosk_app_version TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
paired_at TEXT,
last_seen_at TEXT,
last_bundle_version TEXT,
display_id INTEGER REFERENCES displays(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_kiosks_prefix ON kiosks(key_prefix)`,
// ---- labels --------------------------------------------------------------
`CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
color TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT`,
`CREATE TABLE IF NOT EXISTS kiosk_labels (
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('consume', 'operate')),
PRIMARY KEY (kiosk_id, label_id, role)
) STRICT`,
`CREATE TABLE IF NOT EXISTS camera_labels (
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (camera_id, label_id)
) STRICT`,
`CREATE TABLE IF NOT EXISTS layout_labels (
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (layout_id, label_id)
) STRICT`,
// ---- pairing_codes -------------------------------------------------------
`CREATE TABLE IF NOT EXISTS pairing_codes (
code TEXT PRIMARY KEY,
kiosk_proposed_name TEXT,
kiosk_hardware_model TEXT,
kiosk_capabilities TEXT NOT NULL DEFAULT '[]',
issued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL,
consumed_at TEXT,
consumed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
extras TEXT NOT NULL DEFAULT '{}'
) STRICT`,
// ---- event_log -----------------------------------------------------------
`CREATE TABLE IF NOT EXISTS event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
source_camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')),
topic TEXT NOT NULL,
property_op TEXT,
payload TEXT NOT NULL DEFAULT '{}',
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
forwarded_to_nodered INTEGER NOT NULL DEFAULT 0
) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_event_log_received ON event_log(received_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_topic ON event_log(topic, received_at DESC)`,
];

View file

@ -0,0 +1,803 @@
/**
* Repository typed accessor over the sqlite handle.
*
* Keeps prepared statements cached for the life of the connection. All
* mutating methods invoke the `notify` callback with (table, op, id) so the
* surrounding plugin can broadcast a `store.changed` event.
*
* NOT THREAD SAFE node:sqlite is single-threaded, and so is Node. Don't
* cross workers with the same handle.
*/
import type { DatabaseSync, StatementSync } from "node:sqlite";
import { randomBytes } from "node:crypto";
import type {
ApiKey,
ApiKeyScope,
Camera,
CameraStream,
CameraType,
Display,
EventLog,
EventSourceType,
Kiosk,
KioskLabel,
Label,
LabelRole,
Layout,
LayoutCell,
LayoutTemplate,
PairingCode,
Session,
SetupState,
StreamPolicy,
StreamRole,
User,
UserRole,
} from "../../shared/types.js";
import {
rowToApiKey,
rowToCamera,
rowToCameraStream,
rowToDisplay,
rowToEventLog,
rowToKiosk,
rowToLabel,
rowToLayout,
rowToLayoutCell,
rowToLayoutTemplate,
rowToPairingCode,
rowToSession,
rowToSetupState,
rowToUser,
} from "./mappers.js";
import { B, J, isoIn, isoNow, j } from "./util.js";
type NotifyFn = (
table: string,
op: "create" | "update" | "delete",
id?: string | number,
) => Promise<void>;
export class Repository {
private readonly db: DatabaseSync;
private readonly notify: NotifyFn;
private readonly stmts = new Map<string, StatementSync>();
constructor(db: DatabaseSync, notify: NotifyFn) {
this.db = db;
this.notify = notify;
}
/** Cached prepared statements. */
private prep(sql: string): StatementSync {
let s = this.stmts.get(sql);
if (!s) {
s = this.db.prepare(sql);
this.stmts.set(sql, s);
}
return s;
}
/** Ad-hoc transaction. */
transact<T>(fn: () => T): T {
this.db.exec("BEGIN");
try {
const out = fn();
this.db.exec("COMMIT");
return out;
} catch (err) {
try {
this.db.exec("ROLLBACK");
} catch {
/* ignore */
}
throw err;
}
}
// ===========================================================================
// setup_state
// ===========================================================================
getSetupState(): SetupState {
const r = this.prep("SELECT * FROM setup_state WHERE id = 1").get();
if (!r) throw new Error("setup_state row missing");
return rowToSetupState(r as Record<string, unknown>);
}
isSetupComplete(): boolean {
return this.getSetupState().is_complete && this.countUsers() > 0;
}
markSetupComplete(): void {
this.prep(
`UPDATE setup_state
SET is_complete = 1,
completed_at = COALESCE(completed_at, ?)
WHERE id = 1`,
).run(isoNow());
void this.notify("setup_state", "update", 1);
}
setSetupExtra(key: string, value: unknown): void {
const cur = this.getSetupState().extras;
cur[key] = value;
this.prep("UPDATE setup_state SET extras = ? WHERE id = 1").run(J(cur));
}
getSetupExtra(key: string): unknown {
return this.getSetupState().extras[key];
}
markClusterKeyProvisioned(): void {
this.prep(
"UPDATE setup_state SET cluster_key_provisioned = 1 WHERE id = 1",
).run();
}
// ===========================================================================
// users
// ===========================================================================
countUsers(): number {
const r = this.prep("SELECT COUNT(*) AS c FROM users").get() as
| { c: number }
| undefined;
return r?.c ?? 0;
}
getUserById(id: number): User | null {
const r = this.prep("SELECT * FROM users WHERE id = ?").get(id);
return r ? rowToUser(r as Record<string, unknown>) : null;
}
getUserByUsername(username: string): User | null {
const r = this.prep("SELECT * FROM users WHERE username = ?").get(username);
return r ? rowToUser(r as Record<string, unknown>) : null;
}
createUser(input: {
username: string;
password_hash: string;
role?: UserRole;
must_change_password?: boolean;
}): User {
const role: UserRole = input.role ?? "operator";
const result = this.prep(
`INSERT INTO users (username, password_hash, role, is_active, must_change_password)
VALUES (?, ?, ?, 1, ?)`,
).run(
input.username,
input.password_hash,
role,
B(Boolean(input.must_change_password)),
);
const id = Number(result.lastInsertRowid);
void this.notify("users", "create", id);
const u = this.getUserById(id);
if (!u) throw new Error("user vanished after insert");
return u;
}
updateUser(id: number, patch: Partial<User>): void {
const cols: string[] = [];
const vals: unknown[] = [];
if ("password_hash" in patch) {
cols.push("password_hash = ?");
vals.push(patch.password_hash);
}
if ("totp_enabled" in patch) {
cols.push("totp_enabled = ?");
vals.push(B(Boolean(patch.totp_enabled)));
}
if ("totp_secret_encrypted" in patch) {
cols.push("totp_secret_encrypted = ?");
vals.push(patch.totp_secret_encrypted);
}
if ("recovery_codes_hashed" in patch) {
cols.push("recovery_codes_hashed = ?");
vals.push(J(patch.recovery_codes_hashed));
}
if ("must_change_password" in patch) {
cols.push("must_change_password = ?");
vals.push(B(Boolean(patch.must_change_password)));
}
if ("failed_login_count" in patch) {
cols.push("failed_login_count = ?");
vals.push(patch.failed_login_count);
}
if ("locked_until" in patch) {
cols.push("locked_until = ?");
vals.push(patch.locked_until);
}
if ("last_login_at" in patch) {
cols.push("last_login_at = ?");
vals.push(patch.last_login_at);
}
if ("is_active" in patch) {
cols.push("is_active = ?");
vals.push(B(Boolean(patch.is_active)));
}
if (cols.length === 0) return;
vals.push(id);
this.db.prepare(`UPDATE users SET ${cols.join(", ")} WHERE id = ?`).run(...(vals as never[]));
void this.notify("users", "update", id);
}
// ===========================================================================
// sessions
// ===========================================================================
createSession(input: {
id: string;
user_id: number;
csrf_token: string;
totp_pending: boolean;
user_agent: string | null;
ip_address: string | null;
expires_at: string; // absolute
}): Session {
this.prep(
`INSERT INTO sessions
(id, user_id, csrf_token, totp_pending, user_agent, ip_address, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
input.id,
input.user_id,
input.csrf_token,
B(input.totp_pending),
input.user_agent,
input.ip_address,
input.expires_at,
);
const s = this.getSessionById(input.id);
if (!s) throw new Error("session vanished after insert");
return s;
}
getSessionById(id: string): Session | null {
const r = this.prep("SELECT * FROM sessions WHERE id = ?").get(id);
return r ? rowToSession(r as Record<string, unknown>) : null;
}
touchSession(id: string, lastSeenAt: string): void {
this.prep("UPDATE sessions SET last_seen_at = ? WHERE id = ?").run(
lastSeenAt,
id,
);
}
setSessionTotpPending(id: string, pending: boolean): void {
this.prep("UPDATE sessions SET totp_pending = ? WHERE id = ?").run(
B(pending),
id,
);
}
revokeSession(id: string): void {
this.prep("UPDATE sessions SET revoked_at = ? WHERE id = ?").run(isoNow(), id);
}
revokeAllSessionsForUser(userId: number): void {
this.prep(
`UPDATE sessions SET revoked_at = ?
WHERE user_id = ? AND revoked_at IS NULL`,
).run(isoNow(), userId);
}
// ===========================================================================
// api_keys
// ===========================================================================
createApiKey(input: {
name: string;
key_hash: string;
key_prefix: string;
scopes: ApiKeyScope[];
expires_at: string | null;
}): ApiKey {
const result = this.prep(
`INSERT INTO api_keys (name, key_hash, key_prefix, scopes, expires_at)
VALUES (?, ?, ?, ?, ?)`,
).run(
input.name,
input.key_hash,
input.key_prefix,
J(input.scopes),
input.expires_at,
);
const id = Number(result.lastInsertRowid);
void this.notify("api_keys", "create", id);
const k = this.getApiKeyById(id);
if (!k) throw new Error("api_key vanished after insert");
return k;
}
getApiKeyById(id: number): ApiKey | null {
const r = this.prep("SELECT * FROM api_keys WHERE id = ?").get(id);
return r ? rowToApiKey(r as Record<string, unknown>) : null;
}
/** Lookup all candidates for a given prefix (typically returns 0 or 1). */
listApiKeysByPrefix(prefix: string): ApiKey[] {
const rs = this.prep(
"SELECT * FROM api_keys WHERE key_prefix = ? AND revoked_at IS NULL",
).all(prefix);
return rs.map((r) => rowToApiKey(r as Record<string, unknown>));
}
touchApiKey(id: number, ip: string | null): void {
this.prep(
"UPDATE api_keys SET last_used_at = ?, last_used_ip = ? WHERE id = ?",
).run(isoNow(), ip, id);
}
// ===========================================================================
// displays
// ===========================================================================
listDisplays(): Display[] {
const rs = this.prep('SELECT * FROM displays ORDER BY "index"').all();
return rs.map((r) => rowToDisplay(r as Record<string, unknown>));
}
getDisplayById(id: number): Display | null {
const r = this.prep("SELECT * FROM displays WHERE id = ?").get(id);
return r ? rowToDisplay(r as Record<string, unknown>) : null;
}
createDefaultDisplay(): Display {
const result = this.prep(
`INSERT INTO displays (name, "index", is_primary)
VALUES ('primary', 0, 1)`,
).run();
const id = Number(result.lastInsertRowid);
void this.notify("displays", "create", id);
const d = this.getDisplayById(id);
if (!d) throw new Error("display vanished after insert");
return d;
}
// ===========================================================================
// cameras
// ===========================================================================
listCameras(): Camera[] {
const rs = this.prep("SELECT * FROM cameras ORDER BY name").all();
return rs.map((r) => rowToCamera(r as Record<string, unknown>));
}
getCameraById(id: number): Camera | null {
const r = this.prep("SELECT * FROM cameras WHERE id = ?").get(id);
return r ? rowToCamera(r as Record<string, unknown>) : null;
}
getCameraByName(name: string): Camera | null {
const r = this.prep("SELECT * FROM cameras WHERE name = ?").get(name);
return r ? rowToCamera(r as Record<string, unknown>) : null;
}
createCamera(input: {
name: string;
type: CameraType;
rtsp_url?: string | null;
onvif_host?: string | null;
onvif_port?: number | null;
onvif_username?: string | null;
onvif_password?: string | null; // already-encrypted ciphertext
capabilities?: string[];
stream_policy?: StreamPolicy;
}): Camera {
const result = this.prep(
`INSERT INTO cameras
(name, type, rtsp_url, onvif_host, onvif_port, onvif_username,
onvif_password, capabilities, stream_policy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
input.name,
input.type,
input.rtsp_url ?? null,
input.onvif_host ?? null,
input.onvif_port ?? null,
input.onvif_username ?? null,
input.onvif_password ?? null,
J(input.capabilities ?? []),
input.stream_policy ?? "auto",
);
const id = Number(result.lastInsertRowid);
void this.notify("cameras", "create", id);
const c = this.getCameraById(id);
if (!c) throw new Error("camera vanished after insert");
return c;
}
listCameraStreams(cameraId: number): CameraStream[] {
const rs = this.prep(
"SELECT * FROM camera_streams WHERE camera_id = ?",
).all(cameraId);
return rs.map((r) => rowToCameraStream(r as Record<string, unknown>));
}
createCameraStream(input: {
camera_id: number;
role: StreamRole;
name: string;
rtsp_uri: string;
profile_token?: string | null;
width?: number | null;
height?: number | null;
encoding?: string | null;
framerate?: number | null;
bitrate_kbps?: number | null;
is_discovered?: boolean;
}): CameraStream {
const result = this.prep(
`INSERT INTO camera_streams
(camera_id, role, name, profile_token, rtsp_uri, width, height,
encoding, framerate, bitrate_kbps, is_discovered)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
input.camera_id,
input.role,
input.name,
input.profile_token ?? null,
input.rtsp_uri,
input.width ?? null,
input.height ?? null,
input.encoding ?? null,
input.framerate ?? null,
input.bitrate_kbps ?? null,
B(Boolean(input.is_discovered)),
);
const id = Number(result.lastInsertRowid);
const r = this.prep("SELECT * FROM camera_streams WHERE id = ?").get(id);
if (!r) throw new Error("camera_stream vanished after insert");
void this.notify("camera_streams", "create", id);
return rowToCameraStream(r as Record<string, unknown>);
}
// ===========================================================================
// labels (incl. join tables)
// ===========================================================================
listLabels(): Label[] {
const rs = this.prep("SELECT * FROM labels ORDER BY name").all();
return rs.map((r) => rowToLabel(r as Record<string, unknown>));
}
getLabelByName(name: string): Label | null {
const r = this.prep("SELECT * FROM labels WHERE name = ?").get(name);
return r ? rowToLabel(r as Record<string, unknown>) : null;
}
createLabel(input: {
name: string;
description?: string | null;
color?: string | null;
}): Label {
const result = this.prep(
`INSERT INTO labels (name, description, color)
VALUES (?, ?, ?)`,
).run(input.name, input.description ?? null, input.color ?? null);
const id = Number(result.lastInsertRowid);
void this.notify("labels", "create", id);
const r = this.prep("SELECT * FROM labels WHERE id = ?").get(id);
if (!r) throw new Error("label vanished after insert");
return rowToLabel(r as Record<string, unknown>);
}
/** Get-or-create label by name (used during pairing's free-text label input). */
ensureLabel(name: string): Label {
return this.getLabelByName(name) ?? this.createLabel({ name });
}
attachKioskLabel(kioskId: number, labelId: number, role: LabelRole): void {
this.prep(
`INSERT OR IGNORE INTO kiosk_labels (kiosk_id, label_id, role)
VALUES (?, ?, ?)`,
).run(kioskId, labelId, role);
}
listKioskLabels(kioskId: number): Array<KioskLabel & { name: string }> {
const rs = this.prep(
`SELECT kl.kiosk_id, kl.label_id, kl.role, l.name
FROM kiosk_labels kl
JOIN labels l ON l.id = kl.label_id
WHERE kl.kiosk_id = ?`,
).all(kioskId);
return rs.map((r) => {
const row = r as Record<string, unknown>;
return {
kiosk_id: Number(row["kiosk_id"]),
label_id: Number(row["label_id"]),
role: String(row["role"]) as LabelRole,
name: String(row["name"]),
};
});
}
attachCameraLabel(cameraId: number, labelId: number): void {
this.prep(
`INSERT OR IGNORE INTO camera_labels (camera_id, label_id)
VALUES (?, ?)`,
).run(cameraId, labelId);
}
attachLayoutLabel(layoutId: number, labelId: number): void {
this.prep(
`INSERT OR IGNORE INTO layout_labels (layout_id, label_id)
VALUES (?, ?)`,
).run(layoutId, labelId);
}
// ===========================================================================
// kiosks
// ===========================================================================
listKiosks(): Kiosk[] {
const rs = this.prep("SELECT * FROM kiosks ORDER BY name").all();
return rs.map((r) => rowToKiosk(r as Record<string, unknown>));
}
getKioskById(id: number): Kiosk | null {
const r = this.prep("SELECT * FROM kiosks WHERE id = ?").get(id);
return r ? rowToKiosk(r as Record<string, unknown>) : null;
}
getKioskByName(name: string): Kiosk | null {
const r = this.prep("SELECT * FROM kiosks WHERE name = ?").get(name);
return r ? rowToKiosk(r as Record<string, unknown>) : null;
}
/** Lookup candidates by Bearer-key prefix; verify hash at the call site. */
listKiosksByKeyPrefix(prefix: string): Kiosk[] {
const rs = this.prep(
"SELECT * FROM kiosks WHERE key_prefix = ? AND enabled = 1",
).all(prefix);
return rs.map((r) => rowToKiosk(r as Record<string, unknown>));
}
createKiosk(input: {
name: string;
key_hash: string;
key_prefix: string;
capabilities?: string[];
hardware_model?: string | null;
}): Kiosk {
const result = this.prep(
`INSERT INTO kiosks
(name, key_hash, key_prefix, capabilities, hardware_model, paired_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
input.name,
input.key_hash,
input.key_prefix,
J(input.capabilities ?? []),
input.hardware_model ?? null,
isoNow(),
);
const id = Number(result.lastInsertRowid);
void this.notify("kiosks", "create", id);
const k = this.getKioskById(id);
if (!k) throw new Error("kiosk vanished after insert");
return k;
}
touchKiosk(
id: number,
patch: {
bundle_version?: string | null;
kiosk_app_version?: string | null;
os_version?: string | null;
},
): void {
this.prep(
`UPDATE kiosks SET
last_seen_at = ?,
last_bundle_version = COALESCE(?, last_bundle_version),
kiosk_app_version = COALESCE(?, kiosk_app_version),
os_version = COALESCE(?, os_version)
WHERE id = ?`,
).run(
isoNow(),
patch.bundle_version ?? null,
patch.kiosk_app_version ?? null,
patch.os_version ?? null,
id,
);
}
// ===========================================================================
// pairing_codes
// ===========================================================================
createPairingCode(input: {
code: string;
kiosk_proposed_name: string | null;
kiosk_hardware_model: string | null;
kiosk_capabilities: string[];
expires_at: string;
extras: Record<string, unknown>;
}): PairingCode {
this.prep(
`INSERT INTO pairing_codes
(code, kiosk_proposed_name, kiosk_hardware_model, kiosk_capabilities,
expires_at, extras)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
input.code,
input.kiosk_proposed_name,
input.kiosk_hardware_model,
J(input.kiosk_capabilities),
input.expires_at,
J(input.extras),
);
const r = this.prep("SELECT * FROM pairing_codes WHERE code = ?").get(input.code);
if (!r) throw new Error("pairing_code vanished after insert");
return rowToPairingCode(r as Record<string, unknown>);
}
getPairingCode(code: string): PairingCode | null {
const r = this.prep("SELECT * FROM pairing_codes WHERE code = ?").get(code);
return r ? rowToPairingCode(r as Record<string, unknown>) : null;
}
listPendingPairingCodes(): PairingCode[] {
const rs = this.prep(
`SELECT * FROM pairing_codes
WHERE consumed_at IS NULL AND expires_at > ?
ORDER BY issued_at DESC`,
).all(isoNow());
return rs.map((r) => rowToPairingCode(r as Record<string, unknown>));
}
markPairingCodeClaimed(
code: string,
kioskId: number,
extras: Record<string, unknown>,
): void {
this.prep(
`UPDATE pairing_codes
SET consumed_at = ?,
consumed_by_kiosk_id = ?,
extras = ?
WHERE code = ?`,
).run(isoNow(), kioskId, J(extras), code);
}
updatePairingCodeExtras(code: string, extras: Record<string, unknown>): void {
this.prep("UPDATE pairing_codes SET extras = ? WHERE code = ?").run(
J(extras),
code,
);
}
// ===========================================================================
// event_log
// ===========================================================================
insertEvent(input: {
source_kiosk_id: number | null;
source_camera_id: number | null;
source_type: EventSourceType;
topic: string;
property_op: string | null;
payload: Record<string, unknown>;
forwarded_to_nodered: boolean;
}): number {
const result = this.prep(
`INSERT INTO event_log
(source_kiosk_id, source_camera_id, source_type, topic,
property_op, payload, forwarded_to_nodered)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
input.source_kiosk_id,
input.source_camera_id,
input.source_type,
input.topic,
input.property_op,
J(input.payload),
B(input.forwarded_to_nodered),
);
return Number(result.lastInsertRowid);
}
recentEvents(limit = 10): EventLog[] {
const rs = this.prep(
"SELECT * FROM event_log ORDER BY received_at DESC LIMIT ?",
).all(limit);
return rs.map((r) => rowToEventLog(r as Record<string, unknown>));
}
// ===========================================================================
// bundle queries (label-aware composite reads)
// ===========================================================================
/**
* Returns label IDs + names attached to a kiosk by role.
* Used by `service-bundle` to scope a kiosk's view of the world.
*/
bundleScope(kioskId: number): {
labelIds: number[];
labelNames: string[];
operateLabelIds: number[];
operateLabelNames: string[];
} {
const all = this.listKioskLabels(kioskId);
const labelIds: number[] = [];
const labelNames: string[] = [];
const operateLabelIds: number[] = [];
const operateLabelNames: string[] = [];
const seen = new Set<number>();
for (const kl of all) {
if (!seen.has(kl.label_id)) {
seen.add(kl.label_id);
labelIds.push(kl.label_id);
labelNames.push(kl.name);
}
if (kl.role === "operate") {
operateLabelIds.push(kl.label_id);
operateLabelNames.push(kl.name);
}
}
return { labelIds, labelNames, operateLabelIds, operateLabelNames };
}
/** Cameras whose label set intersects the given label IDs. */
camerasForLabelIds(labelIds: number[]): Camera[] {
if (labelIds.length === 0) return [];
const placeholders = labelIds.map(() => "?").join(",");
const rs = this.db
.prepare(
`SELECT DISTINCT c.* FROM cameras c
JOIN camera_labels cl ON cl.camera_id = c.id
WHERE cl.label_id IN (${placeholders})
AND c.enabled = 1
ORDER BY c.name`,
)
.all(...(labelIds as never[]));
return rs.map((r) => rowToCamera(r as Record<string, unknown>));
}
layoutsForLabelIds(labelIds: number[]): Layout[] {
if (labelIds.length === 0) return [];
const placeholders = labelIds.map(() => "?").join(",");
const rs = this.db
.prepare(
`SELECT DISTINCT l.* FROM layouts l
JOIN layout_labels ll ON ll.layout_id = l.id
WHERE ll.label_id IN (${placeholders})
ORDER BY l.name`,
)
.all(...(labelIds as never[]));
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
}
layoutCells(layoutId: number): LayoutCell[] {
const rs = this.prep(
"SELECT * FROM layout_cells WHERE layout_id = ?",
).all(layoutId);
return rs.map((r) => rowToLayoutCell(r as Record<string, unknown>));
}
layoutTemplates(ids: number[]): LayoutTemplate[] {
if (ids.length === 0) return [];
const placeholders = ids.map(() => "?").join(",");
const rs = this.db
.prepare(
`SELECT * FROM layout_templates WHERE id IN (${placeholders})`,
)
.all(...(ids as never[]));
return rs.map((r) => rowToLayoutTemplate(r as Record<string, unknown>));
}
cameraLabelNames(cameraId: number): string[] {
const rs = this.prep(
`SELECT l.name FROM camera_labels cl
JOIN labels l ON l.id = cl.label_id
WHERE cl.camera_id = ?`,
).all(cameraId);
return rs.map((r) => String((r as Record<string, unknown>)["name"]));
}
}

View file

@ -0,0 +1,43 @@
/**
* SQLite row TS object mapping helpers.
*
* SQLite booleans are stored as INTEGER 0/1 convert with `b()`.
* JSON columns are stored as TEXT parse with `j()` / serialize with `J()`.
*/
export function b(value: unknown): boolean {
return value === 1 || value === true || value === "1";
}
export function B(value: boolean): 0 | 1 {
return value ? 1 : 0;
}
export function j<T>(value: unknown, fallback: T): T {
if (typeof value !== "string" || value.length === 0) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
export function J(value: unknown): string {
return JSON.stringify(value ?? null);
}
export function isoNow(): string {
return new Date().toISOString();
}
/** Add `n` seconds to `now()` and return ISO. */
export function isoIn(seconds: number): string {
return new Date(Date.now() + seconds * 1000).toISOString();
}
/** Compare two ISO strings as UTC datetimes. -1, 0, 1. */
export function isoCmp(a: string, b: string): -1 | 0 | 1 {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}

View file

@ -0,0 +1,31 @@
/**
* Form schemas for the account-management pages.
*/
import * as av from "@anyvali/js";
export const passwordChangeForm = av.object(
{
current_password: av.string().minLength(1).maxLength(256),
new_password: av.string().minLength(12).maxLength(256),
},
{ unknownKeys: "strip" },
);
export const totpConfirmForm = av.object(
{
enrollment_id: av.string().minLength(1).maxLength(64),
code: av.string().pattern("^\\d{6}$"),
},
{ unknownKeys: "strip" },
);
export const totpDisableForm = av.object(
{
password: av.string().minLength(1).maxLength(256),
},
{ unknownKeys: "strip" },
);
export type PasswordChangeForm = av.Infer<typeof passwordChangeForm>;
export type TotpConfirmForm = av.Infer<typeof totpConfirmForm>;
export type TotpDisableForm = av.Infer<typeof totpDisableForm>;

View file

@ -0,0 +1,55 @@
/**
* Form schemas for camera + kiosk admin pages.
*
* Camera-create is a discriminated union on `type`. anyvali's `union`
* picks first match, with the `literal` field as the discriminant.
*/
import * as av from "@anyvali/js";
const labelName = av.string().minLength(1).maxLength(64).pattern("^[a-z0-9][a-z0-9_-]*$");
const cameraCreateRtsp = av.object(
{
name: av.string().minLength(1).maxLength(128),
type: av.literal("rtsp"),
rtsp_url: av.string().minLength(1).maxLength(1024),
},
{ unknownKeys: "strip" },
);
const cameraCreateOnvif = av.object(
{
name: av.string().minLength(1).maxLength(128),
type: av.literal("onvif"),
onvif_host: av.string().minLength(1).maxLength(255),
onvif_port: av.optional(av.int().min(1).max(65535)),
onvif_username: av.optional(av.string().maxLength(128)),
onvif_password: av.optional(av.string().maxLength(256)),
},
{ unknownKeys: "strip" },
);
export const cameraCreateForm = av.union([cameraCreateRtsp, cameraCreateOnvif]);
export const kioskPairConfirmForm = av.object(
{
code: av.string().pattern("^[A-HJ-NP-Z2-9]{8}$"),
name_override: av.optional(av.string().minLength(1).maxLength(128)),
/** Comma-separated label names. The handler splits on commas. */
initial_labels: av.optional(av.string().maxLength(1024)),
},
{ unknownKeys: "strip" },
);
export const labelCreateForm = av.object(
{
name: labelName,
description: av.optional(av.string().maxLength(256)),
color: av.optional(av.string().maxLength(16)),
},
{ unknownKeys: "strip" },
);
export type CameraCreateForm = av.Infer<typeof cameraCreateForm>;
export type KioskPairConfirmForm = av.Infer<typeof kioskPairConfirmForm>;
export type LabelCreateForm = av.Infer<typeof labelCreateForm>;

View file

@ -0,0 +1,45 @@
/**
* Form schemas for the auth flow.
*
* Server: parsed from x-www-form-urlencoded body before any DB access.
* Browser: same schemas drive HTML5 attributes via @anyvali/js/forms.
*/
import * as av from "@anyvali/js";
const usernamePattern = "^[a-zA-Z0-9_-]+$";
export const setupForm = av.object(
{
username: av.string().minLength(3).maxLength(64).pattern(usernamePattern),
password: av.string().minLength(12).maxLength(256),
password_confirm: av.string().minLength(12).maxLength(256),
},
{ unknownKeys: "strip" },
);
export const loginForm = av.object(
{
username: av.string().minLength(1).maxLength(64),
password: av.string().minLength(1).maxLength(256),
},
{ unknownKeys: "strip" },
);
export const totpForm = av.object(
{
code: av.string().pattern("^\\d{6}$"),
},
{ unknownKeys: "strip" },
);
export const recoveryForm = av.object(
{
code: av.string().minLength(6).maxLength(20),
},
{ unknownKeys: "strip" },
);
export type SetupForm = av.Infer<typeof setupForm>;
export type LoginForm = av.Infer<typeof loginForm>;
export type TotpForm = av.Infer<typeof totpForm>;
export type RecoveryForm = av.Infer<typeof recoveryForm>;

View file

@ -0,0 +1,68 @@
/**
* Schema registry. Single source of truth for which schemas exist and what
* they're called when exported to /schemas/<key>.av.json.
*
* Keys use dotted namespaces:
* wire.* cross-language wire contracts (kiosk, node-red consume these)
* forms.* HTML form bodies (browser + server consume these)
*
* Add new schemas here and run `npm run schemas:export`.
*/
import type { BaseSchema } from "@anyvali/js";
import { passwordChangeForm, totpConfirmForm, totpDisableForm } from "./forms/account.js";
import {
cameraCreateForm,
kioskPairConfirmForm,
labelCreateForm,
} from "./forms/admin.js";
import { loginForm, recoveryForm, setupForm, totpForm } from "./forms/auth.js";
import { kioskBundle } from "./wire/bundle.js";
import {
kioskEvent,
kioskEventResponse,
kioskHeartbeat,
kioskHeartbeatResponse,
} from "./wire/events.js";
import {
pairClaimRequest,
pairClaimResponse,
pairInitiateRequest,
pairInitiateResponse,
} from "./wire/pairing.js";
// `BaseSchema<unknown, any>` so heterogeneous schemas fit in one map.
// We never read .Output through the registry — handlers import the named
// schema directly when they care about types.
type AnySchema = BaseSchema<unknown, unknown>;
export const schemas = {
// Forms
"forms.setup": setupForm as AnySchema,
"forms.login": loginForm as AnySchema,
"forms.totp": totpForm as AnySchema,
"forms.recovery": recoveryForm as AnySchema,
"forms.password_change": passwordChangeForm as AnySchema,
"forms.totp_confirm": totpConfirmForm as AnySchema,
"forms.totp_disable": totpDisableForm as AnySchema,
"forms.camera_create": cameraCreateForm as AnySchema,
"forms.kiosk_pair_confirm": kioskPairConfirmForm as AnySchema,
"forms.label_create": labelCreateForm as AnySchema,
// Wire
"wire.pair_initiate": pairInitiateRequest as AnySchema,
"wire.pair_initiate_response": pairInitiateResponse as AnySchema,
"wire.pair_claim": pairClaimRequest as AnySchema,
"wire.pair_claim_response": pairClaimResponse as AnySchema,
"wire.kiosk_bundle": kioskBundle as AnySchema,
"wire.kiosk_heartbeat": kioskHeartbeat as AnySchema,
"wire.kiosk_heartbeat_response": kioskHeartbeatResponse as AnySchema,
"wire.kiosk_event": kioskEvent as AnySchema,
"wire.kiosk_event_response": kioskEventResponse as AnySchema,
} as const;
export type SchemaKey = keyof typeof schemas;
export function getSchema(key: SchemaKey): AnySchema {
return schemas[key];
}

View file

@ -0,0 +1,130 @@
/**
* Wire schema for the kiosk bundle response.
*
* GET /api/kiosk/bundle (kiosk-key auth) this. Contains everything the kiosk
* needs to operate offline. Camera passwords are cluster-encrypted before
* being placed here; the kiosk decrypts using `cluster_key` it received during
* pairing.
*
* Cross-language: imported by the Rust kiosk to populate its in-memory
* configuration. Schema drift will fail loud `unknownKeys: "reject"`.
*/
import * as av from "@anyvali/js";
const cameraType = av.enum_(["rtsp", "onvif"] as const);
const streamRole = av.enum_(["main", "sub", "other"] as const);
const streamSelector = av.enum_(["auto", "main", "sub"] as const);
const layoutPriority = av.enum_(["hot", "normal", "cold"] as const);
const cellContentType = av.enum_(["camera", "web", "html"] as const);
const cameraStream = av.object(
{
id: av.int().min(1),
role: streamRole,
name: av.string().minLength(1).maxLength(64),
rtsp_uri: av.string().minLength(1),
width: av.optional(av.int().min(1).max(8192)),
height: av.optional(av.int().min(1).max(8192)),
encoding: av.optional(av.string().maxLength(32)),
framerate: av.optional(av.number().min(0)),
bitrate_kbps: av.optional(av.int().min(0)),
},
{ unknownKeys: "reject" },
);
const onvifInfo = av.object(
{
host: av.string().minLength(1).maxLength(255),
port: av.int().min(1).max(65535),
username: av.nullable(av.string().maxLength(128)),
password_cluster_encrypted: av.nullable(av.string()),
},
{ unknownKeys: "reject" },
);
const camera = av.object(
{
id: av.int().min(1),
name: av.string().minLength(1).maxLength(128),
type: cameraType,
labels: av.array(av.string()),
should_operate: av.bool(),
rtsp_url: av.nullable(av.string()),
stream_policy: av.enum_(["auto", "always_main", "always_sub"] as const),
onvif: av.nullable(onvifInfo),
streams: av.array(cameraStream),
capabilities: av.array(av.string()),
},
{ unknownKeys: "reject" },
);
const layoutTemplate = av.object(
{
id: av.int().min(1),
name: av.string().minLength(1).maxLength(128),
regions: av.array(
av.object(
{
name: av.string().minLength(1).maxLength(64),
row: av.int().min(0).max(11),
col: av.int().min(0).max(11),
rowSpan: av.int().min(1).max(12),
colSpan: av.int().min(1).max(12),
},
{ unknownKeys: "reject" },
),
),
grid_cols: av.int().min(1).max(64),
grid_rows: av.int().min(1).max(64),
},
{ unknownKeys: "reject" },
);
const layoutCell = av.object(
{
region_name: av.string().minLength(1).maxLength(64),
content_type: cellContentType,
camera_id: av.nullable(av.int().min(1)),
stream_selector: streamSelector,
web_url: av.nullable(av.string()),
html_content: av.nullable(av.string()),
cooling_timeout_seconds: av.nullable(av.int().min(0)),
options: av.record(av.unknown()),
},
{ unknownKeys: "reject" },
);
const layout = av.object(
{
id: av.int().min(1),
name: av.string().minLength(1).maxLength(128),
template_id: av.int().min(1),
display_id: av.int().min(1),
priority: layoutPriority,
cooling_timeout_seconds: av.nullable(av.int().min(0)),
preload_camera_ids: av.array(av.int().min(1)),
is_default: av.bool(),
resets_idle_timer: av.bool(),
cells: av.array(layoutCell),
},
{ unknownKeys: "reject" },
);
export const kioskBundle = av.object(
{
kiosk_id: av.int().min(1),
kiosk_name: av.string().minLength(1).maxLength(128),
labels: av.array(av.string()),
operate_labels: av.array(av.string()),
cameras: av.array(camera),
templates: av.array(layoutTemplate),
layouts: av.array(layout),
version: av.string().minLength(1).maxLength(64),
},
{ unknownKeys: "reject" },
);
export type KioskBundle = av.Infer<typeof kioskBundle>;
export type BundleCamera = av.Infer<typeof camera>;
export type BundleLayout = av.Infer<typeof layout>;
export type BundleLayoutCell = av.Infer<typeof layoutCell>;

View file

@ -0,0 +1,64 @@
/**
* Wire schemas for kiosk-emitted reports.
*
* POST /api/kiosk/heartbeat liveness, version, applied bundle hash
* POST /api/kiosk/event forward a hardware event (ONVIF, GPIO, etc.)
*
* The server logs all events to event_log and forwards them to Node-RED for
* rule processing. Cross-language: imported by Rust kiosk for outbound calls
* and by the Node-RED bridge to validate inbound payloads.
*/
import * as av from "@anyvali/js";
export const kioskHeartbeat = av.object(
{
bundle_version: av.optional(av.string().maxLength(64)),
kiosk_app_version: av.optional(av.string().maxLength(64)),
os_version: av.optional(av.string().maxLength(128)),
uptime_seconds: av.optional(av.int().min(0)),
cpu_load: av.optional(av.number().min(0).max(100)),
memory_used_mb: av.optional(av.int().min(0)),
active_layout_id: av.optional(av.int().min(1)),
streams_warm: av.optional(av.int().min(0)),
streams_hot: av.optional(av.int().min(0)),
},
{ unknownKeys: "strip" },
);
export const eventSourceType = av.enum_(["onvif", "gpio", "synthetic", "system"] as const);
export const kioskEvent = av.object(
{
topic: av.string().minLength(1).maxLength(256),
source_type: eventSourceType,
camera_id: av.optional(av.int().min(1)),
property_op: av.optional(av.enum_(["initial", "changed"] as const)),
payload: av.record(av.unknown()),
occurred_at: av.optional(av.string().format("date-time")),
},
{ unknownKeys: "reject" },
);
export const kioskHeartbeatResponse = av.object(
{
ok: av.bool(),
now: av.string().format("date-time"),
/** If non-null and != current bundle, kiosk should refetch. */
bundle_version_current: av.optional(av.string().maxLength(64)),
},
{ unknownKeys: "reject" },
);
export const kioskEventResponse = av.object(
{
ok: av.bool(),
event_id: av.optional(av.int().min(1)),
error: av.optional(av.string()),
},
{ unknownKeys: "reject" },
);
export type KioskHeartbeat = av.Infer<typeof kioskHeartbeat>;
export type KioskEvent = av.Infer<typeof kioskEvent>;
export type KioskHeartbeatResponse = av.Infer<typeof kioskHeartbeatResponse>;
export type KioskEventResponse = av.Infer<typeof kioskEventResponse>;

View file

@ -0,0 +1,79 @@
/**
* Wire schemas for the kiosk pairing flow.
*
* Cross-language: imported by the Rust kiosk (`av::import_schema`) and the
* Node-RED TypeScript custom nodes. Authored here in TypeScript and exported
* as canonical JSON to /schemas/wire.pair_*.av.json.
*/
import * as av from "@anyvali/js";
/** Capability strings a kiosk reports. Keep this list in sync with the Rust enum. */
export const KIOSK_CAPABILITIES = [
"display",
"cec",
"gpio",
"onvif_discovery",
"hw_decode",
] as const;
/**
* Step 1: kiosk server. The unpaired kiosk introduces itself and asks for a
* pairing code. Untrusted; rate-limit at the proxy.
*/
export const pairInitiateRequest = av.object(
{
proposed_name: av.string().minLength(1).maxLength(128),
hardware_model: av.optional(av.string().maxLength(128)),
capabilities: av.array(av.enum_(KIOSK_CAPABILITIES)),
os_version: av.optional(av.string().maxLength(128)),
kiosk_app_version: av.optional(av.string().maxLength(64)),
},
{ unknownKeys: "reject" },
);
/**
* Step 1 response: server kiosk. Server allocated an 8-character code that
* the kiosk should display on its screen for the admin to enter.
*/
export const pairInitiateResponse = av.object(
{
code: av.string().pattern("^[A-HJ-NP-Z2-9]{8}$"), // 0/O/1/I excluded
expires_at: av.string().format("date-time"),
},
{ unknownKeys: "reject" },
);
/**
* Step 3: kiosk polls server. Body carries only the code. Three terminal
* outcomes:
* - 202: still waiting for admin confirmation
* - 200 + body: confirmed; the response carries the kiosk_key + cluster_key
* - 4xx: unknown / expired / already claimed
*/
export const pairClaimRequest = av.object(
{
code: av.string().pattern("^[A-HJ-NP-Z2-9]{8}$"),
},
{ unknownKeys: "reject" },
);
/**
* Step 3 successful response. The kiosk persists `kiosk_key` 0600 and uses it
* as the Bearer token for all subsequent requests. `cluster_key` is the shared
* symmetric key for camera-password decryption.
*/
export const pairClaimResponse = av.object(
{
kiosk_id: av.int().min(1),
name: av.string().minLength(1).maxLength(128),
kiosk_key: av.string().minLength(32),
cluster_key: av.string().minLength(32),
bundle_url: av.string().minLength(1),
},
{ unknownKeys: "reject" },
);
export type PairInitiateRequest = av.Infer<typeof pairInitiateRequest>;
export type PairInitiateResponse = av.Infer<typeof pairInitiateResponse>;
export type PairClaimRequest = av.Infer<typeof pairClaimRequest>;
export type PairClaimResponse = av.Infer<typeof pairClaimResponse>;

View file

@ -0,0 +1,50 @@
/**
* Schema export.
*
* Run: `npm run schemas:export` (after build) writes every registered
* anyvali schema to /schemas/<key>.av.json. The output is portable: Rust kiosk
* + Node-RED nodes + the browser (via vendored @anyvali/js) all consume these
* exact files.
*
* Also copies the JSON into server/src/web-static/schemas/ so the browser
* fetches them via /static/schemas/<key>.av.json with no extra plumbing.
*/
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { schemas, type SchemaKey } from "../schemas/index.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
// __dirname here is server/lib/scripts after build; want the repo root.
const REPO_ROOT = join(__dirname, "..", "..", "..");
const CANONICAL_DIR = join(REPO_ROOT, "schemas");
const STATIC_DIR = join(REPO_ROOT, "server", "src", "web-static", "schemas");
function freshDir(p: string): void {
rmSync(p, { recursive: true, force: true });
mkdirSync(p, { recursive: true });
}
function exportAll(): void {
freshDir(CANONICAL_DIR);
freshDir(STATIC_DIR);
let count = 0;
for (const key of Object.keys(schemas) as SchemaKey[]) {
const doc = schemas[key].export("portable");
const json = JSON.stringify(doc, null, 2) + "\n";
const file = `${key}.av.json`;
writeFileSync(join(CANONICAL_DIR, file), json, "utf-8");
writeFileSync(join(STATIC_DIR, file), json, "utf-8");
count += 1;
}
// eslint-disable-next-line no-console
console.log(`Exported ${count} schemas to:`);
// eslint-disable-next-line no-console
console.log(` ${CANONICAL_DIR}`);
// eslint-disable-next-line no-console
console.log(` ${STATIC_DIR}`);
}
exportAll();

218
server/src/shared/types.ts Normal file
View file

@ -0,0 +1,218 @@
/**
* Cross-plugin types. Lives outside `plugins/` so any service can import.
*
* Domain types here mirror the SQL schema. Keep field names snake_case in the
* DB, camelCase on the wire/UI. Translation happens in service-store.
*/
export type UserRole = "admin" | "operator";
export type ApiKeyScope = "read" | "control" | "admin";
export type CameraType = "rtsp" | "onvif";
export type StreamRole = "main" | "sub" | "other";
export type StreamSelector = "auto" | "main" | "sub";
export type StreamPolicy = "auto" | "always_main" | "always_sub";
export type LayoutPriority = "hot" | "normal" | "cold";
export type CellContentType = "camera" | "web" | "html";
export type DesiredPowerState = "follow_layout" | "on" | "standby";
export type LabelRole = "consume" | "operate";
export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system";
export interface User {
id: number;
username: string;
password_hash: string;
role: UserRole;
is_active: boolean;
totp_enabled: boolean;
totp_secret_encrypted: string | null;
recovery_codes_hashed: string[]; // each element argon2-hashed
must_change_password: boolean;
failed_login_count: number;
locked_until: string | null; // ISO 8601
last_login_at: string | null;
created_at: string;
}
export interface Session {
id: string; // hex32
user_id: number;
csrf_token: string;
totp_pending: boolean;
user_agent: string | null;
ip_address: string | null;
issued_at: string;
last_seen_at: string;
expires_at: string; // absolute (30d max)
revoked_at: string | null;
}
export interface ApiKey {
id: number;
name: string;
key_hash: string;
key_prefix: string; // indexed for O(1) lookup
scopes: ApiKeyScope[];
expires_at: string | null;
last_used_at: string | null;
last_used_ip: string | null;
created_at: string;
revoked_at: string | null;
}
export interface SetupState {
id: 1;
is_complete: boolean;
cluster_key_provisioned: boolean;
nodered_flows_deployed: boolean;
completed_at: string | null;
extras: Record<string, unknown>;
}
export interface Display {
id: number;
name: string;
index: number; // unique
is_primary: boolean;
width_px: number;
height_px: number;
default_layout_id: number | null;
idle_timeout_seconds: number;
sleep_timeout_seconds: number;
cec_enabled: boolean;
cec_device_path: string | null;
cec_logical_address: number | null;
desired_power_state: DesiredPowerState;
state_check_enabled: boolean;
state_check_interval_seconds: number;
}
export interface Camera {
id: number;
name: string;
type: CameraType;
rtsp_url: string | null;
onvif_host: string | null;
onvif_port: number | null;
onvif_username: string | null;
onvif_password: string | null; // fernet-encrypted ciphertext
capabilities: string[];
stream_policy: StreamPolicy;
enabled: boolean;
last_seen_at: string | null;
created_at: string;
}
export interface CameraStream {
id: number;
camera_id: number;
role: StreamRole;
name: string;
profile_token: string | null;
rtsp_uri: string;
width: number | null;
height: number | null;
encoding: string | null;
framerate: number | null;
bitrate_kbps: number | null;
is_discovered: boolean;
}
export interface LayoutTemplate {
id: number;
name: string;
description: string | null;
regions: LayoutRegion[];
grid_cols: number;
grid_rows: number;
is_builtin: boolean;
}
export interface LayoutRegion {
name: string;
row: number;
col: number;
rowSpan: number;
colSpan: number;
}
export interface Layout {
id: number;
name: string;
description: string | null;
template_id: number;
display_id: number;
priority: LayoutPriority;
cooling_timeout_seconds: number | null;
preload_camera_ids: number[];
is_default: boolean;
resets_idle_timer: boolean;
}
export interface LayoutCell {
id: number;
layout_id: number;
region_name: string;
content_type: CellContentType;
camera_id: number | null;
stream_selector: StreamSelector;
web_url: string | null;
html_content: string | null;
cooling_timeout_seconds: number | null;
options: Record<string, unknown>;
}
export interface Kiosk {
id: number;
name: string;
description: string | null;
key_hash: string;
key_prefix: string;
capabilities: string[];
hardware_model: string | null;
os_version: string | null;
kiosk_app_version: string | null;
enabled: boolean;
paired_at: string | null;
last_seen_at: string | null;
last_bundle_version: string | null;
display_id: number | null;
created_at: string;
}
export interface Label {
id: number;
name: string;
description: string | null;
color: string | null;
created_at: string;
}
export interface KioskLabel {
kiosk_id: number;
label_id: number;
role: LabelRole;
}
export interface PairingCode {
code: string;
kiosk_proposed_name: string | null;
kiosk_hardware_model: string | null;
kiosk_capabilities: string[];
issued_at: string;
expires_at: string;
consumed_at: string | null;
consumed_by_kiosk_id: number | null;
extras: Record<string, unknown>;
}
export interface EventLog {
id: number;
source_kiosk_id: number | null;
source_camera_id: number | null;
source_type: EventSourceType;
topic: string;
property_op: string | null;
payload: Record<string, unknown>;
received_at: string;
forwarded_to_nodered: boolean;
}

View file

@ -0,0 +1 @@
0.2.0

View file

@ -0,0 +1,12 @@
export class ValidationError extends Error {
issues;
constructor(issues) {
const message = issues
.map((i) => `[${i.code}] ${i.path.length > 0 ? i.path.join(".") + ": " : ""}${i.message}`)
.join("\n");
super(message);
this.name = "ValidationError";
this.issues = issues;
}
}
//# sourceMappingURL=errors.js.map

View file

@ -0,0 +1,51 @@
// Email must have at least one dot after the @
const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
const IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
const IPV6_RE = /^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?::[0-9a-fA-F]{1,4}){1,7}|::)$/;
// ISO 8601 date: YYYY-MM-DD
const DATE_RE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
// ISO 8601 date-time: YYYY-MM-DDTHH:MM:SS with optional fractional seconds and timezone
const DATETIME_RE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
function isValidDate(str) {
if (!DATE_RE.test(str))
return false;
const [y, m, d] = str.split("-").map(Number);
const date = new Date(y, m - 1, d);
return (date.getFullYear() === y &&
date.getMonth() === m - 1 &&
date.getDate() === d);
}
function isValidDateTime(str) {
if (!DATETIME_RE.test(str))
return false;
// Validate the date portion
const datePart = str.substring(0, 10);
return isValidDate(datePart);
}
function isValidUrl(str) {
try {
const url = new URL(str);
// Only accept http and https protocols
return url.protocol === "http:" || url.protocol === "https:";
}
catch {
return false;
}
}
const FORMAT_VALIDATORS = {
email: (val) => EMAIL_RE.test(val),
url: isValidUrl,
uuid: (val) => UUID_RE.test(val),
ipv4: (val) => IPV4_RE.test(val),
ipv6: (val) => IPV6_RE.test(val),
date: isValidDate,
"date-time": isValidDateTime,
};
export function validateFormat(value, format) {
const validator = FORMAT_VALIDATORS[format];
if (!validator)
return true; // unknown formats pass
return validator(value);
}
//# sourceMappingURL=validators.js.map

View file

@ -0,0 +1,586 @@
import { exportSchema } from "../interchange/exporter.js";
import { BaseSchema } from "../schemas/base.js";
import { importSchema } from "../interchange/importer.js";
export function createFormBindings(options) {
const doc = normalizeSchemaSource(options.schema);
const errorIdPrefix = options.errorIdPrefix ?? "anyvali";
return {
field(path, attrs = {}) {
return {
...getFieldAttributes(doc, path, errorIdPrefix),
...attrs,
};
},
errorSlot(path, attrs = {}) {
return {
id: errorIdForPath(path, errorIdPrefix),
"data-anyvali-error-for": canonicalizePath(path),
"aria-live": "polite",
...attrs,
};
},
htmx(config) {
const attrs = {};
for (const [method, attr] of [
["get", "hx-get"],
["post", "hx-post"],
["put", "hx-put"],
["patch", "hx-patch"],
["delete", "hx-delete"],
]) {
const value = config[method];
if (value) {
attrs[attr] = value;
}
}
if (config.target)
attrs["hx-target"] = config.target;
if (config.swap)
attrs["hx-swap"] = config.swap;
if (config.trigger)
attrs["hx-trigger"] = config.trigger;
if (config.select)
attrs["hx-select"] = config.select;
if (config.confirm)
attrs["hx-confirm"] = config.confirm;
if (config.include)
attrs["hx-include"] = config.include;
if (config.encoding)
attrs["hx-encoding"] = config.encoding;
if (config.ext)
attrs["hx-ext"] = config.ext;
if (config.indicator)
attrs["hx-indicator"] = config.indicator;
if (config.pushUrl)
attrs["hx-push-url"] = config.pushUrl;
if (config.replaceUrl)
attrs["hx-replace-url"] = config.replaceUrl;
if (config.validate !== undefined) {
attrs["hx-validate"] = String(config.validate);
}
return attrs;
},
init(target, initOptions = {}) {
return initForm(target, {
schema: doc,
...initOptions,
});
},
};
}
export function initForm(target, options) {
const form = resolveForm(target);
const doc = normalizeSchemaSource(options.schema);
const schema = importSchema(doc);
const validateOn = new Set(options.validateOn ?? ["blur", "submit"]);
const nativeValidation = options.nativeValidation ?? true;
const reportValidity = options.reportValidity ?? true;
const useHtmx = options.htmx ?? true;
if (nativeValidation) {
applyNativeConstraints(form, doc);
}
const listeners = [];
const addListener = (eventTarget, event, handler, options) => {
eventTarget.addEventListener(event, handler, options);
listeners.push({ target: eventTarget, event, handler, options });
};
const validateField = (fieldName, shouldReport = false) => {
clearFieldState(form, fieldName);
const result = schema.safeParse(readFormValues(form, doc));
if (!result.success) {
const fieldPath = parseFieldPath(fieldName);
const issue = firstIssueForField(result.issues, fieldPath);
if (issue) {
applyFieldIssue(form, fieldName, issue);
if (shouldReport) {
firstControlForField(form, fieldName)?.reportValidity?.();
}
return false;
}
}
if (shouldReport) {
firstControlForField(form, fieldName)?.reportValidity?.();
}
return true;
};
const validateFormState = (shouldReport = false) => {
clearAllFieldState(form);
const result = schema.safeParse(readFormValues(form, doc));
if (!result.success) {
const fieldNames = fieldNamesForForm(form);
for (const fieldName of fieldNames) {
const issue = firstIssueForField(result.issues, parseFieldPath(fieldName));
if (issue) {
applyFieldIssue(form, fieldName, issue);
}
}
if (shouldReport) {
form.reportValidity?.();
}
return false;
}
return true;
};
if (validateOn.has("input")) {
addListener(form, "input", (event) => {
const control = event.target;
if (control?.name) {
validateField(control.name, false);
}
});
}
if (validateOn.has("change")) {
addListener(form, "change", (event) => {
const control = event.target;
if (control?.name) {
validateField(control.name, false);
}
});
}
if (validateOn.has("blur")) {
addListener(form, "blur", (event) => {
const control = event.target;
if (control?.name) {
validateField(control.name, reportValidity);
}
}, true);
}
addListener(form, "submit", (event) => {
if (!validateFormState(validateOn.has("submit") && reportValidity)) {
event.preventDefault();
event.stopPropagation();
}
});
if (useHtmx) {
const htmx = globalThis.htmx;
if (htmx?.config && reportValidity) {
htmx.config.reportValidityOfForms = true;
}
addListener(form, "htmx:validation:validate", () => {
validateFormState(false);
});
}
return {
form,
document: doc,
validate() {
return validateFormState(false);
},
getValues() {
return readFormValues(form, doc);
},
getResult() {
return schema.safeParse(readFormValues(form, doc));
},
destroy() {
for (const entry of listeners) {
entry.target.removeEventListener(entry.event, entry.handler, entry.options);
}
},
};
}
export function getFieldAttributes(schema, path, errorIdPrefix = "anyvali") {
const doc = normalizeSchemaSource(schema);
const resolved = resolveFieldSchema(doc, path);
const attrs = {
name: path,
"data-anyvali-path": canonicalizePath(path),
"aria-describedby": errorIdForPath(path, errorIdPrefix),
};
if (!resolved) {
return attrs;
}
const { node } = resolved;
const unwrapped = unwrapNode(resolveRefNode(doc, node));
const effective = unwrapped.node;
if (resolved.required && effective.kind !== "bool") {
attrs.required = true;
}
if (effective.kind === "string") {
applyStringAttributes(attrs, effective);
}
else if (isNumericNode(effective)) {
applyNumericAttributes(attrs, effective);
}
else if (effective.kind === "bool") {
attrs.type = "checkbox";
}
else if (effective.kind === "array") {
applyArrayAttributes(attrs, effective, resolved.required);
}
return attrs;
}
function normalizeSchemaSource(schema) {
if (schema instanceof BaseSchema) {
return exportSchema(schema);
}
return schema;
}
function resolveForm(target) {
if (typeof target !== "string") {
return target;
}
const element = document.querySelector(target);
if (!(element instanceof HTMLFormElement)) {
throw new Error(`Form not found for selector: ${target}`);
}
return element;
}
function applyNativeConstraints(form, doc) {
for (const fieldName of fieldNamesForForm(form)) {
const attrs = getFieldAttributes(doc, fieldName);
const controls = controlsForField(form, fieldName);
for (const control of controls) {
for (const [key, value] of Object.entries(attrs)) {
if (key === "name" || key === "data-anyvali-path" || key === "aria-describedby") {
setControlAttribute(control, key, value);
continue;
}
if (value === undefined || value === null) {
continue;
}
if (key === "type" && control instanceof HTMLInputElement) {
if (!control.hasAttribute("type") || control.type === "text") {
control.type = String(value);
}
continue;
}
if (key === "required") {
if (!control.hasAttribute("required")) {
control.required = Boolean(value);
}
continue;
}
if (!control.hasAttribute(key)) {
setControlAttribute(control, key, value);
}
}
}
}
}
function setControlAttribute(control, key, value) {
if (typeof value === "boolean") {
if (value) {
control.setAttribute(key, "");
}
else {
control.removeAttribute(key);
}
return;
}
control.setAttribute(key, String(value));
}
function readFormValues(form, doc) {
const root = {};
for (const fieldName of fieldNamesForForm(form)) {
const controls = controlsForField(form, fieldName);
if (controls.length === 0)
continue;
const resolved = resolveFieldSchema(doc, fieldName);
const value = readFieldValue(controls, resolved?.node);
if (value === undefined)
continue;
setPathValue(root, parseFieldPath(fieldName), value);
}
return root;
}
function readFieldValue(controls, node) {
const first = controls[0];
if (!first)
return undefined;
const effectiveNode = node ? unwrapNode(node).node : undefined;
if (first instanceof HTMLSelectElement && first.multiple) {
return Array.from(first.selectedOptions).map((option) => option.value);
}
if (first instanceof HTMLInputElement && first.type === "radio") {
const checked = controls.find((control) => control instanceof HTMLInputElement && control.checked);
return checked?.value;
}
if (controls.every((control) => control instanceof HTMLInputElement && control.type === "checkbox")) {
if (effectiveNode?.kind === "array") {
return controls
.filter((control) => control instanceof HTMLInputElement && control.checked)
.map((control) => control.value);
}
const checkbox = first;
return checkbox.checked;
}
if (first instanceof HTMLInputElement && isNumericLikeControl(first, effectiveNode)) {
if (first.value === "")
return undefined;
return first.valueAsNumber;
}
const raw = first.value;
return raw === "" ? undefined : raw;
}
function isNumericLikeControl(control, node) {
if (!node)
return control.type === "number";
const effectiveNode = unwrapNode(node).node;
return control.type === "number" || isNumericNode(effectiveNode);
}
function validateIssueMessage(issue) {
return issue.message || "Invalid value";
}
function applyFieldIssue(form, fieldName, issue) {
const message = validateIssueMessage(issue);
for (const control of controlsForField(form, fieldName)) {
control.setCustomValidity(message);
control.setAttribute("aria-invalid", "true");
control.setAttribute("data-anyvali-invalid", "true");
}
for (const slot of errorSlotsForField(form, fieldName)) {
slot.textContent = message;
slot.removeAttribute("hidden");
}
}
function clearFieldState(form, fieldName) {
for (const control of controlsForField(form, fieldName)) {
control.setCustomValidity("");
control.removeAttribute("aria-invalid");
control.removeAttribute("data-anyvali-invalid");
}
for (const slot of errorSlotsForField(form, fieldName)) {
slot.textContent = "";
slot.setAttribute("hidden", "");
}
}
function clearAllFieldState(form) {
for (const fieldName of fieldNamesForForm(form)) {
clearFieldState(form, fieldName);
}
}
function controlsForField(form, fieldName) {
return Array.from(form.querySelectorAll(`[name="${cssEscape(fieldName)}"]`));
}
function firstControlForField(form, fieldName) {
return controlsForField(form, fieldName)[0];
}
function errorSlotsForField(form, fieldName) {
const canonical = canonicalizePath(fieldName);
return Array.from(form.querySelectorAll(`[data-anyvali-error-for="${cssEscape(canonical)}"]`));
}
function fieldNamesForForm(form) {
return Array.from(new Set(Array.from(form.elements)
.filter((element) => {
return (element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement);
})
.map((element) => element.name)
.filter(Boolean)));
}
function resolveFieldSchema(doc, path) {
const segments = parseFieldPath(path);
let current = doc.root;
let required = true;
let nullable = false;
for (const segment of segments) {
if (!current) {
return null;
}
const unwrapped = unwrapNode(resolveRefNode(doc, current));
current = unwrapped.node;
nullable = nullable || unwrapped.nullable;
if (current.kind === "object" && typeof segment === "string") {
const objectNode = current;
const propertyNode = objectNode.properties[segment];
if (!propertyNode) {
return null;
}
const propertyUnwrapped = unwrapNode(resolveRefNode(doc, propertyNode));
required = required && objectNode.required.includes(segment) && !propertyUnwrapped.optional;
nullable = nullable || propertyUnwrapped.nullable;
current = propertyUnwrapped.node;
continue;
}
if (current.kind === "record" && typeof segment === "string") {
current = resolveRefNode(doc, current.valueSchema);
continue;
}
if (current.kind === "array") {
current = resolveRefNode(doc, current.items);
continue;
}
return null;
}
if (!current) {
return null;
}
const unwrapped = unwrapNode(resolveRefNode(doc, current));
return {
path: segments,
canonicalName: formatPath(segments),
required: required && !unwrapped.optional,
nullable: nullable || unwrapped.nullable,
node: unwrapped.node,
};
}
function resolveRefNode(doc, node) {
let current = node;
const seen = new Set();
while (current.kind === "ref") {
const name = current.ref.replace(/^#\/definitions\//, "");
if (seen.has(name)) {
break;
}
seen.add(name);
const resolved = doc.definitions[name];
if (!resolved) {
break;
}
current = resolved;
}
return current;
}
function unwrapNode(node) {
let current = node;
let optional = false;
let nullable = false;
while (current.kind === "optional" || current.kind === "nullable") {
if (current.kind === "optional") {
optional = true;
current = current.inner;
}
else {
nullable = true;
current = current.inner;
}
}
return { node: current, optional, nullable };
}
function applyStringAttributes(attrs, node) {
if (attrs.type === undefined) {
const type = htmlTypeForStringFormat(node.format);
if (type) {
attrs.type = type;
}
}
if (node.minLength !== undefined)
attrs.minLength = node.minLength;
if (node.maxLength !== undefined)
attrs.maxLength = node.maxLength;
if (node.pattern !== undefined)
attrs.pattern = node.pattern;
}
function applyNumericAttributes(attrs, node) {
if (attrs.type === undefined)
attrs.type = "number";
if (node.min !== undefined)
attrs.min = node.min;
if (node.max !== undefined)
attrs.max = node.max;
if (node.multipleOf !== undefined) {
attrs.step = node.multipleOf;
}
else if (isIntegerKind(node.kind)) {
attrs.step = 1;
}
attrs.inputMode = isIntegerKind(node.kind) ? "numeric" : "decimal";
}
function applyArrayAttributes(attrs, node, required) {
if (required || (node.minItems ?? 0) > 0) {
attrs.required = true;
}
}
function htmlTypeForStringFormat(format) {
switch (format) {
case "email":
return "email";
case "url":
return "url";
case "date":
return "date";
case "date-time":
return "datetime-local";
default:
return undefined;
}
}
function firstIssueForField(issues, fieldPath) {
return issues.find((issue) => isIssueForField(issue, fieldPath));
}
function isIssueForField(issue, fieldPath) {
if (issue.path.length < fieldPath.length) {
return false;
}
return fieldPath.every((segment, index) => issue.path[index] === segment);
}
function parseFieldPath(path) {
const segments = [];
const matcher = /([^[.\]]+)|\[(.*?)\]/g;
for (const match of path.matchAll(matcher)) {
const token = match[1] ?? match[2];
if (!token)
continue;
if (/^\d+$/.test(token)) {
segments.push(Number(token));
}
else {
segments.push(token);
}
}
return segments;
}
function canonicalizePath(path) {
return formatPath(parseFieldPath(path));
}
function formatPath(path) {
return path
.map((segment, index) => {
if (typeof segment === "number") {
return `[${segment}]`;
}
return index === 0 ? segment : `.${segment}`;
})
.join("");
}
function setPathValue(root, path, value) {
if (path.length === 0)
return;
let current = root;
for (let index = 0; index < path.length; index++) {
const segment = path[index];
const isLast = index === path.length - 1;
const key = String(segment);
if (isLast) {
current[key] = value;
return;
}
const existing = current[key];
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
current[key] = {};
}
current = current[key];
}
}
function errorIdForPath(path, prefix) {
return `${prefix}-error-${canonicalizePath(path).replace(/[^a-zA-Z0-9_-]+/g, "-")}`;
}
function cssEscape(value) {
const cssApi = globalThis.CSS;
if (cssApi?.escape) {
return cssApi.escape(value);
}
return value.replace(/["\\]/g, "\\$&");
}
function isNumericNode(node) {
return [
"number",
"int",
"float32",
"float64",
"int8",
"int16",
"int32",
"int64",
"uint8",
"uint16",
"uint32",
"uint64",
].includes(node.kind);
}
function isIntegerKind(kind) {
return kind.startsWith("int") || kind.startsWith("uint");
}
//# sourceMappingURL=index.js.map

View file

@ -0,0 +1,156 @@
export { ISSUE_CODES } from "./issue-codes.js";
export { ValidationError } from "./errors.js";
// ---- Re-export schema classes ----
export { BaseSchema, ABSENT, StringSchema, NumberSchema, Float32Schema, Float64Schema, IntSchema, Int8Schema, Int16Schema, Int32Schema, Int64Schema, Uint8Schema, Uint16Schema, Uint32Schema, Uint64Schema, BoolSchema, NullSchema, AnySchema, UnknownSchema, NeverSchema, LiteralSchema, EnumSchema, ArraySchema, TupleSchema, ObjectSchema, RecordSchema, UnionSchema, IntersectionSchema, OptionalSchema, NullableSchema, RefSchema, } from "./schemas/index.js";
// ---- Builder functions ----
import { StringSchema } from "./schemas/string.js";
import { NumberSchema, Float32Schema, Float64Schema } from "./schemas/number.js";
import { IntSchema, Int8Schema, Int16Schema, Int32Schema, Int64Schema, Uint8Schema, Uint16Schema, Uint32Schema, Uint64Schema, } from "./schemas/int.js";
import { BoolSchema } from "./schemas/bool.js";
import { NullSchema } from "./schemas/null.js";
import { AnySchema } from "./schemas/any.js";
import { UnknownSchema } from "./schemas/unknown.js";
import { NeverSchema } from "./schemas/never.js";
import { LiteralSchema } from "./schemas/literal.js";
import { EnumSchema } from "./schemas/enum.js";
import { ArraySchema } from "./schemas/array.js";
import { TupleSchema } from "./schemas/tuple.js";
import { ObjectSchema } from "./schemas/object.js";
import { RecordSchema } from "./schemas/record.js";
import { UnionSchema } from "./schemas/union.js";
import { IntersectionSchema } from "./schemas/intersection.js";
import { OptionalSchema } from "./schemas/optional.js";
import { NullableSchema } from "./schemas/nullable.js";
/** Create a string schema */
export function string() {
return new StringSchema();
}
/** Create a number (float64) schema */
export function number() {
return new NumberSchema();
}
/** Create a float32 schema */
export function float32() {
return new Float32Schema();
}
/** Create a float64 schema */
export function float64() {
return new Float64Schema();
}
/** Create an int (int64) schema */
export function int() {
return new IntSchema();
}
/** Create an int8 schema */
export function int8() {
return new Int8Schema();
}
/** Create an int16 schema */
export function int16() {
return new Int16Schema();
}
/** Create an int32 schema */
export function int32() {
return new Int32Schema();
}
/** Create an int64 schema */
export function int64() {
return new Int64Schema();
}
/** Create a uint8 schema */
export function uint8() {
return new Uint8Schema();
}
/** Create a uint16 schema */
export function uint16() {
return new Uint16Schema();
}
/** Create a uint32 schema */
export function uint32() {
return new Uint32Schema();
}
/** Create a uint64 schema */
export function uint64() {
return new Uint64Schema();
}
/** Create a boolean schema */
export function bool() {
return new BoolSchema();
}
/** Create a null schema. Named null_ to avoid conflict with the null keyword. */
export function null_() {
return new NullSchema();
}
/** Create an any schema */
export function any() {
return new AnySchema();
}
/** Create an unknown schema */
export function unknown() {
return new UnknownSchema();
}
/** Create a never schema */
export function never() {
return new NeverSchema();
}
/** Create a literal schema */
export function literal(value) {
return new LiteralSchema(value);
}
/** Create an enum schema. Named enum_ to avoid conflict with the enum keyword. */
export function enum_(values) {
return new EnumSchema(values);
}
/** Create an array schema */
export function array(items) {
return new ArraySchema(items);
}
/** Create a tuple schema */
export function tuple(items) {
return new TupleSchema(items);
}
/** Create an object schema */
export function object(shape, options) {
return new ObjectSchema(shape, options);
}
/** Create a record schema */
export function record(valueSchema) {
return new RecordSchema(valueSchema);
}
/** Create a union schema */
export function union(variants) {
return new UnionSchema(variants);
}
/** Create an intersection schema */
export function intersection(schemas) {
return new IntersectionSchema(schemas);
}
/** Wrap a schema as optional */
export function optional(schema) {
return new OptionalSchema(schema);
}
/** Wrap a schema as nullable */
export function nullable(schema) {
return new NullableSchema(schema);
}
// ---- Top-level parse functions ----
/** Parse input using the given schema. Throws ValidationError on failure. */
export function parse(schema, input) {
return schema.parse(input);
}
/** Parse input using the given schema. Returns a result object. */
export function safeParse(schema, input) {
return schema.safeParse(input);
}
// ---- Interchange functions ----
import { exportSchema as _exportSchema } from "./interchange/exporter.js";
import { importSchema as _importSchema } from "./interchange/importer.js";
/** Export a schema to an AnyValiDocument */
export function exportSchema(schema, mode = "portable") {
return _exportSchema(schema, mode);
}
/** Import an AnyValiDocument to a live schema */
export function importSchema(doc) {
return _importSchema(doc);
}
//# sourceMappingURL=index.js.map

View file

@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=infer.js.map

View file

@ -0,0 +1,12 @@
export const ANYVALI_VERSION = "1.0";
export const SCHEMA_VERSION = "1";
export function createDocument(root, definitions = {}, extensions = {}) {
return {
anyvaliVersion: ANYVALI_VERSION,
schemaVersion: SCHEMA_VERSION,
root,
definitions,
extensions,
};
}
//# sourceMappingURL=document.js.map

View file

@ -0,0 +1,7 @@
/**
* Export a schema to a portable AnyValiDocument.
*/
export function exportSchema(schema, mode = "portable") {
return schema.export(mode);
}
//# sourceMappingURL=exporter.js.map

View file

@ -0,0 +1,211 @@
import { StringSchema } from "../schemas/string.js";
import { NumberSchema, Float32Schema, Float64Schema, } from "../schemas/number.js";
import { IntSchema, Int8Schema, Int16Schema, Int32Schema, Int64Schema, Uint8Schema, Uint16Schema, Uint32Schema, Uint64Schema, } from "../schemas/int.js";
import { BoolSchema } from "../schemas/bool.js";
import { NullSchema } from "../schemas/null.js";
import { AnySchema } from "../schemas/any.js";
import { UnknownSchema } from "../schemas/unknown.js";
import { NeverSchema } from "../schemas/never.js";
import { LiteralSchema } from "../schemas/literal.js";
import { EnumSchema } from "../schemas/enum.js";
import { ArraySchema } from "../schemas/array.js";
import { TupleSchema } from "../schemas/tuple.js";
import { ObjectSchema } from "../schemas/object.js";
import { RecordSchema } from "../schemas/record.js";
import { UnionSchema } from "../schemas/union.js";
import { IntersectionSchema } from "../schemas/intersection.js";
import { OptionalSchema } from "../schemas/optional.js";
import { NullableSchema } from "../schemas/nullable.js";
import { RefSchema } from "../schemas/ref.js";
import { normalizeCoercionConfig } from "../parse/coerce.js";
/**
* Import an AnyValiDocument back into a live Schema.
*/
export function importSchema(doc) {
const definitions = doc.definitions ?? {};
const resolvedDefs = new Map();
function importNode(node) {
let schema;
switch (node.kind) {
case "string": {
let s = new StringSchema();
if (node.minLength !== undefined)
s = s.minLength(node.minLength);
if (node.maxLength !== undefined)
s = s.maxLength(node.maxLength);
if (node.pattern !== undefined)
s = s.pattern(node.pattern);
if (node.startsWith !== undefined)
s = s.startsWith(node.startsWith);
if (node.endsWith !== undefined)
s = s.endsWith(node.endsWith);
if (node.includes !== undefined)
s = s.includes(node.includes);
if (node.format !== undefined)
s = s.format(node.format);
schema = s;
break;
}
case "number":
case "float64": {
let s = node.kind === "float64" ? new Float64Schema() : new NumberSchema();
schema = applyNumericConstraints(s, node);
break;
}
case "float32": {
schema = applyNumericConstraints(new Float32Schema(), node);
break;
}
case "int":
case "int64": {
schema = applyNumericConstraints(node.kind === "int64" ? new Int64Schema() : new IntSchema(), node);
break;
}
case "int8":
schema = applyNumericConstraints(new Int8Schema(), node);
break;
case "int16":
schema = applyNumericConstraints(new Int16Schema(), node);
break;
case "int32":
schema = applyNumericConstraints(new Int32Schema(), node);
break;
case "uint8":
schema = applyNumericConstraints(new Uint8Schema(), node);
break;
case "uint16":
schema = applyNumericConstraints(new Uint16Schema(), node);
break;
case "uint32":
schema = applyNumericConstraints(new Uint32Schema(), node);
break;
case "uint64":
schema = applyNumericConstraints(new Uint64Schema(), node);
break;
case "bool":
schema = new BoolSchema();
break;
case "null":
schema = new NullSchema();
break;
case "any":
schema = new AnySchema();
break;
case "unknown":
schema = new UnknownSchema();
break;
case "never":
schema = new NeverSchema();
break;
case "literal": {
schema = new LiteralSchema(node.value);
break;
}
case "enum": {
schema = new EnumSchema(node.values);
break;
}
case "array": {
let s = new ArraySchema(importNode(node.items));
if (node.minItems !== undefined)
s = s.minItems(node.minItems);
if (node.maxItems !== undefined)
s = s.maxItems(node.maxItems);
schema = s;
break;
}
case "tuple": {
// Corpus uses "elements", our export uses "items"
const elements = node.elements ?? node.items;
schema = new TupleSchema(elements.map((i) => importNode(i)));
break;
}
case "object": {
const shape = {};
const requiredSet = new Set(node.required ?? []);
for (const [key, propNode] of Object.entries(node.properties ?? {})) {
let propSchema = importNode(propNode);
if (!requiredSet.has(key)) {
propSchema = new OptionalSchema(propSchema);
}
shape[key] = propSchema;
}
schema = new ObjectSchema(shape, {
unknownKeys: node.unknownKeys ?? "reject",
});
break;
}
case "record": {
// Corpus uses "values", our export uses "valueSchema"
const valueNode = node.values ?? node.valueSchema;
schema = new RecordSchema(importNode(valueNode));
break;
}
case "union": {
schema = new UnionSchema(node.variants.map((v) => importNode(v)));
break;
}
case "intersection": {
schema = new IntersectionSchema(node.allOf.map((s) => importNode(s)));
break;
}
case "optional": {
// Corpus uses "schema", our export uses "inner"
const innerNode = node.schema ?? node.inner;
schema = new OptionalSchema(importNode(innerNode));
break;
}
case "nullable": {
// Corpus uses "schema", our export uses "inner"
const innerNode = node.schema ?? node.inner;
schema = new NullableSchema(importNode(innerNode));
break;
}
case "ref": {
const refPath = node.ref;
const defName = refPath.replace("#/definitions/", "");
schema = new RefSchema(refPath, () => {
if (resolvedDefs.has(defName)) {
return resolvedDefs.get(defName);
}
const defNode = definitions[defName];
if (!defNode) {
throw new Error(`Unresolved definition: ${defName}`);
}
const resolved = importNode(defNode);
resolvedDefs.set(defName, resolved);
return resolved;
});
break;
}
default:
throw new Error(`Unsupported schema kind: ${node.kind}`);
}
// Apply default
if (node.default !== undefined) {
schema = schema.default(node.default);
}
// Apply coercion config - handle both string and object formats
if (node.coerce !== undefined) {
const config = normalizeCoercionConfig(node.coerce);
schema = schema.coerce(config);
}
return schema;
}
return importNode(doc.root);
}
function applyNumericConstraints(schema, node) {
let s = schema;
if (node.min !== undefined)
s = s.min(node.min);
if (node.max !== undefined)
s = s.max(node.max);
if (node.exclusiveMin !== undefined)
s = s.exclusiveMin(node.exclusiveMin);
if (node.exclusiveMax !== undefined)
s = s.exclusiveMax(node.exclusiveMax);
if (node.multipleOf !== undefined)
s = s.multipleOf(node.multipleOf);
return s;
}
//# sourceMappingURL=importer.js.map

View file

@ -0,0 +1,17 @@
export const ISSUE_CODES = {
INVALID_TYPE: "invalid_type",
REQUIRED: "required",
UNKNOWN_KEY: "unknown_key",
TOO_SMALL: "too_small",
TOO_LARGE: "too_large",
INVALID_STRING: "invalid_string",
INVALID_NUMBER: "invalid_number",
INVALID_LITERAL: "invalid_literal",
INVALID_UNION: "invalid_union",
CUSTOM_VALIDATION_NOT_PORTABLE: "custom_validation_not_portable",
UNSUPPORTED_EXTENSION: "unsupported_extension",
UNSUPPORTED_SCHEMA_KIND: "unsupported_schema_kind",
COERCION_FAILED: "coercion_failed",
DEFAULT_INVALID: "default_invalid",
};
//# sourceMappingURL=issue-codes.js.map

View file

@ -0,0 +1,115 @@
/**
* Normalize coercion config from the corpus/interchange format.
* The corpus uses strings like "string->int", "trim", "lower", "upper"
* or arrays like ["trim", "lower"]. The SDK API uses CoercionConfig objects.
*/
export function normalizeCoercionConfig(raw) {
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
return raw;
}
const config = {};
const items = Array.isArray(raw) ? raw : [raw];
for (const item of items) {
switch (item) {
case "string->int":
case "string->number":
case "string->bool":
config.from = "string";
break;
case "trim":
config.trim = true;
break;
case "lower":
config.lower = true;
break;
case "upper":
config.upper = true;
break;
}
}
return config;
}
export function applyCoercion(input, config, targetType) {
let value = input;
// String transformations (trim, lower, upper) apply when input is a string
if (typeof value === "string") {
if (config.trim) {
value = value.trim();
}
if (config.lower) {
value = value.toLowerCase();
}
if (config.upper) {
value = value.toUpperCase();
}
}
// Type coercion from string to target
if (config.from === "string" && typeof value === "string") {
switch (targetType) {
case "int":
case "int8":
case "int16":
case "int32":
case "int64":
case "uint8":
case "uint16":
case "uint32":
case "uint64": {
const trimmed = value.trim();
if (trimmed === "" || !/^-?\d+$/.test(trimmed)) {
return {
success: false,
message: `Cannot coerce "${value}" to ${targetType}`,
};
}
const num = Number(trimmed);
if (!Number.isFinite(num) || !Number.isInteger(num)) {
return {
success: false,
message: `Cannot coerce "${value}" to ${targetType}`,
};
}
value = num;
break;
}
case "number":
case "float32":
case "float64": {
const trimmed = value.trim();
if (trimmed === "") {
return {
success: false,
message: `Cannot coerce empty string to ${targetType}`,
};
}
const num = Number(trimmed);
if (!Number.isFinite(num)) {
return {
success: false,
message: `Cannot coerce "${value}" to ${targetType}`,
};
}
value = num;
break;
}
case "bool": {
const lower = value.trim().toLowerCase();
if (lower === "true" || lower === "1") {
value = true;
}
else if (lower === "false" || lower === "0") {
value = false;
}
else {
return {
success: false,
message: `Cannot coerce "${value}" to bool`,
};
}
break;
}
}
}
return { success: true, value };
}
//# sourceMappingURL=coerce.js.map

View file

@ -0,0 +1,12 @@
import { ABSENT } from "../schemas/base.js";
/**
* Apply a default value if the input is absent.
* Returns the default value if input is absent, otherwise returns the input as-is.
*/
export function applyDefault(input, defaultValue) {
if ((input === undefined || input === ABSENT) && defaultValue !== ABSENT) {
return defaultValue;
}
return input;
}
//# sourceMappingURL=defaults.js.map

View file

@ -0,0 +1,13 @@
/**
* Parse input with the given schema. Throws ValidationError on failure.
*/
export function parse(schema, input) {
return schema.parse(input);
}
/**
* Parse input with the given schema. Returns a result object.
*/
export function safeParse(schema, input) {
return schema.safeParse(input);
}
//# sourceMappingURL=parser.js.map

View file

@ -0,0 +1,12 @@
import { BaseSchema } from "./base.js";
export class AnySchema extends BaseSchema {
_validate(input, _ctx) {
return input;
}
_toNode() {
const node = { kind: "any" };
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=any.js.map

View file

@ -0,0 +1,73 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
export class ArraySchema extends BaseSchema {
_items;
_minItems;
_maxItems;
constructor(items) {
super();
this._items = items;
}
minItems(n) {
const clone = this._clone();
clone._minItems = n;
return clone;
}
maxItems(n) {
const clone = this._clone();
clone._maxItems = n;
return clone;
}
_validate(input, ctx) {
if (!Array.isArray(input)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected array, received ${describeType(input)}`,
path: [...ctx.path],
expected: "array",
received: describeType(input),
});
return undefined;
}
if (this._minItems !== undefined && input.length < this._minItems) {
ctx.issues.push({
code: ISSUE_CODES.TOO_SMALL,
message: `Array must have at least ${this._minItems} item(s)`,
path: [...ctx.path],
expected: String(this._minItems),
received: String(input.length),
});
}
if (this._maxItems !== undefined && input.length > this._maxItems) {
ctx.issues.push({
code: ISSUE_CODES.TOO_LARGE,
message: `Array must have at most ${this._maxItems} item(s)`,
path: [...ctx.path],
expected: String(this._maxItems),
received: String(input.length),
});
}
const result = [];
for (let i = 0; i < input.length; i++) {
ctx.path.push(i);
const val = this._items._runPipeline(input[i], ctx);
result.push(val);
ctx.path.pop();
}
return result;
}
_toNode() {
const node = {
kind: "array",
items: this._items._toNode(),
};
if (this._minItems !== undefined)
node.minItems = this._minItems;
if (this._maxItems !== undefined)
node.maxItems = this._maxItems;
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=array.js.map

View file

@ -0,0 +1,115 @@
import { ValidationError } from "../errors.js";
import { applyCoercion } from "../parse/coerce.js";
import { ISSUE_CODES } from "../issue-codes.js";
const ANYVALI_VERSION = "1.0";
const SCHEMA_VERSION = "1";
/** Sentinel for "value not present" */
export const ABSENT = Symbol.for("anyvali.absent");
export class BaseSchema {
/** @internal */ _defaultValue = ABSENT;
/** @internal */ _coercionConfig = undefined;
/** @internal */ _isPortable = true;
// ---------- public API ----------
parse(input) {
const result = this.safeParse(input);
if (result.success)
return result.data;
throw new ValidationError(result.issues);
}
safeParse(input) {
const ctx = { path: [], issues: [] };
const output = this._runPipeline(input, ctx);
if (ctx.issues.length > 0) {
return { success: false, issues: ctx.issues };
}
return { success: true, data: output };
}
/** Internal: run the 5-step pipeline */
_runPipeline(input, ctx) {
// Step 1: detect presence
const isAbsent = input === undefined || input === ABSENT;
let value = input;
// Step 2: coercion (only for present values)
if (!isAbsent && this._coercionConfig) {
const coerced = applyCoercion(value, this._coercionConfig, this._getCoercionTarget());
if (coerced.success) {
value = coerced.value;
}
else {
ctx.issues.push({
code: ISSUE_CODES.COERCION_FAILED,
message: coerced.message,
path: [...ctx.path],
expected: this._getCoercionTarget(),
received: String(input),
});
return undefined;
}
}
// Step 3: default materialization (only for absent values)
let usedDefault = false;
if (isAbsent && this._defaultValue !== ABSENT) {
value = this._defaultValue;
usedDefault = true;
}
// Step 4: validate
const issuesBefore = ctx.issues.length;
const result = this._validate(value, ctx);
// If default was materialized and validation failed, remap issues to default_invalid
if (usedDefault && ctx.issues.length > issuesBefore) {
for (let i = issuesBefore; i < ctx.issues.length; i++) {
const issue = ctx.issues[i];
ctx.issues[i] = {
...issue,
code: ISSUE_CODES.DEFAULT_INVALID,
};
}
}
return result;
}
/** Override in subclasses to provide the coercion target type name */
_getCoercionTarget() {
return "unknown";
}
// optional() and nullable() are provided via standalone functions
// to avoid circular imports. See index.ts.
default(value) {
const clone = this._clone();
clone._defaultValue = value;
return clone;
}
coerce(options = {}) {
const clone = this._clone();
clone._coercionConfig = { ...options };
return clone;
}
export(mode = "portable") {
if (mode === "portable" && !this._isPortable) {
throw new Error("Cannot export in portable mode: schema contains non-portable features");
}
const node = this._toNode();
return {
anyvaliVersion: ANYVALI_VERSION,
schemaVersion: SCHEMA_VERSION,
root: node,
definitions: {},
extensions: {},
};
}
// ---------- internal helpers ----------
_clone() {
const clone = Object.create(Object.getPrototypeOf(this));
Object.assign(clone, this);
return clone;
}
_addDefault(node) {
if (this._defaultValue !== ABSENT) {
node.default = this._defaultValue;
}
if (this._coercionConfig) {
node.coerce = { ...this._coercionConfig };
}
return node;
}
}
//# sourceMappingURL=base.js.map

View file

@ -0,0 +1,27 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
export class BoolSchema extends BaseSchema {
_getCoercionTarget() {
return "bool";
}
_validate(input, ctx) {
if (typeof input !== "boolean") {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected boolean, received ${describeType(input)}`,
path: [...ctx.path],
expected: "bool",
received: describeType(input),
});
return undefined;
}
return input;
}
_toNode() {
const node = { kind: "bool" };
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=bool.js.map

View file

@ -0,0 +1,31 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
export class EnumSchema extends BaseSchema {
_values;
constructor(values) {
super();
this._values = values;
}
_validate(input, ctx) {
if (!this._values.includes(input)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected one of enum(${this._values.join(",")}), received ${String(input)}`,
path: [...ctx.path],
expected: `enum(${this._values.join(",")})`,
received: String(input),
});
return undefined;
}
return input;
}
_toNode() {
const node = {
kind: "enum",
values: [...this._values],
};
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=enum.js.map

View file

@ -0,0 +1,21 @@
export { BaseSchema, ABSENT } from "./base.js";
export { StringSchema } from "./string.js";
export { NumberSchema, Float32Schema, Float64Schema } from "./number.js";
export { IntSchema, Int8Schema, Int16Schema, Int32Schema, Int64Schema, Uint8Schema, Uint16Schema, Uint32Schema, Uint64Schema, } from "./int.js";
export { BoolSchema } from "./bool.js";
export { NullSchema } from "./null.js";
export { AnySchema } from "./any.js";
export { UnknownSchema } from "./unknown.js";
export { NeverSchema } from "./never.js";
export { LiteralSchema } from "./literal.js";
export { EnumSchema } from "./enum.js";
export { ArraySchema } from "./array.js";
export { TupleSchema } from "./tuple.js";
export { ObjectSchema } from "./object.js";
export { RecordSchema } from "./record.js";
export { UnionSchema } from "./union.js";
export { IntersectionSchema } from "./intersection.js";
export { OptionalSchema } from "./optional.js";
export { NullableSchema } from "./nullable.js";
export { RefSchema } from "./ref.js";
//# sourceMappingURL=index.js.map

View file

@ -0,0 +1,108 @@
import { NumberSchema } from "./number.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
const INT_RANGES = {
int8: { min: -128, max: 127 },
int16: { min: -32768, max: 32767 },
int32: { min: -2147483648, max: 2147483647 },
int64: { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
uint8: { min: 0, max: 255 },
uint16: { min: 0, max: 65535 },
uint32: { min: 0, max: 4294967295 },
uint64: { min: 0, max: Number.MAX_SAFE_INTEGER },
int: { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
};
export class IntSchema extends NumberSchema {
_intRange;
constructor(kind = "int") {
super(kind);
this._intRange = INT_RANGES[kind] ?? INT_RANGES.int;
}
_validate(input, ctx) {
if (typeof input !== "number" || !Number.isFinite(input)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected integer, received ${describeType(input)}`,
path: [...ctx.path],
expected: this._kind,
received: describeType(input),
});
return undefined;
}
if (!Number.isInteger(input)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected integer, received float`,
path: [...ctx.path],
expected: this._kind,
received: "number",
});
return undefined;
}
// Range check for the specific int width
if (input > this._intRange.max) {
ctx.issues.push({
code: ISSUE_CODES.TOO_LARGE,
message: `Value ${input} is above the maximum for ${this._kind}`,
path: [...ctx.path],
expected: this._kind,
received: String(input),
});
return undefined;
}
if (input < this._intRange.min) {
ctx.issues.push({
code: ISSUE_CODES.TOO_SMALL,
message: `Value ${input} is below the minimum for ${this._kind}`,
path: [...ctx.path],
expected: this._kind,
received: String(input),
});
return undefined;
}
// Additional user constraints
this._validateConstraints(input, ctx);
return input;
}
}
export class Int8Schema extends IntSchema {
constructor() {
super("int8");
}
}
export class Int16Schema extends IntSchema {
constructor() {
super("int16");
}
}
export class Int32Schema extends IntSchema {
constructor() {
super("int32");
}
}
export class Int64Schema extends IntSchema {
constructor() {
super("int64");
}
}
export class Uint8Schema extends IntSchema {
constructor() {
super("uint8");
}
}
export class Uint16Schema extends IntSchema {
constructor() {
super("uint16");
}
}
export class Uint32Schema extends IntSchema {
constructor() {
super("uint32");
}
}
export class Uint64Schema extends IntSchema {
constructor() {
super("uint64");
}
}
//# sourceMappingURL=int.js.map

View file

@ -0,0 +1,53 @@
import { BaseSchema } from "./base.js";
export class IntersectionSchema extends BaseSchema {
_schemas;
constructor(schemas) {
super();
this._schemas = schemas;
}
_validate(input, ctx) {
let result = input;
let anyFailed = false;
for (const schema of this._schemas) {
const innerCtx = {
path: [...ctx.path],
issues: [],
};
const validated = schema._runPipeline(input, innerCtx);
if (innerCtx.issues.length > 0) {
ctx.issues.push(...innerCtx.issues);
anyFailed = true;
}
else {
// Merge object results
if (typeof result === "object" &&
result !== null &&
typeof validated === "object" &&
validated !== null &&
!Array.isArray(result) &&
!Array.isArray(validated)) {
result = {
...result,
...validated,
};
}
else {
result = validated;
}
}
}
if (anyFailed) {
return undefined;
}
return result;
}
_toNode() {
const node = {
kind: "intersection",
allOf: this._schemas.map((s) => s._toNode()),
};
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=intersection.js.map

View file

@ -0,0 +1,28 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
export class LiteralSchema extends BaseSchema {
_value;
constructor(value) {
super();
this._value = value;
}
_validate(input, ctx) {
if (input !== this._value) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_LITERAL,
message: `Expected literal ${String(this._value)}, received ${String(input)}`,
path: [...ctx.path],
expected: String(this._value),
received: String(input),
});
return undefined;
}
return input;
}
_toNode() {
const node = { kind: "literal", value: this._value };
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=literal.js.map

View file

@ -0,0 +1,19 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
export class NeverSchema extends BaseSchema {
_validate(input, ctx) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected never (no value is valid)`,
path: [...ctx.path],
expected: "never",
received: describeType(input),
});
return undefined;
}
_toNode() {
return { kind: "never" };
}
}
//# sourceMappingURL=never.js.map

View file

@ -0,0 +1,24 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
export class NullSchema extends BaseSchema {
_validate(input, ctx) {
if (input !== null) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected null, received ${describeType(input)}`,
path: [...ctx.path],
expected: "null",
received: describeType(input),
});
return undefined;
}
return null;
}
_toNode() {
const node = { kind: "null" };
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=null.js.map

View file

@ -0,0 +1,29 @@
import { BaseSchema } from "./base.js";
export class NullableSchema extends BaseSchema {
/** @internal */ _inner;
constructor(inner) {
super();
this._inner = inner;
}
_validate(input, ctx) {
if (input === null) {
return null;
}
return this._inner._validate(input, ctx);
}
_runPipeline(input, ctx) {
if (input === null) {
return null;
}
return this._inner._runPipeline(input, ctx);
}
_toNode() {
const node = {
kind: "nullable",
inner: this._inner._toNode(),
};
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=nullable.js.map

View file

@ -0,0 +1,134 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
export class NumberSchema extends BaseSchema {
_kind;
_min;
_max;
_exclusiveMin;
_exclusiveMax;
_multipleOf;
constructor(kind = "number") {
super();
this._kind = kind;
}
_getCoercionTarget() {
return this._kind;
}
min(n) {
const clone = this._clone();
clone._min = n;
return clone;
}
max(n) {
const clone = this._clone();
clone._max = n;
return clone;
}
exclusiveMin(n) {
const clone = this._clone();
clone._exclusiveMin = n;
return clone;
}
exclusiveMax(n) {
const clone = this._clone();
clone._exclusiveMax = n;
return clone;
}
multipleOf(n) {
const clone = this._clone();
clone._multipleOf = n;
return clone;
}
_validate(input, ctx) {
if (typeof input !== "number" || !Number.isFinite(input)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected ${this._kind}, received ${describeType(input)}`,
path: [...ctx.path],
expected: this._kind,
received: describeType(input),
});
return undefined;
}
this._validateConstraints(input, ctx);
return input;
}
_validateConstraints(val, ctx) {
if (this._min !== undefined && val < this._min) {
ctx.issues.push({
code: ISSUE_CODES.TOO_SMALL,
message: `Number must be >= ${this._min}`,
path: [...ctx.path],
expected: String(this._min),
received: String(val),
});
}
if (this._max !== undefined && val > this._max) {
ctx.issues.push({
code: ISSUE_CODES.TOO_LARGE,
message: `Number must be <= ${this._max}`,
path: [...ctx.path],
expected: String(this._max),
received: String(val),
});
}
if (this._exclusiveMin !== undefined && val <= this._exclusiveMin) {
ctx.issues.push({
code: ISSUE_CODES.TOO_SMALL,
message: `Number must be > ${this._exclusiveMin}`,
path: [...ctx.path],
expected: String(this._exclusiveMin),
received: String(val),
});
}
if (this._exclusiveMax !== undefined && val >= this._exclusiveMax) {
ctx.issues.push({
code: ISSUE_CODES.TOO_LARGE,
message: `Number must be < ${this._exclusiveMax}`,
path: [...ctx.path],
expected: String(this._exclusiveMax),
received: String(val),
});
}
if (this._multipleOf !== undefined) {
const remainder = val % this._multipleOf;
if (Math.abs(remainder) > 1e-10 &&
Math.abs(remainder - this._multipleOf) > 1e-10) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_NUMBER,
message: `Number must be a multiple of ${this._multipleOf}`,
path: [...ctx.path],
expected: String(this._multipleOf),
received: String(val),
});
}
}
}
_toNode() {
const node = { kind: this._kind };
if (this._min !== undefined)
node.min = this._min;
if (this._max !== undefined)
node.max = this._max;
if (this._exclusiveMin !== undefined)
node.exclusiveMin = this._exclusiveMin;
if (this._exclusiveMax !== undefined)
node.exclusiveMax = this._exclusiveMax;
if (this._multipleOf !== undefined)
node.multipleOf = this._multipleOf;
this._addDefault(node);
return node;
}
}
export class Float32Schema extends NumberSchema {
constructor() {
super("float32");
}
}
export class Float64Schema extends NumberSchema {
constructor() {
super("float64");
}
}
//# sourceMappingURL=number.js.map

View file

@ -0,0 +1,108 @@
import { BaseSchema, ABSENT } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
export class ObjectSchema extends BaseSchema {
_properties;
_unknownKeys;
constructor(shape, options) {
super();
this._properties = new Map();
this._unknownKeys = options?.unknownKeys ?? "reject";
for (const [key, schema] of Object.entries(shape)) {
// Check if the schema is an OptionalSchema wrapper
const isOptional = schema._isOptionalWrapper === true;
this._properties.set(key, {
schema,
required: !isOptional,
});
}
}
unknownKeys(mode) {
const clone = this._clone();
clone._unknownKeys = mode;
return clone;
}
_validate(input, ctx) {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected object, received ${describeType(input)}`,
path: [...ctx.path],
expected: "object",
received: describeType(input),
});
return undefined;
}
const obj = input;
const result = {};
const inputKeys = new Set(Object.keys(obj));
// Validate declared properties
for (const [key, prop] of this._properties) {
ctx.path.push(key);
const hasKey = Object.prototype.hasOwnProperty.call(obj, key);
inputKeys.delete(key);
if (!hasKey) {
// Check if required
if (prop.required && prop.schema._defaultValue === ABSENT) {
const expectedKind = prop.schema._toNode().kind;
ctx.issues.push({
code: ISSUE_CODES.REQUIRED,
message: `Required property "${key}" is missing`,
path: [...ctx.path],
expected: expectedKind,
received: "undefined",
});
ctx.path.pop();
continue;
}
}
const rawValue = hasKey ? obj[key] : undefined;
const val = prop.schema._runPipeline(rawValue, ctx);
// Only include in result if value is not undefined or it was explicitly present
if (val !== undefined || hasKey || prop.schema._defaultValue !== ABSENT) {
result[key] = val;
}
ctx.path.pop();
}
// Handle unknown keys
for (const key of inputKeys) {
switch (this._unknownKeys) {
case "reject":
ctx.issues.push({
code: ISSUE_CODES.UNKNOWN_KEY,
message: `Unknown key "${key}"`,
path: [...ctx.path, key],
expected: "undefined",
received: key,
});
break;
case "allow":
result[key] = obj[key];
break;
case "strip":
// Just ignore it
break;
}
}
return result;
}
_toNode() {
const properties = {};
const required = [];
for (const [key, prop] of this._properties) {
properties[key] = prop.schema._toNode();
if (prop.required) {
required.push(key);
}
}
const node = {
kind: "object",
properties,
required,
unknownKeys: this._unknownKeys,
};
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=object.js.map

View file

@ -0,0 +1,39 @@
import { BaseSchema, ABSENT } from "./base.js";
export class OptionalSchema extends BaseSchema {
/** @internal */ _inner;
/** @internal */ _isOptionalWrapper = true;
constructor(inner) {
super();
this._inner = inner;
// Inherit defaults/coercion from inner
this._defaultValue = inner._defaultValue;
this._coercionConfig = inner._coercionConfig;
}
_validate(input, ctx) {
if (input === undefined || input === ABSENT) {
return undefined;
}
return this._inner._validate(input, ctx);
}
_runPipeline(input, ctx) {
const isAbsent = input === undefined || input === ABSENT;
// If absent and we have a default from inner, apply it
if (isAbsent && this._inner._defaultValue !== ABSENT) {
return this._inner._runPipeline(input, ctx);
}
if (isAbsent) {
return undefined;
}
// Delegate to inner's pipeline for coercion etc.
return this._inner._runPipeline(input, ctx);
}
_toNode() {
const node = {
kind: "optional",
inner: this._inner._toNode(),
};
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=optional.js.map

View file

@ -0,0 +1,39 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
export class RecordSchema extends BaseSchema {
_valueSchema;
constructor(valueSchema) {
super();
this._valueSchema = valueSchema;
}
_validate(input, ctx) {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected record, received ${describeType(input)}`,
path: [...ctx.path],
expected: "record",
received: describeType(input),
});
return undefined;
}
const obj = input;
const result = {};
for (const [key, value] of Object.entries(obj)) {
ctx.path.push(key);
result[key] = this._valueSchema._runPipeline(value, ctx);
ctx.path.pop();
}
return result;
}
_toNode() {
const node = {
kind: "record",
valueSchema: this._valueSchema._toNode(),
};
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=record.js.map

View file

@ -0,0 +1,30 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
export class RefSchema extends BaseSchema {
_ref;
_resolver;
constructor(ref, resolver) {
super();
this._ref = ref;
this._resolver = resolver;
}
_validate(input, ctx) {
if (this._resolver) {
const resolved = this._resolver();
return resolved._validate(input, ctx);
}
ctx.issues.push({
code: ISSUE_CODES.UNSUPPORTED_SCHEMA_KIND,
message: `Unresolved ref: ${this._ref}`,
path: [...ctx.path],
});
return undefined;
}
_toNode() {
return {
kind: "ref",
ref: this._ref,
};
}
}
//# sourceMappingURL=ref.js.map

View file

@ -0,0 +1,151 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { validateFormat } from "../format/validators.js";
import { describeType } from "../util.js";
export class StringSchema extends BaseSchema {
_minLength;
_maxLength;
_pattern;
_startsWith;
_endsWith;
_includes;
_format;
_getCoercionTarget() {
return "string";
}
minLength(n) {
const clone = this._clone();
clone._minLength = n;
return clone;
}
maxLength(n) {
const clone = this._clone();
clone._maxLength = n;
return clone;
}
pattern(p) {
const clone = this._clone();
clone._pattern = p;
return clone;
}
startsWith(s) {
const clone = this._clone();
clone._startsWith = s;
return clone;
}
endsWith(s) {
const clone = this._clone();
clone._endsWith = s;
return clone;
}
includes(s) {
const clone = this._clone();
clone._includes = s;
return clone;
}
format(f) {
const clone = this._clone();
clone._format = f;
return clone;
}
_validate(input, ctx) {
if (typeof input !== "string") {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected string, received ${describeType(input)}`,
path: [...ctx.path],
expected: "string",
received: describeType(input),
});
return undefined;
}
const val = input;
if (this._minLength !== undefined && val.length < this._minLength) {
ctx.issues.push({
code: ISSUE_CODES.TOO_SMALL,
message: `String must have at least ${this._minLength} character(s)`,
path: [...ctx.path],
expected: String(this._minLength),
received: String(val.length),
});
}
if (this._maxLength !== undefined && val.length > this._maxLength) {
ctx.issues.push({
code: ISSUE_CODES.TOO_LARGE,
message: `String must have at most ${this._maxLength} character(s)`,
path: [...ctx.path],
expected: String(this._maxLength),
received: String(val.length),
});
}
if (this._pattern !== undefined) {
const re = new RegExp(this._pattern);
if (!re.test(val)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_STRING,
message: `String does not match pattern: ${this._pattern}`,
path: [...ctx.path],
expected: this._pattern,
received: val,
});
}
}
if (this._startsWith !== undefined && !val.startsWith(this._startsWith)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_STRING,
message: `String must start with "${this._startsWith}"`,
path: [...ctx.path],
expected: this._startsWith,
received: val,
});
}
if (this._endsWith !== undefined && !val.endsWith(this._endsWith)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_STRING,
message: `String must end with "${this._endsWith}"`,
path: [...ctx.path],
expected: this._endsWith,
received: val,
});
}
if (this._includes !== undefined && !val.includes(this._includes)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_STRING,
message: `String must include "${this._includes}"`,
path: [...ctx.path],
expected: this._includes,
received: val,
});
}
if (this._format !== undefined && !validateFormat(val, this._format)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_STRING,
message: `Invalid ${this._format} format`,
path: [...ctx.path],
expected: this._format,
received: val,
});
}
return val;
}
_toNode() {
const node = { kind: "string" };
if (this._minLength !== undefined)
node.minLength = this._minLength;
if (this._maxLength !== undefined)
node.maxLength = this._maxLength;
if (this._pattern !== undefined)
node.pattern = this._pattern;
if (this._startsWith !== undefined)
node.startsWith = this._startsWith;
if (this._endsWith !== undefined)
node.endsWith = this._endsWith;
if (this._includes !== undefined)
node.includes = this._includes;
if (this._format !== undefined)
node.format = this._format;
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=string.js.map

View file

@ -0,0 +1,59 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
export class TupleSchema extends BaseSchema {
_items;
constructor(items) {
super();
this._items = items;
}
_validate(input, ctx) {
if (!Array.isArray(input)) {
ctx.issues.push({
code: ISSUE_CODES.INVALID_TYPE,
message: `Expected tuple, received ${describeType(input)}`,
path: [...ctx.path],
expected: "tuple",
received: describeType(input),
});
return undefined;
}
if (input.length < this._items.length) {
ctx.issues.push({
code: ISSUE_CODES.TOO_SMALL,
message: `Tuple must have exactly ${this._items.length} element(s), received ${input.length}`,
path: [...ctx.path],
expected: String(this._items.length),
received: String(input.length),
});
return undefined;
}
if (input.length > this._items.length) {
ctx.issues.push({
code: ISSUE_CODES.TOO_LARGE,
message: `Tuple must have exactly ${this._items.length} element(s), received ${input.length}`,
path: [...ctx.path],
expected: String(this._items.length),
received: String(input.length),
});
return undefined;
}
const result = [];
for (let i = 0; i < this._items.length; i++) {
ctx.path.push(i);
const val = this._items[i]._runPipeline(input[i], ctx);
result.push(val);
ctx.path.pop();
}
return result;
}
_toNode() {
const node = {
kind: "tuple",
elements: this._items.map((s) => s._toNode()),
};
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=tuple.js.map

View file

@ -0,0 +1,40 @@
import { BaseSchema } from "./base.js";
import { ISSUE_CODES } from "../issue-codes.js";
import { describeType } from "../util.js";
export class UnionSchema extends BaseSchema {
_variants;
constructor(variants) {
super();
this._variants = variants;
}
_validate(input, ctx) {
for (const variant of this._variants) {
const innerCtx = {
path: [...ctx.path],
issues: [],
};
const result = variant._runPipeline(input, innerCtx);
if (innerCtx.issues.length === 0) {
return result;
}
}
const variantKinds = this._variants.map((v) => v._toNode().kind);
ctx.issues.push({
code: ISSUE_CODES.INVALID_UNION,
message: `Input did not match any variant of the union`,
path: [...ctx.path],
expected: variantKinds.join(" | "),
received: describeType(input),
});
return undefined;
}
_toNode() {
const node = {
kind: "union",
variants: this._variants.map((v) => v._toNode()),
};
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=union.js.map

View file

@ -0,0 +1,12 @@
import { BaseSchema } from "./base.js";
export class UnknownSchema extends BaseSchema {
_validate(input, _ctx) {
return input;
}
_toNode() {
const node = { kind: "unknown" };
this._addDefault(node);
return node;
}
}
//# sourceMappingURL=unknown.js.map

View file

@ -0,0 +1,3 @@
// ---- Schema Node (interchange JSON representation) ----
export {};
//# sourceMappingURL=types.js.map

View file

@ -0,0 +1,12 @@
/**
* Describe the type of a value for error messages, matching the corpus expectations.
* null -> "null", array -> "array", otherwise typeof.
*/
export function describeType(value) {
if (value === null)
return "null";
if (Array.isArray(value))
return "array";
return typeof value;
}
//# sourceMappingURL=util.js.map

1
server/src/web-static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,540 @@
/**
* Admin page templates: overview, cameras, kiosks, account, etc.
*/
import { js } from "jsx-htmx";
import { Layout } from "./layout.js";
import type { Camera, Kiosk, PairingCode, EventLog } from "../shared/types.js";
// ---- Overview ---------------------------------------------------------------
interface OverviewProps {
user: string;
cameraCount: number;
kioskCount: number;
onlineKioskCount: number;
layoutCount: number;
events: EventLog[];
}
export function OverviewPage(props: OverviewProps) {
return (
<Layout title="Overview" user={props.user} activeNav="overview">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Cameras</div>
<div class="stat-value">{String(props.cameraCount)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Kiosks</div>
<div class="stat-value">{String(props.kioskCount)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Kiosks Online</div>
<div class="stat-value">{String(props.onlineKioskCount)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Displays</div>
<div class="stat-value">{String(props.layoutCount)}</div>
</div>
</div>
<div class="section-header">
<h2 class="section-title">Quick Links</h2>
</div>
<div class="stats-grid" style="margin-bottom:1.5rem">
<a href="/admin/cameras/new" class="card" style="text-decoration:none; color:inherit">
<strong>Add Camera</strong>
<div style="color:#666; font-size:0.85rem">RTSP or ONVIF</div>
</a>
<a href="/admin/kiosks" class="card" style="text-decoration:none; color:inherit">
<strong>Pair Kiosk</strong>
<div style="color:#666; font-size:0.85rem">Enter pairing code</div>
</a>
<a href="/nrdp/" class="card" style="text-decoration:none; color:inherit">
<strong>Rule Engine</strong>
<div style="color:#666; font-size:0.85rem">Node-RED dashboard</div>
</a>
</div>
<div class="section-header">
<h2 class="section-title">Recent Events</h2>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>Topic</th>
<th>Source</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
{props.events.length === 0 ? (
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No events yet</td></tr>
) : (
props.events.map((ev) => (
<tr>
<td style="white-space:nowrap; font-size:0.8rem">{formatTime(ev.received_at)}</td>
<td>{ev.topic}</td>
<td><span class="badge badge-gray">{ev.source_type}</span></td>
<td style="font-size:0.8rem; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
{JSON.stringify(ev.payload)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Layout>
);
}
// ---- Cameras ----------------------------------------------------------------
interface CamerasProps {
user: string;
cameras: Camera[];
streamCounts: Map<number, number>;
}
export function CamerasPage(props: CamerasProps) {
return (
<Layout title="Cameras" user={props.user} activeNav="cameras">
<div class="section-header">
<h2 class="section-title">All Cameras</h2>
<a href="/admin/cameras/new" class="btn btn-primary">Add Camera</a>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Streams</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{props.cameras.length === 0 ? (
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No cameras configured</td></tr>
) : (
props.cameras.map((cam) => (
<tr>
<td><strong>{cam.name}</strong></td>
<td><span class="badge badge-blue">{cam.type.toUpperCase()}</span></td>
<td>{String(props.streamCounts.get(cam.id) ?? 0)}</td>
<td>
{cam.enabled
? <span class="badge badge-green">Enabled</span>
: <span class="badge badge-gray">Disabled</span>
}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Layout>
);
}
// ---- Camera New -------------------------------------------------------------
interface CameraNewProps {
user: string;
error?: string;
values?: Record<string, string>;
}
export function CameraNewPage(props: CameraNewProps) {
const v = props.values ?? {};
return (
<Layout
title="Add Camera"
user={props.user}
activeNav="cameras"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<div style="max-width:600px">
<form method="post" action="/admin/cameras/new">
<div class="form-group">
<label for="name">Camera Name</label>
<input
id="name"
name="name"
type="text"
class="form-input"
required
maxlength="128"
value={v["name"] ?? ""}
/>
</div>
<div class="form-group">
<label>Type</label>
<div class="radio-group">
<label>
<input type="radio" name="type" value="rtsp" checked={v["type"] !== "onvif"} />
RTSP
</label>
<label>
<input type="radio" name="type" value="onvif" checked={v["type"] === "onvif"} />
ONVIF
</label>
</div>
</div>
<div id="rtsp-fields">
<div class="form-group">
<label for="rtsp_url">RTSP URL</label>
<input
id="rtsp_url"
name="rtsp_url"
type="url"
class="form-input"
placeholder="rtsp://192.168.1.100:554/stream1"
value={v["rtsp_url"] ?? ""}
/>
</div>
</div>
<div id="onvif-fields" style="display:none">
<div class="form-group">
<label for="onvif_host">ONVIF Host</label>
<input id="onvif_host" name="onvif_host" type="text" class="form-input" value={v["onvif_host"] ?? ""} />
</div>
<div class="form-group">
<label for="onvif_port">Port</label>
<input id="onvif_port" name="onvif_port" type="number" class="form-input" value={v["onvif_port"] ?? "80"} />
</div>
<div class="form-group">
<label for="onvif_username">Username</label>
<input id="onvif_username" name="onvif_username" type="text" class="form-input" value={v["onvif_username"] ?? ""} />
</div>
<div class="form-group">
<label for="onvif_password">Password</label>
<input id="onvif_password" name="onvif_password" type="password" class="form-input" />
</div>
</div>
<button type="submit" class="btn btn-primary">Add Camera</button>
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
</form>
</div>
<script>{js(
`(function(){` +
`var radios=document.querySelectorAll('input[name="type"]');` +
`var rd=document.getElementById("rtsp-fields");` +
`var od=document.getElementById("onvif-fields");` +
`function t(){var el=document.querySelector('input[name="type"]:checked');` +
`var v=el?el.value:"rtsp";` +
`if(rd)rd.style.display=v==="rtsp"?"block":"none";` +
`if(od)od.style.display=v==="onvif"?"block":"none";}` +
`radios.forEach(function(r){r.addEventListener("change",t)});t();})()`
)}</script>
</Layout>
);
}
// ---- Kiosks -----------------------------------------------------------------
interface KiosksProps {
user: string;
kiosks: Kiosk[];
pendingCodes: PairingCode[];
}
export function KiosksPage(props: KiosksProps) {
return (
<Layout title="Kiosks" user={props.user} activeNav="kiosks">
<div class="two-col">
<div>
<div class="section-header">
<h2 class="section-title">Paired Kiosks</h2>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Hardware</th>
<th>Last Seen</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{props.kiosks.length === 0 ? (
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No kiosks paired</td></tr>
) : (
props.kiosks.map((k) => (
<tr>
<td><strong>{k.name}</strong></td>
<td style="font-size:0.85rem">{k.hardware_model ?? "—"}</td>
<td style="font-size:0.85rem; white-space:nowrap">{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</td>
<td>
{k.enabled
? <span class="badge badge-green">Active</span>
: <span class="badge badge-gray">Disabled</span>
}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div>
<div class="section-header">
<h2 class="section-title">Pair New Kiosk</h2>
</div>
<div class="card">
<form method="post" action="/admin/kiosks/pair">
<div class="form-group">
<label for="code">Pairing Code</label>
<input
id="code"
name="code"
type="text"
class="form-input"
required
maxlength="8"
pattern="[A-Z2-9]{8}"
style="text-transform:uppercase; text-align:center; font-size:1.25rem; letter-spacing:0.2rem"
/>
<div class="form-hint">8-character code shown on kiosk screen.</div>
</div>
<div class="form-group">
<label for="name_override">Name Override (optional)</label>
<input id="name_override" name="name_override" type="text" class="form-input" />
</div>
<div class="form-group">
<label for="initial_labels">Initial Labels (optional)</label>
<input id="initial_labels" name="initial_labels" type="text" class="form-input" placeholder="lobby, floor-1" />
<div class="form-hint">Comma-separated label names.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Pair Kiosk</button>
</form>
{props.pendingCodes.length > 0 && (
<div style="margin-top:1.25rem; border-top:1px solid #eee; padding-top:1rem">
<div style="font-weight:600; font-size:0.85rem; margin-bottom:0.5rem">Pending Codes</div>
{props.pendingCodes.map((pc) => (
<div style="display:flex; justify-content:space-between; font-size:0.85rem; padding:0.25rem 0">
<code>{pc.code}</code>
<span style="color:#666">{formatTime(pc.expires_at)}</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
</Layout>
);
}
// ---- Account ----------------------------------------------------------------
interface AccountProps {
user: string;
totpEnabled: boolean;
error?: string;
success?: string;
}
export function AccountPage(props: AccountProps) {
return (
<Layout
title="Account"
user={props.user}
activeNav="account"
flash={
props.error
? { type: "error", message: props.error }
: props.success
? { type: "success", message: props.success }
: undefined
}
>
<div style="max-width:600px">
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Change Password</h2>
<form method="post" action="/admin/account/password">
<div class="form-group">
<label for="current_password">Current Password</label>
<input id="current_password" name="current_password" type="password" class="form-input" required autocomplete="current-password" />
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input id="new_password" name="new_password" type="password" class="form-input" required minlength="12" autocomplete="new-password" />
<div class="form-hint">At least 12 characters.</div>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
<div class="card">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Two-Factor Authentication</h2>
{props.totpEnabled ? (
<div>
<p style="color:#065f46; margin-bottom:1rem">
<span class="badge badge-green">Enabled</span>
{" "}TOTP is active on this account.
</p>
<form method="post" action="/admin/account/totp/disable">
<div class="form-group">
<label for="disable_password">Enter password to disable</label>
<input id="disable_password" name="password" type="password" class="form-input" required />
</div>
<button type="submit" class="btn btn-danger">Disable 2FA</button>
</form>
</div>
) : (
<div>
<p style="color:#666; margin-bottom:1rem">
Protect your account with a TOTP authenticator app.
</p>
<form method="post" action="/admin/account/totp/begin">
<button type="submit" class="btn btn-primary">Enable 2FA</button>
</form>
</div>
)}
</div>
</div>
</Layout>
);
}
// ---- TOTP Enrollment --------------------------------------------------------
interface TotpEnrollProps {
user: string;
secret: string;
provisioningUri: string;
recoveryCodes: string[];
}
export function TotpEnrollPage(props: TotpEnrollProps) {
return (
<Layout title="Enable Two-Factor Auth" user={props.user} activeNav="account">
<div style="max-width:600px">
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Step 1: Scan QR Code</h2>
<p style="color:#666; margin-bottom:1rem">
Scan this with your authenticator app (Google Authenticator, Authy, etc.).
</p>
<div style="text-align:center; padding:1rem; background:#f9fafb; border-radius:4px; margin-bottom:1rem">
<div id="qr-code" style="display:inline-block"></div>
</div>
<details>
<summary style="cursor:pointer; color:#666; font-size:0.85rem">Can't scan? Enter manually</summary>
<code style="display:block; padding:0.75rem; background:#f9fafb; border-radius:4px; margin-top:0.5rem; word-break:break-all; font-size:0.9rem">{props.secret}</code>
</details>
</div>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Step 2: Save Recovery Codes</h2>
<p style="color:#dc2626; font-weight:500; margin-bottom:1rem">
Save these codes somewhere safe. They will not be shown again.
</p>
<div class="code-grid">
{props.recoveryCodes.map((code) => (
<div class="code-item">{code}</div>
))}
</div>
</div>
<div class="card">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Step 3: Verify</h2>
<form method="post" action="/admin/account/totp/confirm">
<input type="hidden" name="recovery_codes" value={JSON.stringify(props.recoveryCodes)} />
<div class="form-group">
<label for="code">Enter code from your authenticator</label>
<input
id="code"
name="code"
type="text"
class="form-input"
required
maxlength="6"
pattern="[0-9]{6}"
autocomplete="one-time-code"
inputmode="numeric"
style="text-align:center; font-size:1.5rem; letter-spacing:0.3rem; max-width:250px"
/>
</div>
<button type="submit" class="btn btn-primary">Confirm &amp; Enable</button>
<a href="/admin/account" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
</form>
</div>
</div>
</Layout>
);
}
// ---- Simple list page -------------------------------------------------------
interface SimpleListProps {
user: string;
pageTitle: string;
description: string;
activeNav: string;
items: Array<{ name: string; detail?: string; badge?: string }>;
}
export function SimpleListPage(props: SimpleListProps) {
return (
<Layout title={props.pageTitle} user={props.user} activeNav={props.activeNav}>
<p style="color:#666; margin-bottom:1.25rem">{props.description}</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{props.items.length === 0 ? (
<tr><td colspan="2" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
) : (
props.items.map((item) => (
<tr>
<td>
<strong>{item.name}</strong>
{item.badge && (
<span class="badge" style={`margin-left:0.5rem; background-color:${item.badge}`}>{item.badge}</span>
)}
</td>
<td style="color:#666">{item.detail ?? ""}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Layout>
);
}
// ---- Helpers ----------------------------------------------------------------
function formatTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}

View file

@ -0,0 +1,165 @@
/**
* Auth page templates: setup, login, TOTP, recovery.
*/
import { js } from "jsx-htmx";
import { MinimalLayout } from "./layout.js";
// ---- Setup ------------------------------------------------------------------
export function SetupPage(props: { error?: string; username?: string }) {
return (
<MinimalLayout
title="Initial Setup"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<p style="color:#666; margin-bottom:1.25rem">
Create your admin account to get started.
</p>
<form method="post" action="/setup">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
class="form-input"
required
minlength="3"
maxlength="64"
pattern="[a-zA-Z0-9_-]+"
value={props.username ?? ""}
autocomplete="username"
/>
<div class="form-hint">364 characters. Letters, digits, underscore, hyphen.</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
class="form-input"
required
minlength="12"
autocomplete="new-password"
/>
<div class="form-hint">At least 12 characters.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Create Admin Account</button>
</form>
</MinimalLayout>
);
}
// ---- Login ------------------------------------------------------------------
export function LoginPage(props: { error?: string; username?: string; welcome?: boolean }) {
return (
<MinimalLayout
title="Sign In"
flash={
props.error
? { type: "error", message: props.error }
: props.welcome
? { type: "success", message: "Admin account created. Sign in to continue." }
: undefined
}
>
<form method="post" action="/auth/login">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
class="form-input"
required
value={props.username ?? ""}
autocomplete="username"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
class="form-input"
required
autocomplete="current-password"
/>
</div>
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
</form>
</MinimalLayout>
);
}
// ---- TOTP -------------------------------------------------------------------
export function TotpPage(props: { error?: string }) {
return (
<MinimalLayout
title="Two-Factor Authentication"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<p style="color:#666; margin-bottom:1rem">
Enter the 6-digit code from your authenticator app.
</p>
<form method="post" action="/auth/totp">
<div class="form-group">
<label for="code">Code</label>
<input
id="code"
name="code"
type="text"
class="form-input"
required
maxlength="6"
pattern="[0-9]{6}"
autocomplete="one-time-code"
inputmode="numeric"
style="text-align:center; font-size:1.5rem; letter-spacing:0.3rem"
/>
</div>
<button type="submit" class="btn btn-primary btn-block">Verify</button>
</form>
<p style="text-align:center; margin-top:1rem">
<a href="/auth/recovery">Use a recovery code</a>
</p>
</MinimalLayout>
);
}
// ---- Recovery ---------------------------------------------------------------
export function RecoveryPage(props: { error?: string }) {
return (
<MinimalLayout
title="Recovery Code"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<p style="color:#666; margin-bottom:1rem">
Enter one of your recovery codes. Each code can only be used once.
</p>
<form method="post" action="/auth/recovery">
<div class="form-group">
<label for="code">Recovery Code</label>
<input
id="code"
name="code"
type="text"
class="form-input"
required
maxlength="10"
style="text-align:center; font-size:1.1rem; letter-spacing:0.15rem; text-transform:uppercase"
/>
</div>
<button type="submit" class="btn btn-primary btn-block">Verify</button>
</form>
<p style="text-align:center; margin-top:1rem">
<a href="/auth/totp">Back to authenticator code</a>
</p>
</MinimalLayout>
);
}

View file

@ -0,0 +1,263 @@
/**
* Base HTML layout for all admin pages.
* Server-side rendered via jsx-htmx returns string.
*/
import { css, js } from "jsx-htmx";
// ---- Shared types -----------------------------------------------------------
export interface PageProps {
title: string;
/** Username shown in navbar; omit for unauthenticated pages. */
user?: string;
/** If true, hide the sidebar nav (used for login/setup). */
minimal?: boolean;
/** Optional flash message. */
flash?: { type: "success" | "error" | "info"; message: string };
/** Active nav item key. */
activeNav?: string;
children?: string | string[];
}
// ---- Components -------------------------------------------------------------
function NavItem(props: { href: string; label: string; icon: string; active?: boolean }) {
return (
<a
href={props.href}
class={`nav-item${props.active ? " active" : ""}`}
>
<span class="nav-icon">{props.icon}</span>
{props.label}
</a>
);
}
function Sidebar(props: { activeNav?: string }) {
const a = props.activeNav;
return (
<aside class="sidebar">
<div class="sidebar-brand">
<strong>BetterFrame</strong>
</div>
<nav class="sidebar-nav">
<NavItem href="/admin/" label="Overview" icon="&#9632;" active={a === "overview"} />
<NavItem href="/admin/cameras" label="Cameras" icon="&#9899;" active={a === "cameras"} />
<NavItem href="/admin/layouts" label="Layouts" icon="&#9638;" active={a === "layouts"} />
<NavItem href="/admin/templates" label="Templates" icon="&#9641;" active={a === "templates"} />
<NavItem href="/admin/displays" label="Displays" icon="&#9642;" active={a === "displays"} />
<NavItem href="/admin/kiosks" label="Kiosks" icon="&#9672;" active={a === "kiosks"} />
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<hr />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />
<NavItem href="/nrdp/" label="Node-RED" icon="&#8594;" />
</nav>
</aside>
);
}
// ---- Layout -----------------------------------------------------------------
export function Layout(props: PageProps) {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{props.title} BetterFrame</title>
<link rel="stylesheet" href="/static/app.css" />
<style>{css(baseStyles as Parameters<typeof css>[0])}</style>
</head>
<body class={props.minimal ? "minimal" : "has-sidebar"} {...{ "hx-boost": "true" }}>
{!props.minimal && <Sidebar activeNav={props.activeNav} />}
<div class="main-wrap">
{!props.minimal && props.user && (
<header class="topbar">
<span class="topbar-title">{props.title}</span>
<div class="topbar-right">
<span class="topbar-user">{props.user}</span>
<form method="post" action="/auth/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost">Logout</button>
</form>
</div>
</header>
)}
{props.flash && (
<div class={`flash flash-${props.flash.type}`}>{props.flash.message}</div>
)}
<main class="content">{props.children}</main>
</div>
<script src="/static/htmx.min.js"></script>
</body>
</html>
);
}
/** Minimal centered layout for login/setup pages. */
export function MinimalLayout(props: { title: string; flash?: PageProps["flash"]; children?: string | string[] }) {
return (
<Layout title={props.title} minimal flash={props.flash}>
<div class="center-card">
<div class="card">
<h1 class="card-title">{props.title}</h1>
{props.children}
</div>
</div>
</Layout>
);
}
// ---- Styles -----------------------------------------------------------------
const baseStyles = {
"*, *::before, *::after": { boxSizing: "border-box" as const },
body: {
margin: "0",
fontFamily: "system-ui, -apple-system, sans-serif",
backgroundColor: "#f4f5f7",
color: "#1a1a2e",
fontSize: "14px",
lineHeight: "1.5",
},
"a": { color: "#2563eb", textDecoration: "none" },
"a:hover": { textDecoration: "underline" },
/* Sidebar */
".has-sidebar": { display: "grid", gridTemplateColumns: "220px 1fr", minHeight: "100vh" },
".sidebar": {
backgroundColor: "#1a1a2e",
color: "#e0e0e0",
display: "flex",
flexDirection: "column",
position: "sticky" as const,
top: "0",
height: "100vh",
overflowY: "auto" as const,
},
".sidebar-brand": { padding: "1.25rem 1rem", fontSize: "1.1rem", borderBottom: "1px solid #2a2a4e" },
".sidebar-nav": { padding: "0.5rem 0", display: "flex", flexDirection: "column", gap: "2px" },
".nav-item": {
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 1rem",
color: "#c0c0d0",
textDecoration: "none",
fontSize: "0.875rem",
borderRadius: "0",
},
".nav-item:hover": { backgroundColor: "#2a2a4e", color: "#fff", textDecoration: "none" },
".nav-item.active": { backgroundColor: "#2563eb", color: "#fff" },
".nav-icon": { fontSize: "0.75rem", width: "1.25rem", textAlign: "center" as const },
".sidebar hr": { border: "none", borderTop: "1px solid #2a2a4e", margin: "0.5rem 0" },
/* Topbar */
".topbar": {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 1.5rem",
backgroundColor: "#fff",
borderBottom: "1px solid #e0e0e0",
},
".topbar-title": { fontWeight: "600", fontSize: "1rem" },
".topbar-right": { display: "flex", alignItems: "center", gap: "0.75rem" },
".topbar-user": { color: "#666", fontSize: "0.85rem" },
/* Content */
".main-wrap": { display: "flex", flexDirection: "column", minHeight: "100vh" },
".content": { flex: "1", padding: "1.5rem" },
/* Minimal / center-card */
".minimal .content": { display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh" },
".center-card": { width: "100%", maxWidth: "420px" },
/* Card */
".card": {
backgroundColor: "#fff",
borderRadius: "8px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
padding: "1.5rem",
},
".card-title": { margin: "0 0 1.25rem", fontSize: "1.25rem", fontWeight: "600" },
/* Forms */
".form-group": { marginBottom: "1rem" },
".form-group label": { display: "block", marginBottom: "0.25rem", fontWeight: "500", fontSize: "0.85rem" },
".form-input": {
width: "100%",
padding: "0.5rem 0.75rem",
border: "1px solid #d0d0d0",
borderRadius: "4px",
fontSize: "0.9rem",
backgroundColor: "#fff",
},
".form-input:focus": { outline: "none", borderColor: "#2563eb", boxShadow: "0 0 0 2px rgba(37,99,235,0.15)" },
".form-hint": { fontSize: "0.8rem", color: "#666", marginTop: "0.25rem" },
".form-error": { fontSize: "0.8rem", color: "#dc2626", marginTop: "0.25rem" },
/* Buttons */
".btn": {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "0.5rem 1rem",
borderRadius: "4px",
border: "none",
cursor: "pointer",
fontSize: "0.9rem",
fontWeight: "500",
textDecoration: "none",
gap: "0.5rem",
},
".btn-primary": { backgroundColor: "#2563eb", color: "#fff" },
".btn-primary:hover": { backgroundColor: "#1d4ed8" },
".btn-danger": { backgroundColor: "#dc2626", color: "#fff" },
".btn-danger:hover": { backgroundColor: "#b91c1c" },
".btn-ghost": { backgroundColor: "transparent", color: "#666", border: "1px solid #d0d0d0" },
".btn-ghost:hover": { backgroundColor: "#f0f0f0" },
".btn-sm": { padding: "0.25rem 0.5rem", fontSize: "0.8rem" },
".btn-block": { width: "100%", justifyContent: "center" },
/* Flash */
".flash": { padding: "0.75rem 1rem", borderRadius: "4px", marginBottom: "1rem", fontSize: "0.9rem" },
".flash-success": { backgroundColor: "#d1fae5", color: "#065f46", border: "1px solid #6ee7b7" },
".flash-error": { backgroundColor: "#fee2e2", color: "#991b1b", border: "1px solid #fca5a5" },
".flash-info": { backgroundColor: "#dbeafe", color: "#1e40af", border: "1px solid #93c5fd" },
/* Stats */
".stats-grid": { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: "1rem", marginBottom: "1.5rem" },
".stat-card": { backgroundColor: "#fff", borderRadius: "8px", padding: "1.25rem", boxShadow: "0 1px 3px rgba(0,0,0,0.08)" },
".stat-label": { fontSize: "0.8rem", color: "#666", textTransform: "uppercase" as const, letterSpacing: "0.05em" },
".stat-value": { fontSize: "1.75rem", fontWeight: "700", marginTop: "0.25rem" },
/* Table */
".table-wrap": { backgroundColor: "#fff", borderRadius: "8px", overflow: "hidden", boxShadow: "0 1px 3px rgba(0,0,0,0.08)" },
table: { width: "100%", borderCollapse: "collapse" as const },
"th, td": { textAlign: "left" as const, padding: "0.75rem 1rem", borderBottom: "1px solid #eee" },
th: { backgroundColor: "#f9fafb", fontWeight: "600", fontSize: "0.8rem", textTransform: "uppercase" as const, letterSpacing: "0.05em", color: "#666" },
"tr:hover td": { backgroundColor: "#f9fafb" },
/* Badge */
".badge": { display: "inline-block", padding: "0.15rem 0.5rem", borderRadius: "12px", fontSize: "0.75rem", fontWeight: "500" },
".badge-green": { backgroundColor: "#d1fae5", color: "#065f46" },
".badge-gray": { backgroundColor: "#e5e7eb", color: "#374151" },
".badge-blue": { backgroundColor: "#dbeafe", color: "#1e40af" },
".badge-red": { backgroundColor: "#fee2e2", color: "#991b1b" },
/* Section */
".section-header": { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" },
".section-title": { fontSize: "1rem", fontWeight: "600", margin: "0" },
/* Radio group (for camera type toggle) */
".radio-group": { display: "flex", gap: "1rem", marginBottom: "0.5rem" },
".radio-group label": { display: "flex", alignItems: "center", gap: "0.35rem", fontWeight: "400", cursor: "pointer" },
/* Two-column layout */
".two-col": { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" },
"@media (max-width: 768px)": { ".two-col": { gridTemplateColumns: "1fr" } },
/* Recovery codes */
".code-grid": { display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: "0.5rem", fontFamily: "monospace", fontSize: "1rem" },
".code-item": { padding: "0.5rem", backgroundColor: "#f9fafb", borderRadius: "4px", textAlign: "center" as const },
};

11
server/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./lib",
"rootDir": "./src",
"jsx": "react-jsx",
"jsxImportSource": "jsx-htmx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib"]
}

21
tsconfig.base.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "NodeNext",
"resolveJsonModule": true,
"isolatedModules": true
}
}