commit 2fd2502b85b1f0000388fc8bed24e0c956e5a132 Author: Mitchell R Date: Sun May 10 01:09:13 2026 +0200 adding initial project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0914809 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d1b80f0 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e7e190 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/vendor-anyvali-js.sh b/scripts/vendor-anyvali-js.sh new file mode 100644 index 0000000..8b3b1a0 --- /dev/null +++ b/scripts/vendor-anyvali-js.sh @@ -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))" diff --git a/scripts/vendor-htmx.sh b/scripts/vendor-htmx.sh new file mode 100644 index 0000000..585aa4b --- /dev/null +++ b/scripts/vendor-htmx.sh @@ -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)" diff --git a/sec-config.yaml b/sec-config.yaml new file mode 100644 index 0000000..be88173 --- /dev/null +++ b/sec-config.yaml @@ -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__=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: {} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..f9b9f33 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts new file mode 100644 index 0000000..e44a076 --- /dev/null +++ b/server/src/plugins/service-admin-http/index.ts @@ -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, 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, 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 { + 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 {} + + async dispose(): Promise { + if (this.server) { + await this.server.close(); + } + } +} diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts new file mode 100644 index 0000000..8db8fac --- /dev/null +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -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; + } + }); +} diff --git a/server/src/plugins/service-admin-http/routes-account.ts b/server/src/plugins/service-admin-http/routes-account.ts new file mode 100644 index 0000000..2ad2cad --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-account.ts @@ -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" }, + }); + }); +} diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts new file mode 100644 index 0000000..dca383c --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -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(); + 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>(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, + })), + })); + }); +} diff --git a/server/src/plugins/service-admin-http/routes-auth.ts b/server/src/plugins/service-admin-http/routes-auth.ts new file mode 100644 index 0000000..f44e450 --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-auth.ts @@ -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; + 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 = { 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" } }); + }); +} diff --git a/server/src/plugins/service-admin-http/routes-setup.ts b/server/src/plugins/service-admin-http/routes-setup.ts new file mode 100644 index 0000000..4d3468e --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-setup.ts @@ -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 3–64 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" }, + }); + }); +} diff --git a/server/src/plugins/service-admin-http/routes-static.ts b/server/src/plugins/service-admin-http/routes-static.ts new file mode 100644 index 0000000..27494f3 --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-static.ts @@ -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 = { + ".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", + }, + }); + }); +} diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts new file mode 100644 index 0000000..219fa95 --- /dev/null +++ b/server/src/plugins/service-api-http/index.ts @@ -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, 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, typeof EventSchemas>) { + super(cfg); + } + + async init(_obs: Observable): Promise { + // TODO: create h3 app, mount kiosk + pairing routes, start listening + } + + async run(_obs: Observable): Promise {} + + async dispose(): Promise { + // TODO: close h3 listener + } +} diff --git a/server/src/plugins/service-auth/index.ts b/server/src/plugins/service-auth/index.ts new file mode 100644 index 0000000..ec00731 --- /dev/null +++ b/server/src/plugins/service-auth/index.ts @@ -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, 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, typeof EventSchemas>) { + super(cfg); + } + + // ---- BSB lifecycle ------------------------------------------------------- + + async init(_obs: Observable): Promise { + // 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 {} + + async dispose(): Promise {} + + /** 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 { + 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 { + 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 { + 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 `.` 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 { + 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; + } +} diff --git a/server/src/plugins/service-bundle/index.ts b/server/src/plugins/service-bundle/index.ts new file mode 100644 index 0000000..feb885d --- /dev/null +++ b/server/src/plugins/service-bundle/index.ts @@ -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, 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, typeof EventSchemas>) { + super(cfg); + } + + async init(_obs: Observable): Promise { + // TODO: implement bundle query + cluster-encrypt + } + + async run(_obs: Observable): Promise {} + + async dispose(): Promise {} +} diff --git a/server/src/plugins/service-cec-relay/index.ts b/server/src/plugins/service-cec-relay/index.ts new file mode 100644 index 0000000..d93da25 --- /dev/null +++ b/server/src/plugins/service-cec-relay/index.ts @@ -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, typeof EventSchemas> { + static override Config = Config; + static override EventSchemas = EventSchemas; + + initBeforePlugins?: string[]; + initAfterPlugins?: string[] = ["service-coordinator-ws"]; + runBeforePlugins?: string[]; + runAfterPlugins?: string[]; + + constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { + super(cfg); + } + + async init(_obs: Observable): Promise { + // TODO: subscribe to CEC command events, relay via coordinator + } + + async run(_obs: Observable): Promise {} + + async dispose(): Promise {} +} diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts new file mode 100644 index 0000000..28480c8 --- /dev/null +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -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, 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, typeof EventSchemas>) { + super(cfg); + } + + async init(_obs: Observable): Promise { + // TODO: create ws server, handle kiosk auth + message routing + } + + async run(_obs: Observable): Promise {} + + async dispose(): Promise { + // TODO: close ws server + } +} diff --git a/server/src/plugins/service-nodered-bridge/index.ts b/server/src/plugins/service-nodered-bridge/index.ts new file mode 100644 index 0000000..0d6755f --- /dev/null +++ b/server/src/plugins/service-nodered-bridge/index.ts @@ -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, typeof EventSchemas> { + static override Config = Config; + static override EventSchemas = EventSchemas; + + initBeforePlugins?: string[]; + initAfterPlugins?: string[] = ["service-store"]; + runBeforePlugins?: string[]; + runAfterPlugins?: string[]; + + constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { + super(cfg); + } + + async init(_obs: Observable): Promise { + // TODO: set up outbound HTTP forwarder + inbound callback routes + } + + async run(_obs: Observable): Promise {} + + async dispose(): Promise {} +} diff --git a/server/src/plugins/service-pairing/index.ts b/server/src/plugins/service-pairing/index.ts new file mode 100644 index 0000000..d56d34c --- /dev/null +++ b/server/src/plugins/service-pairing/index.ts @@ -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, 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, typeof EventSchemas>) { + super(cfg); + } + + async init(_obs: Observable): Promise { + // TODO: implement initiate/claim/poll state machine + } + + async run(_obs: Observable): Promise {} + + async dispose(): Promise {} +} diff --git a/server/src/plugins/service-secrets/index.ts b/server/src/plugins/service-secrets/index.ts new file mode 100644 index 0000000..0631155 --- /dev/null +++ b/server/src/plugins/service-secrets/index.ts @@ -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: /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, 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, typeof EventSchemas>) { + super(cfg); + } + + async init(obs: Observable): Promise { + this.serverKey = this.loadServerKey(obs); + } + + async run(_obs: Observable): Promise {} + + async dispose(): Promise {} + + // ---- public API for sibling services ------------------------------------- + + /** + * Encrypt a UTF-8 string at rest. Returns a self-describing ciphertext: + * v1... + * `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... + * + * 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: /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"); +} diff --git a/server/src/plugins/service-store/index.ts b/server/src/plugins/service-store/index.ts new file mode 100644 index 0000000..184931b --- /dev/null +++ b/server/src/plugins/service-store/index.ts @@ -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, 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, typeof EventSchemas>) { + super(cfg); + } + + async init(obs: Observable): Promise { + 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 { + // Long-lived; no work in run(). + } + + async dispose(): Promise { + 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; + } +} diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts new file mode 100644 index 0000000..bf82cd1 --- /dev/null +++ b/server/src/plugins/service-store/mappers.ts @@ -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) 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; + +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(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(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>(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(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(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(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>(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(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(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>(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>(r["payload"], {}), + received_at: s(r["received_at"]), + forwarded_to_nodered: b(r["forwarded_to_nodered"]), + }; +} diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts new file mode 100644 index 0000000..6da0cfc --- /dev/null +++ b/server/src/plugins/service-store/migrations.ts @@ -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)`, +]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts new file mode 100644 index 0000000..961ab49 --- /dev/null +++ b/server/src/plugins/service-store/repository.ts @@ -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; + +export class Repository { + private readonly db: DatabaseSync; + private readonly notify: NotifyFn; + private readonly stmts = new Map(); + + 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(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); + } + + 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) : null; + } + + getUserByUsername(username: string): User | null { + const r = this.prep("SELECT * FROM users WHERE username = ?").get(username); + return r ? rowToUser(r as Record) : 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): 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) : 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) : 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)); + } + + 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)); + } + + getDisplayById(id: number): Display | null { + const r = this.prep("SELECT * FROM displays WHERE id = ?").get(id); + return r ? rowToDisplay(r as Record) : 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)); + } + + getCameraById(id: number): Camera | null { + const r = this.prep("SELECT * FROM cameras WHERE id = ?").get(id); + return r ? rowToCamera(r as Record) : null; + } + + getCameraByName(name: string): Camera | null { + const r = this.prep("SELECT * FROM cameras WHERE name = ?").get(name); + return r ? rowToCamera(r as Record) : 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)); + } + + 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); + } + + // =========================================================================== + // 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)); + } + + getLabelByName(name: string): Label | null { + const r = this.prep("SELECT * FROM labels WHERE name = ?").get(name); + return r ? rowToLabel(r as Record) : 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); + } + + /** 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 { + 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; + 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)); + } + + getKioskById(id: number): Kiosk | null { + const r = this.prep("SELECT * FROM kiosks WHERE id = ?").get(id); + return r ? rowToKiosk(r as Record) : null; + } + + getKioskByName(name: string): Kiosk | null { + const r = this.prep("SELECT * FROM kiosks WHERE name = ?").get(name); + return r ? rowToKiosk(r as Record) : 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)); + } + + 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; + }): 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); + } + + getPairingCode(code: string): PairingCode | null { + const r = this.prep("SELECT * FROM pairing_codes WHERE code = ?").get(code); + return r ? rowToPairingCode(r as Record) : 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)); + } + + markPairingCodeClaimed( + code: string, + kioskId: number, + extras: Record, + ): 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): 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; + 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)); + } + + // =========================================================================== + // 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(); + 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)); + } + + 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)); + } + + 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)); + } + + 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)); + } + + 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)["name"])); + } +} diff --git a/server/src/plugins/service-store/util.ts b/server/src/plugins/service-store/util.ts new file mode 100644 index 0000000..a4323f1 --- /dev/null +++ b/server/src/plugins/service-store/util.ts @@ -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(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; +} diff --git a/server/src/schemas/forms/account.ts b/server/src/schemas/forms/account.ts new file mode 100644 index 0000000..8f1212e --- /dev/null +++ b/server/src/schemas/forms/account.ts @@ -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; +export type TotpConfirmForm = av.Infer; +export type TotpDisableForm = av.Infer; diff --git a/server/src/schemas/forms/admin.ts b/server/src/schemas/forms/admin.ts new file mode 100644 index 0000000..c7cbe5a --- /dev/null +++ b/server/src/schemas/forms/admin.ts @@ -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; +export type KioskPairConfirmForm = av.Infer; +export type LabelCreateForm = av.Infer; diff --git a/server/src/schemas/forms/auth.ts b/server/src/schemas/forms/auth.ts new file mode 100644 index 0000000..0c22685 --- /dev/null +++ b/server/src/schemas/forms/auth.ts @@ -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; +export type LoginForm = av.Infer; +export type TotpForm = av.Infer; +export type RecoveryForm = av.Infer; diff --git a/server/src/schemas/index.ts b/server/src/schemas/index.ts new file mode 100644 index 0000000..4ce5fab --- /dev/null +++ b/server/src/schemas/index.ts @@ -0,0 +1,68 @@ +/** + * Schema registry. Single source of truth for which schemas exist and what + * they're called when exported to /schemas/.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` 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; + +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]; +} diff --git a/server/src/schemas/wire/bundle.ts b/server/src/schemas/wire/bundle.ts new file mode 100644 index 0000000..97f91cb --- /dev/null +++ b/server/src/schemas/wire/bundle.ts @@ -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; +export type BundleCamera = av.Infer; +export type BundleLayout = av.Infer; +export type BundleLayoutCell = av.Infer; diff --git a/server/src/schemas/wire/events.ts b/server/src/schemas/wire/events.ts new file mode 100644 index 0000000..c1c33d1 --- /dev/null +++ b/server/src/schemas/wire/events.ts @@ -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; +export type KioskEvent = av.Infer; +export type KioskHeartbeatResponse = av.Infer; +export type KioskEventResponse = av.Infer; diff --git a/server/src/schemas/wire/pairing.ts b/server/src/schemas/wire/pairing.ts new file mode 100644 index 0000000..6e2c111 --- /dev/null +++ b/server/src/schemas/wire/pairing.ts @@ -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; +export type PairInitiateResponse = av.Infer; +export type PairClaimRequest = av.Infer; +export type PairClaimResponse = av.Infer; diff --git a/server/src/scripts/export-schemas.ts b/server/src/scripts/export-schemas.ts new file mode 100644 index 0000000..f7b26bd --- /dev/null +++ b/server/src/scripts/export-schemas.ts @@ -0,0 +1,50 @@ +/** + * Schema export. + * + * Run: `npm run schemas:export` (after build) — writes every registered + * anyvali schema to /schemas/.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/.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(); diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts new file mode 100644 index 0000000..b9c257c --- /dev/null +++ b/server/src/shared/types.ts @@ -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; +} + +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; +} + +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; +} + +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; + received_at: string; + forwarded_to_nodered: boolean; +} diff --git a/server/src/web-static/anyvali/VERSION b/server/src/web-static/anyvali/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/server/src/web-static/anyvali/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/server/src/web-static/anyvali/errors.js b/server/src/web-static/anyvali/errors.js new file mode 100644 index 0000000..3f1f23c --- /dev/null +++ b/server/src/web-static/anyvali/errors.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/format/validators.js b/server/src/web-static/anyvali/format/validators.js new file mode 100644 index 0000000..9afc60c --- /dev/null +++ b/server/src/web-static/anyvali/format/validators.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/forms/index.js b/server/src/web-static/anyvali/forms/index.js new file mode 100644 index 0000000..642c9fc --- /dev/null +++ b/server/src/web-static/anyvali/forms/index.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/index.js b/server/src/web-static/anyvali/index.js new file mode 100644 index 0000000..1d0b637 --- /dev/null +++ b/server/src/web-static/anyvali/index.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/infer.js b/server/src/web-static/anyvali/infer.js new file mode 100644 index 0000000..d456c4e --- /dev/null +++ b/server/src/web-static/anyvali/infer.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=infer.js.map \ No newline at end of file diff --git a/server/src/web-static/anyvali/interchange/document.js b/server/src/web-static/anyvali/interchange/document.js new file mode 100644 index 0000000..c0e7c15 --- /dev/null +++ b/server/src/web-static/anyvali/interchange/document.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/interchange/exporter.js b/server/src/web-static/anyvali/interchange/exporter.js new file mode 100644 index 0000000..0623c2d --- /dev/null +++ b/server/src/web-static/anyvali/interchange/exporter.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/interchange/importer.js b/server/src/web-static/anyvali/interchange/importer.js new file mode 100644 index 0000000..1522421 --- /dev/null +++ b/server/src/web-static/anyvali/interchange/importer.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/issue-codes.js b/server/src/web-static/anyvali/issue-codes.js new file mode 100644 index 0000000..dd91de9 --- /dev/null +++ b/server/src/web-static/anyvali/issue-codes.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/parse/coerce.js b/server/src/web-static/anyvali/parse/coerce.js new file mode 100644 index 0000000..19bd61a --- /dev/null +++ b/server/src/web-static/anyvali/parse/coerce.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/parse/defaults.js b/server/src/web-static/anyvali/parse/defaults.js new file mode 100644 index 0000000..f30c12c --- /dev/null +++ b/server/src/web-static/anyvali/parse/defaults.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/parse/parser.js b/server/src/web-static/anyvali/parse/parser.js new file mode 100644 index 0000000..229b5a0 --- /dev/null +++ b/server/src/web-static/anyvali/parse/parser.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/any.js b/server/src/web-static/anyvali/schemas/any.js new file mode 100644 index 0000000..46a2350 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/any.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/array.js b/server/src/web-static/anyvali/schemas/array.js new file mode 100644 index 0000000..0560beb --- /dev/null +++ b/server/src/web-static/anyvali/schemas/array.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/base.js b/server/src/web-static/anyvali/schemas/base.js new file mode 100644 index 0000000..8ac7116 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/base.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/bool.js b/server/src/web-static/anyvali/schemas/bool.js new file mode 100644 index 0000000..913b047 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/bool.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/enum.js b/server/src/web-static/anyvali/schemas/enum.js new file mode 100644 index 0000000..52c175a --- /dev/null +++ b/server/src/web-static/anyvali/schemas/enum.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/index.js b/server/src/web-static/anyvali/schemas/index.js new file mode 100644 index 0000000..84ed3f3 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/index.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/int.js b/server/src/web-static/anyvali/schemas/int.js new file mode 100644 index 0000000..df5b27d --- /dev/null +++ b/server/src/web-static/anyvali/schemas/int.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/intersection.js b/server/src/web-static/anyvali/schemas/intersection.js new file mode 100644 index 0000000..5af2ec9 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/intersection.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/literal.js b/server/src/web-static/anyvali/schemas/literal.js new file mode 100644 index 0000000..4862c5c --- /dev/null +++ b/server/src/web-static/anyvali/schemas/literal.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/never.js b/server/src/web-static/anyvali/schemas/never.js new file mode 100644 index 0000000..6bd898d --- /dev/null +++ b/server/src/web-static/anyvali/schemas/never.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/null.js b/server/src/web-static/anyvali/schemas/null.js new file mode 100644 index 0000000..6d0027c --- /dev/null +++ b/server/src/web-static/anyvali/schemas/null.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/nullable.js b/server/src/web-static/anyvali/schemas/nullable.js new file mode 100644 index 0000000..f6f92e1 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/nullable.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/number.js b/server/src/web-static/anyvali/schemas/number.js new file mode 100644 index 0000000..abe8a70 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/number.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/object.js b/server/src/web-static/anyvali/schemas/object.js new file mode 100644 index 0000000..abe3341 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/object.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/optional.js b/server/src/web-static/anyvali/schemas/optional.js new file mode 100644 index 0000000..de41984 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/optional.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/record.js b/server/src/web-static/anyvali/schemas/record.js new file mode 100644 index 0000000..60029b0 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/record.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/ref.js b/server/src/web-static/anyvali/schemas/ref.js new file mode 100644 index 0000000..d0a67fa --- /dev/null +++ b/server/src/web-static/anyvali/schemas/ref.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/string.js b/server/src/web-static/anyvali/schemas/string.js new file mode 100644 index 0000000..ea0b65a --- /dev/null +++ b/server/src/web-static/anyvali/schemas/string.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/tuple.js b/server/src/web-static/anyvali/schemas/tuple.js new file mode 100644 index 0000000..30db1f5 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/tuple.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/union.js b/server/src/web-static/anyvali/schemas/union.js new file mode 100644 index 0000000..d3f2c8c --- /dev/null +++ b/server/src/web-static/anyvali/schemas/union.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/schemas/unknown.js b/server/src/web-static/anyvali/schemas/unknown.js new file mode 100644 index 0000000..e752611 --- /dev/null +++ b/server/src/web-static/anyvali/schemas/unknown.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/anyvali/types.js b/server/src/web-static/anyvali/types.js new file mode 100644 index 0000000..8515c65 --- /dev/null +++ b/server/src/web-static/anyvali/types.js @@ -0,0 +1,3 @@ +// ---- Schema Node (interchange JSON representation) ---- +export {}; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/server/src/web-static/anyvali/util.js b/server/src/web-static/anyvali/util.js new file mode 100644 index 0000000..00a257d --- /dev/null +++ b/server/src/web-static/anyvali/util.js @@ -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 \ No newline at end of file diff --git a/server/src/web-static/htmx.min.js b/server/src/web-static/htmx.min.js new file mode 100644 index 0000000..3b7ac1a --- /dev/null +++ b/server/src/web-static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.10"};Q.onLoad=j;Q.process=Ft;Q.on=ye;Q.off=xe;Q.trigger=ae;Q.ajax=Nn;Q.find=f;Q.findAll=y;Q.closest=g;Q.remove=z;Q.addClass=w;Q.removeClass=b;Q.toggleClass=G;Q.takeClass=W;Q.swap=_e;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=$;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:U,findThisElement:we,filterValues:yn,swap:_e,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:A,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Se,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:He,querySelectorExt:ce,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function A(e,t){while(e&&!t(e)){e=c(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;A(t,function(e){return!!(r=o(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function M(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){H(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function V(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function y(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return y(te(),e)}}function x(){return window}function z(e,t){e=S(e);if(t){x().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=ue(S(e));if(!e){return}if(n){x().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function b(e,t,n){let r=ue(S(e));if(!r){return}if(n){x().setTimeout(function(){b(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=S(e);e.classList.toggle(t)}function W(e,t){e=S(e);ie(e.parentElement.children,function(e){b(e,t)});w(ue(e),t)}function g(e,t){e=ue(S(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function Y(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=S(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=Y(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),Y(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),Y(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,Y(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,Y(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const u=p(q(t,!!n));i.push(...F(u.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function S(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(k(t)){return{target:te().body,event:K(e),listener:t,options:n}}else{return{target:S(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Gn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function xe(t,n,r){Gn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const be=te().createElement("output");function ve(t,n){const e=ne(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ue(A(t,function(e){return e!==t&&s(ue(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){H('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return ue(A(e,function(e){return a(ue(e),t)!=null}))}function Se(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ee(e){return Q.config.attributesToSettle.includes(e)}function Ce(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Jn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);je(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){ie(y(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","
");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){b(e,Q.config.addedClass);Ft(ue(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function _e(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=S(h);const r=g.contextElement?q(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t0){x().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){x().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,C)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,Ze);const l=o.length;const u=O(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};O(o,Ze);c.pollInterval=d(O(o,/[,\[\s]/));O(o,Ze);var i=nt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,Ze);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,C))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,C);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,C))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,C)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,C)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ut(e,t,n){const r=oe(e);r.timeout=x().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function ct(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ct(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,u,e,c,f){const a=oe(l);let t;if(c.from){t=m(l,c.from)}else{t=[l]}if(c.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(c)){a.lastValue.set(c,new WeakMap)}a.lastValue.get(c).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(c.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(c,l,e)){return}const t=oe(e);t.triggerSpec=c;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(c.consume){e.stopPropagation()}if(c.target&&e.target){if(!h(ue(e.target),c.target)){return}}if(c.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(c.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(c);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(c.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");u(l,e);a.throttle=x().setTimeout(function(){a.throttle=null},c.throttle)}}else if(c.delay>0){a.delayed=x().setTimeout(function(){ae(l,"htmx:trigger");u(l,e)},c.delay)}else{ae(l,"htmx:trigger");u(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:c.trigger,listener:s,on:i});i.addEventListener(c.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){x().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ut(ue(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Rt(e){const t=At(e.target);const n=It(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=It(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ue(e),"button, input[type='submit']")}function Nt(e){return e.form||g(e,"form")}function It(e){const t=At(e.target);if(!t){return}const n=Nt(t);if(!n){return}return oe(n)}function Lt(e){e.addEventListener("click",Rt);e.addEventListener("focusin",Rt);e.addEventListener("focusout",qt)}function Dt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){De(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!U()){return null}t=V(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);_e(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){_e(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;w(e,Q.config.requestClass)});return t}function nn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){b(e,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function cn(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,cn(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,cn(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Nt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");ln(f,c.value,o)}const u=ve(e,"hx-include");ie(u,function(e){fn(n,r,i,ue(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{H("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Dn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){x().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const u in n){if(n.hasOwnProperty(u)){if(i[u]==null){i[u]=n[u]}}}}return Cn(ue(c(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Tn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Rn(e,t){return le(Hn(e,t),Tn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function T(e,t){return t.test(e.getAllResponseHeaders())}function Nn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:S(r)||be,returnPromise:true})}else{let e=S(r.target);if(r.target&&!e||r.source&&!e&&!S(r.source)){e=be}return he(t,n,S(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return he(t,n,null,null,{returnPromise:true})}}function In(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Ln(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||Vn;const F=i.select||null;if(!se(r)){re(s);return e}const u=i.targetOverride||ue(Se(r));if(u==null||u==be){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let c=oe(r);const f=c.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const N=ee(f,"formmethod");if(N!=null){if(de.includes(N.toLowerCase())){t=N}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=ue(ce(r,L))}d=(I[1]||"drop").trim();c=oe(h);if(d==="drop"&&c.xhr&&c.abortable!==true){re(s);return e}else if(d==="abort"){if(c.xhr){re(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(c.xhr){if(c.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const D=oe(o);if(D&&D.triggerSpec&&D.triggerSpec.queue){p=D.triggerSpec.queue}}if(p==null){p="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(p==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;c.xhr=g;c.abortable=B;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const X=ne(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:u})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,u,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Dn(i.values))}const j=Dn(Rn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let H=n;if(E){H=z;const Z=!w.keys().next().done;if(Z){if(H.indexOf("?")<0){H+="?"}else{H+="&"}H+=gn(w);if(O){H+="#"+O}}}if(!Ln(r,H,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),H,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const P in x){if(x.hasOwnProperty(P)){const Y=x[P];qn(g,P,Y)}}}const T={xhr:g,target:u,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:H,responsePath:null,anchor:O}};g.onload=function(){try{const t=In(r);T.pathInfo.responsePath=An(g);M(r,T);if(T.keepIndicators!==true){rn(R,q)}ae(r,"htmx:afterRequest",T);ae(r,"htmx:afterOnLoad",T);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",T);ae(e,"htmx:afterOnLoad",T)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},T));throw e}finally{m()}};g.onerror=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendError",T);re(l);m()};g.onabort=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendAbort",T);re(l);m()};g.ontimeout=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:timeout",T);re(l);m()};if(!ae(r,"htmx:beforeRequest",T)){re(s);m();return e}var R=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",T);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(T(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(T(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(T(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;let l=t.etc.push||ne(e,"hx-push-url");let u=t.etc.replace||ne(e,"hx-replace-url");if(l==="false")l=null;if(u==="false")u=null;const c=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Bn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};x().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx new file mode 100644 index 0000000..4779d33 --- /dev/null +++ b/server/src/web-templates/admin-pages.tsx @@ -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 ( + +
+
+
Cameras
+
{String(props.cameraCount)}
+
+
+
Kiosks
+
{String(props.kioskCount)}
+
+
+
Kiosks Online
+
{String(props.onlineKioskCount)}
+
+
+
Displays
+
{String(props.layoutCount)}
+
+
+ +
+

Quick Links

+
+ + +
+

Recent Events

+
+
+ + + + + + + + + + + {props.events.length === 0 ? ( + + ) : ( + props.events.map((ev) => ( + + + + + + + )) + )} + +
TimeTopicSourcePayload
No events yet
{formatTime(ev.received_at)}{ev.topic}{ev.source_type} + {JSON.stringify(ev.payload)} +
+
+
+ ); +} + +// ---- Cameras ---------------------------------------------------------------- + +interface CamerasProps { + user: string; + cameras: Camera[]; + streamCounts: Map; +} + +export function CamerasPage(props: CamerasProps) { + return ( + +
+

All Cameras

+ Add Camera +
+
+ + + + + + + + + + + {props.cameras.length === 0 ? ( + + ) : ( + props.cameras.map((cam) => ( + + + + + + + )) + )} + +
NameTypeStreamsStatus
No cameras configured
{cam.name}{cam.type.toUpperCase()}{String(props.streamCounts.get(cam.id) ?? 0)} + {cam.enabled + ? Enabled + : Disabled + } +
+
+
+ ); +} + +// ---- Camera New ------------------------------------------------------------- + +interface CameraNewProps { + user: string; + error?: string; + values?: Record; +} + +export function CameraNewPage(props: CameraNewProps) { + const v = props.values ?? {}; + return ( + +
+
+
+ + +
+ +
+ +
+ + +
+
+ +
+
+ + +
+
+ + + + + Cancel +
+
+ + +
+ ); +} + +// ---- Kiosks ----------------------------------------------------------------- + +interface KiosksProps { + user: string; + kiosks: Kiosk[]; + pendingCodes: PairingCode[]; +} + +export function KiosksPage(props: KiosksProps) { + return ( + +
+
+
+

Paired Kiosks

+
+
+ + + + + + + + + + + {props.kiosks.length === 0 ? ( + + ) : ( + props.kiosks.map((k) => ( + + + + + + + )) + )} + +
NameHardwareLast SeenStatus
No kiosks paired
{k.name}{k.hardware_model ?? "—"}{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"} + {k.enabled + ? Active + : Disabled + } +
+
+
+ +
+
+

Pair New Kiosk

+
+
+
+
+ + +
8-character code shown on kiosk screen.
+
+
+ + +
+
+ + +
Comma-separated label names.
+
+ +
+ + {props.pendingCodes.length > 0 && ( +
+
Pending Codes
+ {props.pendingCodes.map((pc) => ( +
+ {pc.code} + {formatTime(pc.expires_at)} +
+ ))} +
+ )} +
+
+
+
+ ); +} + +// ---- Account ---------------------------------------------------------------- + +interface AccountProps { + user: string; + totpEnabled: boolean; + error?: string; + success?: string; +} + +export function AccountPage(props: AccountProps) { + return ( + +
+
+

Change Password

+
+
+ + +
+
+ + +
At least 12 characters.
+
+ +
+
+ +
+

Two-Factor Authentication

+ {props.totpEnabled ? ( +
+

+ Enabled + {" "}TOTP is active on this account. +

+
+
+ + +
+ +
+
+ ) : ( +
+

+ Protect your account with a TOTP authenticator app. +

+
+ +
+
+ )} +
+
+
+ ); +} + +// ---- TOTP Enrollment -------------------------------------------------------- + +interface TotpEnrollProps { + user: string; + secret: string; + provisioningUri: string; + recoveryCodes: string[]; +} + +export function TotpEnrollPage(props: TotpEnrollProps) { + return ( + +
+
+

Step 1: Scan QR Code

+

+ Scan this with your authenticator app (Google Authenticator, Authy, etc.). +

+
+
+
+
+ Can't scan? Enter manually + {props.secret} +
+
+ +
+

Step 2: Save Recovery Codes

+

+ Save these codes somewhere safe. They will not be shown again. +

+
+ {props.recoveryCodes.map((code) => ( +
{code}
+ ))} +
+
+ +
+

Step 3: Verify

+
+ +
+ + +
+ + Cancel +
+
+
+
+ ); +} + +// ---- 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 ( + +

{props.description}

+
+ + + + + + + + + {props.items.length === 0 ? ( + + ) : ( + props.items.map((item) => ( + + + + + )) + )} + +
NameDetails
None configured yet
+ {item.name} + {item.badge && ( + {item.badge} + )} + {item.detail ?? ""}
+
+
+ ); +} + +// ---- 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; + } +} diff --git a/server/src/web-templates/auth-pages.tsx b/server/src/web-templates/auth-pages.tsx new file mode 100644 index 0000000..cc74c61 --- /dev/null +++ b/server/src/web-templates/auth-pages.tsx @@ -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 ( + +

+ Create your admin account to get started. +

+
+
+ + +
3–64 characters. Letters, digits, underscore, hyphen.
+
+
+ + +
At least 12 characters.
+
+ +
+
+ ); +} + +// ---- Login ------------------------------------------------------------------ + +export function LoginPage(props: { error?: string; username?: string; welcome?: boolean }) { + return ( + +
+
+ + +
+
+ + +
+ +
+
+ ); +} + +// ---- TOTP ------------------------------------------------------------------- + +export function TotpPage(props: { error?: string }) { + return ( + +

+ Enter the 6-digit code from your authenticator app. +

+
+
+ + +
+ +
+

+ Use a recovery code +

+
+ ); +} + +// ---- Recovery --------------------------------------------------------------- + +export function RecoveryPage(props: { error?: string }) { + return ( + +

+ Enter one of your recovery codes. Each code can only be used once. +

+
+
+ + +
+ +
+

+ Back to authenticator code +

+
+ ); +} diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx new file mode 100644 index 0000000..77a01fa --- /dev/null +++ b/server/src/web-templates/layout.tsx @@ -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 ( + + {props.icon} + {props.label} + + ); +} + +function Sidebar(props: { activeNav?: string }) { + const a = props.activeNav; + return ( + + ); +} + +// ---- Layout ----------------------------------------------------------------- + +export function Layout(props: PageProps) { + return ( + + + + + {props.title} — BetterFrame + + + + + {!props.minimal && } +
+ {!props.minimal && props.user && ( +
+ {props.title} +
+ {props.user} +
+ +
+
+
+ )} + {props.flash && ( +
{props.flash.message}
+ )} +
{props.children}
+
+ + + + ); +} + +/** Minimal centered layout for login/setup pages. */ +export function MinimalLayout(props: { title: string; flash?: PageProps["flash"]; children?: string | string[] }) { + return ( + +
+
+

{props.title}

+ {props.children} +
+
+
+ ); +} + +// ---- 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 }, +}; diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..dff3eab --- /dev/null +++ b/server/tsconfig.json @@ -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"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..49920d9 --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +}