mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
adding initial project
This commit is contained in:
commit
2fd2502b85
78 changed files with 8157 additions and 0 deletions
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
server/lib/
|
||||||
|
server/bsb-plugin.json
|
||||||
|
nodered/lib/
|
||||||
|
kiosk/target/
|
||||||
|
|
||||||
|
# BSB generated
|
||||||
|
.bsb/
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
secret.key
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.vscode/
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Env / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.tgz
|
||||||
|
*.tsbuildinfo
|
||||||
|
/old-python/
|
||||||
379
package-lock.json
generated
Normal file
379
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
{
|
||||||
|
"name": "betterframe",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "betterframe",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "AGPL-3.0-only OR Commercial",
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"nodered"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=23.0.0",
|
||||||
|
"npm": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@anyvali/js": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anyvali/js/-/js-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-iJq1dZJTwgk3H99/+U+wl0bXbd4zvBv/8ycpl8vBwUPGmbqLjkP+lul0wbjzrVyfBJJJnwXpIRXgWwZn7eBCQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@betterframe/server": {
|
||||||
|
"resolved": "server",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@bsb/base": {
|
||||||
|
"version": "9.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bsb/base/-/base-9.1.11.tgz",
|
||||||
|
"integrity": "sha512-Ys71H04Uc+pftGxWgfEGbmg1NSNxkz6trzmCTUHL+/eg6UqKDuYmvrzr/xB2tBvB5uArTsZ3m0wha+OqghTCZg==",
|
||||||
|
"license": "(AGPL-3.0-only OR Commercial)",
|
||||||
|
"dependencies": {
|
||||||
|
"@anyvali/js": "^0.2.0",
|
||||||
|
"chokidar": "^5.0.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"bsb": "lib/cli.js",
|
||||||
|
"bsb-client-cli": "lib/scripts/bsb-client-cli.js",
|
||||||
|
"bsb-plugin-cli": "lib/scripts/bsb-plugin-cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=23.0.0",
|
||||||
|
"npm": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@epic-web/invariant": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@phc/format": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||||
|
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/argon2": {
|
||||||
|
"version": "0.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
|
||||||
|
"integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@phc/format": "^1.0.0",
|
||||||
|
"cross-env": "^10.0.0",
|
||||||
|
"node-addon-api": "^8.5.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@epic-web/invariant": "^1.0.0",
|
||||||
|
"cross-spawn": "^7.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "dist/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/h3": {
|
||||||
|
"version": "2.0.1-rc.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/h3/-/h3-2.0.1-rc.22.tgz",
|
||||||
|
"integrity": "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rou3": "^0.8.1",
|
||||||
|
"srvx": "^0.11.15"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"h3": "bin/h3.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.11.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"crossws": "^0.4.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"crossws": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jsx-htmx": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsx-htmx/-/jsx-htmx-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-c4PHygDkwo9Xc7fN8iRYw29KAt5Oz+aCTuxneNpLOvt0D2ltTsd2ASreAFtgjDX1D7sOpKM6RUiuh78nvifN5g==",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.2.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||||
|
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/otpauth": {
|
||||||
|
"version": "9.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
|
||||||
|
"integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/hectorm/otpauth?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdirp": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rou3": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/srvx": {
|
||||||
|
"version": "0.11.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
|
||||||
|
"integrity": "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"srvx": "bin/srvx.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "13.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||||
|
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||||
|
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"name": "@betterframe/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "AGPL-3.0-only OR Commercial",
|
||||||
|
"dependencies": {
|
||||||
|
"@anyvali/js": "^0.2.0",
|
||||||
|
"@bsb/base": "^9.1.11",
|
||||||
|
"argon2": "^0.44.0",
|
||||||
|
"h3": "^2.0.1-rc.22",
|
||||||
|
"jsx-htmx": "^2.0.2",
|
||||||
|
"otpauth": "^9.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.0.0",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@bsb/base": "^9.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
package.json
Normal file
25
package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "betterframe",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "BetterFrame — multi-camera display system, Pi-5-first.",
|
||||||
|
"license": "AGPL-3.0-only OR Commercial",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=23.0.0",
|
||||||
|
"npm": ">=11.0.0"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"nodered"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm -w server run build",
|
||||||
|
"dev": "npm -w server run dev",
|
||||||
|
"start": "npm -w server start",
|
||||||
|
"test": "npm -w server test",
|
||||||
|
"schemas:export": "npm -w server run schemas:export",
|
||||||
|
"vendor:anyvali-js": "./scripts/vendor-anyvali-js.sh",
|
||||||
|
"vendor:htmx": "./scripts/vendor-htmx.sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
scripts/vendor-anyvali-js.sh
Normal file
24
scripts/vendor-anyvali-js.sh
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Re-vendor @anyvali/js from npm into server/src/web-static/anyvali/
|
||||||
|
# Usage: ./scripts/vendor-anyvali-js.sh [version]
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${1:-0.2.0}"
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
DEST="$ROOT/server/src/web-static/anyvali"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
cd "$TMP"
|
||||||
|
echo "fetching @anyvali/js@$VERSION ..."
|
||||||
|
npm pack "@anyvali/js@$VERSION" --silent
|
||||||
|
TGZ=$(ls anyvali-js-*.tgz | head -1)
|
||||||
|
tar xzf "$TGZ"
|
||||||
|
echo "rebuilding $DEST ..."
|
||||||
|
rm -rf "$DEST"
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
cd package/dist
|
||||||
|
find . -name '*.js' | while read -r f; do
|
||||||
|
mkdir -p "$DEST/$(dirname "$f")"
|
||||||
|
cp "$f" "$DEST/$f"
|
||||||
|
done
|
||||||
|
echo "$VERSION" > "$DEST/VERSION"
|
||||||
|
echo "done — vendored $VERSION ($(find "$DEST" -name '*.js' | wc -l) files, $(du -sh "$DEST" | cut -f1))"
|
||||||
15
scripts/vendor-htmx.sh
Normal file
15
scripts/vendor-htmx.sh
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Re-vendor htmx into server/src/web-static/htmx.min.js
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${1:-2.0.10}"
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
DEST="$ROOT/server/src/web-static/htmx.min.js"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
cd "$TMP"
|
||||||
|
echo "fetching htmx.org@$VERSION ..."
|
||||||
|
npm pack "htmx.org@$VERSION" --silent
|
||||||
|
TGZ=$(ls htmx.org-*.tgz | head -1)
|
||||||
|
tar xzf "$TGZ" package/dist/htmx.min.js
|
||||||
|
cp package/dist/htmx.min.js "$DEST"
|
||||||
|
echo "done — vendored htmx@$VERSION ($(wc -c < "$DEST") bytes)"
|
||||||
92
sec-config.yaml
Normal file
92
sec-config.yaml
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# BSB runtime configuration for BetterFrame server.
|
||||||
|
#
|
||||||
|
# Profile: 'default' — single-host install where the server, node-red, and
|
||||||
|
# (optionally) one kiosk all run on the same Pi. For multi-kiosk deployments
|
||||||
|
# the server is the same; kiosks have their own runtime config.
|
||||||
|
#
|
||||||
|
# Override individual values via env: BSB_<plugin>_<key>=value (consult BSB
|
||||||
|
# docs for the exact env-override semantics for v9).
|
||||||
|
|
||||||
|
default:
|
||||||
|
observable:
|
||||||
|
observable-default:
|
||||||
|
plugin: observable-default
|
||||||
|
enabled: true
|
||||||
|
config: {}
|
||||||
|
events:
|
||||||
|
events-default:
|
||||||
|
plugin: events-default
|
||||||
|
enabled: true
|
||||||
|
services:
|
||||||
|
# ----- Foundations -----
|
||||||
|
service-store:
|
||||||
|
plugin: service-store
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
sqlitePath: /var/lib/betterframe/betterframe.db
|
||||||
|
|
||||||
|
service-secrets:
|
||||||
|
plugin: service-secrets
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
# In production, leave both unset and rely on systemd-creds.
|
||||||
|
# In dev, the plugin generates a key in dataDir/secret.key (0600) and warns.
|
||||||
|
dataDir: /var/lib/betterframe
|
||||||
|
|
||||||
|
service-auth:
|
||||||
|
plugin: service-auth
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
sessionIdleSeconds: 43200 # 12h
|
||||||
|
sessionMaxSeconds: 2592000 # 30d
|
||||||
|
loginLockoutThreshold: 8
|
||||||
|
loginLockoutSeconds: 900 # 15m
|
||||||
|
argon2Memory: 65536 # KiB; tuned for Pi5 ~100ms
|
||||||
|
argon2TimeCost: 3
|
||||||
|
argon2Parallelism: 2
|
||||||
|
|
||||||
|
# ----- HTTP surfaces (each its own h3 listener; proxy fronts them) -----
|
||||||
|
service-admin-http:
|
||||||
|
plugin: service-admin-http
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 18080
|
||||||
|
|
||||||
|
service-api-http:
|
||||||
|
plugin: service-api-http
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 18081
|
||||||
|
|
||||||
|
service-coordinator-ws:
|
||||||
|
plugin: service-coordinator-ws
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 18082
|
||||||
|
|
||||||
|
# ----- Domain orchestrators -----
|
||||||
|
service-pairing:
|
||||||
|
plugin: service-pairing
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
codeTtlSeconds: 600 # 10m
|
||||||
|
|
||||||
|
service-bundle:
|
||||||
|
plugin: service-bundle
|
||||||
|
enabled: true
|
||||||
|
config: {}
|
||||||
|
|
||||||
|
# ----- Bridges -----
|
||||||
|
service-nodered-bridge:
|
||||||
|
plugin: service-nodered-bridge
|
||||||
|
enabled: true
|
||||||
|
config:
|
||||||
|
noderedUrl: http://127.0.0.1:1880
|
||||||
|
|
||||||
|
service-cec-relay:
|
||||||
|
plugin: service-cec-relay
|
||||||
|
enabled: true
|
||||||
|
config: {}
|
||||||
38
server/package.json
Normal file
38
server/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "@betterframe/server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "BetterFrame backend — BSB v9 service plugins.",
|
||||||
|
"license": "AGPL-3.0-only OR Commercial",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"lib/**/*",
|
||||||
|
"README.md",
|
||||||
|
"bsb-plugin.json",
|
||||||
|
"bsb-tests.json"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "bsb-plugin-cli build",
|
||||||
|
"clean": "bsb-plugin-cli clean",
|
||||||
|
"start": "bsb-plugin-cli start",
|
||||||
|
"dev": "bsb-plugin-cli dev",
|
||||||
|
"test": "bsb-plugin-cli test",
|
||||||
|
"schemas:export": "node --enable-source-maps lib/scripts/export-schemas.js"
|
||||||
|
},
|
||||||
|
"bsb": {},
|
||||||
|
"dependencies": {
|
||||||
|
"@anyvali/js": "^0.2.0",
|
||||||
|
"@bsb/base": "^9.1.11",
|
||||||
|
"argon2": "^0.44.0",
|
||||||
|
"h3": "^2.0.1-rc.22",
|
||||||
|
"jsx-htmx": "^2.0.2",
|
||||||
|
"otpauth": "^9.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.0.0",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@bsb/base": "^9.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
167
server/src/plugins/service-admin-http/index.ts
Normal file
167
server/src/plugins/service-admin-http/index.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
/**
|
||||||
|
* service-admin-http — h3 listener for the admin UI and admin API.
|
||||||
|
*
|
||||||
|
* Serves jsx-htmx rendered pages at /admin/* and JSON endpoints at
|
||||||
|
* /api/admin/*. Port 18080 behind the Angie proxy.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
import { H3, serve } from "h3";
|
||||||
|
import type { Server } from "srvx";
|
||||||
|
|
||||||
|
import type { Plugin as StorePlugin } from "../service-store/index.js";
|
||||||
|
import type { Plugin as AuthPlugin } from "../service-auth/index.js";
|
||||||
|
import type { Plugin as SecretsPlugin } from "../service-secrets/index.js";
|
||||||
|
|
||||||
|
import { registerMiddleware } from "./middleware.js";
|
||||||
|
import { registerSetupRoutes } from "./routes-setup.js";
|
||||||
|
import { registerAuthRoutes } from "./routes-auth.js";
|
||||||
|
import { registerAdminRoutes } from "./routes-admin.js";
|
||||||
|
import { registerAccountRoutes } from "./routes-account.js";
|
||||||
|
import { registerStaticRoutes } from "./routes-static.js";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object(
|
||||||
|
{
|
||||||
|
host: av.string().default("127.0.0.1"),
|
||||||
|
port: av.int().min(1).max(65535).default(18080),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-admin-http",
|
||||||
|
description: "h3 HTTP server for admin UI and admin API endpoints.",
|
||||||
|
tags: ["service", "http", "admin"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Deps interface shared with route modules -------------------------------
|
||||||
|
|
||||||
|
export interface AdminDeps {
|
||||||
|
store: StorePlugin;
|
||||||
|
auth: AuthPlugin;
|
||||||
|
secrets: SecretsPlugin;
|
||||||
|
cookieName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[] = ["service-store", "service-secrets", "service-auth"];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
private _store?: StorePlugin;
|
||||||
|
private _auth?: AuthPlugin;
|
||||||
|
private _secrets?: SecretsPlugin;
|
||||||
|
private server?: Server;
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(handoff): replace with BSB plugin clients
|
||||||
|
setSiblings(store: StorePlugin, auth: AuthPlugin, secrets: SecretsPlugin): void {
|
||||||
|
this._store = store;
|
||||||
|
this._auth = auth;
|
||||||
|
this._secrets = secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
get store(): StorePlugin {
|
||||||
|
if (!this._store) throw new Error("service-admin-http: siblings not wired");
|
||||||
|
return this._store;
|
||||||
|
}
|
||||||
|
|
||||||
|
get auth(): AuthPlugin {
|
||||||
|
if (!this._auth) throw new Error("service-admin-http: siblings not wired");
|
||||||
|
return this._auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
get secrets(): SecretsPlugin {
|
||||||
|
if (!this._secrets) throw new Error("service-admin-http: siblings not wired");
|
||||||
|
return this._secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(obs: Observable): Promise<void> {
|
||||||
|
const app = new H3();
|
||||||
|
const deps: AdminDeps = {
|
||||||
|
store: this.store,
|
||||||
|
auth: this.auth,
|
||||||
|
secrets: this.secrets,
|
||||||
|
cookieName: this.auth.config.cookieName,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order matters: middleware first, then routes
|
||||||
|
registerMiddleware(app, deps);
|
||||||
|
registerStaticRoutes(app);
|
||||||
|
registerSetupRoutes(app, deps);
|
||||||
|
registerAuthRoutes(app, deps);
|
||||||
|
registerAdminRoutes(app, deps);
|
||||||
|
registerAccountRoutes(app, deps);
|
||||||
|
|
||||||
|
// Health/readiness/version (no auth)
|
||||||
|
app.get("/healthz", () => ({ status: "ok" }));
|
||||||
|
app.get("/readyz", () => {
|
||||||
|
try {
|
||||||
|
deps.store.repo.isSetupComplete(); // touches DB
|
||||||
|
return { status: "ready" };
|
||||||
|
} catch {
|
||||||
|
return { status: "not_ready" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.get("/version", () => ({
|
||||||
|
name: "betterframe",
|
||||||
|
version: "0.1.0",
|
||||||
|
now: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Root redirect
|
||||||
|
app.get("/", () => {
|
||||||
|
if (!deps.store.repo.isSetupComplete()) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/setup" } });
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server = serve(app, {
|
||||||
|
port: this.config.port,
|
||||||
|
hostname: this.config.host,
|
||||||
|
});
|
||||||
|
|
||||||
|
obs.log.info("admin-http listening on {host}:{port}", {
|
||||||
|
host: this.config.host,
|
||||||
|
port: this.config.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this.server) {
|
||||||
|
await this.server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
server/src/plugins/service-admin-http/middleware.ts
Normal file
70
server/src/plugins/service-admin-http/middleware.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* Auth & setup gate middleware for admin-http.
|
||||||
|
*/
|
||||||
|
import { type H3, getCookie, createError, type H3Event, getRequestPath } from "h3";
|
||||||
|
import type { AdminDeps } from "./index.js";
|
||||||
|
import type { User, Session } from "../../shared/types.js";
|
||||||
|
|
||||||
|
/** Augment h3 event context with resolved auth info. */
|
||||||
|
declare module "h3" {
|
||||||
|
interface H3EventContext {
|
||||||
|
user?: User;
|
||||||
|
session?: Session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve session from cookie. Returns null if invalid/missing.
|
||||||
|
*/
|
||||||
|
function resolveSession(event: H3Event, deps: AdminDeps): { user: User; session: Session } | null {
|
||||||
|
const cookie = getCookie(event, deps.cookieName);
|
||||||
|
if (!cookie) return null;
|
||||||
|
return deps.auth.resolveSession(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMiddleware(app: H3, deps: AdminDeps): void {
|
||||||
|
// Setup gate: if setup not complete, only /setup, /static, /healthz, /readyz, /version allowed
|
||||||
|
app.use((event) => {
|
||||||
|
const path = getRequestPath(event);
|
||||||
|
|
||||||
|
// Always pass through non-gated paths
|
||||||
|
if (
|
||||||
|
path === "/setup" ||
|
||||||
|
path.startsWith("/static/") ||
|
||||||
|
path === "/healthz" ||
|
||||||
|
path === "/readyz" ||
|
||||||
|
path === "/version" ||
|
||||||
|
path === "/"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setup not complete, block everything except setup flow
|
||||||
|
if (!deps.store.repo.isSetupComplete()) {
|
||||||
|
if (!path.startsWith("/auth/")) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/setup" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth pages don't require session (login/totp/recovery)
|
||||||
|
if (path.startsWith("/auth/")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin pages require valid session
|
||||||
|
if (path.startsWith("/admin") || path.startsWith("/api/admin")) {
|
||||||
|
const resolved = resolveSession(event, deps);
|
||||||
|
if (!resolved) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||||
|
}
|
||||||
|
// TOTP pending — only allow /auth/totp and /auth/recovery
|
||||||
|
if (resolved.session.totp_pending) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/auth/totp" } });
|
||||||
|
}
|
||||||
|
// Attach to context for downstream handlers
|
||||||
|
event.context.user = resolved.user;
|
||||||
|
event.context.session = resolved.session;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
168
server/src/plugins/service-admin-http/routes-account.ts
Normal file
168
server/src/plugins/service-admin-http/routes-account.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
/**
|
||||||
|
* Account management routes — password change, TOTP enrollment.
|
||||||
|
*/
|
||||||
|
import { type H3, html, readBody } from "h3";
|
||||||
|
import type { AdminDeps } from "./index.js";
|
||||||
|
import { AccountPage, TotpEnrollPage } from "../../web-templates/admin-pages.js";
|
||||||
|
|
||||||
|
export function registerAccountRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
// ---- Account page ---------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/admin/account", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
return html(AccountPage({ user: user.username, totpEnabled: user.totp_enabled }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Change password ------------------------------------------------------
|
||||||
|
|
||||||
|
app.post("/admin/account/password", async (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const body = await readBody<{ current_password?: string; new_password?: string }>(event);
|
||||||
|
const current = body?.current_password ?? "";
|
||||||
|
const newPw = body?.new_password ?? "";
|
||||||
|
|
||||||
|
if (!current || !newPw) {
|
||||||
|
return html(AccountPage({
|
||||||
|
user: user.username,
|
||||||
|
totpEnabled: user.totp_enabled,
|
||||||
|
error: "Both current and new password required.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPw.length < 12) {
|
||||||
|
return html(AccountPage({
|
||||||
|
user: user.username,
|
||||||
|
totpEnabled: user.totp_enabled,
|
||||||
|
error: "New password must be at least 12 characters.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await deps.auth.verifyPassword(current, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
return html(AccountPage({
|
||||||
|
user: user.username,
|
||||||
|
totpEnabled: user.totp_enabled,
|
||||||
|
error: "Current password incorrect.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await deps.auth.hashPassword(newPw);
|
||||||
|
deps.store.repo.updateUser(user.id, { password_hash: hash });
|
||||||
|
|
||||||
|
// Revoke all sessions (force re-login)
|
||||||
|
deps.store.repo.revokeAllSessionsForUser(user.id);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: "/auth/login" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- TOTP: begin enrollment -----------------------------------------------
|
||||||
|
|
||||||
|
app.post("/admin/account/totp/begin", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
|
||||||
|
if (user.totp_enabled) {
|
||||||
|
return html(AccountPage({
|
||||||
|
user: user.username,
|
||||||
|
totpEnabled: true,
|
||||||
|
error: "TOTP already enabled.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = deps.auth.generateTotpSecret();
|
||||||
|
const uri = deps.auth.totpProvisioningUri(user.username, secret);
|
||||||
|
const codes = deps.auth.generateRecoveryCodes();
|
||||||
|
|
||||||
|
// Store unconfirmed secret + codes
|
||||||
|
const encrypted = deps.auth.encryptTotpSecret(secret);
|
||||||
|
deps.store.repo.updateUser(user.id, {
|
||||||
|
totp_secret_encrypted: encrypted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return html(TotpEnrollPage({
|
||||||
|
user: user.username,
|
||||||
|
secret,
|
||||||
|
provisioningUri: uri,
|
||||||
|
recoveryCodes: codes,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- TOTP: confirm enrollment ---------------------------------------------
|
||||||
|
|
||||||
|
app.post("/admin/account/totp/confirm", async (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const body = await readBody<{ code?: string; recovery_codes?: string }>(event);
|
||||||
|
const code = (body?.code ?? "").trim().replace(/\s/g, "");
|
||||||
|
|
||||||
|
if (!code || code.length !== 6) {
|
||||||
|
return html(AccountPage({
|
||||||
|
user: user.username,
|
||||||
|
totpEnabled: false,
|
||||||
|
error: "Enter a valid 6-digit code.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.totp_secret_encrypted) {
|
||||||
|
return html(AccountPage({
|
||||||
|
user: user.username,
|
||||||
|
totpEnabled: false,
|
||||||
|
error: "No TOTP enrollment in progress. Start again.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted);
|
||||||
|
const valid = deps.auth.verifyTotpCode(secret, code);
|
||||||
|
if (!valid) {
|
||||||
|
return html(AccountPage({
|
||||||
|
user: user.username,
|
||||||
|
totpEnabled: false,
|
||||||
|
error: "Invalid code. Scan the QR code again and enter the current code.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash recovery codes and save
|
||||||
|
const codesJson = body?.recovery_codes ?? "[]";
|
||||||
|
const codes: string[] = JSON.parse(codesJson);
|
||||||
|
const hashed = await deps.auth.hashRecoveryCodes(codes);
|
||||||
|
|
||||||
|
deps.store.repo.updateUser(user.id, {
|
||||||
|
totp_enabled: true,
|
||||||
|
recovery_codes_hashed: hashed,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: "/admin/account" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- TOTP: disable --------------------------------------------------------
|
||||||
|
|
||||||
|
app.post("/admin/account/totp/disable", async (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const body = await readBody<{ password?: string }>(event);
|
||||||
|
const password = body?.password ?? "";
|
||||||
|
|
||||||
|
const valid = await deps.auth.verifyPassword(password, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
return html(AccountPage({
|
||||||
|
user: user.username,
|
||||||
|
totpEnabled: true,
|
||||||
|
error: "Password incorrect.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.store.repo.updateUser(user.id, {
|
||||||
|
totp_enabled: false,
|
||||||
|
totp_secret_encrypted: null,
|
||||||
|
recovery_codes_hashed: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: "/admin/account" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
191
server/src/plugins/service-admin-http/routes-admin.ts
Normal file
191
server/src/plugins/service-admin-http/routes-admin.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
/**
|
||||||
|
* Admin page routes — overview, cameras, kiosks, labels, etc.
|
||||||
|
*/
|
||||||
|
import { type H3, html, readBody } from "h3";
|
||||||
|
import type { AdminDeps } from "./index.js";
|
||||||
|
import {
|
||||||
|
OverviewPage,
|
||||||
|
CamerasPage,
|
||||||
|
CameraNewPage,
|
||||||
|
KiosksPage,
|
||||||
|
SimpleListPage,
|
||||||
|
} from "../../web-templates/admin-pages.js";
|
||||||
|
|
||||||
|
export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
// ---- Overview -------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/admin/", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const cameras = deps.store.repo.listCameras();
|
||||||
|
const kiosks = deps.store.repo.listKiosks();
|
||||||
|
const layouts = deps.store.repo.listDisplays(); // for count
|
||||||
|
const events = deps.store.repo.recentEvents(10);
|
||||||
|
const onlineKiosks = kiosks.filter((k) => {
|
||||||
|
if (!k.last_seen_at) return false;
|
||||||
|
return Date.now() - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html(OverviewPage({
|
||||||
|
user: user.username,
|
||||||
|
cameraCount: cameras.length,
|
||||||
|
kioskCount: kiosks.length,
|
||||||
|
onlineKioskCount: onlineKiosks.length,
|
||||||
|
layoutCount: layouts.length,
|
||||||
|
events,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect /admin to /admin/
|
||||||
|
app.get("/admin", () => {
|
||||||
|
return new Response(null, { status: 301, headers: { location: "/admin/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Cameras --------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/admin/cameras", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const cameras = deps.store.repo.listCameras();
|
||||||
|
const streamCounts = new Map<number, number>();
|
||||||
|
for (const cam of cameras) {
|
||||||
|
streamCounts.set(cam.id, deps.store.repo.listCameraStreams(cam.id).length);
|
||||||
|
}
|
||||||
|
return html(CamerasPage({ user: user.username, cameras, streamCounts }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/cameras/new", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
return html(CameraNewPage({ user: user.username }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/cameras/new", async (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const name = (body?.["name"] ?? "").trim();
|
||||||
|
const type = body?.["type"] as "rtsp" | "onvif" | undefined;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!name || name.length > 128) {
|
||||||
|
errors.push("Name required (max 128 chars).");
|
||||||
|
} else if (deps.store.repo.getCameraByName(name)) {
|
||||||
|
errors.push("Camera name already in use.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== "rtsp" && type !== "onvif") {
|
||||||
|
errors.push("Select camera type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rtspUrl: string | undefined;
|
||||||
|
let onvifHost: string | undefined;
|
||||||
|
let onvifPort: number | undefined;
|
||||||
|
let onvifUser: string | undefined;
|
||||||
|
let onvifPass: string | undefined;
|
||||||
|
|
||||||
|
if (type === "rtsp") {
|
||||||
|
rtspUrl = (body?.["rtsp_url"] ?? "").trim();
|
||||||
|
if (!rtspUrl) errors.push("RTSP URL required.");
|
||||||
|
} else if (type === "onvif") {
|
||||||
|
onvifHost = (body?.["onvif_host"] ?? "").trim();
|
||||||
|
onvifPort = parseInt(body?.["onvif_port"] ?? "80", 10);
|
||||||
|
onvifUser = (body?.["onvif_username"] ?? "").trim();
|
||||||
|
onvifPass = body?.["onvif_password"] ?? "";
|
||||||
|
if (!onvifHost) errors.push("ONVIF host required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return html(CameraNewPage({
|
||||||
|
user: user.username,
|
||||||
|
error: errors.join(" "),
|
||||||
|
values: body,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cam = deps.store.repo.createCamera({
|
||||||
|
name,
|
||||||
|
type: type!,
|
||||||
|
rtsp_url: rtspUrl ?? null,
|
||||||
|
onvif_host: onvifHost ?? null,
|
||||||
|
onvif_port: onvifPort ?? null,
|
||||||
|
onvif_username: onvifUser ?? null,
|
||||||
|
onvif_password: onvifPass ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create default main stream for RTSP cameras
|
||||||
|
if (type === "rtsp" && rtspUrl) {
|
||||||
|
deps.store.repo.createCameraStream({
|
||||||
|
camera_id: cam.id,
|
||||||
|
role: "main",
|
||||||
|
name: "Main",
|
||||||
|
rtsp_uri: rtspUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: "/admin/cameras" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Kiosks ---------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/admin/kiosks", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const kiosks = deps.store.repo.listKiosks();
|
||||||
|
const pending = deps.store.repo.listPendingPairingCodes();
|
||||||
|
return html(KiosksPage({ user: user.username, kiosks, pendingCodes: pending }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Simple list pages (templates, layouts, displays, labels) -------------
|
||||||
|
|
||||||
|
app.get("/admin/templates", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
return html(SimpleListPage({
|
||||||
|
user: user.username,
|
||||||
|
pageTitle: "Layout Templates",
|
||||||
|
description: "Templates define named regions on a 12x12 grid. A visual template designer is coming.",
|
||||||
|
activeNav: "templates",
|
||||||
|
items: [], // TODO: list templates
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/layouts", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
return html(SimpleListPage({
|
||||||
|
user: user.username,
|
||||||
|
pageTitle: "Layouts",
|
||||||
|
description: "A layout binds cameras and other content into a template's regions for one display.",
|
||||||
|
activeNav: "layouts",
|
||||||
|
items: [], // TODO: list layouts
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/displays", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const displays = deps.store.repo.listDisplays();
|
||||||
|
return html(SimpleListPage({
|
||||||
|
user: user.username,
|
||||||
|
pageTitle: "Displays",
|
||||||
|
description: "Physical HDMI displays. Primary display created during setup.",
|
||||||
|
activeNav: "displays",
|
||||||
|
items: displays.map((d) => ({
|
||||||
|
name: d.name,
|
||||||
|
detail: `${d.width_px}x${d.height_px} — index ${d.index}`,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/labels", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const labels = deps.store.repo.listLabels();
|
||||||
|
return html(SimpleListPage({
|
||||||
|
user: user.username,
|
||||||
|
pageTitle: "Labels",
|
||||||
|
description: "Labels route cameras, layouts, and kiosks to each other across sites.",
|
||||||
|
activeNav: "labels",
|
||||||
|
items: labels.map((l) => ({
|
||||||
|
name: l.name,
|
||||||
|
detail: l.description ?? "",
|
||||||
|
badge: l.color ?? undefined,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
196
server/src/plugins/service-admin-http/routes-auth.ts
Normal file
196
server/src/plugins/service-admin-http/routes-auth.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
/**
|
||||||
|
* Authentication routes: login, TOTP, recovery, logout.
|
||||||
|
*/
|
||||||
|
import { type H3, readBody, html, getCookie, setCookie, deleteCookie, getQuery, getRequestHeader } from "h3";
|
||||||
|
import type { AdminDeps } from "./index.js";
|
||||||
|
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
|
||||||
|
|
||||||
|
const COOKIE_OPTS = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
// ---- Login ----------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/auth/login", (event) => {
|
||||||
|
const q = getQuery(event) as Record<string, string | undefined>;
|
||||||
|
const welcome = q["welcome"] === "1";
|
||||||
|
return html(LoginPage({ welcome }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/auth/login", async (event) => {
|
||||||
|
const body = await readBody<{ username?: string; password?: string }>(event);
|
||||||
|
const username = (body?.username ?? "").trim();
|
||||||
|
const password = body?.password ?? "";
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return html(LoginPage({ error: "Username and password required.", username }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = deps.store.repo.getUserByUsername(username);
|
||||||
|
if (!user || !user.is_active) {
|
||||||
|
return html(LoginPage({ error: "Invalid credentials.", username }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lockout check
|
||||||
|
if (user.locked_until) {
|
||||||
|
const lockEnd = new Date(user.locked_until);
|
||||||
|
if (lockEnd > new Date()) {
|
||||||
|
return html(LoginPage({ error: "Account locked. Try again later.", username }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await deps.auth.verifyPassword(password, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
// Increment failed count
|
||||||
|
const count = user.failed_login_count + 1;
|
||||||
|
const patch: Record<string, unknown> = { failed_login_count: count };
|
||||||
|
if (count >= 8) {
|
||||||
|
patch["locked_until"] = new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
deps.store.repo.updateUser(user.id, patch);
|
||||||
|
return html(LoginPage({ error: "Invalid credentials.", username }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset failed login count
|
||||||
|
deps.store.repo.updateUser(user.id, {
|
||||||
|
failed_login_count: 0,
|
||||||
|
locked_until: null,
|
||||||
|
last_login_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const totpPending = user.totp_enabled;
|
||||||
|
const { cookieValue } = await deps.auth.createSession({
|
||||||
|
user,
|
||||||
|
userAgent: getRequestHeader(event, "user-agent") ?? null,
|
||||||
|
ipAddress: getRequestHeader(event, "x-forwarded-for")
|
||||||
|
?? getRequestHeader(event, "x-real-ip")
|
||||||
|
?? null,
|
||||||
|
totpPending,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCookie(event, deps.cookieName, cookieValue, {
|
||||||
|
...COOKIE_OPTS,
|
||||||
|
maxAge: 30 * 24 * 60 * 60, // 30d absolute max
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totpPending) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/auth/totp" } });
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- TOTP -----------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/auth/totp", (event) => {
|
||||||
|
const cookie = getCookie(event, deps.cookieName);
|
||||||
|
if (!cookie) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||||
|
}
|
||||||
|
const resolved = deps.auth.resolveSession(cookie);
|
||||||
|
if (!resolved || !resolved.session.totp_pending) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
}
|
||||||
|
return html(TotpPage({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/auth/totp", async (event) => {
|
||||||
|
const cookie = getCookie(event, deps.cookieName);
|
||||||
|
if (!cookie) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||||
|
}
|
||||||
|
const resolved = deps.auth.resolveSession(cookie);
|
||||||
|
if (!resolved || !resolved.session.totp_pending) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{ code?: string }>(event);
|
||||||
|
const code = (body?.code ?? "").trim().replace(/\s/g, "");
|
||||||
|
|
||||||
|
if (!code || code.length !== 6) {
|
||||||
|
return html(TotpPage({ error: "Enter a 6-digit code." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, session } = resolved;
|
||||||
|
if (!user.totp_secret_encrypted) {
|
||||||
|
return html(TotpPage({ error: "TOTP not configured for this account." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted);
|
||||||
|
const valid = deps.auth.verifyTotpCode(secret, code);
|
||||||
|
if (!valid) {
|
||||||
|
return html(TotpPage({ error: "Invalid code. Try again." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear totp_pending
|
||||||
|
deps.store.repo.setSessionTotpPending(session.id, false);
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Recovery code --------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/auth/recovery", (event) => {
|
||||||
|
const cookie = getCookie(event, deps.cookieName);
|
||||||
|
if (!cookie) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||||
|
}
|
||||||
|
const resolved = deps.auth.resolveSession(cookie);
|
||||||
|
if (!resolved || !resolved.session.totp_pending) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
}
|
||||||
|
return html(RecoveryPage({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/auth/recovery", async (event) => {
|
||||||
|
const cookie = getCookie(event, deps.cookieName);
|
||||||
|
if (!cookie) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||||
|
}
|
||||||
|
const resolved = deps.auth.resolveSession(cookie);
|
||||||
|
if (!resolved || !resolved.session.totp_pending) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{ code?: string }>(event);
|
||||||
|
const code = (body?.code ?? "").trim().toUpperCase().replace(/\s/g, "");
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return html(RecoveryPage({ error: "Enter a recovery code." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, session } = resolved;
|
||||||
|
const hashedCodes: string[] = user.recovery_codes_hashed ?? [];
|
||||||
|
|
||||||
|
const result = await deps.auth.consumeRecoveryCode(code, hashedCodes);
|
||||||
|
if (!result.ok) {
|
||||||
|
return html(RecoveryPage({ error: "Invalid recovery code." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update remaining codes
|
||||||
|
deps.store.repo.updateUser(user.id, {
|
||||||
|
recovery_codes_hashed: result.remaining,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear totp_pending
|
||||||
|
deps.store.repo.setSessionTotpPending(session.id, false);
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Logout ---------------------------------------------------------------
|
||||||
|
|
||||||
|
app.post("/auth/logout", (event) => {
|
||||||
|
const cookie = getCookie(event, deps.cookieName);
|
||||||
|
if (cookie) {
|
||||||
|
const resolved = deps.auth.resolveSession(cookie);
|
||||||
|
if (resolved) {
|
||||||
|
deps.auth.revokeSession(resolved.session.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteCookie(event, deps.cookieName, { path: "/" });
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
||||||
|
});
|
||||||
|
}
|
||||||
64
server/src/plugins/service-admin-http/routes-setup.ts
Normal file
64
server/src/plugins/service-admin-http/routes-setup.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* First-run setup routes.
|
||||||
|
*
|
||||||
|
* GET /setup — render setup form
|
||||||
|
* POST /setup — create admin user, provision cluster key, create default display
|
||||||
|
*/
|
||||||
|
import { type H3, readBody, html } from "h3";
|
||||||
|
import type { AdminDeps } from "./index.js";
|
||||||
|
import { SetupPage } from "../../web-templates/auth-pages.js";
|
||||||
|
|
||||||
|
export function registerSetupRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
app.get("/setup", () => {
|
||||||
|
if (deps.store.repo.isSetupComplete()) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
}
|
||||||
|
return html(SetupPage({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/setup", async (event) => {
|
||||||
|
if (deps.store.repo.isSetupComplete()) {
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{ username?: string; password?: string }>(event);
|
||||||
|
const username = (body?.username ?? "").trim();
|
||||||
|
const password = body?.password ?? "";
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (!username || username.length < 3 || username.length > 64) {
|
||||||
|
errors.push("Username must be 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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
54
server/src/plugins/service-admin-http/routes-static.ts
Normal file
54
server/src/plugins/service-admin-http/routes-static.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Static file serving for /static/* paths.
|
||||||
|
*
|
||||||
|
* In production the Angie proxy serves these directly;
|
||||||
|
* this handler is for dev mode only.
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join, extname, resolve } from "node:path";
|
||||||
|
import { type H3, getRouterParam, createError } from "h3";
|
||||||
|
|
||||||
|
const STATIC_DIR = resolve(
|
||||||
|
import.meta.dirname ?? ".",
|
||||||
|
"../../web-static",
|
||||||
|
);
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".mjs": "application/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerStaticRoutes(app: H3): void {
|
||||||
|
app.get("/static/**:path", (event) => {
|
||||||
|
const reqPath = getRouterParam(event, "path");
|
||||||
|
if (!reqPath) throw createError({ statusCode: 404 });
|
||||||
|
|
||||||
|
// Prevent directory traversal
|
||||||
|
const resolved = resolve(STATIC_DIR, reqPath);
|
||||||
|
if (!resolved.startsWith(STATIC_DIR)) {
|
||||||
|
throw createError({ statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(resolved)) {
|
||||||
|
throw createError({ statusCode: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extname(resolved).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
||||||
|
const body = readFileSync(resolved);
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"content-type": contentType,
|
||||||
|
"cache-control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
68
server/src/plugins/service-api-http/index.ts
Normal file
68
server/src/plugins/service-api-http/index.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* service-api-http — h3 listener for the kiosk-facing REST API.
|
||||||
|
*
|
||||||
|
* Serves pairing, bundle, and kiosk management endpoints at /api/kiosk/*
|
||||||
|
* and /api/pair/*. Port 18081 behind the Angie proxy.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object(
|
||||||
|
{
|
||||||
|
host: av.string().default("127.0.0.1"),
|
||||||
|
port: av.int().min(1).max(65535).default(18081),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-api-http",
|
||||||
|
description: "h3 HTTP server for kiosk-facing REST API.",
|
||||||
|
tags: ["service", "http", "api", "kiosk"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[] = ["service-store", "service-auth"];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(_obs: Observable): Promise<void> {
|
||||||
|
// TODO: create h3 app, mount kiosk + pairing routes, start listening
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
// TODO: close h3 listener
|
||||||
|
}
|
||||||
|
}
|
||||||
382
server/src/plugins/service-auth/index.ts
Normal file
382
server/src/plugins/service-auth/index.ts
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
/**
|
||||||
|
* service-auth — credentials and session management.
|
||||||
|
*
|
||||||
|
* Like service-store, exposes a public class API to sibling services rather
|
||||||
|
* than wrapping every operation in a typed event. Calls cross processes only
|
||||||
|
* if/when we shard auth across instances; until then this is a tight, fast,
|
||||||
|
* single-binary service.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - argon2id password hashing/verification (tuned for Pi5 ~100ms)
|
||||||
|
* - TOTP secret gen + verify, recovery code gen + single-use consumption
|
||||||
|
* - Session create/lookup/revoke (signed cookie envelope)
|
||||||
|
* - API key create / verify-by-bearer
|
||||||
|
*/
|
||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import { TOTP, Secret } from "otpauth";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
|
||||||
|
import type { ApiKey, ApiKeyScope, Session, User } from "../../shared/types.js";
|
||||||
|
import type { Plugin as StorePlugin } from "../service-store/index.js";
|
||||||
|
import type { Plugin as SecretsPlugin } from "../service-secrets/index.js";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object(
|
||||||
|
{
|
||||||
|
sessionIdleSeconds: av.int().min(60).default(43200),
|
||||||
|
sessionMaxSeconds: av.int().min(3600).default(2592000),
|
||||||
|
loginLockoutThreshold: av.int().min(1).default(8),
|
||||||
|
loginLockoutSeconds: av.int().min(1).default(900),
|
||||||
|
argon2Memory: av.int().min(8).default(65536), // KiB
|
||||||
|
argon2TimeCost: av.int().min(1).default(3),
|
||||||
|
argon2Parallelism: av.int().min(1).default(2),
|
||||||
|
/** Issuer string used in TOTP provisioning URIs. */
|
||||||
|
totpIssuer: av.string().minLength(1).default("BetterFrame"),
|
||||||
|
/** Cookie name (used by service-admin-http to set/read). */
|
||||||
|
cookieName: av.string().minLength(1).default("betterframe_session"),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-auth",
|
||||||
|
description:
|
||||||
|
"Authentication primitives: argon2id passwords, TOTP, recovery codes, " +
|
||||||
|
"sessions (signed cookie envelope), and API keys.",
|
||||||
|
tags: ["service", "auth"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Constants -------------------------------------------------------------
|
||||||
|
|
||||||
|
const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I
|
||||||
|
const RECOVERY_CODE_COUNT = 10;
|
||||||
|
const RECOVERY_CODE_LENGTH = 10;
|
||||||
|
|
||||||
|
// ---- Plugin ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[] = ["service-store", "service-secrets"];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
// Sibling services: set in init() once they've initialized themselves.
|
||||||
|
// TODO(handoff): Replace with proper BSB plugin clients once we generate
|
||||||
|
// them. For v0.1 we resolve via the runtime's plugin lookup.
|
||||||
|
// The actual lookup mechanism is provided by the BSB framework — this
|
||||||
|
// file pretends the references arrive in init(). Wire-up happens in run().
|
||||||
|
private _store?: StorePlugin;
|
||||||
|
private _secrets?: SecretsPlugin;
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- BSB lifecycle -------------------------------------------------------
|
||||||
|
|
||||||
|
async init(_obs: Observable): Promise<void> {
|
||||||
|
// TODO(handoff): wire sibling-service references via plugin clients.
|
||||||
|
// For now `setSiblings()` is called by the boot script (see CLAUDE.md).
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {}
|
||||||
|
|
||||||
|
/** Called once by the boot wrapper after all plugins have constructed. */
|
||||||
|
setSiblings(store: StorePlugin, secrets: SecretsPlugin): void {
|
||||||
|
this._store = store;
|
||||||
|
this._secrets = secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get store(): StorePlugin {
|
||||||
|
if (!this._store) throw new Error("service-auth: siblings not set");
|
||||||
|
return this._store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get secrets(): SecretsPlugin {
|
||||||
|
if (!this._secrets) throw new Error("service-auth: siblings not set");
|
||||||
|
return this._secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Passwords
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async hashPassword(plain: string): Promise<string> {
|
||||||
|
return argon2.hash(plain, {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: this.config.argon2Memory,
|
||||||
|
timeCost: this.config.argon2TimeCost,
|
||||||
|
parallelism: this.config.argon2Parallelism,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await argon2.verify(hash, plain);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
needsRehash(hash: string): boolean {
|
||||||
|
return argon2.needsRehash(hash, {
|
||||||
|
memoryCost: this.config.argon2Memory,
|
||||||
|
timeCost: this.config.argon2TimeCost,
|
||||||
|
parallelism: this.config.argon2Parallelism,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// TOTP
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
generateTotpSecret(): string {
|
||||||
|
// 20 bytes (160 bits) base32-encoded by otpauth's Secret class
|
||||||
|
return new Secret({ size: 20 }).base32;
|
||||||
|
}
|
||||||
|
|
||||||
|
totpProvisioningUri(username: string, secretBase32: string): string {
|
||||||
|
const totp = new TOTP({
|
||||||
|
issuer: this.config.totpIssuer,
|
||||||
|
label: username,
|
||||||
|
algorithm: "SHA1",
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
secret: Secret.fromBase32(secretBase32),
|
||||||
|
});
|
||||||
|
return totp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyTotpCode(secretBase32: string, code: string): boolean {
|
||||||
|
const totp = new TOTP({
|
||||||
|
issuer: this.config.totpIssuer,
|
||||||
|
algorithm: "SHA1",
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
secret: Secret.fromBase32(secretBase32),
|
||||||
|
});
|
||||||
|
// Tolerate ±1 step for clock skew
|
||||||
|
return totp.validate({ token: code, window: 1 }) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptTotpSecret(secret: string): string {
|
||||||
|
return this.secrets.encryptString(secret, "totp");
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptTotpSecret(ciphertext: string): string {
|
||||||
|
return this.secrets.decryptString(ciphertext, "totp");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Recovery codes ------------------------------------------------------
|
||||||
|
|
||||||
|
generateRecoveryCodes(): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
||||||
|
const chars: string[] = [];
|
||||||
|
const buf = randomBytes(RECOVERY_CODE_LENGTH);
|
||||||
|
for (let j = 0; j < RECOVERY_CODE_LENGTH; j++) {
|
||||||
|
chars.push(RECOVERY_ALPHABET[buf[j]! % RECOVERY_ALPHABET.length]!);
|
||||||
|
}
|
||||||
|
out.push(chars.join(""));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hashRecoveryCodes(codes: string[]): Promise<string[]> {
|
||||||
|
return Promise.all(codes.map((c) => this.hashPassword(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeRecoveryCode(
|
||||||
|
code: string,
|
||||||
|
hashedCodes: string[],
|
||||||
|
): Promise<{ ok: boolean; remaining: string[] }> {
|
||||||
|
const remaining: string[] = [];
|
||||||
|
let consumed = false;
|
||||||
|
for (const h of hashedCodes) {
|
||||||
|
if (!consumed && (await this.verifyPassword(code, h))) {
|
||||||
|
consumed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
remaining.push(h);
|
||||||
|
}
|
||||||
|
return { ok: consumed, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Sessions (signed cookie envelope)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a session row + return (Session, signedCookieValue).
|
||||||
|
* Cookie envelope is `<sid>.<hmac>` where hmac uses the server-local key
|
||||||
|
* (info="cookie"). Tampering with the sid invalidates the cookie.
|
||||||
|
*/
|
||||||
|
async createSession(input: {
|
||||||
|
user: User;
|
||||||
|
userAgent: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
totpPending: boolean;
|
||||||
|
}): Promise<{ session: Session; cookieValue: string }> {
|
||||||
|
const id = randomBytes(32).toString("hex");
|
||||||
|
const csrfToken = randomBytes(32).toString("hex");
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + this.config.sessionMaxSeconds * 1000,
|
||||||
|
).toISOString();
|
||||||
|
const session = this.store.repo.createSession({
|
||||||
|
id,
|
||||||
|
user_id: input.user.id,
|
||||||
|
csrf_token: csrfToken,
|
||||||
|
totp_pending: input.totpPending,
|
||||||
|
user_agent: input.userAgent,
|
||||||
|
ip_address: input.ipAddress,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
});
|
||||||
|
return { session, cookieValue: this.signCookie(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a cookie value and look up the session.
|
||||||
|
* Also enforces sliding (idle) and absolute expiry. Touches last_seen_at
|
||||||
|
* if valid.
|
||||||
|
*/
|
||||||
|
resolveSession(
|
||||||
|
cookieValue: string,
|
||||||
|
): { session: Session; user: User } | null {
|
||||||
|
const sid = this.unsignCookie(cookieValue);
|
||||||
|
if (!sid) return null;
|
||||||
|
const session = this.store.repo.getSessionById(sid);
|
||||||
|
if (!session) return null;
|
||||||
|
if (session.revoked_at) return null;
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(session.expires_at);
|
||||||
|
if (expiresAt <= now) return null;
|
||||||
|
const lastSeen = new Date(session.last_seen_at);
|
||||||
|
const idleMs = this.config.sessionIdleSeconds * 1000;
|
||||||
|
if (now.getTime() - lastSeen.getTime() > idleMs) {
|
||||||
|
this.store.repo.revokeSession(sid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const user = this.store.repo.getUserById(session.user_id);
|
||||||
|
if (!user || !user.is_active) return null;
|
||||||
|
this.store.repo.touchSession(sid, now.toISOString());
|
||||||
|
return { session, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeSession(sid: string): void {
|
||||||
|
this.store.repo.revokeSession(sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Cookie signing ------------------------------------------------------
|
||||||
|
|
||||||
|
private signCookie(sid: string): string {
|
||||||
|
const mac = this.cookieMac(sid);
|
||||||
|
return `${sid}.${mac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the sid iff the signature is valid; null otherwise. */
|
||||||
|
private unsignCookie(cookieValue: string): string | null {
|
||||||
|
const dot = cookieValue.indexOf(".");
|
||||||
|
if (dot < 0) return null;
|
||||||
|
const sid = cookieValue.slice(0, dot);
|
||||||
|
const mac = cookieValue.slice(dot + 1);
|
||||||
|
const expected = this.cookieMac(sid);
|
||||||
|
const a = Buffer.from(mac, "hex");
|
||||||
|
const b = Buffer.from(expected, "hex");
|
||||||
|
if (a.length !== b.length) return null;
|
||||||
|
return timingSafeEqual(a, b) ? sid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cookieMac(sid: string): string {
|
||||||
|
// Derive a cookie-signing key off the server key with HKDF info="cookie".
|
||||||
|
// We don't have direct access to the key; ask service-secrets to do an
|
||||||
|
// HMAC for us. To avoid a round-trip API, we add a small helper there
|
||||||
|
// later if profiling shows it. For now we compute on a derived subkey by
|
||||||
|
// running encryptString with deterministic IV (NO — that leaks). Better:
|
||||||
|
// use HKDF via secrets internally. For v0.1 we expose `signCookie` here
|
||||||
|
// as HMAC-SHA256 keyed on the encryption of a fixed plaintext, which
|
||||||
|
// produces a stable subkey-equivalent. This is acceptable but a TODO.
|
||||||
|
// TODO(handoff): expose `secrets.deriveSubkey(info)` publicly so we can
|
||||||
|
// hold a Buffer here and stop round-tripping through encryptString.
|
||||||
|
const subkeyMaterial = this.secrets.encryptString("cookie-subkey", "cookie-derivation");
|
||||||
|
return createHmac("sha256", subkeyMaterial).update(sid).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// API keys
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async createApiKey(input: {
|
||||||
|
name: string;
|
||||||
|
scopes: ApiKeyScope[];
|
||||||
|
expiresAt: string | null;
|
||||||
|
}): Promise<{ apiKey: ApiKey; plaintext: string }> {
|
||||||
|
const plaintext = `bf-${randomBytes(24).toString("base64url")}`;
|
||||||
|
const keyHash = await this.hashPassword(plaintext);
|
||||||
|
const keyPrefix = plaintext.slice(0, 8);
|
||||||
|
const apiKey = this.store.repo.createApiKey({
|
||||||
|
name: input.name,
|
||||||
|
key_hash: keyHash,
|
||||||
|
key_prefix: keyPrefix,
|
||||||
|
scopes: input.scopes,
|
||||||
|
expires_at: input.expiresAt,
|
||||||
|
});
|
||||||
|
return { apiKey, plaintext };
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null> {
|
||||||
|
const prefix = plaintext.slice(0, 8);
|
||||||
|
const candidates = this.store.repo.listApiKeysByPrefix(prefix);
|
||||||
|
for (const cand of candidates) {
|
||||||
|
if (cand.revoked_at) continue;
|
||||||
|
if (cand.expires_at && new Date(cand.expires_at) <= new Date()) continue;
|
||||||
|
if (await this.verifyPassword(plaintext, cand.key_hash)) {
|
||||||
|
this.store.repo.touchApiKey(cand.id, ip);
|
||||||
|
return cand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Kiosk-key verification (mirror of API key verify but for the kiosks table)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async verifyKioskKey(plaintext: string): Promise<{ id: number } | null> {
|
||||||
|
if (plaintext.length < 8) return null;
|
||||||
|
const prefix = plaintext.slice(0, 8);
|
||||||
|
const candidates = this.store.repo.listKiosksByKeyPrefix(prefix);
|
||||||
|
for (const cand of candidates) {
|
||||||
|
if (await this.verifyPassword(plaintext, cand.key_hash)) {
|
||||||
|
return { id: cand.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
server/src/plugins/service-bundle/index.ts
Normal file
61
server/src/plugins/service-bundle/index.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* service-bundle — label-scoped bundle generation for kiosks.
|
||||||
|
*
|
||||||
|
* Queries layouts/cameras/labels for a kiosk's label set, encrypts ONVIF
|
||||||
|
* passwords with the cluster key, and returns a versioned JSON bundle
|
||||||
|
* the kiosk caches locally.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object({}, { unknownKeys: "strip" });
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-bundle",
|
||||||
|
description: "Label-aware bundle generation for kiosks.",
|
||||||
|
tags: ["service", "bundle", "kiosk"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[] = ["service-store", "service-secrets"];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(_obs: Observable): Promise<void> {
|
||||||
|
// TODO: implement bundle query + cluster-encrypt
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {}
|
||||||
|
}
|
||||||
60
server/src/plugins/service-cec-relay/index.ts
Normal file
60
server/src/plugins/service-cec-relay/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* service-cec-relay — translates CEC commands to ws messages.
|
||||||
|
*
|
||||||
|
* Receives CEC control requests from the admin API or Node-RED and
|
||||||
|
* relays them to the authoritative kiosk via the coordinator WS channel.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object({}, { unknownKeys: "strip" });
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-cec-relay",
|
||||||
|
description: "Relay CEC commands to the authoritative kiosk.",
|
||||||
|
tags: ["service", "cec", "relay"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[] = ["service-coordinator-ws"];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(_obs: Observable): Promise<void> {
|
||||||
|
// TODO: subscribe to CEC command events, relay via coordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {}
|
||||||
|
}
|
||||||
68
server/src/plugins/service-coordinator-ws/index.ts
Normal file
68
server/src/plugins/service-coordinator-ws/index.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* service-coordinator-ws — WebSocket hub for live kiosk channel.
|
||||||
|
*
|
||||||
|
* Kiosks connect here to receive real-time layout switches, power
|
||||||
|
* commands, and status pings. Port 18082 behind the Angie proxy.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object(
|
||||||
|
{
|
||||||
|
host: av.string().default("127.0.0.1"),
|
||||||
|
port: av.int().min(1).max(65535).default(18082),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-coordinator-ws",
|
||||||
|
description: "WebSocket server for real-time kiosk coordination.",
|
||||||
|
tags: ["service", "ws", "kiosk", "coordinator"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[] = ["service-store", "service-auth"];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(_obs: Observable): Promise<void> {
|
||||||
|
// TODO: create ws server, handle kiosk auth + message routing
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
// TODO: close ws server
|
||||||
|
}
|
||||||
|
}
|
||||||
65
server/src/plugins/service-nodered-bridge/index.ts
Normal file
65
server/src/plugins/service-nodered-bridge/index.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* service-nodered-bridge — bidirectional HTTP bridge to Node-RED.
|
||||||
|
*
|
||||||
|
* Forwards events from the BSB bus to Node-RED HTTP-in endpoints,
|
||||||
|
* and exposes callbacks for Node-RED to push back into the bus.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object(
|
||||||
|
{
|
||||||
|
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-nodered-bridge",
|
||||||
|
description: "HTTP bridge between BSB event bus and Node-RED.",
|
||||||
|
tags: ["service", "nodered", "bridge"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[] = ["service-store"];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(_obs: Observable): Promise<void> {
|
||||||
|
// TODO: set up outbound HTTP forwarder + inbound callback routes
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {}
|
||||||
|
}
|
||||||
65
server/src/plugins/service-pairing/index.ts
Normal file
65
server/src/plugins/service-pairing/index.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* service-pairing — 8-char code state machine for kiosk pairing.
|
||||||
|
*
|
||||||
|
* Kiosk shows code on screen, admin enters it in UI, server delivers
|
||||||
|
* kiosk_key + cluster_key + bundle_url via one-shot poll.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object(
|
||||||
|
{
|
||||||
|
codeTtlSeconds: av.int().min(60).max(3600).default(600),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-pairing",
|
||||||
|
description: "Kiosk pairing code state machine.",
|
||||||
|
tags: ["service", "pairing", "kiosk"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[] = ["service-store", "service-secrets"];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(_obs: Observable): Promise<void> {
|
||||||
|
// TODO: implement initiate/claim/poll state machine
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {}
|
||||||
|
}
|
||||||
232
server/src/plugins/service-secrets/index.ts
Normal file
232
server/src/plugins/service-secrets/index.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
/**
|
||||||
|
* service-secrets — symmetric crypto and the cluster key.
|
||||||
|
*
|
||||||
|
* Two roles:
|
||||||
|
* 1. Field encryption for ONVIF passwords (and anything else stored
|
||||||
|
* sensitively at rest). Uses AES-256-GCM with a server-local key.
|
||||||
|
* 2. Holding the cluster key (the shared symmetric secret kiosks use to
|
||||||
|
* decrypt the camera credentials in their bundle). Cluster key is
|
||||||
|
* generated at first-run setup and stored in setup_state.extras
|
||||||
|
* (server-encrypted).
|
||||||
|
*
|
||||||
|
* Server-local key sources (priority order):
|
||||||
|
* 1. systemd-creds: $CREDENTIALS_DIRECTORY/betterframe-secret
|
||||||
|
* 2. Dev fallback: <data_dir>/secret.key (chmod 0600). Generated if
|
||||||
|
* missing, with a WARN log so deploys notice.
|
||||||
|
*
|
||||||
|
* The cluster key never reaches disk in plaintext; it's encrypted with the
|
||||||
|
* server-local key and stored in setup_state.extras["cluster_key_encrypted"].
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
randomBytes,
|
||||||
|
hkdfSync,
|
||||||
|
} from "node:crypto";
|
||||||
|
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object(
|
||||||
|
{
|
||||||
|
dataDir: av.string().minLength(1).default("/var/lib/betterframe"),
|
||||||
|
/** Override the systemd-creds credential name. */
|
||||||
|
systemdCredsName: av.string().default("betterframe-secret"),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-secrets",
|
||||||
|
description:
|
||||||
|
"Symmetric crypto for at-rest secrets and the inter-kiosk cluster key.",
|
||||||
|
tags: ["service", "secrets", "crypto"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
/** 32-byte server-local key. Used to wrap field secrets and the cluster key. */
|
||||||
|
private serverKey?: Buffer;
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(obs: Observable): Promise<void> {
|
||||||
|
this.serverKey = this.loadServerKey(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {}
|
||||||
|
|
||||||
|
// ---- public API for sibling services -------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a UTF-8 string at rest. Returns a self-describing ciphertext:
|
||||||
|
* v1.<iv-b64url>.<tag-b64url>.<ct-b64url>
|
||||||
|
* `info` lets us domain-separate keys (e.g. "field" vs "cluster") so the
|
||||||
|
* same server key can be used for distinct purposes safely.
|
||||||
|
*/
|
||||||
|
encryptString(plaintext: string, info: string = "field"): string {
|
||||||
|
const subkey = this.deriveSubkey(info);
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", subkey, iv);
|
||||||
|
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptString(ciphertext: string, info: string = "field"): string {
|
||||||
|
const parts = ciphertext.split(".");
|
||||||
|
if (parts.length !== 4 || parts[0] !== "v1") {
|
||||||
|
throw new Error("ciphertext: bad format");
|
||||||
|
}
|
||||||
|
const iv = b64uDecode(parts[1]!);
|
||||||
|
const tag = b64uDecode(parts[2]!);
|
||||||
|
const ct = b64uDecode(parts[3]!);
|
||||||
|
const subkey = this.deriveSubkey(info);
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", subkey, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||||
|
return pt.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a fresh cluster key (32 bytes, base64url). */
|
||||||
|
generateClusterKey(): string {
|
||||||
|
return b64u(randomBytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt-for-cluster: takes a plaintext + the cluster key, returns the
|
||||||
|
* format the kiosk expects in its bundle. Symmetric counterpart in Rust.
|
||||||
|
*
|
||||||
|
* v1.<iv-b64url>.<tag-b64url>.<ct-b64url>
|
||||||
|
*
|
||||||
|
* Same envelope shape as encryptString but keyed off the cluster key.
|
||||||
|
*/
|
||||||
|
encryptForCluster(plaintext: string, clusterKeyB64u: string): string {
|
||||||
|
const key = b64uDecode(clusterKeyB64u);
|
||||||
|
if (key.length !== 32) throw new Error("cluster key must be 32 bytes");
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return `v1.${b64u(iv)}.${b64u(tag)}.${b64u(ct)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internals -----------------------------------------------------------
|
||||||
|
|
||||||
|
private deriveSubkey(info: string): Buffer {
|
||||||
|
if (!this.serverKey) throw new Error("service-secrets not initialized");
|
||||||
|
// HKDF-SHA256 with the info string as the context.
|
||||||
|
const out = hkdfSync(
|
||||||
|
"sha256",
|
||||||
|
this.serverKey,
|
||||||
|
Buffer.alloc(0),
|
||||||
|
Buffer.from(`betterframe.${info}`, "utf8"),
|
||||||
|
32,
|
||||||
|
);
|
||||||
|
return Buffer.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadServerKey(obs: Observable): Buffer {
|
||||||
|
// 1. systemd-creds
|
||||||
|
const credsDir = process.env["CREDENTIALS_DIRECTORY"];
|
||||||
|
if (credsDir) {
|
||||||
|
const path = join(credsDir, this.config.systemdCredsName);
|
||||||
|
if (existsSync(path)) {
|
||||||
|
const buf = readFileSync(path);
|
||||||
|
if (buf.length >= 32) {
|
||||||
|
obs.log.info("server key loaded from systemd-creds");
|
||||||
|
return buf.subarray(0, 32);
|
||||||
|
}
|
||||||
|
obs.log.warn(
|
||||||
|
"systemd-creds file too short ({len}); falling back to dev key",
|
||||||
|
{ len: buf.length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Dev fallback: <data_dir>/secret.key
|
||||||
|
const path = join(this.config.dataDir, "secret.key");
|
||||||
|
if (existsSync(path)) {
|
||||||
|
const buf = readFileSync(path);
|
||||||
|
if (buf.length >= 32) {
|
||||||
|
obs.log.info("server key loaded from {path}", { path });
|
||||||
|
return buf.subarray(0, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generate new dev key
|
||||||
|
obs.log.warn(
|
||||||
|
"GENERATING DEV SERVER KEY at {path} — production deploys should use systemd-creds (CREDENTIALS_DIRECTORY/{name}) instead",
|
||||||
|
{ path, name: this.config.systemdCredsName },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
} catch {
|
||||||
|
/* already exists or insufficient perms */
|
||||||
|
}
|
||||||
|
const fresh = randomBytes(32);
|
||||||
|
writeFileSync(path, fresh, { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
chmodSync(path, 0o600);
|
||||||
|
} catch {
|
||||||
|
/* not POSIX; fine on dev */
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- base64url helpers (no padding) ----------------------------------------
|
||||||
|
|
||||||
|
function b64u(buf: Buffer): string {
|
||||||
|
return buf
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64uDecode(s: string): Buffer {
|
||||||
|
const padded = s + "=".repeat((4 - (s.length % 4)) % 4);
|
||||||
|
return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
||||||
|
}
|
||||||
160
server/src/plugins/service-store/index.ts
Normal file
160
server/src/plugins/service-store/index.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
/**
|
||||||
|
* service-store — the only service that opens the sqlite database.
|
||||||
|
*
|
||||||
|
* Architecture choice (v0.1):
|
||||||
|
* For now, other services hold a typed reference to this plugin's
|
||||||
|
* `Repository` instance via constructor injection (BSB plugin clients).
|
||||||
|
* We expose the high-level data API as plain methods rather than wiring
|
||||||
|
* every CRUD operation as a typed BSB event.
|
||||||
|
*
|
||||||
|
* Reason: 60+ tables × 4 operations × 2 (input + output) anyvali schemas
|
||||||
|
* would be ~2000 lines of declarative bus plumbing. The event bus pays off
|
||||||
|
* when calls cross processes; in v0.1 everything is single-process.
|
||||||
|
*
|
||||||
|
* When we scale `service-coordinator-ws` to multiple instances (one per N
|
||||||
|
* kiosks), we'll graduate the hot-path operations (bundle lookup, label
|
||||||
|
* filter) to typed returnable events and keep the rest as direct calls.
|
||||||
|
*
|
||||||
|
* To-then: emit a domain-event broadcast on every write so listeners
|
||||||
|
* (e.g. coordinator-ws notifying kiosks of bundle changes) can react.
|
||||||
|
*/
|
||||||
|
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
import {
|
||||||
|
BSBService,
|
||||||
|
type BSBServiceConstructor,
|
||||||
|
createConfigSchema,
|
||||||
|
createEventSchemas,
|
||||||
|
createBroadcastEvent,
|
||||||
|
type Observable,
|
||||||
|
} from "@bsb/base";
|
||||||
|
|
||||||
|
import { MIGRATIONS } from "./migrations.js";
|
||||||
|
import { Repository } from "./repository.js";
|
||||||
|
|
||||||
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfigSchema = av.object(
|
||||||
|
{
|
||||||
|
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Config = createConfigSchema(
|
||||||
|
{
|
||||||
|
name: "service-store",
|
||||||
|
description:
|
||||||
|
"BetterFrame canonical SQLite store. The single writer in the system; " +
|
||||||
|
"all other services read/write through this plugin.",
|
||||||
|
tags: ["service", "store", "sqlite"],
|
||||||
|
},
|
||||||
|
ConfigSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Event schemas ----------------------------------------------------------
|
||||||
|
|
||||||
|
const broadcastDomainChange = av.object(
|
||||||
|
{
|
||||||
|
table: av.string(),
|
||||||
|
op: av.enum_(["create", "update", "delete"] as const),
|
||||||
|
id: av.optional(av.union([av.string(), av.int()])),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EventSchemas = createEventSchemas({
|
||||||
|
emitEvents: {},
|
||||||
|
onEvents: {},
|
||||||
|
emitReturnableEvents: {},
|
||||||
|
onReturnableEvents: {},
|
||||||
|
emitBroadcast: {
|
||||||
|
"store.changed": createBroadcastEvent(broadcastDomainChange, "Domain row changed"),
|
||||||
|
},
|
||||||
|
onBroadcast: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
||||||
|
static override Config = Config;
|
||||||
|
static override EventSchemas = EventSchemas;
|
||||||
|
|
||||||
|
initBeforePlugins?: string[];
|
||||||
|
initAfterPlugins?: string[];
|
||||||
|
runBeforePlugins?: string[];
|
||||||
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
|
// The DB handle and Repository are created in init() and exposed for
|
||||||
|
// sibling-service consumption.
|
||||||
|
private db?: DatabaseSync;
|
||||||
|
private _repo?: Repository;
|
||||||
|
|
||||||
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
|
super(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(obs: Observable): Promise<void> {
|
||||||
|
const path = this.config.sqlitePath;
|
||||||
|
obs.log.info("opening sqlite at {path}", { path });
|
||||||
|
|
||||||
|
// Ensure parent dir exists (in dev BETTERFRAME_DATA_DIR may be in $HOME)
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
obs.log.warn("mkdir failed for {dir}: {err}", {
|
||||||
|
dir: dirname(path),
|
||||||
|
err: (err as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db = new DatabaseSync(path);
|
||||||
|
|
||||||
|
// SQLite pragmas for an embedded one-writer setup
|
||||||
|
this.db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||||
|
this.db.exec("PRAGMA foreign_keys = ON");
|
||||||
|
this.db.exec("PRAGMA busy_timeout = 10000");
|
||||||
|
|
||||||
|
obs.log.info("running {n} migrations", { n: MIGRATIONS.length });
|
||||||
|
for (const stmt of MIGRATIONS) {
|
||||||
|
this.db.exec(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._repo = new Repository(this.db, async (table, op, id) => {
|
||||||
|
// Best-effort broadcast — never let a failed event-bus call fail a write.
|
||||||
|
try {
|
||||||
|
await this.events.emitBroadcast("store.changed", obs, { table, op, id });
|
||||||
|
} catch (err) {
|
||||||
|
obs.log.warn("broadcast store.changed failed: {err}", {
|
||||||
|
err: (err as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
obs.log.info("store ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(_obs: Observable): Promise<void> {
|
||||||
|
// Long-lived; no work in run().
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
this.db?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public accessor for sibling services. Throws before init() completes —
|
||||||
|
* services that need the repo should declare their initAfterPlugins
|
||||||
|
* dependency on `service-store`.
|
||||||
|
*/
|
||||||
|
get repo(): Repository {
|
||||||
|
if (!this._repo) {
|
||||||
|
throw new Error("service-store: repository accessed before init()");
|
||||||
|
}
|
||||||
|
return this._repo;
|
||||||
|
}
|
||||||
|
}
|
||||||
267
server/src/plugins/service-store/mappers.ts
Normal file
267
server/src/plugins/service-store/mappers.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
/**
|
||||||
|
* Row-to-domain mappers. Pure functions, no DB access.
|
||||||
|
*
|
||||||
|
* Every mapper accepts `unknown` (because node:sqlite returns row objects
|
||||||
|
* typed as Record<string, SqliteValue>) and returns a fully-typed domain
|
||||||
|
* object from shared/types.ts.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ApiKey,
|
||||||
|
ApiKeyScope,
|
||||||
|
Camera,
|
||||||
|
CameraStream,
|
||||||
|
CameraType,
|
||||||
|
CellContentType,
|
||||||
|
DesiredPowerState,
|
||||||
|
Display,
|
||||||
|
EventLog,
|
||||||
|
EventSourceType,
|
||||||
|
Kiosk,
|
||||||
|
KioskLabel,
|
||||||
|
Label,
|
||||||
|
LabelRole,
|
||||||
|
Layout,
|
||||||
|
LayoutCell,
|
||||||
|
LayoutPriority,
|
||||||
|
LayoutRegion,
|
||||||
|
LayoutTemplate,
|
||||||
|
PairingCode,
|
||||||
|
Session,
|
||||||
|
SetupState,
|
||||||
|
StreamPolicy,
|
||||||
|
StreamRole,
|
||||||
|
StreamSelector,
|
||||||
|
User,
|
||||||
|
UserRole,
|
||||||
|
} from "../../shared/types.js";
|
||||||
|
import { b, j } from "./util.js";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
const s = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||||
|
const sn = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
||||||
|
const n = (v: unknown): number => (typeof v === "number" ? v : Number(v) || 0);
|
||||||
|
const nn = (v: unknown): number | null =>
|
||||||
|
v === null || v === undefined ? null : typeof v === "number" ? v : Number(v) || null;
|
||||||
|
|
||||||
|
export function rowToUser(r: Row): User {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
username: s(r["username"]),
|
||||||
|
password_hash: s(r["password_hash"]),
|
||||||
|
role: s(r["role"]) as UserRole,
|
||||||
|
is_active: b(r["is_active"]),
|
||||||
|
totp_enabled: b(r["totp_enabled"]),
|
||||||
|
totp_secret_encrypted: sn(r["totp_secret_encrypted"]),
|
||||||
|
recovery_codes_hashed: j<string[]>(r["recovery_codes_hashed"], []),
|
||||||
|
must_change_password: b(r["must_change_password"]),
|
||||||
|
failed_login_count: n(r["failed_login_count"]),
|
||||||
|
locked_until: sn(r["locked_until"]),
|
||||||
|
last_login_at: sn(r["last_login_at"]),
|
||||||
|
created_at: s(r["created_at"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToSession(r: Row): Session {
|
||||||
|
return {
|
||||||
|
id: s(r["id"]),
|
||||||
|
user_id: n(r["user_id"]),
|
||||||
|
csrf_token: s(r["csrf_token"]),
|
||||||
|
totp_pending: b(r["totp_pending"]),
|
||||||
|
user_agent: sn(r["user_agent"]),
|
||||||
|
ip_address: sn(r["ip_address"]),
|
||||||
|
issued_at: s(r["issued_at"]),
|
||||||
|
last_seen_at: s(r["last_seen_at"]),
|
||||||
|
expires_at: s(r["expires_at"]),
|
||||||
|
revoked_at: sn(r["revoked_at"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToApiKey(r: Row): ApiKey {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
name: s(r["name"]),
|
||||||
|
key_hash: s(r["key_hash"]),
|
||||||
|
key_prefix: s(r["key_prefix"]),
|
||||||
|
scopes: j<ApiKeyScope[]>(r["scopes"], []),
|
||||||
|
expires_at: sn(r["expires_at"]),
|
||||||
|
last_used_at: sn(r["last_used_at"]),
|
||||||
|
last_used_ip: sn(r["last_used_ip"]),
|
||||||
|
created_at: s(r["created_at"]),
|
||||||
|
revoked_at: sn(r["revoked_at"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToSetupState(r: Row): SetupState {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
is_complete: b(r["is_complete"]),
|
||||||
|
cluster_key_provisioned: b(r["cluster_key_provisioned"]),
|
||||||
|
nodered_flows_deployed: b(r["nodered_flows_deployed"]),
|
||||||
|
completed_at: sn(r["completed_at"]),
|
||||||
|
extras: j<Record<string, unknown>>(r["extras"], {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToDisplay(r: Row): Display {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
name: s(r["name"]),
|
||||||
|
index: n(r["index"]),
|
||||||
|
is_primary: b(r["is_primary"]),
|
||||||
|
width_px: n(r["width_px"]),
|
||||||
|
height_px: n(r["height_px"]),
|
||||||
|
default_layout_id: nn(r["default_layout_id"]),
|
||||||
|
idle_timeout_seconds: n(r["idle_timeout_seconds"]),
|
||||||
|
sleep_timeout_seconds: n(r["sleep_timeout_seconds"]),
|
||||||
|
cec_enabled: b(r["cec_enabled"]),
|
||||||
|
cec_device_path: sn(r["cec_device_path"]),
|
||||||
|
cec_logical_address: nn(r["cec_logical_address"]),
|
||||||
|
desired_power_state: s(r["desired_power_state"]) as DesiredPowerState,
|
||||||
|
state_check_enabled: b(r["state_check_enabled"]),
|
||||||
|
state_check_interval_seconds: n(r["state_check_interval_seconds"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToCamera(r: Row): Camera {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
name: s(r["name"]),
|
||||||
|
type: s(r["type"]) as CameraType,
|
||||||
|
rtsp_url: sn(r["rtsp_url"]),
|
||||||
|
onvif_host: sn(r["onvif_host"]),
|
||||||
|
onvif_port: nn(r["onvif_port"]),
|
||||||
|
onvif_username: sn(r["onvif_username"]),
|
||||||
|
onvif_password: sn(r["onvif_password"]),
|
||||||
|
capabilities: j<string[]>(r["capabilities"], []),
|
||||||
|
stream_policy: s(r["stream_policy"]) as StreamPolicy,
|
||||||
|
enabled: b(r["enabled"]),
|
||||||
|
last_seen_at: sn(r["last_seen_at"]),
|
||||||
|
created_at: s(r["created_at"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToCameraStream(r: Row): CameraStream {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
camera_id: n(r["camera_id"]),
|
||||||
|
role: s(r["role"]) as StreamRole,
|
||||||
|
name: s(r["name"]),
|
||||||
|
profile_token: sn(r["profile_token"]),
|
||||||
|
rtsp_uri: s(r["rtsp_uri"]),
|
||||||
|
width: nn(r["width"]),
|
||||||
|
height: nn(r["height"]),
|
||||||
|
encoding: sn(r["encoding"]),
|
||||||
|
framerate: nn(r["framerate"]),
|
||||||
|
bitrate_kbps: nn(r["bitrate_kbps"]),
|
||||||
|
is_discovered: b(r["is_discovered"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToLayoutTemplate(r: Row): LayoutTemplate {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
name: s(r["name"]),
|
||||||
|
description: sn(r["description"]),
|
||||||
|
regions: j<LayoutRegion[]>(r["regions"], []),
|
||||||
|
grid_cols: n(r["grid_cols"]),
|
||||||
|
grid_rows: n(r["grid_rows"]),
|
||||||
|
is_builtin: b(r["is_builtin"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToLayout(r: Row): Layout {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
name: s(r["name"]),
|
||||||
|
description: sn(r["description"]),
|
||||||
|
template_id: n(r["template_id"]),
|
||||||
|
display_id: n(r["display_id"]),
|
||||||
|
priority: s(r["priority"]) as LayoutPriority,
|
||||||
|
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
||||||
|
preload_camera_ids: j<number[]>(r["preload_camera_ids"], []),
|
||||||
|
is_default: b(r["is_default"]),
|
||||||
|
resets_idle_timer: b(r["resets_idle_timer"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToLayoutCell(r: Row): LayoutCell {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
layout_id: n(r["layout_id"]),
|
||||||
|
region_name: s(r["region_name"]),
|
||||||
|
content_type: s(r["content_type"]) as CellContentType,
|
||||||
|
camera_id: nn(r["camera_id"]),
|
||||||
|
stream_selector: s(r["stream_selector"]) as StreamSelector,
|
||||||
|
web_url: sn(r["web_url"]),
|
||||||
|
html_content: sn(r["html_content"]),
|
||||||
|
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
||||||
|
options: j<Record<string, unknown>>(r["options"], {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToKiosk(r: Row): Kiosk {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
name: s(r["name"]),
|
||||||
|
description: sn(r["description"]),
|
||||||
|
key_hash: s(r["key_hash"]),
|
||||||
|
key_prefix: s(r["key_prefix"]),
|
||||||
|
capabilities: j<string[]>(r["capabilities"], []),
|
||||||
|
hardware_model: sn(r["hardware_model"]),
|
||||||
|
os_version: sn(r["os_version"]),
|
||||||
|
kiosk_app_version: sn(r["kiosk_app_version"]),
|
||||||
|
enabled: b(r["enabled"]),
|
||||||
|
paired_at: sn(r["paired_at"]),
|
||||||
|
last_seen_at: sn(r["last_seen_at"]),
|
||||||
|
last_bundle_version: sn(r["last_bundle_version"]),
|
||||||
|
display_id: nn(r["display_id"]),
|
||||||
|
created_at: s(r["created_at"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToLabel(r: Row): Label {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
name: s(r["name"]),
|
||||||
|
description: sn(r["description"]),
|
||||||
|
color: sn(r["color"]),
|
||||||
|
created_at: s(r["created_at"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToKioskLabel(r: Row): KioskLabel {
|
||||||
|
return {
|
||||||
|
kiosk_id: n(r["kiosk_id"]),
|
||||||
|
label_id: n(r["label_id"]),
|
||||||
|
role: s(r["role"]) as LabelRole,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToPairingCode(r: Row): PairingCode {
|
||||||
|
return {
|
||||||
|
code: s(r["code"]),
|
||||||
|
kiosk_proposed_name: sn(r["kiosk_proposed_name"]),
|
||||||
|
kiosk_hardware_model: sn(r["kiosk_hardware_model"]),
|
||||||
|
kiosk_capabilities: j<string[]>(r["kiosk_capabilities"], []),
|
||||||
|
issued_at: s(r["issued_at"]),
|
||||||
|
expires_at: s(r["expires_at"]),
|
||||||
|
consumed_at: sn(r["consumed_at"]),
|
||||||
|
consumed_by_kiosk_id: nn(r["consumed_by_kiosk_id"]),
|
||||||
|
extras: j<Record<string, unknown>>(r["extras"], {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToEventLog(r: Row): EventLog {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
source_kiosk_id: nn(r["source_kiosk_id"]),
|
||||||
|
source_camera_id: nn(r["source_camera_id"]),
|
||||||
|
source_type: s(r["source_type"]) as EventSourceType,
|
||||||
|
topic: s(r["topic"]),
|
||||||
|
property_op: sn(r["property_op"]),
|
||||||
|
payload: j<Record<string, unknown>>(r["payload"], {}),
|
||||||
|
received_at: s(r["received_at"]),
|
||||||
|
forwarded_to_nodered: b(r["forwarded_to_nodered"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
252
server/src/plugins/service-store/migrations.ts
Normal file
252
server/src/plugins/service-store/migrations.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
/**
|
||||||
|
* Database migrations.
|
||||||
|
*
|
||||||
|
* Idempotent — `service-store.init()` runs ALL of these on every startup,
|
||||||
|
* inside one transaction. SQLite tolerates `IF NOT EXISTS` everywhere we
|
||||||
|
* need it. When schemas change non-additively, we'll graduate to a real
|
||||||
|
* versioned migrator; for v0.1 this is sufficient.
|
||||||
|
*
|
||||||
|
* NOTE on datetimes: stored as TEXT in ISO-8601 UTC ("YYYY-MM-DDTHH:MM:SS.sssZ").
|
||||||
|
* Application code uses `new Date().toISOString()` for writes and
|
||||||
|
* `new Date(value)` for reads. No tz-aware datetime gotcha because TEXT is
|
||||||
|
* pure string round-trip. (Old python build hit a pain point with
|
||||||
|
* SQLAlchemy's DateTime adapter — we avoid the whole class of issue here.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MIGRATIONS: readonly string[] = [
|
||||||
|
// ---- users ---------------------------------------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'operator' CHECK(role IN ('admin', 'operator')),
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
totp_secret_encrypted TEXT,
|
||||||
|
recovery_codes_hashed TEXT NOT NULL DEFAULT '[]',
|
||||||
|
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_login_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
locked_until TEXT,
|
||||||
|
last_login_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
// ---- sessions ------------------------------------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
csrf_token TEXT NOT NULL,
|
||||||
|
totp_pending INTEGER NOT NULL DEFAULT 0,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
issued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_sessions_active
|
||||||
|
ON sessions(expires_at)
|
||||||
|
WHERE revoked_at IS NULL`,
|
||||||
|
|
||||||
|
// ---- api_keys ------------------------------------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
scopes TEXT NOT NULL DEFAULT '[]',
|
||||||
|
expires_at TEXT,
|
||||||
|
last_used_at TEXT,
|
||||||
|
last_used_ip TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
revoked_at TEXT
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix)`,
|
||||||
|
|
||||||
|
// ---- setup_state (singleton row, id=1) -----------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS setup_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||||
|
is_complete INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cluster_key_provisioned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
nodered_flows_deployed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
completed_at TEXT,
|
||||||
|
extras TEXT NOT NULL DEFAULT '{}'
|
||||||
|
) STRICT`,
|
||||||
|
`INSERT OR IGNORE INTO setup_state (id) VALUES (1)`,
|
||||||
|
|
||||||
|
// ---- displays ------------------------------------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS displays (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
"index" INTEGER NOT NULL UNIQUE,
|
||||||
|
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||||
|
width_px INTEGER NOT NULL DEFAULT 1920,
|
||||||
|
height_px INTEGER NOT NULL DEFAULT 1080,
|
||||||
|
default_layout_id INTEGER,
|
||||||
|
idle_timeout_seconds INTEGER NOT NULL DEFAULT 600,
|
||||||
|
sleep_timeout_seconds INTEGER NOT NULL DEFAULT 1800,
|
||||||
|
cec_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
cec_device_path TEXT,
|
||||||
|
cec_logical_address INTEGER,
|
||||||
|
desired_power_state TEXT NOT NULL DEFAULT 'follow_layout'
|
||||||
|
CHECK(desired_power_state IN ('follow_layout', 'on', 'standby')),
|
||||||
|
state_check_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state_check_interval_seconds INTEGER NOT NULL DEFAULT 60
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
// ---- cameras -------------------------------------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS cameras (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('rtsp', 'onvif')),
|
||||||
|
rtsp_url TEXT,
|
||||||
|
onvif_host TEXT,
|
||||||
|
onvif_port INTEGER,
|
||||||
|
onvif_username TEXT,
|
||||||
|
onvif_password TEXT,
|
||||||
|
capabilities TEXT NOT NULL DEFAULT '[]',
|
||||||
|
stream_policy TEXT NOT NULL DEFAULT 'auto'
|
||||||
|
CHECK(stream_policy IN ('auto', 'always_main', 'always_sub')),
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS camera_streams (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('main', 'sub', 'other')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
profile_token TEXT,
|
||||||
|
rtsp_uri TEXT NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
encoding TEXT,
|
||||||
|
framerate REAL,
|
||||||
|
bitrate_kbps INTEGER,
|
||||||
|
is_discovered INTEGER NOT NULL DEFAULT 0
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_camera_streams_camera ON camera_streams(camera_id)`,
|
||||||
|
|
||||||
|
// ---- layout templates + layouts + cells ----------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS layout_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
regions TEXT NOT NULL DEFAULT '[]',
|
||||||
|
grid_cols INTEGER NOT NULL DEFAULT 12,
|
||||||
|
grid_rows INTEGER NOT NULL DEFAULT 12,
|
||||||
|
is_builtin INTEGER NOT NULL DEFAULT 0
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS layouts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
template_id INTEGER NOT NULL REFERENCES layout_templates(id),
|
||||||
|
display_id INTEGER NOT NULL REFERENCES displays(id),
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('hot', 'normal', 'cold')),
|
||||||
|
cooling_timeout_seconds INTEGER,
|
||||||
|
preload_camera_ids TEXT NOT NULL DEFAULT '[]',
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
resets_idle_timer INTEGER NOT NULL DEFAULT 1
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS layout_cells (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
||||||
|
region_name TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL CHECK(content_type IN ('camera', 'web', 'html')),
|
||||||
|
camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
|
||||||
|
stream_selector TEXT NOT NULL DEFAULT 'auto'
|
||||||
|
CHECK(stream_selector IN ('auto', 'main', 'sub')),
|
||||||
|
web_url TEXT,
|
||||||
|
html_content TEXT,
|
||||||
|
cooling_timeout_seconds INTEGER,
|
||||||
|
options TEXT NOT NULL DEFAULT '{}'
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_layout_cells_layout ON layout_cells(layout_id)`,
|
||||||
|
|
||||||
|
// ---- kiosks --------------------------------------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS kiosks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
capabilities TEXT NOT NULL DEFAULT '[]',
|
||||||
|
hardware_model TEXT,
|
||||||
|
os_version TEXT,
|
||||||
|
kiosk_app_version TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
paired_at TEXT,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
last_bundle_version TEXT,
|
||||||
|
display_id INTEGER REFERENCES displays(id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_kiosks_prefix ON kiosks(key_prefix)`,
|
||||||
|
|
||||||
|
// ---- labels --------------------------------------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS labels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
color TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS kiosk_labels (
|
||||||
|
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
||||||
|
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('consume', 'operate')),
|
||||||
|
PRIMARY KEY (kiosk_id, label_id, role)
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS camera_labels (
|
||||||
|
camera_id INTEGER NOT NULL REFERENCES cameras(id) ON DELETE CASCADE,
|
||||||
|
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (camera_id, label_id)
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS layout_labels (
|
||||||
|
layout_id INTEGER NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
|
||||||
|
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (layout_id, label_id)
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
// ---- pairing_codes -------------------------------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS pairing_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
kiosk_proposed_name TEXT,
|
||||||
|
kiosk_hardware_model TEXT,
|
||||||
|
kiosk_capabilities TEXT NOT NULL DEFAULT '[]',
|
||||||
|
issued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
consumed_at TEXT,
|
||||||
|
consumed_by_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
|
||||||
|
extras TEXT NOT NULL DEFAULT '{}'
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
// ---- event_log -----------------------------------------------------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS event_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_kiosk_id INTEGER REFERENCES kiosks(id) ON DELETE SET NULL,
|
||||||
|
source_camera_id INTEGER REFERENCES cameras(id) ON DELETE SET NULL,
|
||||||
|
source_type TEXT NOT NULL CHECK(source_type IN ('onvif', 'gpio', 'synthetic', 'system')),
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
property_op TEXT,
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
forwarded_to_nodered INTEGER NOT NULL DEFAULT 0
|
||||||
|
) STRICT`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_event_log_received ON event_log(received_at DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_event_log_topic ON event_log(topic, received_at DESC)`,
|
||||||
|
];
|
||||||
803
server/src/plugins/service-store/repository.ts
Normal file
803
server/src/plugins/service-store/repository.ts
Normal file
|
|
@ -0,0 +1,803 @@
|
||||||
|
/**
|
||||||
|
* Repository — typed accessor over the sqlite handle.
|
||||||
|
*
|
||||||
|
* Keeps prepared statements cached for the life of the connection. All
|
||||||
|
* mutating methods invoke the `notify` callback with (table, op, id) so the
|
||||||
|
* surrounding plugin can broadcast a `store.changed` event.
|
||||||
|
*
|
||||||
|
* NOT THREAD SAFE — node:sqlite is single-threaded, and so is Node. Don't
|
||||||
|
* cross workers with the same handle.
|
||||||
|
*/
|
||||||
|
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ApiKey,
|
||||||
|
ApiKeyScope,
|
||||||
|
Camera,
|
||||||
|
CameraStream,
|
||||||
|
CameraType,
|
||||||
|
Display,
|
||||||
|
EventLog,
|
||||||
|
EventSourceType,
|
||||||
|
Kiosk,
|
||||||
|
KioskLabel,
|
||||||
|
Label,
|
||||||
|
LabelRole,
|
||||||
|
Layout,
|
||||||
|
LayoutCell,
|
||||||
|
LayoutTemplate,
|
||||||
|
PairingCode,
|
||||||
|
Session,
|
||||||
|
SetupState,
|
||||||
|
StreamPolicy,
|
||||||
|
StreamRole,
|
||||||
|
User,
|
||||||
|
UserRole,
|
||||||
|
} from "../../shared/types.js";
|
||||||
|
import {
|
||||||
|
rowToApiKey,
|
||||||
|
rowToCamera,
|
||||||
|
rowToCameraStream,
|
||||||
|
rowToDisplay,
|
||||||
|
rowToEventLog,
|
||||||
|
rowToKiosk,
|
||||||
|
rowToLabel,
|
||||||
|
rowToLayout,
|
||||||
|
rowToLayoutCell,
|
||||||
|
rowToLayoutTemplate,
|
||||||
|
rowToPairingCode,
|
||||||
|
rowToSession,
|
||||||
|
rowToSetupState,
|
||||||
|
rowToUser,
|
||||||
|
} from "./mappers.js";
|
||||||
|
import { B, J, isoIn, isoNow, j } from "./util.js";
|
||||||
|
|
||||||
|
type NotifyFn = (
|
||||||
|
table: string,
|
||||||
|
op: "create" | "update" | "delete",
|
||||||
|
id?: string | number,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
export class Repository {
|
||||||
|
private readonly db: DatabaseSync;
|
||||||
|
private readonly notify: NotifyFn;
|
||||||
|
private readonly stmts = new Map<string, StatementSync>();
|
||||||
|
|
||||||
|
constructor(db: DatabaseSync, notify: NotifyFn) {
|
||||||
|
this.db = db;
|
||||||
|
this.notify = notify;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached prepared statements. */
|
||||||
|
private prep(sql: string): StatementSync {
|
||||||
|
let s = this.stmts.get(sql);
|
||||||
|
if (!s) {
|
||||||
|
s = this.db.prepare(sql);
|
||||||
|
this.stmts.set(sql, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ad-hoc transaction. */
|
||||||
|
transact<T>(fn: () => T): T {
|
||||||
|
this.db.exec("BEGIN");
|
||||||
|
try {
|
||||||
|
const out = fn();
|
||||||
|
this.db.exec("COMMIT");
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
this.db.exec("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// setup_state
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
getSetupState(): SetupState {
|
||||||
|
const r = this.prep("SELECT * FROM setup_state WHERE id = 1").get();
|
||||||
|
if (!r) throw new Error("setup_state row missing");
|
||||||
|
return rowToSetupState(r as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSetupComplete(): boolean {
|
||||||
|
return this.getSetupState().is_complete && this.countUsers() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
markSetupComplete(): void {
|
||||||
|
this.prep(
|
||||||
|
`UPDATE setup_state
|
||||||
|
SET is_complete = 1,
|
||||||
|
completed_at = COALESCE(completed_at, ?)
|
||||||
|
WHERE id = 1`,
|
||||||
|
).run(isoNow());
|
||||||
|
void this.notify("setup_state", "update", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSetupExtra(key: string, value: unknown): void {
|
||||||
|
const cur = this.getSetupState().extras;
|
||||||
|
cur[key] = value;
|
||||||
|
this.prep("UPDATE setup_state SET extras = ? WHERE id = 1").run(J(cur));
|
||||||
|
}
|
||||||
|
|
||||||
|
getSetupExtra(key: string): unknown {
|
||||||
|
return this.getSetupState().extras[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
markClusterKeyProvisioned(): void {
|
||||||
|
this.prep(
|
||||||
|
"UPDATE setup_state SET cluster_key_provisioned = 1 WHERE id = 1",
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// users
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
countUsers(): number {
|
||||||
|
const r = this.prep("SELECT COUNT(*) AS c FROM users").get() as
|
||||||
|
| { c: number }
|
||||||
|
| undefined;
|
||||||
|
return r?.c ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserById(id: number): User | null {
|
||||||
|
const r = this.prep("SELECT * FROM users WHERE id = ?").get(id);
|
||||||
|
return r ? rowToUser(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserByUsername(username: string): User | null {
|
||||||
|
const r = this.prep("SELECT * FROM users WHERE username = ?").get(username);
|
||||||
|
return r ? rowToUser(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createUser(input: {
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
role?: UserRole;
|
||||||
|
must_change_password?: boolean;
|
||||||
|
}): User {
|
||||||
|
const role: UserRole = input.role ?? "operator";
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO users (username, password_hash, role, is_active, must_change_password)
|
||||||
|
VALUES (?, ?, ?, 1, ?)`,
|
||||||
|
).run(
|
||||||
|
input.username,
|
||||||
|
input.password_hash,
|
||||||
|
role,
|
||||||
|
B(Boolean(input.must_change_password)),
|
||||||
|
);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("users", "create", id);
|
||||||
|
const u = this.getUserById(id);
|
||||||
|
if (!u) throw new Error("user vanished after insert");
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUser(id: number, patch: Partial<User>): void {
|
||||||
|
const cols: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
if ("password_hash" in patch) {
|
||||||
|
cols.push("password_hash = ?");
|
||||||
|
vals.push(patch.password_hash);
|
||||||
|
}
|
||||||
|
if ("totp_enabled" in patch) {
|
||||||
|
cols.push("totp_enabled = ?");
|
||||||
|
vals.push(B(Boolean(patch.totp_enabled)));
|
||||||
|
}
|
||||||
|
if ("totp_secret_encrypted" in patch) {
|
||||||
|
cols.push("totp_secret_encrypted = ?");
|
||||||
|
vals.push(patch.totp_secret_encrypted);
|
||||||
|
}
|
||||||
|
if ("recovery_codes_hashed" in patch) {
|
||||||
|
cols.push("recovery_codes_hashed = ?");
|
||||||
|
vals.push(J(patch.recovery_codes_hashed));
|
||||||
|
}
|
||||||
|
if ("must_change_password" in patch) {
|
||||||
|
cols.push("must_change_password = ?");
|
||||||
|
vals.push(B(Boolean(patch.must_change_password)));
|
||||||
|
}
|
||||||
|
if ("failed_login_count" in patch) {
|
||||||
|
cols.push("failed_login_count = ?");
|
||||||
|
vals.push(patch.failed_login_count);
|
||||||
|
}
|
||||||
|
if ("locked_until" in patch) {
|
||||||
|
cols.push("locked_until = ?");
|
||||||
|
vals.push(patch.locked_until);
|
||||||
|
}
|
||||||
|
if ("last_login_at" in patch) {
|
||||||
|
cols.push("last_login_at = ?");
|
||||||
|
vals.push(patch.last_login_at);
|
||||||
|
}
|
||||||
|
if ("is_active" in patch) {
|
||||||
|
cols.push("is_active = ?");
|
||||||
|
vals.push(B(Boolean(patch.is_active)));
|
||||||
|
}
|
||||||
|
if (cols.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
this.db.prepare(`UPDATE users SET ${cols.join(", ")} WHERE id = ?`).run(...(vals as never[]));
|
||||||
|
void this.notify("users", "update", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// sessions
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
createSession(input: {
|
||||||
|
id: string;
|
||||||
|
user_id: number;
|
||||||
|
csrf_token: string;
|
||||||
|
totp_pending: boolean;
|
||||||
|
user_agent: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
expires_at: string; // absolute
|
||||||
|
}): Session {
|
||||||
|
this.prep(
|
||||||
|
`INSERT INTO sessions
|
||||||
|
(id, user_id, csrf_token, totp_pending, user_agent, ip_address, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.id,
|
||||||
|
input.user_id,
|
||||||
|
input.csrf_token,
|
||||||
|
B(input.totp_pending),
|
||||||
|
input.user_agent,
|
||||||
|
input.ip_address,
|
||||||
|
input.expires_at,
|
||||||
|
);
|
||||||
|
const s = this.getSessionById(input.id);
|
||||||
|
if (!s) throw new Error("session vanished after insert");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionById(id: string): Session | null {
|
||||||
|
const r = this.prep("SELECT * FROM sessions WHERE id = ?").get(id);
|
||||||
|
return r ? rowToSession(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchSession(id: string, lastSeenAt: string): void {
|
||||||
|
this.prep("UPDATE sessions SET last_seen_at = ? WHERE id = ?").run(
|
||||||
|
lastSeenAt,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionTotpPending(id: string, pending: boolean): void {
|
||||||
|
this.prep("UPDATE sessions SET totp_pending = ? WHERE id = ?").run(
|
||||||
|
B(pending),
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeSession(id: string): void {
|
||||||
|
this.prep("UPDATE sessions SET revoked_at = ? WHERE id = ?").run(isoNow(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeAllSessionsForUser(userId: number): void {
|
||||||
|
this.prep(
|
||||||
|
`UPDATE sessions SET revoked_at = ?
|
||||||
|
WHERE user_id = ? AND revoked_at IS NULL`,
|
||||||
|
).run(isoNow(), userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// api_keys
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
createApiKey(input: {
|
||||||
|
name: string;
|
||||||
|
key_hash: string;
|
||||||
|
key_prefix: string;
|
||||||
|
scopes: ApiKeyScope[];
|
||||||
|
expires_at: string | null;
|
||||||
|
}): ApiKey {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO api_keys (name, key_hash, key_prefix, scopes, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.name,
|
||||||
|
input.key_hash,
|
||||||
|
input.key_prefix,
|
||||||
|
J(input.scopes),
|
||||||
|
input.expires_at,
|
||||||
|
);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("api_keys", "create", id);
|
||||||
|
const k = this.getApiKeyById(id);
|
||||||
|
if (!k) throw new Error("api_key vanished after insert");
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
getApiKeyById(id: number): ApiKey | null {
|
||||||
|
const r = this.prep("SELECT * FROM api_keys WHERE id = ?").get(id);
|
||||||
|
return r ? rowToApiKey(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lookup all candidates for a given prefix (typically returns 0 or 1). */
|
||||||
|
listApiKeysByPrefix(prefix: string): ApiKey[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
"SELECT * FROM api_keys WHERE key_prefix = ? AND revoked_at IS NULL",
|
||||||
|
).all(prefix);
|
||||||
|
return rs.map((r) => rowToApiKey(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
touchApiKey(id: number, ip: string | null): void {
|
||||||
|
this.prep(
|
||||||
|
"UPDATE api_keys SET last_used_at = ?, last_used_ip = ? WHERE id = ?",
|
||||||
|
).run(isoNow(), ip, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// displays
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
listDisplays(): Display[] {
|
||||||
|
const rs = this.prep('SELECT * FROM displays ORDER BY "index"').all();
|
||||||
|
return rs.map((r) => rowToDisplay(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayById(id: number): Display | null {
|
||||||
|
const r = this.prep("SELECT * FROM displays WHERE id = ?").get(id);
|
||||||
|
return r ? rowToDisplay(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createDefaultDisplay(): Display {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO displays (name, "index", is_primary)
|
||||||
|
VALUES ('primary', 0, 1)`,
|
||||||
|
).run();
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("displays", "create", id);
|
||||||
|
const d = this.getDisplayById(id);
|
||||||
|
if (!d) throw new Error("display vanished after insert");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// cameras
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
listCameras(): Camera[] {
|
||||||
|
const rs = this.prep("SELECT * FROM cameras ORDER BY name").all();
|
||||||
|
return rs.map((r) => rowToCamera(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCameraById(id: number): Camera | null {
|
||||||
|
const r = this.prep("SELECT * FROM cameras WHERE id = ?").get(id);
|
||||||
|
return r ? rowToCamera(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCameraByName(name: string): Camera | null {
|
||||||
|
const r = this.prep("SELECT * FROM cameras WHERE name = ?").get(name);
|
||||||
|
return r ? rowToCamera(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createCamera(input: {
|
||||||
|
name: string;
|
||||||
|
type: CameraType;
|
||||||
|
rtsp_url?: string | null;
|
||||||
|
onvif_host?: string | null;
|
||||||
|
onvif_port?: number | null;
|
||||||
|
onvif_username?: string | null;
|
||||||
|
onvif_password?: string | null; // already-encrypted ciphertext
|
||||||
|
capabilities?: string[];
|
||||||
|
stream_policy?: StreamPolicy;
|
||||||
|
}): Camera {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO cameras
|
||||||
|
(name, type, rtsp_url, onvif_host, onvif_port, onvif_username,
|
||||||
|
onvif_password, capabilities, stream_policy)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.name,
|
||||||
|
input.type,
|
||||||
|
input.rtsp_url ?? null,
|
||||||
|
input.onvif_host ?? null,
|
||||||
|
input.onvif_port ?? null,
|
||||||
|
input.onvif_username ?? null,
|
||||||
|
input.onvif_password ?? null,
|
||||||
|
J(input.capabilities ?? []),
|
||||||
|
input.stream_policy ?? "auto",
|
||||||
|
);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("cameras", "create", id);
|
||||||
|
const c = this.getCameraById(id);
|
||||||
|
if (!c) throw new Error("camera vanished after insert");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
listCameraStreams(cameraId: number): CameraStream[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
"SELECT * FROM camera_streams WHERE camera_id = ?",
|
||||||
|
).all(cameraId);
|
||||||
|
return rs.map((r) => rowToCameraStream(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
createCameraStream(input: {
|
||||||
|
camera_id: number;
|
||||||
|
role: StreamRole;
|
||||||
|
name: string;
|
||||||
|
rtsp_uri: string;
|
||||||
|
profile_token?: string | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
encoding?: string | null;
|
||||||
|
framerate?: number | null;
|
||||||
|
bitrate_kbps?: number | null;
|
||||||
|
is_discovered?: boolean;
|
||||||
|
}): CameraStream {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO camera_streams
|
||||||
|
(camera_id, role, name, profile_token, rtsp_uri, width, height,
|
||||||
|
encoding, framerate, bitrate_kbps, is_discovered)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.camera_id,
|
||||||
|
input.role,
|
||||||
|
input.name,
|
||||||
|
input.profile_token ?? null,
|
||||||
|
input.rtsp_uri,
|
||||||
|
input.width ?? null,
|
||||||
|
input.height ?? null,
|
||||||
|
input.encoding ?? null,
|
||||||
|
input.framerate ?? null,
|
||||||
|
input.bitrate_kbps ?? null,
|
||||||
|
B(Boolean(input.is_discovered)),
|
||||||
|
);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
const r = this.prep("SELECT * FROM camera_streams WHERE id = ?").get(id);
|
||||||
|
if (!r) throw new Error("camera_stream vanished after insert");
|
||||||
|
void this.notify("camera_streams", "create", id);
|
||||||
|
return rowToCameraStream(r as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// labels (incl. join tables)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
listLabels(): Label[] {
|
||||||
|
const rs = this.prep("SELECT * FROM labels ORDER BY name").all();
|
||||||
|
return rs.map((r) => rowToLabel(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
getLabelByName(name: string): Label | null {
|
||||||
|
const r = this.prep("SELECT * FROM labels WHERE name = ?").get(name);
|
||||||
|
return r ? rowToLabel(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createLabel(input: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
}): Label {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO labels (name, description, color)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
).run(input.name, input.description ?? null, input.color ?? null);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("labels", "create", id);
|
||||||
|
const r = this.prep("SELECT * FROM labels WHERE id = ?").get(id);
|
||||||
|
if (!r) throw new Error("label vanished after insert");
|
||||||
|
return rowToLabel(r as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get-or-create label by name (used during pairing's free-text label input). */
|
||||||
|
ensureLabel(name: string): Label {
|
||||||
|
return this.getLabelByName(name) ?? this.createLabel({ name });
|
||||||
|
}
|
||||||
|
|
||||||
|
attachKioskLabel(kioskId: number, labelId: number, role: LabelRole): void {
|
||||||
|
this.prep(
|
||||||
|
`INSERT OR IGNORE INTO kiosk_labels (kiosk_id, label_id, role)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
).run(kioskId, labelId, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
listKioskLabels(kioskId: number): Array<KioskLabel & { name: string }> {
|
||||||
|
const rs = this.prep(
|
||||||
|
`SELECT kl.kiosk_id, kl.label_id, kl.role, l.name
|
||||||
|
FROM kiosk_labels kl
|
||||||
|
JOIN labels l ON l.id = kl.label_id
|
||||||
|
WHERE kl.kiosk_id = ?`,
|
||||||
|
).all(kioskId);
|
||||||
|
return rs.map((r) => {
|
||||||
|
const row = r as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
kiosk_id: Number(row["kiosk_id"]),
|
||||||
|
label_id: Number(row["label_id"]),
|
||||||
|
role: String(row["role"]) as LabelRole,
|
||||||
|
name: String(row["name"]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
attachCameraLabel(cameraId: number, labelId: number): void {
|
||||||
|
this.prep(
|
||||||
|
`INSERT OR IGNORE INTO camera_labels (camera_id, label_id)
|
||||||
|
VALUES (?, ?)`,
|
||||||
|
).run(cameraId, labelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
attachLayoutLabel(layoutId: number, labelId: number): void {
|
||||||
|
this.prep(
|
||||||
|
`INSERT OR IGNORE INTO layout_labels (layout_id, label_id)
|
||||||
|
VALUES (?, ?)`,
|
||||||
|
).run(layoutId, labelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// kiosks
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
listKiosks(): Kiosk[] {
|
||||||
|
const rs = this.prep("SELECT * FROM kiosks ORDER BY name").all();
|
||||||
|
return rs.map((r) => rowToKiosk(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
getKioskById(id: number): Kiosk | null {
|
||||||
|
const r = this.prep("SELECT * FROM kiosks WHERE id = ?").get(id);
|
||||||
|
return r ? rowToKiosk(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getKioskByName(name: string): Kiosk | null {
|
||||||
|
const r = this.prep("SELECT * FROM kiosks WHERE name = ?").get(name);
|
||||||
|
return r ? rowToKiosk(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lookup candidates by Bearer-key prefix; verify hash at the call site. */
|
||||||
|
listKiosksByKeyPrefix(prefix: string): Kiosk[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
"SELECT * FROM kiosks WHERE key_prefix = ? AND enabled = 1",
|
||||||
|
).all(prefix);
|
||||||
|
return rs.map((r) => rowToKiosk(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
createKiosk(input: {
|
||||||
|
name: string;
|
||||||
|
key_hash: string;
|
||||||
|
key_prefix: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
hardware_model?: string | null;
|
||||||
|
}): Kiosk {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO kiosks
|
||||||
|
(name, key_hash, key_prefix, capabilities, hardware_model, paired_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.name,
|
||||||
|
input.key_hash,
|
||||||
|
input.key_prefix,
|
||||||
|
J(input.capabilities ?? []),
|
||||||
|
input.hardware_model ?? null,
|
||||||
|
isoNow(),
|
||||||
|
);
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
void this.notify("kiosks", "create", id);
|
||||||
|
const k = this.getKioskById(id);
|
||||||
|
if (!k) throw new Error("kiosk vanished after insert");
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchKiosk(
|
||||||
|
id: number,
|
||||||
|
patch: {
|
||||||
|
bundle_version?: string | null;
|
||||||
|
kiosk_app_version?: string | null;
|
||||||
|
os_version?: string | null;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
this.prep(
|
||||||
|
`UPDATE kiosks SET
|
||||||
|
last_seen_at = ?,
|
||||||
|
last_bundle_version = COALESCE(?, last_bundle_version),
|
||||||
|
kiosk_app_version = COALESCE(?, kiosk_app_version),
|
||||||
|
os_version = COALESCE(?, os_version)
|
||||||
|
WHERE id = ?`,
|
||||||
|
).run(
|
||||||
|
isoNow(),
|
||||||
|
patch.bundle_version ?? null,
|
||||||
|
patch.kiosk_app_version ?? null,
|
||||||
|
patch.os_version ?? null,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// pairing_codes
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
createPairingCode(input: {
|
||||||
|
code: string;
|
||||||
|
kiosk_proposed_name: string | null;
|
||||||
|
kiosk_hardware_model: string | null;
|
||||||
|
kiosk_capabilities: string[];
|
||||||
|
expires_at: string;
|
||||||
|
extras: Record<string, unknown>;
|
||||||
|
}): PairingCode {
|
||||||
|
this.prep(
|
||||||
|
`INSERT INTO pairing_codes
|
||||||
|
(code, kiosk_proposed_name, kiosk_hardware_model, kiosk_capabilities,
|
||||||
|
expires_at, extras)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.code,
|
||||||
|
input.kiosk_proposed_name,
|
||||||
|
input.kiosk_hardware_model,
|
||||||
|
J(input.kiosk_capabilities),
|
||||||
|
input.expires_at,
|
||||||
|
J(input.extras),
|
||||||
|
);
|
||||||
|
const r = this.prep("SELECT * FROM pairing_codes WHERE code = ?").get(input.code);
|
||||||
|
if (!r) throw new Error("pairing_code vanished after insert");
|
||||||
|
return rowToPairingCode(r as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPairingCode(code: string): PairingCode | null {
|
||||||
|
const r = this.prep("SELECT * FROM pairing_codes WHERE code = ?").get(code);
|
||||||
|
return r ? rowToPairingCode(r as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
listPendingPairingCodes(): PairingCode[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
`SELECT * FROM pairing_codes
|
||||||
|
WHERE consumed_at IS NULL AND expires_at > ?
|
||||||
|
ORDER BY issued_at DESC`,
|
||||||
|
).all(isoNow());
|
||||||
|
return rs.map((r) => rowToPairingCode(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
markPairingCodeClaimed(
|
||||||
|
code: string,
|
||||||
|
kioskId: number,
|
||||||
|
extras: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
this.prep(
|
||||||
|
`UPDATE pairing_codes
|
||||||
|
SET consumed_at = ?,
|
||||||
|
consumed_by_kiosk_id = ?,
|
||||||
|
extras = ?
|
||||||
|
WHERE code = ?`,
|
||||||
|
).run(isoNow(), kioskId, J(extras), code);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePairingCodeExtras(code: string, extras: Record<string, unknown>): void {
|
||||||
|
this.prep("UPDATE pairing_codes SET extras = ? WHERE code = ?").run(
|
||||||
|
J(extras),
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// event_log
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
insertEvent(input: {
|
||||||
|
source_kiosk_id: number | null;
|
||||||
|
source_camera_id: number | null;
|
||||||
|
source_type: EventSourceType;
|
||||||
|
topic: string;
|
||||||
|
property_op: string | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
forwarded_to_nodered: boolean;
|
||||||
|
}): number {
|
||||||
|
const result = this.prep(
|
||||||
|
`INSERT INTO event_log
|
||||||
|
(source_kiosk_id, source_camera_id, source_type, topic,
|
||||||
|
property_op, payload, forwarded_to_nodered)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.source_kiosk_id,
|
||||||
|
input.source_camera_id,
|
||||||
|
input.source_type,
|
||||||
|
input.topic,
|
||||||
|
input.property_op,
|
||||||
|
J(input.payload),
|
||||||
|
B(input.forwarded_to_nodered),
|
||||||
|
);
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
recentEvents(limit = 10): EventLog[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
"SELECT * FROM event_log ORDER BY received_at DESC LIMIT ?",
|
||||||
|
).all(limit);
|
||||||
|
return rs.map((r) => rowToEventLog(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// bundle queries (label-aware composite reads)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns label IDs + names attached to a kiosk by role.
|
||||||
|
* Used by `service-bundle` to scope a kiosk's view of the world.
|
||||||
|
*/
|
||||||
|
bundleScope(kioskId: number): {
|
||||||
|
labelIds: number[];
|
||||||
|
labelNames: string[];
|
||||||
|
operateLabelIds: number[];
|
||||||
|
operateLabelNames: string[];
|
||||||
|
} {
|
||||||
|
const all = this.listKioskLabels(kioskId);
|
||||||
|
const labelIds: number[] = [];
|
||||||
|
const labelNames: string[] = [];
|
||||||
|
const operateLabelIds: number[] = [];
|
||||||
|
const operateLabelNames: string[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
for (const kl of all) {
|
||||||
|
if (!seen.has(kl.label_id)) {
|
||||||
|
seen.add(kl.label_id);
|
||||||
|
labelIds.push(kl.label_id);
|
||||||
|
labelNames.push(kl.name);
|
||||||
|
}
|
||||||
|
if (kl.role === "operate") {
|
||||||
|
operateLabelIds.push(kl.label_id);
|
||||||
|
operateLabelNames.push(kl.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { labelIds, labelNames, operateLabelIds, operateLabelNames };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cameras whose label set intersects the given label IDs. */
|
||||||
|
camerasForLabelIds(labelIds: number[]): Camera[] {
|
||||||
|
if (labelIds.length === 0) return [];
|
||||||
|
const placeholders = labelIds.map(() => "?").join(",");
|
||||||
|
const rs = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT c.* FROM cameras c
|
||||||
|
JOIN camera_labels cl ON cl.camera_id = c.id
|
||||||
|
WHERE cl.label_id IN (${placeholders})
|
||||||
|
AND c.enabled = 1
|
||||||
|
ORDER BY c.name`,
|
||||||
|
)
|
||||||
|
.all(...(labelIds as never[]));
|
||||||
|
return rs.map((r) => rowToCamera(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutsForLabelIds(labelIds: number[]): Layout[] {
|
||||||
|
if (labelIds.length === 0) return [];
|
||||||
|
const placeholders = labelIds.map(() => "?").join(",");
|
||||||
|
const rs = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT l.* FROM layouts l
|
||||||
|
JOIN layout_labels ll ON ll.layout_id = l.id
|
||||||
|
WHERE ll.label_id IN (${placeholders})
|
||||||
|
ORDER BY l.name`,
|
||||||
|
)
|
||||||
|
.all(...(labelIds as never[]));
|
||||||
|
return rs.map((r) => rowToLayout(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutCells(layoutId: number): LayoutCell[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
"SELECT * FROM layout_cells WHERE layout_id = ?",
|
||||||
|
).all(layoutId);
|
||||||
|
return rs.map((r) => rowToLayoutCell(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutTemplates(ids: number[]): LayoutTemplate[] {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const placeholders = ids.map(() => "?").join(",");
|
||||||
|
const rs = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM layout_templates WHERE id IN (${placeholders})`,
|
||||||
|
)
|
||||||
|
.all(...(ids as never[]));
|
||||||
|
return rs.map((r) => rowToLayoutTemplate(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraLabelNames(cameraId: number): string[] {
|
||||||
|
const rs = this.prep(
|
||||||
|
`SELECT l.name FROM camera_labels cl
|
||||||
|
JOIN labels l ON l.id = cl.label_id
|
||||||
|
WHERE cl.camera_id = ?`,
|
||||||
|
).all(cameraId);
|
||||||
|
return rs.map((r) => String((r as Record<string, unknown>)["name"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
server/src/plugins/service-store/util.ts
Normal file
43
server/src/plugins/service-store/util.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* SQLite row → TS object mapping helpers.
|
||||||
|
*
|
||||||
|
* SQLite booleans are stored as INTEGER 0/1 — convert with `b()`.
|
||||||
|
* JSON columns are stored as TEXT — parse with `j()` / serialize with `J()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function b(value: unknown): boolean {
|
||||||
|
return value === 1 || value === true || value === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function B(value: boolean): 0 | 1 {
|
||||||
|
return value ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function j<T>(value: unknown, fallback: T): T {
|
||||||
|
if (typeof value !== "string" || value.length === 0) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function J(value: unknown): string {
|
||||||
|
return JSON.stringify(value ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add `n` seconds to `now()` and return ISO. */
|
||||||
|
export function isoIn(seconds: number): string {
|
||||||
|
return new Date(Date.now() + seconds * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare two ISO strings as UTC datetimes. -1, 0, 1. */
|
||||||
|
export function isoCmp(a: string, b: string): -1 | 0 | 1 {
|
||||||
|
if (a < b) return -1;
|
||||||
|
if (a > b) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
31
server/src/schemas/forms/account.ts
Normal file
31
server/src/schemas/forms/account.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* Form schemas for the account-management pages.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
|
||||||
|
export const passwordChangeForm = av.object(
|
||||||
|
{
|
||||||
|
current_password: av.string().minLength(1).maxLength(256),
|
||||||
|
new_password: av.string().minLength(12).maxLength(256),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const totpConfirmForm = av.object(
|
||||||
|
{
|
||||||
|
enrollment_id: av.string().minLength(1).maxLength(64),
|
||||||
|
code: av.string().pattern("^\\d{6}$"),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const totpDisableForm = av.object(
|
||||||
|
{
|
||||||
|
password: av.string().minLength(1).maxLength(256),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PasswordChangeForm = av.Infer<typeof passwordChangeForm>;
|
||||||
|
export type TotpConfirmForm = av.Infer<typeof totpConfirmForm>;
|
||||||
|
export type TotpDisableForm = av.Infer<typeof totpDisableForm>;
|
||||||
55
server/src/schemas/forms/admin.ts
Normal file
55
server/src/schemas/forms/admin.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* Form schemas for camera + kiosk admin pages.
|
||||||
|
*
|
||||||
|
* Camera-create is a discriminated union on `type`. anyvali's `union`
|
||||||
|
* picks first match, with the `literal` field as the discriminant.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
|
||||||
|
const labelName = av.string().minLength(1).maxLength(64).pattern("^[a-z0-9][a-z0-9_-]*$");
|
||||||
|
|
||||||
|
const cameraCreateRtsp = av.object(
|
||||||
|
{
|
||||||
|
name: av.string().minLength(1).maxLength(128),
|
||||||
|
type: av.literal("rtsp"),
|
||||||
|
rtsp_url: av.string().minLength(1).maxLength(1024),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const cameraCreateOnvif = av.object(
|
||||||
|
{
|
||||||
|
name: av.string().minLength(1).maxLength(128),
|
||||||
|
type: av.literal("onvif"),
|
||||||
|
onvif_host: av.string().minLength(1).maxLength(255),
|
||||||
|
onvif_port: av.optional(av.int().min(1).max(65535)),
|
||||||
|
onvif_username: av.optional(av.string().maxLength(128)),
|
||||||
|
onvif_password: av.optional(av.string().maxLength(256)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const cameraCreateForm = av.union([cameraCreateRtsp, cameraCreateOnvif]);
|
||||||
|
|
||||||
|
export const kioskPairConfirmForm = av.object(
|
||||||
|
{
|
||||||
|
code: av.string().pattern("^[A-HJ-NP-Z2-9]{8}$"),
|
||||||
|
name_override: av.optional(av.string().minLength(1).maxLength(128)),
|
||||||
|
/** Comma-separated label names. The handler splits on commas. */
|
||||||
|
initial_labels: av.optional(av.string().maxLength(1024)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const labelCreateForm = av.object(
|
||||||
|
{
|
||||||
|
name: labelName,
|
||||||
|
description: av.optional(av.string().maxLength(256)),
|
||||||
|
color: av.optional(av.string().maxLength(16)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CameraCreateForm = av.Infer<typeof cameraCreateForm>;
|
||||||
|
export type KioskPairConfirmForm = av.Infer<typeof kioskPairConfirmForm>;
|
||||||
|
export type LabelCreateForm = av.Infer<typeof labelCreateForm>;
|
||||||
45
server/src/schemas/forms/auth.ts
Normal file
45
server/src/schemas/forms/auth.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Form schemas for the auth flow.
|
||||||
|
*
|
||||||
|
* Server: parsed from x-www-form-urlencoded body before any DB access.
|
||||||
|
* Browser: same schemas drive HTML5 attributes via @anyvali/js/forms.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
|
||||||
|
const usernamePattern = "^[a-zA-Z0-9_-]+$";
|
||||||
|
|
||||||
|
export const setupForm = av.object(
|
||||||
|
{
|
||||||
|
username: av.string().minLength(3).maxLength(64).pattern(usernamePattern),
|
||||||
|
password: av.string().minLength(12).maxLength(256),
|
||||||
|
password_confirm: av.string().minLength(12).maxLength(256),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const loginForm = av.object(
|
||||||
|
{
|
||||||
|
username: av.string().minLength(1).maxLength(64),
|
||||||
|
password: av.string().minLength(1).maxLength(256),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const totpForm = av.object(
|
||||||
|
{
|
||||||
|
code: av.string().pattern("^\\d{6}$"),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const recoveryForm = av.object(
|
||||||
|
{
|
||||||
|
code: av.string().minLength(6).maxLength(20),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SetupForm = av.Infer<typeof setupForm>;
|
||||||
|
export type LoginForm = av.Infer<typeof loginForm>;
|
||||||
|
export type TotpForm = av.Infer<typeof totpForm>;
|
||||||
|
export type RecoveryForm = av.Infer<typeof recoveryForm>;
|
||||||
68
server/src/schemas/index.ts
Normal file
68
server/src/schemas/index.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* Schema registry. Single source of truth for which schemas exist and what
|
||||||
|
* they're called when exported to /schemas/<key>.av.json.
|
||||||
|
*
|
||||||
|
* Keys use dotted namespaces:
|
||||||
|
* wire.* cross-language wire contracts (kiosk, node-red consume these)
|
||||||
|
* forms.* HTML form bodies (browser + server consume these)
|
||||||
|
*
|
||||||
|
* Add new schemas here and run `npm run schemas:export`.
|
||||||
|
*/
|
||||||
|
import type { BaseSchema } from "@anyvali/js";
|
||||||
|
|
||||||
|
import { passwordChangeForm, totpConfirmForm, totpDisableForm } from "./forms/account.js";
|
||||||
|
import {
|
||||||
|
cameraCreateForm,
|
||||||
|
kioskPairConfirmForm,
|
||||||
|
labelCreateForm,
|
||||||
|
} from "./forms/admin.js";
|
||||||
|
import { loginForm, recoveryForm, setupForm, totpForm } from "./forms/auth.js";
|
||||||
|
import { kioskBundle } from "./wire/bundle.js";
|
||||||
|
import {
|
||||||
|
kioskEvent,
|
||||||
|
kioskEventResponse,
|
||||||
|
kioskHeartbeat,
|
||||||
|
kioskHeartbeatResponse,
|
||||||
|
} from "./wire/events.js";
|
||||||
|
import {
|
||||||
|
pairClaimRequest,
|
||||||
|
pairClaimResponse,
|
||||||
|
pairInitiateRequest,
|
||||||
|
pairInitiateResponse,
|
||||||
|
} from "./wire/pairing.js";
|
||||||
|
|
||||||
|
// `BaseSchema<unknown, any>` so heterogeneous schemas fit in one map.
|
||||||
|
// We never read .Output through the registry — handlers import the named
|
||||||
|
// schema directly when they care about types.
|
||||||
|
type AnySchema = BaseSchema<unknown, unknown>;
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
// Forms
|
||||||
|
"forms.setup": setupForm as AnySchema,
|
||||||
|
"forms.login": loginForm as AnySchema,
|
||||||
|
"forms.totp": totpForm as AnySchema,
|
||||||
|
"forms.recovery": recoveryForm as AnySchema,
|
||||||
|
"forms.password_change": passwordChangeForm as AnySchema,
|
||||||
|
"forms.totp_confirm": totpConfirmForm as AnySchema,
|
||||||
|
"forms.totp_disable": totpDisableForm as AnySchema,
|
||||||
|
"forms.camera_create": cameraCreateForm as AnySchema,
|
||||||
|
"forms.kiosk_pair_confirm": kioskPairConfirmForm as AnySchema,
|
||||||
|
"forms.label_create": labelCreateForm as AnySchema,
|
||||||
|
|
||||||
|
// Wire
|
||||||
|
"wire.pair_initiate": pairInitiateRequest as AnySchema,
|
||||||
|
"wire.pair_initiate_response": pairInitiateResponse as AnySchema,
|
||||||
|
"wire.pair_claim": pairClaimRequest as AnySchema,
|
||||||
|
"wire.pair_claim_response": pairClaimResponse as AnySchema,
|
||||||
|
"wire.kiosk_bundle": kioskBundle as AnySchema,
|
||||||
|
"wire.kiosk_heartbeat": kioskHeartbeat as AnySchema,
|
||||||
|
"wire.kiosk_heartbeat_response": kioskHeartbeatResponse as AnySchema,
|
||||||
|
"wire.kiosk_event": kioskEvent as AnySchema,
|
||||||
|
"wire.kiosk_event_response": kioskEventResponse as AnySchema,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SchemaKey = keyof typeof schemas;
|
||||||
|
|
||||||
|
export function getSchema(key: SchemaKey): AnySchema {
|
||||||
|
return schemas[key];
|
||||||
|
}
|
||||||
130
server/src/schemas/wire/bundle.ts
Normal file
130
server/src/schemas/wire/bundle.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
/**
|
||||||
|
* Wire schema for the kiosk bundle response.
|
||||||
|
*
|
||||||
|
* GET /api/kiosk/bundle (kiosk-key auth) → this. Contains everything the kiosk
|
||||||
|
* needs to operate offline. Camera passwords are cluster-encrypted before
|
||||||
|
* being placed here; the kiosk decrypts using `cluster_key` it received during
|
||||||
|
* pairing.
|
||||||
|
*
|
||||||
|
* Cross-language: imported by the Rust kiosk to populate its in-memory
|
||||||
|
* configuration. Schema drift will fail loud — `unknownKeys: "reject"`.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
|
||||||
|
const cameraType = av.enum_(["rtsp", "onvif"] as const);
|
||||||
|
const streamRole = av.enum_(["main", "sub", "other"] as const);
|
||||||
|
const streamSelector = av.enum_(["auto", "main", "sub"] as const);
|
||||||
|
const layoutPriority = av.enum_(["hot", "normal", "cold"] as const);
|
||||||
|
const cellContentType = av.enum_(["camera", "web", "html"] as const);
|
||||||
|
|
||||||
|
const cameraStream = av.object(
|
||||||
|
{
|
||||||
|
id: av.int().min(1),
|
||||||
|
role: streamRole,
|
||||||
|
name: av.string().minLength(1).maxLength(64),
|
||||||
|
rtsp_uri: av.string().minLength(1),
|
||||||
|
width: av.optional(av.int().min(1).max(8192)),
|
||||||
|
height: av.optional(av.int().min(1).max(8192)),
|
||||||
|
encoding: av.optional(av.string().maxLength(32)),
|
||||||
|
framerate: av.optional(av.number().min(0)),
|
||||||
|
bitrate_kbps: av.optional(av.int().min(0)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const onvifInfo = av.object(
|
||||||
|
{
|
||||||
|
host: av.string().minLength(1).maxLength(255),
|
||||||
|
port: av.int().min(1).max(65535),
|
||||||
|
username: av.nullable(av.string().maxLength(128)),
|
||||||
|
password_cluster_encrypted: av.nullable(av.string()),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const camera = av.object(
|
||||||
|
{
|
||||||
|
id: av.int().min(1),
|
||||||
|
name: av.string().minLength(1).maxLength(128),
|
||||||
|
type: cameraType,
|
||||||
|
labels: av.array(av.string()),
|
||||||
|
should_operate: av.bool(),
|
||||||
|
rtsp_url: av.nullable(av.string()),
|
||||||
|
stream_policy: av.enum_(["auto", "always_main", "always_sub"] as const),
|
||||||
|
onvif: av.nullable(onvifInfo),
|
||||||
|
streams: av.array(cameraStream),
|
||||||
|
capabilities: av.array(av.string()),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const layoutTemplate = av.object(
|
||||||
|
{
|
||||||
|
id: av.int().min(1),
|
||||||
|
name: av.string().minLength(1).maxLength(128),
|
||||||
|
regions: av.array(
|
||||||
|
av.object(
|
||||||
|
{
|
||||||
|
name: av.string().minLength(1).maxLength(64),
|
||||||
|
row: av.int().min(0).max(11),
|
||||||
|
col: av.int().min(0).max(11),
|
||||||
|
rowSpan: av.int().min(1).max(12),
|
||||||
|
colSpan: av.int().min(1).max(12),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
grid_cols: av.int().min(1).max(64),
|
||||||
|
grid_rows: av.int().min(1).max(64),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const layoutCell = av.object(
|
||||||
|
{
|
||||||
|
region_name: av.string().minLength(1).maxLength(64),
|
||||||
|
content_type: cellContentType,
|
||||||
|
camera_id: av.nullable(av.int().min(1)),
|
||||||
|
stream_selector: streamSelector,
|
||||||
|
web_url: av.nullable(av.string()),
|
||||||
|
html_content: av.nullable(av.string()),
|
||||||
|
cooling_timeout_seconds: av.nullable(av.int().min(0)),
|
||||||
|
options: av.record(av.unknown()),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const layout = av.object(
|
||||||
|
{
|
||||||
|
id: av.int().min(1),
|
||||||
|
name: av.string().minLength(1).maxLength(128),
|
||||||
|
template_id: av.int().min(1),
|
||||||
|
display_id: av.int().min(1),
|
||||||
|
priority: layoutPriority,
|
||||||
|
cooling_timeout_seconds: av.nullable(av.int().min(0)),
|
||||||
|
preload_camera_ids: av.array(av.int().min(1)),
|
||||||
|
is_default: av.bool(),
|
||||||
|
resets_idle_timer: av.bool(),
|
||||||
|
cells: av.array(layoutCell),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const kioskBundle = av.object(
|
||||||
|
{
|
||||||
|
kiosk_id: av.int().min(1),
|
||||||
|
kiosk_name: av.string().minLength(1).maxLength(128),
|
||||||
|
labels: av.array(av.string()),
|
||||||
|
operate_labels: av.array(av.string()),
|
||||||
|
cameras: av.array(camera),
|
||||||
|
templates: av.array(layoutTemplate),
|
||||||
|
layouts: av.array(layout),
|
||||||
|
version: av.string().minLength(1).maxLength(64),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type KioskBundle = av.Infer<typeof kioskBundle>;
|
||||||
|
export type BundleCamera = av.Infer<typeof camera>;
|
||||||
|
export type BundleLayout = av.Infer<typeof layout>;
|
||||||
|
export type BundleLayoutCell = av.Infer<typeof layoutCell>;
|
||||||
64
server/src/schemas/wire/events.ts
Normal file
64
server/src/schemas/wire/events.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Wire schemas for kiosk-emitted reports.
|
||||||
|
*
|
||||||
|
* POST /api/kiosk/heartbeat → liveness, version, applied bundle hash
|
||||||
|
* POST /api/kiosk/event → forward a hardware event (ONVIF, GPIO, etc.)
|
||||||
|
*
|
||||||
|
* The server logs all events to event_log and forwards them to Node-RED for
|
||||||
|
* rule processing. Cross-language: imported by Rust kiosk for outbound calls
|
||||||
|
* and by the Node-RED bridge to validate inbound payloads.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
|
||||||
|
export const kioskHeartbeat = av.object(
|
||||||
|
{
|
||||||
|
bundle_version: av.optional(av.string().maxLength(64)),
|
||||||
|
kiosk_app_version: av.optional(av.string().maxLength(64)),
|
||||||
|
os_version: av.optional(av.string().maxLength(128)),
|
||||||
|
uptime_seconds: av.optional(av.int().min(0)),
|
||||||
|
cpu_load: av.optional(av.number().min(0).max(100)),
|
||||||
|
memory_used_mb: av.optional(av.int().min(0)),
|
||||||
|
active_layout_id: av.optional(av.int().min(1)),
|
||||||
|
streams_warm: av.optional(av.int().min(0)),
|
||||||
|
streams_hot: av.optional(av.int().min(0)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "strip" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const eventSourceType = av.enum_(["onvif", "gpio", "synthetic", "system"] as const);
|
||||||
|
|
||||||
|
export const kioskEvent = av.object(
|
||||||
|
{
|
||||||
|
topic: av.string().minLength(1).maxLength(256),
|
||||||
|
source_type: eventSourceType,
|
||||||
|
camera_id: av.optional(av.int().min(1)),
|
||||||
|
property_op: av.optional(av.enum_(["initial", "changed"] as const)),
|
||||||
|
payload: av.record(av.unknown()),
|
||||||
|
occurred_at: av.optional(av.string().format("date-time")),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const kioskHeartbeatResponse = av.object(
|
||||||
|
{
|
||||||
|
ok: av.bool(),
|
||||||
|
now: av.string().format("date-time"),
|
||||||
|
/** If non-null and != current bundle, kiosk should refetch. */
|
||||||
|
bundle_version_current: av.optional(av.string().maxLength(64)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const kioskEventResponse = av.object(
|
||||||
|
{
|
||||||
|
ok: av.bool(),
|
||||||
|
event_id: av.optional(av.int().min(1)),
|
||||||
|
error: av.optional(av.string()),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type KioskHeartbeat = av.Infer<typeof kioskHeartbeat>;
|
||||||
|
export type KioskEvent = av.Infer<typeof kioskEvent>;
|
||||||
|
export type KioskHeartbeatResponse = av.Infer<typeof kioskHeartbeatResponse>;
|
||||||
|
export type KioskEventResponse = av.Infer<typeof kioskEventResponse>;
|
||||||
79
server/src/schemas/wire/pairing.ts
Normal file
79
server/src/schemas/wire/pairing.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* Wire schemas for the kiosk pairing flow.
|
||||||
|
*
|
||||||
|
* Cross-language: imported by the Rust kiosk (`av::import_schema`) and the
|
||||||
|
* Node-RED TypeScript custom nodes. Authored here in TypeScript and exported
|
||||||
|
* as canonical JSON to /schemas/wire.pair_*.av.json.
|
||||||
|
*/
|
||||||
|
import * as av from "@anyvali/js";
|
||||||
|
|
||||||
|
/** Capability strings a kiosk reports. Keep this list in sync with the Rust enum. */
|
||||||
|
export const KIOSK_CAPABILITIES = [
|
||||||
|
"display",
|
||||||
|
"cec",
|
||||||
|
"gpio",
|
||||||
|
"onvif_discovery",
|
||||||
|
"hw_decode",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1: kiosk → server. The unpaired kiosk introduces itself and asks for a
|
||||||
|
* pairing code. Untrusted; rate-limit at the proxy.
|
||||||
|
*/
|
||||||
|
export const pairInitiateRequest = av.object(
|
||||||
|
{
|
||||||
|
proposed_name: av.string().minLength(1).maxLength(128),
|
||||||
|
hardware_model: av.optional(av.string().maxLength(128)),
|
||||||
|
capabilities: av.array(av.enum_(KIOSK_CAPABILITIES)),
|
||||||
|
os_version: av.optional(av.string().maxLength(128)),
|
||||||
|
kiosk_app_version: av.optional(av.string().maxLength(64)),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 response: server → kiosk. Server allocated an 8-character code that
|
||||||
|
* the kiosk should display on its screen for the admin to enter.
|
||||||
|
*/
|
||||||
|
export const pairInitiateResponse = av.object(
|
||||||
|
{
|
||||||
|
code: av.string().pattern("^[A-HJ-NP-Z2-9]{8}$"), // 0/O/1/I excluded
|
||||||
|
expires_at: av.string().format("date-time"),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3: kiosk polls server. Body carries only the code. Three terminal
|
||||||
|
* outcomes:
|
||||||
|
* - 202: still waiting for admin confirmation
|
||||||
|
* - 200 + body: confirmed; the response carries the kiosk_key + cluster_key
|
||||||
|
* - 4xx: unknown / expired / already claimed
|
||||||
|
*/
|
||||||
|
export const pairClaimRequest = av.object(
|
||||||
|
{
|
||||||
|
code: av.string().pattern("^[A-HJ-NP-Z2-9]{8}$"),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 successful response. The kiosk persists `kiosk_key` 0600 and uses it
|
||||||
|
* as the Bearer token for all subsequent requests. `cluster_key` is the shared
|
||||||
|
* symmetric key for camera-password decryption.
|
||||||
|
*/
|
||||||
|
export const pairClaimResponse = av.object(
|
||||||
|
{
|
||||||
|
kiosk_id: av.int().min(1),
|
||||||
|
name: av.string().minLength(1).maxLength(128),
|
||||||
|
kiosk_key: av.string().minLength(32),
|
||||||
|
cluster_key: av.string().minLength(32),
|
||||||
|
bundle_url: av.string().minLength(1),
|
||||||
|
},
|
||||||
|
{ unknownKeys: "reject" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PairInitiateRequest = av.Infer<typeof pairInitiateRequest>;
|
||||||
|
export type PairInitiateResponse = av.Infer<typeof pairInitiateResponse>;
|
||||||
|
export type PairClaimRequest = av.Infer<typeof pairClaimRequest>;
|
||||||
|
export type PairClaimResponse = av.Infer<typeof pairClaimResponse>;
|
||||||
50
server/src/scripts/export-schemas.ts
Normal file
50
server/src/scripts/export-schemas.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Schema export.
|
||||||
|
*
|
||||||
|
* Run: `npm run schemas:export` (after build) — writes every registered
|
||||||
|
* anyvali schema to /schemas/<key>.av.json. The output is portable: Rust kiosk
|
||||||
|
* + Node-RED nodes + the browser (via vendored @anyvali/js) all consume these
|
||||||
|
* exact files.
|
||||||
|
*
|
||||||
|
* Also copies the JSON into server/src/web-static/schemas/ so the browser
|
||||||
|
* fetches them via /static/schemas/<key>.av.json with no extra plumbing.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { schemas, type SchemaKey } from "../schemas/index.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
// __dirname here is server/lib/scripts after build; want the repo root.
|
||||||
|
const REPO_ROOT = join(__dirname, "..", "..", "..");
|
||||||
|
const CANONICAL_DIR = join(REPO_ROOT, "schemas");
|
||||||
|
const STATIC_DIR = join(REPO_ROOT, "server", "src", "web-static", "schemas");
|
||||||
|
|
||||||
|
function freshDir(p: string): void {
|
||||||
|
rmSync(p, { recursive: true, force: true });
|
||||||
|
mkdirSync(p, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportAll(): void {
|
||||||
|
freshDir(CANONICAL_DIR);
|
||||||
|
freshDir(STATIC_DIR);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const key of Object.keys(schemas) as SchemaKey[]) {
|
||||||
|
const doc = schemas[key].export("portable");
|
||||||
|
const json = JSON.stringify(doc, null, 2) + "\n";
|
||||||
|
const file = `${key}.av.json`;
|
||||||
|
writeFileSync(join(CANONICAL_DIR, file), json, "utf-8");
|
||||||
|
writeFileSync(join(STATIC_DIR, file), json, "utf-8");
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Exported ${count} schemas to:`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` ${CANONICAL_DIR}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` ${STATIC_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportAll();
|
||||||
218
server/src/shared/types.ts
Normal file
218
server/src/shared/types.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
/**
|
||||||
|
* Cross-plugin types. Lives outside `plugins/` so any service can import.
|
||||||
|
*
|
||||||
|
* Domain types here mirror the SQL schema. Keep field names snake_case in the
|
||||||
|
* DB, camelCase on the wire/UI. Translation happens in service-store.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UserRole = "admin" | "operator";
|
||||||
|
export type ApiKeyScope = "read" | "control" | "admin";
|
||||||
|
export type CameraType = "rtsp" | "onvif";
|
||||||
|
export type StreamRole = "main" | "sub" | "other";
|
||||||
|
export type StreamSelector = "auto" | "main" | "sub";
|
||||||
|
export type StreamPolicy = "auto" | "always_main" | "always_sub";
|
||||||
|
export type LayoutPriority = "hot" | "normal" | "cold";
|
||||||
|
export type CellContentType = "camera" | "web" | "html";
|
||||||
|
export type DesiredPowerState = "follow_layout" | "on" | "standby";
|
||||||
|
export type LabelRole = "consume" | "operate";
|
||||||
|
export type EventSourceType = "onvif" | "gpio" | "synthetic" | "system";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
role: UserRole;
|
||||||
|
is_active: boolean;
|
||||||
|
totp_enabled: boolean;
|
||||||
|
totp_secret_encrypted: string | null;
|
||||||
|
recovery_codes_hashed: string[]; // each element argon2-hashed
|
||||||
|
must_change_password: boolean;
|
||||||
|
failed_login_count: number;
|
||||||
|
locked_until: string | null; // ISO 8601
|
||||||
|
last_login_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string; // hex32
|
||||||
|
user_id: number;
|
||||||
|
csrf_token: string;
|
||||||
|
totp_pending: boolean;
|
||||||
|
user_agent: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
issued_at: string;
|
||||||
|
last_seen_at: string;
|
||||||
|
expires_at: string; // absolute (30d max)
|
||||||
|
revoked_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
key_hash: string;
|
||||||
|
key_prefix: string; // indexed for O(1) lookup
|
||||||
|
scopes: ApiKeyScope[];
|
||||||
|
expires_at: string | null;
|
||||||
|
last_used_at: string | null;
|
||||||
|
last_used_ip: string | null;
|
||||||
|
created_at: string;
|
||||||
|
revoked_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupState {
|
||||||
|
id: 1;
|
||||||
|
is_complete: boolean;
|
||||||
|
cluster_key_provisioned: boolean;
|
||||||
|
nodered_flows_deployed: boolean;
|
||||||
|
completed_at: string | null;
|
||||||
|
extras: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Display {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
index: number; // unique
|
||||||
|
is_primary: boolean;
|
||||||
|
width_px: number;
|
||||||
|
height_px: number;
|
||||||
|
default_layout_id: number | null;
|
||||||
|
idle_timeout_seconds: number;
|
||||||
|
sleep_timeout_seconds: number;
|
||||||
|
cec_enabled: boolean;
|
||||||
|
cec_device_path: string | null;
|
||||||
|
cec_logical_address: number | null;
|
||||||
|
desired_power_state: DesiredPowerState;
|
||||||
|
state_check_enabled: boolean;
|
||||||
|
state_check_interval_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Camera {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: CameraType;
|
||||||
|
rtsp_url: string | null;
|
||||||
|
onvif_host: string | null;
|
||||||
|
onvif_port: number | null;
|
||||||
|
onvif_username: string | null;
|
||||||
|
onvif_password: string | null; // fernet-encrypted ciphertext
|
||||||
|
capabilities: string[];
|
||||||
|
stream_policy: StreamPolicy;
|
||||||
|
enabled: boolean;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraStream {
|
||||||
|
id: number;
|
||||||
|
camera_id: number;
|
||||||
|
role: StreamRole;
|
||||||
|
name: string;
|
||||||
|
profile_token: string | null;
|
||||||
|
rtsp_uri: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
encoding: string | null;
|
||||||
|
framerate: number | null;
|
||||||
|
bitrate_kbps: number | null;
|
||||||
|
is_discovered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutTemplate {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
regions: LayoutRegion[];
|
||||||
|
grid_cols: number;
|
||||||
|
grid_rows: number;
|
||||||
|
is_builtin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutRegion {
|
||||||
|
name: string;
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
rowSpan: number;
|
||||||
|
colSpan: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Layout {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
template_id: number;
|
||||||
|
display_id: number;
|
||||||
|
priority: LayoutPriority;
|
||||||
|
cooling_timeout_seconds: number | null;
|
||||||
|
preload_camera_ids: number[];
|
||||||
|
is_default: boolean;
|
||||||
|
resets_idle_timer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutCell {
|
||||||
|
id: number;
|
||||||
|
layout_id: number;
|
||||||
|
region_name: string;
|
||||||
|
content_type: CellContentType;
|
||||||
|
camera_id: number | null;
|
||||||
|
stream_selector: StreamSelector;
|
||||||
|
web_url: string | null;
|
||||||
|
html_content: string | null;
|
||||||
|
cooling_timeout_seconds: number | null;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Kiosk {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
key_hash: string;
|
||||||
|
key_prefix: string;
|
||||||
|
capabilities: string[];
|
||||||
|
hardware_model: string | null;
|
||||||
|
os_version: string | null;
|
||||||
|
kiosk_app_version: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
paired_at: string | null;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
last_bundle_version: string | null;
|
||||||
|
display_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Label {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
color: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KioskLabel {
|
||||||
|
kiosk_id: number;
|
||||||
|
label_id: number;
|
||||||
|
role: LabelRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingCode {
|
||||||
|
code: string;
|
||||||
|
kiosk_proposed_name: string | null;
|
||||||
|
kiosk_hardware_model: string | null;
|
||||||
|
kiosk_capabilities: string[];
|
||||||
|
issued_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
consumed_at: string | null;
|
||||||
|
consumed_by_kiosk_id: number | null;
|
||||||
|
extras: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventLog {
|
||||||
|
id: number;
|
||||||
|
source_kiosk_id: number | null;
|
||||||
|
source_camera_id: number | null;
|
||||||
|
source_type: EventSourceType;
|
||||||
|
topic: string;
|
||||||
|
property_op: string | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
received_at: string;
|
||||||
|
forwarded_to_nodered: boolean;
|
||||||
|
}
|
||||||
1
server/src/web-static/anyvali/VERSION
Normal file
1
server/src/web-static/anyvali/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0.2.0
|
||||||
12
server/src/web-static/anyvali/errors.js
Normal file
12
server/src/web-static/anyvali/errors.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
issues;
|
||||||
|
constructor(issues) {
|
||||||
|
const message = issues
|
||||||
|
.map((i) => `[${i.code}] ${i.path.length > 0 ? i.path.join(".") + ": " : ""}${i.message}`)
|
||||||
|
.join("\n");
|
||||||
|
super(message);
|
||||||
|
this.name = "ValidationError";
|
||||||
|
this.issues = issues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=errors.js.map
|
||||||
51
server/src/web-static/anyvali/format/validators.js
Normal file
51
server/src/web-static/anyvali/format/validators.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Email must have at least one dot after the @
|
||||||
|
const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
|
||||||
|
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||||
|
const IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
||||||
|
const IPV6_RE = /^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?::[0-9a-fA-F]{1,4}){1,7}|::)$/;
|
||||||
|
// ISO 8601 date: YYYY-MM-DD
|
||||||
|
const DATE_RE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/;
|
||||||
|
// ISO 8601 date-time: YYYY-MM-DDTHH:MM:SS with optional fractional seconds and timezone
|
||||||
|
const DATETIME_RE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
|
||||||
|
function isValidDate(str) {
|
||||||
|
if (!DATE_RE.test(str))
|
||||||
|
return false;
|
||||||
|
const [y, m, d] = str.split("-").map(Number);
|
||||||
|
const date = new Date(y, m - 1, d);
|
||||||
|
return (date.getFullYear() === y &&
|
||||||
|
date.getMonth() === m - 1 &&
|
||||||
|
date.getDate() === d);
|
||||||
|
}
|
||||||
|
function isValidDateTime(str) {
|
||||||
|
if (!DATETIME_RE.test(str))
|
||||||
|
return false;
|
||||||
|
// Validate the date portion
|
||||||
|
const datePart = str.substring(0, 10);
|
||||||
|
return isValidDate(datePart);
|
||||||
|
}
|
||||||
|
function isValidUrl(str) {
|
||||||
|
try {
|
||||||
|
const url = new URL(str);
|
||||||
|
// Only accept http and https protocols
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const FORMAT_VALIDATORS = {
|
||||||
|
email: (val) => EMAIL_RE.test(val),
|
||||||
|
url: isValidUrl,
|
||||||
|
uuid: (val) => UUID_RE.test(val),
|
||||||
|
ipv4: (val) => IPV4_RE.test(val),
|
||||||
|
ipv6: (val) => IPV6_RE.test(val),
|
||||||
|
date: isValidDate,
|
||||||
|
"date-time": isValidDateTime,
|
||||||
|
};
|
||||||
|
export function validateFormat(value, format) {
|
||||||
|
const validator = FORMAT_VALIDATORS[format];
|
||||||
|
if (!validator)
|
||||||
|
return true; // unknown formats pass
|
||||||
|
return validator(value);
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=validators.js.map
|
||||||
586
server/src/web-static/anyvali/forms/index.js
Normal file
586
server/src/web-static/anyvali/forms/index.js
Normal file
|
|
@ -0,0 +1,586 @@
|
||||||
|
import { exportSchema } from "../interchange/exporter.js";
|
||||||
|
import { BaseSchema } from "../schemas/base.js";
|
||||||
|
import { importSchema } from "../interchange/importer.js";
|
||||||
|
export function createFormBindings(options) {
|
||||||
|
const doc = normalizeSchemaSource(options.schema);
|
||||||
|
const errorIdPrefix = options.errorIdPrefix ?? "anyvali";
|
||||||
|
return {
|
||||||
|
field(path, attrs = {}) {
|
||||||
|
return {
|
||||||
|
...getFieldAttributes(doc, path, errorIdPrefix),
|
||||||
|
...attrs,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
errorSlot(path, attrs = {}) {
|
||||||
|
return {
|
||||||
|
id: errorIdForPath(path, errorIdPrefix),
|
||||||
|
"data-anyvali-error-for": canonicalizePath(path),
|
||||||
|
"aria-live": "polite",
|
||||||
|
...attrs,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
htmx(config) {
|
||||||
|
const attrs = {};
|
||||||
|
for (const [method, attr] of [
|
||||||
|
["get", "hx-get"],
|
||||||
|
["post", "hx-post"],
|
||||||
|
["put", "hx-put"],
|
||||||
|
["patch", "hx-patch"],
|
||||||
|
["delete", "hx-delete"],
|
||||||
|
]) {
|
||||||
|
const value = config[method];
|
||||||
|
if (value) {
|
||||||
|
attrs[attr] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.target)
|
||||||
|
attrs["hx-target"] = config.target;
|
||||||
|
if (config.swap)
|
||||||
|
attrs["hx-swap"] = config.swap;
|
||||||
|
if (config.trigger)
|
||||||
|
attrs["hx-trigger"] = config.trigger;
|
||||||
|
if (config.select)
|
||||||
|
attrs["hx-select"] = config.select;
|
||||||
|
if (config.confirm)
|
||||||
|
attrs["hx-confirm"] = config.confirm;
|
||||||
|
if (config.include)
|
||||||
|
attrs["hx-include"] = config.include;
|
||||||
|
if (config.encoding)
|
||||||
|
attrs["hx-encoding"] = config.encoding;
|
||||||
|
if (config.ext)
|
||||||
|
attrs["hx-ext"] = config.ext;
|
||||||
|
if (config.indicator)
|
||||||
|
attrs["hx-indicator"] = config.indicator;
|
||||||
|
if (config.pushUrl)
|
||||||
|
attrs["hx-push-url"] = config.pushUrl;
|
||||||
|
if (config.replaceUrl)
|
||||||
|
attrs["hx-replace-url"] = config.replaceUrl;
|
||||||
|
if (config.validate !== undefined) {
|
||||||
|
attrs["hx-validate"] = String(config.validate);
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
},
|
||||||
|
init(target, initOptions = {}) {
|
||||||
|
return initForm(target, {
|
||||||
|
schema: doc,
|
||||||
|
...initOptions,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function initForm(target, options) {
|
||||||
|
const form = resolveForm(target);
|
||||||
|
const doc = normalizeSchemaSource(options.schema);
|
||||||
|
const schema = importSchema(doc);
|
||||||
|
const validateOn = new Set(options.validateOn ?? ["blur", "submit"]);
|
||||||
|
const nativeValidation = options.nativeValidation ?? true;
|
||||||
|
const reportValidity = options.reportValidity ?? true;
|
||||||
|
const useHtmx = options.htmx ?? true;
|
||||||
|
if (nativeValidation) {
|
||||||
|
applyNativeConstraints(form, doc);
|
||||||
|
}
|
||||||
|
const listeners = [];
|
||||||
|
const addListener = (eventTarget, event, handler, options) => {
|
||||||
|
eventTarget.addEventListener(event, handler, options);
|
||||||
|
listeners.push({ target: eventTarget, event, handler, options });
|
||||||
|
};
|
||||||
|
const validateField = (fieldName, shouldReport = false) => {
|
||||||
|
clearFieldState(form, fieldName);
|
||||||
|
const result = schema.safeParse(readFormValues(form, doc));
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldPath = parseFieldPath(fieldName);
|
||||||
|
const issue = firstIssueForField(result.issues, fieldPath);
|
||||||
|
if (issue) {
|
||||||
|
applyFieldIssue(form, fieldName, issue);
|
||||||
|
if (shouldReport) {
|
||||||
|
firstControlForField(form, fieldName)?.reportValidity?.();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldReport) {
|
||||||
|
firstControlForField(form, fieldName)?.reportValidity?.();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const validateFormState = (shouldReport = false) => {
|
||||||
|
clearAllFieldState(form);
|
||||||
|
const result = schema.safeParse(readFormValues(form, doc));
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldNames = fieldNamesForForm(form);
|
||||||
|
for (const fieldName of fieldNames) {
|
||||||
|
const issue = firstIssueForField(result.issues, parseFieldPath(fieldName));
|
||||||
|
if (issue) {
|
||||||
|
applyFieldIssue(form, fieldName, issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldReport) {
|
||||||
|
form.reportValidity?.();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if (validateOn.has("input")) {
|
||||||
|
addListener(form, "input", (event) => {
|
||||||
|
const control = event.target;
|
||||||
|
if (control?.name) {
|
||||||
|
validateField(control.name, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (validateOn.has("change")) {
|
||||||
|
addListener(form, "change", (event) => {
|
||||||
|
const control = event.target;
|
||||||
|
if (control?.name) {
|
||||||
|
validateField(control.name, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (validateOn.has("blur")) {
|
||||||
|
addListener(form, "blur", (event) => {
|
||||||
|
const control = event.target;
|
||||||
|
if (control?.name) {
|
||||||
|
validateField(control.name, reportValidity);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
addListener(form, "submit", (event) => {
|
||||||
|
if (!validateFormState(validateOn.has("submit") && reportValidity)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (useHtmx) {
|
||||||
|
const htmx = globalThis.htmx;
|
||||||
|
if (htmx?.config && reportValidity) {
|
||||||
|
htmx.config.reportValidityOfForms = true;
|
||||||
|
}
|
||||||
|
addListener(form, "htmx:validation:validate", () => {
|
||||||
|
validateFormState(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
document: doc,
|
||||||
|
validate() {
|
||||||
|
return validateFormState(false);
|
||||||
|
},
|
||||||
|
getValues() {
|
||||||
|
return readFormValues(form, doc);
|
||||||
|
},
|
||||||
|
getResult() {
|
||||||
|
return schema.safeParse(readFormValues(form, doc));
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
for (const entry of listeners) {
|
||||||
|
entry.target.removeEventListener(entry.event, entry.handler, entry.options);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function getFieldAttributes(schema, path, errorIdPrefix = "anyvali") {
|
||||||
|
const doc = normalizeSchemaSource(schema);
|
||||||
|
const resolved = resolveFieldSchema(doc, path);
|
||||||
|
const attrs = {
|
||||||
|
name: path,
|
||||||
|
"data-anyvali-path": canonicalizePath(path),
|
||||||
|
"aria-describedby": errorIdForPath(path, errorIdPrefix),
|
||||||
|
};
|
||||||
|
if (!resolved) {
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
const { node } = resolved;
|
||||||
|
const unwrapped = unwrapNode(resolveRefNode(doc, node));
|
||||||
|
const effective = unwrapped.node;
|
||||||
|
if (resolved.required && effective.kind !== "bool") {
|
||||||
|
attrs.required = true;
|
||||||
|
}
|
||||||
|
if (effective.kind === "string") {
|
||||||
|
applyStringAttributes(attrs, effective);
|
||||||
|
}
|
||||||
|
else if (isNumericNode(effective)) {
|
||||||
|
applyNumericAttributes(attrs, effective);
|
||||||
|
}
|
||||||
|
else if (effective.kind === "bool") {
|
||||||
|
attrs.type = "checkbox";
|
||||||
|
}
|
||||||
|
else if (effective.kind === "array") {
|
||||||
|
applyArrayAttributes(attrs, effective, resolved.required);
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
function normalizeSchemaSource(schema) {
|
||||||
|
if (schema instanceof BaseSchema) {
|
||||||
|
return exportSchema(schema);
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
function resolveForm(target) {
|
||||||
|
if (typeof target !== "string") {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
const element = document.querySelector(target);
|
||||||
|
if (!(element instanceof HTMLFormElement)) {
|
||||||
|
throw new Error(`Form not found for selector: ${target}`);
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
function applyNativeConstraints(form, doc) {
|
||||||
|
for (const fieldName of fieldNamesForForm(form)) {
|
||||||
|
const attrs = getFieldAttributes(doc, fieldName);
|
||||||
|
const controls = controlsForField(form, fieldName);
|
||||||
|
for (const control of controls) {
|
||||||
|
for (const [key, value] of Object.entries(attrs)) {
|
||||||
|
if (key === "name" || key === "data-anyvali-path" || key === "aria-describedby") {
|
||||||
|
setControlAttribute(control, key, value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "type" && control instanceof HTMLInputElement) {
|
||||||
|
if (!control.hasAttribute("type") || control.type === "text") {
|
||||||
|
control.type = String(value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "required") {
|
||||||
|
if (!control.hasAttribute("required")) {
|
||||||
|
control.required = Boolean(value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!control.hasAttribute(key)) {
|
||||||
|
setControlAttribute(control, key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setControlAttribute(control, key, value) {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
if (value) {
|
||||||
|
control.setAttribute(key, "");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
control.removeAttribute(key);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
control.setAttribute(key, String(value));
|
||||||
|
}
|
||||||
|
function readFormValues(form, doc) {
|
||||||
|
const root = {};
|
||||||
|
for (const fieldName of fieldNamesForForm(form)) {
|
||||||
|
const controls = controlsForField(form, fieldName);
|
||||||
|
if (controls.length === 0)
|
||||||
|
continue;
|
||||||
|
const resolved = resolveFieldSchema(doc, fieldName);
|
||||||
|
const value = readFieldValue(controls, resolved?.node);
|
||||||
|
if (value === undefined)
|
||||||
|
continue;
|
||||||
|
setPathValue(root, parseFieldPath(fieldName), value);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
function readFieldValue(controls, node) {
|
||||||
|
const first = controls[0];
|
||||||
|
if (!first)
|
||||||
|
return undefined;
|
||||||
|
const effectiveNode = node ? unwrapNode(node).node : undefined;
|
||||||
|
if (first instanceof HTMLSelectElement && first.multiple) {
|
||||||
|
return Array.from(first.selectedOptions).map((option) => option.value);
|
||||||
|
}
|
||||||
|
if (first instanceof HTMLInputElement && first.type === "radio") {
|
||||||
|
const checked = controls.find((control) => control instanceof HTMLInputElement && control.checked);
|
||||||
|
return checked?.value;
|
||||||
|
}
|
||||||
|
if (controls.every((control) => control instanceof HTMLInputElement && control.type === "checkbox")) {
|
||||||
|
if (effectiveNode?.kind === "array") {
|
||||||
|
return controls
|
||||||
|
.filter((control) => control instanceof HTMLInputElement && control.checked)
|
||||||
|
.map((control) => control.value);
|
||||||
|
}
|
||||||
|
const checkbox = first;
|
||||||
|
return checkbox.checked;
|
||||||
|
}
|
||||||
|
if (first instanceof HTMLInputElement && isNumericLikeControl(first, effectiveNode)) {
|
||||||
|
if (first.value === "")
|
||||||
|
return undefined;
|
||||||
|
return first.valueAsNumber;
|
||||||
|
}
|
||||||
|
const raw = first.value;
|
||||||
|
return raw === "" ? undefined : raw;
|
||||||
|
}
|
||||||
|
function isNumericLikeControl(control, node) {
|
||||||
|
if (!node)
|
||||||
|
return control.type === "number";
|
||||||
|
const effectiveNode = unwrapNode(node).node;
|
||||||
|
return control.type === "number" || isNumericNode(effectiveNode);
|
||||||
|
}
|
||||||
|
function validateIssueMessage(issue) {
|
||||||
|
return issue.message || "Invalid value";
|
||||||
|
}
|
||||||
|
function applyFieldIssue(form, fieldName, issue) {
|
||||||
|
const message = validateIssueMessage(issue);
|
||||||
|
for (const control of controlsForField(form, fieldName)) {
|
||||||
|
control.setCustomValidity(message);
|
||||||
|
control.setAttribute("aria-invalid", "true");
|
||||||
|
control.setAttribute("data-anyvali-invalid", "true");
|
||||||
|
}
|
||||||
|
for (const slot of errorSlotsForField(form, fieldName)) {
|
||||||
|
slot.textContent = message;
|
||||||
|
slot.removeAttribute("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearFieldState(form, fieldName) {
|
||||||
|
for (const control of controlsForField(form, fieldName)) {
|
||||||
|
control.setCustomValidity("");
|
||||||
|
control.removeAttribute("aria-invalid");
|
||||||
|
control.removeAttribute("data-anyvali-invalid");
|
||||||
|
}
|
||||||
|
for (const slot of errorSlotsForField(form, fieldName)) {
|
||||||
|
slot.textContent = "";
|
||||||
|
slot.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearAllFieldState(form) {
|
||||||
|
for (const fieldName of fieldNamesForForm(form)) {
|
||||||
|
clearFieldState(form, fieldName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function controlsForField(form, fieldName) {
|
||||||
|
return Array.from(form.querySelectorAll(`[name="${cssEscape(fieldName)}"]`));
|
||||||
|
}
|
||||||
|
function firstControlForField(form, fieldName) {
|
||||||
|
return controlsForField(form, fieldName)[0];
|
||||||
|
}
|
||||||
|
function errorSlotsForField(form, fieldName) {
|
||||||
|
const canonical = canonicalizePath(fieldName);
|
||||||
|
return Array.from(form.querySelectorAll(`[data-anyvali-error-for="${cssEscape(canonical)}"]`));
|
||||||
|
}
|
||||||
|
function fieldNamesForForm(form) {
|
||||||
|
return Array.from(new Set(Array.from(form.elements)
|
||||||
|
.filter((element) => {
|
||||||
|
return (element instanceof HTMLInputElement ||
|
||||||
|
element instanceof HTMLSelectElement ||
|
||||||
|
element instanceof HTMLTextAreaElement);
|
||||||
|
})
|
||||||
|
.map((element) => element.name)
|
||||||
|
.filter(Boolean)));
|
||||||
|
}
|
||||||
|
function resolveFieldSchema(doc, path) {
|
||||||
|
const segments = parseFieldPath(path);
|
||||||
|
let current = doc.root;
|
||||||
|
let required = true;
|
||||||
|
let nullable = false;
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const unwrapped = unwrapNode(resolveRefNode(doc, current));
|
||||||
|
current = unwrapped.node;
|
||||||
|
nullable = nullable || unwrapped.nullable;
|
||||||
|
if (current.kind === "object" && typeof segment === "string") {
|
||||||
|
const objectNode = current;
|
||||||
|
const propertyNode = objectNode.properties[segment];
|
||||||
|
if (!propertyNode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const propertyUnwrapped = unwrapNode(resolveRefNode(doc, propertyNode));
|
||||||
|
required = required && objectNode.required.includes(segment) && !propertyUnwrapped.optional;
|
||||||
|
nullable = nullable || propertyUnwrapped.nullable;
|
||||||
|
current = propertyUnwrapped.node;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current.kind === "record" && typeof segment === "string") {
|
||||||
|
current = resolveRefNode(doc, current.valueSchema);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current.kind === "array") {
|
||||||
|
current = resolveRefNode(doc, current.items);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const unwrapped = unwrapNode(resolveRefNode(doc, current));
|
||||||
|
return {
|
||||||
|
path: segments,
|
||||||
|
canonicalName: formatPath(segments),
|
||||||
|
required: required && !unwrapped.optional,
|
||||||
|
nullable: nullable || unwrapped.nullable,
|
||||||
|
node: unwrapped.node,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function resolveRefNode(doc, node) {
|
||||||
|
let current = node;
|
||||||
|
const seen = new Set();
|
||||||
|
while (current.kind === "ref") {
|
||||||
|
const name = current.ref.replace(/^#\/definitions\//, "");
|
||||||
|
if (seen.has(name)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen.add(name);
|
||||||
|
const resolved = doc.definitions[name];
|
||||||
|
if (!resolved) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = resolved;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
function unwrapNode(node) {
|
||||||
|
let current = node;
|
||||||
|
let optional = false;
|
||||||
|
let nullable = false;
|
||||||
|
while (current.kind === "optional" || current.kind === "nullable") {
|
||||||
|
if (current.kind === "optional") {
|
||||||
|
optional = true;
|
||||||
|
current = current.inner;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nullable = true;
|
||||||
|
current = current.inner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { node: current, optional, nullable };
|
||||||
|
}
|
||||||
|
function applyStringAttributes(attrs, node) {
|
||||||
|
if (attrs.type === undefined) {
|
||||||
|
const type = htmlTypeForStringFormat(node.format);
|
||||||
|
if (type) {
|
||||||
|
attrs.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.minLength !== undefined)
|
||||||
|
attrs.minLength = node.minLength;
|
||||||
|
if (node.maxLength !== undefined)
|
||||||
|
attrs.maxLength = node.maxLength;
|
||||||
|
if (node.pattern !== undefined)
|
||||||
|
attrs.pattern = node.pattern;
|
||||||
|
}
|
||||||
|
function applyNumericAttributes(attrs, node) {
|
||||||
|
if (attrs.type === undefined)
|
||||||
|
attrs.type = "number";
|
||||||
|
if (node.min !== undefined)
|
||||||
|
attrs.min = node.min;
|
||||||
|
if (node.max !== undefined)
|
||||||
|
attrs.max = node.max;
|
||||||
|
if (node.multipleOf !== undefined) {
|
||||||
|
attrs.step = node.multipleOf;
|
||||||
|
}
|
||||||
|
else if (isIntegerKind(node.kind)) {
|
||||||
|
attrs.step = 1;
|
||||||
|
}
|
||||||
|
attrs.inputMode = isIntegerKind(node.kind) ? "numeric" : "decimal";
|
||||||
|
}
|
||||||
|
function applyArrayAttributes(attrs, node, required) {
|
||||||
|
if (required || (node.minItems ?? 0) > 0) {
|
||||||
|
attrs.required = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function htmlTypeForStringFormat(format) {
|
||||||
|
switch (format) {
|
||||||
|
case "email":
|
||||||
|
return "email";
|
||||||
|
case "url":
|
||||||
|
return "url";
|
||||||
|
case "date":
|
||||||
|
return "date";
|
||||||
|
case "date-time":
|
||||||
|
return "datetime-local";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function firstIssueForField(issues, fieldPath) {
|
||||||
|
return issues.find((issue) => isIssueForField(issue, fieldPath));
|
||||||
|
}
|
||||||
|
function isIssueForField(issue, fieldPath) {
|
||||||
|
if (issue.path.length < fieldPath.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fieldPath.every((segment, index) => issue.path[index] === segment);
|
||||||
|
}
|
||||||
|
function parseFieldPath(path) {
|
||||||
|
const segments = [];
|
||||||
|
const matcher = /([^[.\]]+)|\[(.*?)\]/g;
|
||||||
|
for (const match of path.matchAll(matcher)) {
|
||||||
|
const token = match[1] ?? match[2];
|
||||||
|
if (!token)
|
||||||
|
continue;
|
||||||
|
if (/^\d+$/.test(token)) {
|
||||||
|
segments.push(Number(token));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
segments.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
function canonicalizePath(path) {
|
||||||
|
return formatPath(parseFieldPath(path));
|
||||||
|
}
|
||||||
|
function formatPath(path) {
|
||||||
|
return path
|
||||||
|
.map((segment, index) => {
|
||||||
|
if (typeof segment === "number") {
|
||||||
|
return `[${segment}]`;
|
||||||
|
}
|
||||||
|
return index === 0 ? segment : `.${segment}`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
function setPathValue(root, path, value) {
|
||||||
|
if (path.length === 0)
|
||||||
|
return;
|
||||||
|
let current = root;
|
||||||
|
for (let index = 0; index < path.length; index++) {
|
||||||
|
const segment = path[index];
|
||||||
|
const isLast = index === path.length - 1;
|
||||||
|
const key = String(segment);
|
||||||
|
if (isLast) {
|
||||||
|
current[key] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = current[key];
|
||||||
|
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function errorIdForPath(path, prefix) {
|
||||||
|
return `${prefix}-error-${canonicalizePath(path).replace(/[^a-zA-Z0-9_-]+/g, "-")}`;
|
||||||
|
}
|
||||||
|
function cssEscape(value) {
|
||||||
|
const cssApi = globalThis.CSS;
|
||||||
|
if (cssApi?.escape) {
|
||||||
|
return cssApi.escape(value);
|
||||||
|
}
|
||||||
|
return value.replace(/["\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
function isNumericNode(node) {
|
||||||
|
return [
|
||||||
|
"number",
|
||||||
|
"int",
|
||||||
|
"float32",
|
||||||
|
"float64",
|
||||||
|
"int8",
|
||||||
|
"int16",
|
||||||
|
"int32",
|
||||||
|
"int64",
|
||||||
|
"uint8",
|
||||||
|
"uint16",
|
||||||
|
"uint32",
|
||||||
|
"uint64",
|
||||||
|
].includes(node.kind);
|
||||||
|
}
|
||||||
|
function isIntegerKind(kind) {
|
||||||
|
return kind.startsWith("int") || kind.startsWith("uint");
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
156
server/src/web-static/anyvali/index.js
Normal file
156
server/src/web-static/anyvali/index.js
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
export { ISSUE_CODES } from "./issue-codes.js";
|
||||||
|
export { ValidationError } from "./errors.js";
|
||||||
|
// ---- Re-export schema classes ----
|
||||||
|
export { BaseSchema, ABSENT, StringSchema, NumberSchema, Float32Schema, Float64Schema, IntSchema, Int8Schema, Int16Schema, Int32Schema, Int64Schema, Uint8Schema, Uint16Schema, Uint32Schema, Uint64Schema, BoolSchema, NullSchema, AnySchema, UnknownSchema, NeverSchema, LiteralSchema, EnumSchema, ArraySchema, TupleSchema, ObjectSchema, RecordSchema, UnionSchema, IntersectionSchema, OptionalSchema, NullableSchema, RefSchema, } from "./schemas/index.js";
|
||||||
|
// ---- Builder functions ----
|
||||||
|
import { StringSchema } from "./schemas/string.js";
|
||||||
|
import { NumberSchema, Float32Schema, Float64Schema } from "./schemas/number.js";
|
||||||
|
import { IntSchema, Int8Schema, Int16Schema, Int32Schema, Int64Schema, Uint8Schema, Uint16Schema, Uint32Schema, Uint64Schema, } from "./schemas/int.js";
|
||||||
|
import { BoolSchema } from "./schemas/bool.js";
|
||||||
|
import { NullSchema } from "./schemas/null.js";
|
||||||
|
import { AnySchema } from "./schemas/any.js";
|
||||||
|
import { UnknownSchema } from "./schemas/unknown.js";
|
||||||
|
import { NeverSchema } from "./schemas/never.js";
|
||||||
|
import { LiteralSchema } from "./schemas/literal.js";
|
||||||
|
import { EnumSchema } from "./schemas/enum.js";
|
||||||
|
import { ArraySchema } from "./schemas/array.js";
|
||||||
|
import { TupleSchema } from "./schemas/tuple.js";
|
||||||
|
import { ObjectSchema } from "./schemas/object.js";
|
||||||
|
import { RecordSchema } from "./schemas/record.js";
|
||||||
|
import { UnionSchema } from "./schemas/union.js";
|
||||||
|
import { IntersectionSchema } from "./schemas/intersection.js";
|
||||||
|
import { OptionalSchema } from "./schemas/optional.js";
|
||||||
|
import { NullableSchema } from "./schemas/nullable.js";
|
||||||
|
/** Create a string schema */
|
||||||
|
export function string() {
|
||||||
|
return new StringSchema();
|
||||||
|
}
|
||||||
|
/** Create a number (float64) schema */
|
||||||
|
export function number() {
|
||||||
|
return new NumberSchema();
|
||||||
|
}
|
||||||
|
/** Create a float32 schema */
|
||||||
|
export function float32() {
|
||||||
|
return new Float32Schema();
|
||||||
|
}
|
||||||
|
/** Create a float64 schema */
|
||||||
|
export function float64() {
|
||||||
|
return new Float64Schema();
|
||||||
|
}
|
||||||
|
/** Create an int (int64) schema */
|
||||||
|
export function int() {
|
||||||
|
return new IntSchema();
|
||||||
|
}
|
||||||
|
/** Create an int8 schema */
|
||||||
|
export function int8() {
|
||||||
|
return new Int8Schema();
|
||||||
|
}
|
||||||
|
/** Create an int16 schema */
|
||||||
|
export function int16() {
|
||||||
|
return new Int16Schema();
|
||||||
|
}
|
||||||
|
/** Create an int32 schema */
|
||||||
|
export function int32() {
|
||||||
|
return new Int32Schema();
|
||||||
|
}
|
||||||
|
/** Create an int64 schema */
|
||||||
|
export function int64() {
|
||||||
|
return new Int64Schema();
|
||||||
|
}
|
||||||
|
/** Create a uint8 schema */
|
||||||
|
export function uint8() {
|
||||||
|
return new Uint8Schema();
|
||||||
|
}
|
||||||
|
/** Create a uint16 schema */
|
||||||
|
export function uint16() {
|
||||||
|
return new Uint16Schema();
|
||||||
|
}
|
||||||
|
/** Create a uint32 schema */
|
||||||
|
export function uint32() {
|
||||||
|
return new Uint32Schema();
|
||||||
|
}
|
||||||
|
/** Create a uint64 schema */
|
||||||
|
export function uint64() {
|
||||||
|
return new Uint64Schema();
|
||||||
|
}
|
||||||
|
/** Create a boolean schema */
|
||||||
|
export function bool() {
|
||||||
|
return new BoolSchema();
|
||||||
|
}
|
||||||
|
/** Create a null schema. Named null_ to avoid conflict with the null keyword. */
|
||||||
|
export function null_() {
|
||||||
|
return new NullSchema();
|
||||||
|
}
|
||||||
|
/** Create an any schema */
|
||||||
|
export function any() {
|
||||||
|
return new AnySchema();
|
||||||
|
}
|
||||||
|
/** Create an unknown schema */
|
||||||
|
export function unknown() {
|
||||||
|
return new UnknownSchema();
|
||||||
|
}
|
||||||
|
/** Create a never schema */
|
||||||
|
export function never() {
|
||||||
|
return new NeverSchema();
|
||||||
|
}
|
||||||
|
/** Create a literal schema */
|
||||||
|
export function literal(value) {
|
||||||
|
return new LiteralSchema(value);
|
||||||
|
}
|
||||||
|
/** Create an enum schema. Named enum_ to avoid conflict with the enum keyword. */
|
||||||
|
export function enum_(values) {
|
||||||
|
return new EnumSchema(values);
|
||||||
|
}
|
||||||
|
/** Create an array schema */
|
||||||
|
export function array(items) {
|
||||||
|
return new ArraySchema(items);
|
||||||
|
}
|
||||||
|
/** Create a tuple schema */
|
||||||
|
export function tuple(items) {
|
||||||
|
return new TupleSchema(items);
|
||||||
|
}
|
||||||
|
/** Create an object schema */
|
||||||
|
export function object(shape, options) {
|
||||||
|
return new ObjectSchema(shape, options);
|
||||||
|
}
|
||||||
|
/** Create a record schema */
|
||||||
|
export function record(valueSchema) {
|
||||||
|
return new RecordSchema(valueSchema);
|
||||||
|
}
|
||||||
|
/** Create a union schema */
|
||||||
|
export function union(variants) {
|
||||||
|
return new UnionSchema(variants);
|
||||||
|
}
|
||||||
|
/** Create an intersection schema */
|
||||||
|
export function intersection(schemas) {
|
||||||
|
return new IntersectionSchema(schemas);
|
||||||
|
}
|
||||||
|
/** Wrap a schema as optional */
|
||||||
|
export function optional(schema) {
|
||||||
|
return new OptionalSchema(schema);
|
||||||
|
}
|
||||||
|
/** Wrap a schema as nullable */
|
||||||
|
export function nullable(schema) {
|
||||||
|
return new NullableSchema(schema);
|
||||||
|
}
|
||||||
|
// ---- Top-level parse functions ----
|
||||||
|
/** Parse input using the given schema. Throws ValidationError on failure. */
|
||||||
|
export function parse(schema, input) {
|
||||||
|
return schema.parse(input);
|
||||||
|
}
|
||||||
|
/** Parse input using the given schema. Returns a result object. */
|
||||||
|
export function safeParse(schema, input) {
|
||||||
|
return schema.safeParse(input);
|
||||||
|
}
|
||||||
|
// ---- Interchange functions ----
|
||||||
|
import { exportSchema as _exportSchema } from "./interchange/exporter.js";
|
||||||
|
import { importSchema as _importSchema } from "./interchange/importer.js";
|
||||||
|
/** Export a schema to an AnyValiDocument */
|
||||||
|
export function exportSchema(schema, mode = "portable") {
|
||||||
|
return _exportSchema(schema, mode);
|
||||||
|
}
|
||||||
|
/** Import an AnyValiDocument to a live schema */
|
||||||
|
export function importSchema(doc) {
|
||||||
|
return _importSchema(doc);
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
2
server/src/web-static/anyvali/infer.js
Normal file
2
server/src/web-static/anyvali/infer.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export {};
|
||||||
|
//# sourceMappingURL=infer.js.map
|
||||||
12
server/src/web-static/anyvali/interchange/document.js
Normal file
12
server/src/web-static/anyvali/interchange/document.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const ANYVALI_VERSION = "1.0";
|
||||||
|
export const SCHEMA_VERSION = "1";
|
||||||
|
export function createDocument(root, definitions = {}, extensions = {}) {
|
||||||
|
return {
|
||||||
|
anyvaliVersion: ANYVALI_VERSION,
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
root,
|
||||||
|
definitions,
|
||||||
|
extensions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=document.js.map
|
||||||
7
server/src/web-static/anyvali/interchange/exporter.js
Normal file
7
server/src/web-static/anyvali/interchange/exporter.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Export a schema to a portable AnyValiDocument.
|
||||||
|
*/
|
||||||
|
export function exportSchema(schema, mode = "portable") {
|
||||||
|
return schema.export(mode);
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=exporter.js.map
|
||||||
211
server/src/web-static/anyvali/interchange/importer.js
Normal file
211
server/src/web-static/anyvali/interchange/importer.js
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { StringSchema } from "../schemas/string.js";
|
||||||
|
import { NumberSchema, Float32Schema, Float64Schema, } from "../schemas/number.js";
|
||||||
|
import { IntSchema, Int8Schema, Int16Schema, Int32Schema, Int64Schema, Uint8Schema, Uint16Schema, Uint32Schema, Uint64Schema, } from "../schemas/int.js";
|
||||||
|
import { BoolSchema } from "../schemas/bool.js";
|
||||||
|
import { NullSchema } from "../schemas/null.js";
|
||||||
|
import { AnySchema } from "../schemas/any.js";
|
||||||
|
import { UnknownSchema } from "../schemas/unknown.js";
|
||||||
|
import { NeverSchema } from "../schemas/never.js";
|
||||||
|
import { LiteralSchema } from "../schemas/literal.js";
|
||||||
|
import { EnumSchema } from "../schemas/enum.js";
|
||||||
|
import { ArraySchema } from "../schemas/array.js";
|
||||||
|
import { TupleSchema } from "../schemas/tuple.js";
|
||||||
|
import { ObjectSchema } from "../schemas/object.js";
|
||||||
|
import { RecordSchema } from "../schemas/record.js";
|
||||||
|
import { UnionSchema } from "../schemas/union.js";
|
||||||
|
import { IntersectionSchema } from "../schemas/intersection.js";
|
||||||
|
import { OptionalSchema } from "../schemas/optional.js";
|
||||||
|
import { NullableSchema } from "../schemas/nullable.js";
|
||||||
|
import { RefSchema } from "../schemas/ref.js";
|
||||||
|
import { normalizeCoercionConfig } from "../parse/coerce.js";
|
||||||
|
/**
|
||||||
|
* Import an AnyValiDocument back into a live Schema.
|
||||||
|
*/
|
||||||
|
export function importSchema(doc) {
|
||||||
|
const definitions = doc.definitions ?? {};
|
||||||
|
const resolvedDefs = new Map();
|
||||||
|
function importNode(node) {
|
||||||
|
let schema;
|
||||||
|
switch (node.kind) {
|
||||||
|
case "string": {
|
||||||
|
let s = new StringSchema();
|
||||||
|
if (node.minLength !== undefined)
|
||||||
|
s = s.minLength(node.minLength);
|
||||||
|
if (node.maxLength !== undefined)
|
||||||
|
s = s.maxLength(node.maxLength);
|
||||||
|
if (node.pattern !== undefined)
|
||||||
|
s = s.pattern(node.pattern);
|
||||||
|
if (node.startsWith !== undefined)
|
||||||
|
s = s.startsWith(node.startsWith);
|
||||||
|
if (node.endsWith !== undefined)
|
||||||
|
s = s.endsWith(node.endsWith);
|
||||||
|
if (node.includes !== undefined)
|
||||||
|
s = s.includes(node.includes);
|
||||||
|
if (node.format !== undefined)
|
||||||
|
s = s.format(node.format);
|
||||||
|
schema = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "number":
|
||||||
|
case "float64": {
|
||||||
|
let s = node.kind === "float64" ? new Float64Schema() : new NumberSchema();
|
||||||
|
schema = applyNumericConstraints(s, node);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "float32": {
|
||||||
|
schema = applyNumericConstraints(new Float32Schema(), node);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "int":
|
||||||
|
case "int64": {
|
||||||
|
schema = applyNumericConstraints(node.kind === "int64" ? new Int64Schema() : new IntSchema(), node);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "int8":
|
||||||
|
schema = applyNumericConstraints(new Int8Schema(), node);
|
||||||
|
break;
|
||||||
|
case "int16":
|
||||||
|
schema = applyNumericConstraints(new Int16Schema(), node);
|
||||||
|
break;
|
||||||
|
case "int32":
|
||||||
|
schema = applyNumericConstraints(new Int32Schema(), node);
|
||||||
|
break;
|
||||||
|
case "uint8":
|
||||||
|
schema = applyNumericConstraints(new Uint8Schema(), node);
|
||||||
|
break;
|
||||||
|
case "uint16":
|
||||||
|
schema = applyNumericConstraints(new Uint16Schema(), node);
|
||||||
|
break;
|
||||||
|
case "uint32":
|
||||||
|
schema = applyNumericConstraints(new Uint32Schema(), node);
|
||||||
|
break;
|
||||||
|
case "uint64":
|
||||||
|
schema = applyNumericConstraints(new Uint64Schema(), node);
|
||||||
|
break;
|
||||||
|
case "bool":
|
||||||
|
schema = new BoolSchema();
|
||||||
|
break;
|
||||||
|
case "null":
|
||||||
|
schema = new NullSchema();
|
||||||
|
break;
|
||||||
|
case "any":
|
||||||
|
schema = new AnySchema();
|
||||||
|
break;
|
||||||
|
case "unknown":
|
||||||
|
schema = new UnknownSchema();
|
||||||
|
break;
|
||||||
|
case "never":
|
||||||
|
schema = new NeverSchema();
|
||||||
|
break;
|
||||||
|
case "literal": {
|
||||||
|
schema = new LiteralSchema(node.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "enum": {
|
||||||
|
schema = new EnumSchema(node.values);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "array": {
|
||||||
|
let s = new ArraySchema(importNode(node.items));
|
||||||
|
if (node.minItems !== undefined)
|
||||||
|
s = s.minItems(node.minItems);
|
||||||
|
if (node.maxItems !== undefined)
|
||||||
|
s = s.maxItems(node.maxItems);
|
||||||
|
schema = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tuple": {
|
||||||
|
// Corpus uses "elements", our export uses "items"
|
||||||
|
const elements = node.elements ?? node.items;
|
||||||
|
schema = new TupleSchema(elements.map((i) => importNode(i)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "object": {
|
||||||
|
const shape = {};
|
||||||
|
const requiredSet = new Set(node.required ?? []);
|
||||||
|
for (const [key, propNode] of Object.entries(node.properties ?? {})) {
|
||||||
|
let propSchema = importNode(propNode);
|
||||||
|
if (!requiredSet.has(key)) {
|
||||||
|
propSchema = new OptionalSchema(propSchema);
|
||||||
|
}
|
||||||
|
shape[key] = propSchema;
|
||||||
|
}
|
||||||
|
schema = new ObjectSchema(shape, {
|
||||||
|
unknownKeys: node.unknownKeys ?? "reject",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "record": {
|
||||||
|
// Corpus uses "values", our export uses "valueSchema"
|
||||||
|
const valueNode = node.values ?? node.valueSchema;
|
||||||
|
schema = new RecordSchema(importNode(valueNode));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "union": {
|
||||||
|
schema = new UnionSchema(node.variants.map((v) => importNode(v)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "intersection": {
|
||||||
|
schema = new IntersectionSchema(node.allOf.map((s) => importNode(s)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "optional": {
|
||||||
|
// Corpus uses "schema", our export uses "inner"
|
||||||
|
const innerNode = node.schema ?? node.inner;
|
||||||
|
schema = new OptionalSchema(importNode(innerNode));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "nullable": {
|
||||||
|
// Corpus uses "schema", our export uses "inner"
|
||||||
|
const innerNode = node.schema ?? node.inner;
|
||||||
|
schema = new NullableSchema(importNode(innerNode));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ref": {
|
||||||
|
const refPath = node.ref;
|
||||||
|
const defName = refPath.replace("#/definitions/", "");
|
||||||
|
schema = new RefSchema(refPath, () => {
|
||||||
|
if (resolvedDefs.has(defName)) {
|
||||||
|
return resolvedDefs.get(defName);
|
||||||
|
}
|
||||||
|
const defNode = definitions[defName];
|
||||||
|
if (!defNode) {
|
||||||
|
throw new Error(`Unresolved definition: ${defName}`);
|
||||||
|
}
|
||||||
|
const resolved = importNode(defNode);
|
||||||
|
resolvedDefs.set(defName, resolved);
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported schema kind: ${node.kind}`);
|
||||||
|
}
|
||||||
|
// Apply default
|
||||||
|
if (node.default !== undefined) {
|
||||||
|
schema = schema.default(node.default);
|
||||||
|
}
|
||||||
|
// Apply coercion config - handle both string and object formats
|
||||||
|
if (node.coerce !== undefined) {
|
||||||
|
const config = normalizeCoercionConfig(node.coerce);
|
||||||
|
schema = schema.coerce(config);
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
return importNode(doc.root);
|
||||||
|
}
|
||||||
|
function applyNumericConstraints(schema, node) {
|
||||||
|
let s = schema;
|
||||||
|
if (node.min !== undefined)
|
||||||
|
s = s.min(node.min);
|
||||||
|
if (node.max !== undefined)
|
||||||
|
s = s.max(node.max);
|
||||||
|
if (node.exclusiveMin !== undefined)
|
||||||
|
s = s.exclusiveMin(node.exclusiveMin);
|
||||||
|
if (node.exclusiveMax !== undefined)
|
||||||
|
s = s.exclusiveMax(node.exclusiveMax);
|
||||||
|
if (node.multipleOf !== undefined)
|
||||||
|
s = s.multipleOf(node.multipleOf);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=importer.js.map
|
||||||
17
server/src/web-static/anyvali/issue-codes.js
Normal file
17
server/src/web-static/anyvali/issue-codes.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export const ISSUE_CODES = {
|
||||||
|
INVALID_TYPE: "invalid_type",
|
||||||
|
REQUIRED: "required",
|
||||||
|
UNKNOWN_KEY: "unknown_key",
|
||||||
|
TOO_SMALL: "too_small",
|
||||||
|
TOO_LARGE: "too_large",
|
||||||
|
INVALID_STRING: "invalid_string",
|
||||||
|
INVALID_NUMBER: "invalid_number",
|
||||||
|
INVALID_LITERAL: "invalid_literal",
|
||||||
|
INVALID_UNION: "invalid_union",
|
||||||
|
CUSTOM_VALIDATION_NOT_PORTABLE: "custom_validation_not_portable",
|
||||||
|
UNSUPPORTED_EXTENSION: "unsupported_extension",
|
||||||
|
UNSUPPORTED_SCHEMA_KIND: "unsupported_schema_kind",
|
||||||
|
COERCION_FAILED: "coercion_failed",
|
||||||
|
DEFAULT_INVALID: "default_invalid",
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=issue-codes.js.map
|
||||||
115
server/src/web-static/anyvali/parse/coerce.js
Normal file
115
server/src/web-static/anyvali/parse/coerce.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Normalize coercion config from the corpus/interchange format.
|
||||||
|
* The corpus uses strings like "string->int", "trim", "lower", "upper"
|
||||||
|
* or arrays like ["trim", "lower"]. The SDK API uses CoercionConfig objects.
|
||||||
|
*/
|
||||||
|
export function normalizeCoercionConfig(raw) {
|
||||||
|
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
const config = {};
|
||||||
|
const items = Array.isArray(raw) ? raw : [raw];
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item) {
|
||||||
|
case "string->int":
|
||||||
|
case "string->number":
|
||||||
|
case "string->bool":
|
||||||
|
config.from = "string";
|
||||||
|
break;
|
||||||
|
case "trim":
|
||||||
|
config.trim = true;
|
||||||
|
break;
|
||||||
|
case "lower":
|
||||||
|
config.lower = true;
|
||||||
|
break;
|
||||||
|
case "upper":
|
||||||
|
config.upper = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
export function applyCoercion(input, config, targetType) {
|
||||||
|
let value = input;
|
||||||
|
// String transformations (trim, lower, upper) apply when input is a string
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (config.trim) {
|
||||||
|
value = value.trim();
|
||||||
|
}
|
||||||
|
if (config.lower) {
|
||||||
|
value = value.toLowerCase();
|
||||||
|
}
|
||||||
|
if (config.upper) {
|
||||||
|
value = value.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Type coercion from string to target
|
||||||
|
if (config.from === "string" && typeof value === "string") {
|
||||||
|
switch (targetType) {
|
||||||
|
case "int":
|
||||||
|
case "int8":
|
||||||
|
case "int16":
|
||||||
|
case "int32":
|
||||||
|
case "int64":
|
||||||
|
case "uint8":
|
||||||
|
case "uint16":
|
||||||
|
case "uint32":
|
||||||
|
case "uint64": {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === "" || !/^-?\d+$/.test(trimmed)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Cannot coerce "${value}" to ${targetType}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const num = Number(trimmed);
|
||||||
|
if (!Number.isFinite(num) || !Number.isInteger(num)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Cannot coerce "${value}" to ${targetType}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
value = num;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "number":
|
||||||
|
case "float32":
|
||||||
|
case "float64": {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Cannot coerce empty string to ${targetType}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const num = Number(trimmed);
|
||||||
|
if (!Number.isFinite(num)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Cannot coerce "${value}" to ${targetType}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
value = num;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "bool": {
|
||||||
|
const lower = value.trim().toLowerCase();
|
||||||
|
if (lower === "true" || lower === "1") {
|
||||||
|
value = true;
|
||||||
|
}
|
||||||
|
else if (lower === "false" || lower === "0") {
|
||||||
|
value = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Cannot coerce "${value}" to bool`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, value };
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=coerce.js.map
|
||||||
12
server/src/web-static/anyvali/parse/defaults.js
Normal file
12
server/src/web-static/anyvali/parse/defaults.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { ABSENT } from "../schemas/base.js";
|
||||||
|
/**
|
||||||
|
* Apply a default value if the input is absent.
|
||||||
|
* Returns the default value if input is absent, otherwise returns the input as-is.
|
||||||
|
*/
|
||||||
|
export function applyDefault(input, defaultValue) {
|
||||||
|
if ((input === undefined || input === ABSENT) && defaultValue !== ABSENT) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=defaults.js.map
|
||||||
13
server/src/web-static/anyvali/parse/parser.js
Normal file
13
server/src/web-static/anyvali/parse/parser.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Parse input with the given schema. Throws ValidationError on failure.
|
||||||
|
*/
|
||||||
|
export function parse(schema, input) {
|
||||||
|
return schema.parse(input);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Parse input with the given schema. Returns a result object.
|
||||||
|
*/
|
||||||
|
export function safeParse(schema, input) {
|
||||||
|
return schema.safeParse(input);
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=parser.js.map
|
||||||
12
server/src/web-static/anyvali/schemas/any.js
Normal file
12
server/src/web-static/anyvali/schemas/any.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
export class AnySchema extends BaseSchema {
|
||||||
|
_validate(input, _ctx) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = { kind: "any" };
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=any.js.map
|
||||||
73
server/src/web-static/anyvali/schemas/array.js
Normal file
73
server/src/web-static/anyvali/schemas/array.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class ArraySchema extends BaseSchema {
|
||||||
|
_items;
|
||||||
|
_minItems;
|
||||||
|
_maxItems;
|
||||||
|
constructor(items) {
|
||||||
|
super();
|
||||||
|
this._items = items;
|
||||||
|
}
|
||||||
|
minItems(n) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._minItems = n;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
maxItems(n) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._maxItems = n;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected array, received ${describeType(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: "array",
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (this._minItems !== undefined && input.length < this._minItems) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_SMALL,
|
||||||
|
message: `Array must have at least ${this._minItems} item(s)`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._minItems),
|
||||||
|
received: String(input.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._maxItems !== undefined && input.length > this._maxItems) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_LARGE,
|
||||||
|
message: `Array must have at most ${this._maxItems} item(s)`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._maxItems),
|
||||||
|
received: String(input.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = [];
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
ctx.path.push(i);
|
||||||
|
const val = this._items._runPipeline(input[i], ctx);
|
||||||
|
result.push(val);
|
||||||
|
ctx.path.pop();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = {
|
||||||
|
kind: "array",
|
||||||
|
items: this._items._toNode(),
|
||||||
|
};
|
||||||
|
if (this._minItems !== undefined)
|
||||||
|
node.minItems = this._minItems;
|
||||||
|
if (this._maxItems !== undefined)
|
||||||
|
node.maxItems = this._maxItems;
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=array.js.map
|
||||||
115
server/src/web-static/anyvali/schemas/base.js
Normal file
115
server/src/web-static/anyvali/schemas/base.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { ValidationError } from "../errors.js";
|
||||||
|
import { applyCoercion } from "../parse/coerce.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
const ANYVALI_VERSION = "1.0";
|
||||||
|
const SCHEMA_VERSION = "1";
|
||||||
|
/** Sentinel for "value not present" */
|
||||||
|
export const ABSENT = Symbol.for("anyvali.absent");
|
||||||
|
export class BaseSchema {
|
||||||
|
/** @internal */ _defaultValue = ABSENT;
|
||||||
|
/** @internal */ _coercionConfig = undefined;
|
||||||
|
/** @internal */ _isPortable = true;
|
||||||
|
// ---------- public API ----------
|
||||||
|
parse(input) {
|
||||||
|
const result = this.safeParse(input);
|
||||||
|
if (result.success)
|
||||||
|
return result.data;
|
||||||
|
throw new ValidationError(result.issues);
|
||||||
|
}
|
||||||
|
safeParse(input) {
|
||||||
|
const ctx = { path: [], issues: [] };
|
||||||
|
const output = this._runPipeline(input, ctx);
|
||||||
|
if (ctx.issues.length > 0) {
|
||||||
|
return { success: false, issues: ctx.issues };
|
||||||
|
}
|
||||||
|
return { success: true, data: output };
|
||||||
|
}
|
||||||
|
/** Internal: run the 5-step pipeline */
|
||||||
|
_runPipeline(input, ctx) {
|
||||||
|
// Step 1: detect presence
|
||||||
|
const isAbsent = input === undefined || input === ABSENT;
|
||||||
|
let value = input;
|
||||||
|
// Step 2: coercion (only for present values)
|
||||||
|
if (!isAbsent && this._coercionConfig) {
|
||||||
|
const coerced = applyCoercion(value, this._coercionConfig, this._getCoercionTarget());
|
||||||
|
if (coerced.success) {
|
||||||
|
value = coerced.value;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.COERCION_FAILED,
|
||||||
|
message: coerced.message,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._getCoercionTarget(),
|
||||||
|
received: String(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Step 3: default materialization (only for absent values)
|
||||||
|
let usedDefault = false;
|
||||||
|
if (isAbsent && this._defaultValue !== ABSENT) {
|
||||||
|
value = this._defaultValue;
|
||||||
|
usedDefault = true;
|
||||||
|
}
|
||||||
|
// Step 4: validate
|
||||||
|
const issuesBefore = ctx.issues.length;
|
||||||
|
const result = this._validate(value, ctx);
|
||||||
|
// If default was materialized and validation failed, remap issues to default_invalid
|
||||||
|
if (usedDefault && ctx.issues.length > issuesBefore) {
|
||||||
|
for (let i = issuesBefore; i < ctx.issues.length; i++) {
|
||||||
|
const issue = ctx.issues[i];
|
||||||
|
ctx.issues[i] = {
|
||||||
|
...issue,
|
||||||
|
code: ISSUE_CODES.DEFAULT_INVALID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/** Override in subclasses to provide the coercion target type name */
|
||||||
|
_getCoercionTarget() {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
// optional() and nullable() are provided via standalone functions
|
||||||
|
// to avoid circular imports. See index.ts.
|
||||||
|
default(value) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._defaultValue = value;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
coerce(options = {}) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._coercionConfig = { ...options };
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
export(mode = "portable") {
|
||||||
|
if (mode === "portable" && !this._isPortable) {
|
||||||
|
throw new Error("Cannot export in portable mode: schema contains non-portable features");
|
||||||
|
}
|
||||||
|
const node = this._toNode();
|
||||||
|
return {
|
||||||
|
anyvaliVersion: ANYVALI_VERSION,
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
root: node,
|
||||||
|
definitions: {},
|
||||||
|
extensions: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ---------- internal helpers ----------
|
||||||
|
_clone() {
|
||||||
|
const clone = Object.create(Object.getPrototypeOf(this));
|
||||||
|
Object.assign(clone, this);
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
_addDefault(node) {
|
||||||
|
if (this._defaultValue !== ABSENT) {
|
||||||
|
node.default = this._defaultValue;
|
||||||
|
}
|
||||||
|
if (this._coercionConfig) {
|
||||||
|
node.coerce = { ...this._coercionConfig };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=base.js.map
|
||||||
27
server/src/web-static/anyvali/schemas/bool.js
Normal file
27
server/src/web-static/anyvali/schemas/bool.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class BoolSchema extends BaseSchema {
|
||||||
|
_getCoercionTarget() {
|
||||||
|
return "bool";
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (typeof input !== "boolean") {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected boolean, received ${describeType(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: "bool",
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = { kind: "bool" };
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=bool.js.map
|
||||||
31
server/src/web-static/anyvali/schemas/enum.js
Normal file
31
server/src/web-static/anyvali/schemas/enum.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
export class EnumSchema extends BaseSchema {
|
||||||
|
_values;
|
||||||
|
constructor(values) {
|
||||||
|
super();
|
||||||
|
this._values = values;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (!this._values.includes(input)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected one of enum(${this._values.join(",")}), received ${String(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: `enum(${this._values.join(",")})`,
|
||||||
|
received: String(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = {
|
||||||
|
kind: "enum",
|
||||||
|
values: [...this._values],
|
||||||
|
};
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=enum.js.map
|
||||||
21
server/src/web-static/anyvali/schemas/index.js
Normal file
21
server/src/web-static/anyvali/schemas/index.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export { BaseSchema, ABSENT } from "./base.js";
|
||||||
|
export { StringSchema } from "./string.js";
|
||||||
|
export { NumberSchema, Float32Schema, Float64Schema } from "./number.js";
|
||||||
|
export { IntSchema, Int8Schema, Int16Schema, Int32Schema, Int64Schema, Uint8Schema, Uint16Schema, Uint32Schema, Uint64Schema, } from "./int.js";
|
||||||
|
export { BoolSchema } from "./bool.js";
|
||||||
|
export { NullSchema } from "./null.js";
|
||||||
|
export { AnySchema } from "./any.js";
|
||||||
|
export { UnknownSchema } from "./unknown.js";
|
||||||
|
export { NeverSchema } from "./never.js";
|
||||||
|
export { LiteralSchema } from "./literal.js";
|
||||||
|
export { EnumSchema } from "./enum.js";
|
||||||
|
export { ArraySchema } from "./array.js";
|
||||||
|
export { TupleSchema } from "./tuple.js";
|
||||||
|
export { ObjectSchema } from "./object.js";
|
||||||
|
export { RecordSchema } from "./record.js";
|
||||||
|
export { UnionSchema } from "./union.js";
|
||||||
|
export { IntersectionSchema } from "./intersection.js";
|
||||||
|
export { OptionalSchema } from "./optional.js";
|
||||||
|
export { NullableSchema } from "./nullable.js";
|
||||||
|
export { RefSchema } from "./ref.js";
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
108
server/src/web-static/anyvali/schemas/int.js
Normal file
108
server/src/web-static/anyvali/schemas/int.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { NumberSchema } from "./number.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
const INT_RANGES = {
|
||||||
|
int8: { min: -128, max: 127 },
|
||||||
|
int16: { min: -32768, max: 32767 },
|
||||||
|
int32: { min: -2147483648, max: 2147483647 },
|
||||||
|
int64: { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
|
||||||
|
uint8: { min: 0, max: 255 },
|
||||||
|
uint16: { min: 0, max: 65535 },
|
||||||
|
uint32: { min: 0, max: 4294967295 },
|
||||||
|
uint64: { min: 0, max: Number.MAX_SAFE_INTEGER },
|
||||||
|
int: { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
|
||||||
|
};
|
||||||
|
export class IntSchema extends NumberSchema {
|
||||||
|
_intRange;
|
||||||
|
constructor(kind = "int") {
|
||||||
|
super(kind);
|
||||||
|
this._intRange = INT_RANGES[kind] ?? INT_RANGES.int;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected integer, received ${describeType(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._kind,
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(input)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected integer, received float`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._kind,
|
||||||
|
received: "number",
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Range check for the specific int width
|
||||||
|
if (input > this._intRange.max) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_LARGE,
|
||||||
|
message: `Value ${input} is above the maximum for ${this._kind}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._kind,
|
||||||
|
received: String(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (input < this._intRange.min) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_SMALL,
|
||||||
|
message: `Value ${input} is below the minimum for ${this._kind}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._kind,
|
||||||
|
received: String(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Additional user constraints
|
||||||
|
this._validateConstraints(input, ctx);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Int8Schema extends IntSchema {
|
||||||
|
constructor() {
|
||||||
|
super("int8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Int16Schema extends IntSchema {
|
||||||
|
constructor() {
|
||||||
|
super("int16");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Int32Schema extends IntSchema {
|
||||||
|
constructor() {
|
||||||
|
super("int32");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Int64Schema extends IntSchema {
|
||||||
|
constructor() {
|
||||||
|
super("int64");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Uint8Schema extends IntSchema {
|
||||||
|
constructor() {
|
||||||
|
super("uint8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Uint16Schema extends IntSchema {
|
||||||
|
constructor() {
|
||||||
|
super("uint16");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Uint32Schema extends IntSchema {
|
||||||
|
constructor() {
|
||||||
|
super("uint32");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Uint64Schema extends IntSchema {
|
||||||
|
constructor() {
|
||||||
|
super("uint64");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=int.js.map
|
||||||
53
server/src/web-static/anyvali/schemas/intersection.js
Normal file
53
server/src/web-static/anyvali/schemas/intersection.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
export class IntersectionSchema extends BaseSchema {
|
||||||
|
_schemas;
|
||||||
|
constructor(schemas) {
|
||||||
|
super();
|
||||||
|
this._schemas = schemas;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
let result = input;
|
||||||
|
let anyFailed = false;
|
||||||
|
for (const schema of this._schemas) {
|
||||||
|
const innerCtx = {
|
||||||
|
path: [...ctx.path],
|
||||||
|
issues: [],
|
||||||
|
};
|
||||||
|
const validated = schema._runPipeline(input, innerCtx);
|
||||||
|
if (innerCtx.issues.length > 0) {
|
||||||
|
ctx.issues.push(...innerCtx.issues);
|
||||||
|
anyFailed = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Merge object results
|
||||||
|
if (typeof result === "object" &&
|
||||||
|
result !== null &&
|
||||||
|
typeof validated === "object" &&
|
||||||
|
validated !== null &&
|
||||||
|
!Array.isArray(result) &&
|
||||||
|
!Array.isArray(validated)) {
|
||||||
|
result = {
|
||||||
|
...result,
|
||||||
|
...validated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
result = validated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anyFailed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = {
|
||||||
|
kind: "intersection",
|
||||||
|
allOf: this._schemas.map((s) => s._toNode()),
|
||||||
|
};
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=intersection.js.map
|
||||||
28
server/src/web-static/anyvali/schemas/literal.js
Normal file
28
server/src/web-static/anyvali/schemas/literal.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
export class LiteralSchema extends BaseSchema {
|
||||||
|
_value;
|
||||||
|
constructor(value) {
|
||||||
|
super();
|
||||||
|
this._value = value;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (input !== this._value) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_LITERAL,
|
||||||
|
message: `Expected literal ${String(this._value)}, received ${String(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._value),
|
||||||
|
received: String(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = { kind: "literal", value: this._value };
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=literal.js.map
|
||||||
19
server/src/web-static/anyvali/schemas/never.js
Normal file
19
server/src/web-static/anyvali/schemas/never.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class NeverSchema extends BaseSchema {
|
||||||
|
_validate(input, ctx) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected never (no value is valid)`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: "never",
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
return { kind: "never" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=never.js.map
|
||||||
24
server/src/web-static/anyvali/schemas/null.js
Normal file
24
server/src/web-static/anyvali/schemas/null.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class NullSchema extends BaseSchema {
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (input !== null) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected null, received ${describeType(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: "null",
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = { kind: "null" };
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=null.js.map
|
||||||
29
server/src/web-static/anyvali/schemas/nullable.js
Normal file
29
server/src/web-static/anyvali/schemas/nullable.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
export class NullableSchema extends BaseSchema {
|
||||||
|
/** @internal */ _inner;
|
||||||
|
constructor(inner) {
|
||||||
|
super();
|
||||||
|
this._inner = inner;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (input === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this._inner._validate(input, ctx);
|
||||||
|
}
|
||||||
|
_runPipeline(input, ctx) {
|
||||||
|
if (input === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this._inner._runPipeline(input, ctx);
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = {
|
||||||
|
kind: "nullable",
|
||||||
|
inner: this._inner._toNode(),
|
||||||
|
};
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=nullable.js.map
|
||||||
134
server/src/web-static/anyvali/schemas/number.js
Normal file
134
server/src/web-static/anyvali/schemas/number.js
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class NumberSchema extends BaseSchema {
|
||||||
|
_kind;
|
||||||
|
_min;
|
||||||
|
_max;
|
||||||
|
_exclusiveMin;
|
||||||
|
_exclusiveMax;
|
||||||
|
_multipleOf;
|
||||||
|
constructor(kind = "number") {
|
||||||
|
super();
|
||||||
|
this._kind = kind;
|
||||||
|
}
|
||||||
|
_getCoercionTarget() {
|
||||||
|
return this._kind;
|
||||||
|
}
|
||||||
|
min(n) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._min = n;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
max(n) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._max = n;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
exclusiveMin(n) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._exclusiveMin = n;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
exclusiveMax(n) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._exclusiveMax = n;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
multipleOf(n) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._multipleOf = n;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected ${this._kind}, received ${describeType(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._kind,
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
this._validateConstraints(input, ctx);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
_validateConstraints(val, ctx) {
|
||||||
|
if (this._min !== undefined && val < this._min) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_SMALL,
|
||||||
|
message: `Number must be >= ${this._min}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._min),
|
||||||
|
received: String(val),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._max !== undefined && val > this._max) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_LARGE,
|
||||||
|
message: `Number must be <= ${this._max}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._max),
|
||||||
|
received: String(val),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._exclusiveMin !== undefined && val <= this._exclusiveMin) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_SMALL,
|
||||||
|
message: `Number must be > ${this._exclusiveMin}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._exclusiveMin),
|
||||||
|
received: String(val),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._exclusiveMax !== undefined && val >= this._exclusiveMax) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_LARGE,
|
||||||
|
message: `Number must be < ${this._exclusiveMax}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._exclusiveMax),
|
||||||
|
received: String(val),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._multipleOf !== undefined) {
|
||||||
|
const remainder = val % this._multipleOf;
|
||||||
|
if (Math.abs(remainder) > 1e-10 &&
|
||||||
|
Math.abs(remainder - this._multipleOf) > 1e-10) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_NUMBER,
|
||||||
|
message: `Number must be a multiple of ${this._multipleOf}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._multipleOf),
|
||||||
|
received: String(val),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = { kind: this._kind };
|
||||||
|
if (this._min !== undefined)
|
||||||
|
node.min = this._min;
|
||||||
|
if (this._max !== undefined)
|
||||||
|
node.max = this._max;
|
||||||
|
if (this._exclusiveMin !== undefined)
|
||||||
|
node.exclusiveMin = this._exclusiveMin;
|
||||||
|
if (this._exclusiveMax !== undefined)
|
||||||
|
node.exclusiveMax = this._exclusiveMax;
|
||||||
|
if (this._multipleOf !== undefined)
|
||||||
|
node.multipleOf = this._multipleOf;
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Float32Schema extends NumberSchema {
|
||||||
|
constructor() {
|
||||||
|
super("float32");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Float64Schema extends NumberSchema {
|
||||||
|
constructor() {
|
||||||
|
super("float64");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=number.js.map
|
||||||
108
server/src/web-static/anyvali/schemas/object.js
Normal file
108
server/src/web-static/anyvali/schemas/object.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { BaseSchema, ABSENT } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class ObjectSchema extends BaseSchema {
|
||||||
|
_properties;
|
||||||
|
_unknownKeys;
|
||||||
|
constructor(shape, options) {
|
||||||
|
super();
|
||||||
|
this._properties = new Map();
|
||||||
|
this._unknownKeys = options?.unknownKeys ?? "reject";
|
||||||
|
for (const [key, schema] of Object.entries(shape)) {
|
||||||
|
// Check if the schema is an OptionalSchema wrapper
|
||||||
|
const isOptional = schema._isOptionalWrapper === true;
|
||||||
|
this._properties.set(key, {
|
||||||
|
schema,
|
||||||
|
required: !isOptional,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unknownKeys(mode) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._unknownKeys = mode;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected object, received ${describeType(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: "object",
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const obj = input;
|
||||||
|
const result = {};
|
||||||
|
const inputKeys = new Set(Object.keys(obj));
|
||||||
|
// Validate declared properties
|
||||||
|
for (const [key, prop] of this._properties) {
|
||||||
|
ctx.path.push(key);
|
||||||
|
const hasKey = Object.prototype.hasOwnProperty.call(obj, key);
|
||||||
|
inputKeys.delete(key);
|
||||||
|
if (!hasKey) {
|
||||||
|
// Check if required
|
||||||
|
if (prop.required && prop.schema._defaultValue === ABSENT) {
|
||||||
|
const expectedKind = prop.schema._toNode().kind;
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.REQUIRED,
|
||||||
|
message: `Required property "${key}" is missing`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: expectedKind,
|
||||||
|
received: "undefined",
|
||||||
|
});
|
||||||
|
ctx.path.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rawValue = hasKey ? obj[key] : undefined;
|
||||||
|
const val = prop.schema._runPipeline(rawValue, ctx);
|
||||||
|
// Only include in result if value is not undefined or it was explicitly present
|
||||||
|
if (val !== undefined || hasKey || prop.schema._defaultValue !== ABSENT) {
|
||||||
|
result[key] = val;
|
||||||
|
}
|
||||||
|
ctx.path.pop();
|
||||||
|
}
|
||||||
|
// Handle unknown keys
|
||||||
|
for (const key of inputKeys) {
|
||||||
|
switch (this._unknownKeys) {
|
||||||
|
case "reject":
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.UNKNOWN_KEY,
|
||||||
|
message: `Unknown key "${key}"`,
|
||||||
|
path: [...ctx.path, key],
|
||||||
|
expected: "undefined",
|
||||||
|
received: key,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "allow":
|
||||||
|
result[key] = obj[key];
|
||||||
|
break;
|
||||||
|
case "strip":
|
||||||
|
// Just ignore it
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const properties = {};
|
||||||
|
const required = [];
|
||||||
|
for (const [key, prop] of this._properties) {
|
||||||
|
properties[key] = prop.schema._toNode();
|
||||||
|
if (prop.required) {
|
||||||
|
required.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const node = {
|
||||||
|
kind: "object",
|
||||||
|
properties,
|
||||||
|
required,
|
||||||
|
unknownKeys: this._unknownKeys,
|
||||||
|
};
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=object.js.map
|
||||||
39
server/src/web-static/anyvali/schemas/optional.js
Normal file
39
server/src/web-static/anyvali/schemas/optional.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { BaseSchema, ABSENT } from "./base.js";
|
||||||
|
export class OptionalSchema extends BaseSchema {
|
||||||
|
/** @internal */ _inner;
|
||||||
|
/** @internal */ _isOptionalWrapper = true;
|
||||||
|
constructor(inner) {
|
||||||
|
super();
|
||||||
|
this._inner = inner;
|
||||||
|
// Inherit defaults/coercion from inner
|
||||||
|
this._defaultValue = inner._defaultValue;
|
||||||
|
this._coercionConfig = inner._coercionConfig;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (input === undefined || input === ABSENT) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this._inner._validate(input, ctx);
|
||||||
|
}
|
||||||
|
_runPipeline(input, ctx) {
|
||||||
|
const isAbsent = input === undefined || input === ABSENT;
|
||||||
|
// If absent and we have a default from inner, apply it
|
||||||
|
if (isAbsent && this._inner._defaultValue !== ABSENT) {
|
||||||
|
return this._inner._runPipeline(input, ctx);
|
||||||
|
}
|
||||||
|
if (isAbsent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Delegate to inner's pipeline for coercion etc.
|
||||||
|
return this._inner._runPipeline(input, ctx);
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = {
|
||||||
|
kind: "optional",
|
||||||
|
inner: this._inner._toNode(),
|
||||||
|
};
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=optional.js.map
|
||||||
39
server/src/web-static/anyvali/schemas/record.js
Normal file
39
server/src/web-static/anyvali/schemas/record.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class RecordSchema extends BaseSchema {
|
||||||
|
_valueSchema;
|
||||||
|
constructor(valueSchema) {
|
||||||
|
super();
|
||||||
|
this._valueSchema = valueSchema;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected record, received ${describeType(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: "record",
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const obj = input;
|
||||||
|
const result = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
ctx.path.push(key);
|
||||||
|
result[key] = this._valueSchema._runPipeline(value, ctx);
|
||||||
|
ctx.path.pop();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = {
|
||||||
|
kind: "record",
|
||||||
|
valueSchema: this._valueSchema._toNode(),
|
||||||
|
};
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=record.js.map
|
||||||
30
server/src/web-static/anyvali/schemas/ref.js
Normal file
30
server/src/web-static/anyvali/schemas/ref.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
export class RefSchema extends BaseSchema {
|
||||||
|
_ref;
|
||||||
|
_resolver;
|
||||||
|
constructor(ref, resolver) {
|
||||||
|
super();
|
||||||
|
this._ref = ref;
|
||||||
|
this._resolver = resolver;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (this._resolver) {
|
||||||
|
const resolved = this._resolver();
|
||||||
|
return resolved._validate(input, ctx);
|
||||||
|
}
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.UNSUPPORTED_SCHEMA_KIND,
|
||||||
|
message: `Unresolved ref: ${this._ref}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
return {
|
||||||
|
kind: "ref",
|
||||||
|
ref: this._ref,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=ref.js.map
|
||||||
151
server/src/web-static/anyvali/schemas/string.js
Normal file
151
server/src/web-static/anyvali/schemas/string.js
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { validateFormat } from "../format/validators.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class StringSchema extends BaseSchema {
|
||||||
|
_minLength;
|
||||||
|
_maxLength;
|
||||||
|
_pattern;
|
||||||
|
_startsWith;
|
||||||
|
_endsWith;
|
||||||
|
_includes;
|
||||||
|
_format;
|
||||||
|
_getCoercionTarget() {
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
minLength(n) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._minLength = n;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
maxLength(n) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._maxLength = n;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
pattern(p) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._pattern = p;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
startsWith(s) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._startsWith = s;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
endsWith(s) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._endsWith = s;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
includes(s) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._includes = s;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
format(f) {
|
||||||
|
const clone = this._clone();
|
||||||
|
clone._format = f;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (typeof input !== "string") {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected string, received ${describeType(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: "string",
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const val = input;
|
||||||
|
if (this._minLength !== undefined && val.length < this._minLength) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_SMALL,
|
||||||
|
message: `String must have at least ${this._minLength} character(s)`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._minLength),
|
||||||
|
received: String(val.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._maxLength !== undefined && val.length > this._maxLength) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_LARGE,
|
||||||
|
message: `String must have at most ${this._maxLength} character(s)`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._maxLength),
|
||||||
|
received: String(val.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._pattern !== undefined) {
|
||||||
|
const re = new RegExp(this._pattern);
|
||||||
|
if (!re.test(val)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_STRING,
|
||||||
|
message: `String does not match pattern: ${this._pattern}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._pattern,
|
||||||
|
received: val,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._startsWith !== undefined && !val.startsWith(this._startsWith)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_STRING,
|
||||||
|
message: `String must start with "${this._startsWith}"`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._startsWith,
|
||||||
|
received: val,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._endsWith !== undefined && !val.endsWith(this._endsWith)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_STRING,
|
||||||
|
message: `String must end with "${this._endsWith}"`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._endsWith,
|
||||||
|
received: val,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._includes !== undefined && !val.includes(this._includes)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_STRING,
|
||||||
|
message: `String must include "${this._includes}"`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._includes,
|
||||||
|
received: val,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._format !== undefined && !validateFormat(val, this._format)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_STRING,
|
||||||
|
message: `Invalid ${this._format} format`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: this._format,
|
||||||
|
received: val,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = { kind: "string" };
|
||||||
|
if (this._minLength !== undefined)
|
||||||
|
node.minLength = this._minLength;
|
||||||
|
if (this._maxLength !== undefined)
|
||||||
|
node.maxLength = this._maxLength;
|
||||||
|
if (this._pattern !== undefined)
|
||||||
|
node.pattern = this._pattern;
|
||||||
|
if (this._startsWith !== undefined)
|
||||||
|
node.startsWith = this._startsWith;
|
||||||
|
if (this._endsWith !== undefined)
|
||||||
|
node.endsWith = this._endsWith;
|
||||||
|
if (this._includes !== undefined)
|
||||||
|
node.includes = this._includes;
|
||||||
|
if (this._format !== undefined)
|
||||||
|
node.format = this._format;
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=string.js.map
|
||||||
59
server/src/web-static/anyvali/schemas/tuple.js
Normal file
59
server/src/web-static/anyvali/schemas/tuple.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class TupleSchema extends BaseSchema {
|
||||||
|
_items;
|
||||||
|
constructor(items) {
|
||||||
|
super();
|
||||||
|
this._items = items;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_TYPE,
|
||||||
|
message: `Expected tuple, received ${describeType(input)}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: "tuple",
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (input.length < this._items.length) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_SMALL,
|
||||||
|
message: `Tuple must have exactly ${this._items.length} element(s), received ${input.length}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._items.length),
|
||||||
|
received: String(input.length),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (input.length > this._items.length) {
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.TOO_LARGE,
|
||||||
|
message: `Tuple must have exactly ${this._items.length} element(s), received ${input.length}`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: String(this._items.length),
|
||||||
|
received: String(input.length),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result = [];
|
||||||
|
for (let i = 0; i < this._items.length; i++) {
|
||||||
|
ctx.path.push(i);
|
||||||
|
const val = this._items[i]._runPipeline(input[i], ctx);
|
||||||
|
result.push(val);
|
||||||
|
ctx.path.pop();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = {
|
||||||
|
kind: "tuple",
|
||||||
|
elements: this._items.map((s) => s._toNode()),
|
||||||
|
};
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=tuple.js.map
|
||||||
40
server/src/web-static/anyvali/schemas/union.js
Normal file
40
server/src/web-static/anyvali/schemas/union.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
import { ISSUE_CODES } from "../issue-codes.js";
|
||||||
|
import { describeType } from "../util.js";
|
||||||
|
export class UnionSchema extends BaseSchema {
|
||||||
|
_variants;
|
||||||
|
constructor(variants) {
|
||||||
|
super();
|
||||||
|
this._variants = variants;
|
||||||
|
}
|
||||||
|
_validate(input, ctx) {
|
||||||
|
for (const variant of this._variants) {
|
||||||
|
const innerCtx = {
|
||||||
|
path: [...ctx.path],
|
||||||
|
issues: [],
|
||||||
|
};
|
||||||
|
const result = variant._runPipeline(input, innerCtx);
|
||||||
|
if (innerCtx.issues.length === 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const variantKinds = this._variants.map((v) => v._toNode().kind);
|
||||||
|
ctx.issues.push({
|
||||||
|
code: ISSUE_CODES.INVALID_UNION,
|
||||||
|
message: `Input did not match any variant of the union`,
|
||||||
|
path: [...ctx.path],
|
||||||
|
expected: variantKinds.join(" | "),
|
||||||
|
received: describeType(input),
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = {
|
||||||
|
kind: "union",
|
||||||
|
variants: this._variants.map((v) => v._toNode()),
|
||||||
|
};
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=union.js.map
|
||||||
12
server/src/web-static/anyvali/schemas/unknown.js
Normal file
12
server/src/web-static/anyvali/schemas/unknown.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { BaseSchema } from "./base.js";
|
||||||
|
export class UnknownSchema extends BaseSchema {
|
||||||
|
_validate(input, _ctx) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
_toNode() {
|
||||||
|
const node = { kind: "unknown" };
|
||||||
|
this._addDefault(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=unknown.js.map
|
||||||
3
server/src/web-static/anyvali/types.js
Normal file
3
server/src/web-static/anyvali/types.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// ---- Schema Node (interchange JSON representation) ----
|
||||||
|
export {};
|
||||||
|
//# sourceMappingURL=types.js.map
|
||||||
12
server/src/web-static/anyvali/util.js
Normal file
12
server/src/web-static/anyvali/util.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* Describe the type of a value for error messages, matching the corpus expectations.
|
||||||
|
* null -> "null", array -> "array", otherwise typeof.
|
||||||
|
*/
|
||||||
|
export function describeType(value) {
|
||||||
|
if (value === null)
|
||||||
|
return "null";
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return "array";
|
||||||
|
return typeof value;
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=util.js.map
|
||||||
1
server/src/web-static/htmx.min.js
vendored
Normal file
1
server/src/web-static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
540
server/src/web-templates/admin-pages.tsx
Normal file
540
server/src/web-templates/admin-pages.tsx
Normal file
|
|
@ -0,0 +1,540 @@
|
||||||
|
/**
|
||||||
|
* Admin page templates: overview, cameras, kiosks, account, etc.
|
||||||
|
*/
|
||||||
|
import { js } from "jsx-htmx";
|
||||||
|
import { Layout } from "./layout.js";
|
||||||
|
import type { Camera, Kiosk, PairingCode, EventLog } from "../shared/types.js";
|
||||||
|
|
||||||
|
// ---- Overview ---------------------------------------------------------------
|
||||||
|
|
||||||
|
interface OverviewProps {
|
||||||
|
user: string;
|
||||||
|
cameraCount: number;
|
||||||
|
kioskCount: number;
|
||||||
|
onlineKioskCount: number;
|
||||||
|
layoutCount: number;
|
||||||
|
events: EventLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewPage(props: OverviewProps) {
|
||||||
|
return (
|
||||||
|
<Layout title="Overview" user={props.user} activeNav="overview">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Cameras</div>
|
||||||
|
<div class="stat-value">{String(props.cameraCount)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Kiosks</div>
|
||||||
|
<div class="stat-value">{String(props.kioskCount)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Kiosks Online</div>
|
||||||
|
<div class="stat-value">{String(props.onlineKioskCount)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Displays</div>
|
||||||
|
<div class="stat-value">{String(props.layoutCount)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Quick Links</h2>
|
||||||
|
</div>
|
||||||
|
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||||||
|
<a href="/admin/cameras/new" class="card" style="text-decoration:none; color:inherit">
|
||||||
|
<strong>Add Camera</strong>
|
||||||
|
<div style="color:#666; font-size:0.85rem">RTSP or ONVIF</div>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/kiosks" class="card" style="text-decoration:none; color:inherit">
|
||||||
|
<strong>Pair Kiosk</strong>
|
||||||
|
<div style="color:#666; font-size:0.85rem">Enter pairing code</div>
|
||||||
|
</a>
|
||||||
|
<a href="/nrdp/" class="card" style="text-decoration:none; color:inherit">
|
||||||
|
<strong>Rule Engine</strong>
|
||||||
|
<div style="color:#666; font-size:0.85rem">Node-RED dashboard</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Recent Events</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Topic</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Payload</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.events.length === 0 ? (
|
||||||
|
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No events yet</td></tr>
|
||||||
|
) : (
|
||||||
|
props.events.map((ev) => (
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap; font-size:0.8rem">{formatTime(ev.received_at)}</td>
|
||||||
|
<td>{ev.topic}</td>
|
||||||
|
<td><span class="badge badge-gray">{ev.source_type}</span></td>
|
||||||
|
<td style="font-size:0.8rem; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
|
||||||
|
{JSON.stringify(ev.payload)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Cameras ----------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CamerasProps {
|
||||||
|
user: string;
|
||||||
|
cameras: Camera[];
|
||||||
|
streamCounts: Map<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CamerasPage(props: CamerasProps) {
|
||||||
|
return (
|
||||||
|
<Layout title="Cameras" user={props.user} activeNav="cameras">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">All Cameras</h2>
|
||||||
|
<a href="/admin/cameras/new" class="btn btn-primary">Add Camera</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Streams</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.cameras.length === 0 ? (
|
||||||
|
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No cameras configured</td></tr>
|
||||||
|
) : (
|
||||||
|
props.cameras.map((cam) => (
|
||||||
|
<tr>
|
||||||
|
<td><strong>{cam.name}</strong></td>
|
||||||
|
<td><span class="badge badge-blue">{cam.type.toUpperCase()}</span></td>
|
||||||
|
<td>{String(props.streamCounts.get(cam.id) ?? 0)}</td>
|
||||||
|
<td>
|
||||||
|
{cam.enabled
|
||||||
|
? <span class="badge badge-green">Enabled</span>
|
||||||
|
: <span class="badge badge-gray">Disabled</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Camera New -------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CameraNewProps {
|
||||||
|
user: string;
|
||||||
|
error?: string;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CameraNewPage(props: CameraNewProps) {
|
||||||
|
const v = props.values ?? {};
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="Add Camera"
|
||||||
|
user={props.user}
|
||||||
|
activeNav="cameras"
|
||||||
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
|
>
|
||||||
|
<div style="max-width:600px">
|
||||||
|
<form method="post" action="/admin/cameras/new">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Camera Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
maxlength="128"
|
||||||
|
value={v["name"] ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type</label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="type" value="rtsp" checked={v["type"] !== "onvif"} />
|
||||||
|
RTSP
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="type" value="onvif" checked={v["type"] === "onvif"} />
|
||||||
|
ONVIF
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rtsp-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rtsp_url">RTSP URL</label>
|
||||||
|
<input
|
||||||
|
id="rtsp_url"
|
||||||
|
name="rtsp_url"
|
||||||
|
type="url"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="rtsp://192.168.1.100:554/stream1"
|
||||||
|
value={v["rtsp_url"] ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="onvif-fields" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="onvif_host">ONVIF Host</label>
|
||||||
|
<input id="onvif_host" name="onvif_host" type="text" class="form-input" value={v["onvif_host"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="onvif_port">Port</label>
|
||||||
|
<input id="onvif_port" name="onvif_port" type="number" class="form-input" value={v["onvif_port"] ?? "80"} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="onvif_username">Username</label>
|
||||||
|
<input id="onvif_username" name="onvif_username" type="text" class="form-input" value={v["onvif_username"] ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="onvif_password">Password</label>
|
||||||
|
<input id="onvif_password" name="onvif_password" type="password" class="form-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Add Camera</button>
|
||||||
|
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>{js(
|
||||||
|
`(function(){` +
|
||||||
|
`var radios=document.querySelectorAll('input[name="type"]');` +
|
||||||
|
`var rd=document.getElementById("rtsp-fields");` +
|
||||||
|
`var od=document.getElementById("onvif-fields");` +
|
||||||
|
`function t(){var el=document.querySelector('input[name="type"]:checked');` +
|
||||||
|
`var v=el?el.value:"rtsp";` +
|
||||||
|
`if(rd)rd.style.display=v==="rtsp"?"block":"none";` +
|
||||||
|
`if(od)od.style.display=v==="onvif"?"block":"none";}` +
|
||||||
|
`radios.forEach(function(r){r.addEventListener("change",t)});t();})()`
|
||||||
|
)}</script>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Kiosks -----------------------------------------------------------------
|
||||||
|
|
||||||
|
interface KiosksProps {
|
||||||
|
user: string;
|
||||||
|
kiosks: Kiosk[];
|
||||||
|
pendingCodes: PairingCode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KiosksPage(props: KiosksProps) {
|
||||||
|
return (
|
||||||
|
<Layout title="Kiosks" user={props.user} activeNav="kiosks">
|
||||||
|
<div class="two-col">
|
||||||
|
<div>
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Paired Kiosks</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Hardware</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.kiosks.length === 0 ? (
|
||||||
|
<tr><td colspan="4" style="text-align:center; color:#999; padding:2rem">No kiosks paired</td></tr>
|
||||||
|
) : (
|
||||||
|
props.kiosks.map((k) => (
|
||||||
|
<tr>
|
||||||
|
<td><strong>{k.name}</strong></td>
|
||||||
|
<td style="font-size:0.85rem">{k.hardware_model ?? "—"}</td>
|
||||||
|
<td style="font-size:0.85rem; white-space:nowrap">{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</td>
|
||||||
|
<td>
|
||||||
|
{k.enabled
|
||||||
|
? <span class="badge badge-green">Active</span>
|
||||||
|
: <span class="badge badge-gray">Disabled</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Pair New Kiosk</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" action="/admin/kiosks/pair">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="code">Pairing Code</label>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
name="code"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
maxlength="8"
|
||||||
|
pattern="[A-Z2-9]{8}"
|
||||||
|
style="text-transform:uppercase; text-align:center; font-size:1.25rem; letter-spacing:0.2rem"
|
||||||
|
/>
|
||||||
|
<div class="form-hint">8-character code shown on kiosk screen.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name_override">Name Override (optional)</label>
|
||||||
|
<input id="name_override" name="name_override" type="text" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="initial_labels">Initial Labels (optional)</label>
|
||||||
|
<input id="initial_labels" name="initial_labels" type="text" class="form-input" placeholder="lobby, floor-1" />
|
||||||
|
<div class="form-hint">Comma-separated label names.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Pair Kiosk</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{props.pendingCodes.length > 0 && (
|
||||||
|
<div style="margin-top:1.25rem; border-top:1px solid #eee; padding-top:1rem">
|
||||||
|
<div style="font-weight:600; font-size:0.85rem; margin-bottom:0.5rem">Pending Codes</div>
|
||||||
|
{props.pendingCodes.map((pc) => (
|
||||||
|
<div style="display:flex; justify-content:space-between; font-size:0.85rem; padding:0.25rem 0">
|
||||||
|
<code>{pc.code}</code>
|
||||||
|
<span style="color:#666">{formatTime(pc.expires_at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Account ----------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AccountProps {
|
||||||
|
user: string;
|
||||||
|
totpEnabled: boolean;
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountPage(props: AccountProps) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="Account"
|
||||||
|
user={props.user}
|
||||||
|
activeNav="account"
|
||||||
|
flash={
|
||||||
|
props.error
|
||||||
|
? { type: "error", message: props.error }
|
||||||
|
: props.success
|
||||||
|
? { type: "success", message: props.success }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style="max-width:600px">
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Change Password</h2>
|
||||||
|
<form method="post" action="/admin/account/password">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="current_password">Current Password</label>
|
||||||
|
<input id="current_password" name="current_password" type="password" class="form-input" required autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_password">New Password</label>
|
||||||
|
<input id="new_password" name="new_password" type="password" class="form-input" required minlength="12" autocomplete="new-password" />
|
||||||
|
<div class="form-hint">At least 12 characters.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Two-Factor Authentication</h2>
|
||||||
|
{props.totpEnabled ? (
|
||||||
|
<div>
|
||||||
|
<p style="color:#065f46; margin-bottom:1rem">
|
||||||
|
<span class="badge badge-green">Enabled</span>
|
||||||
|
{" "}TOTP is active on this account.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/admin/account/totp/disable">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="disable_password">Enter password to disable</label>
|
||||||
|
<input id="disable_password" name="password" type="password" class="form-input" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-danger">Disable 2FA</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p style="color:#666; margin-bottom:1rem">
|
||||||
|
Protect your account with a TOTP authenticator app.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/admin/account/totp/begin">
|
||||||
|
<button type="submit" class="btn btn-primary">Enable 2FA</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TOTP Enrollment --------------------------------------------------------
|
||||||
|
|
||||||
|
interface TotpEnrollProps {
|
||||||
|
user: string;
|
||||||
|
secret: string;
|
||||||
|
provisioningUri: string;
|
||||||
|
recoveryCodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TotpEnrollPage(props: TotpEnrollProps) {
|
||||||
|
return (
|
||||||
|
<Layout title="Enable Two-Factor Auth" user={props.user} activeNav="account">
|
||||||
|
<div style="max-width:600px">
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Step 1: Scan QR Code</h2>
|
||||||
|
<p style="color:#666; margin-bottom:1rem">
|
||||||
|
Scan this with your authenticator app (Google Authenticator, Authy, etc.).
|
||||||
|
</p>
|
||||||
|
<div style="text-align:center; padding:1rem; background:#f9fafb; border-radius:4px; margin-bottom:1rem">
|
||||||
|
<div id="qr-code" style="display:inline-block"></div>
|
||||||
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary style="cursor:pointer; color:#666; font-size:0.85rem">Can't scan? Enter manually</summary>
|
||||||
|
<code style="display:block; padding:0.75rem; background:#f9fafb; border-radius:4px; margin-top:0.5rem; word-break:break-all; font-size:0.9rem">{props.secret}</code>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Step 2: Save Recovery Codes</h2>
|
||||||
|
<p style="color:#dc2626; font-weight:500; margin-bottom:1rem">
|
||||||
|
Save these codes somewhere safe. They will not be shown again.
|
||||||
|
</p>
|
||||||
|
<div class="code-grid">
|
||||||
|
{props.recoveryCodes.map((code) => (
|
||||||
|
<div class="code-item">{code}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Step 3: Verify</h2>
|
||||||
|
<form method="post" action="/admin/account/totp/confirm">
|
||||||
|
<input type="hidden" name="recovery_codes" value={JSON.stringify(props.recoveryCodes)} />
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="code">Enter code from your authenticator</label>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
name="code"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
maxlength="6"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
inputmode="numeric"
|
||||||
|
style="text-align:center; font-size:1.5rem; letter-spacing:0.3rem; max-width:250px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Confirm & Enable</button>
|
||||||
|
<a href="/admin/account" class="btn btn-ghost" style="margin-left:0.5rem">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Simple list page -------------------------------------------------------
|
||||||
|
|
||||||
|
interface SimpleListProps {
|
||||||
|
user: string;
|
||||||
|
pageTitle: string;
|
||||||
|
description: string;
|
||||||
|
activeNav: string;
|
||||||
|
items: Array<{ name: string; detail?: string; badge?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleListPage(props: SimpleListProps) {
|
||||||
|
return (
|
||||||
|
<Layout title={props.pageTitle} user={props.user} activeNav={props.activeNav}>
|
||||||
|
<p style="color:#666; margin-bottom:1.25rem">{props.description}</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.items.length === 0 ? (
|
||||||
|
<tr><td colspan="2" style="text-align:center; color:#999; padding:2rem">None configured yet</td></tr>
|
||||||
|
) : (
|
||||||
|
props.items.map((item) => (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
{item.badge && (
|
||||||
|
<span class="badge" style={`margin-left:0.5rem; background-color:${item.badge}`}>{item.badge}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style="color:#666">{item.detail ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
165
server/src/web-templates/auth-pages.tsx
Normal file
165
server/src/web-templates/auth-pages.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
/**
|
||||||
|
* Auth page templates: setup, login, TOTP, recovery.
|
||||||
|
*/
|
||||||
|
import { js } from "jsx-htmx";
|
||||||
|
import { MinimalLayout } from "./layout.js";
|
||||||
|
|
||||||
|
// ---- Setup ------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function SetupPage(props: { error?: string; username?: string }) {
|
||||||
|
return (
|
||||||
|
<MinimalLayout
|
||||||
|
title="Initial Setup"
|
||||||
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
|
>
|
||||||
|
<p style="color:#666; margin-bottom:1.25rem">
|
||||||
|
Create your admin account to get started.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/setup">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
maxlength="64"
|
||||||
|
pattern="[a-zA-Z0-9_-]+"
|
||||||
|
value={props.username ?? ""}
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
<div class="form-hint">3–64 characters. Letters, digits, underscore, hyphen.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
minlength="12"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<div class="form-hint">At least 12 characters.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Create Admin Account</button>
|
||||||
|
</form>
|
||||||
|
</MinimalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Login ------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function LoginPage(props: { error?: string; username?: string; welcome?: boolean }) {
|
||||||
|
return (
|
||||||
|
<MinimalLayout
|
||||||
|
title="Sign In"
|
||||||
|
flash={
|
||||||
|
props.error
|
||||||
|
? { type: "error", message: props.error }
|
||||||
|
: props.welcome
|
||||||
|
? { type: "success", message: "Admin account created. Sign in to continue." }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form method="post" action="/auth/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
value={props.username ?? ""}
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</MinimalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TOTP -------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function TotpPage(props: { error?: string }) {
|
||||||
|
return (
|
||||||
|
<MinimalLayout
|
||||||
|
title="Two-Factor Authentication"
|
||||||
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
|
>
|
||||||
|
<p style="color:#666; margin-bottom:1rem">
|
||||||
|
Enter the 6-digit code from your authenticator app.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/auth/totp">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="code">Code</label>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
name="code"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
maxlength="6"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
inputmode="numeric"
|
||||||
|
style="text-align:center; font-size:1.5rem; letter-spacing:0.3rem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Verify</button>
|
||||||
|
</form>
|
||||||
|
<p style="text-align:center; margin-top:1rem">
|
||||||
|
<a href="/auth/recovery">Use a recovery code</a>
|
||||||
|
</p>
|
||||||
|
</MinimalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Recovery ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RecoveryPage(props: { error?: string }) {
|
||||||
|
return (
|
||||||
|
<MinimalLayout
|
||||||
|
title="Recovery Code"
|
||||||
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
|
>
|
||||||
|
<p style="color:#666; margin-bottom:1rem">
|
||||||
|
Enter one of your recovery codes. Each code can only be used once.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/auth/recovery">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="code">Recovery Code</label>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
name="code"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
maxlength="10"
|
||||||
|
style="text-align:center; font-size:1.1rem; letter-spacing:0.15rem; text-transform:uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Verify</button>
|
||||||
|
</form>
|
||||||
|
<p style="text-align:center; margin-top:1rem">
|
||||||
|
<a href="/auth/totp">Back to authenticator code</a>
|
||||||
|
</p>
|
||||||
|
</MinimalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
server/src/web-templates/layout.tsx
Normal file
263
server/src/web-templates/layout.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
/**
|
||||||
|
* Base HTML layout for all admin pages.
|
||||||
|
* Server-side rendered via jsx-htmx — returns string.
|
||||||
|
*/
|
||||||
|
import { css, js } from "jsx-htmx";
|
||||||
|
|
||||||
|
// ---- Shared types -----------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PageProps {
|
||||||
|
title: string;
|
||||||
|
/** Username shown in navbar; omit for unauthenticated pages. */
|
||||||
|
user?: string;
|
||||||
|
/** If true, hide the sidebar nav (used for login/setup). */
|
||||||
|
minimal?: boolean;
|
||||||
|
/** Optional flash message. */
|
||||||
|
flash?: { type: "success" | "error" | "info"; message: string };
|
||||||
|
/** Active nav item key. */
|
||||||
|
activeNav?: string;
|
||||||
|
children?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Components -------------------------------------------------------------
|
||||||
|
|
||||||
|
function NavItem(props: { href: string; label: string; icon: string; active?: boolean }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={props.href}
|
||||||
|
class={`nav-item${props.active ? " active" : ""}`}
|
||||||
|
>
|
||||||
|
<span class="nav-icon">{props.icon}</span>
|
||||||
|
{props.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar(props: { activeNav?: string }) {
|
||||||
|
const a = props.activeNav;
|
||||||
|
return (
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<strong>BetterFrame</strong>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<NavItem href="/admin/" label="Overview" icon="■" active={a === "overview"} />
|
||||||
|
<NavItem href="/admin/cameras" label="Cameras" icon="⚫" active={a === "cameras"} />
|
||||||
|
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
||||||
|
<NavItem href="/admin/templates" label="Templates" icon="▩" active={a === "templates"} />
|
||||||
|
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
||||||
|
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||||
|
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||||
|
<hr />
|
||||||
|
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
||||||
|
<NavItem href="/nrdp/" label="Node-RED" icon="→" />
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Layout -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function Layout(props: PageProps) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{props.title} — BetterFrame</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
|
<style>{css(baseStyles as Parameters<typeof css>[0])}</style>
|
||||||
|
</head>
|
||||||
|
<body class={props.minimal ? "minimal" : "has-sidebar"} {...{ "hx-boost": "true" }}>
|
||||||
|
{!props.minimal && <Sidebar activeNav={props.activeNav} />}
|
||||||
|
<div class="main-wrap">
|
||||||
|
{!props.minimal && props.user && (
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="topbar-title">{props.title}</span>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<span class="topbar-user">{props.user}</span>
|
||||||
|
<form method="post" action="/auth/logout" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-ghost">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
{props.flash && (
|
||||||
|
<div class={`flash flash-${props.flash.type}`}>{props.flash.message}</div>
|
||||||
|
)}
|
||||||
|
<main class="content">{props.children}</main>
|
||||||
|
</div>
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal centered layout for login/setup pages. */
|
||||||
|
export function MinimalLayout(props: { title: string; flash?: PageProps["flash"]; children?: string | string[] }) {
|
||||||
|
return (
|
||||||
|
<Layout title={props.title} minimal flash={props.flash}>
|
||||||
|
<div class="center-card">
|
||||||
|
<div class="card">
|
||||||
|
<h1 class="card-title">{props.title}</h1>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Styles -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const baseStyles = {
|
||||||
|
"*, *::before, *::after": { boxSizing: "border-box" as const },
|
||||||
|
body: {
|
||||||
|
margin: "0",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
backgroundColor: "#f4f5f7",
|
||||||
|
color: "#1a1a2e",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
},
|
||||||
|
"a": { color: "#2563eb", textDecoration: "none" },
|
||||||
|
"a:hover": { textDecoration: "underline" },
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
".has-sidebar": { display: "grid", gridTemplateColumns: "220px 1fr", minHeight: "100vh" },
|
||||||
|
".sidebar": {
|
||||||
|
backgroundColor: "#1a1a2e",
|
||||||
|
color: "#e0e0e0",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
position: "sticky" as const,
|
||||||
|
top: "0",
|
||||||
|
height: "100vh",
|
||||||
|
overflowY: "auto" as const,
|
||||||
|
},
|
||||||
|
".sidebar-brand": { padding: "1.25rem 1rem", fontSize: "1.1rem", borderBottom: "1px solid #2a2a4e" },
|
||||||
|
".sidebar-nav": { padding: "0.5rem 0", display: "flex", flexDirection: "column", gap: "2px" },
|
||||||
|
".nav-item": {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
color: "#c0c0d0",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
borderRadius: "0",
|
||||||
|
},
|
||||||
|
".nav-item:hover": { backgroundColor: "#2a2a4e", color: "#fff", textDecoration: "none" },
|
||||||
|
".nav-item.active": { backgroundColor: "#2563eb", color: "#fff" },
|
||||||
|
".nav-icon": { fontSize: "0.75rem", width: "1.25rem", textAlign: "center" as const },
|
||||||
|
".sidebar hr": { border: "none", borderTop: "1px solid #2a2a4e", margin: "0.5rem 0" },
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
".topbar": {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0.75rem 1.5rem",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderBottom: "1px solid #e0e0e0",
|
||||||
|
},
|
||||||
|
".topbar-title": { fontWeight: "600", fontSize: "1rem" },
|
||||||
|
".topbar-right": { display: "flex", alignItems: "center", gap: "0.75rem" },
|
||||||
|
".topbar-user": { color: "#666", fontSize: "0.85rem" },
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
".main-wrap": { display: "flex", flexDirection: "column", minHeight: "100vh" },
|
||||||
|
".content": { flex: "1", padding: "1.5rem" },
|
||||||
|
|
||||||
|
/* Minimal / center-card */
|
||||||
|
".minimal .content": { display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh" },
|
||||||
|
".center-card": { width: "100%", maxWidth: "420px" },
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
".card": {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||||
|
padding: "1.5rem",
|
||||||
|
},
|
||||||
|
".card-title": { margin: "0 0 1.25rem", fontSize: "1.25rem", fontWeight: "600" },
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
".form-group": { marginBottom: "1rem" },
|
||||||
|
".form-group label": { display: "block", marginBottom: "0.25rem", fontWeight: "500", fontSize: "0.85rem" },
|
||||||
|
".form-input": {
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
border: "1px solid #d0d0d0",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
".form-input:focus": { outline: "none", borderColor: "#2563eb", boxShadow: "0 0 0 2px rgba(37,99,235,0.15)" },
|
||||||
|
".form-hint": { fontSize: "0.8rem", color: "#666", marginTop: "0.25rem" },
|
||||||
|
".form-error": { fontSize: "0.8rem", color: "#dc2626", marginTop: "0.25rem" },
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
".btn": {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
fontWeight: "500",
|
||||||
|
textDecoration: "none",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
".btn-primary": { backgroundColor: "#2563eb", color: "#fff" },
|
||||||
|
".btn-primary:hover": { backgroundColor: "#1d4ed8" },
|
||||||
|
".btn-danger": { backgroundColor: "#dc2626", color: "#fff" },
|
||||||
|
".btn-danger:hover": { backgroundColor: "#b91c1c" },
|
||||||
|
".btn-ghost": { backgroundColor: "transparent", color: "#666", border: "1px solid #d0d0d0" },
|
||||||
|
".btn-ghost:hover": { backgroundColor: "#f0f0f0" },
|
||||||
|
".btn-sm": { padding: "0.25rem 0.5rem", fontSize: "0.8rem" },
|
||||||
|
".btn-block": { width: "100%", justifyContent: "center" },
|
||||||
|
|
||||||
|
/* Flash */
|
||||||
|
".flash": { padding: "0.75rem 1rem", borderRadius: "4px", marginBottom: "1rem", fontSize: "0.9rem" },
|
||||||
|
".flash-success": { backgroundColor: "#d1fae5", color: "#065f46", border: "1px solid #6ee7b7" },
|
||||||
|
".flash-error": { backgroundColor: "#fee2e2", color: "#991b1b", border: "1px solid #fca5a5" },
|
||||||
|
".flash-info": { backgroundColor: "#dbeafe", color: "#1e40af", border: "1px solid #93c5fd" },
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
".stats-grid": { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: "1rem", marginBottom: "1.5rem" },
|
||||||
|
".stat-card": { backgroundColor: "#fff", borderRadius: "8px", padding: "1.25rem", boxShadow: "0 1px 3px rgba(0,0,0,0.08)" },
|
||||||
|
".stat-label": { fontSize: "0.8rem", color: "#666", textTransform: "uppercase" as const, letterSpacing: "0.05em" },
|
||||||
|
".stat-value": { fontSize: "1.75rem", fontWeight: "700", marginTop: "0.25rem" },
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
".table-wrap": { backgroundColor: "#fff", borderRadius: "8px", overflow: "hidden", boxShadow: "0 1px 3px rgba(0,0,0,0.08)" },
|
||||||
|
table: { width: "100%", borderCollapse: "collapse" as const },
|
||||||
|
"th, td": { textAlign: "left" as const, padding: "0.75rem 1rem", borderBottom: "1px solid #eee" },
|
||||||
|
th: { backgroundColor: "#f9fafb", fontWeight: "600", fontSize: "0.8rem", textTransform: "uppercase" as const, letterSpacing: "0.05em", color: "#666" },
|
||||||
|
"tr:hover td": { backgroundColor: "#f9fafb" },
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
".badge": { display: "inline-block", padding: "0.15rem 0.5rem", borderRadius: "12px", fontSize: "0.75rem", fontWeight: "500" },
|
||||||
|
".badge-green": { backgroundColor: "#d1fae5", color: "#065f46" },
|
||||||
|
".badge-gray": { backgroundColor: "#e5e7eb", color: "#374151" },
|
||||||
|
".badge-blue": { backgroundColor: "#dbeafe", color: "#1e40af" },
|
||||||
|
".badge-red": { backgroundColor: "#fee2e2", color: "#991b1b" },
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
".section-header": { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" },
|
||||||
|
".section-title": { fontSize: "1rem", fontWeight: "600", margin: "0" },
|
||||||
|
|
||||||
|
/* Radio group (for camera type toggle) */
|
||||||
|
".radio-group": { display: "flex", gap: "1rem", marginBottom: "0.5rem" },
|
||||||
|
".radio-group label": { display: "flex", alignItems: "center", gap: "0.35rem", fontWeight: "400", cursor: "pointer" },
|
||||||
|
|
||||||
|
/* Two-column layout */
|
||||||
|
".two-col": { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" },
|
||||||
|
"@media (max-width: 768px)": { ".two-col": { gridTemplateColumns: "1fr" } },
|
||||||
|
|
||||||
|
/* Recovery codes */
|
||||||
|
".code-grid": { display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: "0.5rem", fontFamily: "monospace", fontSize: "1rem" },
|
||||||
|
".code-item": { padding: "0.5rem", backgroundColor: "#f9fafb", borderRadius: "4px", textAlign: "center" as const },
|
||||||
|
};
|
||||||
11
server/tsconfig.json
Normal file
11
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./lib",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "jsx-htmx"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "lib"]
|
||||||
|
}
|
||||||
21
tsconfig.base.json
Normal file
21
tsconfig.base.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue