openvibers/openvibe.chat Commits

Chat service: Live's chat behind a service boundary (roadmap Wave 6)

OP OpenVibers committed 23 Sept 2026 · abf1e4b40a · 41 files, +13703 −0

Live's chat server, DMs, history, TTS, sounds, soundboard, moderation utils and word filter
moved as they were (same WebSocket protocol, same REST paths and answers) onto Chat's own
SQLite database with Live's tables and ids. Everything Live still owns goes through one
adapter, server/live-context.js: cached projections of users, streams, channels, channel
policy, bans, follows, cosmetics and settings (no network read per message), and Live
effects for coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat,
notifications, viewer counts and the IP log.

- New rows carry the Network subject next to Live's user ids.
- chat.message.created / chat.dm.created / chat.moderation.action go to an outbox in the
  same transaction; relayed to OpenVibe.Events when EVENTS_URL is set.
- /internal/live/calls and /internal/live/presence: the bridge Live's remaining modules use
  (ordered calls, placeholder ids, idempotent writes, cache invalidations).
- A read mirror copies Chat's changes into Live's tables (Live's readers, rollback).
- scripts/import-from-live.js (idempotent, import_hold, never drops rows), parity-check,
  mirror-flush.
- Tests against stub Live and Network: WS protocol, REST, bridge, import, cache behaviour,
  outbox and mirror, sounds and TTS settings.

Changes

ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..07af271
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,11 @@
++node_modules/
++data/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..a395ff8
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1483 @@
++{
++ "name": "openvibe-chat",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-chat",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "cors": "^2.8.5",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "multer": "^2.0.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1",
++ "ws": "^8.18.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/append-field": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
++ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
++ "license": "MIT"
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/busboy": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
++ "dependencies": {
++ "streamsearch": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=10.16.0"
++ }
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/cors": {
++ "version": "2.8.6",
++ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
++ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
++ "license": "MIT",
++ "dependencies": {
++ "object-assign": "^4",
++ "vary": "^1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "license": "MIT"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "license": "MIT"
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "license": "MIT"
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/multer": {
++ "version": "2.4.0",
++ "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
++ "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
++ "license": "MIT",
++ "dependencies": {
++ "append-field": "^1.0.0",
++ "busboy": "^1.6.0",
++ "type-is": "^1.6.18"
++ },
++ "engines": {
++ "node": ">= 10.16.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-assign": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
++ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.5.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1",
++ "integrity": "sha512-czQpWKEo8cY56Crj5l5Mv8r5vlIIj0vn/HMxv0OlIu5YTuSSlpvXyayYEfn3GQ5orDhaMIhOUlzmQMxUtWXaLA==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/streamsearch": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
++ "engines": {
++ "node": ">=10.0.0"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ },
++ "node_modules/ws": {
++ "version": "8.21.3",
++ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
++ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10.0.0"
++ },
++ "peerDependencies": {
++ "bufferutil": "^4.0.1",
++ "utf-8-validate": ">=5.0.2"
++ },
++ "peerDependenciesMeta": {
++ "bufferutil": {
++ "optional": true
++ },
++ "utf-8-validate": {
++ "optional": true
++ }
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..df4849e
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,36 @@
++{
++ "name": "openvibe-chat",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Chat — rooms, messages, DMs, TTS and sound queues, moderation. Wave 6: Live's chat moved behind a service boundary (same WebSocket protocol and REST paths, own database, Live data through server/live-context.js).",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js",
++ "import-from-live": "node scripts/import-from-live.js"
++ },
++ "keywords": [
++ "openvibe",
++ "chat",
++ "websocket",
++ "dm",
++ "tts"
++ ],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "cookie-parser": "^1.4.7",
++ "cors": "^2.8.5",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "multer": "^2.0.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1",
++ "ws": "^8.18.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++}
ddiff --git a/scripts/import-from-live.js b/scripts/import-from-live.js
nnew file mode 100644
iindex 0000000..2501696
---- /dev/null
++++ b/scripts/import-from-live.js
@@@ -0,0 +1,268 @@
++#!/usr/bin/env node
++/**
++ * Copy OpenVibe.Live's chat tables into OpenVibe.Chat's database.
++ *
++ * node scripts/import-from-live.js --live-db <copy of live.db> [--dry-run] [--chat-db <path>]
++ * [--headroom <n>] [--tables a,b] [--no-projections]
++ *
++ * Run it against a SNAPSHOT (sqlite3 live.db ".backup /tmp/live-snapshot.db"), never the live file.
++ *
++ * - Idempotent: a row already in Chat is recognised by its primary key and never written twice;
++ * running it again after the cutover copies only what Live wrote since the first run.
++ * - Ids are kept (a message, DM or sound keeps its Live id). Chat's own id sequences start
++ * `--headroom` (default 10000) above Live's highest id, so rows Live writes between the
++ * first import and the flag flip still fit below and a second pass can bring them over.
++ * - Never drops a row. What cannot be represented goes to import_hold with the reason:
++ * an id already used by a DIFFERENT row, a constraint the row breaks, rows of Live's
++ * transient *_new migration tables.
++ * - Chat's tables: an existing Chat row always wins (after the cutover Chat is the authority
++ * and may have edited it). Staged tables (Live still writes them in W6): refreshed from Live.
++ * - New columns: *subject_id is filled from Live's linked_accounts (the Network subject).
++ * - media_requests / media_request_settings stay in Live (decision: docs/cutover.md) and are
++ * only reported.
++ * - Also seeds the ctx_* projections (users, streams, slots, channels) so a rehearsal can read
++ * history before Chat's first sync (--no-projections to skip).
++ *
++ * Prints counts per table as JSON and records the run in import_runs. Exit code 1 when Live has
++ * columns Chat does not know (schema drift: fix the schema first, nothing is written).
++ */
++'use strict';
++
++const path = require('path');
++const Database = require('better-sqlite3');
++
++function parseArgs(argv) {
++ const out = { dryRun: false, headroom: 10000, projections: true, tables: null };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ if (a === '--live-db') out.liveDb = argv[++i];
++ else if (a === '--chat-db') out.chatDb = argv[++i];
++ else if (a === '--dry-run') out.dryRun = true;
++ else if (a === '--headroom') out.headroom = Math.max(0, parseInt(argv[++i], 10) || 0);
++ else if (a === '--tables') out.tables = String(argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
++ else if (a === '--no-projections') out.projections = false;
++ else if (a === '--help' || a === '-h') out.help = true;
++ else throw new Error(`unknown argument ${a}`);
++ }
++ return out;
++}
++
++// Columns Live keeps for itself on a table Chat owns (not moved; they stay in Live's copy).
++const LIVE_ONLY_COLUMNS = {
++ channel_sounds: ['media_url', 'media_asset_id'],
++};
++// Columns that never change once a row exists: equal = the same row, different = an id conflict.
++const IDENTITY = {
++ chat_messages: ['stream_id', 'user_id', 'anon_id', 'message', 'timestamp'],
++ dm_conversations: ['created_by', 'created_at'],
++ dm_participants: ['conversation_id', 'user_id'],
++ dm_messages: ['conversation_id', 'sender_id', 'message', 'created_at'],
++ dm_blocks: ['blocker_id', 'blocked_id'],
++ channel_sounds: ['channel_owner_id', 'url', 'created_at'],
++ hidden_relay_users: ['platform', 'external_username', 'created_at'],
++ pending_ip_messages: ['channel_id', 'ip_address', 'message', 'created_at'],
++ moderation_actions: ['action_type', 'actor_user_id', 'created_at'],
++};
++// Subject columns filled from the author's Live user id.
++const SUBJECT_COLUMNS = {
++ chat_messages: [['subject_id', 'user_id']],
++ dm_messages: [['sender_subject_id', 'sender_id']],
++ dm_participants: [['subject_id', 'user_id']],
++ dm_blocks: [['blocker_subject_id', 'blocker_id']],
++ moderation_actions: [['actor_subject_id', 'actor_user_id']],
++ channel_sounds: [['created_by_subject_id', 'created_by']],
++};
++const TRANSIENT = ['chat_messages_new', 'emotes_new', 'channel_sounds_new'];
++const NOT_MOVED = ['media_requests', 'media_request_settings'];
++const STAGED_PKS = {
++ channel_moderators: ['id'],
++ channel_moderation_settings: ['channel_id'],
++ emotes: ['id'],
++ user_tags: ['id'],
++ chat_ai_summaries: ['id'],
++ chat_timeline_events: ['id'],
++};
++
++class Rollback extends Error {}
++
++function run(opts) {
++ if (!opts.liveDb) throw new Error('--live-db <snapshot> is required');
++ if (opts.chatDb) process.env.CHAT_DB_PATH = opts.chatDb;
++ const db = require('../server/db/database');
++ // No mirror capture: these rows came from Live.
++ db.initDb({ captureMirror: false });
++ const chat = db.getDb();
++ const live = new Database(path.resolve(opts.liveDb), { readonly: true, fileMustExist: true });
++
++ const liveTables = new Set(live.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
++ const colsOf = (conn, t) => conn.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
++
++ const plan = [
++ ...Object.entries(db.CHAT_TABLES).map(([t, pk]) => ({ table: t, pk, kind: 'chat' })),
++ ...Object.entries(STAGED_PKS).map(([t, pk]) => ({ table: t, pk, kind: 'staged' })),
++ ].filter((p) => !opts.tables || opts.tables.includes(p.table));
++
++ // Schema drift check first: nothing is written when Live has columns Chat cannot hold.
++ const drift = [];
++ for (const p of plan) {
++ if (!liveTables.has(p.table)) continue;
++ const chatCols = new Set(colsOf(chat, p.table));
++ const extra = colsOf(live, p.table).filter((c) => !chatCols.has(c) && !(LIVE_ONLY_COLUMNS[p.table] || []).includes(c));
++ if (extra.length) drift.push(`${p.table}: ${extra.join(', ')}`);
++ }
++ if (drift.length) {
++ live.close();
++ const err = new Error(`Live has columns Chat does not know — update server/db/schema.sql first:\n ${drift.join('\n ')}`);
++ err.code = 'SCHEMA_DRIFT';
++ throw err;
++ }
++
++ // Network subjects by Live user id (newest link wins, as Live resolves them).
++ const subjects = new Map();
++ if (liveTables.has('linked_accounts') && colsOf(live, 'linked_accounts').includes('subject_id')) {
++ for (const r of live.prepare("SELECT user_id, subject_id FROM linked_accounts WHERE service = 'network' AND subject_id IS NOT NULL ORDER BY id").iterate()) subjects.set(r.user_id, r.subject_id);
++ }
++
++ const holdStmt = chat.prepare('INSERT OR IGNORE INTO import_hold (source_table, source_pk, reason, row_json) VALUES (?, ?, ?, ?)');
++ const hold = (table, pkObj, reason, row) => holdStmt.run(table, JSON.stringify(pkObj), String(reason).slice(0, 300), JSON.stringify(row)).changes;
++
++ const report = { live_db: path.resolve(opts.liveDb), dry_run: !!opts.dryRun, tables: {} };
++
++ for (const p of plan) {
++ const r = { live: 0, inserted: 0, identical: 0, refreshed: 0, chat_kept: 0, held: 0 };
++ report.tables[p.table] = r;
++ if (!liveTables.has(p.table)) { r.missing_in_live = true; continue; }
++ const chatCols = new Set(colsOf(chat, p.table));
++ const cols = colsOf(live, p.table).filter((c) => chatCols.has(c));
++ const subjectCols = (SUBJECT_COLUMNS[p.table] || []).filter(([sc]) => chatCols.has(sc));
++ const insertCols = cols.concat(subjectCols.map(([sc]) => sc));
++ const where = p.pk.map((k) => `${k} = ?`).join(' AND ');
++ const getChat = chat.prepare(`SELECT * FROM ${p.table} WHERE ${where}`);
++ const ins = chat.prepare(`INSERT INTO ${p.table} (${insertCols.join(', ')}) VALUES (${insertCols.map(() => '?').join(', ')})`);
++ const upd = cols.filter((c) => !p.pk.includes(c));
++ const refresh = upd.length ? chat.prepare(`UPDATE ${p.table} SET ${upd.map((c) => `${c} = ?`).join(', ')} WHERE ${where}`) : null;
++ const identity = IDENTITY[p.table] || null;
++ const orderBy = p.pk.join(', ');
++
++ const apply = () => {
++ for (const row of live.prepare(`SELECT ${cols.join(', ')} FROM ${p.table} ORDER BY ${orderBy}`).iterate()) {
++ r.live++;
++ const pkVals = p.pk.map((k) => row[k]);
++ const pkObj = Object.fromEntries(p.pk.map((k) => [k, row[k]]));
++ const existing = getChat.get(...pkVals);
++ if (!existing) {
++ try {
++ ins.run(...cols.map((c) => row[c]), ...subjectCols.map(([, uidCol]) => subjects.get(row[uidCol]) || null));
++ r.inserted++;
++ } catch (err) {
++ r.held += hold(p.table, pkObj, `insert failed: ${err.message}`, row);
++ }
++ continue;
++ }
++ const same = cols.every((c) => existing[c] === row[c] || (existing[c] == null && row[c] == null));
++ if (same) { r.identical++; continue; }
++ if (identity && !identity.every((c) => !cols.includes(c) || existing[c] === row[c] || (existing[c] == null && row[c] == null))) {
++ // Same id, different row: Chat's stays, Live's is held for a person to look at.
++ r.held += hold(p.table, pkObj, 'id already used by a different row in chat', row);
++ continue;
++ }
++ if (p.kind === 'staged' && refresh) {
++ refresh.run(...upd.map((c) => row[c]), ...pkVals);
++ r.refreshed++;
++ } else {
++ r.chat_kept++;
++ }
++ }
++ };
++
++ try {
++ chat.transaction(() => { apply(); if (opts.dryRun) throw new Rollback(); })();
++ } catch (err) {
++ if (!(err instanceof Rollback)) throw err;
++ }
++ }
++
++ // Transient migration tables (Live rebuilds a table through <name>_new): never expected to
++ // hold rows; if a snapshot caught one mid-rebuild, keep every row for review.
++ for (const t of TRANSIENT) {
++ if (!liveTables.has(t)) continue;
++ const r = { live: 0, held: 0 };
++ report.tables[t] = r;
++ try {
++ chat.transaction(() => {
++ const pkCol = colsOf(live, t).includes('id') ? 'id' : 'rowid';
++ for (const row of live.prepare(`SELECT rowid AS _rowid, * FROM ${t}`).iterate()) {
++ r.live++;
++ r.held += hold(t, { [pkCol]: pkCol === 'id' ? row.id : row._rowid }, 'row of a transient migration table (Live rebuilds tables through <name>_new)', row);
++ }
++ if (opts.dryRun) throw new Rollback();
++ })();
++ } catch (err) { if (!(err instanceof Rollback)) throw err; }
++ }
++ for (const t of NOT_MOVED) {
++ if (liveTables.has(t)) report.tables[t] = { live: live.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n, not_moved: 'stays in Live (media queue is Live-owned in W6)' };
++ }
++
++ if (!opts.dryRun) {
++ // Chat's new ids start above Live's highest id plus headroom (never lowered).
++ chat.transaction(() => {
++ for (const [t] of Object.entries(db.CHAT_TABLES)) {
++ if (!liveTables.has(t) || !colsOf(live, t).includes('id')) continue;
++ const liveMax = live.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${t}`).get().m;
++ const chatMax = chat.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM ${t}`).get().m;
++ const target = Math.max(liveMax + opts.headroom, chatMax);
++ const cur = chat.prepare('SELECT seq FROM sqlite_sequence WHERE name = ?').get(t);
++ if (!cur) chat.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(t, target);
++ else if (cur.seq < target) chat.prepare('UPDATE sqlite_sequence SET seq = ? WHERE name = ?').run(target, t);
++ report.tables[t].next_id = Math.max(target, cur ? cur.seq : 0) + 1;
++ }
++ })();
++
++ if (opts.projections) {
++ const seed = (table, sql, cols) => {
++ if (!liveTables.has(sql.table)) return 0;
++ const have = new Set(colsOf(live, sql.table));
++ const pick = cols.filter((c) => have.has(c.from || c.to));
++ const st = chat.prepare(`INSERT INTO ${table} (${pick.map((c) => c.to).join(', ')}, synced_at) VALUES (${pick.map(() => '?').join(', ')}, 0)
++ ON CONFLICT(id) DO NOTHING`);
++ let n = 0;
++ chat.transaction(() => {
++ for (const row of live.prepare(`SELECT ${pick.map((c) => c.from || c.to).join(', ')} FROM ${sql.table}`).iterate()) {
++ n += st.run(...pick.map((c) => (c.to === 'subject_id' ? (subjects.get(row.id) || null) : row[c.from || c.to]))).changes;
++ }
++ })();
++ return n;
++ };
++ const c = (to, from) => ({ to, from });
++ report.projections = {
++ ctx_users: seed('ctx_users', { table: 'users' }, [c('id'), c('username'), c('display_name'), c('avatar_url'), c('profile_color'), c('role'), c('is_banned'), c('ban_reason'), c('is_owner'), c('created_at'), c('subject_id', 'id')]),
++ ctx_streams: seed('ctx_streams', { table: 'streams' }, [c('id'), c('user_id'), c('channel_id'), c('managed_stream_id'), c('title'), c('is_live'), c('started_at'), c('ended_at'), c('created_at')]),
++ ctx_managed_streams: seed('ctx_managed_streams', { table: 'managed_streams' }, [c('id'), c('user_id'), c('slug'), c('title'), c('sort_order'), c('created_at')]),
++ ctx_channels: seed('ctx_channels', { table: 'channels' }, [c('id'), c('user_id'), c('title')]),
++ };
++ }
++ chat.prepare('INSERT INTO import_runs (live_db, dry_run, counts, finished_at) VALUES (?, 0, ?, CURRENT_TIMESTAMP)').run(report.live_db, JSON.stringify(report.tables));
++ }
++ report.held_total = chat.prepare('SELECT COUNT(*) AS n FROM import_hold').get().n;
++ live.close();
++ return report;
++}
++
++if (require.main === module) {
++ let opts;
++ try { opts = parseArgs(process.argv.slice(2)); } catch (err) { console.error(err.message); process.exit(2); }
++ if (opts.help || !opts.liveDb) {
++ console.log('usage: node scripts/import-from-live.js --live-db <snapshot> [--dry-run] [--chat-db <path>] [--headroom <n>] [--tables a,b] [--no-projections]');
++ process.exit(opts.help ? 0 : 2);
++ }
++ try {
++ const report = run(opts);
++ console.log(JSON.stringify(report, null, 2));
++ try { require('../server/db/database').close(); } catch { /* */ }
++ } catch (err) {
++ console.error(err.message);
++ process.exit(err.code === 'SCHEMA_DRIFT' ? 1 : 3);
++ }
++}
++
++module.exports = { run, parseArgs, IDENTITY, SUBJECT_COLUMNS, LIVE_ONLY_COLUMNS };
ddiff --git a/scripts/mirror-flush.js b/scripts/mirror-flush.js
nnew file mode 100644
iindex 0000000..d780de0
---- /dev/null
++++ b/scripts/mirror-flush.js
@@@ -0,0 +1,25 @@
++#!/usr/bin/env node
++/**
++ * Send whatever is still queued in live_mirror_outbox to Live, once, without starting the service.
++ * For a rollback when the service cannot run: every change Chat made reaches Live's tables before
++ * Live becomes the chat authority again (docs/cutover.md). Uses the same env as the service
++ * (/etc/openvibe/chat.env); LIVE_MIRROR is forced on.
++ *
++ * node scripts/mirror-flush.js → { sent, pending, error }
++ */
++'use strict';
++
++process.env.LIVE_MIRROR = '1';
++const config = require('../server/config');
++const db = require('../server/db/database');
++const { createMirror } = require('../server/bridge/live-mirror');
++
++(async () => {
++ db.initDb({ captureMirror: false });
++ const mirror = createMirror({ config });
++ const before = mirror.pending();
++ const r = await mirror.flush();
++ console.log(JSON.stringify({ pending_before: before, ...r }, null, 2));
++ db.close();
++ process.exit(r.pending ? 1 : 0);
++})().catch((err) => { console.error(err.message); process.exit(3); });
ddiff --git a/scripts/parity-check.js b/scripts/parity-check.js
nnew file mode 100644
iindex 0000000..d23e4c3
---- /dev/null
++++ b/scripts/parity-check.js
@@@ -0,0 +1,75 @@
++#!/usr/bin/env node
++/**
++ * Compare what Live and Chat answer for the same chat reads (cutover rehearsal and flip checks).
++ *
++ * node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 \
++ * [--before "2026-09-22 10:00:00"] [--stream 123]... [--channel 45]... [--token <jwt>]
++ *
++ * --before pins history pages to messages older than the snapshot, so rows written since do not
++ * count as differences. --token (a Network JWT of a test account) adds the DM routes. Prints one
++ * line per path (same / DIFFERENT with the first differing keys) and exits 1 on any difference.
++ * Fields that legitimately move between two reads (latest_id of a room that grew, request ids)
++ * are ignored.
++ */
++'use strict';
++
++function parseArgs(argv) {
++ const o = { streams: [], channels: [] };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i], v = argv[i + 1];
++ if (a === '--live') { o.live = v; i++; } else if (a === '--chat') { o.chat = v; i++; } else if (a === '--before') { o.before = v; i++; } else if (a === '--stream') { o.streams.push(v); i++; } else if (a === '--channel') { o.channels.push(v); i++; } else if (a === '--token') { o.token = v; i++; } else throw new Error(`unknown argument ${a}`);
++ }
++ if (!o.live || !o.chat) throw new Error('--live and --chat are required');
++ return o;
++}
++
++const IGNORE = new Set(['request_id', 'trace_id', 'latest_id']);
++function diff(a, b, at = '', out = []) {
++ if (out.length > 5) return out;
++ if (typeof a !== typeof b || Array.isArray(a) !== Array.isArray(b)) { out.push(`${at || '/'}: ${JSON.stringify(a)?.slice(0, 60)} ≠ ${JSON.stringify(b)?.slice(0, 60)}`); return out; }
++ if (Array.isArray(a)) {
++ if (a.length !== b.length) out.push(`${at}: length ${a.length} ≠ ${b.length}`);
++ for (let i = 0; i < Math.min(a.length, b.length); i++) diff(a[i], b[i], `${at}[${i}]`, out);
++ return out;
++ }
++ if (a && typeof a === 'object') {
++ for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { if (!IGNORE.has(k)) diff(a[k], b[k], `${at}.${k}`, out); }
++ return out;
++ }
++ if (a !== b) out.push(`${at}: ${JSON.stringify(a)} ≠ ${JSON.stringify(b)}`);
++ return out;
++}
++
++async function get(base, p, token) {
++ const res = await fetch(`${base.replace(/\/+$/, '')}${p}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
++ let body = null; try { body = await res.json(); } catch { /* */ }
++ return { status: res.status, body };
++}
++
++async function main() {
++ const o = parseArgs(process.argv.slice(2));
++ const before = o.before ? `&before=${encodeURIComponent(o.before)}` : '';
++ const paths = [
++ `/api/chat/global/history?limit=200${before}`,
++ '/api/chat/filters/friendly',
++ '/api/chat/gif/providers',
++ '/api/tts/settings',
++ '/api/tts/voices',
++ ...o.streams.map((s) => `/api/chat/${s}/history?limit=200${before}`),
++ ...o.streams.map((s) => `/api/sounds/all/${s}`),
++ ...o.channels.map((c) => `/api/chat/channel/${c}/history?limit=200${before}`),
++ ...o.channels.map((c) => `/api/sounds/channel/${c}`),
++ ];
++ const authed = o.token ? ['/api/dm/conversations', '/api/dm/unread', '/api/dm/blocks'] : [];
++ let bad = 0;
++ for (const p of [...paths, ...authed]) {
++ const tok = authed.includes(p) ? o.token : null;
++ const [a, b] = await Promise.all([get(o.live, p, tok), get(o.chat, p, tok)]);
++ const d = a.status !== b.status ? [`status ${a.status} ≠ ${b.status}`] : diff(a.body, b.body);
++ if (d.length) { bad++; console.log(`DIFFERENT ${p}\n ${d.join('\n ')}`); } else console.log(`same ${p}`);
++ }
++ console.log(bad ? `\n${bad} path(s) differ` : '\nall paths answer the same');
++ process.exit(bad ? 1 : 0);
++}
++
++main().catch((err) => { console.error(err.message); process.exit(2); });
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..07d15e7
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,174 @@
++/**
++ * OpenVibe.Chat — HTTP app and WebSocket upgrade handling.
++ *
++ * Browsers reach Chat on Live's origin: nginx sends /ws/chat and the chat REST prefixes
++ * (/api/chat/, /api/dm/, /api/tts/, /api/sounds) here (docs/cutover.md), so the guards Live ran
++ * in front of those routes run here too, with Live's values: credentialed CORS for Live's
++ * origins, the /api rate limit, IP/network bans (admins exempt) and the ov_banned cookie, the
++ * WebSocket origin allow-list and IP-ban check at upgrade.
++ */
++'use strict';
++
++const express = require('express');
++const cookieParser = require('cookie-parser');
++const cors = require('cors');
++const rateLimit = require('express-rate-limit');
++const config = require('./config');
++const ctx = require('./live-context');
++const { extractToken, extractWsToken, authenticateWs } = require('./auth/auth');
++
++function normalizeOrigin(origin) {
++ if (!origin || typeof origin !== 'string') return null;
++ try {
++ return new URL(origin).origin;
++ } catch {
++ return null;
++ }
++}
++
++/** Live's getAllowedOrigins(), plus ALLOWED_ORIGINS. */
++function getAllowedOrigins() {
++ const allowed = new Set();
++ const baseOrigin = normalizeOrigin(config.baseUrl);
++ if (baseOrigin) {
++ allowed.add(baseOrigin);
++ // www variant (and vice versa), as Live does, so www.openvibe.live works too
++ try {
++ const url = new URL(baseOrigin);
++ if (url.hostname.startsWith('www.')) allowed.add(`${url.protocol}//${url.hostname.slice(4)}${url.port ? ':' + url.port : ''}`);
++ else allowed.add(`${url.protocol}//www.${url.hostname}${url.port ? ':' + url.port : ''}`);
++ } catch { /* */ }
++ }
++ allowed.add(normalizeOrigin(config.networkUrl) || 'https://openvibe.network');
++ allowed.add('https://openvibe.games');
++ allowed.add('https://openvibe.tools');
++ if (!config.isProduction) {
++ ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost:3200']
++ .forEach((o) => allowed.add(o));
++ }
++ for (const o of config.extraOrigins) { const n = normalizeOrigin(o); if (n) allowed.add(n); }
++ return allowed;
++}
++
++function createApp({ chatServer, bridge, mirror, relay }) {
++ const allowedOrigins = getAllowedOrigins();
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy); // Cloudflare → nginx → Node
++
++ /** Exact allow-list first, then *.openvibe.tools over https (Live's isAllowedOrigin). */
++ function isAllowedOrigin(origin) {
++ if (allowedOrigins.has(origin)) return true;
++ try {
++ const u = new URL(origin);
++ if (u.protocol !== 'https:') return false;
++ return u.hostname === 'openvibe.tools' || u.hostname.endsWith('.openvibe.tools');
++ } catch { return false; }
++ }
++
++ // ── Internal (loopback; never routed by nginx) ─────────────
++ // Alert sounds travel as base64 in broadcasts (uploads up to MAX_SOUND_SIZE_KB): room for them.
++ app.use('/internal/live', express.json({ limit: '16mb' }), bridge);
++ app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
++ app.get('/ready', (req, res) => {
++ const ready = !!ctx.stats.lastSyncAt;
++ res.status(ready ? 200 : 503).json({
++ ready,
++ connections: chatServer.getTotalConnections(),
++ live: { last_sync_at: ctx.stats.lastSyncAt, failures: ctx.stats.failures, last_error: ctx.stats.lastError },
++ mirror: mirror ? { enabled: config.live.mirror, pending: mirror.pending(), last_error: mirror.lastError() } : null,
++ events: { enabled: !!config.events.url },
++ });
++ });
++
++ // ── Public: the same middleware Live runs in front of these routes ─
++ const allowlistedCors = cors({
++ origin(origin, callback) {
++ if (!origin) return callback(null, true);
++ if (isAllowedOrigin(origin)) return callback(null, true);
++ console.warn(`[CORS] Rejected origin: "${origin}"`);
++ return callback(new Error('Origin not allowed by CORS'));
++ },
++ credentials: true,
++ });
++ app.use(allowlistedCors);
++ app.use(express.json({ limit: '1mb' }));
++ app.use(express.urlencoded({ extended: true, limit: '256kb' }));
++ app.use(cookieParser());
++ app.use((err, req, res, next) => {
++ if (err && err.message === 'Origin not allowed by CORS') return res.status(403).json({ error: 'Origin not allowed' });
++ next(err);
++ });
++
++ // Live's /api limiter (per process, so chat routes now have their own budget).
++ app.use('/api/', rateLimit({
++ windowMs: 60 * 1000,
++ max: (req) => ((req.method === 'GET' || req.method === 'HEAD') ? 900 : 180),
++ standardHeaders: true,
++ legacyHeaders: false,
++ message: { error: 'Too many requests, slow down partner' },
++ }));
++
++ // Banned networks and the ban cookie (Live's middleware, API branch). Admins pass: they may
++ // share an address with a banned person.
++ async function isBanExemptAdmin(req) {
++ if (req._ovBanUser === undefined) {
++ let user = null;
++ try { const t = extractToken(req); user = t ? await ctx.authenticate(t) : null; } catch { user = null; }
++ req._ovBanUser = user;
++ }
++ const u = req._ovBanUser;
++ return !!(u && !u.is_banned && u.role === 'admin');
++ }
++ app.use(async (req, res, next) => {
++ try {
++ const ipBan = ctx.getIpBan(req.ip, null);
++ if (ipBan && !(await isBanExemptAdmin(req))) return res.status(403).json({ error: 'Access denied' });
++ } catch { /* policy unavailable — let the request through, as Live does on a DB error */ }
++ if (req.cookies && req.cookies.ov_banned === '1') {
++ if (await isBanExemptAdmin(req)) { res.clearCookie('ov_banned'); res.clearCookie('ov_banned_name'); return next(); }
++ return res.status(403).json({ error: 'Account is banned' });
++ }
++ next();
++ });
++
++ app.use('/api/chat', require('./chat/routes'));
++ app.use('/api/dm', require('./chat/dm-routes'));
++ app.use('/api/tts', require('./chat/tts-routes'));
++ app.use('/api/sounds', require('./chat/sounds-routes'));
++
++ app.use((req, res) => res.status(404).json({ error: 'Not found' }));
++ app.use((err, req, res, _next) => {
++ if (err.name === 'MulterError' || (err.message && err.message.includes('file'))) {
++ return res.status(400).json({ error: err.message || 'File upload error' });
++ }
++ console.error('[Server] Unhandled route error:', err.message || err);
++ if (!res.headersSent) res.status(500).json({ error: 'Internal server error' });
++ });
++
++ /** Live's upgrade guard for /ws/chat: exact origin allow-list, then IP / network bans. */
++ async function handleUpgrade(req, socket, head) {
++ const url = req.url || '';
++ const origin = normalizeOrigin(req.headers.origin);
++ if (origin && !allowedOrigins.has(origin)) {
++ console.warn(`[Server] WebSocket upgrade rejected — origin "${origin}" not in allowed origins`);
++ socket.destroy();
++ return;
++ }
++ if (!url.startsWith('/ws/chat')) { socket.destroy(); return; }
++ try {
++ const wsIp = chatServer.getClientIp(req);
++ if (ctx.isIpBanned(wsIp, null)) {
++ // Admins pass network bans (shared home network).
++ let exempt = false;
++ try { const u = await authenticateWs(extractWsToken(req)); exempt = !!(u && !u.is_banned && u.role === 'admin'); } catch { exempt = false; }
++ if (!exempt) { socket.destroy(); return; }
++ }
++ } catch { /* non-critical — allow through on a policy error */ }
++ chatServer.handleUpgrade(req, socket, head);
++ }
++
++ return { app, handleUpgrade, allowedOrigins, isAllowedOrigin };
++}
++
++module.exports = { createApp, getAllowedOrigins, normalizeOrigin };
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
nnew file mode 100644
iindex 0000000..ad20b90
---- /dev/null
++++ b/server/auth/auth.js
@@@ -0,0 +1,180 @@
++/**
++ * OpenVibe.Chat — request authentication.
++ *
++ * The same tokens Live accepts (a Network RS256 JWT in Authorization / ov_token / token cookie,
++ * or an hbt_ API token) with the same rules, resolved by Live through live-context.authenticate()
++ * — Live owns the account links, auto-creates first-time accounts and knows API-token scopes.
++ * Moved from OpenVibe.Live server/auth/auth.js; the middleware are async now because resolution
++ * is a (cached) call to Live.
++ */
++'use strict';
++
++const ctx = require('../live-context');
++
++/**
++ * What an hbt_ API token may do over REST, by scope (Live's apiTokenAllows, verbatim).
++ * - money, staff and credential routes refuse API tokens outright;
++ * - reads (GET/HEAD) need any scope;
++ * - writes need the scope that covers the area, and areas with no scope refuse tokens.
++ */
++const TOKEN_DENIED_PREFIXES = ['/api/admin', '/api/mod', '/api/funds', '/api/payments', '/api/auth/stream-key', '/api/auth/tokens', '/api/cosmetics', '/api/analytics'];
++const TOKEN_WRITE_SCOPES = [
++ ['/api/chat', ['chat']], ['/api/dm', ['chat']], ['/api/emotes', ['chat']], ['/api/sounds', ['chat']], ['/api/tts', ['chat']],
++];
++function apiTokenAllows(req, scopes) {
++ const path = String(req.originalUrl || req.url || '').split('?')[0];
++ const under = (prefix) => path === prefix || path.startsWith(prefix + '/');
++ if (TOKEN_DENIED_PREFIXES.some(under)) return false;
++ const list = Array.isArray(scopes) ? scopes : [];
++ if (req.method === 'GET' || req.method === 'HEAD') return list.length > 0;
++ const rule = TOKEN_WRITE_SCOPES.find(([prefix]) => under(prefix));
++ return !!rule && rule[1].some((sc) => list.includes(sc));
++}
++
++/**
++ * Extract the token from Authorization header or cookie
++ */
++function extractToken(req) {
++ const authHeader = req.headers.authorization;
++ if (authHeader && authHeader.startsWith('Bearer ')) {
++ return authHeader.slice(7);
++ }
++
++ // Check both cookie names: 'token' (legacy openvibelive) and 'ov_token' (shared network)
++ if (req.cookies) {
++ if (req.cookies.ov_token) return req.cookies.ov_token;
++ if (req.cookies.token) return req.cookies.token;
++ }
++
++ // Raw Node/WebSocket upgrade requests do not go through cookie-parser,
++ // so parse the Cookie header directly as a fallback.
++ const cookieHeader = req.headers?.cookie;
++ if (cookieHeader && typeof cookieHeader === 'string') {
++ const parsed = {};
++ for (const part of cookieHeader.split(';')) {
++ const idx = part.indexOf('=');
++ if (idx === -1) continue;
++ const key = part.slice(0, idx).trim();
++ const value = part.slice(idx + 1).trim();
++ if (!key) continue;
++ try { parsed[key] = decodeURIComponent(value); } catch { parsed[key] = value; }
++ }
++ if (parsed.ov_token) return parsed.ov_token;
++ if (parsed.token) return parsed.token;
++ }
++
++ return null;
++}
++
++/**
++ * Extract the token for a WebSocket upgrade request
++ */
++function extractWsToken(req) {
++ // Prefer explicit ?token= query param — clients (e.g. broadcast signaling)
++ // put the freshest localStorage token in the URL, which may be newer than
++ // a stale httpOnly cookie from an earlier session.
++ try {
++ const url = new URL(req.url || '/', 'http://localhost');
++ const queryToken = url.searchParams.get('token');
++ if (queryToken && queryToken !== 'null' && queryToken !== 'undefined') return queryToken;
++ } catch { /* fall through */ }
++
++ // Fall back to cookie / Authorization header
++ const direct = extractToken(req);
++ if (direct) return direct;
++
++ // Legacy: req.query fallback for non-URL parse environments
++ return (req.query && req.query.token) || null;
++}
++
++/**
++ * Authenticate a WebSocket connection (resolves to user or null).
++ * Supports both openvibe.network JWT and API tokens (hbt_xxx).
++ */
++async function authenticateWs(token) {
++ if (!token) return null;
++ const user = await ctx.authenticate(token);
++ if (user && user.auth_source === 'api_token') user._authSource = 'api_token';
++ return user;
++}
++
++/**
++ * Express middleware — requires a valid openvibe.network JWT or API token.
++ */
++async function requireAuth(req, res, next) {
++ const token = extractToken(req);
++ if (!token) {
++ return res.status(401).json({ error: 'Authentication required' });
++ }
++ let user = null;
++ try { user = await ctx.authenticate(token); } catch { user = null; }
++ if (!user) {
++ return ctx.authFailureReason(token) === 'unresolved'
++ ? res.status(401).json({ error: 'Unable to resolve account' })
++ : res.status(401).json({ error: 'Invalid or expired token' });
++ }
++ if (user.auth_source === 'api_token') {
++ if (user.is_banned) {
++ return res.status(403).json({ error: 'Account is banned' });
++ }
++ if (!apiTokenAllows(req, user.scopes)) {
++ return res.status(403).json({ error: 'This API token\'s scopes do not allow this request' });
++ }
++ req.user = user;
++ req.authSource = 'api_token';
++ req.tokenScopes = user.scopes || [];
++ return next();
++ }
++ if (user.is_banned) {
++ return res.status(403).json({ error: 'Account is banned', reason: user.ban_reason });
++ }
++ req.user = user;
++ req.authSource = 'network';
++ next();
++}
++
++/**
++ * Express middleware — optional auth (attaches user if token present)
++ */
++async function optionalAuth(req, res, next) {
++ const token = extractToken(req);
++ if (token) {
++ let user = null;
++ try { user = await ctx.authenticate(token); } catch { user = null; }
++ if (user && !user.is_banned) {
++ if (user.auth_source === 'api_token') {
++ if (apiTokenAllows(req, user.scopes)) {
++ req.user = user;
++ req.authSource = 'api_token';
++ req.tokenScopes = user.scopes || [];
++ }
++ } else {
++ req.user = user;
++ req.authSource = 'network';
++ }
++ }
++ }
++ next();
++}
++
++/**
++ * Express middleware — requires admin role
++ */
++function requireAdmin(req, res, next) {
++ requireAuth(req, res, () => {
++ if (req.user.role !== 'admin') {
++ return res.status(403).json({ error: 'Admin access required' });
++ }
++ next();
++ });
++}
++
++module.exports = {
++ apiTokenAllows,
++ extractToken,
++ extractWsToken,
++ authenticateWs,
++ requireAuth,
++ optionalAuth,
++ requireAdmin,
++};
ddiff --git a/server/auth/permissions.js b/server/auth/permissions.js
nnew file mode 100644
iindex 0000000..d0189e2
---- /dev/null
++++ b/server/auth/permissions.js
@@@ -0,0 +1,153 @@
++/**
++ * OpenVibe.Chat — capability & scope permission checks used by chat.
++ *
++ * Moved from OpenVibe.Live server/auth/permissions.js (the chat subset, rules unchanged).
++ * Roles come with the user from Live; streams, channels and channel moderators are Live data
++ * read through live-context (warm caches, no network call per check).
++ *
++ * Roles answer "who are you globally?" user, streamer, global_mod, admin
++ * Scope answers "where do you have power?" channel_moderators (per channel)
++ */
++'use strict';
++
++const ctx = require('../live-context');
++
++// ── Role hierarchy (higher = more power) ─────────────────────
++const ROLE_RANK = {
++ user: 0,
++ streamer: 1,
++ global_mod: 2,
++ admin: 3,
++};
++
++function roleRank(role) {
++ return ROLE_RANK[role] ?? 0;
++}
++
++// ── Core role checks ─────────────────────────────────────────
++
++function isAdmin(user) {
++ return user?.role === 'admin';
++}
++
++/**
++ * Owner = an admin with Live's local is_owner flag. Owners are the ONLY users who may view or
++ * change API keys (TTS credentials here).
++ */
++function isOwner(user) {
++ return !!(user && user.is_owner && (user.role === 'admin' || roleRank(user.role) >= ROLE_RANK.admin));
++}
++
++function isGlobalMod(user) {
++ return user?.role === 'global_mod';
++}
++
++function isGlobalModOrAbove(user) {
++ return roleRank(user?.role) >= ROLE_RANK.global_mod;
++}
++
++const isStaff = isGlobalModOrAbove;
++
++function isStreamer(user) {
++ return roleRank(user?.role) >= ROLE_RANK.streamer;
++}
++
++// ── Channel mod checks ──────────────────────────────────────
++
++/**
++ * Is this user a channel moderator for the given channel?
++ */
++function isChannelMod(user, channelId) {
++ if (!user?.id || !channelId) return false;
++ return !!ctx.isChannelModerator(user.id, channelId);
++}
++
++/**
++ * Is this user the owner of the given channel?
++ */
++function isChannelOwner(user, channelId) {
++ if (!user?.id || !channelId) return false;
++ const channel = ctx.getChannelById(channelId);
++ return channel?.user_id === user.id;
++}
++
++/**
++ * Does a stream belong to this user?
++ */
++function isStreamOwner(user, streamId) {
++ if (!user?.id || !streamId) return false;
++ const stream = ctx.getStreamById(streamId);
++ return stream?.user_id === user.id;
++}
++
++/**
++ * Get the channel_id for a given stream.
++ */
++function getChannelIdForStream(streamId) {
++ if (!streamId) return null;
++ const stream = ctx.getStreamById(streamId);
++ return stream?.channel_id || null;
++}
++
++// ── Capability checks ────────────────────────────────────────
++
++/**
++ * Can this user moderate a specific channel's chat?
++ *
++ * True for: admin, global_mod, channel owner, channel mod
++ */
++function canModerateChannel(user, channelId) {
++ if (!user) return false;
++ if (isGlobalModOrAbove(user)) return true;
++ if (isChannelOwner(user, channelId)) return true;
++ return isChannelMod(user, channelId);
++}
++
++/**
++ * Can this user moderate a specific stream's chat?
++ *
++ * Resolves stream → channel, then checks channel moderation.
++ */
++function canModerateStream(user, streamId) {
++ if (!user) return false;
++ if (isGlobalModOrAbove(user)) return true;
++ if (isStreamOwner(user, streamId)) return true;
++ const channelId = getChannelIdForStream(streamId);
++ if (channelId && isChannelMod(user, channelId)) return true;
++ return false;
++}
++
++/**
++ * Can this user view chat logs?
++ */
++function canViewChatLogs(user, scope = 'own') {
++ if (!user) return false;
++ if (isGlobalModOrAbove(user)) return true;
++ return scope === 'own';
++}
++
++/**
++ * Can this user view another user's chat logs?
++ */
++function canViewOtherUserLogs(user) {
++ return isGlobalModOrAbove(user);
++}
++
++module.exports = {
++ ROLE_RANK,
++ roleRank,
++ isAdmin,
++ isOwner,
++ isGlobalMod,
++ isGlobalModOrAbove,
++ isStaff,
++ isStreamer,
++ isChannelMod,
++ isChannelOwner,
++ isStreamOwner,
++ getChannelIdForStream,
++ canModerateChannel,
++ canModerateStream,
++ canViewChatLogs,
++ canViewOtherUserLogs,
++};
ddiff --git a/server/bridge/live-bridge.js b/server/bridge/live-bridge.js
nnew file mode 100644
iindex 0000000..9a97560
---- /dev/null
++++ b/server/bridge/live-bridge.js
@@@ -0,0 +1,203 @@
++/**
++ * OpenVibe.Chat — the bridge Live uses while it still hosts code that talks to chat.
++ *
++ * With CHAT_AUTHORITY=chat Live stops running the chat server; its `require('./chat/chat-server')`
++ * becomes a proxy (Live server/chat/chat-remote.js, docs/live-patch.diff) that sends every call
++ * here, in order, in small batches:
++ *
++ * POST /internal/live/calls capability chat.live_bridge.write
++ * { boot, ops: [{ seq, op, args, ref? }] } → { ok, results: [{ seq, ok, result?, error? }] }
++ * op = a ChatServer method Live's modules call (broadcastToStream, forwardToGlobal, sendDm,
++ * synthesizeAndBroadcastTTS, triggerChannelSound, disconnectUser, sendUserUpdate, …), a few
++ * bridge-only ops (sendToConn, deployNotice, userChanged, invalidate, broadcastAllRaw), or `db` — one of
++ * the chat-table writes Live's un-moved code still makes (AI viewers, relays, donations,
++ * /api/mod), executed on Chat's database, which is the authority.
++ *
++ * Pending ids: Live's saveChatMessage cannot wait for Chat's id, so it hands its caller a
++ * placeholder (a large negative number, `ref`) and sends the insert with it. Chat maps the
++ * placeholder to the real id, and every later op in that Live boot that carries it — a
++ * broadcast `{ id }`, a TTS key `m<ref>`, a reply_to_id — is rewritten before it runs.
++ *
++ * GET /internal/live/presence capability chat.presence.read
++ * Who is connected where (counts, slow modes, user/anon → ip), for Live's synchronous
++ * reads (getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,
++ * slowModeByStream). Internal only: it carries addresses.
++ */
++'use strict';
++
++const express = require('express');
++const db = require('../db/database');
++const ctx = require('../live-context');
++const serviceAuth = require('../net/service-auth');
++
++// Chat-table writes Live's remaining modules make (function names and arguments of Live's
++// database.js, which Chat's database.js keeps).
++const DB_OPS = new Set([
++ 'saveChatMessage', 'mergeChatMessageMetadata', 'deleteChatMessage', 'deleteUserChatMessages',
++ 'deleteAnonChatMessages', 'deleteRelayUserMessages', 'deleteChatMessagesByTimeRange',
++ 'holdMessageForApproval', 'reviewPendingIpMessage', 'approveAllFromIp', 'denyAllFromIp',
++ 'recordRelayUser', 'hideRelayUser', 'unhideRelayUser', 'unhideRelayUserByIdentity',
++ 'logModerationAction', 'recordFirstChat', 'setTtsVoiceOverride', 'deleteTtsVoiceOverride',
++ 'createChannelSound', 'setChannelSoundEmote', 'deleteChannelSound', 'renameChannelSoundCommand',
++ 'updateChannelSoundEmoteRefs',
++]);
++
++// ChatServer methods Live's modules call, and where their stream id argument sits (the stream is
++// loaded into the projection first, so labels like source_channel are right on a fresh stream).
++const SERVER_OPS = {
++ broadcastToStream: [0],
++ broadcastToChannelRoom: [1],
++ broadcastGlobal: [],
++ broadcastAll: [],
++ forwardToGlobal: [0],
++ forwardToGlobalByChannel: [],
++ forwardToStreamerRooms: [0],
++ broadcastToOwnerStreams: [],
++ sendDm: [],
++ sendUserUpdate: [],
++ disconnectUser: [],
++ synthesizeAndBroadcastTTS: [0],
++ triggerChannelSound: [],
++ sendToConn: [],
++};
++
++const REF_TTL_MS = 10 * 60 * 1000;
++const REF_MIN = -(2 ** 40); // Live's placeholders are ≤ this
++
++function createBridge({ chatServer }) {
++ const router = express.Router();
++ const refs = new Map(); // `${boot}|${ref}` → { id, at }
++
++ function sweepRefs() {
++ const cutoff = Date.now() - REF_TTL_MS;
++ for (const [k, v] of refs) if (v.at < cutoff) refs.delete(k);
++ try { db.run('DELETE FROM bridge_applied WHERE applied_at < ?', [Date.now() - 7 * 24 * 3600 * 1000]); } catch { /* */ }
++ }
++ const sweep = setInterval(sweepRefs, 60_000);
++ if (sweep.unref) sweep.unref();
++
++ function mapRefs(value, boot) {
++ if (typeof value === 'number') {
++ if (value <= REF_MIN) { const hit = refs.get(`${boot}|${value}`); return hit ? hit.id : value; }
++ return value;
++ }
++ if (typeof value === 'string') {
++ const m = /^(m|ov-)(-\d{13,})$/.exec(value);
++ if (m) { const hit = refs.get(`${boot}|${Number(m[2])}`); return hit ? `${m[1]}${hit.id}` : value; }
++ return value;
++ }
++ if (Array.isArray(value)) return value.map((v) => mapRefs(v, boot));
++ if (value && typeof value === 'object') {
++ const out = {};
++ for (const [k, v] of Object.entries(value)) out[k] = mapRefs(v, boot);
++ return out;
++ }
++ return value;
++ }
++
++ const plain = (r) => {
++ if (r && typeof r === 'object' && 'changes' in r && 'lastInsertRowid' in r) return { changes: r.changes, lastInsertRowid: Number(r.lastInsertRowid) };
++ return r === undefined ? null : r;
++ };
++
++ async function runOp(op, rawArgs, boot, ref, key) {
++ const args = mapRefs(Array.isArray(rawArgs) ? rawArgs : [], boot);
++ if (op === 'db') {
++ const [fn, ...fnArgs] = args;
++ if (!DB_OPS.has(fn) || typeof db[fn] !== 'function') throw new Error(`db.${fn} is not a bridge write`);
++ // saveChatMessage derives the channel from the stream: have the stream first.
++ if (fn === 'saveChatMessage' && fnArgs[0] && fnArgs[0].stream_id) await ctx.ensureStream(fnArgs[0].stream_id);
++ const remember = (result) => {
++ if (ref != null && result && result.lastInsertRowid != null) refs.set(`${boot}|${ref}`, { id: Number(result.lastInsertRowid), at: Date.now() });
++ return result;
++ };
++ if (!key) return plain(remember(db[fn](...fnArgs)));
++ // Applied once per idempotency key, in the same transaction as the write.
++ return db.transaction(() => {
++ const done = db.get('SELECT result FROM bridge_applied WHERE key = ?', [key]);
++ if (done) { const prev = done.result ? JSON.parse(done.result) : null; return remember(prev); }
++ const result = plain(db[fn](...fnArgs));
++ db.run('INSERT INTO bridge_applied (key, result, applied_at) VALUES (?, ?, ?)', [key, JSON.stringify(result === undefined ? null : result), Date.now()]);
++ return remember(result);
++ });
++ }
++ if (Object.prototype.hasOwnProperty.call(SERVER_OPS, op)) {
++ for (const i of SERVER_OPS[op]) if (args[i]) await ctx.ensureStream(args[i]);
++ if (op === 'disconnectUser') await ctx.invalidateBans(); // a ban was just written in Live
++ if (op === 'triggerChannelSound') {
++ // (ws, client, stream, command, args, relay) — Live passes no socket.
++ const [, client, stream, command, soundArgs, relay] = args;
++ return plain(chatServer.triggerChannelSound(null, client || {}, stream, command, soundArgs || [], relay || null));
++ }
++ return plain(chatServer[op](...args));
++ }
++ switch (op) {
++ case 'broadcastAllRaw': {
++ // Live code that iterated chatServer.clients and sent a pre-serialized payload.
++ let data;
++ try { data = JSON.parse(String(args[0])); } catch { throw new Error('broadcastAllRaw: not JSON'); }
++ return plain(chatServer.broadcastAll(data));
++ }
++ case 'deployNotice':
++ return require('../chat/deploy-notice').announceCommits({ db, chatServer, commits: args[0] });
++ case 'userChanged':
++ ctx.invalidateUser(args[0]);
++ await ctx.ensureUsers([args[0]]).catch(() => {});
++ return null;
++ case 'invalidate': {
++ // Live wrote data Chat caches (channel moderators/settings, IP approvals, bans, a user).
++ const [kind, id] = args;
++ if (kind === 'channel') ctx.invalidateChannel(id);
++ else if (kind === 'approvals') ctx.invalidateApprovals(id);
++ else if (kind === 'bans') await ctx.invalidateBans();
++ else if (kind === 'user') ctx.invalidateUser(id);
++ else throw new Error(`invalidate: unknown kind ${kind}`);
++ return null;
++ }
++ default:
++ throw new Error(`unknown op ${op}`);
++ }
++ }
++
++ router.post('/calls', serviceAuth.guard('chat.live_bridge.write'), async (req, res) => {
++ const boot = String((req.body && req.body.boot) || '').slice(0, 64) || 'unknown';
++ const ops = Array.isArray(req.body && req.body.ops) ? req.body.ops.slice(0, 500) : [];
++ const results = [];
++ for (const o of ops) {
++ try {
++ const result = await runOp(String(o.op || ''), o.args, boot, typeof o.ref === 'number' ? o.ref : null, typeof o.key === 'string' ? o.key.slice(0, 80) : null);
++ results.push({ seq: o.seq, ok: true, result });
++ } catch (err) {
++ console.warn(`[Bridge] ${o.op}${o.op === 'db' && Array.isArray(o.args) ? `.${o.args[0]}` : ''} failed: ${err.message}`);
++ results.push({ seq: o.seq, ok: false, error: err.message });
++ }
++ }
++ res.json({ ok: true, results });
++ });
++
++ router.get('/presence', serviceAuth.guard('chat.presence.read'), (req, res) => {
++ const streams = {};
++ const users = [];
++ const anons = [];
++ for (const [, c] of chatServer.clients) {
++ if (c.streamId) streams[c.streamId] = null;
++ if (c.user) users.push({ user_id: c.user.id, ip: c.ip, stream_id: c.streamId || null });
++ else if (c.anonId) anons.push({ anon_id: c.anonId, ip: c.ip, stream_id: c.streamId || null });
++ }
++ for (const sid of Object.keys(streams)) streams[sid] = chatServer.getStreamViewerCount(Number(sid));
++ res.json({
++ at: new Date().toISOString(),
++ total: chatServer.getTotalConnections(),
++ streams,
++ slow_mode: Object.fromEntries(chatServer.slowModeByStream),
++ users,
++ anons,
++ });
++ });
++
++ router._mapRefs = mapRefs;
++ router._refs = refs;
++ return router;
++}
++
++module.exports = { createBridge, DB_OPS, SERVER_OPS };
ddiff --git a/server/bridge/live-mirror.js b/server/bridge/live-mirror.js
nnew file mode 100644
iindex 0000000..6e31aca
---- /dev/null
++++ b/server/bridge/live-mirror.js
@@@ -0,0 +1,112 @@
++/**
++ * OpenVibe.Chat → OpenVibe.Live read mirror.
++ *
++ * After the cutover Chat is the only writer of its tables, but Live still reads some of them in
++ * place (home-page stats, recaps, AI chat context, VOD chat replay, /api/mod queues, analytics),
++ * and rollback means Live becomes the authority again. So every change Chat makes to its tables
++ * is copied into Live's tables of the same name, same ids:
++ *
++ * - this connection's TEMP triggers (db.initDb({ captureMirror: true })) record each change in
++ * live_mirror_outbox — writes by the importer (rows that came from Live) are never recorded;
++ * - this relay sends batches to Live's POST /internal/chat-effects/mirror (capability
++ * live.chat_mirror.write) as { changes: [{ table, op: 'upsert', row } | { table, op: 'delete', pk }] }
++ * with the row as it is NOW (so repeated changes to one row collapse into one upsert);
++ * - Live applies them idempotently (INSERT … ON CONFLICT DO UPDATE on the columns it has; it
++ * refuses unless it runs with CHAT_AUTHORITY=chat, so a rehearsal can never touch production).
++ *
++ * Rows leave the outbox only when Live acknowledged them; a Live outage just queues. Before a
++ * rollback, `pending()` must be 0 (docs/cutover.md).
++ */
++'use strict';
++
++const db = require('../db/database');
++const serviceAuth = require('../net/service-auth');
++
++const BATCH = 200;
++const MAX_BODY = 800 * 1024; // Live parses JSON bodies up to 1 MB
++const SCOPE = 'live.chat_mirror.write';
++
++function createMirror({ config, fetchImpl = (...a) => globalThis.fetch(...a), log = console } = {}) {
++ let timer = null;
++ let busy = false;
++ let lastError = null;
++
++ function pending() { return db.get('SELECT COUNT(*) AS n FROM live_mirror_outbox')?.n || 0; }
++
++ function buildChanges(rows) {
++ // Newest state per row: several changes to one key collapse into the last op.
++ const byKey = new Map();
++ for (const r of rows) byKey.set(`${r.tbl}|${r.pk}`, r);
++ const changes = [];
++ for (const r of byKey.values()) {
++ const cols = db.CHAT_TABLES[r.tbl];
++ if (!cols) continue;
++ let pk;
++ try { pk = JSON.parse(r.pk); } catch { continue; }
++ if (r.op === 'delete') { changes.push({ table: r.tbl, op: 'delete', pk }); continue; }
++ const where = cols.map((c) => `${c} = ?`).join(' AND ');
++ const row = db.get(`SELECT * FROM ${r.tbl} WHERE ${where}`, cols.map((c) => pk[c]));
++ changes.push(row ? { table: r.tbl, op: 'upsert', row } : { table: r.tbl, op: 'delete', pk });
++ }
++ return changes;
++ }
++
++ async function flush() {
++ if (!config.live.mirror) return { sent: 0, disabled: true };
++ if (busy) return { sent: 0, busy: true };
++ busy = true;
++ let sent = 0;
++ try {
++ for (;;) {
++ const rows = db.all('SELECT seq, tbl, op, pk FROM live_mirror_outbox ORDER BY seq LIMIT ?', [BATCH]);
++ if (!rows.length) break;
++ const changes = buildChanges(rows);
++ // Requests stay under Live's body limit; a batch is acknowledged when all its parts are.
++ const parts = [[]];
++ let size = 0;
++ for (const c of changes) {
++ const n = Buffer.byteLength(JSON.stringify(c));
++ if (parts[parts.length - 1].length && size + n > MAX_BODY) { parts.push([]); size = 0; }
++ parts[parts.length - 1].push(c);
++ size += n;
++ }
++ let failed = false;
++ for (const part of parts) {
++ let res;
++ try {
++ res = await fetchImpl(`${config.live.internalUrl}/internal/chat-effects/mirror`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', ...(await serviceAuth.headers(config.live.audience, SCOPE)) },
++ body: JSON.stringify({ changes: part }),
++ signal: AbortSignal.timeout(15000),
++ });
++ } catch (err) { lastError = err.message; failed = true; break; }
++ if (res.status === 401) serviceAuth.invalidate(config.live.audience, SCOPE);
++ if (!res.ok) {
++ const text = await res.text().catch(() => '');
++ lastError = `${res.status} ${text.slice(0, 200)}`;
++ failed = true;
++ break;
++ }
++ }
++ if (failed) break;
++ const maxSeq = rows[rows.length - 1].seq;
++ db.run('DELETE FROM live_mirror_outbox WHERE seq <= ?', [maxSeq]);
++ sent += changes.length;
++ lastError = null;
++ }
++ } finally { busy = false; }
++ if (lastError) log.warn(`[Mirror] Live mirror waiting (${pending()} pending): ${lastError}`);
++ return { sent, pending: pending(), error: lastError };
++ }
++
++ function start() {
++ if (timer || !config.live.mirror) return;
++ timer = setInterval(() => { flush().catch((e) => log.warn('[Mirror]', e.message)); }, config.live.mirrorIntervalMs);
++ if (timer.unref) timer.unref();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++ return { start, stop, flush, pending, lastError: () => lastError };
++}
++
++module.exports = { createMirror };
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
nnew file mode 100644
iindex 0000000..f20982a
---- /dev/null
++++ b/server/chat/chat-server.js
@@@ -0,0 +1,2479 @@
++/**
++ * OpenVibe.Chat — WebSocket Chat Server (/ws/chat)
++ *
++ * Moved from OpenVibe.Live server/chat/chat-server.js (Wave 6) with the same protocol: every
++ * message type, command, reply and broadcast is Live's. What changed is only where Live-owned
++ * data comes from — accounts, streams, channels, bans, cosmetics and every side effect
++ * (coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat) go through
++ * ../live-context.js — and that authentication is a cached call to Live, so a socket's
++ * messages are processed in order behind its (async) connect and join.
++ *
++ * Features:
++ * - Anonymous chat with sequential numbering (anon12345)
++ * - Global chat + per-stream chat
++ * - Word filtering (safe/unsafe mode)
++ * - Anti-VPN approval queue
++ * - Streamer moderation (ban, timeout, delete)
++ * - Chat commands (/help, /tts, /color, etc.)
++ * - Rate limiting
++ */
++const WebSocket = require('ws');
++const fs = require('fs');
++const crypto = require('crypto');
++const db = require('../db/database');
++const ctx = require('../live-context');
++const { extractWsToken, authenticateWs } = require('../auth/auth');
++const permissions = require('../auth/permissions');
++const wordFilter = require('./word-filter');
++const ttsEngine = require('./tts-engine');
++const soundboard = require('./soundboard-service');
++const dm = require('./dm');
++// Cosmetics and tags are Live's (monetization/cosmetics, game/tags); read through live-context.
++const cosmetics = { getCosmeticProfile: (userId) => ctx.getCosmeticProfile(userId) };
++const tags = { getTagProfile: (userId) => ctx.getTagProfile(userId) };
++// Concurrent chat sockets per address. Generous: one person has several tabs, a school or
++// carrier NAT puts many people behind one address; this only stops one host opening thousands.
++const MAX_CHAT_SOCKETS_PER_IP = 48;
++
++const DEBUG_DM_DELIVERY = process.env.DEBUG_DM_DELIVERY === '1';
++
++const WS_HEARTBEAT_MS = 30000;
++const CHAT_AUTO_DELETE_SWEEP_MS = 30000;
++const MIN_CHAT_AUTO_DELETE_MINUTES = 3;
++const MAX_CHAT_AUTO_DELETE_MINUTES = 10080;
++const MAX_SEND_BACKPRESSURE = 256 * 1024;
++const RATE_LIMIT_CACHE_TTL_MS = 10 * 60 * 1000;
++const SOUNDBOARD_RATE_LIMIT_MS = 8000;
++const SOUNDBOARD_STREAM_MAX_PER_WINDOW = 15;
++const SOUNDBOARD_STREAM_WINDOW_MS = 60 * 1000;
++const GOTTI_GIF_URL = 'https://media1.tenor.com/m/Y-GsLUQT9LQAAAAd/deez-something-came-in-the-mail-today.gif';
++const GOTTI_CAPTION = 'Something came in the mail today... deez nuts. GOTTI!';
++const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat. Free speech is still alive, but this lane is closed today. Try a different word and keep it funny.';
++// Built-in slur categories and normalization are defined in moderation-utils.js
++// (single source of truth — browser-side chat.js mirrors the same patterns).
++const {
++ CORE_SLUR_CATEGORIES,
++ normalizeSlurText: _modNormalizeSlurText,
++ normalizeSlurPatternText: _modNormalizeSlurPatternText,
++ containsCoreSlur: _modContainsCoreSlur,
++ containsRegexSlur: _modContainsRegexSlur,
++ containsConfiguredSlur: _modContainsConfiguredSlur,
++ compileRegexList: _modCompileRegexList,
++} = require('./moderation-utils');
++
++class ChatServer {
++ constructor() {
++ this.wss = null;
++ /** @type {Map<WebSocket, { user: object|null, anonId: string, streamId: number|null, ip: string }>} */
++ this.clients = new Map();
++ /** @type {Map<string, number>} ip → open sockets */
++ this._ipSockets = new Map();
++ /** @type {Map<string, number>} IP → unified anon number (warm cache, backed by openvibe.network) */
++ this.anonMap = new Map();
++ this.nextAnonId = 1;
++ this._anonDbLoaded = false;
++ /** @type {Map<string, number>} `${ip}:${streamId}` → last message time (rate limiting) */
++ this.rateLimits = new Map();
++ this.DEFAULT_RATE_LIMIT_MS = 1000; // 1 message per second
++ /** @type {Map<number, number>} streamId → slow mode ms (0 = off, default rate limit applies) */
++ this.slowModeByStream = new Map();
++ this.heartbeatInterval = null;
++ /** @type {Map<number, number>} streamId → current TTS queue size */
++ this.ttsQueueSize = new Map();
++ /** @type {Map<string, number>} `${streamId}:${userId}` → user's TTS queue count */
++ this.ttsUserCounts = new Map();
++ /** @type {Map<string, number>} `${streamId}:${userKey}` → last soundboard trigger */
++ this.soundboardRateLimits = new Map();
++ /** @type {Map<number, {count: number, windowStart: number}>} streamId → stream-level soundboard rate window */
++ this.soundboardStreamLimits = new Map();
++ this._autoDeleteSweepInterval = null;
++ }
++
++ normalizeIp(ip) {
++ let normalized = String(ip || 'unknown').trim();
++ if (!normalized) normalized = 'unknown';
++ if (normalized === '::1') return '127.0.0.1';
++ if (normalized.startsWith('::ffff:')) return normalized.slice(7);
++ return normalized;
++ }
++
++ /**
++ * Extract the real client IP from Express/WS request.
++ * Prefers CF-Connecting-IP (set by Cloudflare, unforgeable through proxy),
++ * then X-Forwarded-For first entry, then socket remote address.
++ */
++ /** Admins (the site owner) pass IP / network bans — they may share a home network with a banned person. */
++ /**
++ * Is this viewer new to OpenVibe.Live? True for an account under a day old — unless the same
++ * address has had an anon identity for longer — and for an anon first seen under a day ago.
++ * Drives the default of the viewer-side "Friendly global chat" setting only.
++ */
++ _isNewcomer(client) {
++ const DAY = 24 * 3600 * 1000;
++ const age = (ts) => { if (!ts) return null; const t = Date.parse(String(ts).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(ts)) ? '' : 'Z')); return Number.isFinite(t) ? Date.now() - t : null; };
++ let anonAge = null;
++ try { anonAge = age(ctx.getAnonFirstSeen(this.normalizeIp(client.ip))); } catch { /* */ }
++ if (client.user) {
++ const userAge = age(client.user.created_at);
++ if (userAge == null || userAge >= DAY) return false;
++ return !(anonAge != null && anonAge >= DAY);
++ }
++ return anonAge == null ? false : anonAge < DAY;
++ }
++
++ _isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && client.user.role === 'admin'); }
++
++ getClientIp(req) {
++ const raw = req.headers?.['cf-connecting-ip']
++ || req.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
++ || req.socket?.remoteAddress
++ || req.connection?.remoteAddress
++ || 'unknown';
++ return this.normalizeIp(raw);
++ }
++
++ /**
++ * Resolve the anon number for an address. Live allocates it exactly as it did in-process
++ * (openvibe.network's unified /internal/resolve-anon, falling back to its local
++ * anon_ip_mappings), so a person keeps one number across chat, calls and every Live page.
++ * Returns a Promise<number>; cached in memory.
++ */
++ async _resolveUnifiedAnonNum(ip) {
++ if (this.anonMap.has(ip)) return this.anonMap.get(ip);
++ const r = await ctx.resolveAnon(ip);
++ if (!r.temporary) this.anonMap.set(ip, r.num);
++ return r.num;
++ }
++
++ getAnonIdForIp(ip) {
++ const anonKey = this.normalizeIp(ip);
++ if (this.anonMap.has(anonKey)) {
++ return `anon${this.anonMap.get(anonKey)}`;
++ }
++ // Connections resolve their number before they are registered (handleConnection), so
++ // this is only reached for an address Live could not answer for: use its temporary number.
++ const r = ctx.peekAnon(anonKey);
++ if (r) { this.anonMap.set(anonKey, r.num); return `anon${r.num}`; }
++ const num = 900000000 + (crypto.createHash('sha256').update(anonKey).digest().readUInt32BE(0) % 99999999);
++ return `anon${num}`;
++ }
++
++ getAnonIdForConnection(ip, streamId = null) {
++ const anonKey = this.normalizeIp(ip);
++ for (const [, info] of this.clients) {
++ if (info.ip !== anonKey || !info.anonId) continue;
++ if (streamId == null || info.streamId === streamId) {
++ return info.anonId;
++ }
++ }
++ return this.getAnonIdForIp(anonKey);
++ }
++
++ /**
++ * Attach to an existing HTTP server for WebSocket upgrade
++ */
++ init(server) {
++ this.wss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
++
++ // Word filter
++ wordFilter.load();
++
++ if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
++ this.heartbeatInterval = setInterval(() => {
++ if (!this.wss) return;
++
++ const now = Date.now();
++ for (const [ip, lastSeen] of this.rateLimits.entries()) {
++ if ((now - lastSeen) > RATE_LIMIT_CACHE_TTL_MS) {
++ this.rateLimits.delete(ip);
++ }
++ }
++
++ this.wss.clients.forEach((ws) => {
++ if (ws.isAlive === false) {
++ try { ws.terminate(); } catch {}
++ return;
++ }
++ ws.isAlive = false;
++ try { ws.ping(); } catch {}
++ });
++ }, WS_HEARTBEAT_MS);
++
++ // ── Viewer snapshot recording (every 60s) ────────────
++ if (this._snapshotInterval) clearInterval(this._snapshotInterval);
++ this._snapshotInterval = setInterval(() => {
++ this._recordViewerSnapshots();
++ }, 60_000);
++
++ if (this._autoDeleteSweepInterval) clearInterval(this._autoDeleteSweepInterval);
++ this._autoDeleteSweepInterval = setInterval(() => {
++ this._sweepExpiredChatMessages();
++ }, CHAT_AUTO_DELETE_SWEEP_MS);
++ this._sweepExpiredChatMessages();
++
++ this.wss.on('connection', (ws, req) => {
++ this.handleConnection(ws, req);
++ });
++
++ console.log('[Chat] WebSocket chat server initialized');
++ return this.wss;
++ }
++
++ /**
++ * Handle WebSocket upgrade for chat connections
++ */
++ handleUpgrade(req, socket, head) {
++ if (req.url.startsWith('/ws/chat')) {
++ this.wss.handleUpgrade(req, socket, head, (ws) => {
++ this.wss.emit('connection', ws, req);
++ });
++ return true;
++ }
++ return false;
++ }
++
++ /**
++ * Handle a new chat connection
++ */
++ handleConnection(ws, req) {
++ const ip = this.getClientIp(req);
++
++ // Diagnostic: log IP resolution chain for first few connections
++ if (this.anonMap.size < 20) {
++ console.log('[Chat] IP resolution — cf-connecting-ip:', req.headers?.['cf-connecting-ip'] || '(none)',
++ '| x-forwarded-for:', req.headers?.['x-forwarded-for'] || '(none)',
++ '| x-real-ip:', req.headers?.['x-real-ip'] || '(none)',
++ '| socket:', req.socket?.remoteAddress || '(none)',
++ '| resolved:', ip);
++ }
++
++ const urlParams = new URL(req.url, 'http://localhost').searchParams;
++ const token = extractWsToken(req);
++ const streamId = parseInt(urlParams.get('stream')) || null;
++
++ ws.isAlive = true;
++ ws.on('pong', () => {
++ ws.isAlive = true;
++ });
++ try { ws._socket?.setNoDelay(true); } catch {}
++
++ // Authentication (and the anon number) is a cached call to Live now. Messages that arrive
++ // meanwhile wait, in order, and are handled once the connection is registered.
++ const early = [];
++ let closedEarly = false;
++ const onEarlyMessage = (data) => early.push(data);
++ const onEarlyClose = () => { closedEarly = true; };
++ ws.on('message', onEarlyMessage);
++ ws.on('close', onEarlyClose);
++ ws.on('error', onEarlyClose);
++ (async () => {
++ // Authenticate (optional — anon if no token)
++ const user = await authenticateWs(token).catch(() => null);
++ if (!user) await this._resolveUnifiedAnonNum(this.normalizeIp(ip)).catch(() => {});
++ await ctx.warm({ user, streamId, ip }).catch(() => {});
++ ws.off('message', onEarlyMessage);
++ ws.off('close', onEarlyClose);
++ ws.off('error', onEarlyClose);
++ if (closedEarly || ws.readyState !== WebSocket.OPEN) return;
++ this._registerConnection(ws, req, { ip, streamId, user, early });
++ })().catch((err) => {
++ console.warn('[Chat] connection setup failed:', err.message);
++ try { ws.close(1011, 'setup failed'); } catch {}
++ });
++ }
++
++ /** The rest of Live's handleConnection, once the socket's identity is known. */
++ _registerConnection(ws, req, { ip, streamId, user, early }) {
++ const perIp = this._ipSockets.get(ip) || 0;
++ if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !(user && user.role === 'admin')) {
++ ws.close(4029, 'Too many connections');
++ return;
++ }
++ this._ipSockets.set(ip, perIp + 1);
++ let released = false;
++ const release = () => {
++ if (released) return;
++ released = true;
++ const n = (this._ipSockets.get(ip) || 1) - 1;
++ if (n > 0) this._ipSockets.set(ip, n); else this._ipSockets.delete(ip);
++ };
++
++ // Generate or reuse anon ID for this IP
++ const anonId = user ? null : this.getAnonIdForConnection(ip, streamId);
++
++ const clientInfo = {
++ user,
++ anonId,
++ streamId,
++ ip,
++ joinedAt: Date.now(),
++ // Handle Live can address replies to (arena commands) through the bridge.
++ connId: crypto.randomUUID(),
++ };
++ this.clients.set(ws, clientInfo);
++
++ // Log IP for tracking (Live's ip_log; Live adds the geo enrichment)
++ try {
++ ctx.effects.logIp({ userId: user?.id, anonId, ip, action: 'chat' });
++ } catch (e) { /* non-critical */ }
++
++ // (No verbose welcome message — the client shows a concise "Connected to
++ // chat" / "Chatting as X" instead.)
++
++ // Send user count + users list update
++ this.broadcastUserCount(streamId);
++ this.broadcastUsersList(streamId);
++
++ // ── Message handler ──────────────────────────────────
++ // One socket's messages run strictly in order: a join (which may wait on Live for a
++ // token or a cold stream) finishes before the chat line typed right after it.
++ let chain = Promise.resolve();
++ const onData = (data) => {
++ let msg;
++ try {
++ msg = JSON.parse(data.toString());
++ } catch (err) {
++ console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
++ return;
++ }
++ chain = chain.then(() => this.handleMessage(ws, msg)).catch((err) => {
++ console.warn('[Chat] message handling failed:', err.message);
++ });
++ };
++ ws.on('message', onData);
++ for (const data of early || []) onData(data);
++
++ ws.on('close', () => {
++ release();
++ this.clients.delete(ws);
++ this.broadcastUserCount(streamId);
++ this.broadcastUsersList(streamId);
++ });
++
++ ws.on('error', (err) => {
++ console.warn('[Chat] WebSocket error for', ws._clientIp || 'unknown', ':', err.message);
++ release();
++ this.clients.delete(ws);
++ });
++ }
++
++ /**
++ * Handle incoming chat message
++ */
++ async handleMessage(ws, msg) {
++ const client = this.clients.get(ws);
++ if (!client) return;
++
++ // Rate limiting (only for chat messages, not join/leave)
++ if (msg.type === 'chat') {
++ const now = Date.now();
++ const rateKey = `${client.ip}:${client.streamId || 'global'}`;
++ const lastMsg = this.rateLimits.get(rateKey) || 0;
++ const streamSlowMs = this.slowModeByStream.get(client.streamId) || 0;
++ const effectiveLimit = Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);
++ if (now - lastMsg < effectiveLimit) {
++ this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
++ return;
++ }
++ this.rateLimits.set(rateKey, now);
++ }
++
++ switch (msg.type) {
++ case 'chat':
++ this.handleChatMessage(ws, client, msg);
++ break;
++ case 'self-delete-history':
++ this.handleSelfDeleteHistory(ws, client);
++ break;
++ case 'join':
++ case 'join_stream': {
++ // (Re-)authenticate if a token is provided
++ if (msg.token) {
++ const user = await authenticateWs(msg.token).catch(() => null);
++ if (user) {
++ if (!client.user || client.user.id === user.id) {
++ client.user = user;
++ client.anonId = null; // no longer anonymous
++ } else {
++ console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
++ }
++ }
++ }
++ const oldStream = client.streamId;
++ const nextStreamId = parseInt(msg.streamId || msg.stream_id) || null;
++ const requestedChannel = parseInt(msg.channelUserId || msg.channel_user_id) || null;
++ // Load what this room's messages will read (stream, channel policy, the viewer's
++ // follows and decor, the channel's latest stream) before the socket is moved.
++ await ctx.warm({ user: client.user, streamId: nextStreamId, channelUserId: requestedChannel, ip: client.ip }).catch(() => {});
++ if (!nextStreamId && requestedChannel) {
++ // Offline channel room: its moderation comes from the channel's latest stream.
++ await ctx.warm({ user: null, streamId: ctx.latestStreamIdForUser(requestedChannel), channelUserId: requestedChannel }).catch(() => {});
++ }
++ client.streamId = nextStreamId;
++ // Channel room: the streamer's stable user id. Lets a viewer stay in
++ // the SAME chat room across live-slot switches AND while the streamer
++ // is offline. Prefer an explicit channelUserId from the client; else
++ // derive it from the live stream's owner.
++ let channelUserId = requestedChannel;
++ // With a stream, the room is that stream's owner — whatever the client says. Taking the
++ // client's value let a viewer join stream A's session while posting into channel B's
++ // room, where B's bans and chat rules (checked against the stream) never applied.
++ if (client.streamId) {
++ channelUserId = null;
++ try { const s = ctx.getStreamById(client.streamId); if (s) channelUserId = s.user_id; } catch { /* ignore */ }
++ }
++ client.channelUserId = channelUserId;
++ client._modStream = null;
++ // Update viewer counts for old and new streams
++ if (oldStream !== client.streamId) {
++ if (oldStream) this.broadcastUserCount(oldStream);
++ this.broadcastUserCount(client.streamId);
++ }
++ // A deploy notice from this boot reaches late joiners too (once per socket; see deploy-notice.js).
++ setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);
++ // Send identity confirmation so the client knows who it is
++ const displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;
++ const streamSlowSec = client.streamId ? Math.round((this.slowModeByStream.get(client.streamId) || 0) / 1000) : 0;
++ const streamSettings = this._getChannelChatSettings(client.streamId);
++ this.sendTo(ws, {
++ type: 'auth',
++ authenticated: !!client.user,
++ newcomer: this._isNewcomer(client),
++ username: displayName,
++ core_username: client.user?.username || null,
++ role: client.user ? client.user.role : 'anon',
++ user_id: client.user?.id || null,
++ slowmode_seconds: streamSlowSec,
++ allow_auto_delete: !client.streamId || streamSettings.viewer_auto_delete_enabled !== 0,
++ allow_self_delete_all: !client.streamId || streamSettings.viewer_delete_all_enabled !== 0,
++ gifs_enabled: !client.streamId || streamSettings.gifs_enabled !== 0,
++ custom_emotes_enabled: !client.streamId || streamSettings.custom_emotes_enabled !== 0,
++ custom_sounds_enabled: !client.streamId || streamSettings.custom_sounds_enabled !== 0,
++ uploads_mods_only: !!(client.streamId && streamSettings.uploads_mods_only),
++ max_sound_seconds: client.streamId ? (streamSettings.max_sound_seconds || 10) : 10,
++ emote_scale: client.streamId ? (streamSettings.emote_scale || 100) : 100,
++ soundboard_enabled: !client.streamId || streamSettings.soundboard_enabled !== 0,
++ soundboard_allow_pitch: !client.streamId || streamSettings.soundboard_allow_pitch !== 0,
++ soundboard_allow_speed: !client.streamId || streamSettings.soundboard_allow_speed !== 0,
++ slur_filter_enabled: !!(client.streamId && streamSettings.slur_filter_enabled),
++ slur_filter_use_builtin: streamSettings.slur_filter_use_builtin !== 0,
++ slur_filter_disabled_categories: (() => { try { return JSON.parse(streamSettings.slur_filter_disabled_categories || '[]') || []; } catch { return []; } })(),
++ slur_filter_terms: this._parseSlurFilterTerms(streamSettings.slur_filter_terms),
++ slur_filter_regexes: this._parseRegexLines(streamSettings.slur_filter_regexes),
++ slur_filter_nudge_message: String(streamSettings.slur_filter_nudge_message || ''),
++ min_auto_delete_minutes: MIN_CHAT_AUTO_DELETE_MINUTES,
++ // Language the channel lives in — the client shows "auto-translated for the
++ // streamer" when it isn't English (see server/i18n/translate.js).
++ channel_language: this._channelLanguage(client.channelUserId),
++ });
++ break;
++ }
++ case 'leave_stream':
++ client.streamId = null;
++ break;
++ case 'get-users':
++ this.sendTo(ws, { type: 'users-list', users: this.getUserList(client.streamId) });
++ break;
++ default:
++ break;
++ }
++ }
++
++ /** The stream whose bans and chat settings govern an offline channel-room chatter (cached 30s). */
++ _moderationStreamFor(client) {
++ if (!client || !client.channelUserId) return null;
++ const now = Date.now();
++ if (client._modStream && now - client._modStream.at < 30000) return client._modStream.id;
++ let id = null;
++ try { id = ctx.latestStreamIdForUser(client.channelUserId); } catch { id = null; }
++ client._modStream = { id, at: now };
++ return id;
++ }
++
++ handleSelfDeleteHistory(ws, client) {
++ if (!client) return;
++
++ const canBypass = client.streamId ? permissions.canModerateStream(client.user, client.streamId) : false;
++ const chatSettings = this._getChannelChatSettings(client.streamId);
++ if (client.streamId && !canBypass && chatSettings.viewer_delete_all_enabled === 0) {
++ this.sendTo(ws, { type: 'error', message: 'This streamer has disabled viewer self-delete for this chat.' });
++ return;
++ }
++
++ let ids = [];
++ if (client.user?.id) {
++ ids = db.deleteUserChatMessages(client.user.id, {
++ streamId: client.streamId || null,
++ deletedBy: client.user.id,
++ });
++ } else if (client.anonId) {
++ ids = db.deleteAnonChatMessages(client.anonId, {
++ streamId: client.streamId || null,
++ deletedBy: null,
++ });
++ } else {
++ this.sendTo(ws, { type: 'error', message: 'Join chat first before deleting history.' });
++ return;
++ }
++
++ this._broadcastDeletedMessages(client.streamId || null, ids);
++
++ try {
++ db.logModerationAction({
++ scope_type: client.streamId ? 'stream' : 'site',
++ scope_id: client.streamId || undefined,
++ actor_user_id: client.user?.id || undefined,
++ action_type: 'self_message_delete_all',
++ details: {
++ count: ids.length,
++ stream_id: client.streamId || null,
++ anon_id: client.user ? null : client.anonId,
++ },
++ });
++ } catch { /* non-critical */ }
++
++ this.sendTo(ws, {
++ type: 'self-delete-result',
++ count: ids.length,
++ scope: client.streamId ? 'stream' : 'global',
++ });
++ }
++
++ /**
++ * Build a deduplicated list of users in a given stream (or global if null).
++ * Returns { logged: [{username, display_name, avatar_url, role}], anonCount: N }
++ */
++ getUserList(streamId) {
++ const seen = new Set();
++ const logged = [];
++ let anonCount = 0;
++ for (const [, c] of this.clients) {
++ if (c.streamId !== streamId) continue;
++ if (c.user) {
++ if (seen.has(c.user.id)) continue;
++ seen.add(c.user.id);
++ logged.push({
++ username: c.user.username,
++ display_name: c.user.display_name || c.user.username,
++ avatar_url: c.user.avatar_url || null,
++ role: c.user.role,
++ });
++ } else if (c.anonId) {
++ if (seen.has(c.anonId)) continue;
++ seen.add(c.anonId);
++ anonCount++;
++ }
++ }
++ // Sort: admins first, then mods, then alphabetical
++ const rolePriority = { admin: 0, global_mod: 1, streamer: 2, user: 3 };
++ logged.sort((a, b) => (rolePriority[a.role] ?? 9) - (rolePriority[b.role] ?? 9) || a.display_name.localeCompare(b.display_name));
++ return { logged, anonCount };
++ }
++
++ _parseSlurFilterTerms(rawTerms) {
++ return String(rawTerms || '')
++ .split(/[\n,]/)
++ .map((t) => t.trim().toLowerCase())
++ .filter(Boolean)
++ .slice(0, 200);
++ }
++
++ _parseRegexLines(rawRegexes) {
++ return String(rawRegexes || '')
++ .split(/\r?\n/)
++ .map((line) => line.trim())
++ .filter(Boolean)
++ .slice(0, 300);
++ }
++
++ // Normalization and matching are delegated to moderation-utils.js (shared module).
++ // These thin wrappers preserve the existing call sites inside this class.
++ _normalizeSlurText(input) { return _modNormalizeSlurText(input); }
++ _normalizeSlurPatternText(input) { return _modNormalizeSlurPatternText(input); }
++ _containsCoreSlur(text, disabledCategories = []) { return _modContainsCoreSlur(text, disabledCategories); }
++ _containsRegexSlur(text, regexLines) { return _modContainsRegexSlur(text, regexLines); }
++ _containsConfiguredSlur(text, terms) { return _modContainsConfiguredSlur(text, terms); }
++ _compileRegexList(patternStrings, opts) { return _modCompileRegexList(patternStrings, opts); }
++
++ /**
++ * Handle a chat message
++ */
++ handleChatMessage(ws, client, msg) {
++ let text = (msg.message || '').trim();
++ // Absolute upper bound (DoS guard) = the highest value any channel/admin can configure
++ // (admins can set a channel up to 6000). The REAL per-channel limit is enforced below at
++ // the `max_message_length` check — this must never be lower than that or it silently
++ // swallows long messages the channel actually allows.
++ if (!text || text.length > 6000) return;
++
++ // ── Ban check ────────────────────────────────────────
++ // This has to come before the command dispatch below, not after it. Both command paths
++ // return early, so a banned viewer could still run every "!" utility command — queueing
++ // and skipping media on the stream they were banned from — and every "/" command,
++ // including /me, which puts their text back in the chat they are banned from. A ban means
++ // no input of any kind.
++ // Offline channel chat has no stream of its own; bans and chat rules come from the channel's
++ // most recent stream, so a ban does not stop at the end of a broadcast.
++ const modStreamId = client.streamId || this._moderationStreamFor(client);
++ if (client.user && ctx.isUserBanned(client.user.id, modStreamId)) {
++ this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
++ return;
++ }
++ if (!this._isBanExemptAdmin(client) && ctx.isIpBanned(client.ip, modStreamId)) {
++ this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
++ return;
++ }
++
++ // ── Stream utility commands (!sr, !queue, !nowplaying, !skip) ──
++ if (text.startsWith('!')) {
++ this.handleBangCommand(ws, client, text);
++ return;
++ }
++
++ // ── Chat commands ────────────────────────────────────
++ if (text.startsWith('/')) {
++ this.handleCommand(ws, client, text);
++ return;
++ }
++
++ // ── IP Approval Mode (Anti-VPN) ─────────────────────
++ if (client.streamId && client.ip) {
++ try {
++ const stream = ctx.getStreamById(client.streamId);
++ const channel = stream?.channel_id ? ctx.getChannelById(stream.channel_id) : (stream ? ctx.getChannelByUserId(stream.user_id) : null);
++ if (channel) {
++ const settings = ctx.getChannelModerationSettings(channel.id);
++ if (settings?.ip_approval_mode) {
++ const isStaffBypass = client.user && permissions.isGlobalModOrAbove(client.user);
++ const isOwner = client.user && stream && stream.user_id === client.user.id;
++ if (!isStaffBypass && !isOwner) {
++ if (!ctx.isIpApproved(channel.id, client.ip)) {
++ // Auto-approve IPs that have existing non-deleted chat messages in this channel's streams
++ const existing = db.get(
++ `SELECT 1 FROM chat_messages cm
++ JOIN ctx_streams s ON cm.stream_id = s.id
++ WHERE s.channel_id = ? AND cm.is_deleted = 0
++ AND (cm.user_id = ? OR cm.anon_id = ?)
++ LIMIT 1`,
++ [channel.id, client.user?.id || -1, client.anonId || '']
++ );
++ if (existing) {
++ // This user has chatted before — auto-approve their IP
++ ctx.approveIp(channel.id, client.ip, null, 'auto_existing');
++ } else {
++ // Hold the message for streamer approval
++ const username = client.user ? client.user.display_name : client.anonId;
++ db.holdMessageForApproval({
++ channelId: channel.id,
++ streamId: client.streamId,
++ ip: client.ip,
++ userId: client.user?.id || null,
++ anonId: client.anonId || null,
++ username,
++ message: text,
++ });
++ this.sendTo(ws, {
++ type: 'system',
++ message: 'This channel has IP approval mode enabled. Your message is being held for review by the streamer.',
++ });
++ // Notify the streamer that a new IP needs approval
++ this._notifyStreamerPendingIp(client.streamId, stream.user_id, username, client.ip);
++ return;
++ }
++ }
++ }
++ }
++ }
++ } catch { /* non-critical — don't block chat for IP approval errors */ }
++ }
++
++ // ── Channel moderation settings ──────────────────────
++ if (modStreamId) {
++ const chatSettings = this._getChannelChatSettings(modStreamId);
++ const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);
++ const canModerateThisStream = permissions.canModerateStream(client.user, modStreamId);
++
++ // Max message length
++ const maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));
++ if (text.length > maxLen) {
++ this.sendTo(ws, { type: 'system', message: `Message too long. Max ${maxLen} characters.` });
++ return;
++ }
++
++ // Anonymous not allowed
++ if (!client.user && !chatSettings.allow_anonymous) {
++ this.sendTo(ws, { type: 'system', message: 'This channel requires a logged-in account to chat.' });
++ return;
++ }
++
++ // Links disabled — exempt [gif:url] tags (validated separately)
++ if (chatSettings.links_allowed === 0 && !isStaff) {
++ const textWithoutGifs = text.replace(/\[gif:https?:\/\/[^\]]+\]/gi, '');
++ if (/(https?:\/\/|www\.)/i.test(textWithoutGifs)) {
++ this.sendTo(ws, { type: 'system', message: 'Links are disabled in this channel chat.' });
++ return;
++ }
++ }
++
++ // Validate [gif:url] — only allow trusted domains
++ const gifTagMatch = text.match(/\[gif:(https?:\/\/[^\]]+)\]/i);
++ if (gifTagMatch) {
++ const ALLOWED_GIF_DOMAINS = ['tenor.com', 'media.tenor.com', 'media1.tenor.com', 'c.tenor.com', 'giphy.com', 'media.giphy.com', 'media0.giphy.com', 'media1.giphy.com', 'media2.giphy.com', 'media3.giphy.com', 'media4.giphy.com', 'i.giphy.com'];
++ try {
++ const gifUrl = new URL(gifTagMatch[1]);
++ if (!ALLOWED_GIF_DOMAINS.includes(gifUrl.hostname)) {
++ this.sendTo(ws, { type: 'system', message: 'Only Tenor and Giphy GIFs are allowed.' });
++ return;
++ }
++ } catch {
++ this.sendTo(ws, { type: 'system', message: 'Invalid GIF URL.' });
++ return;
++ }
++ }
++
++ // Followers only
++ if (chatSettings.followers_only && client.user && !isStaff) {
++ const stream = ctx.getStreamById(modStreamId);
++ if (stream && stream.user_id !== client.user.id && !ctx.isFollowing(client.user.id, stream.user_id)) {
++ this.sendTo(ws, { type: 'system', message: 'This chat is currently followers-only.' });
++ return;
++ }
++ }
++
++ // Account age gate
++ if (chatSettings.account_age_gate_hours && client.user && !isStaff) {
++ const ageMs = Date.now() - new Date(client.user.created_at).getTime();
++ if (ageMs < Number(chatSettings.account_age_gate_hours) * 3600000) {
++ this.sendTo(ws, { type: 'system', message: `This chat requires accounts older than ${chatSettings.account_age_gate_hours} hour(s).` });
++ return;
++ }
++ }
++
++ // Optional per-streamer anti-slur filter
++ if (chatSettings.slur_filter_enabled && !isStaff && !canModerateThisStream) {
++ const blockedTerms = this._parseSlurFilterTerms(chatSettings.slur_filter_terms);
++ const configuredRegexLines = this._parseRegexLines(chatSettings.slur_filter_regexes);
++ const hitConfigured = blockedTerms.length && this._containsConfiguredSlur(text, blockedTerms);
++ const hitCore = chatSettings.slur_filter_use_builtin !== 0 && this._containsCoreSlur(text, (() => { try { return JSON.parse(chatSettings.slur_filter_disabled_categories || '[]') || []; } catch { return []; } })());
++ const hitRegex = configuredRegexLines.length && this._containsRegexSlur(text, configuredRegexLines);
++ if (hitConfigured || hitCore || hitRegex) {
++ this.sendTo(ws, {
++ type: 'slur-blocked',
++ message: String(chatSettings.slur_filter_nudge_message || '').trim() || DEFAULT_SLUR_NUDGE,
++ streamer_enabled: true,
++ });
++ return;
++ }
++ }
++
++ }
++
++ const username = client.user ? client.user.display_name : client.anonId;
++ const coreUsername = client.user ? client.user.username : null;
++ const role = client.user ? client.user.role : 'anon';
++
++ // Voice channel tagging — clients can tag messages with the voice channel they're in
++ const voiceChannelId = (typeof msg.voiceChannelId === 'string' && msg.voiceChannelId) ? msg.voiceChannelId : null;
++
++ // Reply-to support — client sends reply_to_id, we look up the parent message
++ const replyToId = msg.reply_to_id ? parseInt(msg.reply_to_id) : null;
++ let replyTo = null;
++ if (replyToId) {
++ try {
++ const parent = db.getChatMessageById(replyToId);
++ const parentStillVisible = parent && !parent.is_deleted
++ && (!parent.auto_delete_at || new Date(parent.auto_delete_at).getTime() > Date.now());
++ if (parentStillVisible) {
++ replyTo = {
++ id: parent.id,
++ username: parent.username,
++ user_id: parent.user_id,
++ message: parent.message.length > 100 ? parent.message.slice(0, 100) + '…' : parent.message,
++ };
++ }
++ } catch { /* non-critical */ }
++ }
++
++ const requestedAutoDeleteMinutes = parseInt(msg.auto_delete_minutes, 10);
++ const allowViewerAutoDelete = !client.streamId
++ || this._getChannelChatSettings(client.streamId).viewer_auto_delete_enabled !== 0
++ || permissions.canModerateStream(client.user, client.streamId);
++ const autoDeleteAt = Number.isFinite(requestedAutoDeleteMinutes)
++ && requestedAutoDeleteMinutes >= MIN_CHAT_AUTO_DELETE_MINUTES
++ && allowViewerAutoDelete
++ ? new Date(Date.now() + Math.min(MAX_CHAT_AUTO_DELETE_MINUTES, requestedAutoDeleteMinutes) * 60 * 1000).toISOString()
++ : null;
++
++ const chatMsg = {
++ type: 'chat',
++ username,
++ core_username: coreUsername,
++ user_id: client.user?.id || null,
++ anon_id: client.anonId,
++ role,
++ message: text,
++ stream_id: client.streamId,
++ channel_user_id: client.channelUserId || null,
++ is_global: !client.streamId && !client.channelUserId,
++ avatar_url: client.user?.avatar_url || null,
++ profile_color: client.user?.profile_color || '#999',
++ filtered: false,
++ timestamp: new Date().toISOString(),
++ auto_delete_at: autoDeleteAt,
++ };
++
++ // Preserve voice channel tag so clients can filter voice-call messages
++ if (voiceChannelId) chatMsg.voiceChannelId = voiceChannelId;
++
++ // Attach cosmetic data for chat rendering
++ if (client.user?.id) {
++ try {
++ const cosmeticProfile = cosmetics.getCosmeticProfile(client.user.id);
++ if (cosmeticProfile.nameFX) chatMsg.nameFX = cosmeticProfile.nameFX;
++ if (cosmeticProfile.particleFX) chatMsg.particleFX = cosmeticProfile.particleFX;
++ if (cosmeticProfile.hatFX) chatMsg.hatFX = cosmeticProfile.hatFX;
++ if (cosmeticProfile.voiceFX) chatMsg.voiceFX = cosmeticProfile.voiceFX;
++ } catch { /* non-critical */ }
++
++ // Attach equipped tag for chat rendering
++ try {
++ const tagProfile = tags.getTagProfile(client.user.id);
++ if (tagProfile) chatMsg.tag = tagProfile;
++ } catch { /* non-critical */ }
++ }
++
++ // Save to database
++ try {
++ const result = db.saveChatMessage({
++ stream_id: client.streamId || null,
++ channel_user_id: client.channelUserId || null,
++ user_id: client.user?.id,
++ anon_id: client.anonId,
++ username,
++ message: text,
++ message_type: 'chat',
++ is_global: !client.streamId && !client.channelUserId,
++ reply_to_id: replyToId,
++ auto_delete_at: autoDeleteAt,
++ });
++ if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
++ } catch { /* non-critical */ }
++
++ // Attach reply context to broadcast
++ if (replyTo) chatMsg.reply_to = replyTo;
++
++ // Live's reactions to a real chat line — OpenCoins chat bonus (logged-in users in a
++ // stream), AI chat viewers (stream chat), the PowerChat overlay relay (any channel chat) —
++ // are ONE call to Live per message; the coin result comes back for this socket.
++ let isChannelMod = false;
++ try { isChannelMod = !!(client.user && client.channelUserId && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
++ const liveReactions = {
++ award: !!(client.user?.id && client.streamId),
++ ai: !!client.streamId,
++ powerchat: !!client.channelUserId,
++ };
++
++ // Welcome first-time chatters in this streamer's channel
++ if (client.streamId) {
++ try {
++ const stream = ctx.getStreamById(client.streamId);
++ if (stream?.user_id) {
++ const chatterKey = client.user ? `user:${client.user.id}` : `anon:${client.anonId}`;
++ if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {
++ db.recordFirstChat(chatterKey, stream.user_id);
++ const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';
++ this.broadcastToStream(client.streamId, {
++ type: 'system',
++ message: `Welcome ${welcomeName} to the chat! 👋`,
++ timestamp: new Date().toISOString(),
++ });
++ }
++ }
++ } catch { /* non-critical */ }
++ }
++
++ // Broadcast to appropriate audience
++ if (client.streamId || client.channelUserId) {
++ // Deliver to the streamer's whole channel room (every live slot + offline
++ // viewers) plus the specific stream room. This subsumes cross-slot
++ // forwarding so a viewer isn't interrupted when the streamer switches
++ // slots or briefly drops offline.
++ this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);
++ // Surface on the homepage global feed (tagged with the channel).
++ if (client.streamId) this.forwardToGlobal(client.streamId, chatMsg);
++ else this.forwardToGlobalByChannel(client.channelUserId, chatMsg);
++ // Auto-translate (async): foreign → English for everyone, English → the channel's
++ // language for a non-English streamer. Lands as a follow-up 'chat_translation'.
++ this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);
++ }
++ if (client.streamId) {
++
++ // Trigger server-side TTS synthesis (async, non-blocking).
++ // identityKey uses the immutable login handle (or anon id) so the
++ // per-user voice is stable even if the display name changes.
++ this.synthesizeAndBroadcastTTS(
++ client.streamId,
++ username,
++ text,
++ chatMsg.voiceFX,
++ null,
++ client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,
++ null,
++ chatMsg.id ? `m${chatMsg.id}` : null
++ );
++
++ // Check for 101soundboards links in the message (async, non-blocking)
++ this.processSoundboard(ws, client, text);
++
++ // AI chat viewers react to REAL typed chat (streamer or viewers) — sent with the
++ // other Live reactions below. Bots inject via broadcastToStream directly, so this
++ // path only sees genuine human messages.
++
++ } else if (!client.channelUserId) {
++ // Pure global chat (homepage) — offline channel messages were already
++ // delivered to the channel room above.
++ this.broadcastGlobal(chatMsg);
++ } else if (this._channelOwnerInRoom(client.channelUserId)) {
++ // OFFLINE channel chat with the channel OWNER present in their own room:
++ // synthesize TTS so their "TTS on my channel even when offline" option has
++ // audio to play (client-side settings + the one-speaking-tab lock decide
++ // whether it's actually audible). Owner absent = skip the synth cost.
++ this.synthesizeAndBroadcastTTS(
++ null,
++ username,
++ text,
++ chatMsg.voiceFX,
++ null,
++ client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,
++ client.channelUserId,
++ chatMsg.id ? `m${chatMsg.id}` : null
++ );
++ }
++
++ // Merge real chat into the streamer's PowerChat unified overlay (chat:write).
++ // Scoped to the CHANNEL, not the live session. This used to sit inside the
++ // `if (client.streamId)` block above, so it only fired while the streamer was
++ // live — yet viewers can chat in a channel whenever they like, and those
++ // messages are already persisted and broadcast channel-wide. The overlay simply
++ // never saw them. Chat is a property of the channel; only TTS, soundboards and
++ // AI viewers genuinely need a live stream.
++ // Relay is per CHANNEL, not per slot: a popout pinned to an expired stream id keeps
++ // chatting in the same channel, so Live's slot check (powerchat-platform
++ // channelRelayEnabled) follows the channel's current live stream rather than the stale
++ // id. Live adds isSubscriber from its subscriptions.
++ if (liveReactions.award || liveReactions.ai || liveReactions.powerchat) {
++ ctx.effects.chatMessage({
++ ...liveReactions,
++ stream_id: client.streamId || null,
++ channel_user_id: client.channelUserId || null,
++ user_id: client.user?.id || null,
++ anon_id: client.user ? null : (client.anonId || null),
++ username,
++ message: text,
++ msg_id: chatMsg && chatMsg.id ? chatMsg.id : null,
++ is_mod: isChannelMod,
++ is_streamer: !!(client.user && client.user.id === client.channelUserId),
++ powerchat_chat: {
++ chatterName: username,
++ externalChatterId: client.user?.id ? ('u' + client.user.id) : ('a' + (client.anonId || 'anon')),
++ message: text,
++ avatarUrl: client.user?.avatar_url || undefined,
++ isModerator: isChannelMod,
++ },
++ }).then((r) => {
++ const coinResult = r && r.coin;
++ if (coinResult) {
++ this.sendTo(ws, {
++ type: 'coin_earned',
++ coins: coinResult.coins,
++ total: coinResult.total,
++ streamerId: coinResult.streamerId,
++ reason: 'Chat bonus',
++ });
++ }
++ }).catch(() => { /* non-critical */ });
++ }
++ }
++
++ handleBangCommand(ws, client, text) {
++ const parts = text.trim().split(/\s+/);
++ const cmd = parts[0].toLowerCase();
++
++ // Arena: !hype / !beef / !arena (Live server/arena/arena-chat.js) — chat can only hype; the Arena
++ // is pure mic. Live runs the command and answers this socket through the bridge (sendToConn).
++ if (ctx.ARENA_COMMANDS.includes(cmd)) { ctx.effects.arenaCommand(client, cmd, parts); return; }
++
++ if (cmd === '!gotti') {
++ const username = client.user ? client.user.display_name : client.anonId;
++ const coreUsername = client.user ? client.user.username : null;
++ const role = client.user ? client.user.role : 'anon';
++ const gottiMsg = {
++ type: 'gotti',
++ username,
++ core_username: coreUsername,
++ user_id: client.user?.id || null,
++ anon_id: client.anonId,
++ role,
++ stream_id: client.streamId,
++ is_global: !client.streamId,
++ avatar_url: client.user?.avatar_url || null,
++ profile_color: client.user?.profile_color || '#999',
++ message: GOTTI_CAPTION,
++ gif_url: GOTTI_GIF_URL,
++ source_url: 'https://tenor.com/view/deez-something-came-in-the-mail-today-deez-nuts-discord-gif-20388619',
++ timestamp: new Date().toISOString(),
++ };
++
++ if (client.user?.id) {
++ try {
++ const cosmeticProfile = cosmetics.getCosmeticProfile(client.user.id);
++ if (cosmeticProfile.nameFX) gottiMsg.nameFX = cosmeticProfile.nameFX;
++ if (cosmeticProfile.particleFX) gottiMsg.particleFX = cosmeticProfile.particleFX;
++ if (cosmeticProfile.hatFX) gottiMsg.hatFX = cosmeticProfile.hatFX;
++ } catch { /* non-critical */ }
++
++ try {
++ const tagProfile = tags.getTagProfile(client.user.id);
++ if (tagProfile) gottiMsg.tag = tagProfile;
++ } catch { /* non-critical */ }
++ }
++
++ if (client.streamId) {
++ this.broadcastToStream(client.streamId, gottiMsg);
++ this.forwardToGlobal(client.streamId, gottiMsg);
++ } else {
++ this.broadcastGlobal(gottiMsg);
++ }
++ return;
++ }
++
++ if (cmd === '!sb') {
++ if (!client.streamId) {
++ this.sendTo(ws, { type: 'system', message: 'Soundboard commands only work in a stream chat.' });
++ return;
++ }
++ const chatSettings = this._getChannelChatSettings(client.streamId);
++ if (chatSettings.soundboard_enabled === 0) {
++ this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });
++ return;
++ }
++ const sbArgs = text.slice(parts[0].length).trim();
++ const parsed = soundboard.parseSoundboardMessage(`!sb ${sbArgs}`, {
++ allowPitch: chatSettings.soundboard_allow_pitch !== 0,
++ allowSpeed: chatSettings.soundboard_allow_speed !== 0,
++ });
++ if (!parsed) {
++ this.sendTo(ws, { type: 'system', message: 'Usage: !sb <sound-id or 101soundboards URL> [100p|-100p] [0.5-3 speed]' });
++ return;
++ }
++ this.processSoundboard(ws, client, text);
++ return;
++ }
++
++ if (!client.streamId) {
++ this.sendTo(ws, { type: 'system', message: 'Media request commands only work in a stream chat.' });
++ return;
++ }
++
++ const args = text.slice(parts[0].length).trim();
++
++ try {
++ const stream = ctx.getStreamById(client.streamId);
++ if (!stream?.user_id) {
++ this.sendTo(ws, { type: 'system', message: 'Could not resolve the current stream owner.' });
++ return;
++ }
++
++ // The media queue is Live's (server/media/media-queue.js: gold payment, playback,
++ // the streamer's overlay). Chat keeps the commands and asks Live to act.
++ const mediaFailed = (err, fallback) => this.sendTo(ws, { type: 'system', message: (err && err.message) || fallback });
++
++ switch (cmd) {
++ case '!sr':
++ case '!yt':
++ case '!youtube':
++ case '!req':
++ case '!request': {
++ if (!client.user?.id) {
++ this.sendTo(ws, { type: 'system', message: 'You must be logged in to request media.' });
++ return;
++ }
++
++ ctx.effects.mediaQueue('add', {
++ streamerId: stream.user_id,
++ streamId: client.streamId,
++ userId: client.user.id,
++ username: client.user.display_name || client.user.username,
++ input: args,
++ }).then(({ request }) => {
++ this.broadcastToStream(client.streamId, {
++ type: 'system',
++ message: `${request.username} added “${request.title}”${request.duration_seconds ? ` (${Math.floor(request.duration_seconds / 60)}m${request.duration_seconds % 60}s)` : ''} to the media queue for ${request.cost} gold.`,
++ timestamp: new Date().toISOString(),
++ });
++ this.sendTo(ws, {
++ type: 'coin_earned',
++ coins: 0,
++ currency: 'gold',
++ total: null, // client refreshes via /api/coins/balance (network wallet)
++ reason: 'Media request purchase',
++ });
++ }).catch((err) => {
++ this.sendTo(ws, { type: 'system', message: err.message || 'Failed to add media request.' });
++ });
++ return;
++ }
++
++ case '!queue': {
++ ctx.effects.mediaQueue('state', { streamerId: stream.user_id }).then(({ state }) => {
++ const items = state.queue.slice(0, 3);
++ if (!items.length) {
++ this.sendTo(ws, { type: 'system', message: 'The media queue is empty. Use !sr, !yt, !youtube, !req, or !request with a URL to queue something.' });
++ return;
++ }
++ const summary = items.map((item, index) => `#${index + 1} ${item.title}`).join(' • ');
++ this.sendTo(ws, { type: 'system', message: `Queued: ${summary}` });
++ }).catch((err) => mediaFailed(err, 'Media command failed.'));
++ return;
++ }
++
++ case '!np':
++ case '!nowplaying':
++ case '!watching': {
++ ctx.effects.mediaQueue('state', { streamerId: stream.user_id }).then(({ state }) => {
++ if (state.now_playing) {
++ const np = state.now_playing;
++ const durText = np.duration_seconds ? ` [${Math.floor(np.duration_seconds / 60)}m${np.duration_seconds % 60}s]` : '';
++ this.sendTo(ws, { type: 'system', message: `Now playing: ${np.title}${durText} (requested by ${np.username})` });
++ } else if (state.queue[0]) {
++ this.sendTo(ws, { type: 'system', message: `Nothing is playing yet. Up next: ${state.queue[0].title}` });
++ } else {
++ this.sendTo(ws, { type: 'system', message: 'Nothing is playing right now.' });
++ }
++ }).catch((err) => mediaFailed(err, 'Media command failed.'));
++ return;
++ }
++
++ case '!skip': {
++ if (!this.canModerate(client) && client.user?.id !== stream.user_id) {
++ this.sendTo(ws, { type: 'system', message: 'Only the streamer or a moderator can skip media.' });
++ return;
++ }
++ // Live runs finishCurrent(streamer, 'skipped') then startNext(streamer).
++ ctx.effects.mediaQueue('skip', { streamerId: stream.user_id, actorUserId: client.user?.id || null }).then(({ ended, next }) => {
++ if (ended) {
++ this.broadcastToStream(client.streamId, {
++ type: 'system',
++ message: `Skipped: ${ended.title}${next ? ` • Up next: ${next.title}` : ''}`,
++ timestamp: new Date().toISOString(),
++ });
++ } else {
++ this.sendTo(ws, { type: 'system', message: 'Nothing is currently playing.' });
++ }
++ }).catch((err) => mediaFailed(err, 'Media command failed.'));
++ return;
++ }
++
++ case '!mediahelp': {
++ this.sendTo(ws, { type: 'system', message: 'Media commands: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying, !skip' });
++ return;
++ }
++
++ // ── Cozmo robot commands ───────────────
++ // The robot's control socket lives in Live (controls/control-server.js), keyed by
++ // the streamer's stream key; Live delivers the command.
++ case '!forward':
++ case '!backward':
++ case '!left':
++ case '!right':
++ case '!liftup':
++ case '!liftdown':
++ case '!headup':
++ case '!headdown': {
++ const cozmoMap = {
++ '!forward': 'forward', '!backward': 'backward',
++ '!left': 'turn_left', '!right': 'turn_right',
++ '!liftup': 'lift_up', '!liftdown': 'lift_down',
++ '!headup': 'head_up', '!headdown': 'head_down',
++ };
++ this._hardwareCommand(ws, stream, cozmoMap[cmd], client);
++ return;
++ }
++
++ case '!say': {
++ if (!args) return;
++ this._hardwareCommand(ws, stream, `say:${args.slice(0, 200)}`, client);
++ return;
++ }
++
++ default:
++ // Unknown !command → try a per-channel viewer-uploaded sound clip.
++ // Forward trailing tokens (e.g. "500p", "0.5") as pitch/speed args.
++ this.triggerChannelSound(ws, client, stream, cmd.slice(1), parts.slice(1));
++ return;
++ }
++ } catch (err) {
++ this.sendTo(ws, { type: 'system', message: err.message || 'Media command failed.' });
++ }
++ }
++
++ /** Send a command to the streamer's hardware client (Live). Silent when the streamer is unknown. */
++ _hardwareCommand(ws, stream, command, client) {
++ ctx.effects.hardwareCommand(stream.user_id, command, client.user?.display_name || `anon${client.anonId || ''}`).then((r) => {
++ if (r && r.ok) return;
++ if (r && r.reason === 'no_hardware') this.sendTo(ws, { type: 'system', message: 'No hardware client connected.' });
++ }).catch((err) => {
++ this.sendTo(ws, { type: 'system', message: err.message || 'Media command failed.' });
++ });
++ }
++
++ /**
++ * Play a per-channel viewer-uploaded sound clip (triggered by !command).
++ * Silent no-op when the command is not a registered sound so unknown
++ * commands don't spam the chat.
++ */
++ triggerChannelSound(ws, client, stream, command, args = [], relay = null) {
++ try {
++ if (!stream?.user_id || !command) return;
++ const cmd = String(command).toLowerCase();
++ const sound = db.getChannelSoundByCommand(stream.user_id, cmd);
++ if (!sound) return; // not a sound command — stay silent
++
++ const chatSettings = this._getChannelChatSettings(client.streamId);
++ if (chatSettings.custom_sounds_enabled === 0) {
++ if (ws) this.sendTo(ws, { type: 'system', message: 'This streamer has disabled chat sound commands.' });
++ return;
++ }
++
++ // Pitch/speed args (e.g. "!honk 500p 0.5"), clamped to the channel's limits.
++ const mods = soundboard.parseModifiers(args || [], {
++ allowPitch: chatSettings.soundboard_allow_pitch !== 0,
++ allowSpeed: chatSettings.soundboard_allow_speed !== 0,
++ minSpeed: chatSettings.sound_min_speed,
++ maxSpeed: chatSettings.sound_max_speed,
++ minPitch: chatSettings.sound_min_pitch_rate,
++ maxPitch: chatSettings.sound_max_pitch_rate,
++ });
++
++ // Banned users can't trigger sounds
++ if (client.user && ctx.isUserBanned(client.user.id, client.streamId)) return;
++ if (client.ip && !this._isBanExemptAdmin(client) && ctx.isIpBanned(client.ip, client.streamId)) return;
++
++ // Rate limits — reuse the soundboard limiter (per-user + per-stream window)
++ const now = Date.now();
++ const rateKey = `${client.streamId}:${relay ? `rs${relay.username}` : (client.user ? `u${client.user.id}` : `a${client.anonId}`)}`;
++ const lastUsedAt = this.soundboardRateLimits.get(rateKey) || 0;
++ if ((now - lastUsedAt) < SOUNDBOARD_RATE_LIMIT_MS) {
++ const remaining = Math.ceil((SOUNDBOARD_RATE_LIMIT_MS - (now - lastUsedAt)) / 1000);
++ if (ws) this.sendTo(ws, { type: 'system', message: `Wait ${remaining}s before triggering another sound.` });
++ return;
++ }
++ const streamWindow = this.soundboardStreamLimits.get(client.streamId) || { count: 0, windowStart: now };
++ if ((now - streamWindow.windowStart) >= SOUNDBOARD_STREAM_WINDOW_MS) {
++ streamWindow.count = 0;
++ streamWindow.windowStart = now;
++ }
++ if (streamWindow.count >= SOUNDBOARD_STREAM_MAX_PER_WINDOW) {
++ if (ws) this.sendTo(ws, { type: 'system', message: 'Too many sounds are playing right now — try again shortly.' });
++ return;
++ }
++
++ // Read + encode the clip
++ if (!sound.url || !fs.existsSync(sound.url)) return;
++ let audioB64;
++ try {
++ audioB64 = fs.readFileSync(sound.url).toString('base64');
++ } catch { return; }
++
++ this.soundboardRateLimits.set(rateKey, now);
++ streamWindow.count += 1;
++ this.soundboardStreamLimits.set(client.streamId, streamWindow);
++
++ const username = relay ? relay.username
++ : (client.user ? (client.user.display_name || client.user.username) : (client.anonId || 'someone'));
++
++ // Announce as a RICH chat message (same identity/cosmetics/tag as a normal
++ // message) so it shows the user's nametag, badges, avatar and cosmetics —
++ // not a plain "x played !command" text line.
++ const soundMsg = {
++ type: 'chat',
++ username,
++ core_username: relay ? null : (client.user ? client.user.username : null),
++ user_id: client.user?.id || null,
++ anon_id: relay ? null : client.anonId,
++ role: relay ? (relay.role || 'external') : (client.user ? client.user.role : 'anon'),
++ message: `played !${cmd}`,
++ message_type: 'channel-sound',
++ sound: { command: cmd, pitch: mods.pitch, speed: mods.speed, pitchShift: mods.pitchShift, args: (args || []).join(' '), emoteCode: sound.emote_code || '' },
++ stream_id: client.streamId,
++ is_global: !client.streamId,
++ avatar_url: (relay ? relay.avatar_url : client.user?.avatar_url) || null,
++ profile_color: (relay ? relay.profile_color : client.user?.profile_color) || '#999',
++ source_platform: relay ? relay.sourcePlatform : undefined,
++ timestamp: new Date().toISOString(),
++ };
++ if (!relay && client.user?.id) {
++ try {
++ const cp = cosmetics.getCosmeticProfile(client.user.id);
++ if (cp.nameFX) soundMsg.nameFX = cp.nameFX;
++ if (cp.particleFX) soundMsg.particleFX = cp.particleFX;
++ if (cp.hatFX) soundMsg.hatFX = cp.hatFX;
++ } catch { /* non-critical */ }
++ try {
++ const tagProfile = tags.getTagProfile(client.user.id);
++ if (tagProfile) soundMsg.tag = tagProfile;
++ } catch { /* non-critical */ }
++ }
++ // Persist the announce so it survives a reload — history rebuilds the
++ // rich sound row from metadata (the audio itself is never replayed).
++ try {
++ const saved = db.saveChatMessage({
++ stream_id: client.streamId || null,
++ channel_user_id: stream.user_id,
++ user_id: client.user?.id,
++ anon_id: relay ? null : client.anonId,
++ username,
++ message: soundMsg.message,
++ message_type: 'channel-sound',
++ is_global: false,
++ source_platform: relay ? relay.sourcePlatform : null,
++ metadata: { sound: soundMsg.sound },
++ });
++ if (saved.lastInsertRowid) soundMsg.id = Number(saved.lastInsertRowid);
++ } catch { /* non-critical */ }
++ this.broadcastToStream(client.streamId, soundMsg);
++ // Also surface it on the global chat feed / global overlay (with stream_channel).
++ this.forwardToGlobal(client.streamId, soundMsg);
++ this.broadcastToStream(client.streamId, {
++ type: 'soundboard-audio',
++ username,
++ title: `!${cmd}`,
++ audio: audioB64,
++ mimeType: sound.mime || 'audio/mpeg',
++ pitch: mods.pitch,
++ speed: mods.speed,
++ pitchShift: mods.pitchShift,
++ source: 'channel-sound',
++ timestamp: new Date().toISOString(),
++ });
++ } catch (err) {
++ console.error('[ChannelSound] trigger error:', err.message);
++ }
++ }
++
++ /**
++ * Handle chat commands
++ */
++ handleCommand(ws, client, text) {
++ const parts = text.slice(1).split(' ');
++ const cmd = parts[0].toLowerCase();
++ const args = parts.slice(1).join(' ');
++ const argParts = parts.slice(1);
++
++ switch (cmd) {
++ case 'ai': {
++ // /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.
++ if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'Only moderators can control the AI viewers.' }); return; }
++ // Live's ai-chatbot-service.onModCommand answers (AI viewers stay in Live until OpenVibe.AI).
++ ctx.effects.aiModCommand(client.channelUserId, client.streamId, parts.slice(1), { by: client.user?.username })
++ .then((reply) => this.sendTo(ws, { type: 'system', message: reply || 'ok' }))
++ .catch((e) => this.sendTo(ws, { type: 'system', message: `AI viewers: ${e.message}` }));
++ return;
++ }
++ case 'help':
++ this.sendTo(ws, {
++ type: 'system',
++ message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +
++ `\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +
++ (this.canModerate(client)
++ ? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear, /slow <seconds>`
++ : ''),
++ });
++ break;
++
++ case 'tts':
++ if (!args) {
++ this.sendTo(ws, { type: 'system', message: 'Usage: /tts <message>' });
++ return;
++ }
++ {
++ const ttsMsg = {
++ type: 'tts',
++ username: client.user?.display_name || client.anonId,
++ core_username: client.user?.username || null,
++ message: args,
++ timestamp: new Date().toISOString(),
++ };
++ // Attach voice cosmetic if equipped
++ let voiceFX = null;
++ if (client.user?.id) {
++ try {
++ const cp = cosmetics.getCosmeticProfile(client.user.id);
++ if (cp.voiceFX) {
++ ttsMsg.voiceFX = cp.voiceFX;
++ voiceFX = cp.voiceFX;
++ }
++ } catch { /* non-critical */ }
++ }
++ this.broadcastToStream(client.streamId, ttsMsg);
++
++ // Also synthesize server-side TTS for site-wide mode
++ this.synthesizeAndBroadcastTTS(
++ client.streamId,
++ ttsMsg.username,
++ args,
++ voiceFX,
++ null,
++ client.user ? `user:${client.user.username}` : `anon:${client.anonId}`
++ );
++ }
++ break;
++
++ case 'color':
++ if (!client.user) {
++ this.sendTo(ws, { type: 'system', message: 'You must be logged in to change color.' });
++ } else if (!args || !/^#[0-9a-fA-F]{6}$/.test(args)) {
++ this.sendTo(ws, { type: 'system', message: 'Usage: /color #ff00ff (hex color code)' });
++ } else {
++ // users.profile_color is Live's: Live writes it, then this socket uses it at once.
++ ctx.effects.setUserColor(client.user.id, args).then(() => {
++ client.user.profile_color = args;
++ this.sendTo(ws, { type: 'system', message: `Color set to ${args}` });
++ }).catch(() => this.sendTo(ws, { type: 'system', message: 'Could not change your color right now. Try again.' }));
++ }
++ break;
++
++ case 'viewers': {
++ const count = this.getStreamViewerCount(client.streamId);
++ this.sendTo(ws, { type: 'system', message: `${count} viewer(s) in chat` });
++ break;
++ }
++
++ case 'uptime': {
++ if (!client.streamId) {
++ this.sendTo(ws, { type: 'system', message: 'Not in a stream chat.' });
++ break;
++ }
++ try {
++ const stream = ctx.getStreamById(client.streamId);
++ if (stream && stream.started_at) {
++ const start = new Date(stream.started_at.replace(' ', 'T') + 'Z').getTime();
++ const elapsed = Date.now() - start;
++ const hours = Math.floor(elapsed / 3600000);
++ const minutes = Math.floor((elapsed % 3600000) / 60000);
++ const seconds = Math.floor((elapsed % 60000) / 1000);
++ const parts = [];
++ if (hours > 0) parts.push(`${hours}h`);
++ parts.push(`${minutes}m`);
++ parts.push(`${seconds}s`);
++ this.sendTo(ws, { type: 'system', message: `Stream uptime: ${parts.join(' ')}` });
++ } else {
++ this.sendTo(ws, { type: 'system', message: 'Stream is offline.' });
++ }
++ } catch {
++ this.sendTo(ws, { type: 'system', message: 'Could not determine uptime.' });
++ }
++ break;
++ }
++
++ case 'w':
++ case 'whisper':
++ case 'msg': {
++ this.sendTo(ws, { type: 'system', message: 'Whispers have been replaced by DMs! Click the message icon in the navbar to open Messenger.' });
++ break;
++ }
++
++ case 'me': {
++ if (!args) {
++ this.sendTo(ws, { type: 'system', message: 'Usage: /me <action>' });
++ break;
++ }
++ const username = client.user?.display_name || client.anonId;
++ this.broadcastToStream(client.streamId, {
++ type: 'chat',
++ username,
++ core_username: client.user?.username || null,
++ role: client.user?.role || 'anon',
++ message: `* ${username} ${args}`,
++ is_action: true,
++ timestamp: new Date().toISOString(),
++ });
++ break;
++ }
++
++ case 'ban':
++ this.handleModAction(ws, client, 'ban', args);
++ break;
++
++ case 'unban':
++ this.handleModAction(ws, client, 'unban', args);
++ break;
++
++ case 'timeout':
++ this.handleModAction(ws, client, 'timeout', args);
++ break;
++
++ case 'clear':
++ if (this.canModerate(client)) {
++ this.broadcastToStream(client.streamId, { type: 'clear' });
++ this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');
++ } else {
++ this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
++ }
++ break;
++
++ case 'slow': {
++ if (this.canModerate(client)) {
++ let seconds;
++ if (args === 'off' || args === 'disable' || args === '0') {
++ seconds = 0;
++ } else {
++ seconds = parseInt(args);
++ if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;
++ }
++ // Per-stream slow mode (not global)
++ if (client.streamId) {
++ this.slowModeByStream.set(client.streamId, seconds > 0 ? seconds * 1000 : 0);
++ // Persist (channel_moderation_settings is written by Live in this wave)
++ try {
++ const stream = ctx.getStreamById(client.streamId);
++ if (stream?.channel_id) {
++ ctx.effects.updateChannelModerationSettings(stream.channel_id, { slow_mode_seconds: seconds }, client.user?.id || null).catch(() => { /* non-critical */ });
++ }
++ } catch { /* non-critical */ }
++ }
++ // Dedicated slowmode event so clients can show/hide UI
++ this.broadcastToStream(client.streamId, {
++ type: 'slowmode',
++ seconds,
++ });
++ const msg = seconds > 0
++ ? `Slow mode enabled: ${seconds}s between messages`
++ : 'Slow mode disabled.';
++ this.broadcastToStream(client.streamId, {
++ type: 'system',
++ message: msg,
++ });
++ this.logChatModeration(client, 'slowmode_update', { seconds });
++ } else {
++ this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
++ }
++ break;
++ }
++
++ case 'paste': {
++ // /paste <content> — create a quick paste from chat
++ if (!args.trim()) {
++ this.sendTo(ws, { type: 'system', message: 'Usage: /paste <content to share>' });
++ break;
++ }
++ {
++ const title = `Chat paste by ${client.username || client.displayName || 'anon'}`;
++ const userId = client.userId || client.user?.id || null;
++ // Pastes: Live's pastes-client posts them (OpenVibe.Community / Media per PASTES_AUTHORITY).
++ ctx.effects.createPaste({
++ title,
++ content: args.trim(),
++ language: 'auto',
++ visibility: 'public',
++ user_id: userId || undefined,
++ stream_id: client.streamId || undefined,
++ }).then((paste) => {
++ const siteUrl = process.env.SITE_URL || '';
++ const pasteUrl = `${siteUrl}/p/${paste.slug}`;
++ // Show link to everyone in stream
++ this.broadcastToStream(client.streamId, {
++ type: 'system',
++ message: `📋 ${client.displayName || client.username || 'Anonymous'} shared a paste: ${pasteUrl}`,
++ });
++ }).catch((err) => {
++ console.error('[Chat] /paste error:', err.message);
++ this.sendTo(ws, { type: 'system', message: 'Failed to create paste.' });
++ });
++ }
++ break;
++ }
++
++ default:
++ this.sendTo(ws, { type: 'system', message: `Unknown command: /${cmd}. Type /help for a list.` });
++ }
++ }
++
++ /**
++ * Handle mod actions (ban, unban, timeout)
++ */
++ handleModAction(ws, client, action, args) {
++ this._handleModAction(ws, client, action, args).catch((err) => {
++ console.warn(`[Chat] /${action} failed:`, err.message);
++ this.sendTo(ws, { type: 'system', message: err.status === 403 ? 'You do not have permission.' : `Could not ${action} right now. Try again.` });
++ });
++ }
++
++ /**
++ * The bans table is Live's (OpenVibe.Network policy later): Chat decides as before, Live writes
++ * the row (re-checking the moderator) and Chat's ban cache is refreshed before the reply.
++ */
++ async _handleModAction(ws, client, action, args) {
++ if (!this.canModerate(client)) {
++ this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
++ return;
++ }
++
++ const target = args.split(' ')[0];
++ if (!target) return;
++ // The stream whose moderators may do this (offline channel rooms: the latest stream).
++ const moderationStreamId = client.streamId || this._moderationStreamFor(client);
++ const banEffect = (row) => ctx.effects.ban({ ...row, actor_user_id: client.user.id, moderation_stream_id: moderationStreamId });
++
++ switch (action) {
++ case 'ban': {
++ const targetUser = await ctx.ensureUserByUsername(target);
++ if (targetUser) {
++ // Prevent non-admins from banning admins
++ if (permissions.isGlobalModOrAbove(targetUser) && targetUser.role === 'admin' && client.user.role !== 'admin') {
++ this.sendTo(ws, { type: 'system', message: 'You cannot ban an admin.' });
++ return;
++ }
++ await banEffect({ action: 'ban', stream_id: client.streamId, user_id: targetUser.id, reason: 'Banned by moderator', banned_by: client.user.id });
++ this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
++ this.broadcastToStream(client.streamId, {
++ type: 'system', message: `${target} has been banned.`
++ });
++ this.logChatModeration(client, client.streamId ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);
++ } else {
++ // Ban by anon ID
++ const anonTarget = this.findClientByAnonId(target, client.streamId);
++ if (anonTarget) {
++ await banEffect({ action: 'ban', stream_id: client.streamId, ip_address: anonTarget.ip, anon_id: target, reason: 'Banned by moderator', banned_by: client.user.id });
++ this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
++ }
++ this.logChatModeration(client, client.streamId ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });
++ }
++ break;
++ }
++ case 'timeout': {
++ const duration = parseInt(args.split(' ')[1]) || 300; // Default 5 min
++ const targetUser = await ctx.ensureUserByUsername(target);
++ const expires = new Date(Date.now() + duration * 1000).toISOString();
++ if (targetUser) {
++ await banEffect({ action: 'ban', stream_id: client.streamId, user_id: targetUser.id, reason: `Timeout ${duration}s`, banned_by: client.user.id, expires_at: expires });
++ this.logChatModeration(client, client.streamId ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);
++ }
++ this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });
++ break;
++ }
++ case 'unban': {
++ const targetUser = await ctx.ensureUserByUsername(target);
++ if (targetUser) {
++ await banEffect({ action: 'unban', stream_id: client.streamId, user_id: targetUser.id });
++ this.logChatModeration(client, client.streamId ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);
++ }
++ this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });
++ break;
++ }
++ }
++ }
++
++ // ── Helper methods ───────────────────────────────────────
++ /**
++ * Synthesize TTS audio for a chat message and broadcast to stream.
++ * Runs asynchronously — does not block message delivery.
++ */
++ // Is the channel owner connected to their OWN channel room right now? Gates
++ // offline-channel TTS synthesis so we never pay for audio nobody will hear.
++ _channelOwnerInRoom(channelUserId) {
++ if (!channelUserId) return false;
++ for (const [ws, client] of this.clients) {
++ if (ws.readyState !== WebSocket.OPEN) continue;
++ if (client.user && Number(client.user.id) === Number(channelUserId)
++ && Number(client.channelUserId) === Number(channelUserId)) return true;
++ }
++ return false;
++ }
++
++ // TTS audio delivery: to the stream room while live, to the CHANNEL room for
++ // offline channel chat (streamId null).
++ _broadcastTtsPayload(streamId, channelUserId, payload) {
++ if (streamId) this.broadcastToStream(streamId, payload);
++ else if (channelUserId) this.broadcastToChannelRoom(channelUserId, null, payload);
++ }
++
++ // streamId may be null for OFFLINE channel chat — pass channelUserId instead and
++ // the audio is delivered to the channel room rather than a stream room.
++ async synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null, channelUserId = null, ttsKey = null) {
++ // Queue accounting key: per-stream when live, per-channel when offline.
++ const queueKey = streamId || (channelUserId ? `ch:${channelUserId}` : null);
++ // Duplicate suppression by MESSAGE IDENTITY, not content: the same chat
++ // message synthesized twice (duplicated bridge, double-fired relay) is a dupe;
++ // a user legitimately typing the same text again is NOT and must be read
++ // again. Callers pass ttsKey (the persisted message id). The content fallback
++ // for id-less paths uses a 2s window — wide enough for a racing double
++ // delivery, far too narrow to eat a real repeat.
++ if (!this._recentTtsDedupe) this._recentTtsDedupe = new Map();
++ const dedupeKey = ttsKey ? `k:${queueKey}|${ttsKey}` : `${queueKey}|${identityKey || username}|${String(text).slice(0, 200)}`;
++ const windowMs = ttsKey ? 30000 : 2000;
++ const nowMs = Date.now();
++ const lastMs = this._recentTtsDedupe.get(dedupeKey);
++ if (lastMs && nowMs - lastMs < windowMs) { console.log(`[TTS] deduped duplicate synth for ${username} in ${queueKey} (${ttsKey ? 'same message id' : 'same content <2s'})`); return; }
++ this._recentTtsDedupe.set(dedupeKey, nowMs);
++ if (this._recentTtsDedupe.size > 500) {
++ for (const [k, t] of this._recentTtsDedupe) if (nowMs - t > 60000) this._recentTtsDedupe.delete(k);
++ }
++ try {
++ // "." prefix = user opted this message out of TTS — never synthesize or broadcast it.
++ if (String(text || '').trimStart().startsWith('.')) return;
++
++ const settings = ttsEngine.getTTSSettings();
++ if (!settings.enabled) return;
++
++ // Queue limit checks
++ const limits = ttsEngine.getQueueLimits();
++ const globalCount = this.ttsQueueSize.get(queueKey) || 0;
++ if (globalCount >= limits.maxGlobal) return;
++
++ // Determine voice ID from equipped cosmetic
++ let voiceId = null;
++ if (voiceFX?.itemId && ttsEngine.VOICE_CATALOG[voiceFX.itemId]) {
++ voiceId = voiceFX.itemId;
++ }
++
++ // Increment queue counter
++ this.ttsQueueSize.set(queueKey, globalCount + 1);
++
++ // Honor the channel's configured TTS length (streamers can raise it up to 1200);
++ // falls back to the site default when unset. Without this the server synth always
++ // truncated at the global 200 even when the channel allowed more.
++ let ttsMaxOverride;
++ try { ttsMaxOverride = Number(this._getChannelChatSettings(streamId).tts_max_length) || undefined; } catch { /* use engine default */ }
++
++ let result;
++ if (!voiceId && settings.perUserVoices) {
++ // No equipped cosmetic voice → give this chatter a stable per-username voice.
++ const idKey = identityKey || username || 'anon';
++ result = await ttsEngine.synthesizeUserVoice(text, idKey, username, ttsMaxOverride);
++ } else {
++ result = await ttsEngine.synthesize(text, voiceId || settings.defaultVoice, username, ttsMaxOverride);
++ }
++
++ // Decrement queue counter
++ const current = this.ttsQueueSize.get(queueKey) || 1;
++ this.ttsQueueSize.set(queueKey, Math.max(0, current - 1));
++
++ if (!result) return;
++
++ // Broadcast TTS audio to all clients in the stream
++ this._broadcastTtsPayload(streamId, channelUserId, {
++ type: 'tts-audio',
++ username,
++ // Stable sender identity ("user:<login>" / "anon:<id>") so clients can
++ // skip their OWN message's TTS locally — senders were hearing their
++ // message twice (their tab + the stream audio).
++ sender_key: identityKey || undefined,
++ // Unique per utterance — clients dedupe playback on this, so identical
++ // TEXT from separate messages still reads every time.
++ ttsKey: ttsKey || undefined,
++ message: text,
++ audio: result.audio,
++ mimeType: result.mimeType,
++ engine: result.engine,
++ voiceName: result.voiceName,
++ voiceId: result.voiceId,
++ fallback: result.fallback || false,
++ source_platform: sourcePlatform || undefined,
++ timestamp: new Date().toISOString(),
++ });
++ } catch (err) {
++ // Ensure queue counter is decremented on error
++ const current = this.ttsQueueSize.get(queueKey) || 1;
++ this.ttsQueueSize.set(queueKey, Math.max(0, current - 1));
++ console.error('[TTS] Synthesis broadcast error:', err.message);
++ }
++ }
++
++ /**
++ * Process a chat message for 101soundboards links or !sb commands.
++ * Fetches the audio, caches it, and broadcasts to stream clients.
++ */
++ async processSoundboard(ws, client, text) {
++ try {
++ if (!soundboard.isConfigured()) {
++ if (String(text || '').trim().startsWith('!sb')) {
++ this.sendTo(ws, { type: 'system', message: 'Soundboard is not configured on this server.' });
++ }
++ return;
++ }
++
++ const streamId = client?.streamId || null;
++ if (!streamId) return;
++
++ const chatSettings = this._getChannelChatSettings(streamId);
++ if (chatSettings.soundboard_enabled === 0) {
++ if (String(text || '').trim().startsWith('!sb')) {
++ this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });
++ }
++ return;
++ }
++
++ const parsed = soundboard.parseSoundboardMessage(text, {
++ allowPitch: chatSettings.soundboard_allow_pitch !== 0,
++ allowSpeed: chatSettings.soundboard_allow_speed !== 0,
++ });
++ if (!parsed) return;
++
++ const bannedIds = new Set(soundboard.normalizeBannedIds(chatSettings.soundboard_banned_ids));
++ if (bannedIds.has(String(parsed.soundId))) {
++ if (String(text || '').trim().startsWith('!sb')) {
++ this.sendTo(ws, { type: 'system', message: 'That 101soundboards sound is blocked by this streamer.' });
++ }
++ return;
++ }
++
++ const userKey = client.user?.id ? `user:${client.user.id}` : `anon:${client.anonId || client.ip}`;
++ const rateKey = `${streamId}:${userKey}`;
++ const lastUsedAt = this.soundboardRateLimits.get(rateKey) || 0;
++ const now = Date.now();
++ if ((now - lastUsedAt) < SOUNDBOARD_RATE_LIMIT_MS) {
++ if (String(text || '').trim().startsWith('!sb')) {
++ const remaining = Math.ceil((SOUNDBOARD_RATE_LIMIT_MS - (now - lastUsedAt)) / 1000);
++ this.sendTo(ws, { type: 'system', message: `Wait ${remaining}s before using another soundboard clip.` });
++ }
++ return;
++ }
++
++ // Per-stream global rate limit (15 sounds/min across all users)
++ const streamWindow = this.soundboardStreamLimits.get(streamId) || { count: 0, windowStart: now };
++ if ((now - streamWindow.windowStart) >= SOUNDBOARD_STREAM_WINDOW_MS) {
++ streamWindow.count = 0;
++ streamWindow.windowStart = now;
++ }
++ if (streamWindow.count >= SOUNDBOARD_STREAM_MAX_PER_WINDOW) {
++ if (String(text || '').trim().startsWith('!sb')) {
++ this.sendTo(ws, { type: 'system', message: 'Too many soundboard clips are playing in this stream right now. Try again soon.' });
++ }
++ return;
++ }
++ streamWindow.count++;
++ this.soundboardStreamLimits.set(streamId, streamWindow);
++
++ const result = await soundboard.getSoundboardAudio(parsed.soundId);
++ if (!result) {
++ if (String(text || '').trim().startsWith('!sb')) {
++ this.sendTo(ws, { type: 'system', message: 'Could not load that 101soundboards clip.' });
++ }
++ return;
++ }
++
++ this.soundboardRateLimits.set(rateKey, now);
++
++ const username = client.user?.display_name || client.user?.username || client.anonId || 'anon';
++ const coreUsername = client.user?.username || null;
++ const role = client.user ? client.user.role : 'anon';
++
++ const sbMsg = {
++ type: 'chat',
++ username,
++ core_username: coreUsername,
++ user_id: client.user?.id || null,
++ anon_id: client.anonId,
++ role,
++ stream_id: streamId,
++ avatar_url: client.user?.avatar_url || null,
++ profile_color: client.user?.profile_color || '#999',
++ message_type: 'soundboard',
++ message: `played ${result.title}`,
++ soundboard: {
++ soundId: result.soundId,
++ title: result.title,
++ sourceUrl: result.sourceUrl,
++ pitch: parsed.pitch,
++ speed: parsed.speed,
++ pitchShift: parsed.pitchShift,
++ },
++ timestamp: new Date().toISOString(),
++ };
++ // Persist so the announce survives a reload (rebuilt from metadata).
++ try {
++ const saved = db.saveChatMessage({
++ stream_id: streamId,
++ user_id: client.user?.id,
++ anon_id: client.anonId,
++ username,
++ message: sbMsg.message,
++ message_type: 'soundboard',
++ is_global: false,
++ metadata: { soundboard: sbMsg.soundboard },
++ });
++ if (saved.lastInsertRowid) sbMsg.id = Number(saved.lastInsertRowid);
++ } catch { /* non-critical */ }
++ this.broadcastToStream(streamId, sbMsg);
++
++ this.broadcastToStream(streamId, {
++ type: 'soundboard-audio',
++ username,
++ audio: result.audio,
++ mimeType: result.mimeType,
++ soundId: result.soundId,
++ title: result.title,
++ sourceUrl: result.sourceUrl,
++ pitch: parsed.pitch,
++ speed: parsed.speed,
++ pitchShift: parsed.pitchShift,
++ timestamp: new Date().toISOString(),
++ });
++ } catch (err) {
++ if (String(text || '').trim().startsWith('!sb')) {
++ this.sendTo(ws, { type: 'system', message: err.message || 'Could not load that 101soundboards clip.' });
++ }
++ console.error('[Soundboard] Process error:', err.message);
++ }
++ }
++
++ /**
++ * Can this client moderate their current stream's chat?
++ * Uses the permission layer: admin, global_mod, stream owner, or channel mod.
++ * Streamers do NOT get mod powers in other people's chats.
++ */
++ canModerate(client) {
++ if (!client.user) return false;
++ // Offline channel chat: the channel's latest stream stands in (see _moderationStreamFor).
++ const sid = client.streamId || this._moderationStreamFor(client);
++ return !!sid && permissions.canModerateStream(client.user, sid) || permissions.isGlobalModOrAbove(client.user);
++ }
++
++ /** @deprecated Use canModerate(client) — kept temporarily for any external callers */
++ isMod(client) {
++ return this.canModerate(client);
++ }
++
++ findClientByAnonId(anonId, streamId) {
++ for (const [, info] of this.clients) {
++ if (info.anonId === anonId && info.streamId === streamId) {
++ return info;
++ }
++ }
++ return null;
++ }
++
++ /**
++ * Find the IP address of a connected user by user ID.
++ * Returns the IP from their most recent connection, or null if not connected.
++ */
++ getConnectedUserIp(userId) {
++ for (const [, info] of this.clients) {
++ if (info.user?.id === userId) return info.ip;
++ }
++ return null;
++ }
++
++ /**
++ * Disconnect all WebSocket clients for a given user ID or IP.
++ * Used after banning to immediately kick them.
++ */
++ disconnectUser({ userId, ip, streamId } = {}) {
++ for (const [ws, info] of this.clients) {
++ const matchUser = userId && info.user?.id === userId;
++ const matchIp = ip && info.ip === ip;
++ const matchStream = streamId ? info.streamId === streamId : true;
++ if ((matchUser || matchIp) && matchStream) {
++ this.sendTo(ws, { type: 'system', message: 'You have been banned.' });
++ try { ws.close(1000, 'banned'); } catch {}
++ }
++ }
++ }
++
++ findWsByUsername(name, streamId) {
++ const nameLower = name.toLowerCase();
++ for (const [ws, info] of this.clients) {
++ if (info.streamId !== streamId) continue;
++ const uname = info.user?.display_name || info.user?.username || info.anonId;
++ if (uname && uname.toLowerCase() === nameLower) return ws;
++ }
++ return null;
++ }
++
++ sendTo(ws, data) {
++ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
++ ws.send(JSON.stringify(data));
++ }
++ }
++
++ /** sendTo() for a socket Live knows only by its handle (connId) — replies to Live-run commands. */
++ sendToConn(connId, data) {
++ if (!connId) return false;
++ for (const [ws, client] of this.clients) {
++ if (client.connId === connId) { this.sendTo(ws, data); return true; }
++ }
++ return false;
++ }
++
++ /** Language a channel lives in ('en' when unknown). Live's i18n decides; cached in live-context. */
++ _channelLanguage(channelUserId) {
++ if (!channelUserId) return 'en';
++ try { return ctx.channelLanguage(channelUserId); } catch { return 'en'; }
++ }
++
++ /**
++ * Translate a just-broadcast chat line and push the translation to the same rooms as a
++ * 'chat_translation' event (clients attach it under the message by id). Persisted into
++ * chat_messages.metadata so history shows it too. Fire-and-forget; never throws.
++ */
++ _maybeTranslate(chatMsg, channelUserId, streamId) {
++ if (!chatMsg || !chatMsg.message || chatMsg.message_type && chatMsg.message_type !== 'chat') return;
++ // Live's i18n translates (and answers null when translation is unavailable).
++ let chanUid = channelUserId || null;
++ if (!chanUid && streamId) { try { chanUid = ctx.getStreamById(streamId)?.user_id || null; } catch { /* */ } }
++ const text = String(chatMsg.message).replace(/^\s*\.\s?/, ''); // ".msg" = tts-off marker
++ ctx.effects.translate(text, chanUid).then((tr) => {
++ if (!tr || !tr.text) return;
++ const evt = {
++ type: 'chat_translation', id: chatMsg.id || null, from: tr.from, to: tr.to, text: tr.text,
++ stream_id: streamId || null, channel_user_id: chanUid, timestamp: new Date().toISOString(),
++ };
++ this.broadcastToChannelRoom(chanUid, streamId, evt);
++ if (streamId) this.forwardToGlobal(streamId, evt);
++ else this.forwardToGlobalByChannel(chanUid, evt);
++ if (chatMsg.id) { try { db.mergeChatMessageMetadata(chatMsg.id, { translation: tr }); } catch { /* */ } }
++ }).catch(() => { /* best-effort */ });
++ }
++
++ broadcastToStream(streamId, data) {
++ const msg = JSON.stringify(data);
++ for (const [ws, client] of this.clients) {
++ if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
++ ws.send(msg);
++ }
++ }
++ }
++
++ /**
++ * Deliver to a streamer's whole channel room: any client in that streamer's
++ * channel (`channelUserId`, stable across slots + offline) OR in the specific
++ * live-session stream room (`streamId`, for backward-compat with clients that
++ * joined before channel rooms existed). Deduped per connection.
++ */
++ broadcastToChannelRoom(channelUserId, streamId, data) {
++ const msg = JSON.stringify(data);
++ for (const [ws, client] of this.clients) {
++ if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > MAX_SEND_BACKPRESSURE) continue;
++ const inChannel = channelUserId && client.channelUserId === channelUserId;
++ const inStream = streamId && client.streamId === streamId;
++ if (inChannel || inStream) ws.send(msg);
++ }
++ }
++
++ /**
++ * Forward an OFFLINE channel message to the homepage global feed, tagged with
++ * the channel username (parallels forwardToGlobal but keyed by user id since
++ * there's no live session id to resolve).
++ */
++ forwardToGlobalByChannel(channelUserId, data) {
++ if (!channelUserId) return;
++ let username = null;
++ try { username = ctx.getUserById(channelUserId)?.username; } catch { /* ignore */ }
++ if (!username) return;
++ const globalMsg = JSON.stringify({ ...data, stream_channel: username, source_channel: username });
++ for (const [ws, client] of this.clients) {
++ if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
++ ws.send(globalMsg);
++ }
++ }
++ }
++
++ broadcastGlobal(data) {
++ const msg = JSON.stringify(data);
++ for (const [ws, client] of this.clients) {
++ // Pure global clients only (see forwardToGlobal) — channel/stream viewers get
++ // global activity via their dedicated cross-feed socket, not their main one.
++ if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
++ ws.send(msg);
++ }
++ }
++ }
++
++ /**
++ * Notify everyone watching any of a channel owner's live streams that the
++ * channel's emotes/sounds changed, so their chat pickers refresh live
++ * (no page reload). No-op when the owner has no live streams / viewers.
++ */
++ broadcastToOwnerStreams(ownerUserId, data) {
++ try {
++ const streams = ctx.getLiveStreamsByUserId(ownerUserId) || [];
++ if (!streams.length) return;
++ const ids = new Set(streams.map(s => String(s.id)));
++ const msg = JSON.stringify(data);
++ for (const [ws, client] of this.clients) {
++ if (ids.has(String(client.streamId)) && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
++ ws.send(msg);
++ }
++ }
++ } catch { /* ignore */ }
++ }
++
++ /**
++ * Forward a stream message to all global-connected clients
++ * so the global chat feed shows activity from every stream.
++ */
++ forwardToGlobal(streamId, data) {
++ // Look up stream owner username + slot title/slug (cached per stream)
++ if (!this._streamNameCache) this._streamNameCache = new Map();
++ let info = this._streamNameCache.get(streamId);
++ if (!info || typeof info !== 'object') {
++ try {
++ const stream = ctx.getStreamById(streamId);
++ info = {
++ username: stream?.username || `stream-${streamId}`,
++ title: stream?.managed_stream_title || stream?.title || null,
++ slug: stream?.managed_stream_slug || null,
++ managedId: stream?.managed_stream_id || null,
++ };
++ this._streamNameCache.set(streamId, info);
++ // Auto-expire cache after 5 min
++ setTimeout(() => this._streamNameCache.delete(streamId), 300000);
++ } catch {
++ info = { username: `stream-${streamId}`, title: null, slug: null, managedId: null };
++ }
++ }
++ const globalMsg = JSON.stringify({
++ ...data,
++ stream_channel: info.username,
++ source_channel: info.username,
++ source_stream_id: streamId,
++ source_stream_title: info.title,
++ source_slug: info.slug,
++ source_managed_id: info.managedId,
++ source_is_live: 1, // this path only fires for a live send
++ });
++ for (const [ws, client] of this.clients) {
++ // Only PURE global clients (no stream AND no channel) — otherwise an
++ // offline-channel viewer's main socket (streamId null, channelUserId set)
++ // would render this via addChatMessage AND get it again as a cross-feed on
++ // its dedicated global socket → duplicate. Channel/stream viewers receive the
++ // cross-feed exclusively through their separate global-feed connection.
++ if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
++ ws.send(globalMsg);
++ }
++ }
++ }
++
++ /**
++ * Deliver a stream message to viewers of the SAME streamer's OTHER live slots
++ * ("Show All Chats Under This Streamer"). Tagged with source-slot info so the
++ * client can show a stream-title badge + let viewers hop between slots. Only
++ * fires when the streamer has 2+ live slots.
++ */
++ forwardToStreamerRooms(streamId, data) {
++ try {
++ const stream = ctx.getStreamById(streamId);
++ if (!stream || !stream.user_id) return;
++ const siblings = ctx.getLiveStreamsByUserId(stream.user_id) || [];
++ if (siblings.length < 2) return;
++ const siblingIds = new Set(siblings.map(s => s.id));
++ const payload = JSON.stringify({
++ ...data,
++ cross_stream: true,
++ source_stream_id: streamId,
++ source_stream_title: stream.managed_stream_title || stream.title || null,
++ source_slug: stream.managed_stream_slug || null,
++ source_managed_id: stream.managed_stream_id || null,
++ source_channel: stream.username || null,
++ source_is_live: 1,
++ });
++ for (const [ws, client] of this.clients) {
++ if (client.streamId && client.streamId !== streamId && siblingIds.has(client.streamId)
++ && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
++ ws.send(payload);
++ }
++ }
++ } catch { /* non-critical */ }
++ }
++
++ _broadcastDeletedMessages(streamId, ids) {
++ if (!Array.isArray(ids) || ids.length === 0) return;
++ const payload = { type: 'delete-messages', ids };
++ if (streamId) {
++ this.broadcastToStream(streamId, payload);
++ this.forwardToGlobal(streamId, payload);
++ return;
++ }
++ this.broadcastAll(payload);
++ }
++
++ _sweepExpiredChatMessages() {
++ try {
++ const expired = db.deleteExpiredChatMessages(500);
++ if (!expired.length) return;
++ const byScope = new Map();
++ for (const row of expired) {
++ const key = row.stream_id ? `stream:${row.stream_id}` : 'global';
++ if (!byScope.has(key)) byScope.set(key, []);
++ byScope.get(key).push(row.id);
++ }
++ for (const [key, ids] of byScope.entries()) {
++ const streamId = key === 'global' ? null : parseInt(key.split(':')[1], 10);
++ this._broadcastDeletedMessages(streamId, ids);
++ }
++ } catch (err) {
++ console.warn('[Chat] Failed to sweep expired auto-delete messages:', err.message);
++ }
++ }
++
++ broadcastUserCount(streamId) {
++ const count = this.getStreamViewerCount(streamId);
++ const data = JSON.stringify({ type: 'user-count', count, stream_id: streamId });
++ for (const [ws, client] of this.clients) {
++ if (client.streamId === streamId && ws.readyState === WebSocket.OPEN) {
++ ws.send(data);
++ }
++ }
++ // Live persists it (streams.viewer_count) so /api/streams returns real counts
++ if (streamId) {
++ try { ctx.effects.viewerCount(streamId, count); } catch {}
++ }
++ }
++
++ /**
++ * Record viewer snapshots for all active streams.
++ * Called every 60 seconds by the snapshot interval timer.
++ */
++ _recordViewerSnapshots() {
++ // Collect unique active stream IDs
++ const streamIds = new Set();
++ for (const [, client] of this.clients) {
++ if (client.streamId) streamIds.add(client.streamId);
++ }
++ for (const streamId of streamIds) {
++ try {
++ const count = this.getStreamViewerCount(streamId);
++ const chatActivity = db.getRecentChatActivity(streamId, 5);
++ ctx.effects.viewerSnapshot(streamId, count, chatActivity);
++ } catch (err) {
++ // Non-critical — don't crash the chat server over analytics
++ }
++ }
++ }
++
++ /**
++ * Push the current users list to all clients in a stream/global.
++ * Throttled to avoid flooding on rapid join/leave bursts.
++ */
++ broadcastUsersList(streamId) {
++ const key = `users-${streamId ?? 'global'}`;
++ if (this._usersListTimers?.has(key)) return; // already scheduled
++ if (!this._usersListTimers) this._usersListTimers = new Map();
++ this._usersListTimers.set(key, setTimeout(() => {
++ this._usersListTimers.delete(key);
++ const users = this.getUserList(streamId);
++ const data = JSON.stringify({ type: 'users-list', users });
++ for (const [ws, client] of this.clients) {
++ if (client.streamId === streamId && ws.readyState === WebSocket.OPEN) {
++ try { ws.send(data); } catch {}
++ }
++ }
++ }, 500));
++ }
++
++ /**
++ * Count unique IPs watching a stream (not raw connections).
++ * Multiple tabs from the same IP count as one viewer.
++ */
++ getStreamViewerCount(streamId) {
++ const ips = new Set();
++ for (const [, client] of this.clients) {
++ if (client.streamId === streamId && client.ip) {
++ ips.add(client.ip);
++ }
++ }
++ return ips.size;
++ }
++
++ getTotalConnections() {
++ return this.clients.size;
++ }
++
++ /**
++ * Send a DM payload to all WebSocket connections belonging to a given user ID.
++ * Used by the DM REST API for real-time delivery.
++ */
++ sendDm(userId, data) {
++ const convId = data?.conversation_id;
++ if (convId && !dm.isParticipant(convId, userId)) {
++ if (DEBUG_DM_DELIVERY) {
++ console.warn(`[DM] blocked delivery to user ${userId} for conversation ${convId} (not a participant)`, data.type, data);
++ }
++ return;
++ }
++ const payload = JSON.stringify(data);
++ for (const [ws, client] of this.clients) {
++ if (client.user?.id === userId && ws.readyState === WebSocket.OPEN) {
++ if (DEBUG_DM_DELIVERY) {
++ console.debug(`[DM] delivering ${data.type} conversation ${convId} to ws user ${client.user.id} (${client.user.username})`);
++ }
++ try {
++ if (ws.bufferedAmount < MAX_SEND_BACKPRESSURE) {
++ ws.send(payload);
++ }
++ } catch { /* non-critical */ }
++ }
++ }
++ }
++
++ /**
++ * Push a profile/identity update to all chat connections belonging to a user.
++ * Refreshes cached client.user so subsequent messages use the new info.
++ */
++ sendUserUpdate(userId, userData) {
++ // Live changed the account (admin edit): refresh the projection first. A new name is
++ // also rewritten into this user's stored chat lines, as Live did with its own rows.
++ const before = ctx.getUserById(userId);
++ if (userData && userData.id != null) {
++ ctx.upsertUser({ ...(before || {}), ...userData });
++ const newChatName = userData.display_name || userData.username;
++ if (before && newChatName && newChatName !== (before.display_name || before.username)) {
++ try { db.renameUserChatMessages(userId, newChatName); } catch { /* non-critical */ }
++ }
++ }
++ ctx.invalidateUser(userId);
++ const freshUser = ctx.getUserById(userId);
++ const payload = JSON.stringify({
++ type: 'user-updated',
++ user: {
++ id: userData.id,
++ username: userData.username,
++ display_name: userData.display_name,
++ role: userData.role,
++ avatar_url: userData.avatar_url,
++ profile_color: userData.profile_color,
++ },
++ });
++ for (const [ws, client] of this.clients) {
++ if (client.user?.id === userId && ws.readyState === WebSocket.OPEN) {
++ // Refresh cached user object so future messages use new name
++ if (freshUser) client.user = freshUser;
++ try {
++ if (ws.bufferedAmount < MAX_SEND_BACKPRESSURE) ws.send(payload);
++ } catch { /* non-critical */ }
++ }
++ }
++ }
++
++ /**
++ * Broadcast a message to ALL connected chat clients (every stream + global).
++ * Used for server-wide announcements (restarts, updates).
++ */
++ broadcastAll(data) {
++ const msg = JSON.stringify(data);
++ for (const [ws] of this.clients) {
++ if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
++ ws.send(msg);
++ }
++ }
++ }
++
++ close() {
++ if (this.wss) {
++ if (this.heartbeatInterval) {
++ clearInterval(this.heartbeatInterval);
++ this.heartbeatInterval = null;
++ }
++ if (this._snapshotInterval) {
++ clearInterval(this._snapshotInterval);
++ this._snapshotInterval = null;
++ }
++ if (this._autoDeleteSweepInterval) {
++ clearInterval(this._autoDeleteSweepInterval);
++ this._autoDeleteSweepInterval = null;
++ }
++ this.wss.clients.forEach(ws => ws.close());
++ this.wss.close();
++ }
++ }
++
++ /**
++ * Notify the stream owner that a new IP needs approval.
++ * Sends a system message only to the streamer's connection.
++ */
++ _notifyStreamerPendingIp(streamId, streamOwnerId, username, ip) {
++ if (!streamOwnerId) return;
++ try {
++ for (const [ws, client] of this.clients) {
++ if (client.user?.id === streamOwnerId && client.streamId === streamId) {
++ this.sendTo(ws, {
++ type: 'system',
++ message: `⏳ New IP needs approval: ${username} (${ip}). Open Dashboard → Moderation to review.`,
++ ip_approval_alert: true,
++ });
++ break;
++ }
++ }
++ } catch { /* non-critical */ }
++ }
++
++ /**
++ * Get channel moderation settings for a stream.
++ * Caches the channel lookup to avoid repeated DB queries.
++ */
++ _getChannelChatSettings(streamId) {
++ const defaults = {
++ slow_mode_seconds: 0, followers_only: 0, emote_only: 0,
++ allow_anonymous: 1, links_allowed: 1, account_age_gate_hours: 0,
++ caps_percentage_limit: 0, aggressive_filter: 0, max_message_length: 500,
++ slur_filter_enabled: 0, slur_filter_use_builtin: 1, slur_filter_terms: '', slur_filter_regexes: '', slur_filter_nudge_message: '', slur_filter_disabled_categories: '[]',
++ ip_approval_mode: 0,
++ gifs_enabled: 1,
++ soundboard_enabled: 1,
++ soundboard_allow_pitch: 1,
++ soundboard_allow_speed: 1,
++ soundboard_banned_ids: '',
++ viewer_auto_delete_enabled: 1,
++ viewer_delete_all_enabled: 1,
++ custom_emotes_enabled: 1,
++ custom_sounds_enabled: 1,
++ max_sound_seconds: 10,
++ uploads_mods_only: 0,
++ emote_scale: 100,
++ sound_min_speed: 0.5,
++ sound_max_speed: 3.0,
++ sound_min_pitch_cents: -1200,
++ sound_max_pitch_cents: 1200,
++ };
++ // Derive pitch rate bounds from the configured cents (2^(cents/1200)).
++ const finalize = (s) => ({
++ ...s,
++ sound_min_pitch_rate: Math.pow(2, (Number(s.sound_min_pitch_cents) ?? -1200) / 1200),
++ sound_max_pitch_rate: Math.pow(2, (Number(s.sound_max_pitch_cents) ?? 1200) / 1200),
++ });
++ if (!streamId) return finalize(defaults);
++ try {
++ const stream = ctx.getStreamById(streamId);
++ if (!stream) return finalize(defaults);
++ const channel = stream.channel_id ? ctx.getChannelById(stream.channel_id) : ctx.getChannelByUserId(stream.user_id);
++ if (!channel) return finalize(defaults);
++ return finalize({ ...defaults, ...ctx.getChannelModerationSettings(channel.id) });
++ } catch {
++ return finalize(defaults);
++ }
++ }
++
++ /**
++ * Log a moderation action from a chat command (/ban, /timeout, /clear, /slowmode).
++ * Non-critical — failures are silently ignored.
++ */
++ logChatModeration(client, actionType, details = {}, targetUserId = null) {
++ try {
++ const stream = client.streamId ? ctx.getStreamById(client.streamId) : null;
++ const channel = stream?.channel_id
++ ? ctx.getChannelById(stream.channel_id)
++ : stream ? ctx.getChannelByUserId(stream.user_id) : null;
++ db.logModerationAction({
++ scope_type: channel ? 'channel' : 'site',
++ scope_id: channel?.id || undefined,
++ actor_user_id: client.user?.id || undefined,
++ target_user_id: targetUserId || undefined,
++ action_type: actionType,
++ details: { stream_id: client.streamId || null, ...details },
++ });
++ } catch { /* non-critical */ }
++ }
++}
++
++module.exports = new ChatServer();
ddiff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js
nnew file mode 100644
iindex 0000000..392a979
---- /dev/null
++++ b/server/chat/deploy-notice.js
@@@ -0,0 +1,94 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Deploy notices in chat — one tidy, rolling message instead of a line per restart.
++// (Moved from OpenVibe.Live server/chat/deploy-notice.js, W6.)
++//
++// Live still decides WHAT shipped — its git HEAD and the `deploy_last_announced` site setting —
++// and hands the new commits to Chat over the bridge (bridge op deployNotice). Chat keeps the part
++// that is chat:
++// • Consecutive deploys fold into ONE stored message: while the newest global chat row is a deploy
++// notice (nobody has spoken since) and it is under 12 hours old, that row is updated in place.
++// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,
++// updated_at }. Clients render times from ISO timestamps, so they are always right.
++// • The live broadcast carries the row id; clients replace the card with that id instead of
++// appending, so reconnects and repeats can never duplicate it.
++// • Late joiners of this announcement get it once (replayTo, from the join handler).
++// ═══════════════════════════════════════════════════════════════
++
++const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;
++const MAX_COMMITS = 40;
++const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart
++
++// The notice for the announcing boot, kept for a while so clients that reconnect late still get it exactly once.
++const REPLAY_MS = 15 * 60 * 1000;
++let _live = null; // { payload, until, sent: WeakSet }
++
++const parseLog = (raw) => raw.trim().split('\n').filter(Boolean).map((line) => {
++ const [hash, short, date, ...rest] = line.split('\x1f');
++ return { hash, short, date, subject: rest.join(' ').trim().slice(0, 200) };
++}).filter(c => /^[0-9a-f]{40}$/.test(c.hash || ''));
++
++const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.length === 1 ? '' : 's'} shipped: ${meta.commits.slice(0, 3).map(c => c.subject).join(' · ')}${meta.commits.length > 3 ? ` · and ${meta.commits.length - 3} more` : ''}`;
++
++/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */
++function persist(db, commits) {
++ const nowIso = new Date().toISOString();
++ const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_global = 1 AND is_deleted = 0 ORDER BY id DESC LIMIT 1');
++ let prev = null;
++ if (newest && newest.message_type === 'system' && newest.metadata) {
++ try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }
++ }
++ if (prev) {
++ const seen = new Set(commits.map(c => c.hash));
++ const merged = commits.concat((prev.commits || []).filter(c => !seen.has(c.hash))).slice(0, MAX_COMMITS);
++ const meta = { kind: 'deploy', commits: merged, deploys: (prev.deploys || 1) + 1, first_at: prev.first_at, updated_at: nowIso };
++ db.run('UPDATE chat_messages SET message = ?, metadata = ? WHERE id = ?', [plainText(meta), JSON.stringify(meta), newest.id]);
++ return { id: newest.id, meta };
++ }
++ const meta = { kind: 'deploy', commits: commits.slice(0, MAX_COMMITS), deploys: 1, first_at: nowIso, updated_at: nowIso };
++ const res = db.saveChatMessage({ stream_id: null, user_id: null, anon_id: null, username: 'OpenVibe.Live', message: plainText(meta), message_type: 'system', is_global: true, metadata: meta });
++ return { id: res && (res.lastInsertRowid || res.lastID || res.id) || null, meta };
++}
++
++/**
++ * Announce commits Live just shipped (Live filtered them against deploy_last_announced).
++ * @returns {{ announced: number }}
++ */
++function announceCommits({ db, chatServer, commits, log = console }) {
++ const list = (Array.isArray(commits) ? commits : []).filter((c) => c && /^[0-9a-f]{40}$/.test(c.hash || '')).map((c) => ({
++ hash: c.hash, short: String(c.short || c.hash.slice(0, 7)).slice(0, 12), date: String(c.date || ''), subject: String(c.subject || '').slice(0, 200),
++ }));
++ if (!list.length) return { announced: 0 };
++
++ let saved;
++ try { saved = persist(db, list); }
++ catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
++
++ const payload = JSON.stringify({ type: 'update', kind: 'deploy', id: saved.id, fresh: list.map(c => c.hash), url: '/updates', timestamp: saved.meta.updated_at, ...saved.meta });
++ const sent = new WeakSet();
++ _live = { payload, until: Date.now() + REPLAY_MS, sent };
++ const push = () => {
++ let n = 0;
++ for (const [ws] of chatServer.clients) {
++ if (sent.has(ws) || ws.readyState !== 1 || ws.bufferedAmount > 256 * 1024) continue;
++ try { ws.send(payload); sent.add(ws); n++; } catch { /* socket went away */ }
++ }
++ return n;
++ };
++ ATTEMPTS_MS.forEach((ms, i) => { const t = setTimeout(() => { const n = push(); if (n) log.log(`[Deploy notice] ${list.length} commit(s) → ${n} client(s) (pass ${i + 1})`); }, ms); if (t.unref) t.unref(); });
++ return { announced: list.length, id: saved.id };
++}
++
++/**
++ * Called when a chat client finishes joining: if a deploy was just announced and this socket has not
++ * had it, send it now. Covers clients whose reconnect backoff outlasted the broadcast passes, a chat
++ * server that came up late, and tabs that were asleep. The card is keyed by row id, so a repeat cannot
++ * duplicate.
++ */
++function replayTo(ws) {
++ if (!_live || Date.now() > _live.until || _live.sent.has(ws)) return false;
++ try { if (ws.readyState === 1) { ws.send(_live.payload); _live.sent.add(ws); return true; } } catch { /* socket went away */ }
++ return false;
++}
++
++module.exports = { replayTo, announceCommits, persist, plainText, parseLog };
ddiff --git a/server/chat/dm-routes.js b/server/chat/dm-routes.js
nnew file mode 100644
iindex 0000000..cc7e03f
---- /dev/null
++++ b/server/chat/dm-routes.js
@@@ -0,0 +1,433 @@
++/**
++ * OpenVibe.Chat — DM REST API Routes (moved from OpenVibe.Live server/chat/dm-routes.js, W6)
++ *
++ * GET /api/dm/conversations — list conversations
++ * POST /api/dm/conversations — create or get 1-on-1 / group
++ * GET /api/dm/conversations/:id — get conversation details + participants
++ * GET /api/dm/conversations/:id/messages — paginated messages
++ * POST /api/dm/conversations/:id/messages — send a message
++ * POST /api/dm/conversations/:id/read — mark conversation read
++ * POST /api/dm/conversations/:id/participants — add participant to group
++ * DELETE /api/dm/conversations/:id/participants/:userId — remove participant
++ * PATCH /api/dm/conversations/:id — rename group
++ * GET /api/dm/unread — total unread count
++ * GET /api/dm/users/search — search users for new-message picker
++ */
++const express = require('express');
++const router = express.Router();
++const { requireAuth } = require('../auth/auth');
++const dm = require('./dm');
++const ctx = require('../live-context');
++
++// Chat-internal columns (the Network subject next to Live's ids) are not part of Live's API.
++function publicMessage(m) {
++ if (m) delete m.sender_subject_id;
++ return m;
++}
++
++// All DM routes require authentication
++router.use(requireAuth);
++
++// ── Per-user rate limiting (in-memory sliding window) ────────
++const _rateBuckets = new Map();
++const RATE_MSG_MAX = 10, RATE_MSG_WINDOW = 60_000; // 10 messages / 60s
++const RATE_CONV_MAX = 5, RATE_CONV_WINDOW = 3_600_000; // 5 new conversations / hour
++const MAX_GROUP_SIZE = 20;
++
++function _checkRate(userId, bucket, max, windowMs) {
++ if (!_rateBuckets.has(userId)) _rateBuckets.set(userId, { messages: [], convos: [] });
++ const u = _rateBuckets.get(userId);
++ const now = Date.now(), cutoff = now - windowMs;
++ u[bucket] = u[bucket].filter(t => t > cutoff);
++ if (u[bucket].length >= max) return false;
++ u[bucket].push(now);
++ return true;
++}
++setInterval(() => {
++ const cutoff = Date.now() - 3_600_000;
++ for (const [uid, b] of _rateBuckets) {
++ b.messages = b.messages.filter(t => t > cutoff);
++ b.convos = b.convos.filter(t => t > cutoff);
++ if (!b.messages.length && !b.convos.length) _rateBuckets.delete(uid);
++ }
++}, 300_000);
++
++// List conversations for current user
++router.get('/conversations', (req, res) => {
++ try {
++ const conversations = dm.getConversations(req.user.id);
++ // Attach participant info to each conversation
++ for (const conv of conversations) {
++ conv.participants = dm.getParticipants(conv.id);
++ }
++ res.json({ conversations });
++ } catch (err) {
++ console.error('[DM] List conversations error:', err.message);
++ res.status(500).json({ error: 'Failed to load conversations' });
++ }
++});
++
++// Create or get a conversation
++router.post('/conversations', async (req, res) => {
++ try {
++ const { user_ids, name } = req.body;
++ if (!user_ids || !Array.isArray(user_ids) || user_ids.length === 0) {
++ return res.status(400).json({ error: 'user_ids array required' });
++ }
++ const participantIds = [...new Set([req.user.id, ...user_ids.map(Number).filter(Boolean)])];
++ if (participantIds.length < 2) {
++ return res.status(400).json({ error: 'Need at least one other user' });
++ }
++ if (participantIds.length > MAX_GROUP_SIZE) {
++ return res.status(400).json({ error: `Groups are limited to ${MAX_GROUP_SIZE} members` });
++ }
++
++ // Rate limit conversation creation
++ if (!_checkRate(req.user.id, 'convos', RATE_CONV_MAX, RATE_CONV_WINDOW)) {
++ return res.status(429).json({ error: 'Too many new conversations. Try again later.' });
++ }
++
++ // New account restriction
++ const accountCheck = dm.isAccountTooNew(req.user.id, 5);
++ if (accountCheck.tooNew) {
++ return res.status(403).json({ error: `Your account is too new to start conversations. Please wait ${accountCheck.minutesRemaining} more minute(s).` });
++ }
++
++ // Validate all target users exist, aren't banned, aren't blocked (accounts are Live's)
++ try { await ctx.ensureUsers(participantIds); } catch { /* the projection answers below */ }
++ for (const uid of participantIds) {
++ if (uid === req.user.id) continue;
++ const target = ctx.getUserById(uid);
++ if (!target) return res.status(400).json({ error: 'User not found' });
++ if (target.is_banned) return res.status(400).json({ error: 'Cannot message banned users' });
++ if (dm.isBlockedEither(req.user.id, uid)) {
++ return res.status(403).json({ error: 'Cannot start a conversation with this user' });
++ }
++ }
++
++ let conversationId;
++ if (participantIds.length === 2) {
++ const otherId = participantIds.find(id => id !== req.user.id);
++ conversationId = dm.getOrCreateDirect(req.user.id, otherId);
++ } else {
++ const safeName = name ? String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100) : null;
++ conversationId = dm.createConversation(req.user.id, participantIds, safeName);
++ }
++
++ const conversation = dm.getConversation(conversationId);
++ conversation.participants = dm.getParticipants(conversationId);
++ res.json({ conversation });
++ } catch (err) {
++ console.error('[DM] Create conversation error:', err.message);
++ res.status(500).json({ error: 'Failed to create conversation' });
++ }
++});
++
++// Get conversation details
++router.get('/conversations/:id', (req, res) => {
++ try {
++ const convId = parseInt(req.params.id);
++ if (!dm.isParticipant(convId, req.user.id)) {
++ return res.status(403).json({ error: 'Not a participant' });
++ }
++ const conversation = dm.getConversation(convId);
++ if (!conversation) return res.status(404).json({ error: 'Not found' });
++ conversation.participants = dm.getParticipants(convId);
++ res.json({ conversation });
++ } catch (err) {
++ console.error('[DM] Get conversation error:', err.message);
++ res.status(500).json({ error: 'Failed to load conversation' });
++ }
++});
++
++// Get messages (paginated)
++router.get('/conversations/:id/messages', (req, res) => {
++ try {
++ const convId = parseInt(req.params.id);
++ if (!dm.isParticipant(convId, req.user.id)) {
++ return res.status(403).json({ error: 'Not a participant' });
++ }
++ const limit = Math.min(parseInt(req.query.limit) || 50, 100);
++ const before = parseInt(req.query.before) || null;
++ const after = parseInt(req.query.after) || null;
++ const messages = dm.getMessages(convId, limit, before, after).map(publicMessage);
++ res.json({ messages });
++ } catch (err) {
++ console.error('[DM] Get messages error:', err.message);
++ res.status(500).json({ error: 'Failed to load messages' });
++ }
++});
++
++// Send a message
++router.post('/conversations/:id/messages', (req, res) => {
++ try {
++ const convId = parseInt(req.params.id);
++ if (!dm.isParticipant(convId, req.user.id)) {
++ return res.status(403).json({ error: 'Not a participant' });
++ }
++
++ // Per-user message rate limit
++ if (!_checkRate(req.user.id, 'messages', RATE_MSG_MAX, RATE_MSG_WINDOW)) {
++ return res.status(429).json({ error: 'Slow down! You\'re sending messages too fast.' });
++ }
++
++ // Block check for 1-on-1 conversations
++ const conv = dm.getConversation(convId);
++ if (conv && !conv.is_group) {
++ const participants = dm.getParticipants(convId);
++ const other = participants.find(p => p.id !== req.user.id);
++ if (other && dm.isBlockedEither(req.user.id, other.id)) {
++ return res.status(403).json({ error: 'Cannot send messages in this conversation' });
++ }
++ }
++
++ const { message } = req.body;
++ if (!message || typeof message !== 'string' || !message.trim()) {
++ return res.status(400).json({ error: 'Message required' });
++ }
++
++ const msg = publicMessage(dm.sendMessage(convId, req.user.id, message));
++ if (!msg) return res.status(400).json({ error: 'Failed to send' });
++
++ // Attach sender info
++ const sender = ctx.getUserById(req.user.id);
++ msg.username = sender?.username;
++ msg.display_name = sender?.display_name;
++ msg.avatar_url = sender?.avatar_url;
++ msg.profile_color = sender?.profile_color;
++
++ // Real-time delivery via chat WebSocket
++ try {
++ const chatServer = require('./chat-server');
++ const participants = dm.getParticipants(convId);
++ for (const p of participants) {
++ if (p.id === req.user.id) continue; // skip sender
++ chatServer.sendDm(p.id, {
++ type: 'dm',
++ conversation_id: convId,
++ message: msg,
++ });
++ }
++ } catch { /* non-critical */ }
++
++ // Push notifications to other participants (for when they're offline) — Live's
++ // utils/notify pushes them to the Network; the preview stays generic there.
++ try {
++ const participants = dm.getParticipants(convId);
++ const otherIds = participants.filter(p => p.id !== req.user.id).map(p => p.id);
++ if (otherIds.length) {
++ ctx.effects.notifyDm({ recipient_ids: otherIds, sender_id: req.user.id, conversation_id: convId });
++ }
++ } catch { /* non-critical */ }
++
++ res.json({ message: msg });
++ } catch (err) {
++ console.error('[DM] Send message error:', err.message);
++ res.status(500).json({ error: 'Failed to send message' });
++ }
++});
++
++// Mark conversation read
++router.post('/conversations/:id/read', (req, res) => {
++ try {
++ const convId = parseInt(req.params.id);
++ if (!dm.isParticipant(convId, req.user.id)) {
++ return res.status(403).json({ error: 'Not a participant' });
++ }
++ dm.markRead(convId, req.user.id);
++
++ // Clear matching DM notifications on the Network (through Live's utils/notify)
++ try {
++ ctx.effects.markDmRead(req.user.id, convId);
++ } catch { /* non-critical */ }
++
++ res.json({ ok: true });
++ } catch (err) {
++ console.error('[DM] Mark read error:', err.message);
++ res.status(500).json({ error: 'Failed' });
++ }
++});
++
++// Add participant to group
++router.post('/conversations/:id/participants', async (req, res) => {
++ try {
++ const convId = parseInt(req.params.id);
++ if (!dm.isParticipant(convId, req.user.id)) {
++ return res.status(403).json({ error: 'Not a participant' });
++ }
++ const { user_id } = req.body;
++ if (!user_id) return res.status(400).json({ error: 'user_id required' });
++
++ // Validate target user exists, isn't banned, isn't blocked (accounts are Live's)
++ try { await ctx.ensureUsers([user_id]); } catch { /* the projection answers below */ }
++ const target = ctx.getUserById(user_id);
++ if (!target) return res.status(400).json({ error: 'User not found' });
++ if (target.is_banned) return res.status(400).json({ error: 'Cannot add banned users' });
++ // A private 1:1 stays private: adding a third person would hand them the whole history.
++ const conv = dm.getConversation(convId);
++ if (!conv || !conv.is_group) {
++ return res.status(400).json({ error: 'Start a group conversation to add people' });
++ }
++ // Nobody already in the group should end up talking to someone they blocked (or who blocked them).
++ const members = dm.getParticipants(convId) || [];
++ if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {
++ return res.status(403).json({ error: 'Cannot add this user' });
++ }
++
++ // Group size limit
++ if (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {
++ return res.status(400).json({ error: `Group is full (max ${MAX_GROUP_SIZE} members)` });
++ }
++
++ dm.addParticipant(convId, user_id);
++ const participants = dm.getParticipants(convId);
++
++ // Notify all participants about the new member
++ try {
++ const chatServer = require('./chat-server');
++ for (const p of participants) {
++ chatServer.sendDm(p.id, {
++ type: 'dm-participant-added',
++ conversation_id: convId,
++ user_id,
++ participants,
++ });
++ }
++ } catch { /* non-critical */ }
++
++ res.json({ participants });
++ } catch (err) {
++ console.error('[DM] Add participant error:', err.message);
++ res.status(500).json({ error: 'Failed' });
++ }
++});
++
++// Remove participant from group
++router.delete('/conversations/:id/participants/:userId', (req, res) => {
++ try {
++ const convId = parseInt(req.params.id);
++ const targetUserId = parseInt(req.params.userId);
++ if (!dm.isParticipant(convId, req.user.id)) {
++ return res.status(403).json({ error: 'Not a participant' });
++ }
++ // Can only remove self (leave) or be the creator
++ const conv = dm.getConversation(convId);
++ if (targetUserId !== req.user.id && conv?.created_by !== req.user.id) {
++ return res.status(403).json({ error: 'Only the creator can remove others' });
++ }
++ dm.removeParticipant(convId, targetUserId);
++ res.json({ ok: true });
++ } catch (err) {
++ console.error('[DM] Remove participant error:', err.message);
++ res.status(500).json({ error: 'Failed' });
++ }
++});
++
++// Rename group conversation
++router.patch('/conversations/:id', (req, res) => {
++ try {
++ const convId = parseInt(req.params.id);
++ if (!dm.isParticipant(convId, req.user.id)) {
++ return res.status(403).json({ error: 'Not a participant' });
++ }
++ let { name } = req.body;
++ if (name) {
++ name = String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100);
++ }
++ dm.renameConversation(convId, name || null);
++ res.json({ ok: true });
++ } catch (err) {
++ console.error('[DM] Rename error:', err.message);
++ res.status(500).json({ error: 'Failed' });
++ }
++});
++
++// Total unread count
++router.get('/unread', (req, res) => {
++ try {
++ const total = dm.getTotalUnread(req.user.id);
++ res.json({ unread: total });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed' });
++ }
++});
++
++// Search users for new-message picker
++router.get('/users/search', (req, res) => {
++ try {
++ const q = (req.query.q || '').trim();
++ if (q.length < 2) return res.json({ users: [] });
++ const users = dm.searchUsers(q, req.user.id);
++ res.json({ users });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed' });
++ }
++});
++
++// ── Block & message management ───────────────────────────────
++
++// Block a user
++router.post('/blocks/:userId', (req, res) => {
++ try {
++ const targetId = parseInt(req.params.userId);
++ if (!targetId || targetId === req.user.id) {
++ return res.status(400).json({ error: 'Invalid user' });
++ }
++ dm.blockUser(req.user.id, targetId);
++ res.json({ ok: true });
++ } catch (err) {
++ console.error('[DM] Block error:', err.message);
++ res.status(500).json({ error: 'Failed to block user' });
++ }
++});
++
++// Unblock a user
++router.delete('/blocks/:userId', (req, res) => {
++ try {
++ const targetId = parseInt(req.params.userId);
++ dm.unblockUser(req.user.id, targetId);
++ res.json({ ok: true });
++ } catch (err) {
++ console.error('[DM] Unblock error:', err.message);
++ res.status(500).json({ error: 'Failed to unblock user' });
++ }
++});
++
++// List blocked users
++router.get('/blocks', (req, res) => {
++ try {
++ const blocked = dm.getBlockedUsers(req.user.id);
++ res.json({ blocked });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load blocked users' });
++ }
++});
++
++// Check if a specific user is blocked
++router.get('/blocks/check/:userId', (req, res) => {
++ try {
++ const targetId = parseInt(req.params.userId);
++ res.json({ blocked: dm.hasBlocked(req.user.id, targetId) });
++ } catch {
++ res.json({ blocked: false });
++ }
++});
++
++// Delete own message
++router.delete('/conversations/:id/messages/:msgId', (req, res) => {
++ try {
++ const convId = parseInt(req.params.id);
++ const msgId = parseInt(req.params.msgId);
++ if (!dm.isParticipant(convId, req.user.id)) {
++ return res.status(403).json({ error: 'Not a participant' });
++ }
++ const deleted = dm.deleteMessage(msgId, req.user.id);
++ if (!deleted) return res.status(403).json({ error: 'Cannot delete this message' });
++ res.json({ ok: true });
++ } catch (err) {
++ console.error('[DM] Delete message error:', err.message);
++ res.status(500).json({ error: 'Failed to delete message' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/chat/dm.js b/server/chat/dm.js
nnew file mode 100644
iindex 0000000..d79252d
---- /dev/null
++++ b/server/chat/dm.js
@@@ -0,0 +1,448 @@
++/**
++ * OpenVibe.Chat — Direct Message System (moved from OpenVibe.Live server/chat/dm.js, W6)
++ *
++ * Facebook Messenger-style DMs: 1-on-1 and group conversations,
++ * persisted to SQLite, real-time delivery via WebSocket, with
++ * read receipts and unread counts.
++ *
++ * Tables:
++ * dm_conversations — conversation metadata
++ * dm_participants — who's in each conversation
++ * dm_messages — individual messages
++ *
++ * People are still Live user ids; names and pictures come from the ctx_users projection
++ * (server/live-context.js). New participants, messages and blocks also record the Network
++ * subject, and every message adds a chat.dm.created event (visibility subject) to the outbox in
++ * the same transaction.
++ */
++const db = require('../db/database');
++const outbox = require('../events/outbox');
++
++// ── Schema & Migrations ──────────────────────────────────────
++
++function ensureTables() {
++ // The DM tables are created with the rest of Chat's schema (server/db/schema.sql).
++ db.getDb();
++}
++
++// ── Conversation helpers ─────────────────────────────────────
++
++/**
++ * Find an existing 1-on-1 conversation between two users.
++ * Returns conversation row or null.
++ */
++function findDirectConversation(userIdA, userIdB) {
++ return db.get(`
++ SELECT c.* FROM dm_conversations c
++ JOIN dm_participants p1 ON p1.conversation_id = c.id AND p1.user_id = ?
++ JOIN dm_participants p2 ON p2.conversation_id = c.id AND p2.user_id = ?
++ WHERE c.is_group = 0
++ `, [userIdA, userIdB]) || null;
++}
++
++/**
++ * Create a new conversation. Returns the new conversation id.
++ * participantIds should include the creator.
++ */
++function createConversation(createdBy, participantIds, name = null) {
++ const isGroup = participantIds.length > 2 ? 1 : 0;
++ const result = db.run(
++ `INSERT INTO dm_conversations (name, is_group, created_by) VALUES (?, ?, ?)`,
++ [isGroup ? (name || null) : null, isGroup, createdBy]
++ );
++ const convId = result.lastInsertRowid;
++ const insert = db.getDb().prepare(
++ `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?)`
++ );
++ for (const uid of participantIds) {
++ insert.run(convId, uid, db.subjectFor(uid));
++ }
++ return convId;
++}
++
++/**
++ * Get or create a 1-on-1 conversation between two users.
++ */
++function getOrCreateDirect(userIdA, userIdB) {
++ const existing = findDirectConversation(userIdA, userIdB);
++ if (existing) return existing.id;
++ return createConversation(userIdA, [userIdA, userIdB]);
++}
++
++/**
++ * Add a participant to an existing group conversation.
++ */
++function addParticipant(conversationId, userId) {
++ db.run(
++ `INSERT OR IGNORE INTO dm_participants (conversation_id, user_id, subject_id) VALUES (?, ?, ?)`,
++ [conversationId, userId, db.subjectFor(userId)]
++ );
++ // Mark as group if > 2 participants
++ const count = db.get(
++ `SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,
++ [conversationId]
++ )?.c || 0;
++ if (count > 2) {
++ db.run(`UPDATE dm_conversations SET is_group = 1 WHERE id = ?`, [conversationId]);
++ }
++}
++
++/**
++ * Remove a participant from a group conversation.
++ */
++function removeParticipant(conversationId, userId) {
++ db.run(
++ `DELETE FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,
++ [conversationId, userId]
++ );
++}
++
++/**
++ * Check if a user is a participant in a conversation.
++ */
++function isParticipant(conversationId, userId) {
++ return !!db.get(
++ `SELECT 1 FROM dm_participants WHERE conversation_id = ? AND user_id = ?`,
++ [conversationId, userId]
++ );
++}
++
++/**
++ * Rename a group conversation.
++ */
++function renameConversation(conversationId, name) {
++ db.run(`UPDATE dm_conversations SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [name, conversationId]);
++}
++
++/**
++ * Get all conversations for a user with last message preview and unread count.
++ */
++function getConversations(userId) {
++ return db.all(`
++ SELECT
++ c.id,
++ c.name,
++ c.is_group,
++ c.updated_at,
++ (SELECT dm.message FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message,
++ (SELECT dm.sender_id FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_sender_id,
++ (SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1) AS last_message_at,
++ (SELECT COUNT(*) FROM dm_messages dm WHERE dm.conversation_id = c.id AND dm.created_at > p.last_read_at AND dm.sender_id != ?) AS unread_count
++ FROM dm_conversations c
++ JOIN dm_participants p ON p.conversation_id = c.id AND p.user_id = ?
++ ORDER BY
++ COALESCE((SELECT dm.created_at FROM dm_messages dm WHERE dm.conversation_id = c.id ORDER BY dm.created_at DESC LIMIT 1), c.created_at) DESC
++ `, [userId, userId]);
++}
++
++/**
++ * Get participants of a conversation (with user profile info).
++ */
++function getParticipants(conversationId) {
++ return db.all(`
++ SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color
++ FROM dm_participants p
++ JOIN ctx_users u ON u.id = p.user_id
++ WHERE p.conversation_id = ?
++ `, [conversationId]);
++}
++
++/**
++ * Get a single conversation by id (with participant info for the requesting user).
++ */
++function getConversation(conversationId) {
++ return db.get(`SELECT * FROM dm_conversations WHERE id = ?`, [conversationId]) || null;
++}
++
++// ── Message helpers ──────────────────────────────────────────
++
++/**
++ * Send a message in a conversation. Returns the message row.
++ */
++function sendMessage(conversationId, senderId, text) {
++ if (!text || !text.trim()) return null;
++ const trimmed = text.trim().slice(0, 2000); // max 2000 chars
++ const senderSubject = db.subjectFor(senderId);
++ return db.transaction(() => {
++ const result = db.run(
++ `INSERT INTO dm_messages (conversation_id, sender_id, message, sender_subject_id) VALUES (?, ?, ?, ?)`,
++ [conversationId, senderId, trimmed, senderSubject]
++ );
++ // Touch conversation updated_at
++ db.run(`UPDATE dm_conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [conversationId]);
++ // Auto-mark read for sender
++ markRead(conversationId, senderId);
++ // The event names who is in the conversation, never what was said.
++ const participants = db.all('SELECT user_id, subject_id FROM dm_participants WHERE conversation_id = ?', [conversationId]);
++ outbox.enqueue({
++ event_type: 'chat.dm.created',
++ visibility: 'subject',
++ actorSubject: senderSubject,
++ subject: { type: 'dm_message', id: String(result.lastInsertRowid) },
++ payload: {
++ message_id: Number(result.lastInsertRowid),
++ conversation_id: Number(conversationId),
++ sender_user_id: senderId,
++ sender_subject: senderSubject,
++ participants: participants.map((p) => ({ user_id: p.user_id, subject: p.subject_id || db.subjectFor(p.user_id) })),
++ },
++ });
++ return db.get(`SELECT * FROM dm_messages WHERE id = ?`, [result.lastInsertRowid]);
++ });
++}
++
++/**
++ * Get messages in a conversation (paginated, newest first).
++ */
++function getMessages(conversationId, limit = 50, before = null, after = null) {
++ if (after) {
++ // Fetch messages newer than `after` id (for live polling)
++ return db.all(`
++ SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
++ FROM dm_messages m
++ JOIN ctx_users u ON u.id = m.sender_id
++ WHERE m.conversation_id = ? AND m.id > ?
++ ORDER BY m.created_at ASC
++ LIMIT ?
++ `, [conversationId, after, limit]);
++ }
++ if (before) {
++ return db.all(`
++ SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
++ FROM dm_messages m
++ JOIN ctx_users u ON u.id = m.sender_id
++ WHERE m.conversation_id = ? AND m.id < ?
++ ORDER BY m.created_at DESC
++ LIMIT ?
++ `, [conversationId, before, limit]);
++ }
++ return db.all(`
++ SELECT m.*, u.username, u.display_name, u.avatar_url, u.profile_color
++ FROM dm_messages m
++ JOIN ctx_users u ON u.id = m.sender_id
++ WHERE m.conversation_id = ?
++ ORDER BY m.created_at DESC
++ LIMIT ?
++ `, [conversationId, limit]);
++}
++
++/**
++ * Mark a conversation as read for a user (set last_read_at to now).
++ */
++function markRead(conversationId, userId) {
++ db.run(
++ `UPDATE dm_participants SET last_read_at = CURRENT_TIMESTAMP WHERE conversation_id = ? AND user_id = ?`,
++ [conversationId, userId]
++ );
++}
++
++/**
++ * Get total unread message count across all conversations for a user.
++ */
++function getTotalUnread(userId) {
++ const row = db.get(`
++ SELECT COALESCE(SUM(unread), 0) as total FROM (
++ SELECT COUNT(*) as unread
++ FROM dm_messages m
++ JOIN dm_participants p ON p.conversation_id = m.conversation_id AND p.user_id = ?
++ WHERE m.created_at > p.last_read_at AND m.sender_id != ?
++ )
++ `, [userId, userId]);
++ return row?.total || 0;
++}
++
++/**
++ * Search users by username/display_name for the "new message" user picker.
++ * Excludes the requesting user.
++ */
++function searchUsers(query, excludeUserId, limit = 10) {
++ if (!query || query.length < 2) return [];
++ return db.all(`
++ SELECT id, username, display_name, avatar_url, profile_color
++ FROM ctx_users
++ WHERE id != ? AND is_banned = 0
++ AND id NOT IN (SELECT blocked_id FROM dm_blocks WHERE blocker_id = ?)
++ AND id NOT IN (SELECT blocker_id FROM dm_blocks WHERE blocked_id = ?)
++ AND (username LIKE ? COLLATE NOCASE OR display_name LIKE ? COLLATE NOCASE)
++ LIMIT ?
++ `, [excludeUserId, excludeUserId, excludeUserId, `%${query}%`, `%${query}%`, limit]);
++}
++
++// ── Block helpers ────────────────────────────────────────────
++
++/**
++ * Check if either user has blocked the other (bidirectional).
++ */
++function isBlockedEither(userIdA, userIdB) {
++ return !!db.get(
++ `SELECT 1 FROM dm_blocks
++ WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)`,
++ [userIdA, userIdB, userIdB, userIdA]
++ );
++}
++
++/**
++ * Check if blocker has blocked blocked (one-directional).
++ */
++function hasBlocked(blockerId, blockedId) {
++ return !!db.get(
++ `SELECT 1 FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,
++ [blockerId, blockedId]
++ );
++}
++
++/**
++ * Block a user.
++ */
++function blockUser(blockerId, blockedId) {
++ db.run(
++ `INSERT OR IGNORE INTO dm_blocks (blocker_id, blocked_id, blocker_subject_id) VALUES (?, ?, ?)`,
++ [blockerId, blockedId, db.subjectFor(blockerId)]
++ );
++}
++
++/**
++ * Unblock a user.
++ */
++function unblockUser(blockerId, blockedId) {
++ db.run(
++ `DELETE FROM dm_blocks WHERE blocker_id = ? AND blocked_id = ?`,
++ [blockerId, blockedId]
++ );
++}
++
++/**
++ * Get all users blocked by blockerId.
++ */
++function getBlockedUsers(blockerId) {
++ return db.all(`
++ SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, b.created_at as blocked_at
++ FROM dm_blocks b
++ JOIN ctx_users u ON u.id = b.blocked_id
++ WHERE b.blocker_id = ?
++ ORDER BY b.created_at DESC
++ `, [blockerId]);
++}
++
++/**
++ * Delete a message (only the sender can delete their own message).
++ */
++function deleteMessage(messageId, userId) {
++ const msg = db.get(`SELECT * FROM dm_messages WHERE id = ?`, [messageId]);
++ if (!msg || msg.sender_id !== userId) return false;
++ db.run(`DELETE FROM dm_messages WHERE id = ?`, [messageId]);
++ return true;
++}
++
++/**
++ * Get the number of participants in a conversation.
++ */
++function getParticipantCount(conversationId) {
++ const row = db.get(
++ `SELECT COUNT(*) as c FROM dm_participants WHERE conversation_id = ?`,
++ [conversationId]
++ );
++ return row?.c || 0;
++}
++
++// ── Anti-spam helpers ────────────────────────────────────────
++
++/**
++ * Detect spam patterns in DM message content.
++ * Returns { isSpam: boolean, reason: string|null }
++ */
++function checkMessageSpam(text) {
++ if (!text) return { isSpam: false, reason: null };
++ const trimmed = text.trim();
++
++ // Excessive caps (>70% caps in messages over 10 chars)
++ if (trimmed.length > 10) {
++ const alphaChars = trimmed.replace(/[^a-zA-Z]/g, '');
++ if (alphaChars.length > 5) {
++ const capsRatio = (alphaChars.replace(/[^A-Z]/g, '').length) / alphaChars.length;
++ if (capsRatio > 0.7) return { isSpam: true, reason: 'Excessive caps' };
++ }
++ }
++
++ // Repeated characters (e.g., "aaaaaaa" or "!!!!!!")
++ if (/(.)\1{9,}/i.test(trimmed)) {
++ return { isSpam: true, reason: 'Repeated characters' };
++ }
++
++ // Repeated words (same word 5+ times)
++ const words = trimmed.toLowerCase().split(/\s+/);
++ if (words.length >= 5) {
++ const freq = {};
++ for (const w of words) freq[w] = (freq[w] || 0) + 1;
++ for (const w of Object.keys(freq)) {
++ if (freq[w] >= 5 && freq[w] / words.length > 0.6) {
++ return { isSpam: true, reason: 'Repetitive content' };
++ }
++ }
++ }
++
++ // Common spam patterns (URLs in bulk, typical scam patterns)
++ const urlCount = (trimmed.match(/https?:\/\//gi) || []).length;
++ if (urlCount >= 3) return { isSpam: true, reason: 'Too many URLs' };
++
++ return { isSpam: false, reason: null };
++}
++
++/**
++ * Check if this message is a duplicate of the user's recent messages.
++ * Returns true if the exact same text was sent within the last N seconds.
++ */
++function isDuplicateMessage(conversationId, senderId, text, windowSeconds = 30) {
++ const row = db.get(`
++ SELECT 1 FROM dm_messages
++ WHERE conversation_id = ? AND sender_id = ? AND message = ?
++ AND created_at > datetime('now', '-' || ? || ' seconds')
++ LIMIT 1
++ `, [conversationId, senderId, text.trim().slice(0, 2000), windowSeconds]);
++ return !!row;
++}
++
++/**
++ * Check if a user account is too new to send DMs (minimum account age).
++ * Returns { tooNew: boolean, minutesRemaining: number }
++ */
++function isAccountTooNew(userId, minMinutes = 5) {
++ const user = db.get(`SELECT created_at FROM ctx_users WHERE id = ?`, [userId]);
++ if (!user || !user.created_at) return { tooNew: false, minutesRemaining: 0 };
++ const created = new Date(user.created_at.endsWith('Z') ? user.created_at : user.created_at + 'Z');
++ const ageMs = Date.now() - created.getTime();
++ const ageMinutes = ageMs / 60000;
++ if (ageMinutes < minMinutes) {
++ return { tooNew: true, minutesRemaining: Math.ceil(minMinutes - ageMinutes) };
++ }
++ return { tooNew: false, minutesRemaining: 0 };
++}
++
++module.exports = {
++ ensureTables,
++ findDirectConversation,
++ createConversation,
++ getOrCreateDirect,
++ addParticipant,
++ removeParticipant,
++ isParticipant,
++ renameConversation,
++ getConversations,
++ getParticipants,
++ getConversation,
++ sendMessage,
++ getMessages,
++ markRead,
++ getTotalUnread,
++ searchUsers,
++ isBlockedEither,
++ hasBlocked,
++ blockUser,
++ unblockUser,
++ getBlockedUsers,
++ deleteMessage,
++ getParticipantCount,
++ checkMessageSpam,
++ isDuplicateMessage,
++ isAccountTooNew,
++};
ddiff --git a/server/chat/history-store.js b/server/chat/history-store.js
nnew file mode 100644
iindex 0000000..d269e6b
---- /dev/null
++++ b/server/chat/history-store.js
@@@ -0,0 +1,150 @@
++/**
++ * history-store.js — the one place that answers "what happened in this chat room?".
++ *
++ * Rooms: 'global' | 'channel:<userId>' (the stream route keeps its own scope logic)
++ * Reads:
++ * page(room, { limit, before }) newest `limit` rows, oldest→newest — the first paint
++ * delta(room, { afterId, limit }) rows with id > afterId, oldest→newest — reopen, reconnect,
++ * tab focus. Cheap: a primary-key range scan, no reverse.
++ * Every answer carries `latest_id`, the cursor the client stores; `complete:false` on a delta
++ * means the gap was bigger than `limit` and the client should take a fresh page instead.
++ *
++ * Backing store is Chat's SQLite through `db`; authors, streams and slots are joined from the
++ * ctx_* projections of Live data that server/live-context.js keeps (moved from OpenVibe.Live, W6). `memo` collapses identical page reads inside a
++ * short window (a burst of reconnects after a deploy used to run the five-table global join
++ * once per socket). Swapping in a ring buffer or Redis later means replacing `readPage` /
++ * `readDelta` and calling `invalidate(room)` from the write path — nothing in the routes changes.
++ */
++const db = require('../db/database');
++
++const PAGE_MEMO_MS = 2000;
++const MAX_LIMIT = 500;
++
++const GLOBAL_SELECT = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name,
++ u.username AS core_username,
++ COALESCE(su.username, cu.username) AS stream_channel,
++ s.is_live AS source_is_live,
++ s.managed_stream_id AS source_managed_id,
++ COALESCE(ms.title, s.title) AS source_stream_title,
++ ms.slug AS source_slug
++ FROM chat_messages cm
++ LEFT JOIN ctx_users u ON cm.user_id = u.id
++ LEFT JOIN ctx_streams s ON cm.stream_id = s.id
++ LEFT JOIN ctx_users su ON s.user_id = su.id
++ LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id
++ LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id
++ WHERE cm.is_deleted = 0 AND cm.message_type IN ('chat', 'system', 'channel-sound', 'soundboard', 'donation')
++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
++
++const CHANNEL_SELECT = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name,
++ u.username AS core_username,
++ s.title AS source_stream_title, s.managed_stream_id AS source_managed_id,
++ s.is_live AS source_is_live, ms.slug AS source_slug,
++ COALESCE(bu.username, cu.username) AS source_channel
++ FROM chat_messages cm
++ LEFT JOIN ctx_users u ON cm.user_id = u.id
++ LEFT JOIN ctx_streams s ON cm.stream_id = s.id
++ LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id
++ LEFT JOIN ctx_users bu ON s.user_id = bu.id
++ LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id
++ WHERE cm.channel_user_id = ? AND cm.is_deleted = 0
++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
++
++function clampLimit(v, dflt = 500) {
++ const n = parseInt(v, 10);
++ return Math.min(Number.isFinite(n) && n > 0 ? n : dflt, MAX_LIMIT);
++}
++
++function parseRoom(room) {
++ if (room === 'global') return { kind: 'global' };
++ const m = /^channel:(\d+)$/.exec(String(room || ''));
++ if (m) return { kind: 'channel', userId: parseInt(m[1], 10) };
++ throw new Error(`history-store: unknown room "${room}"`);
++}
++
++function baseSql(r, { channelUsername } = {}) {
++ if (r.kind === 'global') {
++ const params = [];
++ let sql = GLOBAL_SELECT;
++ if (channelUsername) { sql += ` AND LOWER(COALESCE(su.username, cu.username)) = LOWER(?)`; params.push(channelUsername); }
++ return { sql, params };
++ }
++ return { sql: CHANNEL_SELECT, params: [r.userId] };
++}
++
++function tipId() {
++ try { const r = db.get('SELECT MAX(id) AS id FROM chat_messages'); return (r && r.id) || 0; } catch { return Date.now(); }
++}
++const latestIdOf = (rows) => rows.reduce((m, x) => (x && x.id > m ? x.id : m), 0);
++
++// ── reads ──────────────────────────────────────────────────────────────────────────────────────
++const memo = new Map(); // key → { at, value }
++function memoGet(key) {
++ const hit = memo.get(key);
++ if (hit && Date.now() - hit.at < PAGE_MEMO_MS) return hit.value;
++ if (hit) memo.delete(key);
++ return null;
++}
++function memoSet(key, value) {
++ memo.set(key, { at: Date.now(), value });
++ if (memo.size > 64) { const oldest = memo.keys().next().value; memo.delete(oldest); }
++}
++
++function readPage(r, { limit, before, channelUsername }) {
++ const { sql, params } = baseSql(r, { channelUsername });
++ let q = sql;
++ if (before) { q += ' AND cm.timestamp < ?'; params.push(before); }
++ q += ' ORDER BY cm.timestamp DESC, cm.id DESC LIMIT ?'; params.push(limit);
++ return db.all(q, params).reverse();
++}
++
++function readDelta(r, { afterId, limit, channelUsername }) {
++ const { sql, params } = baseSql(r, { channelUsername });
++ // One more than asked: if it comes back, the gap is bigger than the client can splice in.
++ const q = `${sql} AND cm.id > ? ORDER BY cm.id ASC LIMIT ?`;
++ params.push(afterId, limit + 1);
++ return db.all(q, params);
++}
++
++/**
++ * Newest rows of a room, oldest→newest.
++ * @returns {{ messages: object[], latest_id: number }}
++ */
++function page(room, { limit, before, channelUsername, decorate } = {}) {
++ const r = parseRoom(room);
++ const lim = clampLimit(limit);
++ // The newest row id is part of the memo key, so a memoised page can never miss a message:
++ // one primary-key lookup instead of the five-table join for every repeat reader in the window.
++ const key = !before ? `${room}|${lim}|${channelUsername || ''}|${tipId()}` : null;
++ let rows = key ? memoGet(key) : null;
++ if (!rows) {
++ rows = readPage(r, { limit: lim, before, channelUsername });
++ if (key) memoSet(key, rows);
++ }
++ const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;
++ return { messages, latest_id: latestIdOf(rows) };
++}
++
++/**
++ * Everything after a cursor, oldest→newest. `complete:false` = more than `limit` rows were
++ * missing; take a fresh page instead of splicing.
++ * @returns {{ messages: object[], latest_id: number, complete: boolean }}
++ */
++function delta(room, { afterId, limit, channelUsername, decorate } = {}) {
++ const r = parseRoom(room);
++ const lim = clampLimit(limit, 200);
++ const after = Math.max(0, parseInt(afterId, 10) || 0);
++ let rows = readDelta(r, { afterId: after, limit: lim, channelUsername });
++ const complete = rows.length <= lim;
++ if (!complete) rows = rows.slice(0, lim);
++ const messages = decorate ? decorate(rows.map((x) => ({ ...x }))) : rows;
++ return { messages, latest_id: latestIdOf(rows) || after, complete };
++}
++
++/** Drop memoised pages for a room (or all rooms). Call from write paths when a tier is added. */
++function invalidate(room) {
++ if (!room) { memo.clear(); return; }
++ for (const k of memo.keys()) if (k.startsWith(`${room}|`)) memo.delete(k);
++}
++
++module.exports = { page, delta, invalidate, clampLimit, PAGE_MEMO_MS };
ddiff --git a/server/chat/moderation-utils.js b/server/chat/moderation-utils.js
nnew file mode 100644
iindex 0000000..3f708f4
---- /dev/null
++++ b/server/chat/moderation-utils.js
@@@ -0,0 +1,305 @@
++'use strict';
++/**
++ * moderation-utils.js — Shared chat moderation logic (moved from OpenVibe.Live, W6; Live's browser
++ * chat.js mirrors the same patterns)
++ *
++ * This module is the single authoritative source for:
++ * - Text normalization (leet-char mapping, NFKD, de-duplication)
++ * - Built-in slur category pattern definitions
++ * - Matching helpers used by both server and client code
++ *
++ * SERVER AUTHORITATIVE: Even if the client pre-send check misses a variant,
++ * the server always runs these same checks before forwarding the message.
++ *
++ * KEEP IN SYNC: The browser-side copy of these definitions lives in
++ * public/js/chat.js as CHAT_CORE_SLUR_CATEGORIES. When updating patterns
++ * here, update that constant there too (or vice-versa).
++ * Important safety framing:
++ * This is defensive moderation work. Do not generate, expand, list, or repeat offensive protected-class terms in comments, docs, test names, commit messages, logs, or UI text. Use neutral placeholders like BLOCKED_TERM_ALPHA / BLOCKED_TERM_BETA in tests where possible. Existing repository patterns may be refactored, but do not add new hateful terminology unless it already exists in the codebase and is strictly necessary for preserving current behavior.
++ */
++
++// ── Leet-char substitution map ──────────────────────────────────────────────
++// Applied before any pattern matching to catch common substitution ciphers.
++const LEET_MAP = {
++ '0': 'o', '1': 'i', '2': 'z', '3': 'e', '4': 'a',
++ '5': 's', '6': 'g', '7': 't', '8': 'b', '9': 'g',
++ '@': 'a', '$': 's', '!': 'i', '|': 'i', '+': 't',
++};
++
++// ── Built-in slur category definitions ────────────────────────────────────
++// Patterns are matched against normalizeSlurPatternText() output (spaces
++// preserved, leet mapped, accents stripped, non-alphanumeric removed).
++//
++// N-word notes:
++// (?:a+[sz]?|e+r+[sz]?) catches:
++// base
++// plural
++// The second pattern catches the "nick/knick" family (different root).
++// Both patterns are deliberately word-boundary anchored to reduce false positives.
++const CORE_SLUR_CATEGORIES = [
++ {
++ key: 'n_word',
++ label: 'N-word and variants',
++ patterns: [
++ // Base + plural forms: ****a, ****as, ****az, ****er, ****ers, ****az
++ '\\bn+i+g+g+(?:a+[sz]?|e+r+[sz]?)\\b',
++ // Alternate root family: nick, knick, etc.
++ '\\b[kn]*n+h?i+c?k+e+r+s?\\b',
++ ],
++ },
++ {
++ key: 'antisemitic',
++ label: 'Antisemitic slurs',
++ patterns: [
++ '\\bk+\\s*y+\\s*k+\\s*e+\\b',
++ '\\bj+\\s*e+\\s*w+\\s*s?\\s+w+\\s*i+\\s*l+\\s*l+\\s+n+\\s*o+\\s*t+\\s+r+\\s*e+\\s*p+\\s*l+\\s*a+\\s*c+\\s*e+\\b',
++ ],
++ },
++ {
++ key: 'homophobic',
++ label: 'Homophobic slurs',
++ patterns: [
++ '\\bf+\\s*a+\\s*g+(?:o+\\s*t+)?[sz]?\\b',
++ ],
++ },
++ {
++ key: 'racial',
++ label: 'Racial slurs',
++ patterns: [
++ '\\bs+\\s*p+\\s*i+\\s*c+[sz]?\\b',
++ '\\bc+\\s*h+\\s*i+\\s*n+\\s*k+[sz]?\\b',
++ ],
++ },
++];
++
++// Add a reusable candidate generation helper for built-in pattern matching.
++for (const cat of CORE_SLUR_CATEGORIES) {
++ cat.generateCandidates = (text) => {
++ const normalized = normalizeSlurPatternText(text);
++ if (!normalized) return [];
++ const collapsed = normalized.replace(/\s+/g, '');
++ return [normalized, collapsed];
++ };
++}
++
++// Pre-compile all patterns at module load time.
++for (const cat of CORE_SLUR_CATEGORIES) {
++ cat.compiled = cat.patterns
++ .map((src) => { try { return new RegExp(src, 'i'); } catch { return null; } })
++ .filter(Boolean);
++}
++
++// ── Normalization helpers ────────────────────────────────────────────────────
++
++/**
++ * Heavy normalization: strip everything to just unambiguous letters.
++ * Used for configured-term substring matching where inter-word boundaries
++ * are not needed.
++ *
++ * Steps: lowercase → leet map → NFKD + accent strip → letters only → dedup consecutive chars
++ */
++function normalizeSlurText(input) {
++ const lower = String(input || '').toLowerCase();
++ const mapped = lower.split('').map((ch) => LEET_MAP[ch] || ch).join('');
++ const ascii = mapped.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
++ const lettersOnly = ascii.replace(/[^a-z]/g, '');
++ return lettersOnly.replace(/(.)\1{1,}/g, '$1');
++}
++
++/**
++ * Pattern normalization: preserve word boundaries (spaces) so regex anchors work.
++ * Used for core category pattern matching and custom regex matching.
++ *
++ * Steps: lowercase → leet map → NFKD + accent strip → collapse non-alphanum to spaces
++ */
++function normalizeSlurPatternText(input) {
++ const lower = String(input || '').toLowerCase();
++ const mapped = lower.split('').map((ch) => LEET_MAP[ch] || ch).join('');
++ const ascii = mapped.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
++ return ascii.replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
++}
++
++// ── Matching helpers ─────────────────────────────────────────────────────────
++
++/**
++ * Returns true if the normalized text hits any enabled built-in slur category.
++ *
++ * Two normalization passes are run:
++ * 1. Space-preserved: "n i g g a" — catches word-boundary-separated attempts
++ * 2. Space-collapsed: "****a" — catches letter-by-letter separation via dots/dashes
++ *
++ * @param {string} text - Raw message text (pre-normalization).
++ * @param {string[]} disabledCategories - Category keys to skip (per-channel setting).
++ */
++function containsCoreSlur(text, disabledCategories = []) {
++ const disabled = new Set(disabledCategories);
++ return CORE_SLUR_CATEGORIES.some((cat) => {
++ if (disabled.has(cat.key)) return false;
++ const candidates = cat.generateCandidates(text);
++ return candidates.some((candidate) => cat.compiled.some((pat) => pat.test(candidate)));
++ });
++}
++
++/**
++ * Returns true if the normalized text matches any of the provided custom regex lines.
++ * Lines may be bare patterns or /pattern/flags wrapped patterns.
++ *
++ * @param {string} text - Raw message text.
++ * @param {string[]} regexLines - Array of pattern strings (user-configured).
++ */
++function containsRegexSlur(text, regexLines) {
++ const normalized = normalizeSlurPatternText(text);
++ if (!normalized) return false;
++ const candidates = [normalized, normalized.replace(/\s+/g, '')];
++ const compiled = compileRegexList(regexLines, { forceInsensitive: true });
++ return compiled.some((pat) => candidates.some((candidate) => pat.test(candidate)));
++}
++
++/**
++ * Returns true if the heavy-normalized text contains any of the configured term substrings.
++ *
++ * @param {string} text - Raw message text.
++ * @param {string[]} terms - Array of configured term strings (user-configured).
++ */
++function containsConfiguredSlur(text, terms) {
++ const normalizedText = normalizeSlurText(text);
++ if (!normalizedText) return false;
++ for (const term of terms) {
++ const normalizedTerm = normalizeSlurText(term);
++ if (!normalizedTerm || normalizedTerm.length < 2) continue;
++ if (normalizedText.includes(normalizedTerm)) return true;
++ }
++ return false;
++}
++
++/**
++ * Compile an array of raw pattern strings into RegExp objects.
++ * Supports /pattern/flags wrapped syntax. Invalid patterns are silently skipped.
++ *
++ * @param {string[]} patternStrings
++ * @param {{ forceInsensitive?: boolean }} opts
++ * @returns {RegExp[]}
++ */
++function compileRegexList(patternStrings, { forceInsensitive = true } = {}) {
++ const compiled = [];
++ for (const raw of patternStrings || []) {
++ if (!raw || raw.length > 200) continue;
++ let source = raw;
++ let flags = forceInsensitive ? 'i' : '';
++ const slashWrapped = raw.match(/^\/(.+)\/([a-z]*)$/i);
++ if (slashWrapped) {
++ source = slashWrapped[1];
++ flags = slashWrapped[2] || '';
++ if (forceInsensitive && !flags.includes('i')) flags += 'i';
++ }
++ try {
++ compiled.push(new RegExp(source, flags));
++ } catch {
++ // Ignore invalid user-provided regex patterns.
++ }
++ }
++ return compiled;
++}
++
++// ── Friendly global chat (viewer-side) ───────────────────────────────────────
++// What the "Friendly global chat" setting hides on a viewer's screen. Nothing is deleted and
++// nobody is banned for it — the message is still there for anyone with the setting off, and the
++// viewer can reveal it with a tap. It is on for a viewer's first day so partners and newcomers
++// looking around don't meet the roughest corners first.
++//
++// Patterns run against normalizeSlurPatternText() output (lower case, leetspeak folded), with the
++// same word-boundary style as the core list. Deliberately narrow: slurs, hate slogans, threats,
++// sexual content and anything involving minors — not ordinary swearing.
++const FRIENDLY_FILTER_CATEGORIES = [
++ ...CORE_SLUR_CATEGORIES.map(c => ({ key: c.key, label: c.label, patterns: c.patterns })),
++ {
++ key: 'slurs_more',
++ label: 'Other slurs',
++ patterns: [
++ '\\br+e+t+a+r+d+(?:e+d+|s+)?\\b',
++ '\\bt+r+a+n+n+(?:y+|i+e+s+)\\b',
++ '\\bc+o+o+n+s?\\b',
++ '\\bg+o+o+k+s?\\b',
++ '\\bw+e+t+b+a+c+k+s?\\b',
++ '\\bb+e+a+n+e+r+s?\\b',
++ '\\b(?:t+o+w+e+l+|r+a+g+|s+a+n+d+)\\s*(?:h+e+a+d+|n+i+g+g+\\w*)s?\\b',
++ '\\bd+y+k+e+s?\\b',
++ '\\bs+h+e+m+a+l+e+s?\\b',
++ '\\bp+a+k+i+s?\\b',
++ ],
++ },
++ {
++ key: 'hate',
++ label: 'Hate slogans',
++ patterns: [
++ '\\b(?:h+e+i+l+|s+i+e+g+)\\s+(?:h+i+t+l+e+r+|h+e+i+l+)\\b',
++ '\\b14\\s*[/.]?\\s*88\\b',
++ '\\bg+a+s+\\s+(?:t+h+e+|a+l+l+)\\s+\\w+',
++ '\\bw+h+i+t+e+\\s+p+o+w+e+r+\\b',
++ '\\bk+i+l+l+\\s+a+l+l+\\s+(?:t+h+e+\\s+)?(?:j+e+w+|b+l+a+c+k+|g+a+y+|m+u+s+l+i+m+|t+r+a+n+|w+h+i+t+e+|m+e+x+i+c+a+n+|a+s+i+a+n+)\\w*',
++ '\\bh+i+t+l+e+r+\\s+(?:d+i+d+\\s+n+o+t+h+i+n+g+\\s+w+r+o+n+g+|w+a+s+\\s+r+i+g+h+t+)\\b',
++ '\\bh+o+l+o+c+a+u+s+t+\\s+(?:n+e+v+e+r+\\s+h+a+p+p+e+n+e+d+|d+i+d+n+\'?t+\\s+h+a+p+p+e+n+|i+s+\\s+a+\\s+(?:l+i+e+|h+o+a+x+))\\b',
++ ],
++ },
++ {
++ key: 'threats',
++ label: 'Threats and self-harm baiting',
++ patterns: [
++ '\\bk+y+s+\\b',
++ '\\bk+i+l+l+\\s+y+o+u+r+\\s*s+e+l+f+\\b',
++ '\\b(?:i+\\s*(?:\'?l+l+|w+i+l+l+|a+m+\\s+g+o+n+n+a+|a+m+\\s+g+o+i+n+g+\\s+t+o+)|i+m+\\s+g+o+n+n+a+)\\s+(?:k+i+l+l+|m+u+r+d+e+r+|s+h+o+o+t+|s+t+a+b+)\\s+(?:y+o+u+|u+)\\b',
++ '\\bh+o+p+e+\\s+(?:y+o+u+|u+)\\s+d+i+e+\\b',
++ '\\bg+o+\\s+d+i+e+\\b',
++ ],
++ },
++ {
++ key: 'sexual',
++ label: 'Sexual content',
++ patterns: [
++ '\\br+a+p+(?:e+|e+d+|i+n+g+|i+s+t+s?)\\b',
++ '\\bc+u+m+(?:m+i+n+g+|s+h+o+t+)?\\b',
++ '\\bc+o+c+k+s?\\b',
++ '\\bp+u+s+s+(?:y+|i+e+s+)\\b',
++ '\\bb+l+o+w+\\s*j+o+b+s?\\b',
++ '\\bp+o+r+n+\\w*',
++ '\\bn+u+d+e+s+\\b',
++ '\\bj+e+r+k+(?:i+n+g+)?\\s+o+f+f+\\b',
++ ],
++ },
++ {
++ key: 'minors',
++ label: 'Anything sexualising minors',
++ patterns: [
++ '\\bp+e+d+o+(?:p+h+i+l+e+s?|s+)?\\b',
++ '\\bc+h+i+l+d+\\s*p+o+r+n+\\w*',
++ '\\bc+p+\\b',
++ '\\bl+o+l+i+(?:c+o+n+)?s?\\b',
++ '\\bm+i+n+o+r+s?\\s+(?:n+u+d+e+s+|s+e+x+)',
++ ],
++ },
++];
++for (const cat of FRIENDLY_FILTER_CATEGORIES) {
++ cat.compiled = cat.patterns.map((src) => { try { return new RegExp(src, 'i'); } catch { return null; } }).filter(Boolean);
++}
++/** The category a message falls in for the friendly filter, or null. */
++function friendlyFilterCategory(text) {
++ // Both spellings: leetspeak folding catches "n1gg…", but it also turns "14/88" into letters.
++ const n = normalizeSlurPatternText(text);
++ const raw = String(text || '').toLowerCase();
++ for (const cat of FRIENDLY_FILTER_CATEGORIES) if (cat.compiled.some(re => re.test(n) || re.test(raw))) return cat.key;
++ return null;
++}
++
++// ── Exports ──────────────────────────────────────────────────────────────────
++module.exports = {
++ LEET_MAP,
++ CORE_SLUR_CATEGORIES,
++ FRIENDLY_FILTER_CATEGORIES,
++ friendlyFilterCategory,
++ normalizeSlurText,
++ normalizeSlurPatternText,
++ containsCoreSlur,
++ containsRegexSlur,
++ containsConfiguredSlur,
++ compileRegexList,
++};
ddiff --git a/server/chat/routes.js b/server/chat/routes.js
nnew file mode 100644
iindex 0000000..c2daf54
---- /dev/null
++++ b/server/chat/routes.js
@@@ -0,0 +1,783 @@
++/**
++ * OpenVibe.Chat — Chat API Routes (moved from OpenVibe.Live server/chat/routes.js, W6)
++ *
++ * Same paths, bodies and responses as Live's /api/chat. Messages are Chat's; authors, streams,
++ * slots, cosmetics, tags, settings and profile cards are Live's and come through live-context.
++ *
++ * POST /api/chat/send - Send a chat message (REST fallback)
++ * GET /api/chat/:streamId/history - Get chat history for a stream
++ * GET /api/chat/:streamId/users - Get users in chat
++ * GET /api/chat/search - Search chat messages
++ * GET /api/chat/user/:userId/history - Get a user's chat history
++ * GET /api/chat/user/:username/profile - Get user profile card data
++ */
++const express = require('express');
++const db = require('../db/database');
++const ctx = require('../live-context');
++const { optionalAuth, requireAuth } = require('../auth/auth');
++const permissions = require('../auth/permissions');
++const historyStore = require('./history-store');
++
++const router = express.Router();
++
++// Chat-internal columns (the author's Network subject) are not part of Live's API.
++function publicRows(rows) {
++ if (Array.isArray(rows)) for (const r of rows) { if (r) delete r.subject_id; }
++ return rows;
++}
++
++/** Load the cosmetics/tags of every author in these rows (one batched call for the misses). */
++async function loadDecor(rows) {
++ const ids = (rows || []).map((m) => m && m.user_id).filter(Boolean);
++ if (ids.length) { try { await ctx.ensureDecor(ids); } catch { /* history still renders */ } }
++}
++
++/**
++ * Attach each author's CURRENT cosmetics (name/particle/hat effects) + tag to
++ * history rows, so historical messages render with the same effects as live ones
++ * (chat_messages doesn't persist cosmetics). Cached per user_id within the batch.
++ */
++function enrichMessagesWithCosmetics(messages) {
++ // Live's monetization/cosmetics + game/tags, through live-context (see loadDecor).
++ const cosmetics = { getCosmeticProfile: (id) => ctx.getCosmeticProfile(id) };
++ const tags = { getTagProfile: (id) => ctx.getTagProfile(id) };
++ if (!Array.isArray(messages)) return messages;
++ const cache = new Map();
++ for (const m of messages) {
++ if (!m || !m.user_id) continue;
++ let prof = cache.get(m.user_id);
++ if (!prof) {
++ prof = {};
++ try { if (cosmetics) Object.assign(prof, cosmetics.getCosmeticProfile(m.user_id) || {}); } catch { /* */ }
++ try { if (tags) prof.tag = tags.getTagProfile(m.user_id) || null; } catch { /* */ }
++ cache.set(m.user_id, prof);
++ }
++ if (prof.nameFX) m.nameFX = prof.nameFX;
++ if (prof.particleFX) m.particleFX = prof.particleFX;
++ if (prof.hatFX) m.hatFX = prof.hatFX;
++ if (prof.tag) m.tag = prof.tag;
++ }
++ return messages;
++}
++
++const MIN_SELF_DELETE_MINUTES = 3;
++const MAX_SELF_DELETE_MINUTES = 10080;
++const GIF_ALLOWED_PROVIDERS = new Set(['tenor', 'giphy']);
++
++function getGifProviderConfig(provider) {
++ if (provider === 'giphy') {
++ return {
++ key: ctx.getSetting('gif_giphy_api_key') || '',
++ trendingUrl: 'https://api.giphy.com/v1/gifs/trending',
++ searchUrl: 'https://api.giphy.com/v1/gifs/search',
++ };
++ }
++ return {
++ key: ctx.getSetting('gif_tenor_api_key') || '',
++ trendingUrl: 'https://tenor.googleapis.com/v2/featured',
++ searchUrl: 'https://tenor.googleapis.com/v2/search',
++ };
++}
++
++function normalizeGifProvider(provider) {
++ const normalized = String(provider || 'tenor').trim().toLowerCase();
++ return GIF_ALLOWED_PROVIDERS.has(normalized) ? normalized : 'tenor';
++}
++
++function mapGifResults(provider, payload) {
++ if (provider === 'giphy') {
++ const items = Array.isArray(payload?.data) ? payload.data : [];
++ return items.map((item) => {
++ const tiny = item?.images?.fixed_width_small?.url || item?.images?.preview_gif?.url || item?.images?.fixed_width?.url || '';
++ const full = item?.images?.original?.url || item?.images?.fixed_width?.url || tiny;
++ return {
++ id: item?.id || null,
++ preview_url: tiny,
++ full_url: full,
++ title: item?.title || item?.slug || 'GIF',
++ provider: 'giphy',
++ source_url: item?.url || full,
++ };
++ }).filter((item) => item.preview_url && item.full_url);
++ }
++
++ const items = Array.isArray(payload?.results) ? payload.results : [];
++ return items.map((item) => {
++ const tiny = item?.media_formats?.tinygif?.url || item?.media_formats?.gif?.url || '';
++ const full = item?.media_formats?.gif?.url || tiny;
++ return {
++ id: item?.id || null,
++ preview_url: tiny,
++ full_url: full,
++ title: item?.content_description || item?.title || 'GIF',
++ provider: 'tenor',
++ source_url: item?.itemurl || full,
++ };
++ }).filter((item) => item.preview_url && item.full_url);
++}
++
++function normalizeAutoDeleteMinutes(value) {
++ const mins = parseInt(value, 10);
++ if (!Number.isFinite(mins) || mins < MIN_SELF_DELETE_MINUTES) return 0;
++ return Math.min(MAX_SELF_DELETE_MINUTES, mins);
++}
++
++router.get('/gif/providers', optionalAuth, async (req, res) => {
++ await ctx.ensureSettings();
++ res.json({
++ providers: {
++ tenor: !!ctx.getSetting('gif_tenor_api_key'),
++ giphy: !!ctx.getSetting('gif_giphy_api_key'),
++ },
++ defaultProvider: ctx.getSetting('gif_tenor_api_key') ? 'tenor' : (ctx.getSetting('gif_giphy_api_key') ? 'giphy' : null),
++ });
++});
++
++router.get('/gif/trending', optionalAuth, async (req, res) => {
++ try {
++ const provider = normalizeGifProvider(req.query.provider);
++ await ctx.ensureSettings();
++ const config = getGifProviderConfig(provider);
++ if (!config.key) return res.status(503).json({ error: `${provider} API key not configured` });
++
++ const url = new URL(config.trendingUrl);
++ if (provider === 'giphy') {
++ url.searchParams.set('api_key', config.key);
++ url.searchParams.set('limit', '30');
++ url.searchParams.set('rating', 'pg-13');
++ } else {
++ url.searchParams.set('key', config.key);
++ url.searchParams.set('limit', '30');
++ url.searchParams.set('media_filter', 'tinygif,gif');
++ }
++
++ const upstream = await fetch(url, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } });
++ if (!upstream.ok) return res.status(502).json({ error: `GIF provider request failed (${upstream.status})` });
++ const payload = await upstream.json();
++ res.json({ provider, results: mapGifResults(provider, payload) });
++ } catch (err) {
++ res.status(500).json({ error: err.message || 'Failed to load GIFs' });
++ }
++});
++
++router.get('/gif/search', optionalAuth, async (req, res) => {
++ try {
++ const provider = normalizeGifProvider(req.query.provider);
++ const query = String(req.query.q || '').trim();
++ if (query.length < 2) return res.status(400).json({ error: 'Query must be at least 2 characters' });
++
++ await ctx.ensureSettings();
++ const config = getGifProviderConfig(provider);
++ if (!config.key) return res.status(503).json({ error: `${provider} API key not configured` });
++
++ const url = new URL(config.searchUrl);
++ if (provider === 'giphy') {
++ url.searchParams.set('api_key', config.key);
++ url.searchParams.set('q', query);
++ url.searchParams.set('limit', '30');
++ url.searchParams.set('rating', 'pg-13');
++ } else {
++ url.searchParams.set('key', config.key);
++ url.searchParams.set('q', query);
++ url.searchParams.set('limit', '30');
++ url.searchParams.set('media_filter', 'tinygif,gif');
++ }
++
++ const upstream = await fetch(url, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } });
++ if (!upstream.ok) return res.status(502).json({ error: `GIF provider request failed (${upstream.status})` });
++ const payload = await upstream.json();
++ res.json({ provider, results: mapGifResults(provider, payload) });
++ } catch (err) {
++ res.status(500).json({ error: err.message || 'Failed to search GIFs' });
++ }
++});
++
++/**
++ * Hydrate reply_to context onto an array of message rows.
++ * Each row must have reply_to_id (from DB). Adds a reply_to object
++ * with { id, username, user_id, message } for the parent message.
++ */
++function hydrateReplies(messages) {
++ const replyIds = [...new Set(messages.map(m => m.reply_to_id).filter(Boolean))];
++ if (!replyIds.length) return messages;
++ const placeholders = replyIds.map(() => '?').join(',');
++ const parents = db.all(
++ `SELECT id, username, user_id, message
++ FROM chat_messages
++ WHERE id IN (${placeholders})
++ AND is_deleted = 0
++ AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,
++ replyIds
++ );
++ const parentMap = new Map(parents.map(p => [p.id, p]));
++ return messages.map(m => {
++ if (m.reply_to_id && parentMap.has(m.reply_to_id)) {
++ const p = parentMap.get(m.reply_to_id);
++ m.reply_to = {
++ id: p.id,
++ username: p.username,
++ user_id: p.user_id,
++ message: p.message.length > 100 ? p.message.slice(0, 100) + '…' : p.message,
++ };
++ }
++ return m;
++ });
++}
++
++// ── Send Chat Message (REST fallback when WS is down) ────────
++router.post('/send', requireAuth, async (req, res) => {
++ try {
++ const text = (req.body.message || '').trim();
++ // 6000 = the absolute ceiling any channel/admin can configure (see chat-server handleChatMessage).
++ if (!text || text.length > 6000) {
++ return res.status(400).json({ error: 'Invalid message' });
++ }
++
++ // Ban check
++ if (ctx.isUserBanned(req.user.id, null)) {
++ return res.status(403).json({ error: 'You are banned from chat' });
++ }
++
++ const chatServer = require('./chat-server');
++ try { await ctx.ensureDecor([req.user.id]); } catch { /* non-critical */ }
++
++ // Word filter
++ const wordFilter = require('./word-filter');
++ const filterResult = wordFilter.check(text);
++ const filtered = filterResult.safe ? text : filterResult.filtered;
++
++ if (wordFilter.isSpam(filtered)) {
++ return res.status(400).json({ error: 'Message blocked: detected as spam' });
++ }
++
++ const username = req.user.display_name || req.user.username;
++ const autoDeleteMinutes = normalizeAutoDeleteMinutes(req.body.auto_delete_minutes);
++ const autoDeleteAt = autoDeleteMinutes
++ ? new Date(Date.now() + autoDeleteMinutes * 60 * 1000).toISOString()
++ : null;
++
++ // Reply-to support
++ const replyToId = req.body.reply_to_id ? parseInt(req.body.reply_to_id) : null;
++ let replyTo = null;
++ if (replyToId) {
++ const parent = db.getChatMessageById(replyToId);
++ if (parent && !parent.is_deleted) {
++ replyTo = {
++ id: parent.id,
++ username: parent.username,
++ user_id: parent.user_id,
++ message: parent.message.length > 100 ? parent.message.slice(0, 100) + '…' : parent.message,
++ };
++ }
++ }
++
++ const chatMsg = {
++ type: 'chat',
++ username,
++ core_username: req.user.username,
++ user_id: req.user.id,
++ anon_id: null,
++ role: req.user.role || 'user',
++ message: filtered,
++ stream_id: null,
++ is_global: true,
++ avatar_url: req.user.avatar_url || null,
++ profile_color: req.user.profile_color || '#999',
++ filtered: !filterResult.safe,
++ timestamp: new Date().toISOString(),
++ auto_delete_at: autoDeleteAt,
++ };
++
++ // Attach cosmetics: Live's REST fallback required '../game/cosmetics', a module Live does
++ // not have, so it never attached any — kept that way (the WebSocket path does attach them).
++
++ // Attach tag
++ try {
++ const tagProfile = ctx.getTagProfile(req.user.id);
++ if (tagProfile) chatMsg.tag = tagProfile;
++ } catch { /* non-critical */ }
++
++ // Save to database
++ let savedId = null;
++ try {
++ const result = db.saveChatMessage({
++ stream_id: null,
++ user_id: req.user.id,
++ anon_id: null,
++ username,
++ message: filtered,
++ message_type: 'chat',
++ is_global: true,
++ reply_to_id: replyToId,
++ auto_delete_at: autoDeleteAt,
++ });
++ savedId = result.lastInsertRowid;
++ } catch { /* non-critical */ }
++
++ // Attach message ID and reply context to broadcast
++ if (savedId) chatMsg.id = Number(savedId);
++ if (replyTo) chatMsg.reply_to = replyTo;
++
++ // Broadcast to all global chat clients
++ chatServer.broadcastGlobal(chatMsg);
++ res.json({ ok: true });
++ } catch (err) {
++ console.error('[Chat] REST send error:', err.message);
++ res.status(500).json({ error: 'Failed to send message' });
++ }
++});
++
++// ── Search Chat Messages (admin or self) ─────────────────────
++router.get('/search', requireAuth, (req, res) => {
++ try {
++ const limit = Math.min(parseInt(req.query.limit || '50'), 200);
++ const offset = parseInt(req.query.offset || '0');
++ const query = req.query.q || '';
++ const userId = req.query.user_id ? parseInt(req.query.user_id) : null;
++ const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
++
++ // Admin / global_mod can search anyone; others search only their own
++ const effectiveUserId = permissions.canViewOtherUserLogs(req.user) ? userId : req.user.id;
++
++ const result = db.searchChatMessages({
++ query, userId: effectiveUserId, streamId, limit, offset,
++ });
++ publicRows(result.messages);
++
++ res.json(result);
++ } catch (err) {
++ res.status(500).json({ error: 'Search failed' });
++ }
++});
++
++// ── User Chat History ────────────────────────────────────────
++router.get('/user/:userId/history', requireAuth, (req, res) => {
++ try {
++ const userId = parseInt(req.params.userId);
++ const limit = Math.min(parseInt(req.query.limit || '50'), 200);
++ const offset = parseInt(req.query.offset || '0');
++
++ // Admin / global_mod can view anyone; others view only their own
++ if (!permissions.canViewOtherUserLogs(req.user) && req.user.id !== userId) {
++ return res.status(403).json({ error: 'Access denied' });
++ }
++
++ const result = db.getUserChatHistory(userId, limit, offset);
++ publicRows(result.messages);
++ res.json(result);
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get chat history' });
++ }
++});
++
++// ── User Profile Card ────────────────────────────────────────
++router.get('/user/:username/profile', optionalAuth, async (req, res) => {
++ // The profile card is all Live data (account, coins, game stats): Live builds it with the
++ // same rules (private fields only for the user themselves).
++ try {
++ const profile = await ctx.effects.userProfile(req.params.username, req.user ? req.user.id : null);
++ res.json(profile);
++ } catch (err) {
++ if (err.status === 404) return res.status(404).json({ error: err.message || 'User not found' });
++ res.status(500).json({ error: 'Failed to get profile' });
++ }
++});
++
++// ── Chat-relay (external) user info: join date (first message) etc. ──
++router.get('/relay-user/:platform/:username', optionalAuth, (req, res) => {
++ try {
++ const r = db.getRelayUser(req.params.platform, req.params.username);
++ res.json({ relayUser: r || null });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get relay user' });
++ }
++});
++
++// Anonymous chatter info: first-seen (anon-number assignment) + first chat + count.
++router.get('/anon/:anonId', optionalAuth, async (req, res) => {
++ try {
++ const anonId = String(req.params.anonId || '');
++ if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });
++ // When the number was assigned is Live's (anon_ip_mappings); the chat counts are ours.
++ const num = db.anonSubjectId(anonId);
++ const firstSeen = num ? await ctx.anonFirstSeenByNum(num) : null;
++ res.json({ anon: db.getAnonMeta(anonId, firstSeen) });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get anon info' });
++ }
++});
++
++// An anonymous chatter's chat-message history — same gate as native logs.
++router.get('/anon/:anonId/logs', requireAuth, (req, res) => {
++ try {
++ if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
++ const anonId = String(req.params.anonId || '');
++ if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });
++ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
++ const query = String(req.query.q || '').trim();
++ const r = db.getAnonChatHistory(anonId, { limit, offset, query });
++ res.json(r);
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get anon user logs' });
++ }
++});
++
++// A relay (external-platform) user's chat-message history — same gate as native logs.
++router.get('/relay-user/:platform/:username/logs', requireAuth, (req, res) => {
++ try {
++ if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
++ const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
++ const query = String(req.query.q || '').trim();
++ const r = db.getRelayUserChatHistory(req.params.platform, req.params.username, { limit, offset, query });
++ res.json(r);
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get relay user logs' });
++ }
++});
++
++// ── Global Chat History (all streams) ────────────────────────
++// Patterns behind the viewer-side "Friendly global chat" setting (server/chat/moderation-utils.js is
++// the single source; the client compiles these).
++router.get('/filters/friendly', (req, res) => {
++ const { FRIENDLY_FILTER_CATEGORIES } = require('./moderation-utils');
++ res.set('Cache-Control', 'public, max-age=3600');
++ res.json({ version: 1, categories: FRIENDLY_FILTER_CATEGORIES.map(({ key, label, patterns }) => ({ key, label, patterns })) });
++});
++
++router.get('/global/history', optionalAuth, async (req, res) => {
++ try {
++ const limit = req.query.limit;
++ const before = req.query.before;
++ const afterId = req.query.after_id;
++ const channelUsername = String(req.query.username || '').trim();
++ // after_id = cursor read: only what the caller has not seen (oldest→newest, PK range scan).
++ // Anything else = a page: the newest rows, optionally before a timestamp.
++ const out = afterId != null
++ ? historyStore.delta('global', { afterId, limit, channelUsername })
++ : historyStore.page('global', { limit, before, channelUsername });
++ await loadDecor(out.messages);
++ out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));
++ res.json(out);
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get global chat history' });
++ }
++});
++
++// ── Chat Replay (for VOD/clip playback sync) ────────────────
++router.get('/:streamId/replay', optionalAuth, (req, res) => {
++ try {
++ const streamId = parseInt(req.params.streamId);
++ if (!streamId) return res.status(400).json({ error: 'Invalid stream ID' });
++
++ const from = req.query.from || null; // ISO timestamp
++ const to = req.query.to || null; // ISO timestamp
++
++ const messages = publicRows(db.getChatReplay(streamId, from, to));
++ res.json({ messages });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get chat replay' });
++ }
++});
++
++// ── Chat History ─────────────────────────────────────────────
++// Loads history for a streamer's chat. By default this spans ALL of that
++// broadcaster's stream sessions (not just the one slot/session being viewed),
++// so viewers keep context when a stream restarts or the streamer runs multiple
++// slots. Each message carries its source stream (title/slug/live) so the client
++// can label it and let viewers hop between the streamer's live slots.
++// Pass ?scope=stream to restrict to the single session (legacy behavior).
++router.get('/:streamId/history', optionalAuth, async (req, res) => {
++ try {
++ const limit = Math.min(parseInt(req.query.limit || '500'), 500);
++ const before = req.query.before; // ISO timestamp for pagination
++ const scope = req.query.scope || 'streamer';
++
++ // Resolve which broadcaster this stream belongs to.
++ const stream = await ctx.ensureStream(req.params.streamId);
++ const broadcasterId = stream ? stream.user_id : null;
++ const spanStreamer = scope !== 'stream' && broadcasterId;
++
++ // COALESCE the channel name from the source stream's owner (live rows) OR the
++ // message's channel_user_id (offline rows) so the streamer/channel tag renders
++ // in history for both. cu = the broadcaster resolved from channel_user_id.
++ const select = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name,
++ u.username AS core_username,
++ s.title AS source_stream_title, s.managed_stream_id AS source_managed_id,
++ s.is_live AS source_is_live, ms.slug AS source_slug,
++ COALESCE(bu.username, cu.username) AS source_channel`;
++ let sql;
++ const params = [];
++ if (spanStreamer) {
++ // Span by channel_user_id (LEFT JOIN streams) so OFFLINE messages
++ // (stream_id NULL) survive when the streamer goes live — the old INNER
++ // JOIN on streams dropped them.
++ sql = `${select}
++ FROM chat_messages cm
++ LEFT JOIN ctx_users u ON cm.user_id = u.id
++ LEFT JOIN ctx_streams s ON cm.stream_id = s.id
++ LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id
++ LEFT JOIN ctx_users bu ON s.user_id = bu.id
++ LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id
++ WHERE cm.channel_user_id = ? AND cm.is_deleted = 0
++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
++ params.push(broadcasterId);
++ } else {
++ sql = `${select}
++ FROM chat_messages cm
++ LEFT JOIN ctx_users u ON cm.user_id = u.id
++ LEFT JOIN ctx_streams s ON cm.stream_id = s.id
++ LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id
++ LEFT JOIN ctx_users bu ON s.user_id = bu.id
++ LEFT JOIN ctx_users cu ON cm.channel_user_id = cu.id
++ WHERE cm.stream_id = ? AND cm.is_deleted = 0
++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
++ params.push(req.params.streamId);
++ }
++
++ // after_id: cursor read (oldest→newest, primary-key range scan) for reopen/reconnect.
++ // Otherwise a page: the newest rows, optionally before a timestamp.
++ const afterId = req.query.after_id != null ? Math.max(0, parseInt(req.query.after_id, 10) || 0) : null;
++ if (afterId != null) {
++ sql += ` AND cm.id > ? ORDER BY cm.id ASC LIMIT ?`;
++ params.push(afterId, limit + 1);
++ } else {
++ if (before) {
++ sql += ` AND cm.timestamp < ?`;
++ params.push(before);
++ }
++ sql += ` ORDER BY cm.timestamp DESC LIMIT ?`;
++ params.push(limit);
++ }
++
++ let rows = db.all(sql, params);
++ let complete = true;
++ if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();
++ const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);
++ await loadDecor(rows);
++ const messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(rows)));
++
++ // Currently-live slots for this broadcaster — lets the client render a
++ // "hop between live streams" affordance.
++ let liveSlots = [];
++ let channel = null;
++ if (broadcasterId) {
++ try {
++ const owner = ctx.getUserById(broadcasterId);
++ channel = owner ? owner.username : null;
++ liveSlots = (ctx.getManagedStreamsByUserId(broadcasterId) || [])
++ .filter(ms => ms.is_currently_live)
++ .map(ms => ({
++ managed_stream_id: ms.id,
++ slug: ms.slug,
++ title: ms.title,
++ live_session_id: ms.live_session_id,
++ }));
++ } catch { /* non-critical */ }
++ }
++ res.json({
++ messages, latest_id, complete, liveSlots, channel,
++ activeStreamId: parseInt(req.params.streamId) || null,
++ activeManagedId: stream ? (stream.managed_stream_id || null) : null,
++ });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get chat history' });
++ }
++});
++
++// Persistent per-streamer chat history keyed by the broadcaster's USER id — spans
++// every live slot AND offline periods (channel_user_id), so it works when the
++// streamer has no active session. Used by the channel page (online + offline).
++router.get('/channel/:userId/history', optionalAuth, async (req, res) => {
++ try {
++ const userId = parseInt(req.params.userId);
++ if (!userId) return res.status(400).json({ error: 'Bad user id' });
++ const room = `channel:${userId}`;
++ const out = req.query.after_id != null
++ ? historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit })
++ : historyStore.page(room, { limit: req.query.limit, before: req.query.before });
++ await loadDecor(out.messages);
++ out.messages = publicRows(enrichMessagesWithCosmetics(hydrateReplies(out.messages.map((x) => ({ ...x })))));
++ let liveSlots = [], channel = null;
++ try {
++ const owner = ctx.getUserById(userId);
++ channel = owner ? owner.username : null;
++ liveSlots = (ctx.getManagedStreamsByUserId(userId) || [])
++ .filter(ms => ms.is_currently_live)
++ .map(ms => ({ managed_stream_id: ms.id, slug: ms.slug, title: ms.title, live_session_id: ms.live_session_id }));
++ } catch { /* non-critical */ }
++ res.json({ ...out, liveSlots, channel, activeStreamId: null, activeManagedId: null });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get channel chat history' });
++ }
++});
++
++// ── Chat User Count ──────────────────────────────────────────
++router.get('/:streamId/users', (req, res) => {
++ const chatServer = require('./chat-server');
++ const count = chatServer.getStreamViewerCount(parseInt(req.params.streamId));
++ res.json({ count });
++});
++
++// ── Chat Log Management ─────────────────────────────────────
++
++// Preview count of messages in a time range
++router.post('/admin/purge/preview', requireAuth, async (req, res) => {
++ try {
++ const { streamId, from, to } = req.body;
++ if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
++
++ // Must be admin or stream owner
++ let effectiveStreamId = streamId || null;
++ if (!permissions.isAdmin(req.user)) {
++ if (streamId) {
++ const stream = await ctx.ensureStream(streamId);
++ if (!stream || stream.user_id !== req.user.id) {
++ return res.status(403).json({ error: 'Not authorized' });
++ }
++ } else {
++ // Non-admin without streamId: scope to their most recent stream
++ const userStreams = ctx.getStreamsByUserId(req.user.id, 1);
++ if (!userStreams?.length) {
++ return res.json({ count: 0 });
++ }
++ effectiveStreamId = userStreams[0].id;
++ }
++ }
++
++ const count = db.countChatMessagesByTimeRange(effectiveStreamId, from, to);
++ res.json({ count });
++ } catch (e) {
++ console.error('[Chat] Purge preview error:', e.message);
++ res.status(500).json({ error: 'Failed to count messages' });
++ }
++});
++
++// Delete messages in a time range (soft delete)
++router.delete('/admin/purge', requireAuth, async (req, res) => {
++ try {
++ const { streamId, from, to } = req.body;
++ if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
++
++ let effectiveStreamId = streamId || null;
++ if (!permissions.isAdmin(req.user)) {
++ if (streamId) {
++ const stream = await ctx.ensureStream(streamId);
++ if (!stream || stream.user_id !== req.user.id) {
++ return res.status(403).json({ error: 'Not authorized' });
++ }
++ } else {
++ // Non-admin without streamId: scope to their most recent stream
++ const userStreams = ctx.getStreamsByUserId(req.user.id, 1);
++ if (!userStreams?.length) {
++ return res.json({ deleted: 0 });
++ }
++ effectiveStreamId = userStreams[0].id;
++ }
++ }
++
++ const result = db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);
++
++ // Broadcast delete event to live chat
++ try {
++ const chatServer = require('./chat-server');
++ const payload = { type: 'purge', streamId: effectiveStreamId, from, to, by: req.user.display_name };
++ if (effectiveStreamId) {
++ chatServer.broadcastToStream(effectiveStreamId, payload);
++ } else {
++ chatServer.broadcastGlobal(payload);
++ }
++ } catch { /* chat server may not be initialized */ }
++
++ console.log(`[Chat] Purged messages: stream=${effectiveStreamId || 'global'} from=${from} to=${to} by=${req.user.username} changes=${result?.changes || 0}`);
++ res.json({ deleted: result?.changes || 0 });
++ } catch (e) {
++ console.error('[Chat] Purge error:', e.message);
++ res.status(500).json({ error: 'Failed to purge messages' });
++ }
++});
++
++// Get paginated chat logs with filters
++router.get('/admin/logs', requireAuth, async (req, res) => {
++ try {
++ const { streamId, username, search, from, to, messageType, page, limit, includeDeleted } = req.query;
++
++ // Must be admin or stream owner
++ let effectiveStreamId = streamId ? parseInt(streamId) : undefined;
++ if (!permissions.isAdmin(req.user)) {
++ if (streamId) {
++ const stream = await ctx.ensureStream(parseInt(streamId));
++ if (!stream || stream.user_id !== req.user.id) {
++ return res.status(403).json({ error: 'Not authorized' });
++ }
++ } else {
++ // Non-admin without streamId: scope to their most recent stream
++ const userStreams = ctx.getStreamsByUserId(req.user.id, 1);
++ if (!userStreams?.length) {
++ return res.json({ rows: [], total: 0, page: 1, limit: 50, totalPages: 0 });
++ }
++ effectiveStreamId = userStreams[0].id;
++ }
++ }
++
++ const result = db.getChatLogs({
++ streamId: effectiveStreamId,
++ username, search, from, to, messageType,
++ page: parseInt(page) || 1,
++ limit: Math.min(parseInt(limit) || 50, 200),
++ includeDeleted: includeDeleted === 'true' && permissions.isAdmin(req.user),
++ });
++ publicRows(result.rows);
++ res.json(result);
++ } catch (e) {
++ console.error('[Chat] Logs error:', e.message);
++ res.status(500).json({ error: 'Failed to get chat logs' });
++ }
++});
++
++// Export chat logs as CSV or JSON
++router.get('/admin/logs/export', requireAuth, async (req, res) => {
++ try {
++ const { streamId, username, search, from, to, messageType, format } = req.query;
++
++ if (!permissions.isAdmin(req.user)) {
++ if (streamId) {
++ const stream = await ctx.ensureStream(parseInt(streamId));
++ if (!stream || stream.user_id !== req.user.id) {
++ return res.status(403).json({ error: 'Not authorized' });
++ }
++ } else {
++ return res.status(403).json({ error: 'Only admins can export all chat logs' });
++ }
++ }
++
++ // Get all matching rows (up to 50k)
++ const result = db.getChatLogs({
++ streamId: streamId ? parseInt(streamId) : undefined,
++ username, search, from, to, messageType,
++ page: 1, limit: 50000,
++ });
++ publicRows(result.rows);
++
++ if (format === 'csv') {
++ const header = 'id,timestamp,username,message,message_type,stream_id,is_global,source_platform\n';
++ const csvRows = result.rows.map(r => {
++ const msg = (r.message || '').replace(/"/g, '""');
++ return `${r.id},"${r.timestamp}","${(r.username || '').replace(/"/g, '""')}","${msg}","${r.message_type || ''}",${r.stream_id || ''},"${r.is_global || 0}","${r.source_platform || ''}"`;
++ });
++ res.setHeader('Content-Type', 'text/csv');
++ res.setHeader('Content-Disposition', `attachment; filename="chat-logs-${Date.now()}.csv"`);
++ res.send(header + csvRows.join('\n'));
++ } else {
++ res.setHeader('Content-Type', 'application/json');
++ res.setHeader('Content-Disposition', `attachment; filename="chat-logs-${Date.now()}.json"`);
++ res.json(result.rows);
++ }
++ } catch (e) {
++ console.error('[Chat] Export error:', e.message);
++ res.status(500).json({ error: 'Failed to export chat logs' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/chat/soundboard-service.js b/server/chat/soundboard-service.js
nnew file mode 100644
iindex 0000000..04a7abc
---- /dev/null
++++ b/server/chat/soundboard-service.js
@@@ -0,0 +1,431 @@
++/**
++ * OpenVibe.Chat — 101soundboards clips for !sb and pasted links (moved from OpenVibe.Live, W6).
++ * The API key is a Live site setting, read through live-context; clips are cached in
++ * CHAT_CACHE_DIR/soundboard-cache.
++ */
++const https = require('https');
++const http = require('http');
++const dns = require('dns');
++const path = require('path');
++const fs = require('fs');
++// Site settings are Live's (site_settings); read through the Live context projection.
++const ctx = require('../live-context');
++const config = require('../config');
++
++const CACHE_DIR = path.join(config.cacheDir, 'soundboard-cache');
++const CACHE_TTL_MS = 15 * 60 * 1000;
++const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?101soundboards\.com\/sounds\/(\d+)(?:[-\w/?#=&.]*)/i;
++const CMD_REGEX = /^!sb\s+(.+)$/i;
++const SOUND_URL_PREFIX = 'https://www.101soundboards.com/sounds/';
++
++function clamp(value, min, max) {
++ return Math.max(min, Math.min(max, value));
++}
++
++function centsToRate(cents) {
++ return Math.pow(2, cents / 1200);
++}
++
++async function ensureCacheDirAsync() {
++ await fs.promises.mkdir(CACHE_DIR, { recursive: true }).catch((err) => {
++ if (err.code !== 'EEXIST') throw err;
++ });
++}
++
++function getCachePaths(soundId) {
++ return {
++ audio: path.join(CACHE_DIR, `${soundId}.mp3`),
++ meta: path.join(CACHE_DIR, `${soundId}.json`),
++ };
++}
++
++async function getCachedSoundAsync(soundId) {
++ const paths = getCachePaths(soundId);
++ try {
++ const stat = await fs.promises.stat(paths.audio);
++ if ((Date.now() - stat.mtimeMs) > CACHE_TTL_MS) {
++ try { await fs.promises.unlink(paths.audio); } catch {}
++ try { await fs.promises.unlink(paths.meta); } catch {}
++ return null;
++ }
++ const audio = await fs.promises.readFile(paths.audio);
++ let meta = {};
++ try { meta = JSON.parse(await fs.promises.readFile(paths.meta, 'utf8')); } catch {}
++ return {
++ audio,
++ mimeType: meta.mimeType || 'audio/mpeg',
++ title: meta.title || `Sound ${soundId}`,
++ sourceUrl: meta.sourceUrl || `${SOUND_URL_PREFIX}${soundId}`,
++ };
++ } catch {
++ return null;
++ }
++}
++
++async function writeCachedSoundAsync(soundId, audioBuffer, meta = {}) {
++ const paths = getCachePaths(soundId);
++ const tmpAudio = paths.audio + '.tmp';
++ const tmpMeta = paths.meta + '.tmp';
++ try {
++ await fs.promises.writeFile(tmpAudio, audioBuffer);
++ await fs.promises.rename(tmpAudio, paths.audio);
++ await fs.promises.writeFile(tmpMeta, JSON.stringify({
++ mimeType: meta.mimeType || 'audio/mpeg',
++ title: meta.title || `Sound ${soundId}`,
++ sourceUrl: meta.sourceUrl || `${SOUND_URL_PREFIX}${soundId}`,
++ }));
++ await fs.promises.rename(tmpMeta, paths.meta);
++ } catch (err) {
++ console.error('[Soundboard] Cache write error:', err.message);
++ try { await fs.promises.unlink(tmpAudio); } catch {}
++ try { await fs.promises.unlink(tmpMeta); } catch {}
++ }
++}
++
++function extractSoundId(value) {
++ const text = String(value || '').trim();
++ if (!text) return null;
++ const urlMatch = text.match(URL_REGEX);
++ if (urlMatch) return urlMatch[1];
++ const idMatch = text.match(/^(\d{2,})$/);
++ if (idMatch) return idMatch[1];
++ return null;
++}
++
++function normalizeBannedIds(value) {
++ if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean);
++ return String(value || '')
++ .split(/[\s,\n\r]+/)
++ .map((item) => extractSoundId(item) || String(item || '').trim())
++ .filter(Boolean);
++}
++
++function parseModifiers(tokens, options = {}) {
++ const settings = {
++ allowPitch: options.allowPitch !== false,
++ allowSpeed: options.allowSpeed !== false,
++ minSpeed: Number.isFinite(options.minSpeed) ? options.minSpeed : 0.5,
++ maxSpeed: Number.isFinite(options.maxSpeed) ? options.maxSpeed : 3.0,
++ minPitch: Number.isFinite(options.minPitch) ? options.minPitch : 0.5, // rate
++ maxPitch: Number.isFinite(options.maxPitch) ? options.maxPitch : 2.0, // rate
++ };
++ // Pitch is expressed in "units": +100 = one octave up (2×), -100 = one octave
++ // down (0.5×), 0 = unchanged. This is an INDEPENDENT pitch shift (applied via a
++ // real pitch shifter on the client) so it combines with speed, e.g. "2 100p" =
++ // 2× speed AND pitch up. Derive the allowed unit range from the channel's
++ // pitch-rate limits (rate = 2^(units/100)).
++ const minUnits = 100 * Math.log2(settings.minPitch);
++ const maxUnits = 100 * Math.log2(settings.maxPitch);
++ const result = {
++ pitch: 1, // rate multiplier (back-compat / display)
++ speed: 1,
++ pitchShift: 0, // pitch units in [-100, 100]-ish (clamped to channel range)
++ };
++
++ for (const rawToken of tokens) {
++ const token = String(rawToken || '').trim().toLowerCase();
++ if (!token) continue;
++
++ const pitchToken = token.match(/^([+-]?\d+(?:\.\d+)?)p$/i);
++ if (pitchToken && settings.allowPitch) {
++ const raw = parseFloat(pitchToken[1]);
++ if (Number.isFinite(raw)) {
++ result.pitchShift = clamp(raw, minUnits, maxUnits);
++ result.pitch = Math.pow(2, result.pitchShift / 100);
++ }
++ continue;
++ }
++
++ const speedToken = token.match(/^([0-9]*\.?[0-9]+)s$/i);
++ if (speedToken && settings.allowSpeed) {
++ const raw = parseFloat(speedToken[1]);
++ if (Number.isFinite(raw)) result.speed = clamp(raw, settings.minSpeed, settings.maxSpeed);
++ continue;
++ }
++
++ if (/^[0-9]*\.?[0-9]+$/.test(token) && settings.allowSpeed) {
++ const raw = parseFloat(token);
++ if (Number.isFinite(raw)) result.speed = clamp(raw, settings.minSpeed, settings.maxSpeed);
++ }
++ }
++
++ return result;
++}
++
++function parseSoundboardMessage(text, options = {}) {
++ const trimmed = String(text || '').trim();
++ if (!trimmed) return null;
++
++ const cmdMatch = trimmed.match(CMD_REGEX);
++ if (cmdMatch) {
++ const args = cmdMatch[1].trim().split(/\s+/).filter(Boolean);
++ const target = args.shift();
++ const soundId = extractSoundId(target);
++ if (!soundId) return null;
++ const mods = parseModifiers(args, options);
++ return {
++ soundId,
++ ...mods,
++ sourceUrl: `${SOUND_URL_PREFIX}${soundId}`,
++ original: trimmed,
++ };
++ }
++
++ const urlMatch = trimmed.match(URL_REGEX);
++ if (!urlMatch) return null;
++ const soundId = urlMatch[1];
++ const afterUrl = trimmed.slice(urlMatch.index + urlMatch[0].length).trim();
++ const mods = parseModifiers(afterUrl ? afterUrl.split(/\s+/) : [], options);
++ return {
++ soundId,
++ ...mods,
++ sourceUrl: `${SOUND_URL_PREFIX}${soundId}`,
++ original: trimmed,
++ };
++}
++
++function findBestAudioUrl(value) {
++ let best = null;
++
++ function visit(node) {
++ if (best) return;
++ if (!node) return;
++ if (typeof node === 'string') {
++ const candidate = node.match(/https?:\/\/[^\s"'<>]+\.(?:mp3|mpeg|wav|ogg)(?:\?[^\s"'<>]*)?/i);
++ if (candidate) best = candidate[0];
++ return;
++ }
++ if (Array.isArray(node)) {
++ for (const item of node) visit(item);
++ return;
++ }
++ if (typeof node === 'object') {
++ for (const [key, item] of Object.entries(node)) {
++ if (!best && /url|audio|file|src|stream/i.test(key) && typeof item === 'string') visit(item);
++ if (!best) visit(item);
++ }
++ }
++ }
++
++ visit(value);
++ return best;
++}
++
++function findBestTitle(value, soundId) {
++ const candidates = [];
++
++ function visit(node, keyHint = '') {
++ if (!node) return;
++ if (typeof node === 'string') {
++ const clean = node.trim();
++ if (!clean || /^https?:\/\//i.test(clean)) return;
++ if (/^(sound|audio)\s*\d+$/i.test(clean)) return;
++ if (/title|name|label|sound/i.test(keyHint)) candidates.push(clean);
++ return;
++ }
++ if (Array.isArray(node)) {
++ for (const item of node) visit(item, keyHint);
++ return;
++ }
++ if (typeof node === 'object') {
++ for (const [key, item] of Object.entries(node)) visit(item, key);
++ }
++ }
++
++ visit(value);
++ return candidates[0] || `Sound ${soundId}`;
++}
++
++function isPrivateIp(addr) {
++ const parts = addr.split('.');
++ if (parts.length === 4) {
++ const [a, b] = parts.map(Number);
++ if (a === 127) return true; // 127.0.0.0/8 loopback
++ if (a === 10) return true; // 10.0.0.0/8
++ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
++ if (a === 192 && b === 168) return true; // 192.168.0.0/16
++ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
++ if (a === 0) return true; // 0.0.0.0/8
++ }
++ if (addr === '::1') return true; // IPv6 loopback
++ if (/^(fc|fd)/i.test(addr)) return true; // fc00::/7 unique local
++ if (/^fe80:/i.test(addr)) return true; // IPv6 link-local
++ return false;
++}
++
++async function validateAudioUrl(audioUrl) {
++ let parsed;
++ try { parsed = new URL(audioUrl); } catch {
++ throw new Error('Invalid audio URL from 101soundboards API');
++ }
++ if (parsed.protocol !== 'https:') throw new Error('Audio URL must use HTTPS');
++ const host = parsed.hostname.toLowerCase();
++ const ALLOWED = ['101soundboards.com'];
++ if (!ALLOWED.some((s) => host === s || host.endsWith('.' + s))) {
++ throw new Error('Audio URL host is not permitted');
++ }
++ const { address } = await dns.promises.lookup(host).catch(() => {
++ throw new Error('Audio URL host could not be resolved');
++ });
++ if (isPrivateIp(address)) throw new Error('Audio URL resolved to a restricted IP address');
++}
++
++async function fetchJsonWithRetry(url, retries = 2) {
++ let lastErr;
++ let delay = 500;
++ for (let attempt = 0; attempt <= retries; attempt++) {
++ try {
++ const response = await fetch(url, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } });
++ if (response.status === 429) {
++ const retryAfter = parseInt(response.headers.get('retry-after') || '0', 10);
++ const waitMs = retryAfter > 0 ? retryAfter * 1000 : delay;
++ if (attempt < retries) { await new Promise((r) => setTimeout(r, waitMs)); delay *= 3; continue; }
++ throw new Error('101soundboards is temporarily rate-limited, try again shortly');
++ }
++ if (!response.ok) {
++ if (response.status >= 400 && response.status < 500) throw new Error(`HTTP ${response.status}`);
++ lastErr = new Error(`HTTP ${response.status}`);
++ if (attempt < retries) { await new Promise((r) => setTimeout(r, delay)); delay *= 3; continue; }
++ throw lastErr;
++ }
++ return response.json();
++ } catch (err) {
++ if (/rate-limited|HTTP 4/.test(err.message)) throw err;
++ lastErr = err;
++ if (attempt < retries) { await new Promise((r) => setTimeout(r, delay)); delay *= 3; }
++ }
++ }
++ throw lastErr || new Error('101soundboards is temporarily unavailable, try again later');
++}
++
++function fetchSoundTitleHtml(soundId) {
++ return new Promise((resolve) => {
++ const req = https.get(`${SOUND_URL_PREFIX}${soundId}`, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } }, (res) => {
++ if (res.statusCode !== 200) {
++ res.resume();
++ return resolve(`Sound ${soundId}`);
++ }
++ let html = '';
++ res.on('data', (chunk) => { html += chunk; });
++ res.on('end', () => {
++ const h1 = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
++ const og = html.match(/property=["']og:title["'][^>]*content=["']([^"']+)["']/i);
++ resolve((h1?.[1] || og?.[1] || `Sound ${soundId}`).trim());
++ });
++ });
++ req.on('error', () => resolve(`Sound ${soundId}`));
++ req.setTimeout(5000, () => { req.destroy(); resolve(`Sound ${soundId}`); });
++ });
++}
++
++function downloadAudio(audioUrl) {
++ return new Promise((resolve) => {
++ const req = https.get(audioUrl, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } }, (res) => {
++ if (res.statusCode !== 200) {
++ res.resume();
++ return resolve(null);
++ }
++ const chunks = [];
++ let size = 0;
++ const MAX_SIZE = 5 * 1024 * 1024;
++ res.on('data', (chunk) => {
++ size += chunk.length;
++ if (size > MAX_SIZE) {
++ res.destroy();
++ return resolve(null);
++ }
++ chunks.push(chunk);
++ });
++ res.on('end', () => resolve(Buffer.concat(chunks)));
++ });
++ req.on('error', () => resolve(null));
++ req.setTimeout(10000, () => { req.destroy(); resolve(null); });
++ });
++}
++
++async function fetchSoundboardMeta(soundId) {
++ const apiKey = String(ctx.getSetting('soundboard_101_api_key') || '').trim();
++ if (!apiKey) throw new Error('101soundboards API key is not configured');
++
++ const payload = await fetchJsonWithRetry(`${SOUND_URL_PREFIX}${soundId}/streamers?key=${encodeURIComponent(apiKey)}`);
++ const audioUrl = findBestAudioUrl(payload);
++ if (!audioUrl) throw new Error('No streamable audio URL found for this sound');
++ await validateAudioUrl(audioUrl);
++ let title = findBestTitle(payload, soundId);
++ if (!title || /^sound\s+\d+$/i.test(title)) title = await fetchSoundTitleHtml(soundId);
++ return {
++ audioUrl,
++ title,
++ sourceUrl: `${SOUND_URL_PREFIX}${soundId}`,
++ mimeType: 'audio/mpeg',
++ };
++}
++
++const pendingDownloads = new Map();
++
++async function getSoundboardAudio(soundId) {
++ await ensureCacheDirAsync();
++
++ const cached = await getCachedSoundAsync(soundId);
++ if (cached) {
++ return {
++ audio: cached.audio.toString('base64'),
++ mimeType: cached.mimeType,
++ title: cached.title,
++ soundId,
++ sourceUrl: cached.sourceUrl,
++ };
++ }
++
++ // Deduplicate concurrent downloads for the same soundId
++ if (pendingDownloads.has(soundId)) return pendingDownloads.get(soundId);
++
++ const downloadPromise = (async () => {
++ const meta = await fetchSoundboardMeta(soundId);
++ const audioBuffer = await downloadAudio(meta.audioUrl);
++ if (!audioBuffer || audioBuffer.length < 100) throw new Error('Failed to download soundboard audio');
++ await writeCachedSoundAsync(soundId, audioBuffer, meta);
++ return {
++ audio: audioBuffer.toString('base64'),
++ mimeType: meta.mimeType,
++ title: meta.title,
++ soundId,
++ sourceUrl: meta.sourceUrl,
++ };
++ })().finally(() => pendingDownloads.delete(soundId));
++
++ pendingDownloads.set(soundId, downloadPromise);
++ return downloadPromise;
++}
++
++function isConfigured() {
++ return !!String(ctx.getSetting('soundboard_101_api_key') || '').trim();
++}
++
++async function cleanupCache() {
++ try {
++ const now = Date.now();
++ const files = await fs.promises.readdir(CACHE_DIR);
++ await Promise.all(files.map(async (file) => {
++ const full = path.join(CACHE_DIR, file);
++ try {
++ const stat = await fs.promises.stat(full);
++ if ((now - stat.mtimeMs) > CACHE_TTL_MS) await fs.promises.unlink(full);
++ } catch {}
++ }));
++ } catch {}
++}
++
++cleanupCache().catch(() => {});
++
++module.exports = {
++ parseSoundboardMessage,
++ parseModifiers,
++ getSoundboardAudio,
++ isConfigured,
++ cleanupCache,
++ normalizeBannedIds,
++ extractSoundId,
++ URL_REGEX,
++ CMD_REGEX,
++};
\\ No newline at end of file
ddiff --git a/server/chat/sounds-routes.js b/server/chat/sounds-routes.js
nnew file mode 100644
iindex 0000000..c2f0992
---- /dev/null
++++ b/server/chat/sounds-routes.js
@@@ -0,0 +1,474 @@
++/**
++ * OpenVibe.Chat — Channel Sound Commands API (moved from OpenVibe.Live server/chat/sounds-routes.js, W6)
++ *
++ * Sounds are Chat's (channel_sounds); the files stay in the shared sounds directory
++ * (SOUNDS_PATH = Live's data/sounds) because stored rows hold absolute paths into it and Live's
++ * donation alerts read the alert sounds from there. Channels, their moderation settings (incl.
++ * the alert-sound columns) and moderators are Live's and go through live-context.
++ *
++ * Per-channel, viewer-uploadable sound clips triggered by !command in chat.
++ *
++ * GET /api/sounds/channel/:userId - Sounds for a streamer's channel
++ * GET /api/sounds/all/:streamId - Sounds available in a stream context
++ * POST /api/sounds - Upload a channel sound (auth required)
++ * DELETE /api/sounds/:id - Delete a sound (uploader / mod / owner)
++ * GET /api/sounds/file/:filename - Serve a sound file
++ */
++const express = require('express');
++const path = require('path');
++const fs = require('fs');
++const { spawn } = require('child_process');
++const multer = require('multer');
++const db = require('../db/database');
++const ctx = require('../live-context');
++const { requireAuth } = require('../auth/auth');
++const permissions = require('../auth/permissions');
++const config = require('../config');
++
++/** The channel of a user plus its moderation policy, loaded before a permission check. */
++async function channelWithPolicy(userId) {
++ const channel = await ctx.ensureChannelForUser(userId);
++ if (channel) await ctx.ensurePolicy(channel.id);
++ return channel;
++}
++
++const router = express.Router();
++
++// Commands already handled elsewhere in chat — cannot be overridden by a sound.
++const RESERVED_COMMANDS = new Set([
++ 'sb', 'gotti', 'sr', 'yt', 'youtube', 'req', 'request', 'queue', 'np',
++ 'nowplaying', 'watching', 'skip', 'mediahelp', 'say',
++ 'forward', 'backward', 'left', 'right', 'liftup', 'liftdown', 'headup', 'headdown',
++]);
++
++const MIME_TO_EXT = {
++ 'audio/mpeg': '.mp3', 'audio/mp3': '.mp3', 'audio/wav': '.wav', 'audio/x-wav': '.wav',
++ 'audio/wave': '.wav', 'audio/vnd.wave': '.wav', 'audio/x-pn-wav': '.wav',
++ 'audio/ogg': '.ogg', 'audio/webm': '.webm', 'audio/mp4': '.m4a', 'audio/x-m4a': '.m4a',
++ 'audio/aac': '.aac', 'audio/flac': '.flac', 'audio/x-flac': '.flac', 'audio/opus': '.opus',
++};
++// Browsers/OSes report .wav (and others) under many mimetypes — or none at all
++// (application/octet-stream). Fall back to the file extension so uploads don't wrongly fail.
++const ALLOWED_EXT = new Set(['.mp3', '.wav', '.ogg', '.oga', '.opus', '.webm', '.m4a', '.mp4', '.aac', '.flac']);
++
++function extFor(file) {
++ if (MIME_TO_EXT[file.mimetype]) return MIME_TO_EXT[file.mimetype];
++ const ext = path.extname(file.originalname || '').toLowerCase();
++ return ALLOWED_EXT.has(ext) ? ext : '';
++}
++
++function soundsDir() {
++ const dir = path.resolve(config.sounds.path);
++ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
++ return dir;
++}
++
++const soundStorage = multer.diskStorage({
++ destination: (req, file, cb) => cb(null, soundsDir()),
++ filename: (req, file, cb) => {
++ const ext = extFor(file) || '.bin';
++ cb(null, `snd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
++ },
++});
++const soundUpload = multer({
++ storage: soundStorage,
++ limits: { fileSize: config.sounds.maxSizeKb * 1024 },
++ fileFilter: (req, file, cb) => {
++ // Accept by known audio mimetype OR by a recognized audio extension.
++ if (MIME_TO_EXT[file.mimetype] || ALLOWED_EXT.has(path.extname(file.originalname || '').toLowerCase())) {
++ cb(null, true);
++ } else {
++ cb(new Error('Only audio files are allowed (MP3, WAV, OGG, Opus, WebM, M4A, AAC, FLAC).'));
++ }
++ },
++});
++
++/** Transcode an audio file to a canonical MP3 (fast decode, small, universal). Returns the
++ * new path on success (original left for the caller to delete), or null on failure. */
++function convertToMp3(srcPath) {
++ return new Promise((resolve) => {
++ const outPath = srcPath.replace(/\.[^.]+$/, '') + '.conv.mp3';
++ let proc;
++ try {
++ proc = spawn('ffmpeg', ['-y', '-i', srcPath, '-vn', '-ac', '2', '-ar', '44100', '-c:a', 'libmp3lame', '-b:a', '128k', outPath]);
++ } catch { return resolve(null); }
++ proc.on('close', (code) => resolve(code === 0 && fs.existsSync(outPath) ? outPath : null));
++ proc.on('error', () => resolve(null));
++ setTimeout(() => { try { proc.kill(); } catch {} resolve(null); }, 20000);
++ });
++}
++
++/** Probe an audio file's duration in seconds (0 on failure). */
++function probeDuration(filePath) {
++ return new Promise((resolve) => {
++ let done = false;
++ const finish = (v) => { if (!done) { done = true; resolve(v); } };
++ let out = '';
++ let probe;
++ try {
++ probe = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', filePath]);
++ } catch { return finish(0); }
++ probe.stdout.on('data', (d) => { out += d; });
++ probe.on('close', () => {
++ try {
++ const info = JSON.parse(out);
++ const dur = parseFloat(info.format?.duration || '0');
++ finish(Number.isFinite(dur) && dur > 0 ? dur : 0);
++ } catch { finish(0); }
++ });
++ probe.on('error', () => finish(0));
++ setTimeout(() => { try { probe.kill(); } catch {} finish(0); }, 10000);
++ });
++}
++
++function serializeSound(s) {
++ return {
++ id: s.id,
++ command: s.command,
++ url: `/api/sounds/file/${path.basename(s.url)}`,
++ duration_seconds: Math.round((s.duration_seconds || 0) * 10) / 10,
++ uploader: s.created_by_name || 'someone',
++ uploader_id: s.created_by,
++ emote_code: s.emote_code || '',
++ created_at: s.created_at,
++ };
++}
++
++// ── List a channel's sounds ──────────────────────────────────
++router.get('/channel/:userId', (req, res) => {
++ try {
++ const userId = parseInt(req.params.userId);
++ if (!userId) return res.json({ sounds: [] });
++ res.json({ sounds: db.getChannelSounds(userId).map(serializeSound) });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load channel sounds' });
++ }
++});
++
++// ── List sounds available in a stream context ────────────────
++router.get('/all/:streamId', async (req, res) => {
++ try {
++ const stream = await ctx.ensureStream(parseInt(req.params.streamId));
++ if (!stream?.user_id) return res.json({ sounds: [] });
++ res.json({ sounds: db.getChannelSounds(stream.user_id).map(serializeSound) });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to load sounds' });
++ }
++});
++
++// ── Upload a channel sound ───────────────────────────────────
++router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
++ const cleanup = () => { if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch {} } };
++ try {
++ if (!req.file) return res.status(400).json({ error: 'No audio file uploaded' });
++
++ const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');
++ if (!/^[a-z0-9_]{2,24}$/.test(command)) {
++ cleanup();
++ return res.status(400).json({ error: 'Command must be 2-24 letters, numbers, or underscores' });
++ }
++ if (RESERVED_COMMANDS.has(command)) {
++ cleanup();
++ return res.status(400).json({ error: `"!${command}" is a reserved command — pick another name` });
++ }
++
++ // Resolve target channel: channel_id = streamer's user id; stream_id resolves to its owner.
++ let channelOwnerId = parseInt(req.body.channel_id) || null;
++ if (!channelOwnerId && req.body.stream_id) {
++ channelOwnerId = (await ctx.ensureStream(parseInt(req.body.stream_id)))?.user_id || null;
++ }
++ if (!channelOwnerId) channelOwnerId = req.user.id;
++ const channel = (await channelWithPolicy(channelOwnerId)) || (channelOwnerId === req.user.id ? await ctx.createChannel(req.user.id) : null);
++ if (!channel) {
++ cleanup();
++ return res.status(404).json({ error: 'Channel not found' });
++ }
++ await ctx.ensurePolicy(channel.id);
++
++ const settings = ctx.getChannelModerationSettings(channel.id);
++ const isMod = permissions.canModerateChannel(req.user, channel.id);
++ const isOwnChannel = channelOwnerId === req.user.id;
++
++ if (!isMod && !settings.custom_sounds_enabled) {
++ cleanup();
++ return res.status(403).json({ error: 'This streamer has disabled viewer sound uploads.' });
++ }
++ if (!isMod && !isOwnChannel && (settings.uploads_mods_only || settings.sounds_mods_only)) {
++ cleanup();
++ return res.status(403).json({ error: 'Only channel mods can upload sounds here.' });
++ }
++
++ // Adding a NEW file to an EXISTING command is restricted to that command's
++ // creator (or channel mods/owner), so viewers can't hijack someone's command.
++ const existingForCmd = db.getChannelSoundByCommand(channelOwnerId, command);
++ if (existingForCmd && !isMod && !isOwnChannel && existingForCmd.created_by !== req.user.id) {
++ cleanup();
++ return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can add more sounds to it.` });
++ }
++
++ // Per-channel + per-uploader caps
++ if (db.countChannelSounds(channelOwnerId) >= config.sounds.maxPerChannel) {
++ cleanup();
++ return res.status(400).json({ error: `This channel has reached its sound limit (${config.sounds.maxPerChannel}).` });
++ }
++ if (!isMod && db.countChannelSoundsByUploader(channelOwnerId, req.user.id) >= config.sounds.maxPerUploaderPerChannel) {
++ cleanup();
++ return res.status(400).json({ error: `You've reached your upload limit for this channel (${config.sounds.maxPerUploaderPerChannel}).` });
++ }
++
++ // A command may hold multiple sounds (one is chosen at random on playback),
++ // so duplicates are allowed. Per-channel / per-uploader count caps below still apply.
++
++ // Duration gate
++ const maxSeconds = Math.min(30, Math.max(1, settings.max_sound_seconds || config.sounds.defaultMaxSeconds));
++ const duration = await probeDuration(req.file.path);
++ if (!duration) {
++ cleanup();
++ return res.status(400).json({ error: 'Could not read that audio file — try a standard MP3 or WAV.' });
++ }
++ if (duration > maxSeconds + 0.25) {
++ cleanup();
++ return res.status(400).json({ error: `Sound is too long (${duration.toFixed(1)}s). Max is ${maxSeconds}s for this channel.` });
++ }
++
++ // Normalize every upload to MP3 — universal, small, and the fastest to decode for
++ // low-latency chat playback. (Skip if it's already an .mp3.)
++ let finalPath = req.file.path;
++ let finalMime = req.file.mimetype;
++ if (path.extname(finalPath).toLowerCase() !== '.mp3') {
++ const mp3 = await convertToMp3(req.file.path);
++ if (mp3) {
++ try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
++ finalPath = mp3;
++ finalMime = 'audio/mpeg';
++ } else {
++ cleanup();
++ return res.status(400).json({ error: 'Could not process that audio file. Try a standard MP3 or WAV.' });
++ }
++ }
++
++ // Emote attached to the command (per-command; new files inherit the existing one).
++ const emoteCode = String(req.body.emote_code || (existingForCmd && existingForCmd.emote_code) || '').trim().slice(0, 32);
++ const result = db.createChannelSound({
++ channel_owner_id: channelOwnerId,
++ command,
++ url: finalPath,
++ mime: finalMime,
++ duration_seconds: duration,
++ created_by: req.user.id,
++ created_by_name: req.user.display_name || req.user.username,
++ emote_code: emoteCode,
++ });
++ // If an emote was (re)specified, apply it to every sound under this command.
++ if (req.body.emote_code !== undefined) { try { db.setChannelSoundEmote(channelOwnerId, command, emoteCode); } catch { /* */ } }
++ // Live's media-proxy/asset-sync mirrors the file to OpenVibe.Media (from Live's copy of the row).
++ try { ctx.effects.assetSync('syncSoon'); } catch { /* mirror is best-effort */ }
++
++ // Tell everyone watching this channel to refresh their sound list live.
++ try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
++
++ res.json({
++ sound: {
++ id: result.lastInsertRowid,
++ command,
++ url: `/api/sounds/file/${path.basename(finalPath)}`,
++ duration_seconds: Math.round(duration * 10) / 10,
++ channel_id: channelOwnerId,
++ },
++ });
++ } catch (err) {
++ console.error('[Sounds] Upload error:', err);
++ cleanup();
++ if (err && /UNIQUE constraint/i.test(err.message || '')) {
++ return res.status(409).json({ error: 'That command already exists on this channel.' });
++ }
++ res.status(500).json({ error: 'Failed to upload sound' });
++ }
++});
++
++// ── Multer error handler ─────────────────────────────────────
++router.use((err, req, res, next) => {
++ if (err.code === 'LIMIT_FILE_SIZE') {
++ return res.status(400).json({ error: `File too large (max ${Math.round(config.sounds.maxSizeKb / 1024 * 10) / 10}MB)` });
++ }
++ if (err.message && err.message.includes('Only')) {
++ return res.status(400).json({ error: err.message });
++ }
++ console.error('[Sounds] Middleware error:', err);
++ res.status(500).json({ error: 'Sound upload failed' });
++});
++
++// ── Delete a sound ───────────────────────────────────────────
++router.delete('/:id', requireAuth, async (req, res) => {
++ try {
++ const sound = db.getChannelSoundById(parseInt(req.params.id));
++ if (!sound) return res.status(404).json({ error: 'Sound not found' });
++ let allowed = sound.created_by === req.user.id || req.user.role === 'admin';
++ if (!allowed) {
++ const channel = await channelWithPolicy(sound.channel_owner_id);
++ if (channel && permissions.canModerateChannel(req.user, channel.id)) allowed = true;
++ }
++ if (!allowed) return res.status(403).json({ error: 'Not your sound' });
++
++ if (sound.url && fs.existsSync(sound.url)) { try { fs.unlinkSync(sound.url); } catch {} }
++ // The Media asset id lives on Live's copy of the row: Live removes the asset first.
++ try { await ctx.effects.assetSync('remove-sound', sound.id); } catch { /* */ }
++ db.deleteChannelSound(sound.id);
++ try { require('./chat-server').broadcastToOwnerStreams(sound.channel_owner_id, { type: 'sounds-updated' }); } catch { /* */ }
++ res.json({ message: 'Sound deleted' });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to delete sound' });
++ }
++});
++
++// ── Edit a !command group in place (rename / set emote) ──────
++// Body: { channel_id? , stream_id?, command, newCommand?, emoteCode? }.
++// A command may hold several sounds, so edits apply to the whole group.
++// Allowed: channel owner, channel mods, admins, or the creator of every
++// sound in the group (mirrors the add-to-command rule).
++router.patch('/command', requireAuth, async (req, res) => {
++ try {
++ let channelOwnerId = parseInt(req.body.channel_id) || null;
++ if (!channelOwnerId && req.body.stream_id) {
++ channelOwnerId = (await ctx.ensureStream(parseInt(req.body.stream_id)))?.user_id || null;
++ }
++ if (!channelOwnerId) channelOwnerId = req.user.id;
++ const channel = await channelWithPolicy(channelOwnerId);
++ if (!channel) return res.status(404).json({ error: 'Channel not found' });
++
++ const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');
++ const group = (db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);
++ if (!group.length) return res.status(404).json({ error: `No sound command !${command} in this channel.` });
++
++ const isMod = permissions.canModerateChannel(req.user, channel.id);
++ const isOwnChannel = channelOwnerId === req.user.id;
++ const isCreator = group.every((s) => s.created_by === req.user.id);
++ if (!isMod && !isOwnChannel && !isCreator && req.user.role !== 'admin') {
++ return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can edit it.` });
++ }
++
++ let finalCommand = command;
++ if (req.body.newCommand !== undefined) {
++ const next = String(req.body.newCommand || '').trim().toLowerCase().replace(/^!+/, '');
++ if (!/^[a-z0-9_]{2,24}$/.test(next)) {
++ return res.status(400).json({ error: 'Command must be 2-24 letters, numbers, or underscores' });
++ }
++ if (RESERVED_COMMANDS.has(next)) {
++ return res.status(400).json({ error: `"!${next}" is a reserved command — pick another name` });
++ }
++ if (next !== command && db.getChannelSoundByCommand(channelOwnerId, next)) {
++ return res.status(409).json({ error: `!${next} already exists in this channel.` });
++ }
++ if (next !== command) {
++ db.renameChannelSoundCommand(channelOwnerId, command, next);
++ finalCommand = next;
++ }
++ }
++ if (req.body.emoteCode !== undefined) {
++ const emoteCode = String(req.body.emoteCode || '').trim().replace(/^:|:$/g, '');
++ if (emoteCode && !/^[a-zA-Z0-9_]{2,32}$/.test(emoteCode)) {
++ return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
++ }
++ db.setChannelSoundEmote(channelOwnerId, finalCommand, emoteCode);
++ }
++
++ try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
++ res.json({ message: 'Sound command updated', command: finalCommand });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to update sound command' });
++ }
++});
++
++// ── Serve a sound file ───────────────────────────────────────
++const EXT_TO_MIME = {
++ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
++ '.webm': 'audio/webm', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.flac': 'audio/flac',
++};
++router.get('/file/:filename', (req, res) => {
++ try {
++ const filename = path.basename(req.params.filename);
++ const filePath = path.resolve(soundsDir(), filename);
++ if (!filePath.startsWith(soundsDir()) || !fs.existsSync(filePath)) {
++ return res.status(404).json({ error: 'Sound not found' });
++ }
++ const ext = path.extname(filename).toLowerCase();
++ res.setHeader('Content-Type', EXT_TO_MIME[ext] || 'application/octet-stream');
++ res.setHeader('Cache-Control', 'public, max-age=86400');
++ fs.createReadStream(filePath).pipe(res);
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to serve sound' });
++ }
++});
++
++// ── Streamer alert sounds (donation / goal-reached) ──────────────────────
++// Streamer-only. Stored on the channel's moderation settings; the file lives in the
++// sounds dir and is read server-side + broadcast as base64 on donation / goal events.
++function _alertKind(raw) { return raw === 'goal' ? 'goal' : 'donation'; }
++
++// GET which alert sounds are configured (+ a preview URL).
++router.get('/alert/mine', requireAuth, async (req, res) => {
++ await channelWithPolicy(req.user.id);
++ const s = ctx.getChannelAlertSoundsByUser(req.user.id) || {};
++ const toUrl = (p) => (p ? `/api/sounds/file/${path.basename(p)}` : null);
++ res.json({
++ donation: { set: !!s.donation_sound_url, url: toUrl(s.donation_sound_url) },
++ goal: { set: !!s.goal_sound_url, url: toUrl(s.goal_sound_url) },
++ });
++});
++
++// Upload/replace an alert sound. kind = 'donation' | 'goal'.
++router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req, res) => {
++ const kind = _alertKind(req.params.kind);
++ try {
++ if (!req.file) return res.status(400).json({ error: 'No sound file' });
++ const channel = (await channelWithPolicy(req.user.id)) || (await ctx.createChannel(req.user.id));
++ if (!channel) { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: 'No channel' }); }
++
++ // Guard duration (reuse the channel's max-sound limit, capped at 15s for alerts).
++ let duration = 0;
++ try { duration = await probeDuration(req.file.path); } catch { /* */ }
++ if (duration > 15.5) { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: `Alert sound too long (${duration.toFixed(1)}s). Max 15s.` }); }
++
++ // Normalize to mp3 for universal playback; fall back to the original on failure.
++ let finalPath = req.file.path;
++ try {
++ const mp3 = await convertToMp3(req.file.path);
++ if (mp3) { fs.unlink(req.file.path, () => {}); finalPath = mp3; }
++ } catch { /* keep original */ }
++
++ // Remove the previous alert sound of this kind (best-effort).
++ try {
++ const prev = ctx.getChannelAlertSoundsByUser(req.user.id) || {};
++ const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;
++ if (prevPath && fs.existsSync(prevPath) && path.resolve(prevPath) !== path.resolve(finalPath)) fs.unlink(prevPath, () => {});
++ } catch { /* ignore */ }
++
++ const ext = path.extname(finalPath).toLowerCase();
++ const mime = EXT_TO_MIME[ext] || 'audio/mpeg';
++ // The alert columns are on Live's channel_moderation_settings; Live's alerts read them.
++ await ctx.effects.setChannelAlertSound(channel.id, kind, finalPath, mime, req.user.id);
++ res.json({ set: true, kind, url: `/api/sounds/file/${path.basename(finalPath)}` });
++ } catch (err) {
++ if (req.file) fs.unlink(req.file.path, () => {});
++ res.status(500).json({ error: 'Failed to save alert sound: ' + err.message });
++ }
++});
++
++// Clear an alert sound.
++router.delete('/alert/:kind', requireAuth, async (req, res) => {
++ const kind = _alertKind(req.params.kind);
++ try {
++ const channel = await channelWithPolicy(req.user.id);
++ if (channel) {
++ const prev = ctx.getChannelAlertSoundsByUser(req.user.id) || {};
++ const prevPath = kind === 'goal' ? prev.goal_sound_url : prev.donation_sound_url;
++ if (prevPath && fs.existsSync(prevPath)) { try { fs.unlinkSync(prevPath); } catch { /* */ } }
++ await ctx.effects.setChannelAlertSound(channel.id, kind, null, null, req.user.id);
++ }
++ res.json({ set: false, kind });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to clear alert sound' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/chat/tts-engine.js b/server/chat/tts-engine.js
nnew file mode 100644
iindex 0000000..3ce4f53
---- /dev/null
++++ b/server/chat/tts-engine.js
@@@ -0,0 +1,669 @@
++/**
++ * OpenVibe.Chat — Server-Side TTS Engine (moved from OpenVibe.Live, W6; settings are read
++ * through live-context — they stay rows of Live's site_settings in this wave)
++ *
++ * Synthesizes speech audio using multiple providers:
++ * 1. Google Cloud TTS (Standard, WaveNet, Neural2, Studio, Journey)
++ * 2. Amazon Polly (Standard, Neural, Long-form, Generative)
++ * 3. espeak-ng (local, free fallback)
++ *
++ * Returns base64-encoded audio (MP3 or WAV) for client playback.
++ * Ported from RS-Companion's tts-engine.js + voice-system.js.
++ */
++const { execFile, execFileSync } = require('child_process');
++const https = require('https');
++const crypto = require('crypto');
++const path = require('path');
++const fs = require('fs');
++const os = require('os');
++const db = require('../db/database');
++// Site settings are Live's (site_settings); read through the Live context projection.
++const ctx = require('../live-context');
++
++// ── Voice Catalog ─────────────────────────────────────────────
++// Every voice available on the platform — combines RS-Companion's
++// VOICE_CATALOG (72) with OpenVibe.Live cosmetic voices.
++// engine: 'espeak-ng' | 'google-cloud' | 'amazon-polly' | 'browser'
++const VOICE_CATALOG = {
++ // ── espeak-ng voices ──────────────────────────────────────
++ gary: { name: 'Gary', emoji: '🔊', engine: 'espeak-ng', rarity: 'common', tier: 1, params: { voice: 'en', pitch: 50, speed: 160 } },
++ brenda: { name: 'Brenda', emoji: '👩', engine: 'espeak-ng', rarity: 'common', tier: 1, params: { voice: 'en+f1', pitch: 50, speed: 155 } },
++ chadbot: { name: 'ChadBot', emoji: '💪', engine: 'espeak-ng', rarity: 'common', tier: 1, params: { voice: 'en+m1', pitch: 35, speed: 180 } },
++ karen: { name: 'Karen', emoji: '💅', engine: 'espeak-ng', rarity: 'common', tier: 1, params: { voice: 'en+f2', pitch: 65, speed: 170 } },
++ squeakmaster: { name: 'SqueakMaster', emoji: '🐭', engine: 'espeak-ng', rarity: 'common', tier: 1, params: { voice: 'en+f3', pitch: 99, speed: 200 } },
++ bigchungus: { name: 'BigChungus', emoji: '🐰', engine: 'espeak-ng', rarity: 'common', tier: 1, params: { voice: 'en+m7', pitch: 1, speed: 90 } },
++ tweaker: { name: 'Tweaker', emoji: '⚡', engine: 'espeak-ng', rarity: 'common', tier: 1, params: { voice: 'en+m1', pitch: 60, speed: 320 } },
++ grandpa: { name: 'GrandpaJoe', emoji: '👴', engine: 'espeak-ng', rarity: 'common', tier: 1, params: { voice: 'en+m4', pitch: 25, speed: 85 } },
++ crackhead: { name: 'CrackheadCarl', emoji: '💊', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en+m5', pitch: 80, speed: 250 } },
++ ghostgirl: { name: 'GhostGirl', emoji: '👻', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en+whisper', pitch: 70, speed: 120 } },
++ robotoverlord: { name: 'RobotOverlord', emoji: '🤖', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en+m3', pitch: 10, speed: 130 } },
++ sassybitch: { name: 'SassyBitch', emoji: '💁', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en+f5', pitch: 75, speed: 165 } },
++ demon: { name: 'DemonLord', emoji: '👹', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en+croak', pitch: 1, speed: 70 } },
++ helium: { name: 'HeliumHuffer', emoji: '🎈', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en+f4', pitch: 99, speed: 250 } },
++ britbong: { name: 'BritBong', emoji: '🇬🇧', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en-gb-x-rp', pitch: 45, speed: 145 } },
++ yeehaw: { name: 'YeeHaw', emoji: '🤠', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en-us', pitch: 30, speed: 100 } },
++ neckbeard: { name: 'Neckbeard9000', emoji: '🧔', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en+m6', pitch: 20, speed: 130, gap: 5 } },
++ crybaby: { name: 'CryBaby', emoji: '😭', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en+f1', pitch: 90, speed: 110 } },
++ scotsman: { name: 'McScreamy', emoji: '🏴', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en-gb-scotland', pitch: 40, speed: 175 } },
++ alien: { name: 'Zorp', emoji: '👽', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en+m5', pitch: 99, speed: 60, gap: 15 } },
++ gigachad: { name: 'GigaChad', emoji: '🏋️', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m2', pitch: 15, speed: 140 } },
++ uwu: { name: 'UwUBot', emoji: '🌸', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+f3', pitch: 85, speed: 190 } },
++ nyc: { name: 'BrooklynRage', emoji: '🗽', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'en-us-nyc', pitch: 55, speed: 185 } },
++ french: { name: 'LeBaguette', emoji: '🇫🇷', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'fr-fr', pitch: 50, speed: 150 } },
++ german: { name: 'HansGetZeFlammenwerfer', emoji: '🇩🇪', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'de', pitch: 35, speed: 140 } },
++ russian: { name: 'ComradeBot', emoji: '🇷🇺', engine: 'espeak-ng', rarity: 'uncommon', tier: 2, params: { voice: 'ru', pitch: 30, speed: 135 } },
++ japanese: { name: 'SenpaiNotice', emoji: '🇯🇵', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'ja', pitch: 60, speed: 150 } },
++ slowmo: { name: 'SlowMoDave', emoji: '🐌', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m2', pitch: 40, speed: 50 } },
++ glitch: { name: 'GlitchFiend', emoji: '📟', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m3', pitch: 50, speed: 160, gap: 20 } },
++ void: { name: 'VoidScreamer', emoji: '🕳️', engine: 'espeak-ng', rarity: 'epic', tier: 4, params: { voice: 'en+croak', pitch: 1, speed: 200 } },
++ ascended: { name: 'Ascended', emoji: '✨', engine: 'espeak-ng', rarity: 'epic', tier: 4, params: { voice: 'en+whisper', pitch: 50, speed: 300 } },
++ omega: { name: 'OMEGA', emoji: '🔴', engine: 'espeak-ng', rarity: 'legendary', tier: 5, params: { voice: 'en+m7', pitch: 1, speed: 400 } },
++
++ // Achievement espeak voices
++ chatterbox: { name: 'Chatterbox', emoji: '💬', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m2', pitch: 60, speed: 220 } },
++ warlord: { name: 'WarLord', emoji: '⚔️', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m7', pitch: 10, speed: 110 } },
++ degenking: { name: 'DegenKing', emoji: '👑', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m5', pitch: 75, speed: 195 } },
++ fisherman: { name: 'OldManSea', emoji: '🎣', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m4', pitch: 30, speed: 95 } },
++ coinlord: { name: 'CoinLord', emoji: '💰', engine: 'espeak-ng', rarity: 'rare', tier: 3, params: { voice: 'en+m6', pitch: 40, speed: 150 } },
++
++ // ── Google Cloud TTS voices ───────────────────────────────
++ // Standard
++ gc_smooth_operator: { name: 'SmoothOperator', emoji: '🎤', engine: 'google-cloud', rarity: 'rare', tier: 3, cloudVoice: { name: 'en-US-Standard-D', gender: 'MALE', locale: 'en-US' } },
++ gc_silicon_sally: { name: 'SiliconSally', emoji: '🎤', engine: 'google-cloud', rarity: 'rare', tier: 3, cloudVoice: { name: 'en-US-Standard-C', gender: 'FEMALE', locale: 'en-US' } },
++ gc_brit_butler: { name: 'BritButler', emoji: '🎤', engine: 'google-cloud', rarity: 'rare', tier: 3, cloudVoice: { name: 'en-GB-Standard-B', gender: 'MALE', locale: 'en-GB' } },
++ gc_lady_london: { name: 'LadyLondon', emoji: '🎤', engine: 'google-cloud', rarity: 'rare', tier: 3, cloudVoice: { name: 'en-GB-Standard-A', gender: 'FEMALE', locale: 'en-GB' } },
++ gc_down_under: { name: 'DownUnder', emoji: '🎤', engine: 'google-cloud', rarity: 'rare', tier: 3, cloudVoice: { name: 'en-AU-Standard-B', gender: 'MALE', locale: 'en-AU' } },
++ gc_sheila: { name: 'Sheila', emoji: '🎤', engine: 'google-cloud', rarity: 'rare', tier: 3, cloudVoice: { name: 'en-AU-Standard-A', gender: 'FEMALE', locale: 'en-AU' } },
++ gc_mumbai_mike: { name: 'MumbaiMike', emoji: '🎤', engine: 'google-cloud', rarity: 'rare', tier: 3, cloudVoice: { name: 'en-IN-Standard-B', gender: 'MALE', locale: 'en-IN' } },
++ gc_news_anchor: { name: 'NewsAnchor', emoji: '🎤', engine: 'google-cloud', rarity: 'rare', tier: 3, cloudVoice: { name: 'en-US-Standard-J', gender: 'MALE', locale: 'en-US' } },
++ gc_podcast_girl: { name: 'PodcastGirl', emoji: '🎤', engine: 'google-cloud', rarity: 'rare', tier: 3, cloudVoice: { name: 'en-US-Standard-H', gender: 'FEMALE', locale: 'en-US' } },
++ // WaveNet
++ gc_wave_daddy: { name: 'WaveDaddy', emoji: '🎵', engine: 'google-cloud', rarity: 'epic', tier: 4, cloudVoice: { name: 'en-US-Wavenet-D', gender: 'MALE', locale: 'en-US' } },
++ gc_wave_queen: { name: 'WaveQueen', emoji: '🎵', engine: 'google-cloud', rarity: 'epic', tier: 4, cloudVoice: { name: 'en-US-Wavenet-F', gender: 'FEMALE', locale: 'en-US' } },
++ gc_wave_brit: { name: 'WaveBrit', emoji: '🎵', engine: 'google-cloud', rarity: 'epic', tier: 4, cloudVoice: { name: 'en-GB-Wavenet-B', gender: 'MALE', locale: 'en-GB' } },
++ gc_wave_aussie: { name: 'WaveAussie', emoji: '🎵', engine: 'google-cloud', rarity: 'epic', tier: 4, cloudVoice: { name: 'en-AU-Wavenet-B', gender: 'MALE', locale: 'en-AU' } },
++ // Neural2
++ gc_neural_chad: { name: 'NeuralChad', emoji: '🧠', engine: 'google-cloud', rarity: 'epic', tier: 4, cloudVoice: { name: 'en-US-Neural2-D', gender: 'MALE', locale: 'en-US' } },
++ gc_neural_diva: { name: 'NeuralDiva', emoji: '🧠', engine: 'google-cloud', rarity: 'epic', tier: 4, cloudVoice: { name: 'en-US-Neural2-F', gender: 'FEMALE', locale: 'en-US' } },
++ gc_neural_brit_f: { name: 'NeuralLady', emoji: '🧠', engine: 'google-cloud', rarity: 'epic', tier: 4, cloudVoice: { name: 'en-GB-Neural2-A', gender: 'FEMALE', locale: 'en-GB' } },
++ gc_neural_aussie_f: { name: 'NeuralSheila', emoji: '🧠', engine: 'google-cloud', rarity: 'epic', tier: 4, cloudVoice: { name: 'en-AU-Neural2-A', gender: 'FEMALE', locale: 'en-AU' } },
++ // Studio
++ gc_studio_m: { name: 'StudioAlpha', emoji: '🎙️', engine: 'google-cloud', rarity: 'legendary', tier: 5, cloudVoice: { name: 'en-US-Studio-Q', gender: 'MALE', locale: 'en-US' } },
++ gc_studio_f: { name: 'StudioDiva', emoji: '🎙️', engine: 'google-cloud', rarity: 'legendary', tier: 5, cloudVoice: { name: 'en-US-Studio-O', gender: 'FEMALE', locale: 'en-US' } },
++ // Journey
++ gc_journey_sage: { name: 'JourneySage', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-D', gender: 'MALE', locale: 'en-US' } },
++ gc_journey_oracle: { name: 'JourneyOracle', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-F', gender: 'FEMALE', locale: 'en-US' } },
++ gc_journey_whisper: { name: 'JourneyWhisper', emoji: '🗺️', engine: 'google-cloud', rarity: 'mythic', tier: 6, cloudVoice: { name: 'en-US-Journey-O', gender: 'FEMALE', locale: 'en-US' } },
++
++ // ── Amazon Polly voices ───────────────────────────────────
++ // Standard
++ pl_joanna_std: { name: 'PollyJoanna', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joanna', engine: 'standard', locale: 'en-US' } },
++ pl_matthew_std: { name: 'PollyMatt', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Matthew', engine: 'standard', locale: 'en-US' } },
++ pl_amy_std: { name: 'PollyAmy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Amy', engine: 'standard', locale: 'en-GB' } },
++ pl_brian_std: { name: 'PollyBrian', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Brian', engine: 'standard', locale: 'en-GB' } },
++ pl_olivia_std: { name: 'PollyOlivia', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Olivia', engine: 'standard', locale: 'en-AU' } },
++ pl_ivy_std: { name: 'PollyIvy', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Ivy', engine: 'standard', locale: 'en-US' } },
++ pl_joey_std: { name: 'PollyJoey', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Joey', engine: 'standard', locale: 'en-US' } },
++ pl_kendra_std: { name: 'PollyKendra', emoji: '🎤', engine: 'amazon-polly', rarity: 'rare', tier: 3, pollyVoice: { voiceId: 'Kendra', engine: 'standard', locale: 'en-US' } },
++ // Neural
++ pl_joanna_neural: { name: 'NeuralJoanna', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Joanna', engine: 'neural', locale: 'en-US' } },
++ pl_matthew_neural: { name: 'NeuralMatt', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Matthew', engine: 'neural', locale: 'en-US' } },
++ pl_ruth_neural: { name: 'NeuralRuth', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Ruth', engine: 'neural', locale: 'en-US' } },
++ pl_stephen_neural: { name: 'NeuralStephen', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Stephen', engine: 'neural', locale: 'en-US' } },
++ pl_amy_neural: { name: 'NeuralAmy', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Amy', engine: 'neural', locale: 'en-GB' } },
++ pl_arthur_neural: { name: 'NeuralArthur', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Arthur', engine: 'neural', locale: 'en-GB' } },
++ pl_danielle_neural: { name: 'NeuralDanielle', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Danielle', engine: 'neural', locale: 'en-US' } },
++ pl_gregory_neural: { name: 'NeuralGregory', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Gregory', engine: 'neural', locale: 'en-US' } },
++ pl_kevin_neural: { name: 'NeuralKevin', emoji: '🧠', engine: 'amazon-polly', rarity: 'epic', tier: 4, pollyVoice: { voiceId: 'Kevin', engine: 'neural', locale: 'en-US' } },
++ // Long-form
++ pl_danielle_long: { name: 'LongDanielle', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Danielle', engine: 'long-form', locale: 'en-US' } },
++ pl_gregory_long: { name: 'LongGregory', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Gregory', engine: 'long-form', locale: 'en-US' } },
++ pl_ruth_long: { name: 'LongRuth', emoji: '📖', engine: 'amazon-polly', rarity: 'legendary', tier: 5, pollyVoice: { voiceId: 'Ruth', engine: 'long-form', locale: 'en-US' } },
++ // Generative
++ pl_matthew_gen: { name: 'GenMatt', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Matthew', engine: 'generative', locale: 'en-US' } },
++ pl_ruth_gen: { name: 'GenRuth', emoji: '🌟', engine: 'amazon-polly', rarity: 'mythic', tier: 6, pollyVoice: { voiceId: 'Ruth', engine: 'generative', locale: 'en-US' } },
++
++ // ── Browser-only voices (Self TTS — no server synthesis) ──
++ voice_default: { name: 'Default', emoji: '🔊', engine: 'browser', rarity: 'common', tier: 0, browserVoice: { pitch: 1.0, rate: 1.0 } },
++ voice_deep: { name: 'Deep', emoji: '🎵', engine: 'browser', rarity: 'common', tier: 1, browserVoice: { pitch: 0.6, rate: 0.9 } },
++ voice_chipmunk: { name: 'Chipmunk', emoji: '🐿️', engine: 'browser', rarity: 'common', tier: 1, browserVoice: { pitch: 1.8, rate: 1.3 } },
++ voice_robot: { name: 'Robot', emoji: '🤖', engine: 'browser', rarity: 'common', tier: 2, browserVoice: { pitch: 0.8, rate: 1.0 } },
++ voice_whisper: { name: 'Whisper', emoji: '🤫', engine: 'browser', rarity: 'common', tier: 2, browserVoice: { pitch: 1.1, rate: 0.7 } },
++ voice_demon: { name: 'Demon', emoji: '😈', engine: 'browser', rarity: 'uncommon', tier: 3, browserVoice: { pitch: 0.3, rate: 0.6 } },
++};
++
++// ── Rarity colors ─────────────────────────────────────────────
++const RARITY_COLORS = {
++ common: '#aaaaaa',
++ uncommon: '#44ff44',
++ rare: '#4488ff',
++ epic: '#aa44ff',
++ legendary: '#ff8800',
++ mythic: '#ff2222',
++};
++
++// ── TTS Settings Cache ────────────────────────────────────────
++let _settingsCache = null;
++let _settingsCacheTime = 0;
++const SETTINGS_CACHE_TTL = 30_000; // 30s
++
++function getTTSSettings() {
++ const now = Date.now();
++ if (_settingsCache && (now - _settingsCacheTime) < SETTINGS_CACHE_TTL) return _settingsCache;
++ _settingsCache = {
++ enabled: ctx.getSetting('tts_enabled') !== false,
++ provider: ctx.getSetting('tts_provider') || 'espeak-ng',
++ googleApiKey: ctx.getSetting('tts_google_api_key') || '',
++ googleServiceAccount: ctx.getSetting('tts_google_service_account') || '',
++ awsAccessKeyId: ctx.getSetting('tts_aws_access_key_id') || '',
++ awsSecretAccessKey: ctx.getSetting('tts_aws_secret_access_key') || '',
++ awsRegion: ctx.getSetting('tts_aws_region') || 'us-east-1',
++ maxLength: ctx.getSetting('tts_max_length') || 200,
++ maxQueuePerUser: ctx.getSetting('tts_max_queue_per_user') || 3,
++ maxQueueGlobal: ctx.getSetting('tts_max_queue_global') || 20,
++ defaultVoice: ctx.getSetting('tts_default_voice') || 'gary',
++ // When true (default), chatters without an equipped voice cosmetic get a
++ // stable per-username espeak voice so the streamer can tell who is who.
++ perUserVoices: ctx.getSetting('tts_per_user_voices') !== false,
++ };
++ _settingsCacheTime = now;
++ return _settingsCache;
++}
++
++// ── Per-user deterministic voice derivation ───────────────────
++// Subtle base espeak voices that stay natural/intelligible (no croak/whisper).
++const PER_USER_BASE_VOICES = [
++ 'en', 'en+m1', 'en+m2', 'en+m3', 'en+m4', 'en+m5', 'en+m6', 'en+m7',
++ 'en+f1', 'en+f2', 'en+f3', 'en+f4', 'en+f5',
++];
++
++/**
++ * Derive a stable, subtly-randomized espeak voice from an identity key
++ * (username / anon id / relay handle). Same key → same voice everywhere on the
++ * site, so viewers are recognizable by voice. Params stay in intelligible bands.
++ * @param {string} identityKey
++ * @returns {{ voice: string, pitch: number, speed: number, gap: number }}
++ */
++// Espeak-safe bounds for admin-tunable voice params.
++const VOICE_BOUNDS = { pitch: [0, 99], speed: [80, 450], gap: [0, 20] };
++function _clampVoiceParams(p) {
++ const voices = new Set(PER_USER_BASE_VOICES);
++ const voice = (typeof p.voice === 'string' && /^[a-z0-9+._-]{1,24}$/i.test(p.voice)) ? p.voice : 'en';
++ const clamp = (v, [lo, hi], d) => { v = Math.round(Number(v)); return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : d; };
++ return {
++ voice: voice, // allow any valid-looking espeak voice string (incl. catalog voices like en+f3)
++ pitch: clamp(p.pitch, VOICE_BOUNDS.pitch, 50),
++ speed: clamp(p.speed, VOICE_BOUNDS.speed, 165),
++ gap: clamp(p.gap, VOICE_BOUNDS.gap, 0),
++ _voiceInList: voices.has(voice),
++ };
++}
++// The pure hash-derived (auto-assigned) voice — no override lookup.
++function autoUserVoiceParams(identityKey) {
++ const key = String(identityKey || 'anon').trim().toLowerCase() || 'anon';
++ const h = crypto.createHash('sha256').update(key).digest();
++ // Distinct byte slices → weakly independent axes.
++ const voice = PER_USER_BASE_VOICES[h[0] % PER_USER_BASE_VOICES.length];
++ const pitch = 30 + (h[1] % 46); // 30..75 (default 50)
++ const speed = 150 + (h[2] % 41); // 150..190 wpm (natural range)
++ const gap = h[3] % 4; // 0..3 (subtle cadence variation)
++ return { voice, pitch, speed, gap };
++}
++function deriveUserVoiceParams(identityKey) {
++ const key = String(identityKey || 'anon').trim().toLowerCase() || 'anon';
++ // An admin-set override wins over the auto-assigned voice.
++ try { const ov = db.getTtsVoiceOverride(key); if (ov && ov.voice) return _clampVoiceParams(ov); } catch { /* fall through to auto */ }
++ return autoUserVoiceParams(key);
++}
++
++/**
++ * Synthesize a chat line using a per-user derived espeak voice.
++ * @returns Promise resolving to the same shape as synthesize(), or null.
++ */
++async function synthesizeUserVoice(text, identityKey, username, maxLengthOverride) {
++ const settings = getTTSSettings();
++ if (!settings.enabled) return null;
++ // A channel may raise its TTS length above the site default (hard-capped at 1200).
++ const effMax = Math.min(1200, Math.max(1, Number(maxLengthOverride) || settings.maxLength));
++ const cleanText = sanitize(text, effMax, username);
++ if (!cleanText) return null;
++ const params = deriveUserVoiceParams(identityKey);
++ const voiceDef = { engine: 'espeak-ng', params, name: `Voice-${params.voice}` };
++ // Use a synthetic voiceId so the client can distinguish auto voices if needed.
++ return _synthesizeWithDef(cleanText, voiceDef, `auto:${params.voice}`);
++}
++
++/** Invalidate settings cache (call after admin updates) */
++function invalidateSettingsCache() {
++ _settingsCache = null;
++ _settingsCacheTime = 0;
++}
++
++// ── espeak-ng Detection ───────────────────────────────────────
++let _espeakBinary = null;
++let _espeakDetected = false;
++
++function detectEspeak() {
++ if (_espeakDetected) return _espeakBinary;
++ _espeakDetected = true;
++ for (const bin of ['espeak-ng', 'espeak']) {
++ try {
++ execFileSync(bin, ['--version'], { timeout: 3000, encoding: 'utf8' });
++ _espeakBinary = bin;
++ console.log(`[TTS] Detected ${bin}`);
++ return bin;
++ } catch { /* try next */ }
++ }
++ console.warn('[TTS] espeak-ng not found — espeak voices will be unavailable');
++ return null;
++}
++
++// ── Google Cloud TTS ──────────────────────────────────────────
++let _googleAccessToken = null;
++let _googleTokenExpiry = 0;
++
++function _getGoogleServiceAccount(settings) {
++ const raw = settings.googleServiceAccount;
++ if (!raw) return null;
++ try {
++ return JSON.parse(raw);
++ } catch {
++ // Try as file path
++ try { return JSON.parse(fs.readFileSync(raw, 'utf8')); } catch { return null; }
++ }
++}
++
++function _getGoogleAccessToken(sa) {
++ return new Promise((resolve, reject) => {
++ const now = Math.floor(Date.now() / 1000);
++ if (_googleAccessToken && now < _googleTokenExpiry - 300) {
++ return resolve(_googleAccessToken);
++ }
++ // Build JWT
++ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
++ const claim = Buffer.from(JSON.stringify({
++ iss: sa.client_email,
++ scope: 'https://www.googleapis.com/auth/cloud-platform',
++ aud: 'https://oauth2.googleapis.com/token',
++ iat: now,
++ exp: now + 3600,
++ })).toString('base64url');
++ const sig = crypto.createSign('RSA-SHA256').update(`${header}.${claim}`).sign(sa.private_key, 'base64url');
++ const jwt = `${header}.${claim}.${sig}`;
++
++ const body = `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`;
++ const req = https.request('https://oauth2.googleapis.com/token', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) },
++ }, (res) => {
++ let data = '';
++ res.on('data', c => data += c);
++ res.on('end', () => {
++ try {
++ const parsed = JSON.parse(data);
++ _googleAccessToken = parsed.access_token;
++ _googleTokenExpiry = now + (parsed.expires_in || 3600);
++ resolve(_googleAccessToken);
++ } catch (e) { reject(new Error('Google token parse error')); }
++ });
++ });
++ req.on('error', reject);
++ req.end(body);
++ });
++}
++
++function synthesizeGoogleCloud(text, voiceDef) {
++ return new Promise(async (resolve, reject) => {
++ const settings = getTTSSettings();
++ const sa = _getGoogleServiceAccount(settings);
++ const apiKey = settings.googleApiKey;
++ if (!sa && !apiKey) return reject(new Error('Google Cloud TTS not configured'));
++
++ const cv = voiceDef.cloudVoice;
++ const requestBody = JSON.stringify({
++ input: { text },
++ voice: { languageCode: cv.locale, name: cv.name, ssmlGender: cv.gender },
++ audioConfig: { audioEncoding: 'MP3', speakingRate: 1.0, pitch: 0.0 },
++ });
++
++ let headers = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestBody) };
++ let url = 'https://texttospeech.googleapis.com/v1/text:synthesize';
++
++ if (sa) {
++ try {
++ const token = await _getGoogleAccessToken(sa);
++ headers['Authorization'] = `Bearer ${token}`;
++ } catch (e) { return reject(e); }
++ } else {
++ url += `?key=${apiKey}`;
++ }
++
++ const req = https.request(url, { method: 'POST', headers }, (res) => {
++ let data = '';
++ res.on('data', c => data += c);
++ res.on('end', () => {
++ try {
++ const parsed = JSON.parse(data);
++ if (parsed.audioContent) {
++ resolve({ audio: parsed.audioContent, mimeType: 'audio/mpeg', engine: 'google-cloud', voiceName: cv.name });
++ } else {
++ reject(new Error(parsed.error?.message || 'Google TTS returned no audio'));
++ }
++ } catch (e) { reject(e); }
++ });
++ });
++ req.on('error', reject);
++ req.end(requestBody);
++ });
++}
++
++// ── Amazon Polly ──────────────────────────────────────────────
++function _awsSign({ method, service, region, path: urlPath, body, headers: extraHeaders }) {
++ const settings = getTTSSettings();
++ const accessKey = settings.awsAccessKeyId;
++ const secretKey = settings.awsSecretAccessKey;
++ if (!accessKey || !secretKey) throw new Error('AWS credentials not configured');
++
++ const now = new Date();
++ const dateStamp = now.toISOString().replace(/[:-]|\.\d{3}/g, '').slice(0, 8);
++ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
++ const host = `${service}.${region}.amazonaws.com`;
++
++ const headers = {
++ 'Host': host,
++ 'X-Amz-Date': amzDate,
++ 'Content-Type': 'application/json',
++ ...extraHeaders,
++ };
++
++ const signedHeaderKeys = Object.keys(headers).map(k => k.toLowerCase()).sort().join(';');
++ const canonicalHeaders = Object.keys(headers).map(k => `${k.toLowerCase()}:${headers[k].trim()}`).sort().join('\n') + '\n';
++ const payloadHash = crypto.createHash('sha256').update(body || '').digest('hex');
++
++ const canonicalRequest = [method, urlPath, '', canonicalHeaders, signedHeaderKeys, payloadHash].join('\n');
++ const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
++ const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope,
++ crypto.createHash('sha256').update(canonicalRequest).digest('hex')].join('\n');
++
++ const hmac = (key, data) => crypto.createHmac('sha256', key).update(data).digest();
++ const signingKey = hmac(hmac(hmac(hmac(`AWS4${secretKey}`, dateStamp), region), service), 'aws4_request');
++ const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex');
++
++ headers['Authorization'] = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaderKeys}, Signature=${signature}`;
++ return { host, headers };
++}
++
++function synthesizePolly(text, voiceDef) {
++ return new Promise((resolve, reject) => {
++ const settings = getTTSSettings();
++ const pv = voiceDef.pollyVoice;
++
++ let bodyObj = {
++ OutputFormat: 'mp3',
++ Text: text,
++ TextType: 'text',
++ VoiceId: pv.voiceId,
++ Engine: pv.engine,
++ };
++ if (pv.locale) bodyObj.LanguageCode = pv.locale;
++ const body = JSON.stringify(bodyObj);
++
++ let signed;
++ try {
++ signed = _awsSign({
++ method: 'POST', service: 'polly', region: settings.awsRegion,
++ path: '/v1/speech', body,
++ });
++ } catch (e) { return reject(e); }
++
++ const req = https.request({
++ hostname: signed.host,
++ path: '/v1/speech',
++ method: 'POST',
++ headers: { ...signed.headers, 'Content-Length': Buffer.byteLength(body) },
++ }, (res) => {
++ const chunks = [];
++ res.on('data', c => chunks.push(c));
++ res.on('end', () => {
++ const buf = Buffer.concat(chunks);
++ if (res.statusCode === 200) {
++ resolve({ audio: buf.toString('base64'), mimeType: 'audio/mpeg', engine: 'amazon-polly', voiceName: pv.voiceId });
++ } else {
++ reject(new Error(`Polly HTTP ${res.statusCode}: ${buf.toString('utf8').slice(0, 200)}`));
++ }
++ });
++ });
++ req.on('error', reject);
++ req.end(body);
++ });
++}
++
++// ── espeak-ng WAV synthesis ───────────────────────────────────
++function synthesizeEspeak(text, voiceDef) {
++ return new Promise((resolve, reject) => {
++ const bin = detectEspeak();
++ if (!bin) return reject(new Error('espeak-ng not installed'));
++
++ const p = voiceDef.params || {};
++ const tmpFile = path.join(os.tmpdir(), `openvibe-tts-${Date.now()}-${Math.random().toString(36).slice(2)}.wav`);
++ const args = ['-v', p.voice || 'en', '-w', tmpFile];
++ if (p.pitch != null) args.push('-p', String(p.pitch));
++ if (p.speed != null) args.push('-s', String(p.speed));
++ if (p.gap != null) args.push('-g', String(p.gap));
++ // Feed the text over stdin, NOT as an argument: espeak-ng's command-line text buffer is
++ // 1000 bytes, so anything longer was silently cut at exactly character 1000 no matter
++ // what the channel's TTS limit said. stdin has no such cap.
++ args.push('--stdin');
++
++ const child = execFile(bin, args, { timeout: 30000 }, (err) => {
++ if (err) {
++ try { fs.unlinkSync(tmpFile); } catch {}
++ return reject(err);
++ }
++ // Ship MP3, not raw WAV: a 1200-character line is ~3 MB of WAV (4 MB as base64 in
++ // the chat socket frame) delivered to EVERY viewer; the same audio is ~200 KB as MP3.
++ // Falls back to the WAV if ffmpeg is missing or fails.
++ const mp3File = tmpFile.replace(/\.wav$/, '.mp3');
++ execFile('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', tmpFile, '-ac', '1', '-ar', '22050', '-codec:a', 'libmp3lame', '-b:a', '48k', mp3File], { timeout: 30000 }, (ffErr) => {
++ try {
++ let audio, mimeType;
++ if (!ffErr && fs.existsSync(mp3File) && fs.statSync(mp3File).size > 200) { audio = fs.readFileSync(mp3File); mimeType = 'audio/mpeg'; }
++ else { audio = fs.readFileSync(tmpFile); mimeType = 'audio/wav'; }
++ try { fs.unlinkSync(tmpFile); } catch {}
++ try { fs.unlinkSync(mp3File); } catch {}
++ resolve({ audio: audio.toString('base64'), mimeType, engine: 'espeak-ng', voiceName: p.voice });
++ } catch (e) { reject(e); }
++ });
++ });
++ try { child.stdin.on('error', () => {}); child.stdin.end(String(text) + '\n'); } catch (e) { reject(e); }
++ });
++}
++
++// ── Sanitize text ─────────────────────────────────────────────
++/**
++ * Replace URLs with TTS-friendly descriptions.
++ * "check youtube.com/watch?v=abc" → "check (link to youtube)"
++ */
++// Common gTLDs; every 2-letter TLD is accepted as a country code. Mirrors chat-shared.js.
++const _TTS_TLDS = new Set('com net org io co gg tv me app dev xyz info biz live tech online site store blog news wiki gov edu mil int tools quest link click stream fm to ly gl sh cc ws pro club shop art design games game media cloud email fun life world today space website host page video chat social band lol wtf money'.split(/\s+/));
++function _realTld(tld) { tld = String(tld || '').toLowerCase(); return tld.length === 2 || _TTS_TLDS.has(tld); }
++// Shorten a username for speech: collapse long same-character runs ("WWWW…" → "W") and cap length.
++function ttsUsername(name) {
++ if (!name) return name;
++ let n = String(name).replace(/(.)\1{3,}/g, '$1');
++ if (n.length > 18) n = n.slice(0, 18);
++ return n;
++}
++/**
++ * Replace only REAL links with a spoken description; leave non-link text (typos like
++ * "I.gkt" whose ".gkt" is not a real TLD) untouched so TTS doesn't announce fake links.
++ */
++function replaceUrlsForTTS(text, username) {
++ const uname = ttsUsername(username);
++ return String(text == null ? '' : text).replace(
++ /((?:https?:\/\/)?(?:www\.)?)([a-z0-9][-a-z0-9]*(?:\.[a-z]{2,})+)([^\s]*)/gi,
++ (match, pre, domain) => {
++ const hasScheme = /https?:\/\//i.test(pre) || /^www\./i.test(pre);
++ const parts = domain.split('.');
++ if (!hasScheme && !_realTld(parts[parts.length - 1])) return match; // not a real link
++ const siteName = parts.length > 2 ? parts[parts.length - 2] : parts[0];
++ return uname ? `(${uname} sent a link to ${siteName})` : `(link to ${siteName})`;
++ }
++ );
++}
++
++function sanitize(text, maxLen, username) {
++ let clean = String(text || '').replace(/[<>]/g, '').replace(/[\x00-\x1f]/g, ' ').trim();
++ clean = replaceUrlsForTTS(clean, username);
++ if (maxLen && clean.length > maxLen) clean = clean.slice(0, maxLen);
++ return clean;
++}
++
++// ── Main synthesis function ───────────────────────────────────
++/**
++ * Synthesize TTS audio for a chat message.
++ * @param {string} text - The text to speak
++ * @param {string} [voiceId] - Voice ID from VOICE_CATALOG (defaults to site setting)
++ * @param {string} [username] - Username for link replacement context
++ * @returns {Promise<{audio: string, mimeType: string, engine: string, voiceName: string, voiceId: string} | null>}
++ * Returns null if TTS is disabled or voice is browser-only
++ */
++async function synthesize(text, voiceId, username, maxLengthOverride) {
++ const settings = getTTSSettings();
++ if (!settings.enabled) return null;
++
++ // A channel may raise its TTS length above the site default (hard-capped at 1200).
++ const effMax = Math.min(1200, Math.max(1, Number(maxLengthOverride) || settings.maxLength));
++ const cleanText = sanitize(text, effMax, username);
++ if (!cleanText) return null;
++
++ const vid = voiceId || settings.defaultVoice;
++ const voiceDef = VOICE_CATALOG[vid];
++ if (!voiceDef) {
++ // Fallback to default
++ const fallback = VOICE_CATALOG[settings.defaultVoice] || VOICE_CATALOG.gary;
++ return _synthesizeWithDef(cleanText, fallback, settings.defaultVoice);
++ }
++
++ return _synthesizeWithDef(cleanText, voiceDef, vid);
++}
++
++async function _synthesizeWithDef(text, voiceDef, voiceId) {
++ const settings = getTTSSettings();
++
++ // "Browser" voices were designed for client-side Web Speech synthesis, but this
++ // pipeline renders TTS server-side and broadcasts the audio — so a browser voice
++ // produced no sound at all. Render them with espeak instead, mapping the Web Speech
++ // pitch (0–2, 1=normal) and rate (~0.1–10, 1=normal) onto espeak's pitch/speed.
++ if (voiceDef.engine === 'browser') {
++ const bv = voiceDef.browserVoice || {};
++ const espeakDef = {
++ engine: 'espeak-ng',
++ name: voiceDef.name,
++ params: {
++ voice: bv.voice || 'en',
++ pitch: Math.max(0, Math.min(99, Math.round((bv.pitch != null ? bv.pitch : 1) * 50))),
++ speed: Math.max(80, Math.min(400, Math.round((bv.rate != null ? bv.rate : 1) * 175))),
++ },
++ };
++ try {
++ return { ...(await synthesizeEspeak(text, espeakDef)), voiceId };
++ } catch (err) {
++ console.error(`[TTS] Browser-voice espeak render failed (${voiceId}):`, err.message);
++ return null;
++ }
++ }
++
++ try {
++ switch (voiceDef.engine) {
++ case 'google-cloud': {
++ if (!settings.googleApiKey && !settings.googleServiceAccount) {
++ // Fall back to espeak
++ return _espeakFallback(text, voiceId);
++ }
++ return { ...(await synthesizeGoogleCloud(text, voiceDef)), voiceId };
++ }
++ case 'amazon-polly': {
++ if (!settings.awsAccessKeyId) {
++ return _espeakFallback(text, voiceId);
++ }
++ return { ...(await synthesizePolly(text, voiceDef)), voiceId };
++ }
++ case 'espeak-ng':
++ return { ...(await synthesizeEspeak(text, voiceDef)), voiceId };
++ default:
++ return _espeakFallback(text, voiceId);
++ }
++ } catch (err) {
++ console.error(`[TTS] Synthesis error (${voiceDef.engine}/${voiceId}):`, err.message);
++ // Try espeak fallback on cloud failure
++ if (voiceDef.engine !== 'espeak-ng') {
++ try { return _espeakFallback(text, voiceId); } catch {}
++ }
++ return null;
++ }
++}
++
++function _espeakFallback(text, voiceId) {
++ const fallbackDef = VOICE_CATALOG.gary;
++ return synthesizeEspeak(text, fallbackDef).then(result => ({ ...result, voiceId, fallback: true }));
++}
++
++// ── Queue System (per-stream) ─────────────────────────────────
++// TTS queue is managed per-stream in the chat server.
++// This module just provides the max limits.
++function getQueueLimits() {
++ const settings = getTTSSettings();
++ return {
++ maxGlobal: settings.maxQueueGlobal,
++ maxPerUser: settings.maxQueuePerUser,
++ maxLength: settings.maxLength,
++ };
++}
++
++// ── Available voices for a given configuration ────────────────
++function getAvailableVoices() {
++ const settings = getTTSSettings();
++ const voices = [];
++ for (const [id, v] of Object.entries(VOICE_CATALOG)) {
++ let available = true;
++ if (v.engine === 'google-cloud' && !settings.googleApiKey && !settings.googleServiceAccount) {
++ available = false;
++ }
++ if (v.engine === 'amazon-polly' && !settings.awsAccessKeyId) {
++ available = false;
++ }
++ if (v.engine === 'espeak-ng' && !detectEspeak()) {
++ available = false;
++ }
++ voices.push({ id, ...v, available });
++ }
++ return voices;
++}
++
++// ── Exports ───────────────────────────────────────────────────
++// Synthesize an arbitrary espeak param set (admin voice preview).
++async function synthesizeWithParams(text, params) {
++ const p = _clampVoiceParams(params || {});
++ const voiceDef = { engine: 'espeak-ng', params: p, name: `Voice-${p.voice}` };
++ return _synthesizeWithDef(sanitize(text, getTTSSettings().maxLength, null), voiceDef, `preview:${p.voice}`);
++}
++
++module.exports = {
++ VOICE_CATALOG,
++ RARITY_COLORS,
++ synthesize,
++ synthesizeUserVoice,
++ synthesizeWithParams,
++ deriveUserVoiceParams,
++ autoUserVoiceParams,
++ clampVoiceParams: _clampVoiceParams,
++ PER_USER_BASE_VOICES,
++ VOICE_BOUNDS,
++ sanitize,
++ getTTSSettings,
++ invalidateSettingsCache,
++ getQueueLimits,
++ getAvailableVoices,
++ detectEspeak,
++};
ddiff --git a/server/chat/tts-routes.js b/server/chat/tts-routes.js
nnew file mode 100644
iindex 0000000..fdcc35b
---- /dev/null
++++ b/server/chat/tts-routes.js
@@@ -0,0 +1,180 @@
++/**
++ * OpenVibe.Chat — TTS API Routes (moved from OpenVibe.Live server/chat/tts-routes.js, W6)
++ *
++ * TTS settings stay rows of Live's site_settings in this wave: reads come through live-context,
++ * writes go to Live (which re-checks admin/owner). Preview clips are stashed here; clips Live
++ * stashed (arena voice, the mod voice preview) are fetched from Live under the same URL.
++ *
++ * Public routes (authenticated users):
++ * GET /api/tts/voices — List available voices
++ * GET /api/tts/settings — Get TTS config for client
++ *
++ * Admin routes:
++ * GET /api/tts/admin/settings — Full TTS config with API keys
++ * PUT /api/tts/admin/settings — Update TTS config
++ */
++const express = require('express');
++const router = express.Router();
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++const { requireAuth, requireAdmin } = require('../auth/auth');
++const { isOwner } = require('../auth/permissions');
++const ttsEngine = require('./tts-engine');
++const ctx = require('../live-context');
++const config = require('../config');
++
++// ── Same-origin preview clips (Live used its arena voice cache; Chat keeps its own) ──
++const CLIP_DIR = path.join(config.cacheDir, 'tts-clips');
++const CLIP_TTL_MS = 24 * 60 * 60 * 1000;
++const CLIP_EXT = { 'audio/mpeg': 'mp3', 'audio/wav': 'wav', 'audio/ogg': 'ogg', 'audio/webm': 'webm' };
++function stashClip(audioBase64, mimeType) {
++ try {
++ const buf = Buffer.from(String(audioBase64 || ''), 'base64');
++ if (!buf.length) return null;
++ fs.mkdirSync(CLIP_DIR, { recursive: true });
++ const file = `c-${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32)}.${CLIP_EXT[mimeType] || 'mp3'}`;
++ const full = path.join(CLIP_DIR, file);
++ if (!fs.existsSync(full)) fs.writeFileSync(full, buf);
++ return { file };
++ } catch { return null; }
++}
++function cachedClip(file) {
++ const name = path.basename(String(file || ''));
++ if (!/^c-[0-9a-f]{32}\.(mp3|wav|ogg|webm)$/.test(name)) return null;
++ const full = path.join(CLIP_DIR, name);
++ try {
++ const st = fs.statSync(full);
++ if (Date.now() - st.mtimeMs > CLIP_TTL_MS) return null;
++ } catch { return null; }
++ const ext = name.split('.').pop();
++ return { path: full, mimeType: Object.keys(CLIP_EXT).find((k) => CLIP_EXT[k] === ext) || 'audio/mpeg' };
++}
++
++// TTS credential fields/keys are owner-only. Admins may still manage the
++// non-secret TTS config (enabled, provider, limits, default voice).
++const TTS_SECRET_FIELDS = ['googleApiKey', 'googleServiceAccount', 'awsAccessKeyId', 'awsSecretAccessKey'];
++const TTS_SECRET_KEYS = new Set([
++ 'tts_google_api_key', 'tts_google_service_account',
++ 'tts_aws_access_key_id', 'tts_aws_secret_access_key',
++]);
++function maskTtsSecret(v) {
++ if (!v) return v;
++ const s = String(v);
++ return s.length <= 8 ? '••••••••' : '••••' + s.slice(-4);
++}
++
++// ── Public: Get available voices ──────────────────────────────
++router.get('/voices', (req, res) => {
++ try {
++ const voices = ttsEngine.getAvailableVoices();
++ res.json({ voices });
++ } catch (e) {
++ res.status(500).json({ error: e.message });
++ }
++});
++
++// ── Public: Get TTS settings for client ───────────────────────
++router.get('/settings', async (req, res) => {
++ try {
++ await ctx.ensureSettings();
++ const settings = ttsEngine.getTTSSettings();
++ // Don't expose API keys to the client
++ res.json({
++ enabled: settings.enabled,
++ provider: settings.provider,
++ googleConfigured: !!(settings.googleApiKey || settings.googleServiceAccount),
++ pollyConfigured: !!settings.awsAccessKeyId,
++ espeakAvailable: !!ttsEngine.detectEspeak(),
++ maxLength: settings.maxLength,
++ maxQueuePerUser: settings.maxQueuePerUser,
++ maxQueueGlobal: settings.maxQueueGlobal,
++ defaultVoice: settings.defaultVoice,
++ });
++ } catch (e) {
++ res.status(500).json({ error: e.message });
++ }
++});
++
++// ── Admin: Get full TTS config ────────────────────────────────
++router.get('/admin/settings', requireAuth, requireAdmin, async (req, res) => {
++ try {
++ await ctx.ensureSettings();
++ const settings = ttsEngine.getTTSSettings();
++ // Mask credential fields for non-owners.
++ if (!isOwner(req.user)) {
++ for (const f of TTS_SECRET_FIELDS) {
++ if (settings[f]) settings[f] = maskTtsSecret(settings[f]);
++ }
++ settings._secretsRedacted = true;
++ }
++ res.json({ settings });
++ } catch (e) {
++ res.status(500).json({ error: e.message });
++ }
++});
++
++// ── Admin: Update TTS config ──────────────────────────────────
++router.put('/admin/settings', requireAuth, requireAdmin, async (req, res) => {
++ try {
++ const allowed = [
++ 'tts_enabled', 'tts_provider',
++ 'tts_google_api_key', 'tts_google_service_account',
++ 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region',
++ 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global',
++ 'tts_default_voice',
++ ];
++ const updates = req.body.settings || req.body;
++ const owner = isOwner(req.user);
++ let count = 0;
++ const accepted = {};
++ for (const [key, value] of Object.entries(updates)) {
++ if (!allowed.includes(key)) continue;
++ // Credential keys are owner-only.
++ if (TTS_SECRET_KEYS.has(key) && !owner) continue;
++ // Never overwrite a stored secret with a masked placeholder.
++ if (TTS_SECRET_KEYS.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;
++ accepted[key] = value;
++ count++;
++ }
++ // site_settings is Live's: Live writes them (and applies the same admin/owner rules again).
++ if (count) await ctx.setSettings(accepted, req.user.id);
++ ttsEngine.invalidateSettingsCache();
++ res.json({ success: true, updated: count });
++ } catch (e) {
++ res.status(500).json({ error: e.message });
++ }
++});
++
++// ── Admin: Test voice synthesis ───────────────────────────────
++router.post('/admin/test', requireAuth, requireAdmin, async (req, res) => {
++ try {
++ const { voiceId, text } = req.body;
++ const result = await ttsEngine.synthesize(text || 'This is a TTS test from OpenVibe.Live', voiceId);
++ if (!result) return res.status(400).json({ error: 'Voice unavailable or TTS disabled' });
++ // Same-origin URL alongside the base64: plays under media-src 'self' even where the
++ // browser (or an overzealous shield) refuses blob:/data: audio.
++ try { const stashed = stashClip(result.audio, result.mimeType); if (stashed) result.url = `/api/tts/audio/${stashed.file}`; } catch { /* base64 still works */ }
++ res.json(result);
++ } catch (e) {
++ res.status(500).json({ error: e.message });
++ }
++});
++
++// Cached one-off clips (admin tests, previews) — streamed same-origin so no blob:/data: is needed.
++router.get('/audio/:file', async (req, res) => {
++ try {
++ const hit = cachedClip(req.params.file);
++ if (hit) {
++ res.set({ 'Content-Type': hit.mimeType, 'Cache-Control': 'private, max-age=86400' });
++ return fs.createReadStream(hit.path).pipe(res);
++ }
++ // Not ours: Live's arena voice cache (mod voice previews, arena clips).
++ const live = await ctx.fetchTtsAudio(path.basename(String(req.params.file || '')));
++ if (!live) return res.status(404).json({ error: 'No such clip' });
++ res.set({ 'Content-Type': live.mimeType, 'Cache-Control': 'private, max-age=86400' });
++ res.end(live.body);
++ } catch (e) { res.status(500).json({ error: e.message }); }
++});
++
++module.exports = router;
ddiff --git a/server/chat/unsafe-words.txt b/server/chat/unsafe-words.txt
nnew file mode 100644
iindex 0000000..262bfe3
---- /dev/null
++++ b/server/chat/unsafe-words.txt
@@@ -0,0 +1,14 @@
++# OpenVibe.Live Unsafe Words List
++# Lines starting with # are comments
++# One word/phrase per line (case-insensitive)
++# These are filtered in standard mode chat
++# OpSec-focused: protect stealth campers' locations
++
++# Location disclosure
++exact location
++gps coordinates
++street address
++what address
++where are you camping
++camp location
++your coordinates
ddiff --git a/server/chat/word-filter.js b/server/chat/word-filter.js
nnew file mode 100644
iindex 0000000..f9c9352
---- /dev/null
++++ b/server/chat/word-filter.js
@@@ -0,0 +1,104 @@
++/**
++ * OpenVibe.Chat — Word Filter (Safe/Unsafe) (moved from OpenVibe.Live, W6)
++ *
++ * Filters chat messages for unsafe content.
++ * Two modes: SAFE (whitelist only) and STANDARD (blacklist).
++ */
++const fs = require('fs');
++const path = require('path');
++
++class WordFilter {
++ constructor() {
++ this.unsafeWords = new Set();
++ this.unsafePatterns = [];
++ this.loaded = false;
++ }
++
++ /**
++ * Load unsafe words from file
++ */
++ load(filePath) {
++ try {
++ const fullPath = path.resolve(filePath || './server/chat/unsafe-words.txt');
++ if (fs.existsSync(fullPath)) {
++ const content = fs.readFileSync(fullPath, 'utf8');
++ content.split('\n').forEach(line => {
++ const word = line.trim().toLowerCase();
++ if (word && !word.startsWith('#')) {
++ this.unsafeWords.add(word);
++ // Also create regex pattern for partial matches
++ this.unsafePatterns.push(new RegExp(`\\b${this.escapeRegex(word)}\\b`, 'gi'));
++ }
++ });
++ this.loaded = true;
++ console.log(`[Filter] Loaded ${this.unsafeWords.size} unsafe words`);
++ } else {
++ console.log('[Filter] No unsafe words file found, using defaults');
++ this.loadDefaults();
++ }
++ } catch (err) {
++ console.error('[Filter] Failed to load word filter:', err.message);
++ this.loadDefaults();
++ }
++ }
++
++ loadDefaults() {
++ // Minimal default filter — add more as needed
++ const defaults = [
++ // Location identifiers that could compromise OpSec
++ 'address', 'coordinates', 'gps location', 'exact location',
++ ];
++ defaults.forEach(w => this.unsafeWords.add(w.toLowerCase()));
++ this.loaded = true;
++ }
++
++ escapeRegex(str) {
++ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
++ }
++
++ /**
++ * Check if a message contains unsafe words
++ * Returns { safe: boolean, filtered: string, matches: string[] }
++ */
++ check(message) {
++ if (!this.loaded) this.loadDefaults();
++
++ const lower = message.toLowerCase();
++ const matches = [];
++
++ for (const word of this.unsafeWords) {
++ if (lower.includes(word)) {
++ matches.push(word);
++ }
++ }
++
++ if (matches.length === 0) {
++ return { safe: true, filtered: message, matches: [] };
++ }
++
++ // Replace unsafe words with asterisks
++ let filtered = message;
++ for (const pattern of this.unsafePatterns) {
++ filtered = filtered.replace(pattern, (match) => '*'.repeat(match.length));
++ }
++
++ return { safe: false, filtered, matches };
++ }
++
++ /**
++ * Check for obvious spam patterns
++ */
++ isSpam(message) {
++ // Repeated characters
++ if (/(.)\1{10,}/.test(message)) return true;
++ // Too many caps
++ if (message.length > 10 && (message.replace(/[^A-Z]/g, '').length / message.length) > 0.8) return true;
++ // URL spam
++ const urlCount = (message.match(/https?:\/\//g) || []).length;
++ if (urlCount > 3) return true;
++
++ return false;
++ }
++}
++
++module.exports = new WordFilter();
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..6f75d93
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,76 @@
++/**
++ * OpenVibe.Chat — configuration. Everything comes from the environment (.env in development,
++ * /etc/openvibe/chat.env in production); see .env.example for the documented list.
++ */
++'use strict';
++
++require('dotenv').config();
++
++const path = require('path');
++
++const isProduction = (process.env.NODE_ENV || 'development') === 'production';
++const port = process.env.PORT != null && process.env.PORT !== '' && Number.isFinite(parseInt(process.env.PORT, 10)) ? parseInt(process.env.PORT, 10) : 4400;
++const strip = (u) => String(u || '').replace(/\/+$/, '');
++const bool = (v, d = false) => (v == null || v === '' ? d : /^(1|true|yes|on)$/i.test(String(v)));
++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
++
++module.exports = {
++ port,
++ host: process.env.HOST || '127.0.0.1',
++ nodeEnv: process.env.NODE_ENV || 'development',
++ isProduction,
++
++ // Public URL the browser uses. Chat is served on Live's origin (nginx routes /ws/chat and the
++ // chat REST prefixes here), so this is https://openvibe.live in production.
++ baseUrl: strip(process.env.BASE_URL || (isProduction ? 'https://openvibe.live' : 'http://localhost:3000')),
++ // Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2), same as Live.
++ trustProxy: process.env.TRUST_PROXY != null ? Number(process.env.TRUST_PROXY) : 2,
++ // Extra browser origins allowed for WebSocket upgrades and credentialed CORS, on top of the
++ // ones Live allows (BASE_URL + www variant, the Network, openvibe.games, openvibe.tools).
++ extraOrigins: String(process.env.ALLOWED_ORIGINS || '').split(',').map((s) => strip(s.trim())).filter(Boolean),
++
++ // Chat's own SQLite database (systemd StateDirectory openvibe-chat).
++ dbPath: process.env.CHAT_DB_PATH || './data/chat.db',
++
++ // OpenVibe.Network — issuer of user JWTs and service tokens.
++ networkUrl: strip(process.env.OV_NETWORK_URL || 'https://openvibe.network'),
++ networkInternalUrl: strip(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ // PEM with the Network's RS256 public key. When unset or unreadable the key is fetched from
++ // ${networkInternalUrl}/api/.well-known/jwks.
++ networkPublicKeyPath: process.env.OV_NETWORK_PUBLIC_KEY || '',
++ // Chat's service principal (client `chat` in the Network's oauth_clients).
++ oauth: {
++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'chat',
++ clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '',
++ },
++
++ // OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.
++ live: {
++ internalUrl: strip(process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
++ audience: 'openvibe.live',
++ // Chat's read-mirror of its tables into Live (rollback path + Live's own readers). Only
++ // switch on at cutover; Live refuses mirror writes unless it runs with CHAT_AUTHORITY=chat.
++ mirror: bool(process.env.LIVE_MIRROR, false),
++ mirrorIntervalMs: int(process.env.LIVE_MIRROR_INTERVAL_MS, 1000),
++ requestTimeoutMs: int(process.env.LIVE_TIMEOUT_MS, 4000),
++ },
++
++ // OpenVibe.Events — the outbox relays only when EVENTS_URL is set.
++ events: {
++ url: strip(process.env.EVENTS_URL || ''),
++ intervalMs: int(process.env.EVENTS_RELAY_INTERVAL_MS, 5000),
++ },
++
++ // Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows
++ // hold absolute paths into it, and Live's donation alerts read the alert sounds from there.
++ sounds: {
++ path: process.env.SOUNDS_PATH || './data/sounds',
++ maxSizeKb: int(process.env.MAX_SOUND_SIZE_KB, 2048),
++ defaultMaxSeconds: int(process.env.SOUND_DEFAULT_MAX_SECONDS, 10),
++ maxPerChannel: int(process.env.MAX_SOUNDS_PER_CHANNEL, 150),
++ maxPerUploaderPerChannel: int(process.env.MAX_SOUNDS_PER_UPLOADER_PER_CHANNEL, 10),
++ },
++ // Where Chat keeps its own caches (101soundboards clips, TTS preview clips).
++ cacheDir: process.env.CHAT_CACHE_DIR || path.join(path.dirname(process.env.CHAT_DB_PATH || './data/chat.db'), 'cache'),
++ // (SITE_URL — the public origin in /paste links — is read where Live read it, as in Live.)
++};
ddiff --git a/server/db/database.js b/server/db/database.js
nnew file mode 100644
iindex 0000000..20a2df5
---- /dev/null
++++ b/server/db/database.js
@@@ -0,0 +1,840 @@
++/**
++ * OpenVibe.Chat — database access (better-sqlite3).
++ *
++ * The chat functions below are OpenVibe.Live's (server/db/database.js) moved as they were:
++ * same names, same arguments, same SQL, so Live's forwarded calls (server/bridge/) and the moved
++ * chat modules behave exactly as before. What changed:
++ * - Live-owned rows (users, streams) are read from the ctx_* projections that
++ * server/live-context.js maintains, never from Live's database.
++ * - New rows record the author's Network subject (subject_id columns) next to the Live id.
++ * - Writes that are events (a chat message, a DM, a moderation action) add their envelope to
++ * events_outbox in the same transaction (server/events/outbox.js).
++ */
++'use strict';
++
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++const config = require('../config');
++
++let db = null;
++let _dbPath = null;
++const _stmts = new Map();
++
++// Tables Chat writes from the cutover on; Live keeps a read mirror of each (server/bridge/live-mirror.js).
++const CHAT_TABLES = {
++ chat_messages: ['id'],
++ dm_conversations: ['id'],
++ dm_participants: ['id'],
++ dm_messages: ['id'],
++ dm_blocks: ['id'],
++ tts_voice_overrides: ['identity_key'],
++ channel_sounds: ['id'],
++ relay_users: ['platform', 'username'],
++ hidden_relay_users: ['id'],
++ pending_ip_messages: ['id'],
++ stream_first_chats: ['chatter_key', 'channel_user_id'],
++ moderation_actions: ['id'],
++};
++// Chat-target tables Live still writes in this wave: imported here, read through live-context.
++const STAGED_TABLES = {
++ channel_moderators: 'written by Live /api/channels (channel-mod-routes) until that route moves',
++ channel_moderation_settings: 'written by Live /api/channels and the dashboard; /slow and alert sounds go through Live',
++ emotes: 'written by Live /api/emotes and its Media asset-sync',
++ user_tags: 'written by Live game/tags (shop); chat reads tags through live-context',
++ chat_ai_summaries: 'written by Live server/ai/chat-ai.js',
++ chat_timeline_events: 'written by Live server/ai/chat-ai.js',
++};
++
++function getDb() {
++ if (!db) {
++ _dbPath = path.resolve(config.dbPath);
++ fs.mkdirSync(path.dirname(_dbPath), { recursive: true });
++ db = new Database(_dbPath);
++ db.pragma('journal_mode = WAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++ try {
++ db.pragma('synchronous = NORMAL');
++ db.pragma('cache_size = -65536');
++ db.pragma('temp_store = MEMORY');
++ } catch (e) { console.warn('[DB] pragma tuning:', e.message); }
++ }
++ return db;
++}
++
++function stmt(sql) {
++ let s = _stmts.get(sql);
++ if (!s) {
++ s = getDb().prepare(sql);
++ if (_stmts.size > 500) _stmts.clear();
++ _stmts.set(sql, s);
++ }
++ return s;
++}
++
++function run(sql, params = []) {
++ return stmt(sql).run(...(Array.isArray(params) ? params : [params]));
++}
++
++function get(sql, params = []) {
++ return stmt(sql).get(...(Array.isArray(params) ? params : [params]));
++}
++
++function all(sql, params = []) {
++ return stmt(sql).all(...(Array.isArray(params) ? params : [params]));
++}
++
++function transaction(fn) {
++ return getDb().transaction(fn)();
++}
++
++function close() {
++ if (db) { try { db.close(); } catch { /* */ } }
++ db = null;
++ _stmts.clear();
++}
++
++/**
++ * Create the schema. opts.captureMirror installs this connection's TEMP triggers that record
++ * every change to Chat's tables for the Live mirror — the service does; the importer does not,
++ * so imported rows (which came from Live) are never sent back.
++ */
++function initDb({ captureMirror = false } = {}) {
++ const d = getDb();
++ d.exec(fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'));
++ const upsertAuth = d.prepare('INSERT INTO table_authority (table_name, authority, note) VALUES (?, ?, ?) ON CONFLICT(table_name) DO UPDATE SET authority = excluded.authority, note = excluded.note');
++ for (const t of Object.keys(CHAT_TABLES)) upsertAuth.run(t, 'chat', 'Chat writes; Live keeps a read mirror');
++ for (const [t, note] of Object.entries(STAGED_TABLES)) upsertAuth.run(t, 'live', note);
++ if (captureMirror) installMirrorTriggers();
++ return d;
++}
++
++function installMirrorTriggers() {
++ const d = getDb();
++ for (const [table, pk] of Object.entries(CHAT_TABLES)) {
++ const obj = (alias) => `json_object(${pk.map((c) => `'${c}', ${alias}.${c}`).join(', ')})`;
++ d.exec(`
++ CREATE TEMP TRIGGER IF NOT EXISTS mirror_${table}_ins AFTER INSERT ON main.${table}
++ BEGIN INSERT INTO live_mirror_outbox (tbl, op, pk) VALUES ('${table}', 'upsert', ${obj('NEW')}); END;
++ CREATE TEMP TRIGGER IF NOT EXISTS mirror_${table}_upd AFTER UPDATE ON main.${table}
++ BEGIN INSERT INTO live_mirror_outbox (tbl, op, pk) VALUES ('${table}', 'upsert', ${obj('NEW')}); END;
++ CREATE TEMP TRIGGER IF NOT EXISTS mirror_${table}_del AFTER DELETE ON main.${table}
++ BEGIN INSERT INTO live_mirror_outbox (tbl, op, pk) VALUES ('${table}', 'delete', ${obj('OLD')}); END;
++ `);
++ }
++}
++
++// ── Subjects ──────────────────────────────────────────────────
++// Live user id → Network subject (usr_…) from the ctx_users projection. New rows carry it so a
++// later wave can drop Live ids.
++function subjectFor(userId) {
++ if (!userId) return null;
++ try { return get('SELECT subject_id FROM ctx_users WHERE id = ?', [userId])?.subject_id || null; } catch { return null; }
++}
++
++function _outbox() { return require('../events/outbox'); }
++function _ctx() { return require('../live-context'); }
++
++// ── Chat messages ─────────────────────────────────────────────
++
++function saveChatMessage({ stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata }) {
++ // channel_user_id = the broadcaster's user id — set for all channel/stream
++ // messages so a streamer's chat history survives across sessions AND offline
++ // periods (independent of the live-session stream row's lifetime).
++ let chanUid = channel_user_id || null;
++ if (!chanUid && stream_id) { try { chanUid = _ctx().getStreamById(stream_id)?.user_id || null; } catch { /* ignore */ } }
++ const metaStr = metadata == null ? null : (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
++ const subject = subjectFor(user_id);
++ return transaction(() => {
++ const res = run(
++ `INSERT INTO chat_messages (stream_id, channel_user_id, user_id, anon_id, username, message, message_type, is_global, reply_to_id, source_platform, auto_delete_at, metadata, subject_id)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [stream_id, chanUid, user_id || null, anon_id || null, username, message, message_type || 'chat', is_global ? 1 : 0, reply_to_id || null, source_platform || null, auto_delete_at || null, metaStr, subject]
++ );
++ _outbox().enqueue({
++ event_type: 'chat.message.created',
++ visibility: 'public',
++ actorSubject: subject,
++ subject: { type: 'chat_message', id: String(res.lastInsertRowid) },
++ payload: {
++ message_id: Number(res.lastInsertRowid),
++ room: stream_id || chanUid ? { type: 'channel', channel_user_id: chanUid, stream_id: stream_id || null } : { type: 'global' },
++ message_type: message_type || 'chat',
++ user_id: user_id || null,
++ user_subject: subject,
++ anon_id: anon_id || null,
++ username: username || null,
++ text: String(message || '').slice(0, 2000),
++ source_platform: source_platform || null,
++ reply_to_id: reply_to_id || null,
++ },
++ });
++ return res;
++ });
++}
++
++function searchChatMessages({ query, userId, anonId, username, streamId, limit = 50, offset = 0 }) {
++ let sql = `SELECT cm.*, u.display_name, u.username as u_username, u.role, u.avatar_url, u.profile_color
++ FROM chat_messages cm
++ LEFT JOIN ctx_users u ON cm.user_id = u.id
++ WHERE cm.is_deleted = 0
++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
++ const params = [];
++
++ if (query) {
++ sql += ` AND cm.message LIKE ?`;
++ params.push(`%${query}%`);
++ }
++ if (userId) {
++ sql += ` AND cm.user_id = ?`;
++ params.push(userId);
++ }
++ if (anonId) {
++ sql += ` AND cm.anon_id = ?`;
++ params.push(anonId);
++ }
++ if (username) {
++ sql += ` AND LOWER(u.username) LIKE ?`;
++ params.push(`%${username.toLowerCase()}%`);
++ }
++ if (streamId) {
++ sql += ` AND cm.stream_id = ?`;
++ params.push(streamId);
++ }
++
++ const countSql = sql.replace(/SELECT cm\.\*.*FROM/, 'SELECT COUNT(*) as c FROM');
++ const total = get(countSql, params)?.c || 0;
++
++ sql += ` ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;
++ params.push(limit, offset);
++
++ return { messages: all(sql, params), total };
++}
++
++function getUserChatHistory(userId, limit = 50, offset = 0) {
++ const sql = `SELECT cm.*, s.title as stream_title
++ FROM chat_messages cm
++ LEFT JOIN ctx_streams s ON cm.stream_id = s.id
++ WHERE cm.user_id = ? AND cm.is_deleted = 0
++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)
++ ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`;
++ const messages = all(sql, [userId, limit, offset]);
++ const total = get(
++ `SELECT COUNT(*) as c FROM chat_messages
++ WHERE user_id = ? AND is_deleted = 0
++ AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,
++ [userId]
++ )?.c || 0;
++ return { messages, total };
++}
++
++function getChatReplay(streamId, fromTime, toTime) {
++ let sql = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name
++ FROM chat_messages cm
++ LEFT JOIN ctx_users u ON cm.user_id = u.id
++ WHERE cm.stream_id = ? AND cm.is_deleted = 0 AND cm.message_type = 'chat'
++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
++ const params = [streamId];
++ if (fromTime) { sql += ` AND cm.timestamp >= ?`; params.push(fromTime); }
++ if (toTime) { sql += ` AND cm.timestamp <= ?`; params.push(toTime); }
++ sql += ` ORDER BY cm.timestamp ASC`;
++ return all(sql, params);
++}
++
++/**
++ * Get a single chat message by ID.
++ */
++function getChatMessageById(id) {
++ return get('SELECT * FROM chat_messages WHERE id = ?', [id]);
++}
++
++/** Shallow-merge a JSON patch into chat_messages.metadata (e.g. an async translation). */
++function mergeChatMessageMetadata(id, patch) {
++ if (!id || !patch || typeof patch !== 'object') return;
++ const row = get('SELECT metadata FROM chat_messages WHERE id = ?', [id]);
++ if (!row) return;
++ let meta = {};
++ try { meta = row.metadata ? JSON.parse(row.metadata) : {}; } catch { meta = {}; }
++ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) meta = {};
++ return run('UPDATE chat_messages SET metadata = ? WHERE id = ?', [JSON.stringify({ ...meta, ...patch }), id]);
++}
++
++/**
++ * Soft-delete a chat message by ID. Sets is_deleted=1 and records who deleted it.
++ */
++function deleteChatMessage(id, deletedBy = null) {
++ return run(
++ 'UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id = ?',
++ [deletedBy, id]
++ );
++}
++
++/**
++ * Soft-delete ALL chat messages from a specific user, optionally scoped to a stream.
++ * Returns the list of deleted message IDs for real-time broadcast.
++ */
++function deleteUserChatMessages(userId, { streamId = null, deletedBy = null } = {}) {
++ const condition = streamId
++ ? 'user_id = ? AND stream_id = ? AND is_deleted = 0'
++ : 'user_id = ? AND is_deleted = 0';
++ const params = streamId ? [userId, streamId] : [userId];
++ const messages = all(`SELECT id FROM chat_messages WHERE ${condition}`, params);
++ const ids = messages.map(m => m.id);
++ if (ids.length === 0) return [];
++ const placeholders = ids.map(() => '?').join(',');
++ run(
++ `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,
++ [deletedBy, ...ids]
++ );
++ return ids;
++}
++
++/**
++ * Soft-delete ALL chat messages from a specific anon_id, optionally scoped to stream.
++ */
++function deleteAnonChatMessages(anonId, { streamId = null, deletedBy = null } = {}) {
++ const condition = streamId
++ ? 'anon_id = ? AND stream_id = ? AND is_deleted = 0'
++ : 'anon_id = ? AND is_deleted = 0';
++ const params = streamId ? [anonId, streamId] : [anonId];
++ const messages = all(`SELECT id FROM chat_messages WHERE ${condition}`, params);
++ const ids = messages.map(m => m.id);
++ if (ids.length === 0) return [];
++ const placeholders = ids.map(() => '?').join(',');
++ run(
++ `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,
++ [deletedBy, ...ids]
++ );
++ return ids;
++}
++
++/**
++ * Soft-delete ALL messages from a relayed external username (e.g. "[Twitch] foobar")
++ */
++function deleteRelayUserMessages(username, { streamId = null, deletedBy = null } = {}) {
++ const condition = streamId
++ ? 'username = ? AND stream_id = ? AND is_deleted = 0'
++ : 'username = ? AND is_deleted = 0';
++ const params = streamId ? [username, streamId] : [username];
++ const messages = all(`SELECT id FROM chat_messages WHERE ${condition}`, params);
++ const ids = messages.map(m => m.id);
++ if (ids.length === 0) return [];
++ const placeholders = ids.map(() => '?').join(',');
++ run(
++ `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,
++ [deletedBy, ...ids]
++ );
++ return ids;
++}
++
++function deleteExpiredChatMessages(limit = 500) {
++ const rows = all(
++ `SELECT id, stream_id
++ FROM chat_messages
++ WHERE is_deleted = 0
++ AND auto_delete_at IS NOT NULL
++ -- Compared raw, not through datetime(): wrapping the column in a function makes the
++ -- index on auto_delete_at unusable, and this sweep runs every 30 seconds against the
++ -- biggest table on the site. Values are stored in the same 'YYYY-MM-DD HH:MM:SS' shape
++ -- CURRENT_TIMESTAMP produces, so a string comparison sorts identically.
++ AND auto_delete_at <= CURRENT_TIMESTAMP
++ ORDER BY auto_delete_at ASC
++ LIMIT ?`,
++ [Math.max(1, Number(limit) || 500)]
++ );
++ if (!rows.length) return [];
++
++ const ids = rows.map(row => row.id);
++ const placeholders = ids.map(() => '?').join(',');
++ run(
++ `UPDATE chat_messages
++ SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP
++ WHERE id IN (${placeholders})`,
++ ids
++ );
++ return rows;
++}
++
++function deleteChatMessagesByTimeRange(streamId, fromTime, toTime, deletedBy) {
++ if (streamId) {
++ return run(
++ `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP
++ WHERE stream_id = ? AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
++ [deletedBy, streamId, fromTime, toTime]
++ );
++ }
++ // Global chat (is_global = 1)
++ return run(
++ `UPDATE chat_messages SET is_deleted = 1, deleted_by = ?, deleted_at = CURRENT_TIMESTAMP
++ WHERE is_global = 1 AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
++ [deletedBy, fromTime, toTime]
++ );
++}
++
++function countChatMessagesByTimeRange(streamId, fromTime, toTime) {
++ let row;
++ if (streamId) {
++ row = get(
++ `SELECT COUNT(*) as cnt FROM chat_messages
++ WHERE stream_id = ? AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
++ [streamId, fromTime, toTime]
++ );
++ } else {
++ row = get(
++ `SELECT COUNT(*) as cnt FROM chat_messages
++ WHERE is_global = 1 AND timestamp >= ? AND timestamp <= ? AND is_deleted = 0`,
++ [fromTime, toTime]
++ );
++ }
++ return row?.cnt || 0;
++}
++
++function getChatLogs({ streamId, username, search, from, to, messageType, page = 1, limit = 50, includeDeleted = false } = {}) {
++ const conditions = [];
++ const params = [];
++
++ if (streamId) { conditions.push('stream_id = ?'); params.push(streamId); }
++ if (username) { conditions.push('username LIKE ?'); params.push(`%${username}%`); }
++ if (search) { conditions.push('message LIKE ?'); params.push(`%${search}%`); }
++ if (from) { conditions.push('timestamp >= ?'); params.push(from); }
++ if (to) { conditions.push('timestamp <= ?'); params.push(to); }
++ if (messageType) { conditions.push('message_type = ?'); params.push(messageType); }
++ if (!includeDeleted) { conditions.push('is_deleted = 0'); }
++
++ const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
++ const offset = (page - 1) * limit;
++
++ const countRow = get(`SELECT COUNT(*) as total FROM chat_messages ${where}`, params);
++ const total = countRow?.total || 0;
++ const rows = all(
++ `SELECT * FROM chat_messages ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`,
++ [...params, limit, offset]
++ );
++
++ return { rows, total, page, limit, totalPages: Math.ceil(total / limit) };
++}
++
++function getRecentChatActivity(streamId, minutes) {
++ const cutoff = new Date(Date.now() - minutes * 60 * 1000).toISOString();
++ const row = get(
++ `SELECT COUNT(*) as cnt FROM chat_messages
++ WHERE stream_id = ? AND timestamp >= ? AND is_deleted = 0 AND is_global = 0 AND message_type = 'chat'`,
++ [streamId, cutoff]
++ );
++ return row?.cnt || 0;
++}
++
++/**
++ * A user was renamed in Live (admin edit): chat_messages.username stores the display name at
++ * creation time, so Live rewrote its rows in the same request. Chat does the same for its own.
++ */
++function renameUserChatMessages(userId, newChatName) {
++ return run('UPDATE chat_messages SET username = ? WHERE user_id = ?', [newChatName, userId]);
++}
++
++// ── Relay (external platform) chatters ───────────────────────
++
++// Record a chat-relay (external platform) user's activity; keeps the earliest
++// first_seen as their "join date". Keyed case-insensitively by platform+username.
++function recordRelayUser(platform, username) {
++ if (!platform || !username) return;
++ const key = String(username).toLowerCase();
++ try {
++ run(`INSERT INTO relay_users (platform, username, display_name, first_seen, last_seen, message_count)
++ VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
++ ON CONFLICT(platform, username) DO UPDATE SET
++ last_seen = CURRENT_TIMESTAMP,
++ message_count = message_count + 1,
++ display_name = excluded.display_name`,
++ [String(platform).toLowerCase(), key, String(username)]);
++ } catch { /* non-critical */ }
++}
++function getRelayUser(platform, username) {
++ if (!platform || !username) return null;
++ // rowid is a stable integer id for a relay user (no dedicated id column); used to key
++ // their chat-AI insight in chat_ai_summaries.
++ return get('SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?',
++ [String(platform).toLowerCase(), String(username).toLowerCase()]) || null;
++}
++
++// Relay chat messages are stored with a "[Label] name" username + source_platform and a
++// NULL user_id. Match a specific relay user by the trailing "] name" (LIKE is
++// case-insensitive for ASCII in SQLite), scoped to their platform.
++function _likeEscape(s) { return String(s).replace(/[\\%_]/g, '\\$&'); }
++const _RELAY_MATCH = `cm.user_id IS NULL AND cm.source_platform = ? AND cm.username LIKE ? ESCAPE '\\'`;
++function _relayMatchParams(platform, rawUsername) {
++ return [String(platform).toLowerCase(), '%] ' + _likeEscape(String(rawUsername))];
++}
++
++// A relay user's message history (for the "Chat Logs" viewer).
++function getRelayUserChatHistory(platform, rawUsername, { limit = 50, offset = 0, query = '' } = {}) {
++ let where = `${_RELAY_MATCH} AND cm.is_deleted = 0`;
++ const params = _relayMatchParams(platform, rawUsername);
++ if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }
++ const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;
++ const rows = all(
++ `SELECT cm.id, cm.username, cm.message, cm.message_type, cm.timestamp, cm.stream_id,
++ cm.source_platform, s.title AS stream_title
++ FROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id
++ WHERE ${where} ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`,
++ [...params, Math.max(1, Math.min(200, limit)), Math.max(0, offset)]
++ );
++ return { messages: rows, total };
++}
++
++/**
++ * Hide or ban a relayed external user.
++ */
++function hideRelayUser({ channelId, platform, externalUsername, action = 'hide', reason, createdBy }) {
++ return run(
++ `INSERT OR REPLACE INTO hidden_relay_users (channel_id, platform, external_username, action, reason, created_by)
++ VALUES (?, ?, ?, ?, ?, ?)`,
++ [channelId || null, platform, externalUsername, action, reason || null, createdBy]
++ );
++}
++
++/**
++ * Check if a relayed user is hidden/banned.
++ * Checks both channel-scoped and site-wide entries (channel_id IS NULL).
++ */
++function isRelayUserHidden(channelId, platform, externalUsername) {
++ return !!get(
++ `SELECT 1 FROM hidden_relay_users
++ WHERE platform = ? AND external_username = ? AND (channel_id = ? OR channel_id IS NULL)`,
++ [platform, externalUsername, channelId]
++ );
++}
++
++/**
++ * Unhide/unban a relayed external user.
++ */
++function unhideRelayUser(id) {
++ return run('DELETE FROM hidden_relay_users WHERE id = ?', [id]);
++}
++
++/**
++ * Unhide/unban a relayed external user by identity (platform + external username +
++ * channel). Scoped to a single relay identity — never touches a registered
++ * openvibelive account of the same name. `channel_id IS ?` matches NULL safely.
++ */
++function unhideRelayUserByIdentity(channelId, platform, externalUsername) {
++ return run(
++ 'DELETE FROM hidden_relay_users WHERE platform = ? AND external_username = ? AND channel_id IS ?',
++ [platform, externalUsername, channelId || null]
++ );
++}
++
++// ── Anonymous chatters ───────────────────────────────────────
++// Anon messages have user_id NULL and a stable anon_id = "anon<N>" (which also equals
++// their username).
++function anonSubjectId(anonId) {
++ const m = /^anon(\d+)$/i.exec(String(anonId || ''));
++ return m ? parseInt(m[1], 10) : 0;
++}
++
++// Anon meta for the context menu: first-seen (when their anon number was assigned — Live's
++// anon_ip_mappings, passed in by the caller) and first-chat (their earliest chat message), plus
++// total message count.
++function getAnonMeta(anonId, firstSeenAt = null) {
++ const num = anonSubjectId(anonId);
++ const firstChat = get(
++ `SELECT MIN(timestamp) AS t FROM chat_messages
++ WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0`, [String(anonId)]
++ )?.t || null;
++ const count = get(
++ `SELECT COUNT(*) AS c FROM chat_messages
++ WHERE anon_id = ? AND user_id IS NULL AND is_deleted = 0
++ AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`, [String(anonId)]
++ )?.c || 0;
++ return {
++ anon_id: anonId,
++ anon_num: num || null,
++ first_seen: firstSeenAt || firstChat, // fall back to first chat for legacy rows
++ first_chat: firstChat,
++ message_count: count,
++ };
++}
++
++// An anon's message history (for the "Chat Logs" viewer).
++function getAnonChatHistory(anonId, { limit = 50, offset = 0, query = '' } = {}) {
++ let where = `cm.anon_id = ? AND cm.user_id IS NULL AND cm.is_deleted = 0
++ AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
++ const params = [String(anonId)];
++ if (query) { where += ' AND cm.message LIKE ?'; params.push('%' + query + '%'); }
++ const total = get(`SELECT COUNT(*) AS c FROM chat_messages cm WHERE ${where}`, params)?.c || 0;
++ const rows = all(
++ `SELECT cm.id, cm.username, cm.anon_id, cm.message, cm.message_type, cm.timestamp, cm.stream_id,
++ s.title AS stream_title
++ FROM chat_messages cm LEFT JOIN ctx_streams s ON cm.stream_id = s.id
++ WHERE ${where} ORDER BY cm.timestamp DESC LIMIT ? OFFSET ?`,
++ [...params, Math.max(1, Math.min(200, limit)), Math.max(0, offset)]
++ );
++ return { messages: rows, total };
++}
++
++// ── First chats ──────────────────────────────────────────────
++
++/**
++ * Check if a chatter has ever chatted in this streamer's channel.
++ * @param {string} chatterKey - e.g. "user:42" or "anon:anon3" or "ext:[Twitch] foo"
++ * @param {number} channelUserId - the streamer's user ID
++ * @returns {boolean} true if this is their first time
++ */
++function isFirstChatInChannel(chatterKey, channelUserId) {
++ const row = get(
++ 'SELECT 1 FROM stream_first_chats WHERE chatter_key = ? AND channel_user_id = ?',
++ [chatterKey, channelUserId]
++ );
++ return !row;
++}
++
++/**
++ * Record that a chatter has chatted in a streamer's channel.
++ */
++function recordFirstChat(chatterKey, channelUserId) {
++ run(
++ 'INSERT OR IGNORE INTO stream_first_chats (chatter_key, channel_user_id) VALUES (?, ?)',
++ [chatterKey, channelUserId]
++ );
++}
++
++// ── Moderation log ───────────────────────────────────────────
++
++/**
++ * Log a moderation action for auditing.
++ * Used by canvas, chat moderation, bans, etc.
++ */
++function logModerationAction({ scope_type, scope_id, actor_user_id, target_user_id, action_type, details }) {
++ const actorSubject = subjectFor(actor_user_id);
++ return transaction(() => {
++ const res = run(`
++ INSERT INTO moderation_actions (scope_type, scope_id, actor_user_id, target_user_id, action_type, details, actor_subject_id)
++ VALUES (?, ?, ?, ?, ?, ?, ?)
++ `, [scope_type || 'site', scope_id || null, actor_user_id || null, target_user_id || null, action_type, JSON.stringify(details || {}), actorSubject]);
++ _outbox().enqueue({
++ event_type: 'chat.moderation.action',
++ visibility: 'internal',
++ actorSubject,
++ subject: { type: 'moderation_action', id: String(res.lastInsertRowid) },
++ payload: {
++ action_id: Number(res.lastInsertRowid),
++ action_type,
++ scope_type: scope_type || 'site',
++ scope_id: scope_id || null,
++ actor_user_id: actor_user_id || null,
++ actor_subject: actorSubject,
++ target_user_id: target_user_id || null,
++ target_subject: subjectFor(target_user_id),
++ details: details || {},
++ },
++ });
++ return res;
++ });
++}
++
++// ── IP approval queue ────────────────────────────────────────
++
++/**
++ * Hold a message for IP approval.
++ */
++function holdMessageForApproval({ channelId, streamId, ip, userId, anonId, username, message }) {
++ return run(
++ `INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, user_id, anon_id, username, message)
++ VALUES (?, ?, ?, ?, ?, ?, ?)`,
++ [channelId, streamId, ip, userId || null, anonId || null, username, message]
++ );
++}
++
++/**
++ * Approve or deny a pending IP message. If approved, auto-approve the IP too (approved_ips is
++ * Live's table: the approval goes through live-context).
++ */
++function reviewPendingIpMessage(id, { status, reviewedBy, channelId }) {
++ // Scoped to the channel the caller was authorised for, so a message id from another channel's
++ // queue matches nothing.
++ const scoped = channelId != null;
++ const res = scoped
++ ? run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ? AND channel_id = ?', [status, reviewedBy, id, channelId])
++ : run('UPDATE pending_ip_messages SET status = ?, reviewed_by = ? WHERE id = ?', [status, reviewedBy, id]);
++ if (!res.changes) return;
++ if (status === 'approved') {
++ const msg = get('SELECT * FROM pending_ip_messages WHERE id = ?', [id]);
++ if (msg) _ctx().approveIp(channelId || msg.channel_id, msg.ip_address, reviewedBy, 'manual');
++ }
++}
++
++/**
++ * Bulk-approve all pending messages from a specific IP in a channel.
++ */
++function approveAllFromIp(channelId, ip, reviewedBy) {
++ _ctx().approveIp(channelId, ip, reviewedBy, 'manual');
++ return run(
++ "UPDATE pending_ip_messages SET status = 'approved', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
++ [reviewedBy, channelId, ip]
++ );
++}
++
++/**
++ * Deny all pending messages from a specific IP in a channel.
++ */
++function denyAllFromIp(channelId, ip, reviewedBy) {
++ return run(
++ "UPDATE pending_ip_messages SET status = 'denied', reviewed_by = ? WHERE channel_id = ? AND ip_address = ? AND status = 'pending'",
++ [reviewedBy, channelId, ip]
++ );
++}
++
++// ── Per-user TTS voice overrides (admin-set) ─────────────────
++function getTtsVoiceOverride(identityKey) {
++ try {
++ const k = String(identityKey || '').trim().toLowerCase();
++ if (!k) return null;
++ const r = get('SELECT voice, pitch, speed, gap FROM tts_voice_overrides WHERE identity_key = ?', [k]);
++ if (!r) return null;
++ return { voice: r.voice, pitch: r.pitch, speed: r.speed, gap: r.gap || 0 };
++ } catch { return null; }
++}
++function setTtsVoiceOverride(identityKey, params, setBy) {
++ const k = String(identityKey || '').trim().toLowerCase();
++ if (!k) return false;
++ run(`INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed, gap, set_by, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(identity_key) DO UPDATE SET voice=excluded.voice, pitch=excluded.pitch,
++ speed=excluded.speed, gap=excluded.gap, set_by=excluded.set_by, updated_at=CURRENT_TIMESTAMP`,
++ [k, params.voice, params.pitch, params.speed, params.gap || 0, setBy || null]);
++ return true;
++}
++function deleteTtsVoiceOverride(identityKey) {
++ try { run('DELETE FROM tts_voice_overrides WHERE identity_key = ?', [String(identityKey || '').trim().toLowerCase()]); return true; } catch { return false; }
++}
++
++// ── Channel sound commands (viewer-uploadable) ───────────────
++function createChannelSound({ channel_owner_id, command, url, mime = 'audio/mpeg', duration_seconds = 0, created_by = null, created_by_name = '', emote_code = '' }) {
++ return run(
++ `INSERT INTO channel_sounds (channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code, created_by_subject_id)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [channel_owner_id, command, url, mime, duration_seconds, created_by, created_by_name, emote_code || '', subjectFor(created_by)]
++ );
++}
++// Update the shared emote_code for all sounds under a command (an emote is per-command).
++function setChannelSoundEmote(ownerId, command, emoteCode) {
++ return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND command = ?',
++ [emoteCode || '', ownerId, String(command || '').toLowerCase()]);
++}
++
++function getChannelSounds(ownerId) {
++ return all(
++ 'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND is_approved = 1 ORDER BY command',
++ [ownerId]
++ );
++}
++
++function getChannelSoundByCommand(ownerId, command) {
++ // A command may have multiple uploaded sounds — pick one at random each play.
++ return get(
++ 'SELECT * FROM channel_sounds WHERE channel_owner_id = ? AND command = ? AND is_approved = 1 ORDER BY RANDOM() LIMIT 1',
++ [ownerId, String(command || '').toLowerCase()]
++ );
++}
++
++function getChannelSoundById(id) {
++ return get('SELECT * FROM channel_sounds WHERE id = ?', [id]);
++}
++
++function countChannelSounds(ownerId) {
++ const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ?', [ownerId]);
++ return row ? row.count : 0;
++}
++
++function countChannelSoundsByUploader(ownerId, uploaderId) {
++ const row = get('SELECT COUNT(*) as count FROM channel_sounds WHERE channel_owner_id = ? AND created_by = ?', [ownerId, uploaderId]);
++ return row ? row.count : 0;
++}
++
++function deleteChannelSound(id) {
++ return run('DELETE FROM channel_sounds WHERE id = ?', [id]);
++}
++
++// Rename a whole !command group (a command may hold several sounds).
++function renameChannelSoundCommand(ownerId, oldCommand, newCommand) {
++ return run('UPDATE channel_sounds SET command = ? WHERE channel_owner_id = ? AND command = ?',
++ [String(newCommand || '').toLowerCase(), ownerId, String(oldCommand || '').toLowerCase()]);
++}
++
++// Sounds attach emotes BY CODE — keep those references alive when an emote
++// is renamed so the streamer's emote+sound combos don't silently break.
++function updateChannelSoundEmoteRefs(ownerId, oldCode, newCode) {
++ return run('UPDATE channel_sounds SET emote_code = ? WHERE channel_owner_id = ? AND emote_code = ?',
++ [newCode || '', ownerId, oldCode]);
++}
++
++// ── Meta ─────────────────────────────────────────────────────
++function getMeta(key) { return get('SELECT value FROM chat_meta WHERE key = ?', [key])?.value ?? null; }
++function setMeta(key, value) { return run('INSERT INTO chat_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value == null ? null : String(value)]); }
++
++module.exports = {
++ CHAT_TABLES,
++ STAGED_TABLES,
++ getDb,
++ initDb,
++ installMirrorTriggers,
++ close,
++ run,
++ get,
++ all,
++ transaction,
++ subjectFor,
++ getMeta,
++ setMeta,
++ // chat messages
++ saveChatMessage,
++ searchChatMessages,
++ getUserChatHistory,
++ getChatReplay,
++ getChatMessageById,
++ mergeChatMessageMetadata,
++ deleteChatMessage,
++ deleteUserChatMessages,
++ deleteAnonChatMessages,
++ deleteRelayUserMessages,
++ deleteExpiredChatMessages,
++ deleteChatMessagesByTimeRange,
++ countChatMessagesByTimeRange,
++ getChatLogs,
++ getRecentChatActivity,
++ renameUserChatMessages,
++ // relay + anon chatters
++ recordRelayUser,
++ getRelayUser,
++ getRelayUserChatHistory,
++ hideRelayUser,
++ isRelayUserHidden,
++ unhideRelayUser,
++ unhideRelayUserByIdentity,
++ anonSubjectId,
++ getAnonMeta,
++ getAnonChatHistory,
++ // first chats, moderation, IP approval queue
++ isFirstChatInChannel,
++ recordFirstChat,
++ logModerationAction,
++ holdMessageForApproval,
++ reviewPendingIpMessage,
++ approveAllFromIp,
++ denyAllFromIp,
++ // TTS overrides + channel sounds
++ getTtsVoiceOverride,
++ setTtsVoiceOverride,
++ deleteTtsVoiceOverride,
++ createChannelSound,
++ setChannelSoundEmote,
++ getChannelSounds,
++ getChannelSoundByCommand,
++ getChannelSoundById,
++ countChannelSounds,
++ countChannelSoundsByUploader,
++ deleteChannelSound,
++ renameChannelSoundCommand,
++ updateChannelSoundEmoteRefs,
++};
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
nnew file mode 100644
iindex 0000000..02b0d84
---- /dev/null
++++ b/server/db/schema.sql
@@@ -0,0 +1,425 @@
++-- ═══════════════════════════════════════════════════════════════
++-- OpenVibe.Chat — database schema
++--
++-- Three kinds of tables live here:
++--
++-- 1. Chat's own tables (authority from the W6 cutover). Same columns as OpenVibe.Live's
++-- tables of the same name, so importing is a copy and ids are kept: a message, DM or
++-- sound keeps the id it had in Live. Rows still carry Live's user ids; *subject_id
++-- columns add the OpenVibe.Network subject (usr_…) where it is known, so later waves can
++-- drop Live ids.
++-- 2. Staged copies of chat-target tables that Live still writes in W6 (channel moderators
++-- and settings, emotes, user tags, chat-AI summaries and timeline). The importer fills
++-- them; Chat reads the live values through server/live-context.js until the routes that
++-- write them move. See table_authority.
++-- 3. Bookkeeping: projections of Live data (ctx_*), the events outbox, the Live mirror
++-- outbox, import holds.
++--
++-- Everything is CREATE … IF NOT EXISTS (idempotent on every boot). Foreign keys to Live's
++-- tables (users, streams, channels) are gone: those rows are not here.
++-- ═══════════════════════════════════════════════════════════════
++
++-- ── 1. Chat-owned ──────────────────────────────────────────────
++
++CREATE TABLE IF NOT EXISTS chat_messages (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ stream_id INTEGER, -- Live stream (session) id
++ user_id INTEGER, -- Live user id; NULL for anon
++ anon_id TEXT, -- 'anon12345' format
++ username TEXT,
++ message TEXT NOT NULL,
++ message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')),
++ metadata TEXT, -- JSON sidecar for rich events (donation/goal-reached)
++ is_global INTEGER DEFAULT 0,
++ is_deleted INTEGER DEFAULT 0,
++ is_filtered INTEGER DEFAULT 0,
++ reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,
++ source_platform TEXT,
++ deleted_by INTEGER,
++ deleted_at DATETIME,
++ auto_delete_at DATETIME,
++ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
++ channel_user_id INTEGER, -- the broadcaster whose channel room this is
++ subject_id TEXT -- Network subject of the author (usr_…), when known
++);
++CREATE INDEX IF NOT EXISTS idx_chat_stream_id ON chat_messages(stream_id);
++CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_messages(timestamp);
++CREATE INDEX IF NOT EXISTS idx_chat_user_id ON chat_messages(user_id);
++CREATE INDEX IF NOT EXISTS idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp);
++CREATE INDEX IF NOT EXISTS idx_chat_stream_ts ON chat_messages(stream_id, timestamp);
++CREATE INDEX IF NOT EXISTS idx_chat_autodelete ON chat_messages(auto_delete_at);
++CREATE INDEX IF NOT EXISTS idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted);
++CREATE INDEX IF NOT EXISTS idx_chat_anon ON chat_messages(anon_id);
++
++CREATE TABLE IF NOT EXISTS dm_conversations (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ name TEXT,
++ is_group INTEGER DEFAULT 0,
++ created_by INTEGER NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++
++CREATE TABLE IF NOT EXISTS dm_participants (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ conversation_id INTEGER NOT NULL,
++ user_id INTEGER NOT NULL,
++ last_read_at DATETIME DEFAULT '1970-01-01 00:00:00',
++ joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ subject_id TEXT,
++ UNIQUE(conversation_id, user_id),
++ FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE
++);
++CREATE INDEX IF NOT EXISTS idx_dm_participants_conv ON dm_participants(conversation_id);
++CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id);
++
++CREATE TABLE IF NOT EXISTS dm_messages (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ conversation_id INTEGER NOT NULL,
++ sender_id INTEGER NOT NULL,
++ message TEXT NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ sender_subject_id TEXT,
++ FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE
++);
++CREATE INDEX IF NOT EXISTS idx_dm_messages_conv ON dm_messages(conversation_id, created_at);
++CREATE INDEX IF NOT EXISTS idx_dm_messages_sender ON dm_messages(sender_id);
++
++CREATE TABLE IF NOT EXISTS dm_blocks (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ blocker_id INTEGER NOT NULL,
++ blocked_id INTEGER NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ blocker_subject_id TEXT,
++ UNIQUE(blocker_id, blocked_id)
++);
++CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocker ON dm_blocks(blocker_id);
++CREATE INDEX IF NOT EXISTS idx_dm_blocks_blocked ON dm_blocks(blocked_id);
++
++CREATE TABLE IF NOT EXISTS tts_voice_overrides (
++ identity_key TEXT PRIMARY KEY,
++ voice TEXT,
++ pitch INTEGER,
++ speed INTEGER,
++ gap INTEGER DEFAULT 0,
++ set_by INTEGER,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++
++CREATE TABLE IF NOT EXISTS channel_sounds (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ channel_owner_id INTEGER NOT NULL, -- streamer whose channel this sound belongs to
++ command TEXT NOT NULL, -- trigger word (without leading '!'), lowercased
++ url TEXT NOT NULL, -- on-disk path of the audio file
++ mime TEXT DEFAULT 'audio/mpeg',
++ duration_seconds REAL DEFAULT 0,
++ created_by INTEGER, -- uploader user id (NULL if removed user)
++ created_by_name TEXT DEFAULT '',
++ is_approved INTEGER DEFAULT 1,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ emote_code TEXT DEFAULT '',
++ created_by_subject_id TEXT
++ -- Multiple sounds may share a command; playback picks one at random.
++ -- media_url / media_asset_id stay on Live's copy (its asset-sync mirrors files to Media).
++);
++CREATE INDEX IF NOT EXISTS idx_channel_sounds_owner ON channel_sounds(channel_owner_id);
++CREATE INDEX IF NOT EXISTS idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);
++
++CREATE TABLE IF NOT EXISTS relay_users (
++ platform TEXT NOT NULL,
++ username TEXT NOT NULL,
++ display_name TEXT,
++ first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
++ last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
++ message_count INTEGER DEFAULT 0,
++ PRIMARY KEY (platform, username)
++);
++
++CREATE TABLE IF NOT EXISTS hidden_relay_users (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ channel_id INTEGER,
++ platform TEXT NOT NULL,
++ external_username TEXT NOT NULL,
++ action TEXT DEFAULT 'hide' CHECK(action IN ('hide','ban')),
++ reason TEXT,
++ created_by INTEGER,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++CREATE INDEX IF NOT EXISTS idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform);
++
++CREATE TABLE IF NOT EXISTS pending_ip_messages (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ channel_id INTEGER NOT NULL,
++ stream_id INTEGER,
++ ip_address TEXT NOT NULL,
++ user_id INTEGER,
++ anon_id TEXT,
++ username TEXT,
++ message TEXT NOT NULL,
++ status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','denied')),
++ reviewed_by INTEGER,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++CREATE INDEX IF NOT EXISTS idx_pending_ip_channel ON pending_ip_messages(channel_id, status);
++
++CREATE TABLE IF NOT EXISTS stream_first_chats (
++ chatter_key TEXT NOT NULL,
++ channel_user_id INTEGER NOT NULL,
++ first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (chatter_key, channel_user_id)
++);
++CREATE INDEX IF NOT EXISTS idx_sfc_channel ON stream_first_chats(channel_user_id);
++
++CREATE TABLE IF NOT EXISTS moderation_actions (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ scope_type TEXT NOT NULL DEFAULT 'site',
++ scope_id INTEGER,
++ actor_user_id INTEGER,
++ target_user_id INTEGER,
++ action_type TEXT NOT NULL,
++ details TEXT DEFAULT '{}',
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ actor_subject_id TEXT
++);
++CREATE INDEX IF NOT EXISTS idx_mod_actions_created ON moderation_actions(created_at DESC);
++CREATE INDEX IF NOT EXISTS idx_mod_actions_actor ON moderation_actions(actor_user_id);
++CREATE INDEX IF NOT EXISTS idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);
++
++-- ── 2. Staged: chat targets Live still writes in W6 ─────────────
++
++CREATE TABLE IF NOT EXISTS channel_moderators (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ channel_id INTEGER NOT NULL,
++ user_id INTEGER NOT NULL,
++ added_by INTEGER NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(channel_id, user_id)
++);
++CREATE INDEX IF NOT EXISTS idx_channel_mods_channel ON channel_moderators(channel_id);
++CREATE INDEX IF NOT EXISTS idx_channel_mods_user ON channel_moderators(user_id);
++
++CREATE TABLE IF NOT EXISTS channel_moderation_settings (
++ channel_id INTEGER PRIMARY KEY,
++ slow_mode_seconds INTEGER DEFAULT 0,
++ followers_only INTEGER DEFAULT 0,
++ emote_only INTEGER DEFAULT 0,
++ allow_anonymous INTEGER DEFAULT 1,
++ links_allowed INTEGER DEFAULT 1,
++ gifs_enabled INTEGER DEFAULT 1,
++ account_age_gate_hours INTEGER DEFAULT 0,
++ caps_percentage_limit INTEGER DEFAULT 0,
++ aggressive_filter INTEGER DEFAULT 0,
++ max_message_length INTEGER DEFAULT 500,
++ slur_filter_enabled INTEGER DEFAULT 0,
++ slur_filter_use_builtin INTEGER DEFAULT 1,
++ slur_filter_terms TEXT DEFAULT '',
++ slur_filter_regexes TEXT DEFAULT '',
++ slur_filter_nudge_message TEXT DEFAULT '',
++ slur_filter_disabled_categories TEXT DEFAULT '[]',
++ ip_approval_mode INTEGER DEFAULT 0,
++ soundboard_enabled INTEGER DEFAULT 1,
++ soundboard_allow_pitch INTEGER DEFAULT 1,
++ soundboard_allow_speed INTEGER DEFAULT 1,
++ soundboard_banned_ids TEXT DEFAULT '',
++ viewer_auto_delete_enabled INTEGER DEFAULT 1,
++ viewer_delete_all_enabled INTEGER DEFAULT 1,
++ custom_emotes_enabled INTEGER DEFAULT 1,
++ custom_sounds_enabled INTEGER DEFAULT 1,
++ max_sound_seconds INTEGER DEFAULT 10,
++ uploads_mods_only INTEGER DEFAULT 0,
++ mods_can_edit_about INTEGER DEFAULT 0,
++ donation_sound_url TEXT,
++ donation_sound_mime TEXT,
++ goal_sound_url TEXT,
++ goal_sound_mime TEXT,
++ emote_scale INTEGER DEFAULT 100,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ tts_max_length INTEGER DEFAULT 200,
++ sound_min_speed REAL DEFAULT 0.5,
++ sound_max_speed REAL DEFAULT 3.0,
++ sound_min_pitch_cents INTEGER DEFAULT -1200,
++ sound_max_pitch_cents INTEGER DEFAULT 1200,
++ emote_size_min INTEGER DEFAULT 50,
++ emote_size_max INTEGER DEFAULT 200,
++ sounds_mods_only INTEGER DEFAULT 0
++);
++
++CREATE TABLE IF NOT EXISTS emotes (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ code TEXT NOT NULL,
++ url TEXT NOT NULL,
++ animated INTEGER DEFAULT 0,
++ width INTEGER DEFAULT 28,
++ height INTEGER DEFAULT 28,
++ is_global INTEGER DEFAULT 0,
++ is_approved INTEGER DEFAULT 1,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ channel_owner_id INTEGER,
++ size INTEGER DEFAULT 100
++);
++CREATE INDEX IF NOT EXISTS idx_emotes_user ON emotes(user_id);
++CREATE INDEX IF NOT EXISTS idx_emotes_channel_owner ON emotes(channel_owner_id);
++CREATE UNIQUE INDEX IF NOT EXISTS idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code);
++
++CREATE TABLE IF NOT EXISTS user_tags (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ tag_id TEXT NOT NULL,
++ source TEXT DEFAULT 'shop',
++ granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(user_id, tag_id)
++);
++CREATE INDEX IF NOT EXISTS idx_user_tags_user ON user_tags(user_id);
++
++CREATE TABLE IF NOT EXISTS chat_ai_summaries (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ scope TEXT NOT NULL,
++ subject_id INTEGER NOT NULL DEFAULT 0,
++ window TEXT NOT NULL,
++ overview TEXT DEFAULT '',
++ memory_json TEXT DEFAULT '',
++ timeline_json TEXT DEFAULT '[]',
++ message_count INTEGER DEFAULT 0,
++ window_message_count INTEGER DEFAULT 0,
++ last_message_id INTEGER DEFAULT 0,
++ window_label TEXT DEFAULT '',
++ window_start DATETIME,
++ window_end DATETIME,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(scope, subject_id, window)
++);
++
++CREATE TABLE IF NOT EXISTS chat_timeline_events (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ scope TEXT NOT NULL DEFAULT 'global',
++ subject_id INTEGER NOT NULL DEFAULT 0,
++ ts DATETIME NOT NULL,
++ label TEXT NOT NULL,
++ detail TEXT DEFAULT '',
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label);
++
++-- Who writes each baseline chat table in this wave (docs/cutover.md).
++CREATE TABLE IF NOT EXISTS table_authority (
++ table_name TEXT PRIMARY KEY,
++ authority TEXT NOT NULL CHECK(authority IN ('chat', 'live')),
++ note TEXT
++);
++
++-- ── 3. Bookkeeping ──────────────────────────────────────────────
++
++-- Projections of Live data, maintained by server/live-context.js only. Never authority.
++CREATE TABLE IF NOT EXISTS ctx_users (
++ id INTEGER PRIMARY KEY,
++ username TEXT,
++ display_name TEXT,
++ avatar_url TEXT,
++ profile_color TEXT,
++ role TEXT DEFAULT 'user',
++ is_banned INTEGER DEFAULT 0,
++ ban_reason TEXT,
++ is_owner INTEGER DEFAULT 0,
++ created_at DATETIME,
++ subject_id TEXT,
++ synced_at INTEGER
++);
++CREATE INDEX IF NOT EXISTS idx_ctx_users_username ON ctx_users(username COLLATE NOCASE);
++CREATE INDEX IF NOT EXISTS idx_ctx_users_display ON ctx_users(display_name COLLATE NOCASE);
++
++CREATE TABLE IF NOT EXISTS ctx_streams (
++ id INTEGER PRIMARY KEY,
++ user_id INTEGER,
++ channel_id INTEGER,
++ managed_stream_id INTEGER,
++ title TEXT,
++ is_live INTEGER DEFAULT 0,
++ started_at DATETIME,
++ ended_at DATETIME,
++ created_at DATETIME,
++ synced_at INTEGER
++);
++CREATE INDEX IF NOT EXISTS idx_ctx_streams_user ON ctx_streams(user_id, id);
++CREATE INDEX IF NOT EXISTS idx_ctx_streams_channel ON ctx_streams(channel_id);
++CREATE INDEX IF NOT EXISTS idx_ctx_streams_managed ON ctx_streams(managed_stream_id);
++
++CREATE TABLE IF NOT EXISTS ctx_managed_streams (
++ id INTEGER PRIMARY KEY,
++ user_id INTEGER,
++ slug TEXT,
++ title TEXT,
++ sort_order INTEGER DEFAULT 0,
++ created_at DATETIME,
++ synced_at INTEGER
++);
++CREATE INDEX IF NOT EXISTS idx_ctx_ms_user ON ctx_managed_streams(user_id);
++
++CREATE TABLE IF NOT EXISTS ctx_channels (
++ id INTEGER PRIMARY KEY,
++ user_id INTEGER UNIQUE,
++ title TEXT,
++ synced_at INTEGER
++);
++
++CREATE TABLE IF NOT EXISTS ctx_sync (
++ key TEXT PRIMARY KEY,
++ value TEXT,
++ updated_at INTEGER
++);
++
++-- Transactional outbox (events.event-envelope@1), written in the same transaction as the change.
++CREATE TABLE IF NOT EXISTS events_outbox (
++ seq INTEGER PRIMARY KEY AUTOINCREMENT,
++ event_id TEXT NOT NULL UNIQUE,
++ event_type TEXT NOT NULL,
++ event TEXT NOT NULL,
++ created_at TEXT NOT NULL,
++ sent_at TEXT,
++ attempts INTEGER DEFAULT 0,
++ last_error TEXT
++);
++CREATE INDEX IF NOT EXISTS idx_events_outbox_unsent ON events_outbox(sent_at, seq);
++
++-- Row changes to mirror into Live's copy of Chat's tables (filled by per-connection TEMP
++-- triggers, so only writes made by this service are captured — never the importer's).
++CREATE TABLE IF NOT EXISTS live_mirror_outbox (
++ seq INTEGER PRIMARY KEY AUTOINCREMENT,
++ tbl TEXT NOT NULL,
++ op TEXT NOT NULL CHECK(op IN ('upsert', 'delete')),
++ pk TEXT NOT NULL,
++ created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
++);
++
++-- Writes Live forwarded over the bridge, by idempotency key (Live's outbox id): a retried
++-- delivery returns the first result instead of writing twice.
++CREATE TABLE IF NOT EXISTS bridge_applied (
++ key TEXT PRIMARY KEY,
++ result TEXT,
++ applied_at INTEGER NOT NULL
++);
++
++-- Rows the importer could not represent. Never dropped; reviewed by hand.
++CREATE TABLE IF NOT EXISTS import_hold (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ source_table TEXT NOT NULL,
++ source_pk TEXT NOT NULL,
++ reason TEXT NOT NULL,
++ row_json TEXT NOT NULL,
++ held_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(source_table, source_pk)
++);
++
++CREATE TABLE IF NOT EXISTS import_runs (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ live_db TEXT,
++ dry_run INTEGER DEFAULT 0,
++ counts TEXT,
++ started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ finished_at DATETIME
++);
++
++CREATE TABLE IF NOT EXISTS chat_meta (
++ key TEXT PRIMARY KEY,
++ value TEXT
++);
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..6009e5c
---- /dev/null
++++ b/server/events/outbox.js
@@@ -0,0 +1,116 @@
++/**
++ * Transactional outbox (events.event-envelope@1).
++ *
++ * enqueue() runs inside the same SQLite transaction as the change it announces (a chat message,
++ * a DM, a moderation action), so an event exists exactly when its effect does:
++ * chat.message.created visibility public — a message in a public room (global/channel/stream)
++ * chat.dm.created visibility subject — a DM; the payload names participants, never the text
++ * chat.moderation.action visibility internal — ban/timeout/clear/slow/deletes, from chat or Live
++ *
++ * The relay publishes unsent rows to OpenVibe.Events (POST /api/v1/events, service token for
++ * audience openvibe.events with events.event.publish) and runs only when EVENTS_URL is set;
++ * without it rows accumulate and are relayed later. Events deduplicates on event_id, so a retry
++ * after a lost response never publishes twice.
++ */
++'use strict';
++
++const { ids, validate, serviceAuth } = require('openvibe-contracts');
++
++const SOURCE = 'chat';
++const SERVICE_ACTOR = { type: 'service', id: SOURCE };
++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function actorFor(subjectId) {
++ return subjectId && USER_SUBJECT.test(subjectId) ? { type: 'user', id: subjectId } : SERVICE_ACTOR;
++}
++
++/** Add one envelope to the outbox. Call inside the transaction that makes the change. */
++function enqueue({ event_type, subject, payload, visibility = 'internal', priority = 'important', actorSubject = null }) {
++ const db = require('../db/database');
++ const ms = Date.now();
++ const env = {
++ event_id: ids.newId('event', ms),
++ event_type,
++ version: 1,
++ source: SOURCE,
++ actor: actorFor(actorSubject),
++ timestamp: new Date(ms).toISOString(),
++ priority,
++ visibility,
++ subject,
++ payload: payload || {},
++ };
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) throw new Error(`outbox: invalid envelope for ${event_type}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
++ db.run('INSERT INTO events_outbox (event_id, event_type, event, created_at) VALUES (?, ?, ?, ?)', [env.event_id, event_type, JSON.stringify(env), env.timestamp]);
++ return env;
++}
++
++/** Relay loop. Returns { start, stop, flush }. */
++function createRelay({ config, fetchImpl = globalThis.fetch, tokenClient, log = console } = {}) {
++ const db = require('../db/database');
++ const tokens = tokenClient || serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.events',
++ scope: 'events.event.publish',
++ fetchImpl,
++ });
++ let timer = null;
++ let busy = false;
++
++ function markFailed(rows, message) {
++ const mark = db.getDb().prepare('UPDATE events_outbox SET attempts = attempts + 1, last_error = ? WHERE seq = ?');
++ db.transaction(() => { for (const r of rows) mark.run(String(message).slice(0, 500), r.seq); });
++ log.warn(`[Chat] outbox relay: ${rows.length} event(s) not published: ${message}`);
++ }
++
++ async function flush() {
++ if (!config.events.url) return { sent: 0, disabled: true };
++ if (busy) return { sent: 0, busy: true };
++ busy = true;
++ let sent = 0;
++ try {
++ for (;;) {
++ const rows = db.all('SELECT seq, event_id, event FROM events_outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100');
++ if (!rows.length) break;
++ let res;
++ try {
++ res = await fetchImpl(`${config.events.url}/api/v1/events`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify({ events: rows.map((r) => JSON.parse(r.event)) }),
++ signal: AbortSignal.timeout(10000),
++ });
++ } catch (e) {
++ markFailed(rows, e.message);
++ break;
++ }
++ if (res.status === 401) tokens.invalidate();
++ if (!res.ok) {
++ const text = await res.text().catch(() => '');
++ markFailed(rows, `${res.status} ${text.slice(0, 300)}`);
++ break;
++ }
++ const at = new Date().toISOString();
++ const mark = db.getDb().prepare('UPDATE events_outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?');
++ db.transaction(() => { for (const r of rows) mark.run(at, r.seq); });
++ sent += rows.length;
++ }
++ } finally {
++ busy = false;
++ }
++ return { sent };
++ }
++
++ function start() {
++ if (timer || !config.events.url) return;
++ timer = setInterval(() => { flush().catch((e) => log.warn('[Chat] outbox relay:', e.message)); }, config.events.intervalMs);
++ if (timer.unref) timer.unref();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++ return { start, stop, flush };
++}
++
++module.exports = { enqueue, createRelay, actorFor };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..64fe4e9
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,77 @@
++/**
++ * OpenVibe.Chat — entry point.
++ *
++ * node server/index.js (PORT 4400; systemd unit deploy/systemd/openvibe-chat.service)
++ *
++ * Boot: database → Live projections (first full sync; Chat serves with whatever it has if Live is
++ * slow) → chat WebSocket server → HTTP app → background loops (Live sync, events outbox relay,
++ * Live mirror relay). SIGTERM tells clients the chat is restarting, closes sockets and exits.
++ */
++'use strict';
++
++const http = require('http');
++const config = require('./config');
++const db = require('./db/database');
++const ctx = require('./live-context');
++const chatServer = require('./chat/chat-server');
++const { createBridge } = require('./bridge/live-bridge');
++const { createMirror } = require('./bridge/live-mirror');
++const { createRelay } = require('./events/outbox');
++const { createApp } = require('./app');
++
++async function start() {
++ console.log(`[Chat] OpenVibe.Chat starting (db ${config.dbPath}, Live ${config.live.internalUrl}, mirror ${config.live.mirror ? 'on' : 'off'}, events ${config.events.url || 'off'})`);
++ // Mirror capture only when the mirror is on: before the cutover (rehearsals) nothing queues.
++ db.initDb({ captureMirror: config.live.mirror });
++
++ const firstSync = ctx.sync().catch((err) => console.warn('[Chat] first Live sync:', err.message));
++ await Promise.race([firstSync, new Promise((r) => setTimeout(r, 15000))]);
++ ctx.start();
++
++ const server = http.createServer();
++ chatServer.init(server);
++ const bridge = createBridge({ chatServer });
++ const mirror = createMirror({ config });
++ const relay = createRelay({ config });
++ const { app, handleUpgrade } = createApp({ chatServer, bridge, mirror, relay });
++ server.on('request', app);
++ server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });
++ mirror.start();
++ relay.start();
++
++ await new Promise((resolve) => server.listen(config.port, config.host, resolve));
++ console.log(`[Chat] listening on http://${config.host}:${server.address().port} (ws /ws/chat)`);
++
++ let stopping = false;
++ const shutdown = async () => {
++ if (stopping) return;
++ stopping = true;
++ console.log('[Chat] shutting down');
++ try {
++ chatServer.broadcastAll({
++ type: 'server_restart',
++ message: '⚙️ Chat server restarting — you will be reconnected automatically.',
++ timestamp: new Date().toISOString(),
++ });
++ } catch { /* non-critical */ }
++ ctx.stop();
++ relay.stop();
++ mirror.stop();
++ try { await Promise.race([mirror.flush(), new Promise((r) => setTimeout(r, 3000))]); } catch { /* */ }
++ try { chatServer.close(); } catch { /* */ }
++ server.close(() => { db.close(); process.exit(0); });
++ setTimeout(() => process.exit(0), 5000).unref();
++ };
++ process.on('SIGTERM', shutdown);
++ process.on('SIGINT', shutdown);
++ return { server, mirror, relay, bridge, shutdown };
++}
++
++if (require.main === module) {
++ start().catch((err) => {
++ console.error('[Chat] failed to start:', err);
++ process.exit(1);
++ });
++}
++
++module.exports = { start };
ddiff --git a/server/live-context.js b/server/live-context.js
nnew file mode 100644
iindex 0000000..090d3ee
---- /dev/null
++++ b/server/live-context.js
@@@ -0,0 +1,779 @@
++/**
++ * OpenVibe.Chat — the ONE adapter to OpenVibe.Live.
++ *
++ * Every piece of data Chat does not own (accounts and roles, streams and live state, channels,
++ * channel moderation policy, bans and IP rules, follows, cosmetics and tags, site settings,
++ * anon numbers) and every side effect chat triggers in Live (coins, AI viewers, arena, media
++ * queue, hardware, pastes, translation, PowerChat, notifications, viewer counts, IP log) goes
++ * through this module. Nothing else in Chat talks to Live.
++ *
++ * Transport: Live's internal endpoints, service token from OpenVibe.Network for audience
++ * openvibe.live —
++ * GET/POST /internal/chat-context/* capability live.chat_context.read
++ * POST /internal/chat-effects/* capability live.chat_effects.write
++ * POST /internal/chat-effects/mirror capability live.chat_mirror.write (bridge/live-mirror.js)
++ *
++ * Hot path rule: nothing a chat message touches makes a network call. Reads are answered from
++ * - SQLite projections (ctx_users, ctx_streams, ctx_managed_streams, ctx_channels), kept
++ * complete by paged syncs and fetched on a miss, so history SQL can join them; and
++ * - in-memory caches with a TTL that serve a stale value while refreshing in the background
++ * (channel policy, bans, follows, decor, IP approvals, settings, anon numbers).
++ * warm() — awaited when a socket connects or joins a room — fills everything that socket's
++ * messages will read. Explicit invalidation: invalidateUser/invalidateChannel/invalidateBans,
++ * called after Chat's own effects and when Live pushes a change over the bridge.
++ *
++ * ── Interface ───────────────────────────────────────────────────────────────────────────────
++ * lifecycle start(), stop(), sync() (sync = one full projection pass)
++ * identity authenticate(token) → user|null (cached per token ≤60s)
++ * upsertUser(row), invalidateUser(id), subjectFor(id)
++ * users getUserById, getUserByUsername, getUserByDisplayName (sync, projection)
++ * ensureUsers(ids), ensureUserByUsername(name) (async, fetch misses)
++ * streams getStreamById, latestStreamIdForUser, getLiveStreamsByUserId,
++ * getStreamsByUserId, getManagedStreamsByUserId (sync) · ensureStream(id)
++ * channels getChannelById, getChannelByUserId (sync) · ensureChannelForUser(uid),
++ * createChannel(uid) (effect)
++ * policy getChannelModerationSettings(channelId), isChannelModerator(uid, channelId),
++ * channelLanguage(channelUserId), getChannelAlertSoundsByUser(uid) · invalidateChannel
++ * bans isUserBanned(uid, streamId), getIpBan(ip, streamId), isIpBanned(ip, streamId)
++ * follows isFollowing(followerId, streamerId)
++ * approvals isIpApproved(channelId, ip), approveIp(channelId, ip, by, source) (effect)
++ * decor getCosmeticProfile(uid), getTagProfile(uid), ensureDecor(ids)
++ * settings getSetting(key), ensureSettings(), setSettings(map, actor) (effect)
++ * anon resolveAnon(ip) → { num, first_seen }, getAnonFirstSeen(ip), anonFirstSeenByNum(num)
++ * warm-up warm({ user, streamId, channelUserId, ip })
++ * effects effects.* — see the list at the bottom of this file
++ */
++'use strict';
++
++const crypto = require('crypto');
++const net = require('net');
++const config = require('./config');
++const db = require('./db/database');
++const serviceAuth = require('./net/service-auth');
++
++const SCOPE_READ = 'live.chat_context.read';
++const SCOPE_WRITE = 'live.chat_effects.write';
++
++const TTL = {
++ auth: 60_000,
++ authNegative: 10_000,
++ policy: 15_000,
++ follows: 60_000,
++ decor: 60_000,
++ approval: 60_000,
++ settings: 30_000,
++ bans: 10_000,
++ streamRow: 30_000,
++};
++const WARM_TIMEOUT_MS = 2500;
++const PAGE = 1000;
++
++// Arena commands are Live's (server/arena/arena-chat.js); Chat only routes them there.
++const ARENA_COMMANDS = ['!hype', '!beef', '!arena'];
++
++// Channel moderation defaults — Live's getChannelModerationSettings() fallback row, verbatim.
++function defaultModerationSettings(channelId) {
++ return {
++ channel_id: channelId,
++ slow_mode_seconds: 0,
++ followers_only: 0,
++ emote_only: 0,
++ allow_anonymous: 1,
++ links_allowed: 1,
++ gifs_enabled: 1,
++ account_age_gate_hours: 0,
++ caps_percentage_limit: 0,
++ aggressive_filter: 0,
++ max_message_length: 500,
++ tts_max_length: 200,
++ slur_filter_enabled: 0,
++ slur_filter_use_builtin: 1,
++ slur_filter_terms: '',
++ slur_filter_regexes: '',
++ slur_filter_nudge_message: '',
++ slur_filter_disabled_categories: '[]',
++ ip_approval_mode: 0,
++ soundboard_enabled: 1,
++ soundboard_allow_pitch: 1,
++ soundboard_allow_speed: 1,
++ soundboard_banned_ids: '',
++ viewer_auto_delete_enabled: 1,
++ viewer_delete_all_enabled: 1,
++ custom_emotes_enabled: 1,
++ custom_sounds_enabled: 1,
++ max_sound_seconds: 10,
++ uploads_mods_only: 0,
++ mods_can_edit_about: 0,
++ emote_scale: 100,
++ emote_size_min: 50,
++ emote_size_max: 200,
++ sounds_mods_only: 0,
++ sound_min_speed: 0.5,
++ sound_max_speed: 3.0,
++ sound_min_pitch_cents: -1200,
++ sound_max_pitch_cents: 1200,
++ };
++}
++
++// ── Transport ────────────────────────────────────────────────────────────────────────────────
++
++let _fetch = (...a) => globalThis.fetch(...a);
++const stats = { reads: 0, effects: 0, failures: 0, lastError: null, lastSyncAt: null };
++
++class LiveError extends Error {
++ constructor(status, message, body) { super(message); this.status = status; this.body = body; }
++}
++
++async function call(method, path, body, scope, retried = false) {
++ const url = `${config.live.internalUrl}${path}`;
++ let res;
++ try {
++ res = await _fetch(url, {
++ method,
++ headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(await serviceAuth.headers(config.live.audience, scope)) },
++ body: body !== undefined ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(config.live.requestTimeoutMs),
++ });
++ } catch (err) {
++ stats.failures++; stats.lastError = err.message;
++ throw new LiveError(0, `Live unreachable: ${err.message}`);
++ }
++ if (res.status === 401 && !retried) { serviceAuth.invalidate(config.live.audience, scope); return call(method, path, body, scope, true); }
++ if (res.status === 304) return { notModified: true };
++ const data = await res.json().catch(() => null);
++ if (!res.ok) {
++ stats.failures++; stats.lastError = `${res.status} ${path}`;
++ throw new LiveError(res.status, (data && (data.error || data.detail)) || `Live ${res.status}`, data);
++ }
++ return data;
++}
++const read = (path, body) => { stats.reads++; return call(body === undefined ? 'GET' : 'POST', `/internal/chat-context${path}`, body, SCOPE_READ); };
++const effect = (name, body) => { stats.effects++; return call('POST', `/internal/chat-effects/${name}`, body || {}, SCOPE_WRITE); };
++/** Fire-and-forget effect: logged, never thrown. */
++function fire(name, body) { effect(name, body).catch((err) => console.warn(`[LiveContext] ${name}: ${err.message}`)); }
++
++// ── TTL cache with stale-while-revalidate ─────────────────────────────────────────────────────
++
++class Swr {
++ constructor(ttl, loader, { max = 20000 } = {}) { this.ttl = ttl; this.loader = loader; this.max = max; this.map = new Map(); this.inflight = new Map(); }
++ /** The cached value (possibly stale; a stale one triggers a background refresh), or undefined. */
++ peek(key) {
++ const hit = this.map.get(key);
++ if (!hit) { this.refresh(key).catch(() => {}); return undefined; }
++ if (Date.now() - hit.at > this.ttl) this.refresh(key).catch(() => {});
++ return hit.value;
++ }
++ /** Resolves once a fresh value is cached (or the load failed — then the old value stays). */
++ async ensure(key) {
++ const hit = this.map.get(key);
++ if (hit && Date.now() - hit.at <= this.ttl) return hit.value;
++ try { return await this.refresh(key); } catch { return hit ? hit.value : undefined; }
++ }
++ refresh(key) {
++ if (this.inflight.has(key)) return this.inflight.get(key);
++ const p = Promise.resolve().then(() => this.loader(key)).then((value) => { this.set(key, value); return value; })
++ .finally(() => this.inflight.delete(key));
++ this.inflight.set(key, p);
++ return p;
++ }
++ set(key, value) {
++ this.map.set(key, { value, at: Date.now() });
++ if (this.map.size > this.max) this.map.delete(this.map.keys().next().value);
++ }
++ /** Mark stale and reload in the background; the old value keeps answering until then (no gap). */
++ invalidate(key) {
++ const hit = this.map.get(key);
++ if (!hit) return;
++ hit.at = 0;
++ this.refresh(key).catch(() => {});
++ }
++ delete(key) { this.map.delete(key); }
++ clear() { this.map.clear(); }
++}
++
++// ── Projections (SQLite) ────────────────────────────────────────────────────────────────────
++
++const USER_COLS = ['id', 'username', 'display_name', 'avatar_url', 'profile_color', 'role', 'is_banned', 'ban_reason', 'is_owner', 'created_at', 'subject_id'];
++const STREAM_COLS = ['id', 'user_id', 'channel_id', 'managed_stream_id', 'title', 'is_live', 'started_at', 'ended_at', 'created_at'];
++const MS_COLS = ['id', 'user_id', 'slug', 'title', 'sort_order', 'created_at'];
++const CHANNEL_COLS = ['id', 'user_id', 'title'];
++
++function upsertRows(table, cols, rows) {
++ if (!rows || !rows.length) return 0;
++ const sql = `INSERT INTO ${table} (${cols.join(', ')}, synced_at) VALUES (${cols.map(() => '?').join(', ')}, ?)
++ ON CONFLICT(id) DO UPDATE SET ${cols.filter((c) => c !== 'id').map((c) => `${c} = excluded.${c}`).join(', ')}, synced_at = excluded.synced_at`;
++ const st = db.getDb().prepare(sql);
++ const now = Date.now();
++ db.transaction(() => {
++ for (const r of rows) {
++ if (!r || r.id == null) continue;
++ st.run(...cols.map((c) => (r[c] === undefined ? null : (typeof r[c] === 'boolean' ? (r[c] ? 1 : 0) : r[c]))), now);
++ }
++ });
++ return rows.length;
++}
++
++/** Store a user as Live sent it (only the projection columns are kept). */
++function upsertUser(row) {
++ if (!row || row.id == null) return;
++ upsertRows('ctx_users', USER_COLS, [row]);
++}
++
++const userSelect = 'SELECT * FROM ctx_users';
++function getUserById(id) { if (!id) return null; const u = db.get(`${userSelect} WHERE id = ?`, [id]) || null; if (!u) ensureUsers([id]).catch(() => {}); return u; }
++function getUserByUsername(name) { return name ? (db.get(`${userSelect} WHERE username = ? COLLATE NOCASE`, [String(name)]) || null) : null; }
++function getUserByDisplayName(name) { return name ? (db.get(`${userSelect} WHERE display_name = ? COLLATE NOCASE`, [String(name)]) || null) : null; }
++function subjectFor(userId) { return db.subjectFor(userId); }
++
++/** Fetch users the projection lacks. */
++async function ensureUsers(ids) {
++ const want = [...new Set((ids || []).map(Number).filter((n) => Number.isInteger(n) && n > 0))];
++ const missing = want.filter((id) => !db.get('SELECT 1 FROM ctx_users WHERE id = ?', [id]));
++ if (!missing.length) return 0;
++ const data = await read('/users/lookup', { ids: missing.slice(0, 500) });
++ return upsertRows('ctx_users', USER_COLS, data.users || []);
++}
++/** Look a user up by login (then display name) in the projection, asking Live on a miss. */
++async function ensureUserByUsername(name) {
++ const local = getUserByUsername(name);
++ if (local) return local;
++ try {
++ const data = await read('/users/lookup', { usernames: [String(name)] });
++ upsertRows('ctx_users', USER_COLS, data.users || []);
++ } catch (err) { console.warn('[LiveContext] user lookup:', err.message); }
++ return getUserByUsername(name);
++}
++
++const STREAM_SELECT = `SELECT s.*, u.username, u.display_name, u.avatar_url, u.profile_color,
++ ms.slug AS managed_stream_slug, ms.title AS managed_stream_title
++ FROM ctx_streams s
++ LEFT JOIN ctx_users u ON s.user_id = u.id
++ LEFT JOIN ctx_managed_streams ms ON s.managed_stream_id = ms.id`;
++
++function getStreamById(id) {
++ const sid = parseInt(id, 10);
++ if (!sid) return null;
++ const row = db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;
++ if (!row) ensureStream(sid).catch(() => {});
++ return row;
++}
++const _streamFetch = new Swr(TTL.streamRow, async (sid) => {
++ const data = await read(`/streams/${sid}`);
++ if (data.stream) {
++ upsertRows('ctx_streams', STREAM_COLS, [data.stream]);
++ if (data.owner) upsertUser(data.owner);
++ if (data.managed_stream) upsertRows('ctx_managed_streams', MS_COLS, [data.managed_stream]);
++ if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
++ }
++ return !!data.stream;
++});
++/** Make sure a stream (and its owner, slot and channel) is in the projection. */
++async function ensureStream(id) {
++ const sid = parseInt(id, 10);
++ if (!sid) return null;
++ if (!db.get('SELECT 1 FROM ctx_streams WHERE id = ?', [sid])) await _streamFetch.ensure(sid);
++ return db.get(`${STREAM_SELECT} WHERE s.id = ?`, [sid]) || null;
++}
++function latestStreamIdForUser(userId) {
++ if (!userId) return null;
++ return db.get('SELECT id FROM ctx_streams WHERE user_id = ? ORDER BY id DESC LIMIT 1', [userId])?.id || null;
++}
++function getLiveStreamsByUserId(userId) {
++ return db.all(`${STREAM_SELECT} WHERE s.user_id = ? AND s.is_live = 1 ORDER BY s.started_at DESC`, [userId]);
++}
++function getStreamsByUserId(userId, limit = 50) {
++ return db.all(`${STREAM_SELECT} WHERE s.user_id = ? ORDER BY s.created_at DESC LIMIT ?`, [userId, limit]);
++}
++function getManagedStreamsByUserId(userId) {
++ return db.all(`
++ SELECT ms.*,
++ (SELECT s.is_live FROM ctx_streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS is_currently_live,
++ (SELECT s.id FROM ctx_streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 LIMIT 1) AS live_session_id
++ FROM ctx_managed_streams ms
++ WHERE ms.user_id = ?
++ ORDER BY ms.sort_order ASC, ms.created_at ASC`, [userId]);
++}
++
++function getChannelById(id) { return id ? (db.get('SELECT * FROM ctx_channels WHERE id = ?', [id]) || null) : null; }
++function getChannelByUserId(userId) { return userId ? (db.get('SELECT * FROM ctx_channels WHERE user_id = ?', [userId]) || null) : null; }
++/** The channel row of a user, asking Live when the projection lacks it (null = the user has none). */
++async function ensureChannelForUser(userId) {
++ const local = getChannelByUserId(userId);
++ if (local || !userId) return local;
++ try {
++ const data = await read(`/channels/by-user/${parseInt(userId, 10)}`);
++ if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
++ } catch (err) { console.warn('[LiveContext] channel lookup:', err.message); }
++ return getChannelByUserId(userId);
++}
++/** Live's ensureChannel(userId): create the channel if the user has none (effect). */
++async function createChannel(userId) {
++ const data = await effect('ensure-channel', { user_id: userId });
++ if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
++ return getChannelByUserId(userId);
++}
++
++// ── Channel policy: moderation settings, moderators, language, alert sounds ──────────────────
++
++const _policy = new Swr(TTL.policy, async (channelId) => {
++ const data = await read(`/channels/${channelId}/policy`);
++ if (data.channel) upsertRows('ctx_channels', CHANNEL_COLS, [data.channel]);
++ return {
++ settings: data.settings || null,
++ moderators: new Set((data.moderator_ids || []).map(Number)),
++ language: data.language || 'en',
++ };
++});
++function _policyFor(channelId) { return channelId ? _policy.peek(Number(channelId)) : undefined; }
++function getChannelModerationSettings(channelId) {
++ const p = _policyFor(channelId);
++ return (p && p.settings) || defaultModerationSettings(channelId);
++}
++function isChannelModerator(userId, channelId) {
++ const p = _policyFor(channelId);
++ return !!(p && p.moderators.has(Number(userId)));
++}
++function channelLanguage(channelUserId) {
++ const ch = getChannelByUserId(channelUserId);
++ if (!ch) return 'en';
++ const p = _policyFor(ch.id);
++ return (p && p.language) || 'en';
++}
++function getChannelAlertSoundsByUser(userId) {
++ const ch = getChannelByUserId(userId);
++ if (!ch) return {};
++ const s = getChannelModerationSettings(ch.id);
++ const pick = { donation_sound_url: s.donation_sound_url || null, donation_sound_mime: s.donation_sound_mime || null, goal_sound_url: s.goal_sound_url || null, goal_sound_mime: s.goal_sound_mime || null };
++ return pick;
++}
++function ensurePolicy(channelId) { return channelId ? _policy.ensure(Number(channelId)) : Promise.resolve(); }
++function invalidateChannel(channelId) { if (channelId) _policy.invalidate(Number(channelId)); }
++
++// ── Bans (Live's `bans`: user, IP and CIDR rows) ────────────────────────────────────────────
++
++let _bans = { rows: [], version: null, at: 0, cidr: [] };
++let _bansLoading = null;
++
++// Live compares expires_at with SQLite's CURRENT_TIMESTAMP as TEXT. Timeouts store an ISO string
++// ('…T…Z'), which sorts after the same day's 'YYYY-MM-DD HH:MM:SS'; the comparison is kept
++// byte-for-byte so a ban lasts exactly as long as it did when Live evaluated it.
++function sqliteNow() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
++function activeBan(r, now) { return r.expires_at == null || String(r.expires_at) > now; }
++
++function _normalizeBanIp(ip) {
++ let s = String(ip || '').trim();
++ if (!s) return '';
++ if (s === '::1') return '127.0.0.1';
++ if (s.startsWith('::ffff:')) s = s.slice(7);
++ return s;
++}
++
++async function refreshBans() {
++ if (_bansLoading) return _bansLoading;
++ _bansLoading = (async () => {
++ try {
++ const data = await read(`/bans${_bans.version ? `?version=${encodeURIComponent(_bans.version)}` : ''}`);
++ if (data && !data.notModified && !data.unchanged) {
++ const rows = data.bans || [];
++ const cidr = [];
++ for (const r of rows) {
++ if (!r.ip_address || !String(r.ip_address).includes('/')) continue;
++ const [addr, bitsStr] = String(r.ip_address).split('/');
++ const fam = net.isIP(addr), bits = parseInt(bitsStr, 10);
++ if (!fam || !Number.isFinite(bits)) continue;
++ const bl = new net.BlockList();
++ try { bl.addSubnet(addr, bits, fam === 6 ? 'ipv6' : 'ipv4'); } catch { continue; }
++ cidr.push({ bl, fam, row: r });
++ }
++ _bans = { rows, version: data.version || null, at: Date.now(), cidr };
++ } else {
++ _bans.at = Date.now();
++ }
++ } catch (err) {
++ console.warn('[LiveContext] bans refresh:', err.message);
++ } finally { _bansLoading = null; }
++ })();
++ return _bansLoading;
++}
++function _bansFresh() { if (Date.now() - _bans.at > TTL.bans) refreshBans().catch(() => {}); return _bans; }
++function invalidateBans() { _bans.version = null; _bans.at = 0; return refreshBans(); }
++
++function isUserBanned(userId, streamId) {
++ if (!userId) return false;
++ const now = sqliteNow();
++ return _bansFresh().rows.some((r) => r.user_id != null && Number(r.user_id) === Number(userId)
++ && (r.stream_id == null || Number(r.stream_id) === Number(streamId)) && activeBan(r, now));
++}
++/** The active site-wide (or this stream's) ban row for an IP, or null — Live's getIpBan(). */
++function getIpBan(ip, streamId) {
++ const norm = _normalizeBanIp(ip);
++ if (!norm) return null;
++ const now = sqliteNow();
++ const b = _bansFresh();
++ const exact = b.rows.find((r) => r.ip_address != null && (r.ip_address === String(ip) || r.ip_address === norm)
++ && (r.stream_id == null || Number(r.stream_id) === Number(streamId)) && activeBan(r, now));
++ if (exact) return exact;
++ const fam = net.isIP(norm);
++ if (!fam) return null;
++ for (const e of b.cidr) {
++ if (e.fam !== fam || !activeBan(e.row, now)) continue;
++ if (e.row.stream_id !== null && e.row.stream_id !== undefined && e.row.stream_id !== streamId) continue;
++ if (e.bl.check(norm, fam === 6 ? 'ipv6' : 'ipv4')) return e.row;
++ }
++ return null;
++}
++function isIpBanned(ip, streamId) { return !!getIpBan(ip, streamId); }
++
++// ── Follows, IP approvals, decor ──────────────────────────────────────────────────────────────
++
++const _follows = new Swr(TTL.follows, async (userId) => new Set(((await read(`/users/${userId}/follows`)).streamer_ids || []).map(Number)));
++function isFollowing(followerId, streamerId) {
++ const s = followerId ? _follows.peek(Number(followerId)) : undefined;
++ return !!(s && s.has(Number(streamerId)));
++}
++
++const _approvals = new Swr(TTL.approval, async (key) => {
++ const [channelId, ip] = key.split('|');
++ return !!(await read(`/channels/${channelId}/approved-ip?ip=${encodeURIComponent(ip)}`)).approved;
++});
++function isIpApproved(channelId, ip) { return !!_approvals.peek(`${channelId}|${ip}`); }
++/** A channel's IP approvals changed in Live (review queue, revoke): reload the ones we hold. */
++function invalidateApprovals(channelId) {
++ for (const key of [..._approvals.map.keys()]) if (key.startsWith(`${channelId}|`)) _approvals.invalidate(key);
++}
++/** Live's approveIp (approved_ips): recorded here at once, written in Live in the background. */
++function approveIp(channelId, ip, approvedBy = null, source = 'auto') {
++ _approvals.set(`${channelId}|${ip}`, true);
++ fire('approve-ip', { channel_id: channelId, ip, approved_by: approvedBy, source });
++}
++
++const _decor = new Swr(TTL.decor, async (userId) => {
++ const data = await read('/decor', { user_ids: [userId] });
++ return (data.decor || {})[userId] || { cosmetic: {}, tag: null };
++});
++async function ensureDecor(ids) {
++ const want = [...new Set((ids || []).map(Number).filter(Boolean))];
++ const stale = want.filter((id) => { const h = _decor.map.get(id); return !h || Date.now() - h.at > TTL.decor; });
++ for (let i = 0; i < stale.length; i += 200) {
++ const chunk = stale.slice(i, i + 200);
++ try {
++ const data = await read('/decor', { user_ids: chunk });
++ for (const id of chunk) _decor.set(id, (data.decor || {})[id] || { cosmetic: {}, tag: null });
++ } catch (err) { console.warn('[LiveContext] decor:', err.message); break; }
++ }
++}
++/** cosmetics.getCosmeticProfile(userId): { nameFX, particleFX, hatFX, voiceFX } (empty until warm). */
++function getCosmeticProfile(userId) { const d = _decor.peek(Number(userId)); return (d && d.cosmetic) || {}; }
++/** tags.getTagProfile(userId): the equipped tag or null. */
++function getTagProfile(userId) { const d = _decor.peek(Number(userId)); return (d && d.tag) || null; }
++
++// ── Site settings (allow-listed keys Live shares with Chat) ───────────────────────────────────
++
++let _settings = { values: {}, at: 0 };
++let _settingsLoading = null;
++function ensureSettings(force = false) {
++ if (!force && Date.now() - _settings.at <= TTL.settings) return Promise.resolve(_settings.values);
++ if (_settingsLoading) return _settingsLoading;
++ _settingsLoading = read('/settings').then((data) => { _settings = { values: data.settings || {}, at: Date.now() }; return _settings.values; })
++ .catch((err) => { console.warn('[LiveContext] settings:', err.message); return _settings.values; })
++ .finally(() => { _settingsLoading = null; });
++ return _settingsLoading;
++}
++/** Live's db.getSetting(key) for the keys chat reads (tts_*, gif_*_api_key, soundboard_101_api_key). */
++function getSetting(key) {
++ if (Date.now() - _settings.at > TTL.settings) ensureSettings().catch(() => {});
++ const v = _settings.values[key];
++ return v === undefined ? null : v;
++}
++/** Write site settings (TTS admin). Live re-checks the actor (admin; owner for secrets). */
++async function setSettings(values, actorUserId) {
++ const data = await effect('site-settings', { settings: values, actor_user_id: actorUserId });
++ await ensureSettings(true);
++ return data;
++}
++
++// ── Anonymous numbers (Live allocates: Network's unified resolve, else its local table) ──────
++
++const _anon = new Map(); // ip → { num, first_seen }
++const _anonPending = new Map();
++function _tempAnonNum(ip) {
++ // Live unreachable: a stable per-address number far above the sequential range, not cached,
++ // so the next connection asks Live again.
++ const h = crypto.createHash('sha256').update(String(ip)).digest();
++ return 900000000 + (h.readUInt32BE(0) % 99999999);
++}
++async function resolveAnon(ip) {
++ if (_anon.has(ip)) return _anon.get(ip);
++ if (_anonPending.has(ip)) return _anonPending.get(ip);
++ const p = effect('anon', { ip }).then((data) => {
++ const v = { num: Number(data.anon_number), first_seen: data.first_seen || null };
++ _anon.set(ip, v);
++ if (_anon.size > 100000) _anon.delete(_anon.keys().next().value);
++ return v;
++ }).catch((err) => {
++ console.warn(`[LiveContext] anon resolve failed for ${ip}: ${err.message}`);
++ return { num: _tempAnonNum(ip), first_seen: null, temporary: true };
++ }).finally(() => _anonPending.delete(ip));
++ _anonPending.set(ip, p);
++ return p;
++}
++function peekAnon(ip) { return _anon.get(ip) || null; }
++// When an address first got an anon number, without allocating one (signed-in viewers).
++const _anonSeen = new Swr(10 * 60_000, async (ip) => (await read(`/anon-first-seen?ip=${encodeURIComponent(ip)}`)).first_seen || null);
++function getAnonFirstSeen(ip) {
++ const known = _anon.get(ip);
++ if (known) return known.first_seen || null;
++ return _anonSeen.peek(ip) || null;
++}
++function ensureAnonFirstSeen(ip) { return ip ? _anonSeen.ensure(ip).catch(() => null) : Promise.resolve(null); }
++
++/** A TTS clip Live stashed (arena voice cache), as { body, mimeType }, or null. */
++async function fetchTtsAudio(file) {
++ try {
++ const res = await _fetch(`${config.live.internalUrl}/internal/chat-context/tts-audio/${encodeURIComponent(file)}`, {
++ headers: { ...(await serviceAuth.headers(config.live.audience, SCOPE_READ)) },
++ signal: AbortSignal.timeout(config.live.requestTimeoutMs),
++ });
++ if (!res.ok) return null;
++ return { body: Buffer.from(await res.arrayBuffer()), mimeType: res.headers.get('content-type') || 'audio/mpeg' };
++ } catch { return null; }
++}
++async function anonFirstSeenByNum(num) {
++ try { return (await read(`/anon/${parseInt(num, 10)}`)).first_seen || null; } catch { return null; }
++}
++
++// ── Identity ─────────────────────────────────────────────────────────────────────────────────
++
++const _auth = new Map(); // sha256(token) → { user, until, stale_until }
++const _authPending = new Map();
++const tokenKey = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
++
++/**
++ * Resolve a browser/bot token (Network JWT or hbt_ API token) to a Live user, the way Live's
++ * authenticateWs/requireAuth do — Live verifies it, keeps its account links, and answers with
++ * the user projection, its subject and (API tokens) scopes. Cached per token for up to 60s and
++ * never past the token's own expiry. Returns a fresh object each call (callers mutate it).
++ */
++async function authenticate(token) {
++ if (!token) return null;
++ const key = tokenKey(token);
++ const now = Date.now();
++ const hit = _auth.get(key);
++ if (hit && hit.until > now) return hit.user ? { ...hit.user } : null;
++ if (_authPending.has(key)) return _authPending.get(key).then((u) => (u ? { ...u } : null));
++ const p = read('/auth', { token }).then((data) => {
++ const user = data && data.user ? data.user : null;
++ if (user) upsertUser(user);
++ const exp = data && data.expires_at ? Date.parse(data.expires_at) : null;
++ const ttl = user ? TTL.auth : TTL.authNegative;
++ const until = Math.min(now + ttl, exp && Number.isFinite(exp) ? exp : Infinity);
++ _auth.set(key, { user, until, userId: user ? user.id : null, reason: (data && data.reason) || null });
++ if (_auth.size > 50000) _auth.delete(_auth.keys().next().value);
++ return user;
++ }).catch((err) => {
++ // Live down: a recently verified token keeps working for its remaining lifetime.
++ console.warn('[LiveContext] auth:', err.message);
++ return hit && hit.user ? hit.user : null;
++ }).finally(() => _authPending.delete(key));
++ _authPending.set(key, p);
++ const user = await p;
++ return user ? { ...user } : null;
++}
++/** Why the last resolution of this token failed: 'invalid' (bad/expired) or 'unresolved' (no account). */
++function authFailureReason(token) {
++ const hit = token ? _auth.get(tokenKey(token)) : null;
++ return (hit && !hit.user && hit.reason) || 'invalid';
++}
++
++/** Forget everything cached about a user (Live pushed a change, or Chat changed them). */
++function invalidateUser(userId) {
++ const id = Number(userId);
++ for (const [k, v] of _auth) if (v.userId === id) _auth.delete(k);
++ _decor.invalidate(id);
++ _follows.invalidate(id);
++}
++
++// ── Warm-up (connect / join) ─────────────────────────────────────────────────────────────────
++
++/**
++ * Fill every cache the socket's messages will read: its stream (owner, slot, channel), the
++ * channel's policy, the viewer's follows/decor, the IP-approval answer when the channel gates
++ * by IP, the channel's latest stream for offline-room moderation. Bounded: a slow Live delays a
++ * join by at most WARM_TIMEOUT_MS; anything still loading lands in the caches afterwards.
++ */
++async function warm({ user, streamId, channelUserId, ip } = {}) {
++ const job = (async () => {
++ const stream = streamId ? await ensureStream(streamId) : null;
++ const ownerId = (stream && stream.user_id) || channelUserId || null;
++ const tasks = [ensureSettings()];
++ let channel = stream && stream.channel_id ? getChannelById(stream.channel_id) : null;
++ if (!channel && ownerId) channel = await ensureChannelForUser(ownerId);
++ if (channel) tasks.push(ensurePolicy(channel.id));
++ // Live's chat server asks canModerateChannel(user, channelUserId) — the channel OWNER's user
++ // id where a channel id belongs — for the AI-viewer / PowerChat "is mod" flag. Kept as it
++ // was; that lookup is warmed too so a message never waits on it.
++ if (ownerId && (!channel || channel.id !== ownerId)) tasks.push(ensurePolicy(ownerId));
++ if (user && user.id) { tasks.push(_follows.ensure(Number(user.id))); tasks.push(ensureDecor([user.id])); if (ip) tasks.push(ensureAnonFirstSeen(ip)); }
++ await Promise.all(tasks.map((t) => Promise.resolve(t).catch(() => {})));
++ const policy = channel ? _policyFor(channel.id) : null;
++ if (policy && policy.settings && policy.settings.ip_approval_mode && ip) await _approvals.ensure(`${channel.id}|${ip}`).catch(() => {});
++ })();
++ await Promise.race([job.catch(() => {}), new Promise((r) => setTimeout(r, WARM_TIMEOUT_MS).unref?.())]);
++}
++
++// ── Projection sync ─────────────────────────────────────────────────────────────────────────
++
++async function syncTable(kind, table, cols, { full = false } = {}) {
++ let after = full ? 0 : (db.get(`SELECT COALESCE(MAX(id), 0) AS m FROM ${table}`)?.m || 0);
++ let total = 0;
++ for (let pages = 0; pages < 10000; pages++) {
++ const data = await read(`/${kind}?after_id=${after}&limit=${PAGE}`);
++ const rows = data.rows || [];
++ total += upsertRows(table, cols, rows);
++ if (rows.length < PAGE) break;
++ after = rows[rows.length - 1].id;
++ }
++ return total;
++}
++async function syncActiveStreams() {
++ const data = await read('/streams/active');
++ upsertRows('ctx_streams', STREAM_COLS, data.rows || []);
++ // Streams that were live here but are not in Live's active list any more ended between
++ // polls (and were not "recently ended" either): mark them offline.
++ const liveNow = new Set((data.rows || []).filter((r) => r.is_live).map((r) => Number(r.id)));
++ for (const r of db.all('SELECT id FROM ctx_streams WHERE is_live = 1')) {
++ if (!liveNow.has(Number(r.id))) db.run('UPDATE ctx_streams SET is_live = 0 WHERE id = ?', [r.id]);
++ }
++}
++
++const SCHEDULE = [
++ // [name, every ms, fn]
++ ['users+', 30_000, () => syncTable('users', 'ctx_users', USER_COLS)],
++ ['users*', 15 * 60_000, () => syncTable('users', 'ctx_users', USER_COLS, { full: true })],
++ ['streams+', 10_000, () => syncTable('streams', 'ctx_streams', STREAM_COLS)],
++ ['streams~', 10_000, syncActiveStreams],
++ ['streams*', 30 * 60_000, () => syncTable('streams', 'ctx_streams', STREAM_COLS, { full: true })],
++ ['managed*', 5 * 60_000, () => syncTable('managed-streams', 'ctx_managed_streams', MS_COLS, { full: true })],
++ ['channels*', 5 * 60_000, () => syncTable('channels', 'ctx_channels', CHANNEL_COLS, { full: true })],
++ ['bans', TTL.bans, refreshBans],
++ ['settings', TTL.settings, () => ensureSettings(true)],
++];
++const _lastRun = new Map();
++let _timer = null;
++let _running = false;
++
++async function tick() {
++ if (_running) return;
++ _running = true;
++ try {
++ for (const [name, every, fn] of SCHEDULE) {
++ if (Date.now() - (_lastRun.get(name) || 0) < every) continue;
++ _lastRun.set(name, Date.now());
++ try { await fn(); } catch (err) { console.warn(`[LiveContext] sync ${name}: ${err.message}`); }
++ }
++ stats.lastSyncAt = new Date().toISOString();
++ } finally { _running = false; }
++}
++
++/** One full pass over every projection (boot, tests, `npm run` tools). */
++async function sync() {
++ _lastRun.clear();
++ for (const [, , fn] of SCHEDULE) { try { await fn(); } catch (err) { console.warn('[LiveContext] initial sync:', err.message); } }
++ for (const [name] of SCHEDULE) _lastRun.set(name, Date.now());
++ stats.lastSyncAt = new Date().toISOString();
++}
++
++function start({ intervalMs = 2000 } = {}) {
++ if (_timer) return;
++ _timer = setInterval(() => { tick().catch(() => {}); }, intervalMs);
++ if (_timer.unref) _timer.unref();
++ _batchTimer = setInterval(flushBatches, 2000);
++ if (_batchTimer.unref) _batchTimer.unref();
++}
++function stop() {
++ if (_timer) clearInterval(_timer);
++ if (_batchTimer) clearInterval(_batchTimer);
++ _timer = null; _batchTimer = null;
++}
++
++// ── Effects ──────────────────────────────────────────────────────────────────────────────────
++// Batched fire-and-forget writes (IP log, viewer counts, viewer snapshots) go out every 2s.
++
++let _batchTimer = null;
++const _ipLog = [];
++const _viewerCounts = new Map();
++const _snapshots = [];
++function flushBatches() {
++ if (_ipLog.length) fire('ip-log', { entries: _ipLog.splice(0, 500) });
++ if (_viewerCounts.size) { const counts = Object.fromEntries(_viewerCounts); _viewerCounts.clear(); fire('viewer-counts', { counts }); }
++ if (_snapshots.length) fire('viewer-snapshots', { snapshots: _snapshots.splice(0, 500) });
++}
++
++/** A chat client handle Live can reply to (sendToConn over the bridge) and read like chat-server's client. */
++function clientHandle(client) {
++ if (!client) return null;
++ return {
++ conn_id: client.connId || null,
++ user: client.user ? { id: client.user.id, username: client.user.username, display_name: client.user.display_name, role: client.user.role } : null,
++ anonId: client.anonId || null,
++ ip: client.ip || null,
++ streamId: client.streamId || null,
++ channelUserId: client.channelUserId || null,
++ };
++}
++
++const effects = {
++ // Side effects Live used to run in-process from chat-server.js.
++ logIp(entry) { if (entry && entry.ip && entry.ip !== 'unknown') { _ipLog.push(entry); if (_ipLog.length > 5000) _ipLog.splice(0, _ipLog.length - 5000); } },
++ viewerCount(streamId, count) { if (streamId) _viewerCounts.set(String(streamId), count); },
++ viewerSnapshot(streamId, count, chatActivity) { _snapshots.push({ stream_id: streamId, viewer_count: count, chat_messages_5m: chatActivity }); },
++ setUserColor: (userId, color) => effect('user-color', { user_id: userId, color }).then((r) => { db.run('UPDATE ctx_users SET profile_color = ? WHERE id = ?', [color, userId]); return r; }),
++ ban: (body) => effect('ban', body).then((r) => { invalidateBans(); return r; }),
++ updateChannelModerationSettings: (channelId, fields, actorUserId) => effect('channel-settings', { channel_id: channelId, fields, actor_user_id: actorUserId }).then((r) => { invalidateChannel(channelId); return r; }),
++ setChannelAlertSound: (channelId, kind, url, mime, actorUserId) => effect('alert-sound', { channel_id: channelId, kind, url, mime, actor_user_id: actorUserId }).then((r) => { invalidateChannel(channelId); return r; }),
++ // One call per real chat line: coins chat bonus, AI viewers, PowerChat relay (Live decides each).
++ chatMessage: (body) => effect('chat-message', body),
++ aiModCommand: (channelUserId, streamId, args, opts) => effect('ai/mod-command', { channel_user_id: channelUserId, stream_id: streamId, args, by: opts && opts.by }).then((r) => r.reply),
++ arenaCommand: (client, cmd, parts) => fire('arena-command', { client: clientHandle(client), cmd, parts }),
++ mediaQueue: (op, args) => effect('media-queue', { op, ...args }),
++ hardwareCommand: (streamerUserId, command, fromUser) => effect('hardware', { streamer_user_id: streamerUserId, command, from_user: fromUser }),
++ createPaste: (body) => effect('paste', body).then((r) => r.paste),
++ translate: (text, channelUserId) => effect('translate', { text, channel_user_id: channelUserId }).then((r) => r.translation || null),
++ notifyDm: (body) => fire('notify/dm', body),
++ markDmRead: (userId, conversationId) => fire('notify/dm-read', { user_id: userId, conversation_id: conversationId }),
++ // Awaitable (a sound's Media copy is removed from Live's row before the row goes), never throws.
++ assetSync: (op, assetId) => effect('asset-sync', { op, asset_id: assetId || null }).catch((err) => console.warn(`[LiveContext] asset-sync: ${err.message}`)),
++ userProfile: (username, viewerId) => read(`/users/profile?username=${encodeURIComponent(username)}${viewerId ? `&viewer_id=${viewerId}` : ''}`),
++};
++
++module.exports = {
++ ARENA_COMMANDS,
++ LiveError,
++ stats,
++ // lifecycle
++ start, stop, sync, tick, warm,
++ // identity + users
++ authenticate, authFailureReason, upsertUser, invalidateUser, subjectFor,
++ getUserById, getUserByUsername, getUserByDisplayName, ensureUsers, ensureUserByUsername,
++ // streams + channels
++ getStreamById, ensureStream, latestStreamIdForUser, getLiveStreamsByUserId, getStreamsByUserId, getManagedStreamsByUserId,
++ getChannelById, getChannelByUserId, ensureChannelForUser, createChannel,
++ // policy
++ getChannelModerationSettings, isChannelModerator, channelLanguage, getChannelAlertSoundsByUser, ensurePolicy, invalidateChannel,
++ defaultModerationSettings,
++ // bans, follows, approvals, decor
++ isUserBanned, getIpBan, isIpBanned, invalidateBans, refreshBans,
++ isFollowing, isIpApproved, approveIp, invalidateApprovals,
++ getCosmeticProfile, getTagProfile, ensureDecor,
++ // settings, anon
++ getSetting, ensureSettings, setSettings,
++ resolveAnon, peekAnon, getAnonFirstSeen, ensureAnonFirstSeen, anonFirstSeenByNum, fetchTtsAudio,
++ // effects
++ effects, clientHandle,
++ // tests
++ _setFetch(fn) { _fetch = fn; },
++ _reset() {
++ _policy.clear(); _follows.clear(); _approvals.clear(); _decor.clear(); _auth.clear(); _anon.clear(); _anonSeen.clear(); _streamFetch.clear();
++ _bans = { rows: [], version: null, at: 0, cidr: [] }; _settings = { values: {}, at: 0 }; _lastRun.clear();
++ },
++};
ddiff --git a/server/net/service-auth.js b/server/net/service-auth.js
nnew file mode 100644
iindex 0000000..ad7efab
---- /dev/null
++++ b/server/net/service-auth.js
@@@ -0,0 +1,103 @@
++/**
++ * Service principals, both directions (openvibe-contracts serviceAuth, ADR-003).
++ *
++ * headers(audience, scope) Chat → another service: cached client-credentials token from the
++ * Network's /oauth/token for client `chat`.
++ * guard(capability) another service → Chat: Express guard for /internal/live/* that
++ * verifies an RS256 service token for audience openvibe.chat and
++ * checks the capability in its `cap` claim.
++ *
++ * The capabilities Chat introduces (docs/capabilities-proposal/) are not in the contracts
++ * registry yet, so requireCapability() — which refuses unknown ids — can't be used for them;
++ * the guard checks grants with the same library functions and switches to requireCapability as
++ * soon as the registry knows the id. Internal routes are loopback-only: a request that came
++ * through nginx/Cloudflare (it carries a forwarding header) is refused whatever it presents.
++ */
++'use strict';
++
++const fs = require('fs');
++const { serviceAuth, capabilities, http } = require('openvibe-contracts');
++const config = require('../config');
++
++const AUDIENCE = 'openvibe.chat';
++
++let _publicKey = null;
++let _keyLoading = null;
++
++function loadKeyFromFile() {
++ const p = config.networkPublicKeyPath;
++ if (!p) return null;
++ try { return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; } catch { return null; }
++}
++
++/** The Network's RS256 public key: the PEM file when configured, else the Network's JWKS endpoint. */
++async function ensureKey() {
++ if (_publicKey) return _publicKey;
++ const fromFile = loadKeyFromFile();
++ if (fromFile) { _publicKey = fromFile; return _publicKey; }
++ if (!_keyLoading) {
++ _keyLoading = (async () => {
++ try {
++ const res = await fetch(`${config.networkInternalUrl}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
++ const jwks = await res.json().catch(() => null);
++ if (jwks && jwks.public_key) { _publicKey = jwks.public_key; console.log('[Auth] Network public key loaded from JWKS'); }
++ } catch (err) {
++ console.warn('[Auth] Network public key unavailable:', err.message);
++ } finally { _keyLoading = null; }
++ return _publicKey;
++ })();
++ }
++ return _keyLoading;
++}
++function setPublicKey(pem) { _publicKey = pem || null; }
++
++const _clients = new Map();
++function clientFor(audience, scope) {
++ const key = `${audience}|${scope || ''}`;
++ if (!config.oauth.clientSecret) return null;
++ if (!_clients.has(key)) {
++ _clients.set(key, serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience,
++ scope,
++ }));
++ }
++ return _clients.get(key);
++}
++
++/** Bearer headers for a call to another service. Throws when no token can be had. */
++async function headers(audience, scope) {
++ const c = clientFor(audience, scope);
++ if (!c) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
++ return c.authHeaders();
++}
++function invalidate(audience, scope) { const c = _clients.get(`${audience}|${scope || ''}`); if (c) c.invalidate(); }
++
++function viaProxy(req) {
++ return !!(req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']);
++}
++
++/** Express guard for one capability on an /internal route. */
++function guard(capability) {
++ const registered = !!capabilities.get(capability);
++ return async function serviceGuard(req, res, next) {
++ const ctx = http.requestContext(req.headers);
++ if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
++ const auth = String(req.headers.authorization || '');
++ if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });
++ const publicKey = await ensureKey();
++ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });
++ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
++ if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });
++ const c = registered
++ ? capabilities.check(r.claims, capability)
++ : (capabilities.grants(r.claims.cap, capability) ? { allowed: true } : { allowed: false, code: 'capability.denied', reason: `${capability} not granted` });
++ if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });
++ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
++ next();
++ };
++}
++
++module.exports = { AUDIENCE, ensureKey, setPublicKey, headers, invalidate, guard, viaProxy };
ddiff --git a/test/bridge.test.js b/test/bridge.test.js
nnew file mode 100644
iindex 0000000..c922674
---- /dev/null
++++ b/test/bridge.test.js
@@@ -0,0 +1,173 @@
++'use strict';
++/**
++ * The bridge Live's remaining modules use (POST /internal/live/calls, GET /internal/live/presence):
++ * service-token guard, forwarded chat writes with placeholder ids mapped for later ops of the same
++ * Live boot, idempotent retries, only allow-listed writes, pushes to browsers (broadcasts, DMs with
++ * the participant check, disconnects, user updates with the stored-name rewrite, deploy notices),
++ * and the presence snapshot Live's synchronous reads use.
++ */
++const assert = require('assert');
++const { boot, suite } = require('./helpers');
++
++const t = suite('bridge');
++let h, streamer, viewer, channelId, streamId, viewerWs, globalWs, BRIDGE;
++const REF = -(2 ** 40) - 1;
++
++const calls = (ops, { token = BRIDGE, boot: b = 'live-boot-1', headers } = {}) =>
++ h.http('POST', '/internal/live/calls', { token, headers, body: { boot: b, ops: ops.map((o, i) => ({ seq: i + 1, ...o })) } });
++
++t('boot', async () => {
++ h = await boot();
++ BRIDGE = h.serviceToken(['chat.live_bridge.write']);
++ streamer = h.addUser('streamer', { role: 'streamer' });
++ viewer = h.addUser('viewer');
++ channelId = h.addChannel(streamer.id);
++ streamId = h.addStream(streamer.id, channelId);
++ await h.ctx.sync();
++ viewerWs = await h.ws({ ip: '198.51.100.50', token: viewer.token, stream: streamId });
++ viewerWs.sendJson({ type: 'join', streamId, token: viewer.token });
++ await viewerWs.next((m) => m.type === 'auth');
++ globalWs = await h.ws({ ip: '198.51.100.51' });
++ globalWs.sendJson({ type: 'join' });
++ await globalWs.next((m) => m.type === 'auth');
++});
++
++t('only Live’s service token, only from loopback', async () => {
++ assert.strictEqual((await h.http('POST', '/internal/live/calls', { body: { ops: [] } })).status, 401);
++ assert.strictEqual((await calls([], { token: h.serviceToken(['chat.presence.read']) })).status, 403);
++ assert.strictEqual((await calls([], { token: h.serviceToken(['chat.live_bridge.write'], { aud: 'openvibe.live' }) })).status, 401);
++ assert.strictEqual((await calls([], { headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403);
++ assert.strictEqual((await h.http('GET', '/internal/live/presence', { token: BRIDGE })).status, 403);
++});
++
++let realId;
++t('a forwarded insert gets its real id, and later ops of the boot are rewritten', async () => {
++ const r = await calls([
++ { op: 'db', ref: REF, key: 'live:1', args: ['saveChatMessage', { stream_id: streamId, user_id: null, username: 'ChatBot', message: 'beep boop', message_type: 'chat', source_platform: 'ai', metadata: { bot: 1 } }] },
++ { op: 'broadcastToStream', args: [streamId, { type: 'chat', id: REF, username: 'ChatBot', message: 'beep boop', is_ai: true }] },
++ { op: 'forwardToGlobal', args: [streamId, { type: 'chat', id: REF, username: 'ChatBot', message: 'beep boop' }] },
++ ]);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.body.results.every((x) => x.ok), JSON.stringify(r.body.results));
++ realId = r.body.results[0].result.lastInsertRowid;
++ assert.ok(realId > 0);
++ const got = await viewerWs.next((m) => m.type === 'chat' && m.message === 'beep boop');
++ assert.strictEqual(got.id, realId, 'placeholder replaced by the real id');
++ const cross = await globalWs.next((m) => m.type === 'chat' && m.message === 'beep boop');
++ assert.strictEqual(cross.id, realId);
++ assert.strictEqual(cross.stream_channel, 'streamer');
++ const row = h.db.getChatMessageById(realId);
++ assert.strictEqual(row.channel_user_id, streamer.id, 'channel derived from the stream');
++ assert.strictEqual(row.source_platform, 'ai');
++});
++
++t('a retried write (same key) is applied once and answers the first result', async () => {
++ const before = h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n;
++ const r = await calls([{ op: 'db', ref: REF, key: 'live:1', args: ['saveChatMessage', { stream_id: streamId, username: 'ChatBot', message: 'beep boop', message_type: 'chat' }] }]);
++ assert.strictEqual(r.body.results[0].result.lastInsertRowid, realId);
++ assert.strictEqual(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n, before);
++});
++
++t('placeholders are per Live boot', async () => {
++ const r = await calls([{ op: 'broadcastToStream', args: [streamId, { type: 'system', message: 'other boot', id: REF }] }], { boot: 'live-boot-2' });
++ assert.ok(r.body.results[0].ok);
++ const got = await viewerWs.next((m) => m.type === 'system' && m.message === 'other boot');
++ assert.strictEqual(got.id, REF, 'unknown placeholder left alone');
++});
++
++t('only allow-listed writes; unknown ops refused', async () => {
++ const r = await calls([
++ { op: 'db', args: ['run', 'DELETE FROM chat_messages'] },
++ { op: 'eval', args: ['1'] },
++ { op: 'db', args: ['deleteChatMessage', realId, streamer.id] },
++ ]);
++ assert.deepStrictEqual(r.body.results.map((x) => x.ok), [false, false, true]);
++ assert.ok(h.db.get('SELECT COUNT(*) AS n FROM chat_messages').n > 0);
++ assert.strictEqual(h.db.getChatMessageById(realId).is_deleted, 1);
++ // Moderation from Live's /api/mod lands in Chat's log, with an internal event.
++ const m = await calls([{ op: 'db', ref: REF - 1, key: 'live:2', args: ['logModerationAction', { scope_type: 'site', actor_user_id: streamer.id, action_type: 'message_delete', details: { id: realId } }] }]);
++ assert.ok(m.body.results[0].ok);
++ const ev = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'").map((x) => JSON.parse(x.event)).pop();
++ assert.strictEqual(ev.payload.action_type, 'message_delete');
++ assert.strictEqual(ev.visibility, 'internal');
++});
++
++t('DMs through the bridge keep the participant check', async () => {
++ // viewer is not in conversation 999 (it does not exist): nothing is delivered.
++ await calls([{ op: 'sendDm', args: [viewer.id, { type: 'call-invite', conversation_id: 999 }] }]);
++ assert.ok(await viewerWs.none((m) => m.type === 'call-invite'));
++ // Without a conversation id (call ringing) it is delivered, as in Live.
++ await calls([{ op: 'sendDm', args: [viewer.id, { type: 'call-ring', from: 'streamer' }] }]);
++ await viewerWs.next((m) => m.type === 'call-ring');
++});
++
++t('a user update from Live refreshes sockets and rewrites stored names', async () => {
++ h.db.saveChatMessage({ stream_id: streamId, user_id: viewer.id, username: 'Viewer', message: 'old name line', message_type: 'chat' });
++ await calls([{ op: 'sendUserUpdate', args: [viewer.id, { id: viewer.id, username: 'viewer', display_name: 'Viewer Renamed', role: 'user', avatar_url: null, profile_color: '#123456' }] }]);
++ const u = await viewerWs.next((m) => m.type === 'user-updated');
++ assert.strictEqual(u.user.display_name, 'Viewer Renamed');
++ assert.strictEqual(h.db.get('SELECT username FROM chat_messages WHERE message = ?', ['old name line']).username, 'Viewer Renamed');
++ await h.sleep(1100);
++ viewerWs.sendJson({ type: 'chat', message: 'with the new name' });
++ const own = await globalWs.next((m) => m.type === 'chat' && m.message === 'with the new name');
++ assert.strictEqual(own.username, 'Viewer Renamed');
++});
++
++t('broadcastAllRaw (Live code that looped over chatServer.clients) reaches everyone', async () => {
++ await calls([{ op: 'broadcastAllRaw', args: [JSON.stringify({ type: 'delete-messages', ids: [1, 2] })] }]);
++ await viewerWs.next((m) => m.type === 'delete-messages');
++ await globalWs.next((m) => m.type === 'delete-messages');
++});
++
++t('deploy notices: stored as one rolling global row, shown to everyone, folded on the next deploy', async () => {
++ const commit = (n) => ({ hash: String(n).repeat(40).slice(0, 40), short: String(n).repeat(7), date: '2026-09-22T10:00:00Z', subject: `change ${n}` });
++ let r = await calls([{ op: 'deployNotice', args: [[commit(1)]] }]);
++ const id1 = r.body.results[0].result.id;
++ const row = h.db.getChatMessageById(id1);
++ assert.strictEqual(row.message_type, 'system');
++ assert.strictEqual(JSON.parse(row.metadata).kind, 'deploy');
++ r = await calls([{ op: 'deployNotice', args: [[commit(2)]] }]);
++ assert.strictEqual(r.body.results[0].result.id, id1, 'folded into the same row');
++ assert.strictEqual(JSON.parse(h.db.getChatMessageById(id1).metadata).deploys, 2);
++ // A late joiner gets it once.
++ const late = await h.ws({ ip: '198.51.100.52' });
++ late.sendJson({ type: 'join' });
++ const upd = await late.next((m) => m.type === 'update', 4000);
++ assert.strictEqual(upd.id, id1);
++ late.close();
++});
++
++t('Live’s own writes to data Chat caches invalidate it at once (invalidate op)', async () => {
++ await h.ctx.ensurePolicy(channelId);
++ assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), false);
++ h.live.policies.get(channelId).moderator_ids = [viewer.id]; // added on Live's dashboard
++ const r = await calls([{ op: 'invalidate', args: ['channel', channelId] }, { op: 'invalidate', args: ['nonsense', 1] }]);
++ assert.deepStrictEqual(r.body.results.map((x) => x.ok), [true, false]);
++ await h.sleep(150);
++ assert.strictEqual(h.ctx.isChannelModerator(viewer.id, channelId), true);
++});
++
++t('presence snapshot for Live’s synchronous reads', async () => {
++ const p = await h.http('GET', '/internal/live/presence', { token: h.serviceToken(['chat.presence.read']) });
++ assert.strictEqual(p.status, 200);
++ assert.ok(p.body.total >= 2);
++ assert.strictEqual(p.body.streams[streamId], 1);
++ assert.ok(p.body.users.some((u) => u.user_id === viewer.id && u.ip === '198.51.100.50'));
++ assert.ok(p.body.anons.some((a) => a.ip === '198.51.100.51'));
++});
++
++t('disconnectUser (a ban in Live) closes the sockets and refreshes the ban list', async () => {
++ const bye = await h.ws({ ip: '198.51.100.53', token: viewer.token });
++ bye.sendJson({ type: 'join', token: viewer.token });
++ await bye.next((m) => m.type === 'auth');
++ h.live.addBan({ user_id: viewer.id });
++ const closed = new Promise((r) => bye.on('close', r));
++ await calls([{ op: 'disconnectUser', args: [{ userId: viewer.id }] }]);
++ await bye.next((m) => m.type === 'system' && m.message === 'You have been banned.');
++ await closed;
++ assert.ok(h.ctx.isUserBanned(viewer.id, null), 'ban list refreshed');
++ h.live.clearBans();
++ await h.ctx.invalidateBans();
++});
++
++t.run(async () => { for (const w of [viewerWs, globalWs]) { try { w.close(); } catch { /* */ } } if (h) await h.close(); });
ddiff --git a/test/fixtures/live-chat-schema.sql b/test/fixtures/live-chat-schema.sql
nnew file mode 100644
iindex 0000000..48ea69f
---- /dev/null
++++ b/test/fixtures/live-chat-schema.sql
@@@ -0,0 +1,373 @@
++-- Live's chat tables exactly as a current Live creates them (dumped from a fresh
++-- OpenVibe.Live database, 2026-09-22), plus the minimum of Live's own tables the importer reads.
++-- Used by test/import.test.js to build a fake Live snapshot.
++-- chat_messages
++CREATE TABLE "chat_messages" (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ stream_id INTEGER,
++ user_id INTEGER, -- NULL for anon
++ anon_id TEXT, -- 'anon12345' format
++ username TEXT,
++ message TEXT NOT NULL,
++ message_type TEXT DEFAULT 'chat' CHECK(message_type IN ('chat', 'system', 'donation', 'command', 'tts', 'channel-sound', 'soundboard', 'clip')),
++ metadata TEXT, -- JSON sidecar for rich events (donation/goal-reached)
++ is_global INTEGER DEFAULT 0,
++ is_deleted INTEGER DEFAULT 0,
++ is_filtered INTEGER DEFAULT 0,
++ reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL,
++ source_platform TEXT,
++ deleted_by INTEGER,
++ deleted_at DATETIME,
++ auto_delete_at DATETIME,
++ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, channel_user_id INTEGER,
++ FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
++);
++CREATE INDEX idx_chat_stream_id ON chat_messages(stream_id);
++CREATE INDEX idx_chat_timestamp ON chat_messages(timestamp);
++CREATE INDEX idx_chat_user_id ON chat_messages(user_id);
++CREATE INDEX idx_chat_channel_user_ts ON chat_messages(channel_user_id, timestamp);
++CREATE INDEX idx_chat_stream_ts ON chat_messages(stream_id, timestamp);
++CREATE INDEX idx_chat_autodelete ON chat_messages(auto_delete_at);
++CREATE INDEX idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted);
++-- dm_conversations
++CREATE TABLE dm_conversations (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ name TEXT,
++ is_group INTEGER DEFAULT 0,
++ created_by INTEGER NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
++ );
++-- dm_participants
++CREATE TABLE dm_participants (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ conversation_id INTEGER NOT NULL,
++ user_id INTEGER NOT NULL,
++ last_read_at DATETIME DEFAULT '1970-01-01 00:00:00',
++ joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(conversation_id, user_id),
++ FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++CREATE INDEX idx_dm_participants_conv ON dm_participants(conversation_id);
++CREATE INDEX idx_dm_participants_user ON dm_participants(user_id);
++-- dm_messages
++CREATE TABLE dm_messages (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ conversation_id INTEGER NOT NULL,
++ sender_id INTEGER NOT NULL,
++ message TEXT NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (conversation_id) REFERENCES dm_conversations(id) ON DELETE CASCADE,
++ FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++CREATE INDEX idx_dm_messages_conv ON dm_messages(conversation_id, created_at);
++CREATE INDEX idx_dm_messages_sender ON dm_messages(sender_id);
++-- dm_blocks
++CREATE TABLE dm_blocks (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ blocker_id INTEGER NOT NULL,
++ blocked_id INTEGER NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(blocker_id, blocked_id),
++ FOREIGN KEY (blocker_id) REFERENCES users(id) ON DELETE CASCADE,
++ FOREIGN KEY (blocked_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++CREATE INDEX idx_dm_blocks_blocker ON dm_blocks(blocker_id);
++CREATE INDEX idx_dm_blocks_blocked ON dm_blocks(blocked_id);
++-- tts_voice_overrides
++CREATE TABLE tts_voice_overrides (
++ identity_key TEXT PRIMARY KEY,
++ voice TEXT,
++ pitch INTEGER,
++ speed INTEGER,
++ gap INTEGER DEFAULT 0,
++ set_by INTEGER,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++-- emotes
++CREATE TABLE "emotes" (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ code TEXT NOT NULL,
++ url TEXT NOT NULL,
++ animated INTEGER DEFAULT 0,
++ width INTEGER DEFAULT 28,
++ height INTEGER DEFAULT 28,
++ is_global INTEGER DEFAULT 0,
++ is_approved INTEGER DEFAULT 1,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ channel_owner_id INTEGER,
++ size INTEGER DEFAULT 100,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++CREATE INDEX idx_emotes_user ON emotes(user_id);
++CREATE INDEX idx_emotes_global ON emotes(is_global);
++CREATE INDEX idx_emotes_code ON emotes(code);
++CREATE INDEX idx_emotes_channel_owner ON emotes(channel_owner_id);
++CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code);
++-- channel_sounds
++CREATE TABLE channel_sounds (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ channel_owner_id INTEGER NOT NULL, -- streamer whose channel this sound belongs to
++ command TEXT NOT NULL, -- trigger word (without leading '!'), lowercased
++ url TEXT NOT NULL, -- served path of the audio file on disk
++ mime TEXT DEFAULT 'audio/mpeg',
++ duration_seconds REAL DEFAULT 0,
++ created_by INTEGER, -- uploader user id (NULL if removed user)
++ created_by_name TEXT DEFAULT '',
++ is_approved INTEGER DEFAULT 1,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP, emote_code TEXT DEFAULT '',
++ -- Multiple sounds may share a command; playback picks one at random.
++ FOREIGN KEY (channel_owner_id) REFERENCES users(id) ON DELETE CASCADE
++);
++CREATE INDEX idx_channel_sounds_owner ON channel_sounds(channel_owner_id);
++CREATE INDEX idx_channel_sounds_cmd ON channel_sounds(channel_owner_id, command);
++-- media_requests
++CREATE TABLE media_requests (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ streamer_id INTEGER NOT NULL,
++ stream_id INTEGER,
++ user_id INTEGER NOT NULL,
++ username TEXT NOT NULL,
++ input TEXT NOT NULL,
++ canonical_url TEXT NOT NULL,
++ embed_url TEXT,
++ provider TEXT NOT NULL CHECK(provider IN ('youtube', 'vimeo', 'audio', 'video')),
++ title TEXT NOT NULL,
++ thumbnail_url TEXT,
++ duration_seconds INTEGER,
++ cost INTEGER NOT NULL DEFAULT 25,
++ currency TEXT DEFAULT 'opencoins',
++ queue_position INTEGER DEFAULT 0,
++ status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'playing', 'played', 'skipped', 'removed', 'failed')),
++ requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ started_at DATETIME,
++ ended_at DATETIME,
++ last_error TEXT, stream_url TEXT, download_status TEXT DEFAULT 'none' CHECK(download_status IN ('none','extracting','downloading','ready','failed')), file_path TEXT, playback_position REAL DEFAULT 0, refunded INTEGER DEFAULT 0,
++ FOREIGN KEY (streamer_id) REFERENCES users(id) ON DELETE CASCADE,
++ FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE SET NULL,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++CREATE INDEX idx_media_requests_streamer_status ON media_requests(streamer_id, status, queue_position, requested_at);
++CREATE INDEX idx_media_requests_user_status ON media_requests(user_id, status, requested_at);
++CREATE INDEX idx_media_requests_canonical ON media_requests(streamer_id, canonical_url, status);
++-- media_request_settings
++CREATE TABLE media_request_settings (
++ user_id INTEGER PRIMARY KEY,
++ enabled INTEGER DEFAULT 1,
++ request_cost INTEGER DEFAULT 25,
++ max_per_user INTEGER DEFAULT 3,
++ max_duration_seconds INTEGER DEFAULT 600,
++ allow_youtube INTEGER DEFAULT 1,
++ allow_vimeo INTEGER DEFAULT 1,
++ allow_direct_media INTEGER DEFAULT 1,
++ auto_advance INTEGER DEFAULT 1,
++ currency TEXT DEFAULT 'opencoins' CHECK(currency IN ('free','vibes','opencoins','points')),
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, cost_mode TEXT DEFAULT 'flat' CHECK(cost_mode IN ('flat','per_minute')), cost_per_minute INTEGER DEFAULT 5, allow_live INTEGER DEFAULT 0, download_mode TEXT DEFAULT 'stream' CHECK(download_mode IN ('stream','download')),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++);
++-- moderation_actions
++CREATE TABLE moderation_actions (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ scope_type TEXT NOT NULL DEFAULT 'site',
++ scope_id INTEGER,
++ actor_user_id INTEGER,
++ target_user_id INTEGER,
++ action_type TEXT NOT NULL,
++ details TEXT DEFAULT '{}',
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
++ FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL
++ );
++CREATE INDEX idx_mod_actions_created ON moderation_actions(created_at DESC);
++CREATE INDEX idx_mod_actions_actor ON moderation_actions(actor_user_id);
++CREATE INDEX idx_mod_actions_scope ON moderation_actions(scope_type, scope_id);
++-- channel_moderators
++CREATE TABLE channel_moderators (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ channel_id INTEGER NOT NULL,
++ user_id INTEGER NOT NULL,
++ added_by INTEGER NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(channel_id, user_id),
++ FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
++ FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL
++);
++CREATE INDEX idx_channel_mods_channel ON channel_moderators(channel_id);
++CREATE INDEX idx_channel_mods_user ON channel_moderators(user_id);
++-- channel_moderation_settings
++CREATE TABLE channel_moderation_settings (
++ channel_id INTEGER PRIMARY KEY,
++ slow_mode_seconds INTEGER DEFAULT 0,
++ followers_only INTEGER DEFAULT 0,
++ emote_only INTEGER DEFAULT 0,
++ allow_anonymous INTEGER DEFAULT 1,
++ links_allowed INTEGER DEFAULT 1,
++ gifs_enabled INTEGER DEFAULT 1,
++ account_age_gate_hours INTEGER DEFAULT 0,
++ caps_percentage_limit INTEGER DEFAULT 0,
++ aggressive_filter INTEGER DEFAULT 0,
++ max_message_length INTEGER DEFAULT 500,
++ slur_filter_enabled INTEGER DEFAULT 0,
++ slur_filter_use_builtin INTEGER DEFAULT 1,
++ slur_filter_terms TEXT DEFAULT '',
++ slur_filter_regexes TEXT DEFAULT '',
++ slur_filter_nudge_message TEXT DEFAULT '',
++ slur_filter_disabled_categories TEXT DEFAULT '[]',
++ ip_approval_mode INTEGER DEFAULT 0,
++ soundboard_enabled INTEGER DEFAULT 1,
++ soundboard_allow_pitch INTEGER DEFAULT 1,
++ soundboard_allow_speed INTEGER DEFAULT 1,
++ soundboard_banned_ids TEXT DEFAULT '',
++ viewer_auto_delete_enabled INTEGER DEFAULT 1,
++ viewer_delete_all_enabled INTEGER DEFAULT 1,
++ custom_emotes_enabled INTEGER DEFAULT 1, -- viewers may upload gif/png emotes for this channel
++ custom_sounds_enabled INTEGER DEFAULT 1, -- viewers may upload !sound commands for this channel
++ max_sound_seconds INTEGER DEFAULT 10, -- max duration of an uploaded channel sound
++ uploads_mods_only INTEGER DEFAULT 0, -- restrict emote/sound uploads to channel mods
++ mods_can_edit_about INTEGER DEFAULT 0, -- allow channel mods to edit the streamer's About/panels
++ donation_sound_url TEXT, -- on-disk path to the streamer's donation alert sound
++ donation_sound_mime TEXT,
++ goal_sound_url TEXT, -- optional override sound for goal-reached
++ goal_sound_mime TEXT,
++ emote_scale INTEGER DEFAULT 100, -- emote display size in chat, percent (50-300)
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, tts_max_length INTEGER DEFAULT 200, sound_min_speed REAL DEFAULT 0.5, sound_max_speed REAL DEFAULT 3.0, sound_min_pitch_cents INTEGER DEFAULT -1200, sound_max_pitch_cents INTEGER DEFAULT 1200, emote_size_min INTEGER DEFAULT 50, emote_size_max INTEGER DEFAULT 200, sounds_mods_only INTEGER DEFAULT 0,
++ FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
++);
++-- relay_users
++CREATE TABLE relay_users (
++ platform TEXT NOT NULL,
++ username TEXT NOT NULL,
++ display_name TEXT,
++ first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
++ last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
++ message_count INTEGER DEFAULT 0,
++ PRIMARY KEY (platform, username)
++ );
++-- hidden_relay_users
++CREATE TABLE hidden_relay_users (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ channel_id INTEGER,
++ platform TEXT NOT NULL,
++ external_username TEXT NOT NULL,
++ action TEXT DEFAULT 'hide' CHECK(action IN ('hide','ban')),
++ reason TEXT,
++ created_by INTEGER,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
++ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
++ );
++CREATE INDEX idx_hidden_relay_channel ON hidden_relay_users(channel_id, platform);
++-- pending_ip_messages
++CREATE TABLE pending_ip_messages (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ channel_id INTEGER NOT NULL,
++ stream_id INTEGER,
++ ip_address TEXT NOT NULL,
++ user_id INTEGER,
++ anon_id TEXT,
++ username TEXT,
++ message TEXT NOT NULL,
++ status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','denied')),
++ reviewed_by INTEGER,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
++ FOREIGN KEY (reviewed_by) REFERENCES users(id) ON DELETE SET NULL
++ );
++CREATE INDEX idx_pending_ip_channel ON pending_ip_messages(channel_id, status);
++-- stream_first_chats
++CREATE TABLE stream_first_chats (
++ chatter_key TEXT NOT NULL,
++ channel_user_id INTEGER NOT NULL,
++ first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (chatter_key, channel_user_id)
++ );
++CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id);
++-- chat_ai_summaries
++CREATE TABLE chat_ai_summaries (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ scope TEXT NOT NULL,
++ subject_id INTEGER NOT NULL DEFAULT 0,
++ window TEXT NOT NULL,
++ overview TEXT DEFAULT '',
++ memory_json TEXT DEFAULT '',
++ timeline_json TEXT DEFAULT '[]',
++ message_count INTEGER DEFAULT 0,
++ window_message_count INTEGER DEFAULT 0,
++ last_message_id INTEGER DEFAULT 0,
++ window_label TEXT DEFAULT '',
++ window_start DATETIME,
++ window_end DATETIME,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(scope, subject_id, window)
++ );
++CREATE INDEX idx_chat_ai_scope ON chat_ai_summaries(scope, subject_id, window);
++-- chat_timeline_events
++CREATE TABLE chat_timeline_events (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ scope TEXT NOT NULL DEFAULT 'global',
++ subject_id INTEGER NOT NULL DEFAULT 0,
++ ts DATETIME NOT NULL,
++ label TEXT NOT NULL,
++ detail TEXT DEFAULT '',
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++CREATE INDEX idx_chat_tl_scope_ts ON chat_timeline_events(scope, subject_id, ts DESC);
++CREATE UNIQUE INDEX idx_chat_tl_dedup ON chat_timeline_events(scope, subject_id, ts, label);
++-- linked_accounts
++CREATE TABLE linked_accounts (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ service TEXT NOT NULL,
++ service_user_id TEXT NOT NULL,
++ service_username TEXT,
++ linked_at DATETIME DEFAULT CURRENT_TIMESTAMP, subject_id TEXT,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
++ UNIQUE(service, service_user_id)
++ );
++CREATE INDEX idx_linked_subject ON linked_accounts(subject_id);
++CREATE INDEX idx_linked_service ON linked_accounts(service, service_user_id);
++CREATE INDEX idx_linked_user ON linked_accounts(user_id);
++-- managed_streams
++CREATE TABLE managed_streams (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ channel_id INTEGER,
++ slug TEXT, -- optional unique string ID (must contain non-numeric chars)
++ title TEXT DEFAULT 'Untitled Stream',
++ description TEXT DEFAULT '',
++ category TEXT DEFAULT 'irl',
++ tags TEXT DEFAULT '[]',
++ protocol TEXT DEFAULT 'webrtc' CHECK(protocol IN ('jsmpeg', 'webrtc', 'rtmp')),
++ stream_key TEXT UNIQUE NOT NULL, -- per-stream stable key (reusable across sessions)
++ is_nsfw INTEGER DEFAULT 0,
++ control_config_id INTEGER,
++ sort_order INTEGER DEFAULT 0,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, slot_clip_recording_enabled INTEGER DEFAULT 1, slot_clip_notify_enabled INTEGER DEFAULT 1, slot_powerchat_relay INTEGER DEFAULT 1, slot_powerchat_count_rs_views INTEGER DEFAULT 1, broadcast_settings TEXT DEFAULT '{}', streaming_method TEXT DEFAULT 'browser', browser_mode TEXT DEFAULT 'camera', pip_source_msid INTEGER, pip_defaults TEXT DEFAULT '{}', default_vod_visibility TEXT DEFAULT 'public', default_clip_visibility TEXT DEFAULT 'public', slot_vod_recording_enabled INTEGER DEFAULT 1, weather_zip TEXT DEFAULT NULL, weather_detail TEXT DEFAULT 'basic', weather_show_location INTEGER DEFAULT 0, mic_only_image TEXT DEFAULT NULL,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
++ FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,
++ FOREIGN KEY (control_config_id) REFERENCES control_configs(id) ON DELETE SET NULL
++);
++CREATE INDEX idx_managed_streams_user ON managed_streams(user_id);
++CREATE INDEX idx_managed_streams_slug ON managed_streams(slug);
++CREATE INDEX idx_managed_streams_key ON managed_streams(stream_key);
++
++-- user_tags (created lazily by Live's game/tags.js)
++CREATE TABLE user_tags (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ tag_id TEXT NOT NULL,
++ source TEXT DEFAULT 'shop',
++ granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(user_id, tag_id)
++ );
++-- Live-owned tables the importer reads (subjects, projections)
++CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, email TEXT, password_hash TEXT NOT NULL, display_name TEXT, avatar_url TEXT, role TEXT DEFAULT 'user', stream_key TEXT, is_banned INTEGER DEFAULT 0, ban_reason TEXT, profile_color TEXT DEFAULT '#8b5cf6', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_owner INTEGER DEFAULT 0);
++CREATE TABLE streams (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, channel_id INTEGER, managed_stream_id INTEGER, title TEXT, is_live INTEGER DEFAULT 0, started_at DATETIME, ended_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
++CREATE TABLE channels (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, title TEXT);
ddiff --git a/test/helpers.js b/test/helpers.js
nnew file mode 100644
iindex 0000000..67ab4c1
---- /dev/null
++++ b/test/helpers.js
@@@ -0,0 +1,352 @@
++'use strict';
++/**
++ * Test harness: a stub OpenVibe.Network (RS256 keys, /oauth/token), a stub OpenVibe.Live that
++ * answers /internal/chat-context/* and /internal/chat-effects/* the way Live's patch does
++ * (docs/live-patch.diff) from in-memory data, and Chat itself booted in-process on a temp DB.
++ *
++ * const h = await boot({ env }); // before requiring anything from server/
++ * h.live.users / streams / channels / policies / bans / effects …
++ * const ws = await h.ws({ ip, token, stream }); ws.next(pred), ws.send(obj), ws.all
++ * await h.http('GET', '/api/chat/…', { token })
++ * h.userToken(userId), h.serviceToken(caps, { aud })
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++
++const ISS = 'https://openvibe.network';
++const now = () => Math.floor(Date.now() / 1000);
++
++function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); }
++
++function readBody(req) {
++ return new Promise((resolve) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks).toString();
++ try { resolve(raw ? JSON.parse(raw) : {}); } catch { resolve({}); }
++ });
++ });
++}
++
++function sqliteNow(offsetMs = 0) { return new Date(Date.now() + offsetMs).toISOString().replace('T', ' ').slice(0, 19); }
++
++async function boot({ env = {} } = {}) {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-test-'));
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
++ fs.mkdirSync(path.join(tmp, 'sounds'));
++ const { serviceAuth } = require('openvibe-contracts');
++ let jti = 0;
++ const serviceToken = (cap, { aud = 'openvibe.chat', sub = 'svc:live' } = {}) =>
++ serviceAuth.signServiceToken({ iss: ISS, sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey);
++
++ // ── Stub Network ──
++ const tokenRequests = [];
++ const network = http.createServer(async (req, res) => {
++ const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });
++ res.setHeader('Content-Type', 'application/json');
++ if (req.url === '/oauth/token') {
++ const f = new URLSearchParams(raw);
++ tokenRequests.push({ audience: f.get('audience'), scope: f.get('scope'), client: f.get('client_id') });
++ const cap = String(f.get('scope') || '').split(/\s+/).filter(Boolean);
++ return res.end(JSON.stringify({ access_token: serviceToken(cap.length ? cap : ['live.chat_context.read', 'live.chat_effects.write', 'live.chat_mirror.write'], { aud: f.get('audience'), sub: 'svc:chat' }), expires_in: 300 }));
++ }
++ if (req.url === '/api/.well-known/jwks') return res.end(JSON.stringify({ public_key: keys.publicKey }));
++ res.statusCode = 404; res.end('{}');
++ });
++
++ // ── Stub Live ──
++ const live = {
++ users: new Map(), // id → projection row
++ tokens: new Map(), // token → { userId, apiScopes? }
++ streams: new Map(), // id → stream row
++ managed: new Map(),
++ channels: new Map(), // id → { id, user_id, title }
++ policies: new Map(), // channelId → { settings, moderator_ids }
++ follows: new Map(), // userId → [streamerIds]
++ approved: new Set(), // `${channelId}|${ip}`
++ bans: [], // rows
++ settings: { tts_enabled: true, tts_per_user_voices: true, gif_tenor_api_key: '' },
++ decor: new Map(), // userId → { cosmetic, tag }
++ anon: new Map(), // ip → { num, first_seen }
++ effects: [], // { name, body }
++ mirror: [], // changes
++ mirrorStatus: 200,
++ down: false,
++ nextAnon: 1,
++ mediaState: { queue: [], now_playing: null },
++ seenTokens: [],
++ requests: [], // every path Chat called, in order
++ };
++ let banVersion = 1;
++ live.addBan = (row) => { live.bans.push({ id: live.bans.length + 1, stream_id: null, user_id: null, ip_address: null, anon_id: null, expires_at: null, ...row }); banVersion++; };
++ live.clearBans = () => { live.bans = []; banVersion++; };
++
++ const verify = (req, cap) => {
++ const auth = String(req.headers.authorization || '');
++ if (!auth.startsWith('Bearer ')) return 'no token';
++ const r = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, issuer: ISS, audience: 'openvibe.live' });
++ if (!r.ok) return r.code;
++ if (!r.claims.cap.includes(cap)) return `missing ${cap}`;
++ live.seenTokens.push(r.claims.sub);
++ return null;
++ };
++ const liveServer = http.createServer(async (req, res) => {
++ const url = new URL(req.url, 'http://live');
++ const p = url.pathname;
++ const body = req.method === 'POST' ? await readBody(req) : {};
++ live.requests.push(p);
++ const send = (status, data) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(data)); };
++ if (live.down) { req.socket.destroy(); return; }
++ if (p.startsWith('/internal/chat-context/')) {
++ const bad = verify(req, 'live.chat_context.read');
++ if (bad) return send(401, { error: bad });
++ const q = p.slice('/internal/chat-context'.length);
++ const pageOf = (map) => { const after = parseInt(url.searchParams.get('after_id') || '0', 10); const lim = parseInt(url.searchParams.get('limit') || '1000', 10); return [...map.values()].filter((r) => r.id > after).sort((a, b) => a.id - b.id).slice(0, lim); };
++ let m;
++ if (q === '/auth') {
++ const t = live.tokens.get(body.token);
++ if (!t) return send(200, { user: null, reason: 'invalid' });
++ const u = live.users.get(t.userId);
++ return send(200, { user: { ...u, auth_source: t.apiScopes ? 'api_token' : 'network', ...(t.apiScopes ? { scopes: t.apiScopes } : {}) }, expires_at: new Date(Date.now() + 3600e3).toISOString() });
++ }
++ if (q === '/users') return send(200, { rows: pageOf(live.users) });
++ if (q === '/users/lookup') {
++ const out = [];
++ for (const id of body.ids || []) if (live.users.has(id)) out.push(live.users.get(id));
++ for (const n of body.usernames || []) { const u = [...live.users.values()].find((x) => x.username.toLowerCase() === String(n).toLowerCase()); if (u) out.push(u); }
++ return send(200, { users: out });
++ }
++ if ((m = /^\/users\/(\d+)\/follows$/.exec(q))) return send(200, { streamer_ids: live.follows.get(Number(m[1])) || [] });
++ if (q === '/users/profile') {
++ const n = url.searchParams.get('username');
++ const u = [...live.users.values()].find((x) => x.username.toLowerCase() === String(n).toLowerCase());
++ if (!u) return send(404, { error: 'User not found' });
++ return send(200, { id: u.id, username: u.username, display_name: u.display_name, messageCount: 0, followerCount: 0, followingCount: 0, ...(Number(url.searchParams.get('viewer_id')) === u.id ? { last_seen: 'x' } : {}) });
++ }
++ if (q === '/streams') return send(200, { rows: pageOf(live.streams) });
++ if (q === '/streams/active') return send(200, { rows: [...live.streams.values()].filter((s) => s.is_live) });
++ if ((m = /^\/streams\/(\d+)$/.exec(q))) {
++ const s = live.streams.get(Number(m[1]));
++ if (!s) return send(200, { stream: null });
++ return send(200, { stream: s, owner: live.users.get(s.user_id) || null, managed_stream: s.managed_stream_id ? live.managed.get(s.managed_stream_id) || null : null, channel: s.channel_id ? live.channels.get(s.channel_id) : [...live.channels.values()].find((c) => c.user_id === s.user_id) || null });
++ }
++ if (q === '/managed-streams') return send(200, { rows: pageOf(live.managed) });
++ if (q === '/channels') return send(200, { rows: pageOf(live.channels) });
++ if ((m = /^\/channels\/by-user\/(\d+)$/.exec(q))) return send(200, { channel: [...live.channels.values()].find((c) => c.user_id === Number(m[1])) || null });
++ if ((m = /^\/channels\/(\d+)\/policy$/.exec(q))) {
++ const id = Number(m[1]);
++ const pol = live.policies.get(id) || {};
++ return send(200, { channel: live.channels.get(id) || null, settings: pol.settings || null, moderator_ids: pol.moderator_ids || [], language: pol.language || 'en' });
++ }
++ if ((m = /^\/channels\/(\d+)\/approved-ip$/.exec(q))) return send(200, { approved: live.approved.has(`${m[1]}|${url.searchParams.get('ip')}`) });
++ if (q === '/bans') {
++ const version = String(banVersion);
++ if (url.searchParams.get('version') === version) return send(200, { version, unchanged: true });
++ return send(200, { version, bans: live.bans });
++ }
++ if (q === '/decor') {
++ const decor = {};
++ for (const id of body.user_ids || []) decor[id] = live.decor.get(Number(id)) || { cosmetic: {}, tag: null };
++ return send(200, { decor });
++ }
++ if (q === '/settings') return send(200, { settings: live.settings });
++ if ((m = /^\/anon\/(\d+)$/.exec(q))) { const hit = [...live.anon.values()].find((a) => a.num === Number(m[1])); return send(200, { first_seen: hit ? hit.first_seen : null }); }
++ if (q === '/anon-first-seen') { const hit = live.anon.get(url.searchParams.get('ip')); return send(200, { first_seen: hit ? hit.first_seen : null }); }
++ if ((m = /^\/tts-audio\/(.+)$/.exec(q))) {
++ if (m[1] === 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.mp3') { res.setHeader('Content-Type', 'audio/mpeg'); return res.end(Buffer.from('ID3live')); }
++ return send(404, { error: 'No such clip' });
++ }
++ return send(404, { error: `no context ${q}` });
++ }
++ if (p.startsWith('/internal/chat-effects/')) {
++ const name = p.slice('/internal/chat-effects/'.length);
++ const bad = verify(req, name === 'mirror' ? 'live.chat_mirror.write' : 'live.chat_effects.write');
++ if (bad) return send(401, { error: bad });
++ if (name === 'mirror') {
++ if (live.mirrorStatus !== 200) return send(live.mirrorStatus, { error: 'refused' });
++ live.mirror.push(...(body.changes || []));
++ return send(200, { ok: true, applied: (body.changes || []).length, skipped: [] });
++ }
++ live.effects.push({ name, body });
++ switch (name) {
++ case 'anon': {
++ if (!live.anon.has(body.ip)) live.anon.set(body.ip, { num: live.nextAnon++, first_seen: sqliteNow(-2 * 86400e3) });
++ const a = live.anon.get(body.ip);
++ return send(200, { anon_number: a.num, first_seen: a.first_seen });
++ }
++ case 'ban': {
++ const actor = live.users.get(body.actor_user_id);
++ if (!actor) return send(403, { error: 'You do not have permission.' });
++ if (body.action === 'unban') { live.bans = live.bans.filter((b) => !(b.user_id === body.user_id && (b.stream_id === body.stream_id || b.stream_id == null))); banVersion++; return send(200, { ok: true }); }
++ live.addBan({ stream_id: body.stream_id || null, user_id: body.user_id || null, ip_address: body.ip_address || null, anon_id: body.anon_id || null, expires_at: body.expires_at || null });
++ return send(200, { ok: true });
++ }
++ case 'chat-message': return send(200, { coin: body.award ? { coins: 5, total: 105, streamerId: live.streams.get(body.stream_id)?.user_id || null } : null });
++ case 'user-color': { const u = live.users.get(body.user_id); if (u) u.profile_color = body.color; return send(200, { ok: true }); }
++ case 'channel-settings': { const pol = live.policies.get(body.channel_id) || {}; pol.settings = { ...(pol.settings || {}), ...body.fields }; live.policies.set(body.channel_id, pol); return send(200, { ok: true }); }
++ case 'paste': return send(200, { paste: { slug: 'abc123' } });
++ case 'translate': return send(200, { translation: null });
++ case 'ai/mod-command': return send(200, { reply: `AI viewers: ${(body.args || []).join(' ')} ok` });
++ case 'media-queue':
++ if (body.op === 'add') {
++ if (!body.input) return send(400, { error: 'Give me a link to queue.' });
++ return send(200, { request: { username: body.username, title: 'A Video', duration_seconds: 65, cost: 25 } });
++ }
++ if (body.op === 'state') return send(200, { state: live.mediaState });
++ if (body.op === 'skip') return send(200, { ended: live.mediaState.now_playing, next: live.mediaState.queue[0] || null });
++ return send(400, { error: 'unknown op' });
++ case 'hardware': return send(200, { ok: false, reason: 'no_hardware' });
++ case 'ensure-channel': {
++ let ch = [...live.channels.values()].find((c) => c.user_id === body.user_id);
++ if (!ch) { ch = { id: 100 + live.channels.size, user_id: body.user_id, title: 'New Channel' }; live.channels.set(ch.id, ch); }
++ return send(200, { channel: ch });
++ }
++ case 'site-settings': Object.assign(live.settings, body.settings || {}); return send(200, { ok: true, updated: Object.keys(body.settings || {}).length });
++ default: return send(200, { ok: true });
++ }
++ }
++ send(404, { error: 'not found' });
++ });
++
++ const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));
++ const networkPort = await listen(network);
++ const livePort = await listen(liveServer);
++
++ Object.assign(process.env, {
++ NODE_ENV: 'test',
++ PORT: '0',
++ HOST: '127.0.0.1',
++ BASE_URL: 'https://openvibe.live',
++ TRUST_PROXY: '2',
++ CHAT_DB_PATH: path.join(tmp, 'chat.db'),
++ CHAT_CACHE_DIR: path.join(tmp, 'cache'),
++ SOUNDS_PATH: path.join(tmp, 'sounds'),
++ OV_NETWORK_URL: ISS,
++ OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${networkPort}`,
++ OV_NETWORK_PUBLIC_KEY: path.join(tmp, 'network.pem'),
++ OV_OAUTH_CLIENT_ID: 'chat',
++ OV_OAUTH_CLIENT_SECRET: 'chat-secret',
++ OV_LIVE_INTERNAL_URL: `http://127.0.0.1:${livePort}`,
++ LIVE_MIRROR: '0',
++ EVENTS_URL: '',
++ SITE_URL: 'https://openvibe.live',
++ ...env,
++ });
++
++ const h = {
++ tmp, keys, live, network, liveServer, tokenRequests, serviceToken, ISS,
++ userSeq: 0,
++ /** Add a Live user (projection row) with a browser token; returns { id, token, ...row }. */
++ addUser(username, { role = 'user', subject = null, created_at = sqliteNow(-30 * 86400e3), apiScopes = null, is_owner = 0, display_name = null } = {}) {
++ const id = 1000 + (++h.userSeq);
++ const row = { id, username, display_name: display_name || username.charAt(0).toUpperCase() + username.slice(1), avatar_url: null, profile_color: '#8b5cf6', role, is_banned: 0, ban_reason: null, is_owner, created_at, subject_id: subject };
++ live.users.set(id, row);
++ const token = `tok-${username}-${crypto.randomBytes(4).toString('hex')}`;
++ live.tokens.set(token, { userId: id, apiScopes });
++ return { ...row, token };
++ },
++ addChannel(userId, { moderators = [], settings = null } = {}) {
++ const id = 10 + live.channels.size;
++ live.channels.set(id, { id, user_id: userId, title: 'Channel' });
++ live.policies.set(id, { settings, moderator_ids: moderators });
++ return id;
++ },
++ addStream(userId, channelId, { title = 'Live now', is_live = 1, managed = null } = {}) {
++ const id = 500 + live.streams.size;
++ if (managed) live.managed.set(managed.id, { user_id: userId, sort_order: 0, created_at: sqliteNow(), ...managed });
++ live.streams.set(id, { id, user_id: userId, channel_id: channelId, managed_stream_id: managed ? managed.id : null, title, is_live, started_at: sqliteNow(-3600e3), ended_at: null, created_at: sqliteNow(-3600e3) });
++ return id;
++ },
++ };
++
++ const index = require('../server/index');
++ const started = await index.start();
++ h.server = started.server;
++ h.mirrorRelay = started.mirror;
++ h.eventsRelay = started.relay;
++ h.port = started.server.address().port;
++ h.base = `http://127.0.0.1:${h.port}`;
++ h.db = require('../server/db/database');
++ h.ctx = require('../server/live-context');
++ h.chatServer = require('../server/chat/chat-server');
++
++ h.http = async (method, p, { token, body, headers = {}, raw } = {}) => {
++ const res = await fetch(`${h.base}${p}`, {
++ method,
++ headers: { ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers },
++ body: raw !== undefined ? raw : (body !== undefined ? JSON.stringify(body) : undefined),
++ });
++ const text = await res.text();
++ let json = null; try { json = JSON.parse(text); } catch { /* */ }
++ return { status: res.status, body: json, text, headers: res.headers };
++ };
++
++ const WebSocket = require('ws');
++ /** Open /ws/chat as a browser would (through nginx: the address arrives in CF-Connecting-IP). */
++ h.ws = ({ ip = '203.0.113.10', token = null, stream = null, origin = 'https://openvibe.live', query = '' } = {}) => new Promise((resolve, reject) => {
++ const qs = new URLSearchParams();
++ if (stream) qs.set('stream', stream);
++ if (token) qs.set('token', token);
++ const url = `ws://127.0.0.1:${h.port}/ws/chat${qs.toString() ? `?${qs}` : ''}${query}`;
++ const ws = new WebSocket(url, { headers: { 'cf-connecting-ip': ip, origin } });
++ const all = [];
++ const waiters = [];
++ ws.on('message', (d) => {
++ let m; try { m = JSON.parse(d.toString()); } catch { return; }
++ all.push(m);
++ for (const w of [...waiters]) if (w.pred(m)) { waiters.splice(waiters.indexOf(w), 1); clearTimeout(w.timer); w.resolve(m); }
++ });
++ ws.all = all;
++ ws.next = (pred, ms = 3000) => {
++ const hit = all.find((m) => pred(m) && !m.__taken);
++ if (hit) { hit.__taken = true; return Promise.resolve(hit); }
++ return new Promise((res, rej) => {
++ const w = { pred: (m) => { if (pred(m)) { m.__taken = true; return true; } return false; }, resolve: res };
++ w.timer = setTimeout(() => { waiters.splice(waiters.indexOf(w), 1); rej(new Error(`timed out waiting (got: ${JSON.stringify(all.map((x) => [x.type, x.message]).slice(-12))})`)); }, ms);
++ waiters.push(w);
++ });
++ };
++ ws.none = async (pred, ms = 300) => { await new Promise((r) => setTimeout(r, ms)); return !all.some(pred); };
++ ws.sendJson = (o) => ws.send(JSON.stringify(o));
++ ws.on('open', () => resolve(ws));
++ ws.on('error', (e) => reject(e));
++ ws.on('unexpected-response', (req, res) => reject(new Error(`upgrade refused ${res.statusCode}`)));
++ });
++ /** Whether an upgrade is refused (socket destroyed). */
++ h.wsRefused = (opts) => h.ws(opts).then((ws) => new Promise((r) => { const t = setTimeout(() => { ws.close(); r(false); }, 300); ws.on('close', () => { clearTimeout(t); r(true); }); }), () => true);
++
++ h.close = async () => {
++ try { h.chatServer.close(); } catch { /* */ }
++ try { h.ctx.stop(); h.mirrorRelay.stop(); h.eventsRelay.stop(); } catch { /* */ }
++ await new Promise((r) => h.server.close(() => r()));
++ network.close(); liveServer.close();
++ try { h.db.close(); } catch { /* */ }
++ fs.rmSync(tmp, { recursive: true, force: true });
++ };
++ h.sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++ h.sqliteNow = sqliteNow;
++ return h;
++}
++
++/** Tiny runner: named steps, stop at the first failure, exit code for test/run.js. */
++function suite(name) {
++ const steps = [];
++ const t = (label, fn) => steps.push({ label, fn });
++ t.run = async (cleanup) => {
++ let failed = 0;
++ for (const s of steps) {
++ try { await s.fn(); process.stdout.write(` ✓ ${s.label}\n`); } catch (err) { failed++; process.stdout.write(` ✗ ${s.label}\n${err && err.stack || err}\n`); break; }
++ }
++ try { if (cleanup) await cleanup(); } catch { /* */ }
++ process.stdout.write(`${name}: ${failed ? 'FAILED' : `${steps.length} checks passed`}\n`);
++ process.exit(failed ? 1 : 0);
++ };
++ return t;
++}
++
++module.exports = { boot, suite, sqliteNow, b64url };
ddiff --git a/test/import.test.js b/test/import.test.js
nnew file mode 100644
iindex 0000000..b3c54ab
---- /dev/null
++++ b/test/import.test.js
@@@ -0,0 +1,167 @@
++'use strict';
++/**
++ * scripts/import-from-live.js against a fake Live snapshot built from Live's real table
++ * definitions (test/fixtures/live-chat-schema.sql): counts per table, dry run writes nothing,
++ * ids kept, Network subjects filled in, id headroom, projections seeded, idempotent re-runs,
++ * Chat's edits win on chat tables while staged tables refresh from Live, conflicting rows and
++ * transient-table rows held (never dropped), rows Live wrote after the first pass brought over,
++ * schema drift refused.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync } = require('child_process');
++const Database = require('better-sqlite3');
++const { suite } = require('./helpers');
++
++const t = suite('import');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chat-import-'));
++const liveDb = path.join(tmp, 'live-snapshot.db');
++const chatDb = path.join(tmp, 'chat.db');
++const SUBJ = 'usr_01J9SUBJECTSUBJECTSUBJECTS';
++
++function importer(args) {
++ const r = spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'import-from-live.js'), '--live-db', liveDb, '--chat-db', chatDb, ...args], {
++ cwd: path.join(__dirname, '..'), encoding: 'utf8', env: { ...process.env, NODE_ENV: 'test', OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9' },
++ });
++ let report = null;
++ try { report = JSON.parse(r.stdout); } catch { /* */ }
++ return { code: r.status, report, stderr: r.stderr, stdout: r.stdout };
++}
++
++let live;
++t('build a Live snapshot with every chat table', () => {
++ live = new Database(liveDb);
++ live.pragma('foreign_keys = OFF');
++ live.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'live-chat-schema.sql'), 'utf8'));
++ live.exec('ALTER TABLE channel_sounds ADD COLUMN media_url TEXT; ALTER TABLE channel_sounds ADD COLUMN media_asset_id INTEGER;');
++ const u = live.prepare("INSERT INTO users (id, username, password_hash, display_name, role) VALUES (?, ?, '!', ?, ?)");
++ u.run(1, 'streamer', 'Streamer', 'streamer'); u.run(2, 'alice', 'Alice', 'user'); u.run(3, 'bob', 'Bob', 'user');
++ live.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '77', ?)").run(SUBJ);
++ live.prepare("INSERT INTO channels (id, user_id, title) VALUES (5, 1, 'TV')").run();
++ live.prepare("INSERT INTO managed_streams (id, user_id, channel_id, slug, title, stream_key) VALUES (9, 1, 5, 'main', 'Main', 'k')").run();
++ live.prepare("INSERT INTO streams (id, user_id, channel_id, managed_stream_id, title, is_live) VALUES (40, 1, 5, 9, 'Show', 1)").run();
++ const msg = live.prepare('INSERT INTO chat_messages (id, stream_id, channel_user_id, user_id, anon_id, username, message, message_type, metadata, is_global, is_deleted, reply_to_id, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
++ msg.run(101, 40, 1, 2, null, 'Alice', 'first', 'chat', null, 0, 0, null, '2026-09-01 10:00:00');
++ msg.run(102, 40, 1, 3, null, 'Bob', 'reply', 'chat', null, 0, 0, 101, '2026-09-01 10:00:05');
++ msg.run(103, null, null, null, 'anon4', 'anon4', 'global hi', 'chat', null, 1, 0, null, '2026-09-01 10:01:00');
++ msg.run(104, 40, 1, null, null, 'Donor', 'tipped 5 Vibes', 'donation', '{"kind":"donation","amount":5}', 0, 0, null, '2026-09-01 10:02:00');
++ msg.run(105, 40, 1, 2, null, 'Alice', 'deleted one', 'chat', null, 0, 1, null, '2026-09-01 10:03:00');
++ live.prepare("INSERT INTO dm_conversations (id, created_by, created_at) VALUES (7, 2, '2026-09-02 00:00:00')").run();
++ live.prepare('INSERT INTO dm_participants (conversation_id, user_id) VALUES (7, 2), (7, 3)').run();
++ live.prepare("INSERT INTO dm_messages (id, conversation_id, sender_id, message, created_at) VALUES (70, 7, 2, 'psst', '2026-09-02 00:00:01')").run();
++ live.prepare('INSERT INTO dm_blocks (blocker_id, blocked_id) VALUES (3, 1)').run();
++ live.prepare("INSERT INTO tts_voice_overrides (identity_key, voice, pitch, speed) VALUES ('user:alice', 'en+f3', 60, 170)").run();
++ live.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, created_by, media_url, media_asset_id) VALUES (11, 1, 'honk', '/opt/openvibe.live/data/sounds/snd-1.mp3', 2, 'https://openvibe.media/a/1', 555)").run();
++ live.prepare("INSERT INTO relay_users (platform, username, display_name, message_count) VALUES ('twitch', 'zed', 'Zed', 3)").run();
++ live.prepare("INSERT INTO hidden_relay_users (channel_id, platform, external_username) VALUES (5, 'twitch', 'spammer')").run();
++ live.prepare("INSERT INTO pending_ip_messages (channel_id, stream_id, ip_address, username, message) VALUES (5, 40, '203.0.113.5', 'anon9', 'let me in')").run();
++ live.prepare("INSERT INTO stream_first_chats (chatter_key, channel_user_id) VALUES ('user:2', 1)").run();
++ live.prepare("INSERT INTO moderation_actions (scope_type, actor_user_id, target_user_id, action_type, details) VALUES ('channel', 2, 3, 'channel_ban', '{}')").run();
++ live.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (5, 2, 1)').run();
++ live.prepare('INSERT INTO channel_moderation_settings (channel_id, slow_mode_seconds) VALUES (5, 3)').run();
++ live.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (1, 'pog', '/e/pog.png', 1)").run();
++ live.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (2, 'og')").run();
++ live.prepare("INSERT INTO chat_ai_summaries (scope, subject_id, window, overview) VALUES ('user', 2, '7d', 'nice')").run();
++ live.prepare("INSERT INTO chat_timeline_events (scope, ts, label) VALUES ('global', '2026-09-01 10:00:00', 'hype')").run();
++ live.prepare("INSERT INTO media_requests (streamer_id, user_id, username, input, canonical_url, provider, title) VALUES (1, 2, 'Alice', 'x', 'https://youtu.be/x', 'youtube', 'X')").run();
++ live.exec("CREATE TABLE chat_messages_new AS SELECT * FROM chat_messages WHERE id = 101");
++});
++
++t('dry run reports and writes nothing', () => {
++ const r = importer(['--dry-run']);
++ assert.strictEqual(r.code, 0, r.stderr);
++ assert.strictEqual(r.report.dry_run, true);
++ assert.strictEqual(r.report.tables.chat_messages.inserted, 5);
++ assert.strictEqual(r.report.tables.chat_messages_new.held, 1);
++ const c = new Database(chatDb, { readonly: true });
++ assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, 0);
++ assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM import_hold').get().n, 0);
++ assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM import_runs').get().n, 0);
++ c.close();
++});
++
++t('import copies every table with ids kept, subjects filled, headroom set', () => {
++ const r = importer(['--headroom', '1000']);
++ assert.strictEqual(r.code, 0, r.stderr);
++ const T = r.report.tables;
++ const expect = { chat_messages: 5, dm_conversations: 1, dm_participants: 2, dm_messages: 1, dm_blocks: 1, tts_voice_overrides: 1, channel_sounds: 1, relay_users: 1, hidden_relay_users: 1, pending_ip_messages: 1, stream_first_chats: 1, moderation_actions: 1, channel_moderators: 1, channel_moderation_settings: 1, emotes: 1, user_tags: 1, chat_ai_summaries: 1, chat_timeline_events: 1 };
++ for (const [tbl, n] of Object.entries(expect)) {
++ assert.strictEqual(T[tbl].live, n, `${tbl} live`);
++ assert.strictEqual(T[tbl].inserted, n, `${tbl} inserted`);
++ }
++ assert.strictEqual(T.media_requests.not_moved.startsWith('stays in Live'), true);
++ assert.strictEqual(T.chat_messages_new.held, 1);
++ assert.strictEqual(T.chat_messages.next_id, 105 + 1000 + 1);
++ const c = new Database(chatDb, { readonly: true });
++ const m = c.prepare('SELECT * FROM chat_messages WHERE id = 102').get();
++ assert.strictEqual(m.reply_to_id, 101);
++ assert.strictEqual(m.user_id, 3);
++ assert.strictEqual(c.prepare('SELECT subject_id FROM chat_messages WHERE id = 101').get().subject_id, SUBJ);
++ assert.strictEqual(c.prepare('SELECT is_deleted FROM chat_messages WHERE id = 105').get().is_deleted, 1, 'deleted rows are kept, still deleted');
++ assert.strictEqual(c.prepare('SELECT metadata FROM chat_messages WHERE id = 104').get().metadata, '{"kind":"donation","amount":5}');
++ assert.strictEqual(c.prepare('SELECT sender_subject_id FROM dm_messages WHERE id = 70').get().sender_subject_id, SUBJ);
++ assert.strictEqual(c.prepare('SELECT actor_subject_id FROM moderation_actions').get().actor_subject_id, SUBJ);
++ assert.strictEqual(c.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'chat_messages'").get().seq, 1105);
++ const hold = c.prepare('SELECT * FROM import_hold').all();
++ assert.strictEqual(hold.length, 1);
++ assert.strictEqual(hold[0].source_table, 'chat_messages_new');
++ assert.match(hold[0].reason, /transient migration table/);
++ assert.strictEqual(c.prepare('SELECT username FROM ctx_users WHERE id = 2').get().username, 'alice', 'projections seeded');
++ assert.strictEqual(c.prepare('SELECT subject_id FROM ctx_users WHERE id = 2').get().subject_id, SUBJ);
++ assert.strictEqual(c.prepare('SELECT slug FROM ctx_managed_streams WHERE id = 9').get().slug, 'main');
++ assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM live_mirror_outbox').get().n, 0, 'imported rows are never mirrored back to Live');
++ assert.strictEqual(c.prepare('SELECT COUNT(*) AS n FROM events_outbox').get().n, 0, 'imported history is not re-announced');
++ c.close();
++});
++
++t('re-running is idempotent', () => {
++ const r = importer(['--headroom', '1000']);
++ assert.strictEqual(r.code, 0, r.stderr);
++ for (const [tbl, v] of Object.entries(r.report.tables)) {
++ if (v.inserted !== undefined) assert.strictEqual(v.inserted, 0, `${tbl} inserted again`);
++ }
++ assert.strictEqual(r.report.tables.chat_messages.identical, 5);
++ assert.strictEqual(r.report.held_total, 1, 'holds are not duplicated');
++});
++
++t('after the cutover: Chat’s edits win, staged tables refresh, conflicts are held, late Live rows come over', () => {
++ const c = new Database(chatDb);
++ c.prepare('UPDATE chat_messages SET is_deleted = 1 WHERE id = 101').run(); // a moderator deleted it in Chat
++ c.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (106, 3, 'Bob', 'written in chat')").run();
++ c.close();
++ live.prepare('UPDATE channel_moderation_settings SET slow_mode_seconds = 10 WHERE channel_id = 5').run();
++ live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (106, 2, 'Alice', 'a different 106', '2026-09-03 00:00:00')").run();
++ live.prepare("INSERT INTO chat_messages (id, user_id, username, message, timestamp) VALUES (107, 2, 'Alice', 'written in Live after the first pass', '2026-09-03 00:00:01')").run();
++ const r = importer(['--headroom', '1000']);
++ assert.strictEqual(r.code, 0, r.stderr);
++ const T = r.report.tables;
++ assert.strictEqual(T.chat_messages.chat_kept, 1);
++ assert.strictEqual(T.chat_messages.held, 1);
++ assert.strictEqual(T.chat_messages.inserted, 1);
++ assert.strictEqual(T.channel_moderation_settings.refreshed, 1);
++ const d = new Database(chatDb, { readonly: true });
++ assert.strictEqual(d.prepare('SELECT is_deleted FROM chat_messages WHERE id = 101').get().is_deleted, 1);
++ assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 106').get().message, 'written in chat');
++ assert.strictEqual(d.prepare('SELECT message FROM chat_messages WHERE id = 107').get().message, 'written in Live after the first pass');
++ assert.strictEqual(d.prepare('SELECT slow_mode_seconds FROM channel_moderation_settings WHERE channel_id = 5').get().slow_mode_seconds, 10);
++ const held = d.prepare("SELECT * FROM import_hold WHERE source_table = 'chat_messages'").get();
++ assert.match(held.reason, /different row/);
++ assert.strictEqual(JSON.parse(held.row_json).message, 'a different 106');
++ assert.strictEqual(d.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'chat_messages'").get().seq, 1107, 'headroom follows Live’s new max, never lowered');
++ d.close();
++});
++
++t('schema drift is refused before anything is written', () => {
++ live.exec('ALTER TABLE chat_messages ADD COLUMN surprise TEXT');
++ live.prepare("INSERT INTO chat_messages (id, username, message, surprise) VALUES (108, 'x', 'y', 'z')").run();
++ const r = importer([]);
++ assert.strictEqual(r.code, 1);
++ assert.match(r.stderr, /chat_messages: surprise/);
++ const d = new Database(chatDb, { readonly: true });
++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_messages WHERE id = 108').get().n, 0);
++ d.close();
++});
++
++t.run(() => { try { live.close(); } catch { /* */ } fs.rmSync(tmp, { recursive: true, force: true }); });
ddiff --git a/test/live-context.test.js b/test/live-context.test.js
nnew file mode 100644
iindex 0000000..3f056ff
---- /dev/null
++++ b/test/live-context.test.js
@@@ -0,0 +1,119 @@
++'use strict';
++/**
++ * The Live adapter's promises: a chat message makes no read call to Live (only its one effect),
++ * warm caches answer while Live is slow or down (stale values, no gaps on invalidation), and ban
++ * evaluation matches Live's SQL — including the TEXT comparison of expires_at that makes an ISO
++ * timeout outlast its duration until the end of that UTC day.
++ */
++const assert = require('assert');
++const { boot, suite } = require('./helpers');
++
++const t = suite('live-context');
++let h, streamer, alice, mod, channelId, streamId, aliceWs, watcher;
++
++t('boot', async () => {
++ h = await boot();
++ streamer = h.addUser('streamer', { role: 'streamer' });
++ alice = h.addUser('alice');
++ mod = h.addUser('moddy');
++ channelId = h.addChannel(streamer.id, { moderators: [mod.id], settings: { max_message_length: 60 } });
++ streamId = h.addStream(streamer.id, channelId);
++ h.live.follows.set(alice.id, [streamer.id]);
++ await h.ctx.sync();
++});
++
++t('no read call to Live per chat message (only the one effect)', async () => {
++ aliceWs = await h.ws({ ip: '198.51.100.70', token: alice.token, stream: streamId });
++ aliceWs.sendJson({ type: 'join', streamId, token: alice.token });
++ await aliceWs.next((m) => m.type === 'auth');
++ watcher = await h.ws({ ip: '198.51.100.71', stream: streamId });
++ watcher.sendJson({ type: 'join', streamId });
++ await watcher.next((m) => m.type === 'auth');
++ await h.sleep(300);
++ const mark = h.live.requests.length;
++ for (let i = 0; i < 3; i++) {
++ aliceWs.sendJson({ type: 'chat', message: `msg ${i}` });
++ await watcher.next((m) => m.type === 'chat' && m.message === `msg ${i}`);
++ await h.sleep(1050);
++ }
++ const during = h.live.requests.slice(mark);
++ const reads = during.filter((p) => p.startsWith('/internal/chat-context/') && !/\/(streams|users|managed-streams|channels|bans|settings)(\/active)?$/.test(p.replace(/\?.*$/, '')));
++ assert.deepStrictEqual(reads, [], `per-message reads: ${JSON.stringify(reads)}`);
++ assert.strictEqual(during.filter((p) => p === '/internal/chat-effects/chat-message').length, 3, 'one effect per message');
++});
++
++t('channel rules come from the warm policy: message length', async () => {
++ aliceWs.sendJson({ type: 'chat', message: 'x'.repeat(61) });
++ await aliceWs.next((m) => m.type === 'system' && m.message === 'Message too long. Max 60 characters.');
++});
++
++t('Live down: chat keeps flowing from caches; new tokens fall back to anonymous', async () => {
++ h.live.down = true;
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: 'while Live is down' });
++ await watcher.next((m) => m.type === 'chat' && m.message === 'while Live is down');
++ const late = h.addUser('late');
++ const ws = await h.ws({ ip: '198.51.100.72', token: late.token });
++ ws.sendJson({ type: 'join', token: late.token });
++ const auth = await ws.next((m) => m.type === 'auth', 8000);
++ assert.strictEqual(auth.authenticated, false);
++ assert.match(auth.username, /^anon9\d{8}$/, 'a temporary anon number while Live cannot allocate one');
++ ws.close();
++ h.live.down = false;
++});
++
++t('invalidation never leaves a gap: a moderator stays a moderator while the policy reloads', async () => {
++ h.ctx.invalidateChannel(channelId);
++ assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);
++ await h.sleep(100);
++ assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), true);
++ // A moderator removed in Live loses the power after the reload.
++ h.live.policies.get(channelId).moderator_ids = [];
++ h.ctx.invalidateChannel(channelId);
++ await h.sleep(150);
++ assert.strictEqual(h.ctx.isChannelModerator(mod.id, channelId), false);
++});
++
++t('bans: user/stream/site scope, CIDR, and Live’s TEXT comparison of expires_at', async () => {
++ const past = h.sqliteNow(-60e3);
++ const future = h.sqliteNow(3600e3);
++ h.live.addBan({ user_id: alice.id, stream_id: streamId, expires_at: future });
++ h.live.addBan({ user_id: mod.id, expires_at: past });
++ h.live.addBan({ ip_address: '2001:db8:1::/48' });
++ // A timeout written as ISO (what /timeout stores) a minute AGO still sorts after today's
++ // 'YYYY-MM-DD HH:MM:SS' ('T' > ' '), so Live treated it as active until the day ended.
++ const isoPast = new Date(Date.now() - 60e3).toISOString();
++ const other = h.addUser('other');
++ h.live.addBan({ user_id: other.id, expires_at: isoPast });
++ await h.ctx.invalidateBans();
++ assert.strictEqual(h.ctx.isUserBanned(alice.id, streamId), true);
++ assert.strictEqual(h.ctx.isUserBanned(alice.id, 999), false, 'stream ban stays in its stream');
++ assert.strictEqual(h.ctx.isUserBanned(mod.id, streamId), false, 'expired');
++ assert.strictEqual(h.ctx.isIpBanned('2001:db8:1:ffff::1', null), true);
++ assert.strictEqual(h.ctx.isIpBanned('::ffff:10.0.0.1', null), false);
++ if (isoPast.slice(0, 10) === new Date().toISOString().slice(0, 10)) {
++ assert.strictEqual(h.ctx.isUserBanned(other.id, null), true, 'same-day ISO expiry behaves as in Live');
++ }
++ h.live.clearBans();
++ await h.ctx.invalidateBans();
++ assert.strictEqual(h.ctx.isUserBanned(alice.id, streamId), false);
++});
++
++t('followers-only uses the viewer’s warm follow list', async () => {
++ h.live.policies.get(channelId).settings = { followers_only: 1 };
++ h.ctx.invalidateChannel(channelId);
++ await h.sleep(150);
++ const stranger = h.addUser('stranger');
++ await h.ctx.sync();
++ const ws = await h.ws({ ip: '198.51.100.73', token: stranger.token, stream: streamId });
++ ws.sendJson({ type: 'join', streamId, token: stranger.token });
++ await ws.next((m) => m.type === 'auth');
++ ws.sendJson({ type: 'chat', message: 'let me talk' });
++ await ws.next((m) => m.type === 'system' && m.message === 'This chat is currently followers-only.');
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: 'I follow' });
++ await watcher.next((m) => m.type === 'chat' && m.message === 'I follow');
++ ws.close();
++});
++
++t.run(async () => { for (const w of [aliceWs, watcher]) { try { w.close(); } catch { /* */ } } if (h) await h.close(); });
ddiff --git a/test/outbox-mirror.test.js b/test/outbox-mirror.test.js
nnew file mode 100644
iindex 0000000..d6e994b
---- /dev/null
++++ b/test/outbox-mirror.test.js
@@@ -0,0 +1,137 @@
++'use strict';
++/**
++ * Events outbox (events.event-envelope@1, written in the same transaction as the change; relayed
++ * to OpenVibe.Events only when EVENTS_URL is set, with a service token for audience
++ * openvibe.events) and the Live read mirror (every change to Chat's tables copied to Live's
++ * tables of the same name; held while Live refuses, drained when it accepts).
++ */
++const assert = require('assert');
++const http = require('http');
++const { validate } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('outbox-mirror');
++let h, events, eventsPort, alice, bob, streamer, streamId;
++const received = [];
++let eventsMode = 'ok';
++
++t('boot with the mirror on and a stub OpenVibe.Events', async () => {
++ events = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ res.setHeader('Content-Type', 'application/json');
++ if (eventsMode !== 'ok') { res.statusCode = 503; return res.end('{"error":"down"}'); }
++ const { serviceAuth } = require('openvibe-contracts');
++ const r = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: h.keys.publicKey, issuer: h.ISS, audience: 'openvibe.events' });
++ if (!r.ok || !r.claims.cap.includes('events.event.publish')) { res.statusCode = 401; return res.end('{}'); }
++ const body = JSON.parse(raw);
++ received.push(...body.events);
++ res.statusCode = 201;
++ res.end(JSON.stringify({ accepted: body.events.length }));
++ });
++ });
++ eventsPort = await new Promise((r) => events.listen(0, '127.0.0.1', () => r(events.address().port)));
++ h = await boot({ env: { LIVE_MIRROR: '1', LIVE_MIRROR_INTERVAL_MS: '3600000', EVENTS_URL: `http://127.0.0.1:${eventsPort}`, EVENTS_RELAY_INTERVAL_MS: '3600000' } });
++ streamer = h.addUser('streamer', { role: 'streamer' });
++ alice = h.addUser('alice', { subject: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
++ bob = h.addUser('bob');
++ streamId = h.addStream(streamer.id, h.addChannel(streamer.id));
++ await h.ctx.sync();
++});
++
++t('a REST fallback message, a DM and a moderation action each leave one envelope', async () => {
++ const sent = await h.http('POST', '/api/chat/send', { token: alice.token, body: { message: 'rest hello' } });
++ assert.deepStrictEqual(sent.body, { ok: true });
++ const conv = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
++ await h.http('POST', `/api/dm/conversations/${conv.body.conversation.id}/messages`, { token: alice.token, body: { message: 'dm text' } });
++ h.db.logModerationAction({ scope_type: 'site', actor_user_id: alice.id, target_user_id: bob.id, action_type: 'site_timeout', details: { duration: 60 } });
++ const rows = h.db.all('SELECT event_type, event, sent_at FROM events_outbox ORDER BY seq');
++ assert.deepStrictEqual(rows.map((r) => r.event_type), ['chat.message.created', 'chat.dm.created', 'chat.moderation.action']);
++ for (const r of rows) {
++ const env = JSON.parse(r.event);
++ const v = validate('events.event-envelope@1', env);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ assert.strictEqual(r.sent_at, null);
++ }
++ const [msg, dm, mod] = rows.map((r) => JSON.parse(r.event));
++ assert.strictEqual(msg.visibility, 'public');
++ assert.deepStrictEqual(msg.payload.room, { type: 'global' });
++ assert.strictEqual(msg.payload.text, 'rest hello');
++ assert.strictEqual(dm.visibility, 'subject');
++ assert.ok(!JSON.stringify(dm).includes('dm text'));
++ assert.deepStrictEqual(dm.payload.participants.map((p) => p.user_id).sort(), [alice.id, bob.id].sort());
++ assert.strictEqual(mod.visibility, 'internal');
++ assert.deepStrictEqual(mod.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
++ assert.deepStrictEqual(mod.actor.type, 'user');
++ assert.deepStrictEqual(dm.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
++});
++
++t('Events down: rows wait with the error; back up: published once, marked sent', async () => {
++ eventsMode = 'down';
++ await h.eventsRelay.flush();
++ const waiting = h.db.all('SELECT attempts, last_error, sent_at FROM events_outbox');
++ assert.ok(waiting.every((r) => r.attempts === 1 && /503/.test(r.last_error) && r.sent_at === null));
++ eventsMode = 'ok';
++ const r = await h.eventsRelay.flush();
++ assert.strictEqual(r.sent, 3);
++ assert.strictEqual(received.length, 3);
++ assert.ok(h.db.all('SELECT sent_at FROM events_outbox').every((x) => x.sent_at));
++ assert.strictEqual((await h.eventsRelay.flush()).sent, 0, 'nothing twice');
++ assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.events' && x.scope === 'events.event.publish'));
++});
++
++t('mirror: every change to Chat’s tables reaches Live, newest state per row', async () => {
++ const id = Number(h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'to be deleted', message_type: 'chat' }).lastInsertRowid);
++ h.db.deleteChatMessage(id, streamer.id);
++ h.db.recordFirstChat(`user:${bob.id}`, streamer.id);
++ await h.mirrorRelay.flush();
++ const byTable = (tbl) => h.live.mirror.filter((c) => c.table === tbl);
++ const msgChanges = byTable('chat_messages').filter((c) => c.row && c.row.id === id);
++ assert.strictEqual(msgChanges.length, 1, 'insert + update collapsed into one upsert');
++ assert.strictEqual(msgChanges[0].row.is_deleted, 1);
++ assert.strictEqual(msgChanges[0].row.channel_user_id, streamer.id);
++ assert.ok(byTable('chat_messages').some((c) => c.row && c.row.message === 'rest hello'));
++ assert.ok(byTable('dm_messages').length === 1 && byTable('dm_participants').length === 2 && byTable('dm_conversations').length >= 1);
++ assert.ok(byTable('moderation_actions').length === 1);
++ assert.deepStrictEqual(byTable('stream_first_chats')[0].row.chatter_key, `user:${bob.id}`);
++ assert.strictEqual(h.mirrorRelay.pending(), 0);
++ // A delete travels as a delete.
++ const dmId = h.db.get('SELECT id FROM dm_messages').id;
++ const conv = h.db.get('SELECT conversation_id FROM dm_messages').conversation_id;
++ assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${conv}/messages/${dmId}`, { token: alice.token })).status, 200);
++ await h.mirrorRelay.flush();
++ assert.deepStrictEqual(h.live.mirror.at(-1), { table: 'dm_messages', op: 'delete', pk: { id: dmId } });
++});
++
++t('mirror: Live refusing (not yet CHAT_AUTHORITY=chat) keeps the queue; accepted later', async () => {
++ h.live.mirrorStatus = 409;
++ h.db.recordRelayUser('twitch', 'Zed');
++ const r = await h.mirrorRelay.flush();
++ assert.ok(r.pending >= 1 && /409/.test(r.error));
++ h.live.mirrorStatus = 200;
++ const r2 = await h.mirrorRelay.flush();
++ assert.strictEqual(r2.pending, 0);
++ assert.deepStrictEqual(h.live.mirror.at(-1).row.username, 'zed');
++ assert.ok(h.tokenRequests.some((x) => x.audience === 'openvibe.live' && x.scope === 'live.chat_mirror.write'));
++});
++
++t('scripts/mirror-flush.js drains the queue without the service (rollback)', async () => {
++ const { spawn } = require('child_process');
++ const path = require('path');
++ h.live.mirrorStatus = 409;
++ h.db.recordRelayUser('twitch', 'Late');
++ await h.mirrorRelay.flush();
++ assert.ok(h.mirrorRelay.pending() >= 1);
++ h.live.mirrorStatus = 200;
++ const r = await new Promise((resolve) => {
++ const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'mirror-flush.js')], { env: { ...process.env } });
++ let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { out += d; }); c.on('close', (code) => resolve({ code, out }));
++ });
++ assert.strictEqual(r.code, 0, r.out);
++ assert.strictEqual(JSON.parse(r.out.slice(r.out.indexOf('{'))).pending, 0);
++ assert.strictEqual(h.mirrorRelay.pending(), 0);
++ assert.strictEqual(h.live.mirror.at(-1).row.username, 'late');
++});
++
++t.run(async () => { if (h) await h.close(); events.close(); });
ddiff --git a/test/rest.test.js b/test/rest.test.js
nnew file mode 100644
iindex 0000000..fcee91c
---- /dev/null
++++ b/test/rest.test.js
@@@ -0,0 +1,209 @@
++'use strict';
++/**
++ * REST parity for the routes nginx sends to Chat: /api/dm/* (conversations, messages, read,
++ * participants, blocks, unread, user search), /api/chat/* history (global page + cursor delta,
++ * stream scopes, replay, search/logs gates), profile card and anon info through Live, GIF
++ * provider flags from Live's settings, /api/tts settings, and Live's auth answers (401/403 shapes,
++ * API-token scopes).
++ */
++const assert = require('assert');
++const { boot, suite } = require('./helpers');
++
++const t = suite('rest');
++let h, streamer, alice, bob, carol, staff, apiBot, channelId, streamId;
++
++t('boot', async () => {
++ h = await boot();
++ streamer = h.addUser('streamer', { role: 'streamer' });
++ alice = h.addUser('alice');
++ bob = h.addUser('bob');
++ carol = h.addUser('carol');
++ staff = h.addUser('staff', { role: 'global_mod' });
++ apiBot = h.addUser('botowner', { apiScopes: ['read'] });
++ channelId = h.addChannel(streamer.id);
++ streamId = h.addStream(streamer.id, channelId);
++ h.live.settings.gif_tenor_api_key = 'tenor-key';
++ await h.ctx.sync();
++});
++
++t('auth errors keep Live’s shapes', async () => {
++ assert.deepStrictEqual((await h.http('GET', '/api/dm/conversations')).body, { error: 'Authentication required' });
++ const bad = await h.http('GET', '/api/dm/conversations', { token: 'nope' });
++ assert.strictEqual(bad.status, 401);
++ assert.deepStrictEqual(bad.body, { error: 'Invalid or expired token' });
++ // An API token with only a read scope can read but not write chat.
++ assert.strictEqual((await h.http('GET', '/api/dm/unread', { token: apiBot.token })).status, 200);
++ const w = await h.http('POST', '/api/dm/conversations', { token: apiBot.token, body: { user_ids: [alice.id] } });
++ assert.strictEqual(w.status, 403);
++ assert.deepStrictEqual(w.body, { error: 'This API token\'s scopes do not allow this request' });
++});
++
++let convId, groupId;
++t('DMs: create 1:1 (idempotent), send, list with unread, read, delete own message', async () => {
++ const a = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
++ assert.strictEqual(a.status, 200, a.text);
++ convId = a.body.conversation.id;
++ const again = await h.http('POST', '/api/dm/conversations', { token: bob.token, body: { user_ids: [alice.id] } });
++ assert.strictEqual(again.body.conversation.id, convId, 'the same 1:1 conversation');
++ const sent = await h.http('POST', `/api/dm/conversations/${convId}/messages`, { token: alice.token, body: { message: ' hi bob ' } });
++ assert.strictEqual(sent.body.message.message, 'hi bob');
++ assert.strictEqual(sent.body.message.username, 'alice');
++ assert.strictEqual(sent.body.message.display_name, 'Alice');
++ const list = await h.http('GET', '/api/dm/conversations', { token: bob.token });
++ const c = list.body.conversations.find((x) => x.id === convId);
++ assert.strictEqual(c.unread_count, 1);
++ assert.strictEqual(c.last_message, 'hi bob');
++ assert.deepStrictEqual(c.participants.map((p) => p.username).sort(), ['alice', 'bob']);
++ assert.strictEqual((await h.http('GET', '/api/dm/unread', { token: bob.token })).body.unread, 1);
++ assert.deepStrictEqual((await h.http('POST', `/api/dm/conversations/${convId}/read`, { token: bob.token })).body, { ok: true });
++ assert.strictEqual((await h.http('GET', '/api/dm/unread', { token: bob.token })).body.unread, 0);
++ assert.ok(h.live.effects.some((e) => e.name === 'notify/dm-read' && e.body.user_id === bob.id && e.body.conversation_id === convId));
++ const msgs = await h.http('GET', `/api/dm/conversations/${convId}/messages`, { token: bob.token });
++ assert.strictEqual(msgs.body.messages.length, 1);
++ assert.ok(!('sender_subject_id' in msgs.body.messages[0]));
++ const mid = msgs.body.messages[0].id;
++ assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${convId}/messages/${mid}`, { token: bob.token })).status, 403, 'only the sender deletes');
++ assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${convId}/messages/${mid}`, { token: alice.token })).status, 200);
++});
++
++t('DMs: participant checks on every conversation route', async () => {
++ for (const [m, p, body] of [
++ ['GET', `/api/dm/conversations/${convId}`],
++ ['GET', `/api/dm/conversations/${convId}/messages`],
++ ['POST', `/api/dm/conversations/${convId}/messages`, { message: 'x' }],
++ ['POST', `/api/dm/conversations/${convId}/read`],
++ ['POST', `/api/dm/conversations/${convId}/participants`, { user_id: carol.id }],
++ ['PATCH', `/api/dm/conversations/${convId}`, { name: 'x' }],
++ ]) {
++ const r = await h.http(m, p, { token: carol.token, body });
++ assert.strictEqual(r.status, 403, `${m} ${p}`);
++ assert.deepStrictEqual(r.body, { error: 'Not a participant' });
++ }
++ // A 1:1 stays private: nobody can be added to it.
++ const add = await h.http('POST', `/api/dm/conversations/${convId}/participants`, { token: alice.token, body: { user_id: carol.id } });
++ assert.strictEqual(add.status, 400);
++ assert.deepStrictEqual(add.body, { error: 'Start a group conversation to add people' });
++});
++
++t('DMs: groups, blocks, banned users, user search', async () => {
++ const g = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id, carol.id], name: 'The <b>Crew</b>' } });
++ groupId = g.body.conversation.id;
++ assert.strictEqual(g.body.conversation.is_group, 1);
++ assert.strictEqual(g.body.conversation.name, 'The Crew', 'HTML and odd characters stripped as before');
++ // Blocks: bob blocks carol → carol cannot start a 1:1 with bob, bob no longer finds carol.
++ assert.deepStrictEqual((await h.http('POST', `/api/dm/blocks/${carol.id}`, { token: bob.token })).body, { ok: true });
++ assert.strictEqual((await h.http('GET', `/api/dm/blocks/check/${carol.id}`, { token: bob.token })).body.blocked, true);
++ assert.deepStrictEqual((await h.http('GET', '/api/dm/blocks', { token: bob.token })).body.blocked.map((u) => u.username), ['carol']);
++ const blocked = await h.http('POST', '/api/dm/conversations', { token: carol.token, body: { user_ids: [bob.id] } });
++ assert.strictEqual(blocked.status, 403);
++ assert.deepStrictEqual((await h.http('GET', '/api/dm/users/search?q=car', { token: bob.token })).body.users, []);
++ assert.deepStrictEqual((await h.http('GET', '/api/dm/users/search?q=car', { token: alice.token })).body.users.map((u) => u.username), ['carol']);
++ await h.http('DELETE', `/api/dm/blocks/${carol.id}`, { token: bob.token });
++ // A banned account cannot be messaged.
++ h.live.users.get(carol.id).is_banned = 1;
++ await h.ctx.sync();
++ const toBanned = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [carol.id, streamer.id, bob.id, staff.id] } });
++ assert.deepStrictEqual(toBanned.body, { error: 'Cannot message banned users' });
++ h.live.users.get(carol.id).is_banned = 0;
++ await h.ctx.sync();
++ // Leave the group; only the creator removes others.
++ assert.strictEqual((await h.http('DELETE', `/api/dm/conversations/${groupId}/participants/${alice.id}`, { token: bob.token })).status, 403);
++ assert.deepStrictEqual((await h.http('DELETE', `/api/dm/conversations/${groupId}/participants/${bob.id}`, { token: bob.token })).body, { ok: true });
++});
++
++t('chat history: global page and cursor delta, deleted and expired rows hidden', async () => {
++ const ids = [];
++ for (let i = 0; i < 5; i++) ids.push(Number(h.db.saveChatMessage({ stream_id: null, user_id: alice.id, username: 'Alice', message: `g${i}`, message_type: 'chat', is_global: true }).lastInsertRowid));
++ h.db.deleteChatMessage(ids[1], staff.id);
++ h.db.run("UPDATE chat_messages SET auto_delete_at = '2000-01-01 00:00:00' WHERE id = ?", [ids[2]]);
++ const page = await h.http('GET', '/api/chat/global/history?limit=10');
++ const got = page.body.messages.map((m) => m.message);
++ assert.deepStrictEqual(got.filter((x) => /^g\d$/.test(x)), ['g0', 'g3', 'g4']);
++ assert.strictEqual(page.body.latest_id, ids[4]);
++ const delta = await h.http('GET', `/api/chat/global/history?after_id=${ids[3]}`);
++ assert.deepStrictEqual(delta.body.messages.map((m) => m.message), ['g4']);
++ assert.strictEqual(delta.body.complete, true);
++ const small = await h.http('GET', `/api/chat/global/history?after_id=0&limit=1`);
++ assert.strictEqual(small.body.complete, false, 'bigger gap than the limit → take a fresh page');
++ // Channel filter by username (the owner of the source stream or the channel room).
++ h.db.saveChatMessage({ stream_id: streamId, user_id: bob.id, username: 'Bob', message: 'in the stream', message_type: 'chat' });
++ const byChan = await h.http('GET', '/api/chat/global/history?username=streamer');
++ assert.deepStrictEqual(byChan.body.messages.map((m) => m.message), ['in the stream']);
++ assert.strictEqual(byChan.body.messages[0].stream_channel, 'streamer');
++});
++
++t('chat logs and search are gated like Live', async () => {
++ assert.strictEqual((await h.http('GET', `/api/chat/user/${bob.id}/history`, { token: alice.token })).status, 403);
++ const own = await h.http('GET', `/api/chat/user/${alice.id}/history`, { token: alice.token });
++ assert.ok(own.body.total >= 3);
++ assert.ok(own.body.messages.every((m) => !('subject_id' in m)));
++ const staffView = await h.http('GET', `/api/chat/user/${bob.id}/history`, { token: staff.token });
++ assert.strictEqual(staffView.status, 200);
++ // Search: non-staff only see their own lines whatever user_id they ask for.
++ // (`total` is 0 in Live's search whatever matches — its count regex does not cross the
++ // SELECT's line break — and stays so: parity, not a fix in this wave.)
++ const s = await h.http('GET', `/api/chat/search?q=stream&user_id=${bob.id}`, { token: alice.token });
++ assert.strictEqual(s.body.messages.length, 0);
++ const s2 = await h.http('GET', `/api/chat/search?q=stream&user_id=${bob.id}`, { token: staff.token });
++ assert.deepStrictEqual(s2.body.messages.map((m) => m.message), ['in the stream']);
++ assert.strictEqual(s2.body.messages[0].u_username, 'bob');
++ // Admin logs: a non-owner of the stream is refused.
++ assert.strictEqual((await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}`, { token: alice.token })).status, 403);
++ const logs = await h.http('GET', `/api/chat/admin/logs?streamId=${streamId}`, { token: streamer.token });
++ assert.strictEqual(logs.body.total, 1);
++ // Anon logs need staff.
++ assert.strictEqual((await h.http('GET', '/api/chat/anon/anon5/logs', { token: alice.token })).status, 403);
++});
++
++t('profile card and anon info come from Live', async () => {
++ const p = await h.http('GET', '/api/chat/user/alice/profile', { token: bob.token });
++ assert.strictEqual(p.body.username, 'alice');
++ assert.ok(!('last_seen' in p.body), 'presence only for the user themselves');
++ const own = await h.http('GET', '/api/chat/user/alice/profile', { token: alice.token });
++ assert.ok('last_seen' in own.body);
++ assert.strictEqual((await h.http('GET', '/api/chat/user/nobody/profile')).status, 404);
++ h.live.anon.set('203.0.113.99', { num: 42, first_seen: '2026-01-01 00:00:00' });
++ h.db.saveChatMessage({ stream_id: null, anon_id: 'anon42', username: 'anon42', message: 'hi', message_type: 'chat', is_global: true });
++ const a = await h.http('GET', '/api/chat/anon/anon42');
++ assert.deepStrictEqual({ ...a.body.anon, first_chat: !!a.body.anon.first_chat }, { anon_id: 'anon42', anon_num: 42, first_seen: '2026-01-01 00:00:00', first_chat: true, message_count: 1 });
++ assert.strictEqual((await h.http('GET', '/api/chat/anon/bob')).status, 400);
++});
++
++t('settings-driven routes: GIF providers, TTS settings, friendly filters, unknown paths', async () => {
++ const g = await h.http('GET', '/api/chat/gif/providers');
++ assert.deepStrictEqual(g.body, { providers: { tenor: true, giphy: false }, defaultProvider: 'tenor' });
++ const tts = await h.http('GET', '/api/tts/settings');
++ assert.strictEqual(tts.body.enabled, true);
++ assert.ok(!('googleApiKey' in tts.body), 'no credentials to the browser');
++ assert.strictEqual((await h.http('GET', '/api/tts/admin/settings', { token: alice.token })).status, 403);
++ const f = await h.http('GET', '/api/chat/filters/friendly');
++ assert.ok(Array.isArray(f.body.categories) && f.body.categories.length);
++ assert.deepStrictEqual((await h.http('GET', '/api/chat/nope/nope/nope')).body, { error: 'Not found' });
++ // TTS clips: Live's stashed clips are served under the same URL.
++ const clip = await h.http('GET', '/api/tts/audio/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.mp3');
++ assert.strictEqual(clip.status, 200);
++ assert.strictEqual(clip.text, 'ID3live');
++});
++
++t('CORS: Live’s origins allowed with credentials, others refused', async () => {
++ const ok = await h.http('GET', '/api/chat/filters/friendly', { headers: { Origin: 'https://openvibe.network' } });
++ assert.strictEqual(ok.headers.get('access-control-allow-origin'), 'https://openvibe.network');
++ assert.strictEqual(ok.headers.get('access-control-allow-credentials'), 'true');
++ const sat = await h.http('GET', '/api/chat/filters/friendly', { headers: { Origin: 'https://pastes.openvibe.tools' } });
++ assert.strictEqual(sat.status, 200);
++ const bad = await h.http('GET', '/api/chat/filters/friendly', { headers: { Origin: 'https://evil-openvibe.tools' } });
++ assert.strictEqual(bad.status, 403);
++});
++
++t('scripts/parity-check.js: two identical services answer the same', async () => {
++ const { spawn } = require('child_process');
++ const path = require('path');
++ const r = await new Promise((resolve) => {
++ const c = spawn(process.execPath, [path.join(__dirname, '..', 'scripts', 'parity-check.js'), '--live', h.base, '--chat', h.base, '--stream', String(streamId), '--channel', String(streamer.id), '--token', alice.token]);
++ let out = ''; c.stdout.on('data', (d) => { out += d; }); c.on('close', (code) => resolve({ code, out }));
++ });
++ assert.strictEqual(r.code, 0, r.out);
++ assert.match(r.out, /all paths answer the same/);
++});
++
++t.run(async () => { if (h) await h.close(); });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..19f58d6
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,56 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
++ * if any of them fails. They use in-process stubs of OpenVibe.Live and OpenVibe.Network only;
++ * none of them needs the network or a running site.
++ *
++ * npm test # everything
++ * npm test -- ws import # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname)
++ .filter((f) => f.endsWith('.test.js'))
++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
++ .sort();
++const TIMEOUT_MS = 60000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], {
++ cwd: path.join(__dirname, '..'),
++ env: { ...process.env, NODE_ENV: 'test' },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let output = '';
++ child.stdout.on('data', (c) => { output += c; });
++ child.stderr.on('data', (c) => { output += c; });
++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
++ child.on('close', (code, signal) => {
++ clearTimeout(timer);
++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
++ });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);
++ }
++ const failed = results.filter((r) => !r.ok);
++ for (const r of failed) {
++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
++ console.log(r.output.split('\n').slice(-60).join('\n'));
++ }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();
ddiff --git a/test/sounds-tts.test.js b/test/sounds-tts.test.js
nnew file mode 100644
iindex 0000000..478c9de
---- /dev/null
++++ b/test/sounds-tts.test.js
@@@ -0,0 +1,119 @@
++'use strict';
++/**
++ * /api/sounds (upload → MP3, list, serve, !command playback in chat, delete rules, alert sounds
++ * written to Live's channel settings) and /api/tts admin settings (admins; credentials owner-only;
++ * written through Live).
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, suite } = require('./helpers');
++
++const t = suite('sounds-tts');
++let h, streamer, viewer, other, admin, owner, channelId, streamId;
++
++function wav(seconds = 0.4) {
++ const rate = 8000, n = Math.floor(rate * seconds);
++ const b = Buffer.alloc(44 + n);
++ b.write('RIFF', 0); b.writeUInt32LE(36 + n, 4); b.write('WAVE', 8); b.write('fmt ', 12);
++ b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(rate, 24);
++ b.writeUInt32LE(rate, 28); b.writeUInt16LE(1, 32); b.writeUInt16LE(8, 34); b.write('data', 36); b.writeUInt32LE(n, 40);
++ b.fill(128, 44);
++ return b;
++}
++async function upload(p, token, fields, file) {
++ const fd = new FormData();
++ for (const [k, v] of Object.entries(fields)) fd.append(k, String(v));
++ fd.append('sound', new Blob([file], { type: 'audio/wav' }), 'clip.wav');
++ const res = await fetch(`${h.base}${p}`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: fd });
++ return { status: res.status, body: await res.json().catch(() => null) };
++}
++
++t('boot', async () => {
++ h = await boot();
++ streamer = h.addUser('streamer', { role: 'streamer' });
++ viewer = h.addUser('viewer');
++ other = h.addUser('other');
++ admin = h.addUser('admin2', { role: 'admin' });
++ owner = h.addUser('owner', { role: 'admin', is_owner: 1 });
++ channelId = h.addChannel(streamer.id);
++ streamId = h.addStream(streamer.id, channelId);
++ await h.ctx.sync();
++});
++
++let soundId, soundUrl;
++t('upload a channel sound: converted to MP3 in the shared sounds dir, listed, served', async () => {
++ const r = await upload('/api/sounds', viewer.token, { command: '!Honk', channel_id: streamer.id }, wav());
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ soundId = r.body.sound.id;
++ soundUrl = r.body.sound.url;
++ assert.strictEqual(r.body.sound.command, 'honk');
++ assert.match(soundUrl, /^\/api\/sounds\/file\/snd-.*\.mp3$/);
++ const row = h.db.getChannelSoundById(soundId);
++ assert.ok(row.url.startsWith(path.resolve(process.env.SOUNDS_PATH)) && fs.existsSync(row.url));
++ await h.sleep(150);
++ assert.ok(h.live.effects.some((e) => e.name === 'asset-sync' && e.body.op === 'syncSoon'), 'Live mirrors it to Media');
++ const list = await h.http('GET', `/api/sounds/channel/${streamer.id}`);
++ assert.deepStrictEqual(list.body.sounds.map((s) => s.command), ['honk']);
++ assert.strictEqual((await h.http('GET', `/api/sounds/all/${streamId}`)).body.sounds.length, 1);
++ const file = await fetch(`${h.base}${soundUrl}`);
++ assert.strictEqual(file.headers.get('content-type'), 'audio/mpeg');
++ assert.ok(Buffer.from(await file.arrayBuffer()).length > 100);
++ const reserved = await upload('/api/sounds', viewer.token, { command: 'skip', channel_id: streamer.id }, wav());
++ assert.strictEqual(reserved.status, 400);
++});
++
++t('!honk in the stream chat plays it (rich announce + audio), persisted', async () => {
++ const ws = await h.ws({ ip: '198.51.100.80', token: other.token, stream: streamId });
++ ws.sendJson({ type: 'join', streamId, token: other.token });
++ await ws.next((m) => m.type === 'auth');
++ ws.sendJson({ type: 'chat', message: '!honk 0.5' });
++ const ann = await ws.next((m) => m.type === 'chat' && m.message_type === 'channel-sound');
++ assert.strictEqual(ann.message, 'played !honk');
++ assert.strictEqual(ann.sound.speed, 0.5);
++ const audio = await ws.next((m) => m.type === 'soundboard-audio');
++ assert.strictEqual(audio.title, '!honk');
++ assert.ok(audio.audio.length > 100);
++ assert.strictEqual(h.db.getChatMessageById(ann.id).message_type, 'channel-sound');
++ ws.close();
++});
++
++t('delete: not someone else’s sound; the uploader can; Live drops the Media copy first', async () => {
++ assert.strictEqual((await h.http('DELETE', `/api/sounds/${soundId}`, { token: other.token })).status, 403);
++ const row = h.db.getChannelSoundById(soundId);
++ const del = await h.http('DELETE', `/api/sounds/${soundId}`, { token: viewer.token });
++ assert.deepStrictEqual(del.body, { message: 'Sound deleted' });
++ assert.ok(!fs.existsSync(row.url));
++ assert.ok(h.live.effects.some((e) => e.name === 'asset-sync' && e.body.op === 'remove-sound' && e.body.asset_id === soundId));
++ assert.strictEqual(h.db.getChannelSoundById(soundId), undefined);
++});
++
++t('alert sounds: the streamer’s own channel, stored through Live', async () => {
++ const r = await upload('/api/sounds/alert/donation', streamer.token, {}, wav());
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.kind, 'donation');
++ const fx = h.live.effects.find((e) => e.name === 'alert-sound');
++ assert.strictEqual(fx.body.channel_id, channelId);
++ assert.strictEqual(fx.body.actor_user_id, streamer.id);
++ assert.ok(fx.body.url.startsWith(path.resolve(process.env.SOUNDS_PATH)));
++});
++
++t('TTS admin settings: admins only; credentials only the owner; written by Live', async () => {
++ assert.strictEqual((await h.http('PUT', '/api/tts/admin/settings', { token: viewer.token, body: { tts_enabled: false } })).status, 403);
++ const a = await h.http('PUT', '/api/tts/admin/settings', { token: admin.token, body: { tts_max_length: 300, tts_google_api_key: 'nope', bogus: 1 } });
++ assert.deepStrictEqual(a.body, { success: true, updated: 1 });
++ let fx = h.live.effects.filter((e) => e.name === 'site-settings').pop();
++ assert.deepStrictEqual(fx.body.settings, { tts_max_length: 300 });
++ const o = await h.http('PUT', '/api/tts/admin/settings', { token: owner.token, body: { tts_google_api_key: 'real-key', tts_aws_secret_access_key: '••••1234' } });
++ assert.deepStrictEqual(o.body, { success: true, updated: 1 }, 'a masked placeholder never overwrites a secret');
++ fx = h.live.effects.filter((e) => e.name === 'site-settings').pop();
++ assert.deepStrictEqual(fx.body.settings, { tts_google_api_key: 'real-key' });
++ const ownerView = await h.http('GET', '/api/tts/admin/settings', { token: owner.token });
++ assert.strictEqual(ownerView.body.settings.googleApiKey, 'real-key');
++ const view = await h.http('GET', '/api/tts/admin/settings', { token: admin.token });
++ assert.strictEqual(view.body.settings.googleApiKey, '••••••••', 'admins see credentials masked');
++ // (Live masks the engine's cached settings object in place, so for up to 30s after an admin
++ // looks, the owner — and Google synthesis — see the mask too. Moved as it is; see docs/cutover.md.)
++});
++
++t.run(async () => { if (h) await h.close(); });
ddiff --git a/test/ws-protocol.test.js b/test/ws-protocol.test.js
nnew file mode 100644
iindex 0000000..921e766
---- /dev/null
++++ b/test/ws-protocol.test.js
@@@ -0,0 +1,298 @@
++'use strict';
++/**
++ * /ws/chat protocol parity with Live's chat server, against a stub Live context:
++ * anonymous connect → join, anonymous → signed-in upgrade on the same socket, chat into a stream
++ * room (delivery, global cross-feed, persistence with the Network subject, outbox, Live's
++ * reactions and the coin reply), history, /w (DMs replaced whispers), the DM participant check on
++ * delivery, moderation (/ban /timeout /unban /slow /clear with moderator checks), /tts, /paste,
++ * !-commands routed to Live (arena reply through the bridge, media queue), and the upgrade guards
++ * (origin allow-list, IP bans, the per-address cap).
++ */
++const assert = require('assert');
++const { boot, suite } = require('./helpers');
++
++const t = suite('ws-protocol');
++let h, streamer, mod, alice, bob, admin, channelId, streamId;
++
++t('boot Chat against a stub Live', async () => {
++ h = await boot();
++ streamer = h.addUser('streamer', { role: 'streamer' });
++ mod = h.addUser('moddy');
++ alice = h.addUser('alice', { subject: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
++ bob = h.addUser('bob');
++ admin = h.addUser('boss', { role: 'admin' });
++ channelId = h.addChannel(streamer.id, { moderators: [mod.id] });
++ streamId = h.addStream(streamer.id, channelId, { managed: { id: 71, slug: 'main-slot', title: 'Main slot' } });
++ await h.ctx.sync();
++ assert.ok(h.ctx.getStreamById(streamId), 'stream projected');
++});
++
++t('anonymous connect, join global → auth as an anon number from Live', async () => {
++ const ws = await h.ws({ ip: '198.51.100.7' });
++ ws.sendJson({ type: 'join' });
++ const auth = await ws.next((m) => m.type === 'auth');
++ assert.strictEqual(auth.authenticated, false);
++ assert.match(auth.username, /^anon\d+$/);
++ assert.strictEqual(auth.role, 'anon');
++ assert.strictEqual(auth.channel_language, 'en');
++ assert.ok(h.live.effects.some((e) => e.name === 'anon' && e.body.ip === '198.51.100.7'), 'Live allocated the anon number');
++ // Same address, same number (cached, one allocation).
++ const ws2 = await h.ws({ ip: '198.51.100.7' });
++ ws2.sendJson({ type: 'join' });
++ assert.strictEqual((await ws2.next((m) => m.type === 'auth')).username, auth.username);
++ assert.strictEqual(h.live.effects.filter((e) => e.name === 'anon' && e.body.ip === '198.51.100.7').length, 1);
++ ws.close(); ws2.close();
++});
++
++t('anonymous socket upgrades to signed-in on join with a token', async () => {
++ const ws = await h.ws({ ip: '198.51.100.8' });
++ ws.sendJson({ type: 'join', streamId });
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).authenticated, false);
++ ws.sendJson({ type: 'join', streamId, token: alice.token });
++ const auth = await ws.next((m) => m.type === 'auth');
++ assert.strictEqual(auth.authenticated, true);
++ assert.strictEqual(auth.username, 'Alice');
++ assert.strictEqual(auth.core_username, 'alice');
++ assert.strictEqual(auth.user_id, alice.id);
++ assert.strictEqual(auth.role, 'user');
++ // A different account's token on the same socket is ignored (account switch rebuilds the socket).
++ ws.sendJson({ type: 'join', streamId, token: bob.token });
++ assert.strictEqual((await ws.next((m) => m.type === 'auth')).user_id, alice.id);
++ ws.close();
++});
++
++let aliceWs, bobWs, globalWs, msgId;
++t('chat into a stream room: room delivery, global cross-feed, persistence, outbox, Live reactions', async () => {
++ aliceWs = await h.ws({ ip: '198.51.100.20', token: alice.token, stream: streamId });
++ aliceWs.sendJson({ type: 'join', streamId, token: alice.token });
++ await aliceWs.next((m) => m.type === 'auth');
++ bobWs = await h.ws({ ip: '198.51.100.21', token: bob.token, stream: streamId });
++ bobWs.sendJson({ type: 'join', streamId, token: bob.token });
++ await bobWs.next((m) => m.type === 'auth');
++ globalWs = await h.ws({ ip: '198.51.100.22' });
++ globalWs.sendJson({ type: 'join' });
++ await globalWs.next((m) => m.type === 'auth');
++
++ aliceWs.sendJson({ type: 'chat', message: 'hello stream' });
++ const got = await bobWs.next((m) => m.type === 'chat' && m.message === 'hello stream');
++ assert.strictEqual(got.username, 'Alice');
++ assert.strictEqual(got.core_username, 'alice');
++ assert.strictEqual(got.user_id, alice.id);
++ assert.strictEqual(got.stream_id, streamId);
++ assert.strictEqual(got.channel_user_id, streamer.id);
++ assert.strictEqual(got.is_global, false);
++ assert.ok(Number.isInteger(got.id));
++ msgId = got.id;
++ const cross = await globalWs.next((m) => m.type === 'chat' && m.message === 'hello stream');
++ assert.strictEqual(cross.stream_channel, 'streamer');
++ assert.strictEqual(cross.source_slug, 'main-slot');
++ assert.strictEqual(cross.source_stream_title, 'Main slot');
++ // First chat in the channel → welcome line to the room.
++ await bobWs.next((m) => m.type === 'system' && /^Welcome Alice to the chat!/.test(m.message));
++ // Persisted with Live's ids and the Network subject.
++ const row = h.db.getChatMessageById(msgId);
++ assert.strictEqual(row.user_id, alice.id);
++ assert.strictEqual(row.channel_user_id, streamer.id);
++ assert.strictEqual(row.subject_id, 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA');
++ // Outbox envelope in the same transaction.
++ const ev = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.message.created'").map((r) => JSON.parse(r.event)).find((e) => e.payload.message_id === msgId);
++ assert.ok(ev, 'chat.message.created in the outbox');
++ assert.strictEqual(ev.visibility, 'public');
++ assert.deepStrictEqual(ev.actor, { type: 'user', id: 'usr_01J9AAAAAAAAAAAAAAAAAAAAAA' });
++ assert.strictEqual(ev.source, 'chat');
++ // One call to Live for the reactions; the coin reply comes back to the sender.
++ const coin = await aliceWs.next((m) => m.type === 'coin_earned');
++ assert.strictEqual(coin.reason, 'Chat bonus');
++ const reaction = h.live.effects.find((e) => e.name === 'chat-message' && e.body.msg_id === msgId);
++ assert.ok(reaction && reaction.body.award && reaction.body.ai && reaction.body.powerchat);
++ assert.strictEqual(reaction.body.powerchat_chat.externalChatterId, `u${alice.id}`);
++});
++
++t('stream history and channel history return the message like Live did', async () => {
++ const hist = await h.http('GET', `/api/chat/${streamId}/history`);
++ assert.strictEqual(hist.status, 200);
++ const m = hist.body.messages.find((x) => x.id === msgId);
++ assert.ok(m);
++ assert.strictEqual(m.core_username, 'alice');
++ assert.strictEqual(m.source_channel, 'streamer');
++ assert.strictEqual(m.source_slug, 'main-slot');
++ assert.ok(!('subject_id' in m), 'Chat-internal columns stay internal');
++ assert.strictEqual(hist.body.channel, 'streamer');
++ assert.strictEqual(hist.body.activeStreamId, streamId);
++ const delta = await h.http('GET', `/api/chat/${streamId}/history?after_id=${msgId - 1}`);
++ assert.strictEqual(delta.body.complete, true);
++ assert.strictEqual(delta.body.messages[0].id, msgId);
++ const ch = await h.http('GET', `/api/chat/channel/${streamer.id}/history`);
++ assert.ok(ch.body.messages.some((x) => x.id === msgId));
++ assert.strictEqual(ch.body.channel, 'streamer');
++ assert.strictEqual(ch.body.latest_id >= msgId, true);
++ const users = await h.http('GET', `/api/chat/${streamId}/users`);
++ assert.strictEqual(users.body.count, 2, 'two addresses in the stream room');
++});
++
++t('/w and /whisper answer that DMs replaced whispers', async () => {
++ await h.sleep(1100); // chat rate limit (1 message per second per address and room)
++ aliceWs.sendJson({ type: 'chat', message: '/w bob hi' });
++ const m = await aliceWs.next((x) => x.type === 'system' && /Whispers have been replaced by DMs/.test(x.message)).catch((e) => { console.log(JSON.stringify(aliceWs.all)); throw e; });
++ assert.ok(m);
++});
++
++t('DM delivery keeps the participant check (REST send → live socket; a non-participant gets nothing)', async () => {
++ const created = await h.http('POST', '/api/dm/conversations', { token: alice.token, body: { user_ids: [bob.id] } });
++ assert.strictEqual(created.status, 200, created.text);
++ const convId = created.body.conversation.id;
++ assert.deepStrictEqual(created.body.conversation.participants.map((p) => p.id).sort(), [alice.id, bob.id].sort());
++ const sent = await h.http('POST', `/api/dm/conversations/${convId}/messages`, { token: alice.token, body: { message: 'secret hello' } });
++ assert.strictEqual(sent.status, 200, sent.text);
++ assert.ok(!('sender_subject_id' in sent.body.message));
++ const dm = await bobWs.next((m) => m.type === 'dm');
++ assert.strictEqual(dm.conversation_id, convId);
++ assert.strictEqual(dm.message.message, 'secret hello');
++ assert.ok(await aliceWs.none((m) => m.type === 'dm'), 'the sender is not echoed');
++ // chatServer.sendDm is guarded by dm.isParticipant: a non-participant never receives it.
++ const eve = h.addUser('eve');
++ await h.ctx.sync();
++ const eveWs = await h.ws({ ip: '198.51.100.30', token: eve.token });
++ eveWs.sendJson({ type: 'join', token: eve.token });
++ await eveWs.next((m) => m.type === 'auth');
++ h.chatServer.sendDm(eve.id, { type: 'dm', conversation_id: convId, message: { message: 'leak' } });
++ assert.ok(await eveWs.none((m) => m.type === 'dm'), 'participant check held');
++ assert.strictEqual((await h.http('GET', `/api/dm/conversations/${convId}/messages`, { token: eve.token })).status, 403);
++ // Offline notification went to Live; the DM event is subject-scoped and carries no text.
++ assert.ok(h.live.effects.some((e) => e.name === 'notify/dm' && e.body.recipient_ids.includes(bob.id)));
++ const ev = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.dm.created'").map((r) => JSON.parse(r.event)).pop();
++ assert.strictEqual(ev.visibility, 'subject');
++ assert.ok(!JSON.stringify(ev).includes('secret hello'), 'no DM text in the event');
++ eveWs.close();
++});
++
++t('moderation: only moderators; /ban goes to Live and takes effect at once', async () => {
++ bobWs.sendJson({ type: 'chat', message: '/ban alice' });
++ assert.ok(await bobWs.next((m) => m.type === 'system' && m.message === 'You do not have permission.'));
++ const modWs = await h.ws({ ip: '198.51.100.40', token: mod.token, stream: streamId });
++ modWs.sendJson({ type: 'join', streamId, token: mod.token });
++ await modWs.next((m) => m.type === 'auth');
++ modWs.sendJson({ type: 'chat', message: '/ban bob' });
++ await modWs.next((m) => m.type === 'system' && m.message === 'bob has been banned.');
++ const banFx = h.live.effects.find((e) => e.name === 'ban' && e.body.user_id === bob.id);
++ assert.strictEqual(banFx.body.actor_user_id, mod.id);
++ assert.strictEqual(banFx.body.moderation_stream_id, streamId);
++ assert.strictEqual(banFx.body.stream_id, streamId);
++ await bobWs.next((m) => m.type === 'system' && m.message === 'bob has been banned.');
++ await h.sleep(1100); // chat rate limit (1 msg/s per address+room)
++ bobWs.sendJson({ type: 'chat', message: 'am I banned?' });
++ await bobWs.next((m) => m.type === 'system' && m.message === 'You are banned from this chat.');
++ // A moderator cannot ban an admin.
++ await h.sleep(1100);
++ modWs.sendJson({ type: 'chat', message: '/ban boss' });
++ await modWs.next((m) => m.type === 'system' && m.message === 'You cannot ban an admin.');
++ // /unban lifts it.
++ await h.sleep(1100);
++ modWs.sendJson({ type: 'chat', message: '/unban bob' });
++ await modWs.next((m) => m.type === 'system' && m.message === 'bob has been unbanned.');
++ await h.sleep(1100);
++ bobWs.sendJson({ type: 'chat', message: 'back again' });
++ await aliceWs.next((m) => m.type === 'chat' && m.message === 'back again');
++ // /timeout writes an expiring ban.
++ await h.sleep(1100);
++ modWs.sendJson({ type: 'chat', message: '/timeout bob 60' });
++ await modWs.next((m) => m.type === 'system' && m.message === 'bob timed out for 60s.');
++ const to = h.live.effects.filter((e) => e.name === 'ban' && e.body.user_id === bob.id).pop();
++ assert.strictEqual(to.body.reason, 'Timeout 60s');
++ assert.ok(to.body.expires_at);
++ // /clear
++ await h.sleep(1100);
++ modWs.sendJson({ type: 'chat', message: '/clear' });
++ await aliceWs.next((m) => m.type === 'clear');
++ // /slow: slowmode event + system line to the room, persisted through Live (it applies to
++ // everyone's next message, moderators included — Live's rate limit runs before commands).
++ await h.sleep(1100);
++ modWs.sendJson({ type: 'chat', message: '/slow 5' });
++ assert.strictEqual((await aliceWs.next((m) => m.type === 'slowmode')).seconds, 5);
++ await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow mode enabled: 5s between messages');
++ await h.sleep(100);
++ assert.ok(h.live.effects.some((e) => e.name === 'channel-settings' && e.body.fields.slow_mode_seconds === 5 && e.body.actor_user_id === mod.id));
++ assert.strictEqual(h.chatServer.slowModeByStream.get(streamId), 5000);
++ // Every action is logged (and announced as an internal moderation event).
++ const actions = h.db.all('SELECT action_type FROM moderation_actions ORDER BY id').map((r) => r.action_type);
++ for (const a of ['channel_ban', 'channel_unban', 'channel_timeout', 'slowmode_update', 'clear_chat']) assert.ok(actions.includes(a), a);
++ const modEv = h.db.all("SELECT event FROM events_outbox WHERE event_type = 'chat.moderation.action'").map((r) => JSON.parse(r.event));
++ assert.ok(modEv.length >= 5 && modEv.every((e) => e.visibility === 'internal'));
++ await h.sleep(5100);
++ modWs.sendJson({ type: 'chat', message: '/slow off' });
++ await aliceWs.next((m) => m.type === 'system' && m.message === 'Slow mode disabled.').catch((e) => { console.log('MOD', JSON.stringify(modWs.all.slice(-6))); throw e; });
++ modWs.close();
++});
++
++t('/tts broadcasts the line to the room', async () => {
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: '/tts hello voice' });
++ const m = await bobWs.next((x) => x.type === 'tts');
++ assert.strictEqual(m.message, 'hello voice');
++ assert.strictEqual(m.core_username, 'alice');
++});
++
++t('/paste creates the paste through Live and announces it', async () => {
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: '/paste const x = 1;' });
++ const m = await bobWs.next((x) => x.type === 'system' && /shared a paste/.test(x.message));
++ assert.strictEqual(m.message, '📋 Anonymous shared a paste: https://openvibe.live/p/abc123');
++ const fx = h.live.effects.find((e) => e.name === 'paste');
++ assert.strictEqual(fx.body.content, 'const x = 1;');
++ assert.strictEqual(fx.body.user_id, alice.id);
++ assert.strictEqual(fx.body.stream_id, streamId);
++});
++
++t('!hype goes to Live; Live answers this socket through the bridge (sendToConn)', async () => {
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: '!hype' });
++ await h.sleep(200);
++ const fx = h.live.effects.find((e) => e.name === 'arena-command');
++ assert.strictEqual(fx.body.cmd, '!hype');
++ assert.strictEqual(fx.body.client.user.id, alice.id);
++ const res = await h.http('POST', '/internal/live/calls', { token: h.serviceToken(['chat.live_bridge.write']), body: { boot: 'b1', ops: [{ seq: 1, op: 'sendToConn', args: [fx.body.client.conn_id, { type: 'system', message: 'Hyped!' }] }] } });
++ assert.strictEqual(res.status, 200);
++ await aliceWs.next((m) => m.type === 'system' && m.message === 'Hyped!');
++});
++
++t('media commands go to Live’s queue with the same replies', async () => {
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: '!sr https://youtu.be/x' });
++ const m = await bobWs.next((x) => x.type === 'system' && /added/.test(x.message));
++ assert.strictEqual(m.message, 'Alice added “A Video” (1m5s) to the media queue for 25 gold.');
++ await aliceWs.next((x) => x.type === 'coin_earned' && x.reason === 'Media request purchase');
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: '!queue' });
++ await aliceWs.next((x) => x.type === 'system' && /^The media queue is empty/.test(x.message));
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: '!skip' });
++ await aliceWs.next((x) => x.type === 'system' && x.message === 'Only the streamer or a moderator can skip media.');
++ await h.sleep(1100);
++ aliceWs.sendJson({ type: 'chat', message: '!forward' });
++ await aliceWs.next((x) => x.type === 'system' && x.message === 'No hardware client connected.');
++});
++
++t('upgrade guards: foreign origin refused, banned address refused (admins exempt)', async () => {
++ assert.strictEqual(await h.wsRefused({ origin: 'https://evil.example' }), true);
++ h.live.addBan({ ip_address: '192.0.2.0/24' });
++ await h.ctx.invalidateBans();
++ assert.strictEqual(await h.wsRefused({ ip: '192.0.2.55' }), true, 'CIDR network ban');
++ assert.strictEqual(await h.wsRefused({ ip: '192.0.2.56', token: admin.token }), false, 'admin passes');
++ // And REST from a banned network.
++ const r = await h.http('GET', '/api/chat/global/history', { headers: { 'X-Forwarded-For': '192.0.2.9, 10.0.0.1' } });
++ assert.strictEqual(r.status, 403);
++ assert.deepStrictEqual(r.body, { error: 'Access denied' });
++ h.live.clearBans();
++ await h.ctx.invalidateBans();
++});
++
++t('messages sent before the connection is ready are handled in order', async () => {
++ const ws = await h.ws({ ip: '198.51.100.60', token: bob.token, stream: streamId });
++ ws.sendJson({ type: 'join', streamId, token: bob.token });
++ ws.sendJson({ type: 'chat', message: 'right after join' });
++ await ws.next((m) => m.type === 'auth');
++ await aliceWs.next((m) => m.type === 'chat' && m.message === 'right after join');
++ ws.close();
++});
++
++t.run(async () => { for (const w of [aliceWs, bobWs, globalWs]) { try { w.close(); } catch { /* */ } } if (h) await h.close(); });