openvibers/openvibe.community Commits

Scaffold the Community app: package, vendored openvibe-shared, config, env example

OP OpenVibers committed 18 Sept 2026 · 66c11abb66 · 31 files, +8837 −40

openvibe.community stops being a static placeholder and becomes a Node/Express app
(CommonJS, port 4200) in the shape of OpenVibe.Live and the Tools gateway. The shared
server helpers (auth-client, middleware, brand) are vendored unmodified from
OpenVibe.Network/packages/openvibe-shared and depended on via file:./vendor.

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..05306c3
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,35 @@
++# ═══════════════════════════════════════════════════════════════
++# openvibe.community — Environment Configuration
++# Copy to .env and fill in values.
++# In production this lives at /etc/openvibe/community.env (0600).
++# ═══════════════════════════════════════════════════════════════
++
++# Server
++PORT=4200
++HOST=127.0.0.1
++NODE_ENV=development
++BASE_URL=http://localhost:4200
++# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
++TRUST_PROXY=2
++
++# Identity provider — OpenVibe.Network (OAuth2 authorization server + JWKS)
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++
++# OAuth2 client credentials (client `community` registered in the Network DB,
++# redirect https://openvibe.community/auth/callback)
++OV_OAUTH_CLIENT_ID=community
++OV_OAUTH_CLIENT_SECRET=change-me-to-the-registered-community-secret
++# Production: https://openvibe.community/auth/callback
++OV_OAUTH_REDIRECT_URI=http://localhost:4200/auth/callback
++
++# Cookies — production default is Secure; set false for plain-http local dev
++#COOKIE_SECURE=false
++
++# OpenVibe.Live — every /api/pastes/* call is proxied here (internal URL);
++# Live maps the visitor's Network JWT onto its own account ids before it talks to Media.
++OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
++OV_LIVE_URL=https://openvibe.live
++
++# OpenVibe.Media — public host for raw paste text and screenshots
++OV_MEDIA_URL=https://openvibe.media
ddiff --git a/.gitignore b/.gitignore
iindex 3a57296..0f84c2e 100644
---- a/.gitignore
++++ b/.gitignore
@@@ -2,7 +2,11 @@ node_modules/
data/
dist/
.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..15e5e72
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1032 @@
++{
++ "name": "openvibe-community",
++ "version": "1.0.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-community",
++ "version": "1.0.0",
++ "license": "MIT",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "highlight.js": "^11.11.1",
++ "openvibe-shared": "file:./vendor/openvibe-shared"
++ },
++ "engines": {
++ "node": ">=18.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/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/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-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "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/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/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/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/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/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "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/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/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/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/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/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/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/EvanHahn"
++ }
++ },
++ "node_modules/highlight.js": {
++ "version": "11.12.0",
++ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
++ "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
++ "license": "BSD-3-Clause",
++ "engines": {
++ "node": ">=12.0.0"
++ }
++ },
++ "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/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/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/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/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/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "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/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "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/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/openvibe-shared": {
++ "resolved": "vendor/openvibe-shared",
++ "link": true
++ },
++ "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/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/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/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/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/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/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/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"
++ }
++ },
++ "vendor/openvibe-shared": {
++ "version": "1.0.0",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..01350c4
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,27 @@
++{
++ "name": "openvibe-community",
++ "version": "1.0.0",
++ "private": true,
++ "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes; forums and submissions to follow. Part of the OpenVibe network (pastes stored by OpenVibe.Media via OpenVibe.Live, identity by OpenVibe.Network).",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js"
++ },
++ "keywords": ["openvibe", "community", "pastes", "pastebin"],
++ "author": "OpenVibe Community",
++ "license": "MIT",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "highlight.js": "^11.11.1",
++ "openvibe-shared": "file:./vendor/openvibe-shared"
++ },
++ "engines": {
++ "node": ">=18.0.0"
++ }
++}
ddiff --git a/public/index.html b/public/index.html
ddeleted file mode 100644
iindex 675022b..0000000
---- a/public/index.html
++++ /dev/null
@@@ -1,40 +0,0 @@
--<!DOCTYPE html>
--<html lang="en">
--<head>
--<meta charset="UTF-8">
--<meta name="viewport" content="width=device-width, initial-scale=1.0">
--<title>OpenVibe.Community — Coming Soon</title>
--<meta name="description" content="The community hub of the OpenVibe network. Coming soon.">
--<meta property="og:title" content="OpenVibe.Community">
--<meta property="og:description" content="The community hub of the OpenVibe network. Coming soon.">
--<meta property="og:url" content="https://openvibe.community/">
--<style>
-- :root { --accent:#3b82f6; --accent-light:#60a5fa; --bg:#131318; --bg2:#1a1a22; --text:#e8e6e3; --muted:#9a9a9a; }
-- * { margin:0; padding:0; box-sizing:border-box; }
-- body { background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
-- min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:2rem; }
-- .glyph { width:72px; height:72px; border-radius:20px; background:linear-gradient(135deg,var(--accent),#22d3ee);
-- display:flex; align-items:center; justify-content:center; margin-bottom:1.5rem; font-size:2rem; }
-- h1 { font-size:clamp(1.8rem,5vw,3rem); letter-spacing:-0.02em; }
-- h1 span { color:var(--accent-light); }
-- p.tag { color:var(--muted); margin:1rem 0 2.5rem; font-size:1.1rem; max-width:34rem; }
-- .links { display:flex; flex-wrap:wrap; gap:0.75rem; justify-content:center; }
-- .links a { color:var(--text); text-decoration:none; background:var(--bg2); border:1px solid #2a2a32;
-- padding:0.6rem 1.1rem; border-radius:10px; transition:border-color .15s, transform .15s; }
-- .links a:hover { border-color:var(--accent); transform:translateY(-1px); }
-- footer { position:fixed; bottom:1.2rem; color:var(--muted); font-size:0.85rem; }
--</style>
--</head>
--<body>
-- <div class="glyph">✦</div>
-- <h1>OpenVibe<span>.Community</span></h1>
-- <p class="tag">A home for the people of the OpenVibe network. We're still building this one — check back soon.</p>
-- <div class="links">
-- <a href="https://openvibe.live">OpenVibe.Live</a>
-- <a href="https://openvibe.tools">OpenVibe.Tools</a>
-- <a href="https://openvibe.games">OpenVibe.Games</a>
-- <a href="https://openvibe.network">Your Account</a>
-- </div>
-- <footer>© 2026 OpenVibe — Community Project</footer>
--</body>
--</html>
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..22c9863
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++require('dotenv').config();
++
++const isProduction = (process.env.NODE_ENV || 'development') === 'production';
++
++const port = parseInt(process.env.PORT, 10) || 4200;
++
++module.exports = {
++ port,
++ host: process.env.HOST || '127.0.0.1',
++ nodeEnv: process.env.NODE_ENV || 'development',
++ isProduction,
++
++ // Public URL of this site — canonical links, OG tags, sitemap entries.
++ baseUrl: (process.env.BASE_URL || (isProduction ? 'https://openvibe.community' : `http://localhost:${port}`)).replace(/\/$/, ''),
++
++ // Hops in front of Node that set X-Forwarded-For: Cloudflare → nginx → Node.
++ trustProxy: process.env.TRUST_PROXY != null ? Number(process.env.TRUST_PROXY) : 2,
++
++ // Identity provider — OpenVibe.Network (OAuth2 authorization server + JWKS)
++ networkUrl: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
++ networkInternalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
++
++ // OAuth2 client credentials (client `community` registered in the Network's oauth_clients table)
++ oauth: {
++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'community',
++ clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: process.env.OV_OAUTH_REDIRECT_URI
++ || (isProduction ? 'https://openvibe.community/auth/callback' : 'http://localhost:4200/auth/callback'),
++ scope: 'profile theme',
++ },
++
++ // Cookies are host-only for openvibe.community (no Domain attribute — there are no subdomains).
++ cookies: {
++ secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction,
++ },
++
++ // OpenVibe.Live — backs every paste read and write. The pastes themselves are stored in
++ // OpenVibe.Media, but we go through Live rather than straight to Media: Live owns the
++ // accounts these pastes belong to, so it is the only service that can turn a signed-in
++ // visitor (a Network JWT) into the user id Media files the write under.
++ liveInternalUrl: (process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''),
++ liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
++ // OpenVibe.Media — public host that serves raw paste text and screenshots.
++ mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),
++};
ddiff --git a/vendor/openvibe-shared/account-switcher.js b/vendor/openvibe-shared/account-switcher.js
nnew file mode 100644
iindex 0000000..cc16ff3
---- /dev/null
++++ b/vendor/openvibe-shared/account-switcher.js
@@@ -0,0 +1,676 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Account Switcher
++// Google-style multi-account management with anonymous mode.
++// Persists sessions in localStorage, coordinates with server
++// for token swapping. Integrates with OpenVibeNavbar dropdown.
++// Usage: OpenVibeAccountSwitcher.init({ apiBase, onSwitch })
++// ═══════════════════════════════════════════════════════════════
++
++(function (root) {
++ 'use strict';
++
++ const STORAGE_KEY = 'openvibe_accounts';
++ const TOKEN_KEY = 'ov_token';
++ const ACTIVE_KEY = 'openvibe_active_account';
++ const ANON_KEY = 'openvibe_anon_token';
++ const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>
++ const MAX_ACCOUNTS = 10;
++
++ let _config = { apiBase: 'https://openvibe.network', onSwitch: null };
++ let _panelEl = null;
++
++ function isAnonId(id) {
++ return String(id) === 'anon' || String(id).startsWith('anon_');
++ }
++
++ /** Store of session tokens per anon identity */
++ function getAnonTokens() {
++ try { return JSON.parse(localStorage.getItem(ANON_TOKENS_KEY) || '{}'); } catch { return {}; }
++ }
++ function saveAnonTokens(tokens) {
++ localStorage.setItem(ANON_TOKENS_KEY, JSON.stringify(tokens));
++ }
++ function setAnonToken(anonId, token) {
++ const tokens = getAnonTokens();
++ tokens[anonId] = token;
++ saveAnonTokens(tokens);
++ }
++ function getAnonToken(anonId) {
++ return getAnonTokens()[anonId] || null;
++ }
++ function removeAnonToken(anonId) {
++ const tokens = getAnonTokens();
++ delete tokens[anonId];
++ saveAnonTokens(tokens);
++ }
++
++ // Cookie policy (CONTRACTS.md): ov_token is host-only everywhere EXCEPT on
++ // openvibe.tools + its subdomains, where a Domain=.openvibe.tools cookie is
++ // shared across the tool satellites.
++ function onToolsDomain() {
++ const host = (typeof location !== 'undefined' && location.hostname) || '';
++ return host === 'openvibe.tools' || host.endsWith('.openvibe.tools');
++ }
++
++ function clearAuthCookie() {
++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
++ if (onToolsDomain()) {
++ document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools;SameSite=Lax';
++ }
++ }
++
++ function setAuthCookie(token) {
++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};domain=.openvibe.tools;SameSite=Lax;Secure`;
++ } else {
++ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};SameSite=Lax${secure}`;
++ }
++ }
++
++ function normalizeAccount(user, token) {
++ const isAnon = !!user?.is_anon || isAnonId(user?.id);
++ const anonNum = user.anon_number || null;
++ // Each anon gets a unique ID based on its anon_number
++ const id = isAnon ? (anonNum ? `anon_${anonNum}` : 'anon') : user.id;
++ return {
++ id,
++ username: user.username,
++ display_name: user.display_name || user.username,
++ avatar_url: user.avatar_url || null,
++ email: user.email || null,
++ is_anon: isAnon,
++ anon_number: anonNum,
++ token,
++ added_at: Date.now(),
++ };
++ }
++
++ function getAnonAccount(id) {
++ if (id) return getAccounts().find(a => a && a.is_anon && String(a.id) === String(id)) || null;
++ return getAccounts().find(a => a && a.is_anon) || null;
++ }
++
++ function getAnonAccounts() {
++ return getAccounts().filter(a => a && a.is_anon);
++ }
++
++ // ─── Account Store ─────────────────────────────────────────
++ function getAccounts() {
++ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
++ }
++
++ function saveAccounts(accounts) {
++ localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts));
++ }
++
++ function getActiveId() {
++ return localStorage.getItem(ACTIVE_KEY);
++ }
++
++ function setActiveId(id) {
++ localStorage.setItem(ACTIVE_KEY, id);
++ }
++
++ function addAccount(user, token) {
++ const accounts = getAccounts();
++ const entry = normalizeAccount(user, token);
++ const existing = accounts.findIndex(a => String(a.id) === String(entry.id));
++ if (existing !== -1) {
++ accounts[existing] = entry;
++ } else {
++ if (accounts.length >= MAX_ACCOUNTS) {
++ accounts.shift(); // Remove oldest
++ }
++ accounts.push(entry);
++ }
++ saveAccounts(accounts);
++ return accounts;
++ }
++
++ function removeAccount(id) {
++ const targetId = String(id);
++ const accounts = getAccounts().filter(a => String(a.id) !== targetId);
++ saveAccounts(accounts);
++ // Clean up anon token if removing an anon identity
++ if (isAnonId(targetId)) removeAnonToken(targetId);
++ if (getActiveId() === targetId) {
++ const fallback = accounts.find(a => !a.is_anon) || accounts[0];
++ if (fallback) switchTo(fallback.id);
++ else logout();
++ }
++ return accounts;
++ }
++
++ /** Read ov_token from the cookie (host-only; Domain=.openvibe.tools on tools hosts) */
++ function getCookieToken() {
++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
++ return m ? decodeURIComponent(m[1]) : null;
++ }
++
++ // ─── API ───────────────────────────────────────────────────
++ async function apiFetch(path, opts = {}) {
++ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
++ const headers = { 'Content-Type': 'application/json', ...opts.headers };
++ if (token) headers.Authorization = `Bearer ${token}`;
++ const res = await fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: 'include' });
++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
++ return res.json();
++ }
++
++ async function switchTo(accountId) {
++ if (isAnonId(accountId)) return switchToAnon(accountId);
++
++ const accounts = getAccounts();
++ const target = accounts.find(a => String(a.id) === String(accountId));
++ if (target?.is_anon) return switchToAnon(accountId);
++ if (!target || !target.token) {
++ // Token expired — need re-login
++ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
++ return;
++ }
++
++ // Validate token is still good
++ try {
++ const headers = { Authorization: `Bearer ${target.token}` };
++ const res = await fetch(`${_config.apiBase}/api/auth/me`, { headers });
++ if (!res.ok) throw new Error('Token invalid');
++ } catch {
++ // Token expired
++ removeAccount(target.id);
++ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
++ return;
++ }
++
++ localStorage.setItem(TOKEN_KEY, target.token);
++ setActiveId(target.id);
++ setAuthCookie(target.token);
++
++ if (_config.onSwitch) _config.onSwitch(target);
++ else window.location.reload();
++ }
++
++ /**
++ * Switch to an anonymous identity. If anonId is provided, switch to that
++ * specific anon. Otherwise use the most recently active or create a new one.
++ */
++ async function switchToAnon(anonId) {
++ async function fetchAnonUser(token) {
++ const res = await fetch(`${_config.apiBase}/api/auth/anon/${encodeURIComponent(token)}`, { credentials: 'include' });
++ if (!res.ok) throw new Error('Anon session invalid');
++ const data = await res.json();
++ return data.user || null;
++ }
++
++ let anonToken = null;
++ let anonUser = null;
++
++ // If switching to a specific anon identity, look up its token
++ if (anonId && anonId !== 'anon') {
++ anonToken = getAnonToken(anonId);
++ }
++
++ // Fallback to legacy single-anon token
++ if (!anonToken) {
++ anonToken = localStorage.getItem(ANON_KEY);
++ }
++
++ // Try to validate the existing token
++ if (anonToken) {
++ try {
++ anonUser = await fetchAnonUser(anonToken);
++ } catch {
++ // Token invalid — clear it
++ if (anonId) removeAnonToken(anonId);
++ localStorage.removeItem(ANON_KEY);
++ anonToken = null;
++ }
++ }
++
++ // Create new anon session if we don't have a valid one
++ if (!anonToken) {
++ try {
++ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: '{}',
++ credentials: 'include',
++ }).then(async (res) => {
++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
++ return res.json();
++ });
++ anonToken = data.token;
++ anonUser = data.user || null;
++ } catch (err) {
++ console.error('[OpenVibeAccountSwitcher] Failed to create anon session:', err);
++ return;
++ }
++ }
++
++ if (!anonUser) {
++ try {
++ anonUser = await fetchAnonUser(anonToken);
++ } catch (err) {
++ console.error('[OpenVibeAccountSwitcher] Failed to load anon session:', err);
++ return;
++ }
++ }
++
++ // Store the anon account and its token
++ if (anonUser) {
++ const entry = addAccount(anonUser, anonToken);
++ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
++ setAnonToken(realId, anonToken);
++ // Also keep legacy key for backward compat
++ localStorage.setItem(ANON_KEY, anonToken);
++
++ localStorage.removeItem(TOKEN_KEY);
++ setActiveId(realId);
++ clearAuthCookie();
++
++ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number || null });
++ else window.location.reload();
++ }
++ }
++
++ /**
++ * Create a brand-new anonymous identity (even if one already exists).
++ * Useful for the "New Anon Identity" button.
++ */
++ async function createNewAnon() {
++ try {
++ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ force_new: true }),
++ credentials: 'include',
++ }).then(async (res) => {
++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
++ return res.json();
++ });
++ const anonToken = data.token;
++ const anonUser = data.user;
++ if (!anonUser) throw new Error('No user returned');
++
++ const entry = addAccount(anonUser, anonToken);
++ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
++ setAnonToken(realId, anonToken);
++
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.setItem(ANON_KEY, anonToken);
++ setActiveId(realId);
++ clearAuthCookie();
++
++ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number });
++ else window.location.reload();
++ } catch (err) {
++ console.error('[OpenVibeAccountSwitcher] Failed to create new anon:', err);
++ }
++ }
++
++ function logout() {
++ const activeId = getActiveId();
++ if (activeId) removeAccount(activeId);
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.removeItem(ACTIVE_KEY);
++ clearAuthCookie();
++ if (_config.onSwitch) _config.onSwitch(null);
++ else window.location.reload();
++ }
++
++ function logoutAll() {
++ localStorage.removeItem(STORAGE_KEY);
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.removeItem(ACTIVE_KEY);
++ localStorage.removeItem(ANON_KEY);
++ localStorage.removeItem(ANON_TOKENS_KEY);
++ clearAuthCookie();
++ if (_config.onSwitch) _config.onSwitch(null);
++ else window.location.reload();
++ }
++
++ // ─── Switcher Panel UI ─────────────────────────────────────
++ function injectStyles() {
++ if (document.getElementById('openvibe-switcher-styles')) return;
++ const s = document.createElement('style');
++ s.id = 'openvibe-switcher-styles';
++ s.textContent = `
++ .openvibe-switcher-overlay {
++ position: fixed; inset: 0; z-index: 20000;
++ background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
++ display: flex; align-items: center; justify-content: center;
++ animation: openvibe-sw-fadein .2s ease;
++ }
++ @keyframes openvibe-sw-fadein { from { opacity: 0; } to { opacity: 1; } }
++
++ .openvibe-switcher-panel {
++ width: 380px; max-height: 520px;
++ background: var(--bg-card, #22222c);
++ border: 1px solid var(--border, #333340); border-radius: 16px;
++ box-shadow: 0 20px 60px rgba(0,0,0,0.6);
++ overflow: hidden; animation: openvibe-sw-pop .25s cubic-bezier(.34,1.56,.64,1);
++ }
++ @keyframes openvibe-sw-pop { from { transform: scale(.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
++
++ .openvibe-switcher-header {
++ text-align: center; padding: 20px 16px 12px;
++ border-bottom: 1px solid var(--border, #333340);
++ }
++ .openvibe-switcher-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; color: var(--text-primary, #e0e0e0); }
++ .openvibe-switcher-header p { margin: 0; font-size: 12px; color: var(--text-muted, #707080); }
++
++ .openvibe-switcher-list { padding: 8px; max-height: 300px; overflow-y: auto; }
++
++ .openvibe-switcher-item {
++ display: flex; align-items: center; gap: 12px; padding: 10px 12px;
++ border-radius: 10px; cursor: pointer; transition: background .15s;
++ position: relative;
++ }
++ .openvibe-switcher-item:hover { background: var(--bg-hover, #2f2f3d); }
++ .openvibe-switcher-item.active { background: rgba(192,150,92,0.08); }
++ .openvibe-switcher-item.active::after {
++ content: '✓'; position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
++ color: var(--accent, #8b5cf6); font-weight: 700; font-size: 16px;
++ }
++ .openvibe-switcher-item img { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
++ .openvibe-switcher-item .info { flex: 1; line-height: 1.3; }
++ .openvibe-switcher-item .info .name { font-size: 14px; font-weight: 600; color: var(--text-primary, #e0e0e0); }
++ .openvibe-switcher-item .info .sub { font-size: 11px; color: var(--text-muted, #707080); }
++ .openvibe-switcher-item .remove {
++ opacity: 0; font-size: 14px; color: var(--live-red, #e74c3c);
++ cursor: pointer; padding: 4px 6px; border-radius: 4px;
++ transition: opacity .15s, background .15s;
++ }
++ .openvibe-switcher-item:hover .remove { opacity: .6; }
++ .openvibe-switcher-item .remove:hover { opacity: 1; background: rgba(231,76,60,0.1); }
++
++ .openvibe-switcher-anon {
++ display: flex; align-items: center; gap: 12px; padding: 10px 12px;
++ margin: 0 8px 8px; border-radius: 10px; cursor: pointer;
++ border: 1px dashed var(--border, #333340);
++ transition: all .15s; color: var(--text-muted, #707080);
++ }
++ .openvibe-switcher-anon:hover { background: var(--bg-hover, #2f2f3d); border-color: var(--accent-dark, #a07840); }
++ .openvibe-switcher-anon .anon-icon { font-size: 22px; width: 40px; text-align: center; }
++
++ .openvibe-switcher-actions {
++ padding: 8px 12px; border-top: 1px solid var(--border, #333340);
++ display: flex; gap: 8px; justify-content: center;
++ }
++ .openvibe-switcher-actions button {
++ flex: 1; padding: 8px; border: none; border-radius: 8px;
++ font-size: 12px; font-weight: 600; cursor: pointer;
++ transition: all .15s;
++ }
++ .openvibe-switcher-actions .btn-add {
++ background: var(--accent, #8b5cf6); color: #fff;
++ }
++ .openvibe-switcher-actions .btn-add:hover { background: var(--accent-dark, #a07840); }
++ .openvibe-switcher-actions .btn-signout {
++ background: rgba(231,76,60,0.1); color: var(--live-red, #e74c3c);
++ }
++ .openvibe-switcher-actions .btn-signout:hover { background: rgba(231,76,60,0.2); }
++ `;
++ document.head.appendChild(s);
++ }
++
++ function showPanel() {
++ if (_panelEl) return;
++ injectStyles();
++
++ const accounts = getAccounts();
++ const activeId = getActiveId();
++ const isAnonActive = isAnonId(activeId);
++ const loggedAccounts = accounts.filter(a => !a.is_anon);
++ const anonAccounts = accounts.filter(a => a.is_anon);
++
++ const overlay = document.createElement('div');
++ overlay.className = 'openvibe-switcher-overlay';
++ overlay.addEventListener('click', e => {
++ if (e.target === overlay) closePanel();
++ });
++
++ const panel = document.createElement('div');
++ panel.className = 'openvibe-switcher-panel';
++
++ panel.innerHTML = `
++ <div class="openvibe-switcher-header">
++ <h3>\uD83D\uDD25 Switch Account</h3>
++ <p>Choose an account or browse anonymously</p>
++ </div>
++ <div class="openvibe-switcher-list">
++ ${loggedAccounts.map(a => `
++ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
++ <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="">
++ <div class="info">
++ <div class="name">${a.display_name || a.username}</div>
++ <div class="sub">${a.email || `@${a.username}`}</div>
++ </div>
++ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}
++ </div>
++ `).join('')}
++
++ ${anonAccounts.length > 0 ? `
++ <div style="padding:6px 12px 2px;font-size:11px;font-weight:600;color:var(--text-muted,#707080);text-transform:uppercase;letter-spacing:0.05em;">Anonymous Identities</div>
++ ${anonAccounts.map(a => `
++ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
++ <span style="width:40px;height:40px;display:flex;align-items:center;justify-content:center;font-size:20px;border-radius:50%;background:var(--bg-secondary,#1a1a26);flex-shrink:0;">\uD83E\uDEE5</span>
++ <div class="info">
++ <div class="name">Anonymous #${a.anon_number || '?'}</div>
++ <div class="sub">anon_${a.anon_number || '?'}</div>
++ </div>
++ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove identity">\u2715</span>` : ''}
++ </div>
++ `).join('')}
++ ` : ''}
++ </div>
++ <div class="openvibe-switcher-anon" id="openvibe-sw-new-anon">
++ <span class="anon-icon">+</span>
++ <div class="info">
++ <div class="name" style="color:var(--text-primary,#e0e0e0)">${anonAccounts.length > 0 ? 'New Anon Identity' : 'Go Anonymous'}</div>
++ <div class="sub">${anonAccounts.length > 0 ? 'Create another anonymous identity' : 'Browse without an account \u00b7 Limited features'}</div>
++ </div>
++ </div>
++ <div class="openvibe-switcher-actions">
++ <button class="btn-add" id="openvibe-sw-add">+ Add Account</button>
++ <button class="btn-signout" id="openvibe-sw-signout">Sign Out All</button>
++ </div>
++ `;
++
++ overlay.appendChild(panel);
++ document.body.appendChild(overlay);
++ _panelEl = overlay;
++
++ // Wire events
++ panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {
++ el.addEventListener('click', e => {
++ if (e.target.closest('.remove')) return;
++ switchTo(el.dataset.id);
++ closePanel();
++ });
++ });
++
++ panel.querySelectorAll('[data-remove-id]').forEach(el => {
++ el.addEventListener('click', e => {
++ e.stopPropagation();
++ removeAccount(el.dataset.removeId);
++ closePanel();
++ showPanel(); // Re-render
++ });
++ });
++
++ panel.querySelector('#openvibe-sw-new-anon').addEventListener('click', () => {
++ if (anonAccounts.length > 0) {
++ createNewAnon();
++ } else {
++ switchToAnon();
++ }
++ closePanel();
++ });
++
++ panel.querySelector('#openvibe-sw-add').addEventListener('click', () => {
++ window.location.href = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
++ });
++
++ panel.querySelector('#openvibe-sw-signout').addEventListener('click', () => {
++ logoutAll();
++ closePanel();
++ });
++
++ // Esc to close
++ const escHandler = e => { if (e.key === 'Escape') closePanel(); };
++ document.addEventListener('keydown', escHandler);
++ overlay._escHandler = escHandler;
++ }
++
++ function closePanel() {
++ if (!_panelEl) return;
++ document.removeEventListener('keydown', _panelEl._escHandler);
++ _panelEl.remove();
++ _panelEl = null;
++ }
++
++ // ─── Token Refresh ───────────────────────────────────────────
++ // Decode JWT payload without verification (client-side can't verify RS256).
++ // Used only to check `exp` claim for proactive refresh.
++ function decodeJwtPayload(token) {
++ try {
++ const parts = token.split('.');
++ if (parts.length !== 3) return null;
++ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
++ return JSON.parse(atob(payload));
++ } catch { return null; }
++ }
++
++ let _refreshTimer = null;
++ let _refreshInProgress = false;
++
++ /**
++ * Silently refresh the JWT if it expires within the next 2 hours,
++ * or if it has already expired (7-day grace period on the server).
++ * Updates localStorage, cookie, and account list.
++ */
++ async function silentRefresh() {
++ if (_refreshInProgress) return;
++ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
++ if (!token) return;
++ const payload = decodeJwtPayload(token);
++ if (!payload || !payload.exp) return;
++
++ const expiresAt = payload.exp * 1000;
++ const now = Date.now();
++ const twoHours = 2 * 60 * 60 * 1000;
++
++ // Only refresh if token expires within 2 hours or is already expired
++ if (expiresAt - now > twoHours) return;
++
++ _refreshInProgress = true;
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
++ method: 'POST',
++ headers: {
++ 'Content-Type': 'application/json',
++ Authorization: `Bearer ${token}`,
++ },
++ credentials: 'include',
++ });
++ if (!res.ok) {
++ // If refresh fails with 401, token is truly dead — don't retry
++ if (res.status === 401) {
++ console.warn('[OpenVibeAuth] Token refresh failed — session expired');
++ }
++ return;
++ }
++ const data = await res.json();
++ if (data.token) {
++ // Update localStorage
++ localStorage.setItem(TOKEN_KEY, data.token);
++ // Update cookie
++ setAuthCookie(data.token);
++ // Update the stored account entry
++ const activeId = getActiveId();
++ if (activeId && !isAnonId(activeId)) {
++ const accounts = getAccounts();
++ const idx = accounts.findIndex(a => String(a.id) === String(activeId));
++ if (idx !== -1) {
++ accounts[idx].token = data.token;
++ saveAccounts(accounts);
++ }
++ }
++ console.log('[OpenVibeAuth] Token refreshed silently');
++ }
++ } catch (err) {
++ console.warn('[OpenVibeAuth] Silent refresh error:', err.message);
++ } finally {
++ _refreshInProgress = false;
++ }
++ }
++
++ /**
++ * Start a periodic timer that checks token expiry and refreshes when needed.
++ * Checks every 15 minutes. Also runs an immediate check.
++ */
++ function startRefreshTimer() {
++ if (_refreshTimer) return;
++ silentRefresh(); // Immediate check
++ _refreshTimer = setInterval(silentRefresh, 15 * 60 * 1000); // Every 15 min
++ }
++
++ function stopRefreshTimer() {
++ if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; }
++ }
++
++ // ─── Listen for navbar events ──────────────────────────────
++ document.addEventListener('openvibe-switch-account', e => {
++ switchTo(e.detail.accountId);
++ });
++
++ // ─── Public API ────────────────────────────────────────────
++ const OpenVibeAccountSwitcher = {
++ init(opts = {}) {
++ Object.assign(_config, opts);
++ // Start silent token refresh for logged-in users
++ const activeId = getActiveId();
++ if (activeId && !isAnonId(activeId)) {
++ startRefreshTimer();
++ }
++ },
++
++ /** Call after login success to store this account. */
++ addAccount,
++ removeAccount,
++ getAccounts,
++ getActiveId,
++ switchTo,
++ switchToAnon,
++ createNewAnon,
++ getAnonAccounts,
++ logout,
++ logoutAll,
++ silentRefresh,
++ startRefreshTimer,
++ stopRefreshTimer,
++
++ /** Show the full-screen account switcher panel. */
++ showPanel,
++ closePanel,
++
++ /** Quick check: is user on an anonymous session? */
++ isAnonymous() {
++ const id = getActiveId();
++ return isAnonId(id);
++ },
++
++ /** Get the current active account object. */
++ getActive() {
++ const id = getActiveId();
++ if (!id) return null;
++ if (isAnonId(id)) return getAnonAccount(id) || getAnonAccount() || null;
++ return getAccounts().find(a => String(a.id) === String(id)) || null;
++ },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeAccountSwitcher;
++ else root.OpenVibeAccountSwitcher = OpenVibeAccountSwitcher;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
ddiff --git a/vendor/openvibe-shared/analytics.js b/vendor/openvibe-shared/analytics.js
nnew file mode 100644
iindex 0000000..600f2ae
---- /dev/null
++++ b/vendor/openvibe-shared/analytics.js
@@@ -0,0 +1,1066 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Unified Analytics Module
++// Shared analytics tracking for all OpenVibe services.
++// Each service stores events in its own SQLite DB using the
++// same schema. The admin panel aggregates via internal API.
++// ═══════════════════════════════════════════════════════════════
++
++// ── Bot Detection ────────────────────────────────────────────
++
++const BOT_USER_AGENTS = [
++ /googlebot/i, /bingbot/i, /slurp/i, /duckduckbot/i, /baiduspider/i,
++ /yandexbot/i, /facebot/i, /ia_archiver/i, /semrushbot/i, /ahrefsbot/i,
++ /mj12bot/i, /dotbot/i, /petalbot/i, /rogerbot/i, /screaming frog/i,
++ /seznambot/i, /sogou/i, /exabot/i, /archive\.org_bot/i, /crawler/i,
++ /spider/i, /python-requests/i, /python-urllib/i, /httpx/i,
++ /go-http-client/i, /java\//i, /libwww-perl/i, /wget/i, /curl/i,
++ /headlesschrome/i, /phantomjs/i, /scrapy/i, /node-fetch/i,
++ /axios/i, /postman/i, /insomnia/i, /lighthouse/i, /pagespeed/i,
++ /gptbot/i, /chatgpt-user/i, /claudebot/i, /anthropic/i,
++ /bytespider/i, /amazonbot/i, /applebot/i, /twitterbot/i,
++ /facebookexternalhit/i, /linkedinbot/i, /whatsapp/i, /telegrambot/i,
++ /discordbot/i, /slackbot/i, /uptimerobot/i, /pingdom/i,
++ /statuscake/i, /monitoring/i, /health.?check/i,
++];
++
++const SUSPICIOUS_PATTERNS = {
++ // Hits per minute thresholds
++ highRequestRate: 60,
++ // Pages per session in under a minute
++ rapidPageViews: 20,
++ // No JS execution (missing beacon)
++ noJsExecution: true,
++ // Accessing well-known bot traps
++ honeypotPaths: ['/wp-login.php', '/xmlrpc.php', '/wp-admin', '/.env', '/admin.php', '/phpmyadmin'],
++};
++
++/**
++ * Classify whether a request is from a bot.
++ * Returns: { isBot: boolean, botType: string|null, confidence: number }
++ */
++function classifyRequest(req) {
++ const ua = req.headers['user-agent'] || '';
++ const ip = req.ip || req.connection?.remoteAddress || '';
++
++ // 1. Known bot user agents
++ for (const pattern of BOT_USER_AGENTS) {
++ if (pattern.test(ua)) {
++ return { isBot: true, botType: 'known_crawler', confidence: 0.95 };
++ }
++ }
++
++ // 2. Missing or empty user agent
++ if (!ua || ua.length < 10) {
++ return { isBot: true, botType: 'no_useragent', confidence: 0.85 };
++ }
++
++ // 3. Honeypot paths
++ const path = req.path || req.url || '';
++ if (SUSPICIOUS_PATTERNS.honeypotPaths.some(p => path.toLowerCase().startsWith(p))) {
++ return { isBot: true, botType: 'honeypot_hit', confidence: 0.90 };
++ }
++
++ // 4. Looks human (default)
++ return { isBot: false, botType: null, confidence: 0.1 };
++}
++
++
++// ── Schema ───────────────────────────────────────────────────
++
++const ANALYTICS_SCHEMA = `
++ -- Page views / API hits
++ CREATE TABLE IF NOT EXISTS analytics_events (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ service TEXT NOT NULL,
++ event_type TEXT NOT NULL DEFAULT 'pageview',
++ path TEXT,
++ method TEXT DEFAULT 'GET',
++ status_code INTEGER,
++ response_time_ms INTEGER,
++ user_id INTEGER,
++ session_id TEXT,
++ ip TEXT,
++ country TEXT,
++ city TEXT,
++ user_agent TEXT,
++ referer TEXT,
++ is_bot INTEGER DEFAULT 0,
++ bot_type TEXT,
++ device_type TEXT,
++ browser TEXT,
++ os TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++
++ -- Aggregated hourly stats (materialized by cron/timer)
++ CREATE TABLE IF NOT EXISTS analytics_hourly (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ service TEXT NOT NULL,
++ hour TEXT NOT NULL,
++ pageviews INTEGER DEFAULT 0,
++ api_calls INTEGER DEFAULT 0,
++ unique_visitors INTEGER DEFAULT 0,
++ unique_users INTEGER DEFAULT 0,
++ bot_hits INTEGER DEFAULT 0,
++ avg_response_ms INTEGER DEFAULT 0,
++ error_count INTEGER DEFAULT 0,
++ bandwidth_bytes INTEGER DEFAULT 0,
++ top_paths TEXT,
++ top_referers TEXT,
++ UNIQUE(service, hour)
++ );
++
++ -- Daily aggregates
++ CREATE TABLE IF NOT EXISTS analytics_daily (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ service TEXT NOT NULL,
++ date TEXT NOT NULL,
++ pageviews INTEGER DEFAULT 0,
++ api_calls INTEGER DEFAULT 0,
++ unique_visitors INTEGER DEFAULT 0,
++ unique_users INTEGER DEFAULT 0,
++ new_users INTEGER DEFAULT 0,
++ bot_hits INTEGER DEFAULT 0,
++ avg_response_ms INTEGER DEFAULT 0,
++ error_count INTEGER DEFAULT 0,
++ top_paths TEXT,
++ top_referers TEXT,
++ top_countries TEXT,
++ device_breakdown TEXT,
++ browser_breakdown TEXT,
++ UNIQUE(service, date)
++ );
++
++ -- Rate tracking for bot detection
++ CREATE TABLE IF NOT EXISTS analytics_rate_tracking (
++ ip TEXT NOT NULL,
++ window_start INTEGER NOT NULL,
++ hit_count INTEGER DEFAULT 1,
++ PRIMARY KEY (ip, window_start)
++ );
++
++ -- Indexes
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events(created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_service ON analytics_events(service, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_path ON analytics_events(service, path, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_ip ON analytics_events(ip, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_bot ON analytics_events(is_bot, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_user ON analytics_events(user_id, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_hourly_lookup ON analytics_hourly(service, hour);
++ CREATE INDEX IF NOT EXISTS idx_analytics_daily_lookup ON analytics_daily(service, date);
++`;
++
++
++// ── Device/Browser Parsing (lightweight) ─────────────────────
++
++function parseUserAgent(ua) {
++ if (!ua) return { device: 'unknown', browser: 'unknown', os: 'unknown' };
++
++ // Device
++ let device = 'desktop';
++ if (/mobile|android|iphone|ipod/i.test(ua)) device = 'mobile';
++ else if (/ipad|tablet/i.test(ua)) device = 'tablet';
++
++ // Browser
++ let browser = 'other';
++ if (/edg\//i.test(ua)) browser = 'edge';
++ else if (/opr\/|opera/i.test(ua)) browser = 'opera';
++ else if (/firefox\//i.test(ua)) browser = 'firefox';
++ else if (/chrome\//i.test(ua) && !/chromium/i.test(ua)) browser = 'chrome';
++ else if (/safari\//i.test(ua) && !/chrome/i.test(ua)) browser = 'safari';
++ else if (/trident|msie/i.test(ua)) browser = 'ie';
++
++ // OS
++ let os = 'other';
++ if (/windows/i.test(ua)) os = 'windows';
++ else if (/macintosh|mac os/i.test(ua)) os = 'macos';
++ else if (/linux/i.test(ua) && !/android/i.test(ua)) os = 'linux';
++ else if (/android/i.test(ua)) os = 'android';
++ else if (/iphone|ipad|ipod/i.test(ua)) os = 'ios';
++
++ return { device, browser, os };
++}
++
++
++// ── Analytics Tracker Class ──────────────────────────────────
++
++class AnalyticsTracker {
++ /**
++ * @param {object} db - better-sqlite3 database instance
++ * @param {string} service - service name (e.g., 'live', 'openvibe-network', 'tools')
++ */
++ constructor(db, service) {
++ this.db = db;
++ this.service = service;
++ this._buffer = [];
++ this._flushInterval = null;
++ this._aggregateInterval = null;
++
++ // Initialize schema
++ this.db.exec(ANALYTICS_SCHEMA);
++
++ // Prepared statements
++ this._insertEvent = this.db.prepare(`
++ INSERT INTO analytics_events
++ (service, event_type, path, method, status_code, response_time_ms,
++ user_id, session_id, ip, country, city, user_agent, referer,
++ is_bot, bot_type, device_type, browser, os)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ `);
++
++ this._insertMany = this.db.transaction((events) => {
++ for (const e of events) {
++ this._insertEvent.run(
++ e.service, e.event_type, e.path, e.method, e.status_code, e.response_time_ms,
++ e.user_id, e.session_id, e.ip, e.country, e.city, e.user_agent, e.referer,
++ e.is_bot ? 1 : 0, e.bot_type, e.device_type, e.browser, e.os
++ );
++ }
++ });
++
++ this._updateRate = this.db.prepare(`
++ INSERT INTO analytics_rate_tracking (ip, window_start, hit_count)
++ VALUES (?, ?, 1)
++ ON CONFLICT(ip, window_start) DO UPDATE SET hit_count = hit_count + 1
++ `);
++
++ this._getRate = this.db.prepare(
++ 'SELECT SUM(hit_count) as total FROM analytics_rate_tracking WHERE ip = ? AND window_start >= ?'
++ );
++
++ this._cleanRates = this.db.prepare(
++ 'DELETE FROM analytics_rate_tracking WHERE window_start < ?'
++ );
++
++ // Start flush timer (write buffered events every 5s)
++ this._flushInterval = setInterval(() => this.flush(), 5000);
++
++ // Hourly aggregation timer
++ this._aggregateInterval = setInterval(() => this.aggregate(), 60 * 60 * 1000);
++
++ // Initial aggregation on startup (catch up)
++ setTimeout(() => this.aggregate(), 10_000);
++
++ // Clean old rate tracking every 10 min
++ setInterval(() => {
++ const cutoff = Math.floor(Date.now() / 1000) - 300;
++ try { this._cleanRates.run(cutoff); } catch {}
++ }, 10 * 60 * 1000);
++ }
++
++ /**
++ * Express middleware for automatic request tracking.
++ * Tracks page views and API calls with response times.
++ */
++ middleware() {
++ return (req, res, next) => {
++ // Skip health checks and static assets
++ const path = req.path || '';
++ if (path === '/api/health' || path === '/health' ||
++ /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(path)) {
++ return next();
++ }
++
++ const startTime = Date.now();
++
++ // Hook into response finish
++ const originalEnd = res.end;
++ res.end = (...args) => {
++ res.end = originalEnd;
++ res.end(...args);
++
++ const responseTime = Date.now() - startTime;
++ const botCheck = classifyRequest(req);
++ const parsed = parseUserAgent(req.headers['user-agent'] || '');
++
++ const isApi = path.startsWith('/api/') || path.startsWith('/internal/') || path.startsWith('/oauth/');
++ const eventType = isApi ? 'api_call' : 'pageview';
++
++ // Rate-based bot detection
++ const ip = req.ip || '';
++ const windowStart = Math.floor(Date.now() / 60000); // 1-minute windows
++ try {
++ this._updateRate.run(ip, windowStart);
++ if (!botCheck.isBot) {
++ const rateData = this._getRate.get(ip, windowStart - 1);
++ if (rateData && rateData.total > SUSPICIOUS_PATTERNS.highRequestRate) {
++ botCheck.isBot = true;
++ botCheck.botType = 'rate_limit';
++ botCheck.confidence = 0.80;
++ }
++ }
++ } catch {}
++
++ // Get session ID from cookie or generate from IP+UA
++ const sessionId = req.cookies?.openvibe_session ||
++ req.cookies?.ov_token?.slice(-16) ||
++ null;
++
++ this._buffer.push({
++ service: this.service,
++ event_type: eventType,
++ path: path.substring(0, 500),
++ method: req.method,
++ status_code: res.statusCode,
++ response_time_ms: responseTime,
++ user_id: req.user?.id || req.user?.sub || null,
++ session_id: sessionId,
++ ip: ip,
++ country: req.headers['cf-ipcountry'] || null,
++ city: null,
++ user_agent: (req.headers['user-agent'] || '').substring(0, 500),
++ referer: (req.headers['referer'] || '').substring(0, 500),
++ is_bot: botCheck.isBot,
++ bot_type: botCheck.botType,
++ device_type: parsed.device,
++ browser: parsed.browser,
++ os: parsed.os,
++ });
++
++ // Auto-flush if buffer gets large
++ if (this._buffer.length >= 100) {
++ this.flush();
++ }
++ };
++
++ next();
++ };
++ }
++
++ /**
++ * Track a custom event (not from HTTP request).
++ */
++ trackEvent(eventType, data = {}) {
++ this._buffer.push({
++ service: this.service,
++ event_type: eventType,
++ path: data.path || null,
++ method: data.method || null,
++ status_code: data.status_code || null,
++ response_time_ms: data.response_time_ms || null,
++ user_id: data.user_id || null,
++ session_id: data.session_id || null,
++ ip: data.ip || null,
++ country: data.country || null,
++ city: data.city || null,
++ user_agent: data.user_agent || null,
++ referer: data.referer || null,
++ is_bot: data.is_bot || false,
++ bot_type: data.bot_type || null,
++ device_type: data.device_type || null,
++ browser: data.browser || null,
++ os: data.os || null,
++ });
++ }
++
++ /**
++ * Flush buffered events to database.
++ */
++ flush() {
++ if (this._buffer.length === 0) return;
++ const events = this._buffer.splice(0);
++ try {
++ this._insertMany(events);
++ } catch (err) {
++ console.error(`[Analytics:${this.service}] Flush error:`, err.message);
++ }
++ }
++
++ /**
++ * Run hourly + daily aggregation.
++ */
++ aggregate() {
++ try {
++ this._aggregateHourly();
++ this._aggregateDaily();
++ // Prune raw events older than 90 days
++ this.db.prepare("DELETE FROM analytics_events WHERE created_at < datetime('now', '-90 days')").run();
++ } catch (err) {
++ console.error(`[Analytics:${this.service}] Aggregation error:`, err.message);
++ }
++ }
++
++ _aggregateHourly() {
++ // Aggregate last 2 hours (to catch stragglers)
++ const hours = [];
++ const now = new Date();
++ for (let i = 0; i < 2; i++) {
++ const d = new Date(now.getTime() - i * 3600000);
++ hours.push(d.toISOString().slice(0, 13) + ':00:00');
++ }
++
++ const upsert = this.db.prepare(`
++ INSERT OR REPLACE INTO analytics_hourly
++ (service, hour, pageviews, api_calls, unique_visitors, unique_users, bot_hits, avg_response_ms, error_count, top_paths, top_referers)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ `);
++
++ for (const hour of hours) {
++ const nextHour = new Date(new Date(hour).getTime() + 3600000).toISOString().slice(0, 19).replace('T', ' ');
++ const hourStart = hour.replace('T', ' ');
++
++ const stats = this.db.prepare(`
++ SELECT
++ COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,
++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,
++ COUNT(DISTINCT ip) AS unique_visitors,
++ COUNT(DISTINCT user_id) AS unique_users,
++ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS error_count
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at < ?
++ `).get(this.service, hourStart, nextHour);
++
++ if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) continue;
++
++ const topPaths = this.db.prepare(`
++ SELECT path, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at < ? AND is_bot = 0
++ GROUP BY path ORDER BY cnt DESC LIMIT 10
++ `).all(this.service, hourStart, nextHour);
++
++ const topReferers = this.db.prepare(`
++ SELECT referer, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at < ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0
++ GROUP BY referer ORDER BY cnt DESC LIMIT 10
++ `).all(this.service, hourStart, nextHour);
++
++ upsert.run(
++ this.service, hourStart,
++ stats.pageviews, stats.api_calls, stats.unique_visitors, stats.unique_users,
++ stats.bot_hits, stats.avg_response_ms || 0, stats.error_count,
++ JSON.stringify(topPaths), JSON.stringify(topReferers)
++ );
++ }
++ }
++
++ _aggregateDaily() {
++ // Aggregate last 2 days
++ const days = [];
++ const now = new Date();
++ for (let i = 0; i < 2; i++) {
++ const d = new Date(now.getTime() - i * 86400000);
++ days.push(d.toISOString().slice(0, 10));
++ }
++
++ const upsert = this.db.prepare(`
++ INSERT OR REPLACE INTO analytics_daily
++ (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, bot_hits,
++ avg_response_ms, error_count, top_paths, top_referers, top_countries, device_breakdown, browser_breakdown)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ `);
++
++ for (const date of days) {
++ const dayStart = date + ' 00:00:00';
++ const dayEnd = date + ' 23:59:59';
++
++ const stats = this.db.prepare(`
++ SELECT
++ COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,
++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,
++ COUNT(DISTINCT ip) AS unique_visitors,
++ COUNT(DISTINCT user_id) AS unique_users,
++ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS error_count
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ?
++ `).get(this.service, dayStart, dayEnd);
++
++ if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) continue;
++
++ const topPaths = this.db.prepare(`
++ SELECT path, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0
++ GROUP BY path ORDER BY cnt DESC LIMIT 15
++ `).all(this.service, dayStart, dayEnd);
++
++ const topReferers = this.db.prepare(`
++ SELECT referer, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0
++ GROUP BY referer ORDER BY cnt DESC LIMIT 10
++ `).all(this.service, dayStart, dayEnd);
++
++ const topCountries = this.db.prepare(`
++ SELECT country, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND country IS NOT NULL AND is_bot = 0
++ GROUP BY country ORDER BY cnt DESC LIMIT 10
++ `).all(this.service, dayStart, dayEnd);
++
++ const devices = this.db.prepare(`
++ SELECT device_type, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0
++ GROUP BY device_type ORDER BY cnt DESC
++ `).all(this.service, dayStart, dayEnd);
++
++ const browsers = this.db.prepare(`
++ SELECT browser, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0
++ GROUP BY browser ORDER BY cnt DESC
++ `).all(this.service, dayStart, dayEnd);
++
++ // New users (first seen this day)
++ const newUsers = this.db.prepare(`
++ SELECT COUNT(DISTINCT user_id) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND user_id IS NOT NULL
++ AND user_id NOT IN (
++ SELECT DISTINCT user_id FROM analytics_events
++ WHERE service = ? AND created_at < ? AND user_id IS NOT NULL
++ )
++ `).get(this.service, dayStart, dayEnd, this.service, dayStart);
++
++ upsert.run(
++ this.service, date, stats.pageviews, stats.api_calls,
++ stats.unique_visitors, stats.unique_users,
++ newUsers?.cnt || 0, stats.bot_hits,
++ stats.avg_response_ms || 0, stats.error_count,
++ JSON.stringify(topPaths), JSON.stringify(topReferers),
++ JSON.stringify(topCountries), JSON.stringify(devices),
++ JSON.stringify(browsers)
++ );
++ }
++ }
++
++ /**
++ * Get analytics data for the admin dashboard.
++ * @param {object} options
++ * @param {number} options.days - Number of days to look back (default 30)
++ * @param {number} options.hours - If set, overrides days with an hour-based range (e.g. 1, 12, 24, 72)
++ * @param {string} options.service - Service name override
++ */
++ getStats(options = {}) {
++ const { days = 30, hours, service } = options;
++ const svc = service || this.service;
++
++ // Compute cutoff timestamp for raw event queries
++ const rangeMs = hours ? hours * 3600000 : days * 86400000;
++ const rangeCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 19).replace('T', ' ');
++ const isSubDay = hours && hours < 24;
++ const effectiveDays = hours ? hours / 24 : days;
++
++ // Daily trend (use daily aggregates for multi-day, raw events for sub-day)
++ let daily = [];
++ if (!isSubDay) {
++ const dateCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 10);
++ daily = this.db.prepare(`
++ SELECT * FROM analytics_daily
++ WHERE service = ? AND date >= ?
++ ORDER BY date ASC
++ `).all(svc, dateCutoff);
++ }
++
++ // Hourly trend (always useful — scope to the requested range)
++ const hourlyLimit = hours ? Math.min(hours, 168) : 24;
++ const hourlyCutoff = new Date(Date.now() - hourlyLimit * 3600000).toISOString().slice(0, 19).replace('T', ' ');
++ const hourly = this.db.prepare(`
++ SELECT * FROM analytics_hourly
++ WHERE service = ? AND hour >= ?
++ ORDER BY hour ASC
++ `).all(svc, hourlyCutoff);
++
++ // For sub-day ranges, generate fine-grained time buckets from raw events
++ let timeBuckets = [];
++ if (isSubDay) {
++ // 5-minute buckets for ≤1h, 15-min for ≤12h, 30-min for ≤24h
++ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
++ timeBuckets = this.db.prepare(`
++ SELECT
++ strftime('%Y-%m-%d %H:', created_at) ||
++ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,
++ COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS pageviews,
++ COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS api_calls,
++ COUNT(DISTINCT ip) FILTER (WHERE is_bot = 0) AS unique_visitors,
++ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ GROUP BY bucket
++ ORDER BY bucket ASC
++ `).all(svc, rangeCutoff);
++ }
++
++ // Summary totals — for sub-day ranges, compute from raw events
++ let summary;
++ if (hours && hours < 48) {
++ summary = this.db.prepare(`
++ SELECT
++ COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS total_pageviews,
++ COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS total_api_calls,
++ COUNT(DISTINCT ip) FILTER (WHERE is_bot = 0) AS total_unique_visitors,
++ COUNT(DISTINCT user_id) FILTER (WHERE is_bot = 0 AND user_id IS NOT NULL) AS total_unique_users,
++ COUNT(*) FILTER (WHERE is_bot = 1) AS total_bot_hits,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS total_errors,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ `).get(svc, rangeCutoff);
++ } else {
++ const dateCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 10);
++ summary = this.db.prepare(`
++ SELECT
++ SUM(pageviews) as total_pageviews,
++ SUM(api_calls) as total_api_calls,
++ SUM(unique_visitors) as total_unique_visitors,
++ SUM(unique_users) as total_unique_users,
++ SUM(bot_hits) as total_bot_hits,
++ SUM(error_count) as total_errors,
++ CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms
++ FROM analytics_daily
++ WHERE service = ? AND date >= ?
++ `).get(svc, dateCutoff);
++ }
++
++ // Real-time (last 5 min)
++ const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');
++ const realtime = this.db.prepare(`
++ SELECT
++ COUNT(*) as requests,
++ COUNT(DISTINCT ip) as visitors,
++ COUNT(*) FILTER (WHERE is_bot = 1) as bots
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ `).get(svc, fiveMinAgo);
++
++ // Top pages (human only)
++ const topPages = this.db.prepare(`
++ SELECT path, COUNT(*) as hits, COUNT(DISTINCT ip) as visitors
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY path ORDER BY hits DESC LIMIT 20
++ `).all(svc, rangeCutoff);
++
++ // Top referers
++ const topReferers = this.db.prepare(`
++ SELECT referer, COUNT(*) as hits
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ AND referer IS NOT NULL AND referer != '' AND is_bot = 0
++ GROUP BY referer ORDER BY hits DESC LIMIT 15
++ `).all(svc, rangeCutoff);
++
++ // Bot breakdown
++ const botBreakdown = this.db.prepare(`
++ SELECT bot_type, COUNT(*) as hits
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 1
++ GROUP BY bot_type ORDER BY hits DESC LIMIT 15
++ `).all(svc, rangeCutoff);
++
++ // Status code distribution
++ const statusCodes = this.db.prepare(`
++ SELECT
++ CASE
++ WHEN status_code >= 200 AND status_code < 300 THEN '2xx'
++ WHEN status_code >= 300 AND status_code < 400 THEN '3xx'
++ WHEN status_code >= 400 AND status_code < 500 THEN '4xx'
++ WHEN status_code >= 500 THEN '5xx'
++ ELSE 'other'
++ END as group_code,
++ COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ GROUP BY group_code ORDER BY cnt DESC
++ `).all(svc, rangeCutoff);
++
++ // Device/browser/OS breakdown
++ const deviceBreakdown = this.db.prepare(`
++ SELECT device_type, COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY device_type ORDER BY cnt DESC
++ `).all(svc, rangeCutoff);
++
++ const browserBreakdown = this.db.prepare(`
++ SELECT browser, COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY browser ORDER BY cnt DESC
++ `).all(svc, rangeCutoff);
++
++ const osBreakdown = this.db.prepare(`
++ SELECT os, COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY os ORDER BY cnt DESC
++ `).all(svc, rangeCutoff);
++
++ // Country breakdown
++ const countryBreakdown = this.db.prepare(`
++ SELECT country, COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ AND country IS NOT NULL AND is_bot = 0
++ GROUP BY country ORDER BY cnt DESC LIMIT 20
++ `).all(svc, rangeCutoff);
++
++ // ── Enhanced Metrics ─────────────────────────────────
++
++ // Response time percentiles (p50, p90, p95, p99)
++ let responsePercentiles = {};
++ try {
++ const rtRows = this.db.prepare(`
++ SELECT response_time_ms FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0
++ ORDER BY response_time_ms ASC
++ `).all(svc, rangeCutoff);
++ if (rtRows.length > 0) {
++ const vals = rtRows.map(r => r.response_time_ms);
++ const pct = (p) => vals[Math.min(Math.floor(vals.length * p), vals.length - 1)];
++ responsePercentiles = { p50: pct(0.5), p90: pct(0.9), p95: pct(0.95), p99: pct(0.99), max: vals[vals.length - 1] };
++ }
++ } catch {}
++
++ // Bandwidth estimation (response_time_ms * rough factor isn't great,
++ // but we can count requests and estimate from content-length if tracked;
++ // for now, count total requests * avg page weight heuristic)
++ let bandwidth = {};
++ try {
++ const bwData = this.db.prepare(`
++ SELECT
++ COUNT(*) as total_requests,
++ COUNT(*) FILTER (WHERE event_type = 'pageview') AS page_requests,
++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_requests,
++ SUM(CASE WHEN event_type = 'pageview' THEN 50000 ELSE 2000 END) AS estimated_bytes
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ `).get(svc, rangeCutoff);
++ if (bwData) {
++ bandwidth = {
++ total_requests: bwData.total_requests || 0,
++ page_requests: bwData.page_requests || 0,
++ api_requests: bwData.api_requests || 0,
++ estimated_bytes: bwData.estimated_bytes || 0,
++ };
++ }
++ } catch {}
++
++ // Authenticated vs anonymous sessions
++ let authBreakdown = {};
++ try {
++ authBreakdown = this.db.prepare(`
++ SELECT
++ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS authenticated,
++ COUNT(*) FILTER (WHERE user_id IS NULL) AS anonymous,
++ COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL) AS unique_authenticated_users,
++ COUNT(DISTINCT session_id) FILTER (WHERE session_id IS NOT NULL) AS total_sessions
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ `).get(svc, rangeCutoff) || {};
++ } catch {}
++
++ // Error rate over time (hourly for multi-day, bucketed for sub-day)
++ let errorTrend = [];
++ try {
++ if (isSubDay) {
++ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
++ errorTrend = this.db.prepare(`
++ SELECT
++ strftime('%Y-%m-%d %H:', created_at) ||
++ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,
++ COUNT(*) AS total,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
++ COUNT(*) FILTER (WHERE status_code >= 500) AS server_errors
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY bucket ORDER BY bucket ASC
++ `).all(svc, rangeCutoff);
++ } else {
++ errorTrend = this.db.prepare(`
++ SELECT hour as bucket,
++ (pageviews + api_calls) as total,
++ error_count as errors
++ FROM analytics_hourly
++ WHERE service = ? AND hour >= ?
++ ORDER BY hour ASC
++ `).all(svc, hourlyCutoff);
++ }
++ } catch {}
++
++ // Peak hours (which hours of day get most traffic)
++ let peakHours = [];
++ try {
++ peakHours = this.db.prepare(`
++ SELECT
++ CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,
++ COUNT(*) AS hits,
++ COUNT(DISTINCT ip) AS visitors,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY hour_of_day
++ ORDER BY hour_of_day ASC
++ `).all(svc, rangeCutoff);
++ } catch {}
++
++ // Top API endpoints (separate from top pages)
++ let topApiEndpoints = [];
++ try {
++ topApiEndpoints = this.db.prepare(`
++ SELECT path, method, COUNT(*) as hits,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS errors
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND event_type = 'api_call' AND is_bot = 0
++ GROUP BY path, method ORDER BY hits DESC LIMIT 20
++ `).all(svc, rangeCutoff);
++ } catch {}
++
++ // Unique sessions count
++ let sessionCount = 0;
++ try {
++ const sc = this.db.prepare(`
++ SELECT COUNT(DISTINCT session_id) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND session_id IS NOT NULL AND is_bot = 0
++ `).get(svc, rangeCutoff);
++ sessionCount = sc?.cnt || 0;
++ } catch {}
++
++ // New vs returning visitors (first-time IPs in this period vs seen before)
++ let visitorTypes = {};
++ try {
++ visitorTypes = this.db.prepare(`
++ SELECT
++ COUNT(DISTINCT ip) FILTER (
++ WHERE ip NOT IN (
++ SELECT DISTINCT ip FROM analytics_events
++ WHERE service = ? AND created_at < ? AND is_bot = 0
++ )
++ ) AS new_visitors,
++ COUNT(DISTINCT ip) FILTER (
++ WHERE ip IN (
++ SELECT DISTINCT ip FROM analytics_events
++ WHERE service = ? AND created_at < ? AND is_bot = 0
++ )
++ ) AS returning_visitors
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ `).get(svc, rangeCutoff, svc, rangeCutoff, svc, rangeCutoff) || {};
++ } catch {}
++
++ // Slowest endpoints
++ let slowestEndpoints = [];
++ try {
++ slowestEndpoints = this.db.prepare(`
++ SELECT path, method,
++ COUNT(*) as hits,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,
++ MAX(response_time_ms) AS max_ms
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0
++ GROUP BY path, method
++ HAVING hits >= 3
++ ORDER BY avg_ms DESC LIMIT 10
++ `).all(svc, rangeCutoff);
++ } catch {}
++
++ // Authenticated vs anonymous time trend
++ let authTrend = [];
++ try {
++ if (isSubDay) {
++ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
++ authTrend = this.db.prepare(`
++ SELECT
++ strftime('%Y-%m-%d %H:', created_at) ||
++ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,
++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NOT NULL) AS auth_visitors,
++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NULL) AS anon_visitors,
++ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS auth_hits,
++ COUNT(*) FILTER (WHERE user_id IS NULL) AS anon_hits
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY bucket ORDER BY bucket ASC
++ `).all(svc, rangeCutoff);
++ } else {
++ authTrend = this.db.prepare(`
++ SELECT
++ strftime('%Y-%m-%d', created_at) AS bucket,
++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NOT NULL) AS auth_visitors,
++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NULL) AS anon_visitors,
++ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS auth_hits,
++ COUNT(*) FILTER (WHERE user_id IS NULL) AS anon_hits
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY bucket ORDER BY bucket ASC
++ `).all(svc, rangeCutoff);
++ }
++ } catch {}
++
++ return {
++ service: svc,
++ period_days: effectiveDays,
++ period_hours: hours || null,
++ summary: summary || {},
++ realtime: realtime || {},
++ daily,
++ hourly,
++ timeBuckets,
++ topPages,
++ topReferers,
++ botBreakdown,
++ statusCodes,
++ deviceBreakdown,
++ browserBreakdown,
++ osBreakdown,
++ countryBreakdown,
++ // Enhanced metrics
++ responsePercentiles,
++ bandwidth,
++ authBreakdown,
++ errorTrend,
++ peakHours,
++ topApiEndpoints,
++ sessionCount,
++ visitorTypes,
++ slowestEndpoints,
++ authTrend,
++ };
++ }
++
++ /**
++ * Get a cross-service overview (for the admin panel).
++ */
++ getOverview(days = 30) {
++ const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
++
++ const services = this.db.prepare(`
++ SELECT
++ service,
++ SUM(pageviews) as pageviews,
++ SUM(api_calls) as api_calls,
++ SUM(unique_visitors) as unique_visitors,
++ SUM(unique_users) as unique_users,
++ SUM(bot_hits) as bot_hits,
++ SUM(error_count) as errors,
++ CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms
++ FROM analytics_daily
++ WHERE date >= ?
++ GROUP BY service
++ ORDER BY pageviews DESC
++ `).all(cutoff);
++
++ // Totals across all services
++ const totals = this.db.prepare(`
++ SELECT
++ SUM(pageviews) as pageviews,
++ SUM(api_calls) as api_calls,
++ SUM(unique_visitors) as unique_visitors,
++ SUM(bot_hits) as bot_hits,
++ SUM(error_count) as errors
++ FROM analytics_daily
++ WHERE date >= ?
++ `).get(cutoff);
++
++ // Daily trend (all services combined)
++ const dailyTrend = this.db.prepare(`
++ SELECT date,
++ SUM(pageviews) as pageviews,
++ SUM(api_calls) as api_calls,
++ SUM(unique_visitors) as unique_visitors,
++ SUM(bot_hits) as bot_hits
++ FROM analytics_daily
++ WHERE date >= ?
++ GROUP BY date
++ ORDER BY date ASC
++ `).all(cutoff);
++
++ // Real-time all services
++ const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');
++ const realtime = this.db.prepare(`
++ SELECT
++ service,
++ COUNT(*) as requests,
++ COUNT(DISTINCT ip) as visitors,
++ COUNT(*) FILTER (WHERE is_bot = 1) as bots
++ FROM analytics_events
++ WHERE created_at >= ?
++ GROUP BY service
++ `).all(fiveMinAgo);
++
++ return {
++ period_days: days,
++ totals: totals || {},
++ services,
++ dailyTrend,
++ realtime,
++ };
++ }
++
++ /**
++ * Get bot analysis data.
++ */
++ getBotAnalysis(days = 30) {
++ const daysCast = Math.min(Math.max(1, days), 365);
++
++ // Top bot IPs
++ const topBotIPs = this.db.prepare(`
++ SELECT ip, COUNT(*) as hits, bot_type, user_agent,
++ MIN(created_at) as first_seen, MAX(created_at) as last_seen
++ FROM analytics_events
++ WHERE is_bot = 1 AND created_at >= datetime('now', '-' || ? || ' days')
++ GROUP BY ip ORDER BY hits DESC LIMIT 25
++ `).all(daysCast);
++
++ // Bot vs human ratio over time
++ const botTrend = this.db.prepare(`
++ SELECT date,
++ SUM(pageviews) + SUM(api_calls) as human_hits,
++ SUM(bot_hits) as bot_hits
++ FROM analytics_daily
++ WHERE date >= date('now', '-' || ? || ' days')
++ GROUP BY date ORDER BY date ASC
++ `).all(daysCast);
++
++ // Bot type breakdown
++ const botTypes = this.db.prepare(`
++ SELECT bot_type, COUNT(*) as hits, COUNT(DISTINCT ip) as unique_ips
++ FROM analytics_events
++ WHERE is_bot = 1 AND created_at >= datetime('now', '-' || ? || ' days')
++ GROUP BY bot_type ORDER BY hits DESC
++ `).all(daysCast);
++
++ // Suspicious behavior patterns
++ const suspiciousIPs = this.db.prepare(`
++ SELECT ip, COUNT(*) as total_hits,
++ COUNT(*) FILTER (WHERE status_code >= 400) as error_hits,
++ COUNT(DISTINCT path) as unique_paths,
++ MIN(created_at) as first_seen,
++ MAX(created_at) as last_seen
++ FROM analytics_events
++ WHERE created_at >= datetime('now', '-7 days') AND is_bot = 0
++ GROUP BY ip
++ HAVING total_hits > 500
++ ORDER BY total_hits DESC LIMIT 20
++ `).all();
++
++ return {
++ topBotIPs,
++ botTrend,
++ botTypes,
++ suspiciousIPs,
++ };
++ }
++
++ /**
++ * Destroy tracker (clean up timers).
++ */
++ destroy() {
++ this.flush();
++ if (this._flushInterval) clearInterval(this._flushInterval);
++ if (this._aggregateInterval) clearInterval(this._aggregateInterval);
++ }
++}
++
++
++module.exports = {
++ AnalyticsTracker,
++ classifyRequest,
++ parseUserAgent,
++ ANALYTICS_SCHEMA,
++ BOT_USER_AGENTS,
++ SUSPICIOUS_PATTERNS,
++};
ddiff --git a/vendor/openvibe-shared/auth-client.js b/vendor/openvibe-shared/auth-client.js
nnew file mode 100644
iindex 0000000..7a53335
---- /dev/null
++++ b/vendor/openvibe-shared/auth-client.js
@@@ -0,0 +1,164 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — OAuth2 Auth Client
++// Used by OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and OpenVibe.Media
++// to authenticate users via the central OpenVibe.Network SSO.
++// ═══════════════════════════════════════════════════════════════
++
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const { BRAND } = require('./brand');
++
++const AUTH_BASE = BRAND.urls.login;
++
++class OpenVibeAuthClient {
++ /**
++ * @param {Object} opts
++ * @param {string} opts.clientId - OAuth2 client_id (e.g. 'live')
++ * @param {string} opts.clientSecret - OAuth2 client_secret
++ * @param {string} opts.redirectUri - Callback URL (e.g. 'https://openvibe.live/auth/callback')
++ * @param {string} opts.publicKey - RS256 public key for JWT verification (PEM)
++ * @param {string} [opts.authBase] - Override auth server URL (default: openvibe.network)
++ * @param {string} [opts.internalBase] - Internal auth API (default: http://127.0.0.1:4000)
++ */
++ constructor(opts) {
++ this.clientId = opts.clientId;
++ this.clientSecret = opts.clientSecret;
++ this.redirectUri = opts.redirectUri;
++ this.publicKey = opts.publicKey;
++ this.authBase = opts.authBase || AUTH_BASE;
++ this.internalBase = opts.internalBase || 'http://127.0.0.1:4000';
++ }
++
++ /**
++ * Generate an authorization URL for the OAuth2 login redirect.
++ * @param {string} [scope='profile theme'] - Requested scopes
++ * @returns {{ url: string, state: string }}
++ */
++ getAuthorizationUrl(scope = 'profile theme') {
++ const state = crypto.randomBytes(16).toString('hex');
++ const params = new URLSearchParams({
++ client_id: this.clientId,
++ redirect_uri: this.redirectUri,
++ response_type: 'code',
++ scope,
++ state,
++ });
++ return {
++ url: `${this.authBase}/oauth/authorize?${params.toString()}`,
++ state,
++ };
++ }
++
++ /**
++ * Exchange an authorization code for tokens.
++ * @param {string} code - The authorization code from the callback
++ * @returns {Promise<{ access_token: string, refresh_token: string, user: Object }>}
++ */
++ async exchangeCode(code) {
++ const res = await fetch(`${this.authBase}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ grant_type: 'authorization_code',
++ client_id: this.clientId,
++ client_secret: this.clientSecret,
++ redirect_uri: this.redirectUri,
++ code,
++ }),
++ });
++ if (!res.ok) {
++ const err = await res.json().catch(() => ({}));
++ throw new Error(err.error || `Token exchange failed: ${res.status}`);
++ }
++ return res.json();
++ }
++
++ /**
++ * Refresh an access token using a refresh token.
++ * @param {string} refreshToken
++ * @returns {Promise<{ access_token: string, refresh_token: string }>}
++ */
++ async refreshAccessToken(refreshToken) {
++ const res = await fetch(`${this.authBase}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ grant_type: 'refresh_token',
++ client_id: this.clientId,
++ client_secret: this.clientSecret,
++ refresh_token: refreshToken,
++ }),
++ });
++ if (!res.ok) {
++ const err = await res.json().catch(() => ({}));
++ throw new Error(err.error || `Token refresh failed: ${res.status}`);
++ }
++ return res.json();
++ }
++
++ /**
++ * Verify and decode a JWT access token.
++ * Uses the RS256 public key from the auth server.
++ * @param {string} token
++ * @returns {Object|null} Decoded payload or null if invalid
++ */
++ verifyToken(token) {
++ try {
++ // issuer is the tools/SSO public URL — derive from authBase to support white-label
++ const issuer = this.authBase || this.issuer;
++ return jwt.verify(token, this.publicKey, {
++ algorithms: ['RS256'],
++ issuer,
++ });
++ } catch {
++ return null;
++ }
++ }
++
++ /**
++ * Verify a token via the internal API (server-to-server).
++ * Useful as a fallback or for getting fresh user data.
++ * @param {string} token
++ * @param {string} internalKey - X-Internal-Key header value
++ * @returns {Promise<Object|null>}
++ */
++ async verifyTokenInternal(token, internalKey) {
++ try {
++ const res = await fetch(`${this.internalBase}/internal/verify-token`, {
++ method: 'POST',
++ headers: {
++ 'Content-Type': 'application/json',
++ 'X-Internal-Key': internalKey,
++ },
++ body: JSON.stringify({ token }),
++ });
++ if (!res.ok) return null;
++ return res.json();
++ } catch {
++ return null;
++ }
++ }
++
++ /**
++ * Get fresh user profile data via internal API.
++ * @param {number} userId
++ * @param {string} internalKey
++ * @returns {Promise<Object|null>}
++ */
++ async getUserProfile(userId, internalKey) {
++ try {
++ const res = await fetch(`${this.internalBase}/internal/user/${userId}/sync`, {
++ method: 'POST',
++ headers: { 'X-Internal-Key': internalKey },
++ });
++ if (!res.ok) return null;
++ return res.json();
++ } catch {
++ return null;
++ }
++ }
++}
++
++module.exports = { OpenVibeAuthClient };
ddiff --git a/vendor/openvibe-shared/brand.js b/vendor/openvibe-shared/brand.js
nnew file mode 100644
iindex 0000000..61ab011
---- /dev/null
++++ b/vendor/openvibe-shared/brand.js
@@@ -0,0 +1,181 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Brand Constants
++// Shared across all OpenVibe services for consistent branding.
++// Values are seeded with OpenVibe defaults and can be
++// overridden at runtime via the URL registry / admin panel.
++// ═══════════════════════════════════════════════════════════════
++
++// ── Default (OpenVibe) values ────────────────────────────
++// These are the canonical defaults for the OpenVibe deployment.
++// White-label installs override via the URL registry.
++
++const BRAND_DEFAULTS = Object.freeze({
++ networkName: 'OpenVibe',
++ networkServiceName: 'OpenVibe.Network',
++ toolsServiceName: 'OpenVibe.Tools',
++ streamerServiceName:'OpenVibe.Live',
++ gamesServiceName: 'OpenVibe.Games',
++ mediaServiceName: 'OpenVibe.Media',
++ tagline: 'One Account. All of OpenVibe.',
++ campTagline: 'Free & Open Live Streaming',
++ toolsSubdomainBase: 'openvibe.tools',
++ discord: 'https://discord.gg/M6MuRUaeJj',
++ github: 'https://github.com/OpenVibe.Live',
++});
++
++function getDefaultBrandUrls(env = process.env) {
++ return {
++ network: env.OV_NETWORK_URL || 'https://openvibe.network',
++ login: env.LOGIN_URL || env.OV_NETWORK_LOGIN_URL || env.OV_NETWORK_URL || 'https://openvibe.network',
++ tools: env.OV_TOOLS_URL || 'https://openvibe.tools',
++ live: env.OV_LIVE_URL || 'https://openvibe.live',
++ games: env.OV_GAMES_URL || 'https://openvibe.games',
++ media: env.OV_MEDIA_URL || 'https://openvibe.media',
++ community:env.OV_COMMUNITY_URL || 'https://openvibe.community',
++ // Tool satellites (Host-routed under openvibe.tools)
++ maps: env.OV_MAPS_URL || 'https://maps.openvibe.tools',
++ food: env.OV_FOOD_URL || 'https://food.openvibe.tools',
++ img: env.OV_IMG_URL || 'https://img.openvibe.tools',
++ yt: env.OV_YT_URL || 'https://yt.openvibe.tools',
++ audio: env.OV_AUDIO_URL || 'https://audio.openvibe.tools',
++ text: env.OV_TEXT_URL || 'https://text.openvibe.tools',
++ logo: env.OV_LOGO_URL || 'https://logo.openvibe.tools',
++ docs: env.OV_DOCS_URL || 'https://docs.openvibe.tools',
++ net: env.OV_NET_URL || 'https://net.openvibe.tools',
++ dev: env.OV_DEV_URL || 'https://dev.openvibe.tools',
++ pastes: env.OV_PASTES_URL || 'https://pastes.openvibe.tools',
++ // Legacy aliases (older callers) — same targets as above
++ streamer: env.OV_LIVE_URL || 'https://openvibe.live',
++ quest: env.OV_GAMES_URL || 'https://openvibe.games',
++ discord: BRAND_DEFAULTS.discord,
++ github: BRAND_DEFAULTS.github,
++ };
++}
++
++const DEFAULT_URLS = Object.freeze(getDefaultBrandUrls());
++
++function resolveBrandUrls(overrides = {}, env = process.env) {
++ return Object.freeze({
++ ...getDefaultBrandUrls(env),
++ ...overrides,
++ });
++}
++
++/**
++ * Build a brand object from a resolved URL registry map.
++ * Call this server-side when the canonical registry has been loaded.
++ *
++ * @param {Object} registry - resolved registry map from resolveRegistryValues()
++ * @param {Object} [env] - process.env fallback
++ * @returns {Object} brand object with names, urls, colors, services, oauth
++ */
++function buildBrandFromRegistry(registry = {}, env = process.env) {
++ const get = (key) => (registry[key]?.value) || null;
++
++ const networkName = get('NETWORK_NAME') || BRAND_DEFAULTS.networkName;
++ const toolsServiceName = get('TOOLS_SERVICE_NAME') || BRAND_DEFAULTS.toolsServiceName;
++ const streamerServiceName= get('STREAMER_SERVICE_NAME')|| BRAND_DEFAULTS.streamerServiceName;
++ const gamesServiceName = get('GAMES_SERVICE_NAME') || BRAND_DEFAULTS.gamesServiceName;
++ const mediaServiceName = get('MEDIA_SERVICE_NAME') || BRAND_DEFAULTS.mediaServiceName;
++ const defaults = getDefaultBrandUrls(env);
++ const networkUrl = get('OV_NETWORK_URL') || defaults.network;
++ const liveUrl = get('OV_LIVE_URL') || defaults.live;
++ const toolsUrl = get('OV_TOOLS_URL') || defaults.tools;
++ const gamesUrl = get('OV_GAMES_URL') || defaults.games;
++ const mediaUrl = get('OV_MEDIA_URL') || defaults.media;
++
++ const urls = resolveBrandUrls({
++ network: networkUrl,
++ login: get('OV_NETWORK_LOGIN_URL') || networkUrl,
++ live: liveUrl,
++ streamer: liveUrl,
++ tools: toolsUrl,
++ games: gamesUrl,
++ quest: gamesUrl,
++ media: mediaUrl,
++ }, env);
++
++ // Build service list using registry URLs where available
++ const services = [
++ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },
++ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },
++ { id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },
++ { id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },
++ { id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },
++ ];
++
++ return Object.freeze({
++ name: networkName,
++ toolsName: toolsServiceName,
++ streamerName:streamerServiceName,
++ gamesName: gamesServiceName,
++ mediaName: mediaServiceName,
++ tagline: BRAND_DEFAULTS.tagline,
++ campTagline: BRAND_DEFAULTS.campTagline,
++ toolsSubdomainBase: get('TOOLS_SUBDOMAIN_BASE') || BRAND_DEFAULTS.toolsSubdomainBase,
++ urls: Object.freeze(urls),
++ colors: BRAND.colors,
++ oauth: BRAND.oauth,
++ services: Object.freeze(services),
++ });
++}
++
++const BRAND = Object.freeze({
++ name: BRAND_DEFAULTS.networkName,
++ tagline: BRAND_DEFAULTS.tagline,
++ campTagline: BRAND_DEFAULTS.campTagline,
++
++ urls: resolveBrandUrls(),
++
++ // Palette per CONTRACTS.md
++ colors: Object.freeze({
++ accent: '#8b5cf6',
++ accentLight: '#a78bfa',
++ accentDark: '#6d28d9',
++ secondary: '#22d3ee',
++ bgDark: '#131318',
++ flame: '#a78bfa', // legacy alias for accentLight
++ signalRed: '#e74c3c',
++ success: '#2ecc71',
++ warning: '#f39c12',
++ danger: '#e74c3c',
++ info: '#3498db',
++ }),
++
++ // OAuth2 client IDs for each service (seeded in the Network DB)
++ oauth: Object.freeze({
++ live: 'live',
++ tools: 'tools',
++ games: 'games',
++ media: 'media',
++ }),
++
++ // Primary services in the network (for dashboard display, health checks, etc.)
++ // Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.
++ services: Object.freeze([
++ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },
++ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },
++ { id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },
++ { id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },
++ { id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },
++ ]),
++
++ // Tool sub-brands hosted by OpenVibe.Tools (<Name>.OpenVibe naming)
++ toolBrands: Object.freeze([
++ { id: 'net', name: 'Net.OpenVibe', url: 'https://net.openvibe.tools', description: 'Network & Internet Diagnostics' },
++ { id: 'dev', name: 'Dev.OpenVibe', url: 'https://dev.openvibe.tools', description: 'Developer & SEO Tools' },
++ { id: 'paste', name: 'Paste.OpenVibe', url: 'https://pastes.openvibe.tools',description: 'Code & Text Sharing' },
++ { id: 'maps', name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools', description: 'Camp & Shelter Locator' },
++ { id: 'food', name: 'Food.OpenVibe', url: 'https://food.openvibe.tools', description: 'Food Resource Finder' },
++ { id: 'img', name: 'Img.OpenVibe', url: 'https://img.openvibe.tools', description: 'Image Converter & Tools' },
++ { id: 'yt', name: 'YT.OpenVibe', url: 'https://yt.openvibe.tools', description: 'YouTube Downloader' },
++ { id: 'audio', name: 'Audio.OpenVibe', url: 'https://audio.openvibe.tools', description: 'Audio Converter & Effects' },
++ { id: 'text', name: 'Text.OpenVibe', url: 'https://text.openvibe.tools', description: 'Text Generation & Unicode' },
++ { id: 'logo', name: 'Logo.OpenVibe', url: 'https://logo.openvibe.tools', description: 'Logo & Title Card Maker' },
++ { id: 'docs', name: 'Docs.OpenVibe', url: 'https://docs.openvibe.tools', description: 'PDF & Document Tools' },
++ ]),
++});
++
++module.exports = { BRAND, BRAND_DEFAULTS, resolveBrandUrls, buildBrandFromRegistry };
ddiff --git a/vendor/openvibe-shared/builtin-themes.js b/vendor/openvibe-shared/builtin-themes.js
nnew file mode 100644
iindex 0000000..bb93cbc
---- /dev/null
++++ b/vendor/openvibe-shared/builtin-themes.js
@@@ -0,0 +1,949 @@
++'use strict';
++
++const RAW_BUILTIN_THEMES = [
++ {
++ name: 'Vibe',
++ slug: 'vibe',
++ mode: 'dark',
++ description: 'OpenVibe\'s default: deep blue night with an electric-blue glow.',
++ tags: ['default', 'cool', 'blue'],
++ variables: {
++ '--bg-primary': '#0a0f1c',
++ '--bg-secondary': '#101828',
++ '--bg-tertiary': '#172238',
++ '--bg-card': '#131c2e',
++ '--bg-hover': '#1c2a44',
++ '--bg-input': '#0d1424',
++ '--text-primary': '#e6edf7',
++ '--text-secondary': '#96a7c2',
++ '--text-muted': '#7386a3',
++ '--accent': '#3b82f6',
++ '--accent-light': '#60a5fa',
++ '--accent-dark': '#1d4ed8',
++ '--border': '#1f2d47',
++ '--border-light': '#2c3d5c',
++ '--live-red': '#ef4444',
++ '--success': '#22c55e',
++ '--warning': '#f59e0b',
++ '--danger': '#ef4444',
++ '--info': '#38bdf8',
++ '--shadow': '0 2px 12px rgba(0,0,0,0.45)',
++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.6)',
++ },
++ },
++ {
++ name: 'Violet Hour',
++ slug: 'violet-hour',
++ mode: 'dark',
++ description: 'Charcoal and violet: the colours OpenVibe started with.',
++ tags: ['classic', 'purple', 'warm'],
++ variables: {
++ '--bg-primary': '#0d0d0f',
++ '--bg-secondary': '#16161a',
++ '--bg-tertiary': '#1e1e24',
++ '--bg-card': '#1a1a20',
++ '--bg-hover': '#242430',
++ '--bg-input': '#12121a',
++ '--text-primary': '#e8e6e3',
++ '--text-secondary': '#9a9a9a',
++ '--text-muted': '#838282',
++ '--accent': '#8b5cf6',
++ '--accent-light': '#a78bfa',
++ '--accent-dark': '#6d28d9',
++ '--border': '#2a2a32',
++ '--border-light': '#3a3a44',
++ '--live-red': '#e74c3c',
++ '--success': '#2ecc71',
++ '--warning': '#f39c12',
++ '--danger': '#e74c3c',
++ '--info': '#3498db',
++ '--shadow': '0 2px 12px rgba(0,0,0,0.4)',
++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.6)',
++ },
++ },
++ {
++ name: 'Midnight',
++ slug: 'midnight',
++ mode: 'dark',
++ description: 'Blue-black with steel-indigo light.',
++ tags: ['cool', 'minimal', 'blue'],
++ variables: {
++ '--bg-primary': '#0a0e17',
++ '--bg-secondary': '#111827',
++ '--bg-tertiary': '#1a2235',
++ '--bg-card': '#151d2e',
++ '--bg-hover': '#1e2a42',
++ '--bg-input': '#0d1220',
++ '--text-primary': '#e2e8f0',
++ '--text-secondary': '#94a3b8',
++ '--text-muted': '#78879b',
++ '--accent': '#6366f1',
++ '--accent-light': '#818cf8',
++ '--accent-dark': '#4f46e5',
++ '--border': '#1e293b',
++ '--border-light': '#334155',
++ },
++ },
++ {
++ name: 'Understory',
++ slug: 'forest',
++ mode: 'dark',
++ description: 'Deep greens under the canopy.',
++ tags: ['nature', 'green', 'earthy'],
++ variables: {
++ '--bg-primary': '#0a120a',
++ '--bg-secondary': '#121e12',
++ '--bg-tertiary': '#1a2a1a',
++ '--bg-card': '#152015',
++ '--bg-hover': '#1e3420',
++ '--bg-input': '#0d160d',
++ '--text-primary': '#d4e8d4',
++ '--text-secondary': '#8aaa8a',
++ '--text-muted': '#708e70',
++ '--accent': '#4ade80',
++ '--accent-light': '#86efac',
++ '--accent-dark': '#22c55e',
++ '--border': '#1a3a1a',
++ '--border-light': '#2a4a2a',
++ },
++ },
++ {
++ name: 'Neon Tokyo',
++ slug: 'neon-tokyo',
++ mode: 'dark',
++ description: 'Hot pink neon on wet chrome.',
++ tags: ['cyberpunk', 'neon', 'vibrant'],
++ variables: {
++ '--bg-primary': '#0a0a0f',
++ '--bg-secondary': '#12101a',
++ '--bg-tertiary': '#1a1625',
++ '--bg-card': '#15111f',
++ '--bg-hover': '#221a32',
++ '--bg-input': '#0d0b14',
++ '--text-primary': '#f0e6ff',
++ '--text-secondary': '#b4a0d0',
++ '--text-muted': '#87799c',
++ '--accent': '#ff006e',
++ '--accent-light': '#ff4d94',
++ '--accent-dark': '#c9005a',
++ '--border': '#2a1a3a',
++ '--border-light': '#3a2a4a',
++ },
++ },
++ {
++ name: 'Dracula',
++ slug: 'dracula',
++ mode: 'dark',
++ description: 'The Dracula scheme, as its authors drew it.',
++ tags: ['popular', 'purple', 'classic'],
++ variables: {
++ '--bg-primary': '#282a36',
++ '--bg-secondary': '#1e1f29',
++ '--bg-tertiary': '#343746',
++ '--bg-card': '#2c2e3c',
++ '--bg-hover': '#3a3d50',
++ '--bg-input': '#21222c',
++ '--text-primary': '#f8f8f2',
++ '--text-secondary': '#bfbfb2',
++ '--text-muted': '#8995b8',
++ '--accent': '#bd93f9',
++ '--accent-light': '#d4b8ff',
++ '--accent-dark': '#9b6ddb',
++ '--border': '#44475a',
++ '--border-light': '#565970',
++ '--success': '#50fa7b',
++ '--danger': '#ff5555',
++ '--warning': '#f1fa8c',
++ '--info': '#8be9fd',
++ '--live-red': '#ff5555',
++ },
++ },
++ {
++ name: 'Monokai',
++ slug: 'monokai',
++ mode: 'dark',
++ description: 'The Monokai editor palette.',
++ tags: ['developer', 'classic', 'warm'],
++ variables: {
++ '--bg-primary': '#272822',
++ '--bg-secondary': '#1e1f1c',
++ '--bg-tertiary': '#3e3d32',
++ '--bg-card': '#2d2e27',
++ '--bg-hover': '#49483e',
++ '--bg-input': '#22231e',
++ '--text-primary': '#f8f8f2',
++ '--text-secondary': '#cfcfc2',
++ '--text-muted': '#979484',
++ '--accent': '#a6e22e',
++ '--accent-light': '#c4ff50',
++ '--accent-dark': '#7dac1c',
++ '--border': '#49483e',
++ '--border-light': '#5b5a50',
++ '--danger': '#f92672',
++ '--warning': '#e6db74',
++ '--info': '#66d9ef',
++ },
++ },
++ {
++ name: 'Trench',
++ slug: 'ocean-deep',
++ mode: 'dark',
++ description: 'Pressure-dark water with a teal glow.',
++ tags: ['ocean', 'teal', 'calm'],
++ variables: {
++ '--bg-primary': '#0a1215',
++ '--bg-secondary': '#0f1a1f',
++ '--bg-tertiary': '#162228',
++ '--bg-card': '#121c22',
++ '--bg-hover': '#1a2a32',
++ '--bg-input': '#0c1418',
++ '--text-primary': '#d0e8ef',
++ '--text-secondary': '#7aa8ba',
++ '--text-muted': '#658896',
++ '--accent': '#00bcd4',
++ '--accent-light': '#4dd0e1',
++ '--accent-dark': '#0097a7',
++ '--border': '#1a3040',
++ '--border-light': '#2a4050',
++ },
++ },
++ {
++ name: 'Dusk',
++ slug: 'sunset',
++ mode: 'dark',
++ description: 'Orange fading to rose over dark ground.',
++ tags: ['warm', 'gradient', 'vibrant'],
++ variables: {
++ '--bg-primary': '#120a0a',
++ '--bg-secondary': '#1a1010',
++ '--bg-tertiary': '#241616',
++ '--bg-card': '#1e1212',
++ '--bg-hover': '#2e1a1a',
++ '--bg-input': '#140c0c',
++ '--text-primary': '#ffe8e0',
++ '--text-secondary': '#c49080',
++ '--text-muted': '#977970',
++ '--accent': '#ff6b35',
++ '--accent-light': '#ff8f5e',
++ '--accent-dark': '#d04a1a',
++ '--border': '#3a2020',
++ '--border-light': '#4a3030',
++ },
++ },
++ {
++ name: 'Arctic',
++ slug: 'arctic',
++ mode: 'dark',
++ description: 'Frost surfaces, cyan light, crisp white text.',
++ tags: ['cool', 'ice', 'clean'],
++ variables: {
++ '--bg-primary': '#0b1620',
++ '--bg-secondary': '#0f1e2b',
++ '--bg-tertiary': '#163040',
++ '--bg-card': '#122534',
++ '--bg-hover': '#1a3a4c',
++ '--bg-input': '#0c1a26',
++ '--text-primary': '#eaf6ff',
++ '--text-secondary': '#9ec3d8',
++ '--text-muted': '#7ea3ba',
++ '--accent': '#22d3ee',
++ '--accent-light': '#a5f3fc',
++ '--accent-dark': '#0891b2',
++ '--border': '#1e3f52',
++ '--border-light': '#2a5268',
++ '--info': '#7dd3fc',
++ '--success': '#34d399',
++ '--shadow': '0 2px 12px rgba(2,12,20,0.5)',
++ '--shadow-lg': '0 8px 32px rgba(2,12,20,0.65)',
++ },
++ },
++ {
++ name: 'Ember',
++ slug: 'ember',
++ mode: 'dark',
++ description: 'Smouldering red on warm charcoal.',
++ tags: ['red', 'warm', 'intense'],
++ variables: {
++ '--bg-primary': '#120808',
++ '--bg-secondary': '#1a0e0e',
++ '--bg-tertiary': '#241414',
++ '--bg-card': '#1e1010',
++ '--bg-hover': '#2e1818',
++ '--bg-input': '#140a0a',
++ '--text-primary': '#f0dada',
++ '--text-secondary': '#b0808a',
++ '--text-muted': '#95787c',
++ '--accent': '#ef4444',
++ '--accent-light': '#f87171',
++ '--accent-dark': '#b91c1c',
++ '--border': '#3a1a20',
++ '--border-light': '#4a2a30',
++ },
++ },
++ {
++ name: 'Vapor',
++ slug: 'vapor',
++ mode: 'dark',
++ description: 'Magenta and teal, straight off a VHS tape.',
++ tags: ['retro', 'aesthetic', 'neon'],
++ variables: {
++ '--bg-primary': '#0e0a14',
++ '--bg-secondary': '#14101c',
++ '--bg-tertiary': '#1c1628',
++ '--bg-card': '#181220',
++ '--bg-hover': '#241a34',
++ '--bg-input': '#100c16',
++ '--text-primary': '#f0e0ff',
++ '--text-secondary': '#c090e0',
++ '--text-muted': '#8f73b7',
++ '--accent': '#e040fb',
++ '--accent-light': '#ea80fc',
++ '--accent-dark': '#aa00d4',
++ '--border': '#2a1a40',
++ '--border-light': '#3a2a50',
++ '--info': '#00e5ff',
++ '--success': '#69f0ae',
++ },
++ },
++ {
++ name: 'Slate',
++ slug: 'slate',
++ mode: 'dark',
++ description: 'Neutral grey with a cool violet accent.',
++ tags: ['neutral', 'minimal', 'professional'],
++ variables: {
++ '--bg-primary': '#0f1118',
++ '--bg-secondary': '#161820',
++ '--bg-tertiary': '#1e2028',
++ '--bg-card': '#1a1c24',
++ '--bg-hover': '#24262e',
++ '--bg-input': '#12141c',
++ '--text-primary': '#e4e6ea',
++ '--text-secondary': '#a0a4b0',
++ '--text-muted': '#828690',
++ '--accent': '#a78bfa',
++ '--accent-light': '#c4b5fd',
++ '--accent-dark': '#7c3aed',
++ '--border': '#2a2c38',
++ '--border-light': '#3a3c48',
++ },
++ },
++ {
++ name: 'Matrix',
++ slug: 'matrix',
++ mode: 'dark',
++ description: 'Green phosphor on black.',
++ tags: ['hacker', 'retro', 'terminal'],
++ variables: {
++ '--bg-primary': '#000000',
++ '--bg-secondary': '#050a05',
++ '--bg-tertiary': '#0a140a',
++ '--bg-card': '#071007',
++ '--bg-hover': '#0e1e0e',
++ '--bg-input': '#030803',
++ '--text-primary': '#00ff41',
++ '--text-secondary': '#00b030',
++ '--text-muted': '#008f25',
++ '--accent': '#00ff41',
++ '--accent-light': '#66ff8a',
++ '--accent-dark': '#00aa2a',
++ '--border': '#0a2a0a',
++ '--border-light': '#144014',
++ },
++ },
++ {
++ name: 'Nord',
++ slug: 'nord',
++ mode: 'dark',
++ description: 'The Nord palette: muted polar blues.',
++ tags: ['popular', 'blue', 'soft'],
++ variables: {
++ '--bg-primary': '#2e3440',
++ '--bg-secondary': '#272c36',
++ '--bg-tertiary': '#3b4252',
++ '--bg-card': '#333a48',
++ '--bg-hover': '#434c5e',
++ '--bg-input': '#2a303c',
++ '--text-primary': '#eceff4',
++ '--text-secondary': '#d8dee9',
++ '--text-muted': '#9ba5b8',
++ '--accent': '#88c0d0',
++ '--accent-light': '#8fbcbb',
++ '--accent-dark': '#5e81ac',
++ '--border': '#3b4252',
++ '--border-light': '#4c566a',
++ '--success': '#a3be8c',
++ '--danger': '#bf616a',
++ '--warning': '#ebcb8b',
++ '--info': '#81a1c1',
++ },
++ },
++ {
++ name: 'Gruvbox',
++ slug: 'gruvbox-dark',
++ mode: 'dark',
++ description: 'Warm retro contrast.',
++ tags: ['retro', 'warm', 'developer'],
++ variables: {
++ '--bg-primary': '#1d2021',
++ '--bg-secondary': '#282828',
++ '--bg-tertiary': '#3c3836',
++ '--bg-card': '#32302f',
++ '--bg-hover': '#504945',
++ '--bg-input': '#242424',
++ '--text-primary': '#ebdbb2',
++ '--text-secondary': '#d5c4a1',
++ '--text-muted': '#a69682',
++ '--accent': '#fabd2f',
++ '--accent-light': '#fce566',
++ '--accent-dark': '#d79921',
++ '--border': '#3c3836',
++ '--border-light': '#504945',
++ '--success': '#b8bb26',
++ '--danger': '#fb4934',
++ '--warning': '#fe8019',
++ '--info': '#83a598',
++ },
++ },
++ {
++ name: 'Abyss',
++ slug: 'abyss',
++ mode: 'dark',
++ description: 'Nearly black; the interface is the light.',
++ tags: ['minimal', 'ultra-dark', 'oled'],
++ variables: {
++ '--bg-primary': '#000000',
++ '--bg-secondary': '#080808',
++ '--bg-tertiary': '#111111',
++ '--bg-card': '#0a0a0a',
++ '--bg-hover': '#181818',
++ '--bg-input': '#050505',
++ '--text-primary': '#cccccc',
++ '--text-secondary': '#888888',
++ '--text-muted': '#7a7a7a',
++ '--accent': '#ffffff',
++ '--accent-light': '#ffffff',
++ '--accent-dark': '#aaaaaa',
++ '--border': '#1a1a1a',
++ '--border-light': '#252525',
++ },
++ },
++ {
++ name: 'Copper',
++ slug: 'copper',
++ mode: 'dark',
++ description: 'Bronze on dark walnut.',
++ tags: ['metallic', 'warm', 'premium'],
++ variables: {
++ '--bg-primary': '#110d09',
++ '--bg-secondary': '#1a140e',
++ '--bg-tertiary': '#231c14',
++ '--bg-card': '#1e1710',
++ '--bg-hover': '#2c2218',
++ '--bg-input': '#13100b',
++ '--text-primary': '#e8ddd0',
++ '--text-secondary': '#b49a80',
++ '--text-muted': '#8e7f6a',
++ '--accent': '#cd7f32',
++ '--accent-light': '#daa060',
++ '--accent-dark': '#a06020',
++ '--border': '#2a2016',
++ '--border-light': '#3a3020',
++ },
++ },
++ {
++ name: 'Sakura',
++ slug: 'sakura-night',
++ mode: 'dark',
++ description: 'Cherry blossom on indigo.',
++ tags: ['japanese', 'pink', 'soft'],
++ variables: {
++ '--bg-primary': '#0e0a14',
++ '--bg-secondary': '#150f1c',
++ '--bg-tertiary': '#1c1526',
++ '--bg-card': '#181020',
++ '--bg-hover': '#221830',
++ '--bg-input': '#100c16',
++ '--text-primary': '#f0e0f0',
++ '--text-secondary': '#c0a0c0',
++ '--text-muted': '#927592',
++ '--accent': '#f472b6',
++ '--accent-light': '#f9a8d4',
++ '--accent-dark': '#db2777',
++ '--border': '#2a1a30',
++ '--border-light': '#3a2a40',
++ },
++ },
++ {
++ name: 'Amber',
++ slug: 'hacker',
++ mode: 'dark',
++ description: 'Amber phosphor on black.',
++ tags: ['retro', 'terminal', 'nostalgia'],
++ variables: {
++ '--bg-primary': '#000000',
++ '--bg-secondary': '#0a0800',
++ '--bg-tertiary': '#141000',
++ '--bg-card': '#0e0c00',
++ '--bg-hover': '#1c1800',
++ '--bg-input': '#060400',
++ '--text-primary': '#ffb000',
++ '--text-secondary': '#cc8d00',
++ '--text-muted': '#a37100',
++ '--accent': '#ffb000',
++ '--accent-light': '#ffd050',
++ '--accent-dark': '#cc8d00',
++ '--border': '#1a1400',
++ '--border-light': '#2a2200',
++ },
++ },
++ {
++ name: 'Daylight',
++ slug: 'daylight',
++ mode: 'light',
++ description: 'Bright white with warm accents.',
++ tags: ['clean', 'bright', 'default-light'],
++ variables: {
++ '--bg-primary': '#ffffff',
++ '--bg-secondary': '#f7f7f8',
++ '--bg-tertiary': '#eeeef0',
++ '--bg-card': '#ffffff',
++ '--bg-hover': '#f0f0f2',
++ '--bg-input': '#f5f5f7',
++ '--text-primary': '#1a1a2e',
++ '--text-secondary': '#4a4a6a',
++ '--text-muted': '#747489',
++ '--accent': '#8756f3',
++ '--accent-light': '#a78bfa',
++ '--accent-dark': '#6d28d9',
++ '--border': '#e0e0e6',
++ '--border-light': '#d0d0d8',
++ '--shadow': '0 2px 12px rgba(0,0,0,0.08)',
++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.12)',
++ },
++ },
++ {
++ name: 'Paper',
++ slug: 'paper',
++ mode: 'light',
++ description: 'Aged paper and ink.',
++ tags: ['warm', 'soft', 'minimal'],
++ variables: {
++ '--bg-primary': '#faf8f5',
++ '--bg-secondary': '#f0ece6',
++ '--bg-tertiary': '#e8e2d8',
++ '--bg-card': '#faf8f5',
++ '--bg-hover': '#f0ece6',
++ '--bg-input': '#f5f2ed',
++ '--text-primary': '#2c2416',
++ '--text-secondary': '#5a4e3e',
++ '--text-muted': '#7b7061',
++ '--accent': '#8b6914',
++ '--accent-light': '#b08a30',
++ '--accent-dark': '#6a5010',
++ '--border': '#ddd6c8',
++ '--border-light': '#ccc4b0',
++ '--shadow': '0 2px 12px rgba(60,40,0,0.06)',
++ '--shadow-lg': '0 8px 32px rgba(60,40,0,0.10)',
++ },
++ },
++ {
++ name: 'Cloud',
++ slug: 'cloud',
++ mode: 'light',
++ description: 'Airy blue-white with sky accents.',
++ tags: ['blue', 'airy', 'fresh'],
++ variables: {
++ '--bg-primary': '#f0f5ff',
++ '--bg-secondary': '#e8eef8',
++ '--bg-tertiary': '#dde5f0',
++ '--bg-card': '#f5f8ff',
++ '--bg-hover': '#e4ecf8',
++ '--bg-input': '#edf2fa',
++ '--text-primary': '#1a2040',
++ '--text-secondary': '#4a5070',
++ '--text-muted': '#6b718c',
++ '--accent': '#2867ed',
++ '--accent-light': '#60a5fa',
++ '--accent-dark': '#2563eb',
++ '--border': '#d0d8ea',
++ '--border-light': '#bcc8e0',
++ '--shadow': '0 2px 12px rgba(0,20,60,0.06)',
++ '--shadow-lg': '0 8px 32px rgba(0,20,60,0.10)',
++ },
++ },
++ {
++ name: 'Meadow',
++ slug: 'meadow',
++ mode: 'light',
++ description: 'Spring greens in daylight.',
++ tags: ['nature', 'green', 'fresh'],
++ variables: {
++ '--bg-primary': '#f2f8f0',
++ '--bg-secondary': '#e8f0e4',
++ '--bg-tertiary': '#dce8d6',
++ '--bg-card': '#f5faf2',
++ '--bg-hover': '#e4f0de',
++ '--bg-input': '#edf5ea',
++ '--text-primary': '#1a2e16',
++ '--text-secondary': '#3a5a32',
++ '--text-muted': '#5d7a54',
++ '--accent': '#15823e',
++ '--accent-light': '#22c55e',
++ '--accent-dark': '#15803d',
++ '--border': '#c8dcc0',
++ '--border-light': '#b0ccaa',
++ '--shadow': '0 2px 12px rgba(0,40,0,0.06)',
++ '--shadow-lg': '0 8px 32px rgba(0,40,0,0.10)',
++ },
++ },
++ {
++ name: 'Peach',
++ slug: 'peach',
++ mode: 'light',
++ description: 'Peach and coral, soft and warm.',
++ tags: ['warm', 'pink', 'soft'],
++ variables: {
++ '--bg-primary': '#fff5f0',
++ '--bg-secondary': '#fbeae2',
++ '--bg-tertiary': '#f5ddd2',
++ '--bg-card': '#fff7f2',
++ '--bg-hover': '#fce8dc',
++ '--bg-input': '#fdf0ea',
++ '--text-primary': '#2e1a14',
++ '--text-secondary': '#6a4438',
++ '--text-muted': '#8f6a5c',
++ '--accent': '#c04808',
++ '--accent-light': '#fb923c',
++ '--accent-dark': '#ea580c',
++ '--border': '#f0d0c0',
++ '--border-light': '#e4bfab',
++ '--shadow': '0 2px 12px rgba(60,20,0,0.06)',
++ '--shadow-lg': '0 8px 32px rgba(60,20,0,0.10)',
++ },
++ },
++ {
++ name: 'Lavender',
++ slug: 'lavender',
++ mode: 'light',
++ description: 'Calm pale violet.',
++ tags: ['purple', 'calm', 'elegant'],
++ variables: {
++ '--bg-primary': '#f8f5ff',
++ '--bg-secondary': '#f0eaf8',
++ '--bg-tertiary': '#e6ddf0',
++ '--bg-card': '#faf7ff',
++ '--bg-hover': '#eee6f8',
++ '--bg-input': '#f4f0fa',
++ '--text-primary': '#1e1430',
++ '--text-secondary': '#4a3a60',
++ '--text-muted': '#796a92',
++ '--accent': '#844cf2',
++ '--accent-light': '#a78bfa',
++ '--accent-dark': '#7c3aed',
++ '--border': '#ddd0ee',
++ '--border-light': '#ccc0dd',
++ '--shadow': '0 2px 12px rgba(40,0,60,0.06)',
++ '--shadow-lg': '0 8px 32px rgba(40,0,60,0.10)',
++ },
++ },
++ {
++ name: 'Gruvbox Light',
++ slug: 'gruvbox-light',
++ mode: 'light',
++ description: 'Cream with bold retro accents.',
++ tags: ['retro', 'warm', 'developer'],
++ variables: {
++ '--bg-primary': '#fbf1c7',
++ '--bg-secondary': '#f2e5bc',
++ '--bg-tertiary': '#ebdbb2',
++ '--bg-card': '#fbf1c7',
++ '--bg-hover': '#f2e5bc',
++ '--bg-input': '#f5ecc4',
++ '--text-primary': '#3c3836',
++ '--text-secondary': '#504945',
++ '--text-muted': '#766b60',
++ '--accent': '#be4707',
++ '--accent-light': '#fe8019',
++ '--accent-dark': '#af3a03',
++ '--border': '#d5c4a1',
++ '--border-light': '#bdae93',
++ '--success': '#98971a',
++ '--danger': '#cc241d',
++ '--warning': '#d79921',
++ '--info': '#458588',
++ '--shadow': '0 2px 12px rgba(40,30,0,0.08)',
++ '--shadow-lg': '0 8px 32px rgba(40,30,0,0.12)',
++ },
++ },
++ {
++ name: 'Snow',
++ slug: 'snow',
++ mode: 'light',
++ description: 'Pure white with icy blue.',
++ tags: ['minimal', 'clean', 'blue'],
++ variables: {
++ '--bg-primary': '#ffffff',
++ '--bg-secondary': '#f8fafc',
++ '--bg-tertiary': '#f1f5f9',
++ '--bg-card': '#ffffff',
++ '--bg-hover': '#f1f5f9',
++ '--bg-input': '#f8fafc',
++ '--text-primary': '#0f172a',
++ '--text-secondary': '#334155',
++ '--text-muted': '#69768b',
++ '--accent': '#027cbb',
++ '--accent-light': '#38bdf8',
++ '--accent-dark': '#0284c7',
++ '--border': '#e2e8f0',
++ '--border-light': '#cbd5e1',
++ '--shadow': '0 2px 12px rgba(0,0,0,0.05)',
++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.08)',
++ },
++ },
++ {
++ name: 'Sand',
++ slug: 'sand',
++ mode: 'light',
++ description: 'Desert tan and warm stone.',
++ tags: ['warm', 'earthy', 'desert'],
++ variables: {
++ '--bg-primary': '#f8f4ef',
++ '--bg-secondary': '#f0e8de',
++ '--bg-tertiary': '#e8ddd0',
++ '--bg-card': '#faf6f0',
++ '--bg-hover': '#f0e8de',
++ '--bg-input': '#f5f0e8',
++ '--text-primary': '#2e2418',
++ '--text-secondary': '#5a4e3e',
++ '--text-muted': '#7a6e5f',
++ '--accent': '#8c6707',
++ '--accent-light': '#daa520',
++ '--accent-dark': '#8b6508',
++ '--border': '#ddd0be',
++ '--border-light': '#ccc0aa',
++ '--shadow': '0 2px 12px rgba(60,40,0,0.06)',
++ '--shadow-lg': '0 8px 32px rgba(60,40,0,0.10)',
++ },
++ },
++ {
++ name: 'Rose',
++ slug: 'rose',
++ mode: 'light',
++ description: 'Blush pink, light and delicate.',
++ tags: ['pink', 'romantic', 'soft'],
++ variables: {
++ '--bg-primary': '#fff5f7',
++ '--bg-secondary': '#fce7ec',
++ '--bg-tertiary': '#f5d5de',
++ '--bg-card': '#fff7f9',
++ '--bg-hover': '#fce0e8',
++ '--bg-input': '#fdeef2',
++ '--text-primary': '#2e1420',
++ '--text-secondary': '#6a3850',
++ '--text-muted': '#96617e',
++ '--accent': '#de1c47',
++ '--accent-light': '#f43f5e',
++ '--accent-dark': '#be123c',
++ '--border': '#f0c8d4',
++ '--border-light': '#e4b0c0',
++ '--shadow': '0 2px 12px rgba(60,0,20,0.06)',
++ '--shadow-lg': '0 8px 32px rgba(60,0,20,0.10)',
++ },
++ },
++ {
++ name: 'Solarized Dark',
++ slug: 'solarized-dark',
++ mode: 'dark',
++ description: 'Ethan Schoonover\'s precision palette, dark.',
++ tags: ['popular', 'developer', 'balanced'],
++ variables: {
++ '--bg-primary': '#002b36',
++ '--bg-secondary': '#073642',
++ '--bg-tertiary': '#0a3f4e',
++ '--bg-card': '#053542',
++ '--bg-hover': '#0d4a58',
++ '--bg-input': '#01303d',
++ '--text-primary': '#fdf6e3',
++ '--text-secondary': '#93a1a1',
++ '--text-muted': '#909c9a',
++ '--accent': '#b58900',
++ '--accent-light': '#d4a017',
++ '--accent-dark': '#8a6a00',
++ '--border': '#0a4050',
++ '--border-light': '#105060',
++ '--success': '#859900',
++ '--danger': '#dc322f',
++ '--warning': '#cb4b16',
++ '--info': '#268bd2',
++ },
++ },
++ {
++ name: 'Solarized Light',
++ slug: 'solarized-light',
++ mode: 'light',
++ description: 'Ethan Schoonover\'s precision palette, light.',
++ tags: ['popular', 'developer', 'balanced'],
++ variables: {
++ '--bg-primary': '#fdf6e3',
++ '--bg-secondary': '#eee8d5',
++ '--bg-tertiary': '#e4ddc6',
++ '--bg-card': '#fdf6e3',
++ '--bg-hover': '#eee8d5',
++ '--bg-input': '#f5eedb',
++ '--text-primary': '#002b36',
++ '--text-secondary': '#586e75',
++ '--text-muted': '#5b7478',
++ '--accent': '#8d6c00',
++ '--accent-light': '#d4a017',
++ '--accent-dark': '#8a6a00',
++ '--border': '#d6ceb5',
++ '--border-light': '#c8c0a5',
++ '--success': '#859900',
++ '--danger': '#dc322f',
++ '--warning': '#cb4b16',
++ '--info': '#268bd2',
++ '--shadow': '0 2px 12px rgba(0,30,40,0.08)',
++ '--shadow-lg': '0 8px 32px rgba(0,30,40,0.12)',
++ },
++ },
++ {
++ name: 'Catppuccin Mocha',
++ slug: 'catppuccin-mocha',
++ mode: 'dark',
++ description: 'Soothing pastels on mocha.',
++ tags: ['popular', 'pastel', 'soft'],
++ variables: {
++ '--bg-primary': '#1e1e2e',
++ '--bg-secondary': '#181825',
++ '--bg-tertiary': '#313244',
++ '--bg-card': '#1e1e2e',
++ '--bg-hover': '#313244',
++ '--bg-input': '#1a1a2c',
++ '--text-primary': '#cdd6f4',
++ '--text-secondary': '#bac2de',
++ '--text-muted': '#81869e',
++ '--accent': '#cba6f7',
++ '--accent-light': '#dcc0ff',
++ '--accent-dark': '#b07de0',
++ '--border': '#313244',
++ '--border-light': '#45475a',
++ '--success': '#a6e3a1',
++ '--danger': '#f38ba8',
++ '--warning': '#fab387',
++ '--info': '#89b4fa',
++ '--live-red': '#f38ba8',
++ },
++ },
++ {
++ name: 'Catppuccin Latte',
++ slug: 'catppuccin-latte',
++ mode: 'light',
++ description: 'Soothing pastels on latte.',
++ tags: ['popular', 'pastel', 'soft'],
++ variables: {
++ '--bg-primary': '#eff1f5',
++ '--bg-secondary': '#e6e9ef',
++ '--bg-tertiary': '#dce0e8',
++ '--bg-card': '#eff1f5',
++ '--bg-hover': '#dce0e8',
++ '--bg-input': '#e8ebf0',
++ '--text-primary': '#4c4f69',
++ '--text-secondary': '#5c5f77',
++ '--text-muted': '#696c83',
++ '--accent': '#8839ef',
++ '--accent-light': '#a05cff',
++ '--accent-dark': '#7028d4',
++ '--border': '#ccd0da',
++ '--border-light': '#bcc0cc',
++ '--success': '#40a02b',
++ '--danger': '#d20f39',
++ '--warning': '#fe640b',
++ '--info': '#1e66f5',
++ '--live-red': '#d20f39',
++ '--shadow': '0 2px 12px rgba(0,0,0,0.06)',
++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.10)',
++ },
++ },
++ {
++ name: 'High Contrast',
++ slug: 'high-contrast',
++ mode: 'dark',
++ description: 'Bold yellow on black for maximum legibility.',
++ tags: ['accessibility', 'high-contrast', 'bold'],
++ variables: {
++ '--bg-primary': '#000000',
++ '--bg-secondary': '#0a0a0a',
++ '--bg-tertiary': '#1a1a1a',
++ '--bg-card': '#0f0f0f',
++ '--bg-hover': '#222222',
++ '--bg-input': '#0a0a0a',
++ '--text-primary': '#ffffff',
++ '--text-secondary': '#f0f0f0',
++ '--text-muted': '#aaaaaa',
++ '--accent': '#ffd700',
++ '--accent-light': '#ffed4a',
++ '--accent-dark': '#ccac00',
++ '--border': '#444444',
++ '--border-light': '#666666',
++ '--live-red': '#ff0000',
++ '--success': '#00ff00',
++ '--warning': '#ffff00',
++ '--danger': '#ff0000',
++ '--info': '#00ffff',
++ },
++ },
++];
++
++// ── Derived tokens ──────────────────────────────────────────────────────────────
++// Every theme also carries tokens a site cannot guess from the palette alone:
++// --on-accent text/icon colour that reads on top of --accent (dark ink for light accents
++// such as cyan or lime, white for deep ones) — chosen by WCAG contrast
++// --accent-rgb "r,g,b" of the accent, for rgba(var(--accent-rgb), .3) in older stylesheets
++// --accent-glow the accent at 35% for glows and focus rings
++// --color-scheme dark|light, so native controls and scrollbars follow the theme
++// A theme may set any of them explicitly; otherwise they are computed here so all 35 themes,
++// and community themes that only define the base palette, stay consistent on every site.
++function hexToRgb(hex) {
++ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex || '').trim());
++ if (!m) return null;
++ let h = m[1]; if (h.length === 3) h = h.split('').map((c) => c + c).join('');
++ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
++}
++function luminance(rgb) {
++ const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
++ return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]);
++}
++function contrast(a, b) { const [x, y] = [luminance(a), luminance(b)]; return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }
++function deriveTokens(vars, mode) {
++ const out = { ...vars };
++ const accent = hexToRgb(vars['--accent']);
++ if (accent) {
++ // White wherever it reads (>= 3:1, the large-text floor a button label meets); dark ink only
++ // on accents too light for it (cyan, lime, amber).
++ if (!out['--on-accent']) out['--on-accent'] = contrast([255, 255, 255], accent) >= 3 ? '#ffffff' : '#0b0d10';
++ if (!out['--accent-rgb']) out['--accent-rgb'] = accent.join(',');
++ if (!out['--accent-glow']) out['--accent-glow'] = `rgba(${accent.join(',')},0.35)`;
++ }
++ if (!out['--color-scheme']) out['--color-scheme'] = mode === 'light' ? 'light' : 'dark';
++ return out;
++}
++
++const BUILTIN_THEMES = RAW_BUILTIN_THEMES.map((theme) => ({
++ id: theme.slug,
++ ...theme,
++ tags: Array.isArray(theme.tags) ? [...theme.tags] : [],
++ variables: deriveTokens(theme.variables || {}, theme.mode),
++}));
++
++const DEFAULT_THEME = BUILTIN_THEMES.find((theme) => theme.id === 'vibe') || BUILTIN_THEMES[0];
++const DEFAULT_VARS = Object.freeze({ ...(DEFAULT_THEME?.variables || {}) });
++
++module.exports = {
++ BUILTIN_THEMES,
++ DEFAULT_THEME,
++ DEFAULT_VARS,
++ deriveTokens,
++ hexToRgb,
++ contrast,
++};
ddiff --git a/vendor/openvibe-shared/footer.js b/vendor/openvibe-shared/footer.js
nnew file mode 100644
iindex 0000000..bccc6a1
---- /dev/null
++++ b/vendor/openvibe-shared/footer.js
@@@ -0,0 +1,299 @@
++/**
++ * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js.
++ *
++ * OpenVibeFooter.init({ service, variant, links, apiBase, mount })
++ * OpenVibeFooter.setVariant('full' | 'compact') switch at runtime (SPA route changes)
++ * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR
++ *
++ * Why this exists: every property was hand-rolling its own footer, so the network links, the
++ * legal links and the styling drifted apart, and a site that gained a new sibling had to be
++ * edited by hand. Here the site passes only the links that are *its own*; the Network column,
++ * the Legal column, the signed-in row and all the styling come from this module, so adding a
++ * property or changing a policy link updates every site at once.
++ *
++ * Variants:
++ * full the multi-column footer, for home and browse pages
++ * compact one quiet line, for pages where the footer is not the point — a streamer's
++ * channel, a player, a tool someone is mid-task in
++ *
++ * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
++ * hub, so the network behaves like one product instead of a ring of separate sites. It reads
++ * the shared ov_token the same way navbar.js does and never blocks rendering on it.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR)
++ if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser
++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++ 'use strict';
++
++ const NETWORK_URL = 'https://openvibe.network';
++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++ // One place that knows what the network contains. Adding a property here gives every site
++ // the link, with no edit anywhere else.
++ const NETWORK = [
++ { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
++ { id: 'tools', name: 'Free online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
++ { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
++ { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
++ { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
++ { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },
++ { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
++ { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
++ { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
++ { id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
++ ];
++
++ const LEGAL = [
++ { name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
++ { name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
++ { name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
++ ];
++
++ const SOCIAL = [
++ { name: 'GitHub', icon: 'fa-brands fa-github', url: 'https://github.com/OpenVibers' },
++ { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj' },
++ ];
++
++ const DEFAULTS = {
++ service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
++ brandName: null,
++ tagline: 'Free, open source and community run. Built in the open by the people using it.',
++ variant: 'full',
++ mount: '#ov-footer',
++ links: [], // [{ heading, items: [{ name, url, onclick? }] }]
++ legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site
++ apiBase: NETWORK_URL,
++ user: null,
++ showNetwork: true,
++ showAccount: true,
++ sitemap: '/sitemap.xml',
++ };
++
++ let _cfg = { ...DEFAULTS };
++ let _mounted = null;
++
++ function detectService() {
++ if (typeof location === 'undefined') return 'network';
++ const h = location.hostname;
++ if (h.endsWith('openvibe.live')) return 'live';
++ if (h.endsWith('openvibe.network')) return 'network';
++ if (h.endsWith('openvibe.games')) return 'games';
++ const sub = h.replace(/\.openvibe\.tools$/, '');
++ return sub === h ? 'tools' : (sub || 'tools');
++ }
++
++ function brandFor(service) {
++ const found = NETWORK.find(n => n.id === service);
++ if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;
++ return 'OpenVibe';
++ }
++
++ function legalHref(item, cfg) {
++ // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
++ if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
++ if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
++ return item.fallback;
++ }
++
++ // ── Markup ─────────────────────────────────────────────────
++ function linkTag(item) {
++ const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';
++ const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
++ const title = item.desc ? ` title="${esc(item.desc)}"` : '';
++ return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
++ }
++
++ function buildHTML(cfg) {
++ const c = { ...DEFAULTS, ...(cfg || {}) };
++ const service = c.service || detectService();
++ const brand = c.brandName || brandFor(service);
++ const network = NETWORK.filter(n => n.id !== service);
++ const year = new Date().getFullYear();
++
++ if (c.variant === 'compact') {
++ return `<div class="ovf-inner ovf-inner--compact">
++ <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="20"></span><span>${esc(brand)}</span></a>
++ <nav class="ovf-compact-links" aria-label="Site links">
++ ${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}
++ <a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
++ </nav>
++ <span class="ovf-compact-meta">Open source · ${year}</span>
++ </div>`;
++ }
++
++ const cols = (c.links || []).map(group => `
++ <nav class="ovf-col" aria-label="${esc(group.heading)}">
++ <h3>${esc(group.heading)}</h3>
++ ${group.items.map(linkTag).join('')}
++ </nav>`).join('');
++
++ return `<div class="ovf-inner">
++ <div class="ovf-cols">
++ <div class="ovf-col ovf-col--brand">
++ <a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
++ <p class="ovf-tagline">${esc(c.tagline)}</p>
++ <div class="ovf-social">
++ ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}
++ <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
++ </div>
++ </div>
++ ${cols}
++ ${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">
++ <h3>Network</h3>
++ ${network.slice(0, 6).map(linkTag).join('')}
++ <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>
++ </nav>` : ''}
++ <nav class="ovf-col" aria-label="Legal">
++ <h3>Legal</h3>
++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
++ </nav>
++ </div>
++ <div class="ovf-account" id="ovf-account" hidden></div>
++ <div class="ovf-bar">
++ <span class="ovf-copy">Open source &amp; community driven · built in the open</span>
++ <span class="ovf-legal-inline">
++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
++ </span>
++ </div>
++ </div>`;
++ }
++
++ // ── Signed-in row: the same account, wherever you are ──────
++ function accountHTML(user, service) {
++ const name = esc(user.display_name || user.username || 'you');
++ const avatar = user.avatar_url
++ ? `<img src="${esc(user.avatar_url)}" alt="">`
++ : `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
++ const quick = [
++ service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'fa-tower-broadcast' } : { name: 'My dashboard', url: '/dashboard', icon: 'fa-gauge-high' },
++ { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'fa-user' },
++ { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'fa-palette' },
++ { name: 'Tools', url: 'https://openvibe.tools', icon: 'fa-screwdriver-wrench' },
++ ];
++ return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>
++ <span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}><i class="fa-solid ${q.icon}"></i> ${esc(q.name)}</a>`).join('')}</span>`;
++ }
++
++ function storedToken() {
++ try {
++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
++ return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null;
++ } catch { return null; }
++ }
++
++ async function fillAccount(cfg) {
++ const host = document.getElementById('ovf-account');
++ if (!host || !cfg.showAccount || cfg.variant === 'compact') return;
++ let user = cfg.user;
++ if (!user) {
++ const token = storedToken();
++ if (!token) return;
++ try {
++ const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' });
++ if (!res.ok) return;
++ const data = await res.json();
++ user = data.user || data;
++ } catch { return; }
++ }
++ if (!user || !user.username) return;
++ host.innerHTML = accountHTML(user, cfg.service || detectService());
++ host.hidden = false;
++ }
++
++ // ── Styles (injected once) ─────────────────────────────────
++ const CSS = `
++.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;
++ padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}
++.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
++.ovf-inner{max-width:1240px;margin:0 auto}
++.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}
++.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
++.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}
++.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}
++.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}
++.ovf-col a:hover{color:var(--accent,#a78bfa);transform:translateX(2px)}
++.ovf-more{font-weight:600;opacity:.85}.ovf-more i{font-size:.7rem;transition:transform .15s}.ovf-more:hover i{transform:translateX(3px)}
++.ovf-brand{display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-size:1.05rem;font-weight:700;text-decoration:none}
++.ovf-brand--sm{font-size:.9rem}
++.ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch}
++.ovf-social{display:flex;gap:8px;margin-top:4px}
++.ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2);
++ background:var(--bg-tertiary,#1a1b28);border:1px solid var(--border,rgba(255,255,255,.08));font-size:.95rem;transition:color .15s,border-color .15s,transform .15s}
++.ovf-social a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-2px)}
++.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;
++ margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;
++ background:color-mix(in srgb, var(--accent,#a78bfa) 8%, var(--bg-tertiary,#1a1b28));
++ border:1px solid color-mix(in srgb, var(--accent,#a78bfa) 26%, var(--border,rgba(255,255,255,.08)));
++ animation:ovfIn .4s ease-out both}
++@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
++.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}
++.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}
++.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;
++ background:var(--accent,#a78bfa);color:#fff;font-size:.7rem;font-weight:800;flex:none}
++.ovf-avatar img{width:100%;height:100%;object-fit:cover}
++.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}
++.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;
++ color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));
++ text-decoration:none;transition:color .15s,border-color .15s,transform .15s}
++.ovf-account-links a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-1px)}
++.ovf-account-links i{font-size:.72rem;opacity:.8}
++.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;
++ margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));
++ padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}
++.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}
++.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}
++.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}
++.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#a78bfa)}
++.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}
++@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
++@media (max-width:560px){
++ .ovf{padding-bottom:calc(96px + env(safe-area-inset-bottom,0px))}
++ .ovf[data-variant="compact"]{padding-bottom:calc(84px + env(safe-area-inset-bottom,0px))}
++ .ovf-cols{gap:22px 16px}
++ .ovf-bar{justify-content:center;text-align:center;padding-inline:0}
++ .ovf-legal-inline{justify-content:center}
++ .ovf-account{flex-direction:column;align-items:flex-start}
++}
++@media (prefers-reduced-motion:reduce){.ovf-account{animation:none}.ovf-col a:hover,.ovf-social a:hover,.ovf-account-links a:hover{transform:none}}`;
++
++ function injectCSS() {
++ if (typeof document === 'undefined' || document.getElementById('ovf-css')) return;
++ const st = document.createElement('style');
++ st.id = 'ovf-css';
++ st.textContent = CSS;
++ document.head.appendChild(st);
++ }
++
++ // ── Mount ──────────────────────────────────────────────────
++ function render() {
++ if (!_mounted) return;
++ _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
++ _mounted.setAttribute('data-variant', _cfg.variant);
++ _mounted.innerHTML = buildHTML(_cfg);
++ try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
++ fillAccount(_cfg);
++ }
++
++ function init(cfg = {}) {
++ _cfg = { ...DEFAULTS, ..._cfg, ...cfg };
++ _cfg.service = _cfg.service || detectService();
++ injectCSS();
++ const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount;
++ if (!el) return null;
++ _mounted = el;
++ render();
++ return el;
++ }
++
++ function setVariant(variant) {
++ if (variant !== 'full' && variant !== 'compact') return;
++ if (_cfg.variant === variant) return;
++ _cfg.variant = variant;
++ render();
++ }
++
++ return { init, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
++});
ddiff --git a/vendor/openvibe-shared/history.js b/vendor/openvibe-shared/history.js
nnew file mode 100644
iindex 0000000..028c7f8
---- /dev/null
++++ b/vendor/openvibe-shared/history.js
@@@ -0,0 +1,111 @@
++/**
++ * openvibe-shared/history.js — cross-site history for a signed-in account.
++ *
++ * OpenVibeHistory.record({ type, title, url, icon, service, meta }, { token, apiBase })
++ * OpenVibeHistory.recent({ limit, service, type, token, apiBase }) → [{...}]
++ * OpenVibeHistory.clear({ token }) → wipes it
++ *
++ * Every property records what the account touched — a tool used, a stream watched, a paste
++ * opened, a game played — so "Recently used" in the shared navbar and the History tab on the
++ * Network show one timeline across the whole network instead of one per site. Writes are
++ * deduplicated server-side (same URL within ten minutes bumps the entry instead of adding one)
++ * and the browser side throttles too, so a page cannot flood it. Nothing is recorded for
++ * anonymous sessions, and the user can pause or clear the timeline from their account page.
++ *
++ * Loaded lazily by navbar.js from https://openvibe.network/shared/history.js; safe to include
++ * directly. Token resolution matches the navbar: ov_token cookie → localStorage.ov_token.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeHistory) return;
++
++ const DEFAULT_API = 'https://openvibe.network';
++ const SENT_KEY = 'ov_history_sent'; // sessionStorage: url → ts, to avoid re-posting on every re-render
++ const THROTTLE_MS = 5 * 60 * 1000;
++
++ function token(opts) {
++ if (opts && opts.token) return opts.token;
++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_token=([^;]*)/);
++ if (m && m[1]) { try { return decodeURIComponent(m[1]); } catch { return m[1]; } }
++ try { return localStorage.getItem('ov_token'); } catch { return null; }
++ }
++
++ function api(opts) { return (opts && opts.apiBase) || DEFAULT_API; }
++
++ function sent() { try { return JSON.parse(sessionStorage.getItem(SENT_KEY) || '{}'); } catch { return {}; } }
++ function markSent(url) {
++ try {
++ const m = sent(); m[url] = Date.now();
++ const keys = Object.keys(m); if (keys.length > 60) for (const k of keys.slice(0, keys.length - 60)) delete m[k];
++ sessionStorage.setItem(SENT_KEY, JSON.stringify(m));
++ } catch { /* storage unavailable */ }
++ }
++
++ /** Guess an icon and service label from the URL when the caller gives none. */
++ function inferService(url) {
++ let host = '';
++ try { host = new URL(url, location.href).hostname; } catch { return { service: null }; }
++ const m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/) || host.match(/^(?:(.+)\.)?(openre)\.stream$/);
++ if (!m) return { service: null };
++ return { service: m[2], sub: m[1] || null };
++ }
++
++ const ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' };
++
++ async function record(entry, opts) {
++ const t = token(opts);
++ if (!t || !entry) return false;
++ const url = String(entry.url || (typeof location !== 'undefined' ? location.href : '')).slice(0, 2000);
++ if (!url) return false;
++ const last = sent()[url] || 0;
++ if (!entry.force && Date.now() - last < THROTTLE_MS) return false;
++ markSent(url);
++ const inf = inferService(url);
++ const body = {
++ type: String(entry.type || 'page').slice(0, 32),
++ title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),
++ url,
++ icon: entry.icon || ICONS[entry.type] || null,
++ service: entry.service || inf.service || null,
++ sub: entry.sub || inf.sub || null,
++ meta: entry.meta && typeof entry.meta === 'object' ? entry.meta : null,
++ };
++ try {
++ const res = await fetch(`${api(opts)}/api/history`, {
++ method: 'POST', keepalive: true,
++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
++ body: JSON.stringify(body),
++ });
++ return res.ok;
++ } catch { return false; }
++ }
++
++ async function recent(opts) {
++ const t = token(opts);
++ if (!t) return [];
++ const q = new URLSearchParams();
++ if (opts && opts.limit) q.set('limit', String(opts.limit));
++ if (opts && opts.service) q.set('service', opts.service);
++ if (opts && opts.type) q.set('type', opts.type);
++ try {
++ const res = await fetch(`${api(opts)}/api/history?${q}`, { headers: { Authorization: `Bearer ${t}` } });
++ if (!res.ok) return [];
++ const data = await res.json();
++ return (data && data.items) || [];
++ } catch { return []; }
++ }
++
++ async function clear(opts) {
++ const t = token(opts);
++ if (!t) return false;
++ try { const res = await fetch(`${api(opts)}/api/history`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
++ }
++
++ async function remove(id, opts) {
++ const t = token(opts);
++ if (!t) return false;
++ try { const res = await fetch(`${api(opts)}/api/history/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
++ }
++
++ root.OpenVibeHistory = { record, recent, clear, remove, ICONS };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/vendor/openvibe-shared/index.js b/vendor/openvibe-shared/index.js
nnew file mode 100644
iindex 0000000..2981bd7
---- /dev/null
++++ b/vendor/openvibe-shared/index.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++const { BRAND, BRAND_DEFAULTS, resolveBrandUrls, buildBrandFromRegistry } = require('./brand');
++const { OpenVibeAuthClient } = require('./auth-client');
++const { CSS_VARIABLES, DEFAULT_VARS, BUILTIN_THEMES, applyTheme, resolveBuiltinTheme, sanitizeCssValue, loadFromStorage, saveToStorage, syncThemeToServer } = require('./theme-sync');
++const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth, internalApiAuth } = require('./middleware');
++const { PRIORITY, CATEGORY, TYPES, SOUNDS, EMAIL_ELIGIBLE_CATEGORIES, createNotification, DEFAULT_NOTIFICATION_PREFS } = require('./notifications');
++const { AnalyticsTracker, classifyRequest, parseUserAgent, ANALYTICS_SCHEMA, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');
++const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');
++
++module.exports = {
++ // Brand
++ BRAND,
++ BRAND_DEFAULTS,
++ buildBrandFromRegistry,
++ // Auth
++ OpenVibeAuthClient,
++ extractToken,
++ requireOpenVibeAuth,
++ optionalOpenVibeAuth,
++ internalApiAuth,
++ // Themes
++ CSS_VARIABLES,
++ DEFAULT_VARS,
++ BUILTIN_THEMES,
++ applyTheme,
++ resolveBuiltinTheme,
++ sanitizeCssValue,
++ loadFromStorage,
++ saveToStorage,
++ syncThemeToServer,
++ // Notifications
++ PRIORITY,
++ CATEGORY,
++ NOTIFICATION_TYPES: TYPES,
++ SOUNDS,
++ EMAIL_ELIGIBLE_CATEGORIES,
++ createNotification,
++ DEFAULT_NOTIFICATION_PREFS,
++ // Analytics
++ AnalyticsTracker,
++ classifyRequest,
++ parseUserAgent,
++ ANALYTICS_SCHEMA,
++ BOT_USER_AGENTS,
++ SUSPICIOUS_PATTERNS,
++ // URL Registry Resolver
++ URL_DEFINITIONS,
++ normalizeValue,
++ validateValue,
++ resolveRegistryValues,
++ formatRegistryEntry,
++ resolveBrandUrls,
++ // Client-side modules (browser only — require() for bundlers, <script> tag for direct use)
++ // OpenVibeNotifications: require('./notification-ui'),
++ // OpenVibeUserCard: require('./user-card'),
++ // OpenVibeNavbar: require('./navbar'),
++ // OpenVibeAccountSwitcher: require('./account-switcher'),
++};
ddiff --git a/vendor/openvibe-shared/middleware.js b/vendor/openvibe-shared/middleware.js
nnew file mode 100644
iindex 0000000..4509e16
---- /dev/null
++++ b/vendor/openvibe-shared/middleware.js
@@@ -0,0 +1,94 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Express Middleware
++// Auth middleware for services consuming OpenVibe.Network SSO tokens.
++// ═══════════════════════════════════════════════════════════════
++
++const { OpenVibeAuthClient } = require('./auth-client');
++
++/**
++ * Extract token from request.
++ * Priority: Authorization header → cookie → query param (WebSocket)
++ */
++function extractToken(req) {
++ // 1. Authorization: Bearer <token>
++ const authHeader = req.headers.authorization;
++ if (authHeader && authHeader.startsWith('Bearer ')) {
++ return authHeader.slice(7);
++ }
++ // 2. Cookie
++ if (req.cookies?.ov_token) return req.cookies.ov_token;
++ if (req.cookies?.token) return req.cookies.token; // legacy compat
++ // 3. Query param (WebSocket upgrade)
++ if (req.query?.token) return req.query.token;
++ return null;
++}
++
++/**
++ * Create requireOpenVibeAuth middleware.
++ * Verifies JWT signed by OpenVibe.Network and attaches req.user.
++ *
++ * @param {OpenVibeAuthClient} authClient
++ * @returns {Function} Express middleware
++ */
++function requireOpenVibeAuth(authClient) {
++ return (req, res, next) => {
++ const token = extractToken(req);
++ if (!token) {
++ return res.status(401).json({ error: 'Authentication required' });
++ }
++ const user = authClient.verifyToken(token);
++ if (!user) {
++ return res.status(401).json({ error: 'Invalid or expired token' });
++ }
++ req.user = user;
++ req.token = token;
++ next();
++ };
++}
++
++/**
++ * Create optionalOpenVibeAuth middleware.
++ * Attaches req.user if a valid token is present, otherwise continues.
++ *
++ * @param {OpenVibeAuthClient} authClient
++ * @returns {Function} Express middleware
++ */
++function optionalOpenVibeAuth(authClient) {
++ return (req, _res, next) => {
++ const token = extractToken(req);
++ if (token) {
++ const user = authClient.verifyToken(token);
++ if (user) {
++ req.user = user;
++ req.token = token;
++ }
++ }
++ next();
++ };
++}
++
++/**
++ * Internal API authentication middleware.
++ * Verifies X-Internal-Key header for server-to-server calls.
++ *
++ * @param {string} internalKey - Expected key value
++ * @returns {Function} Express middleware
++ */
++function internalApiAuth(internalKey) {
++ return (req, res, next) => {
++ const provided = req.headers['x-internal-key'];
++ if (!provided || provided !== internalKey) {
++ return res.status(403).json({ error: 'Forbidden' });
++ }
++ next();
++ };
++}
++
++module.exports = {
++ extractToken,
++ requireOpenVibeAuth,
++ optionalOpenVibeAuth,
++ internalApiAuth,
++};
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
nnew file mode 100644
iindex 0000000..8402a57
---- /dev/null
++++ b/vendor/openvibe-shared/navbar.js
@@@ -0,0 +1,1130 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Universal Navbar
++// Consistent top bar across all services with logo, navigation,
++// notification bell, account switcher, and theme-aware styling.
++// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
++// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
++// endpoint returning { user } — used only when no ov_token exists),
++// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
++// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
++// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
++//
++// Brand: derived from the hostname — `pastes.openvibe.tools` renders as
++// Pastes · OpenVibe · Tools (three segments, the subdomain first so the
++// context reads left-to-right), `openvibe.live` as OpenVibe · Live. Pass
++// brand: { sub, tld, name, icon, variant } to override any part, or the
++// legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
++// to the subdomain on narrow viewports), 'always', 'never'.
++// Menus are modular: every site keeps the shared account menu and adds its own
++// pieces — links: [{label, href, icon?, active?}] replaces the service's top
++// links; menu: { before: [item], after: [item] } adds dropdown rows
++// ({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
++// / setLinks() do the same at runtime. Signed-in users also get a
++// "Recently used" row fed by the shared history module when it is loaded.
++// ═══════════════════════════════════════════════════════════════
++
++(function (root) {
++ 'use strict';
++
++ let _config = {
++ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',
++ onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null,
++ brand: null, brandName: null, brandIcon: null, compact: 'auto',
++ links: null, menu: null, recent: true,
++ // history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the
++ // signed-in user once auth resolves (cross-site "Recently used" / History on the Network).
++ history: null,
++ // silentLogin: 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in here
++ // but this browser has signed in to the network before (ov_sso_hint), try one silent
++ // prompt=none round trip per tab so a session on one site becomes a session on all.
++ silentLogin: null,
++ };
++ const _runtimeMenu = { before: [], after: [] };
++ let _runtimeLinks = null;
++ let _navEl = null;
++
++ function injectStyles() {
++ if (document.getElementById('openvibe-navbar-styles')) return;
++ const s = document.createElement('style');
++ s.id = 'openvibe-navbar-styles';
++ s.textContent = `
++ .openvibe-navbar {
++ position: sticky; top: 0; z-index: 10000;
++ height: 52px; display: flex; align-items: center; padding: 0 16px; gap: 8px;
++ background: var(--bg-secondary, #252530);
++ border-bottom: 1px solid var(--border, #333340);
++ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
++ color: var(--text-primary, #e0e0e0);
++ }
++ .openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
++ .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
++ .openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
++ /* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
++ .openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }
++ .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); }
++ .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #a78bfa)); }
++ .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
++ .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
++ .openvibe-navbar-brand .b-dot { color: var(--accent, #8b5cf6); opacity: .75; margin: 0 1px; font-weight: 800; }
++ .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; }
++ .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #a78bfa); }
++ .openvibe-navbar-brand:hover .b-dot { opacity: 1; }
++ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #a78bfa); background: var(--accent-glow, rgba(139,92,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; }
++ /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */
++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core,
++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld,
++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
++ @media (max-width: 860px) {
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-core,
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
++ }
++ @media (max-width: 420px) {
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-core,
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-dot { display: none; }
++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }
++ }
++ .openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }
++ .openvibe-navbar-dropdown-recent { padding: 6px 8px 2px; border-bottom: 1px solid var(--border, #333340); }
++ .openvibe-navbar-dropdown-recent .label { font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--text-muted, #707080); padding: 2px 8px 4px; display: flex; justify-content: space-between; align-items: center; }
++ .openvibe-navbar-dropdown-recent .label a { color: var(--accent-light, #a78bfa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }
++ .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }
++ .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
++ .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #a78bfa); flex: none; }
++ .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++ .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
++ .openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
++
++ .openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
++ .openvibe-navbar-links a {
++ padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500;
++ color: var(--text-secondary, #b0b0b8); text-decoration: none;
++ transition: all .15s;
++ }
++ .openvibe-navbar-links a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
++ .openvibe-navbar-links a.active { background: var(--bg-tertiary, #2a2a38); color: var(--accent-light, #a78bfa); }
++
++ .openvibe-navbar-spacer { flex: 1; }
++
++ .openvibe-navbar-right { display: flex; align-items: center; gap: 6px; }
++
++ .openvibe-navbar-avatar {
++ width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
++ border: 2px solid var(--border, #333340); transition: border-color .2s;
++ object-fit: cover;
++ }
++ .openvibe-navbar-avatar:hover { border-color: var(--accent, #8b5cf6); }
++
++ .openvibe-navbar-login {
++ padding: 6px 16px; border-radius: 6px; font-size: 13px; font-weight: 600;
++ background: var(--accent, #8b5cf6); color: #fff; border: none; cursor: pointer;
++ transition: background .15s; text-decoration: none; display: inline-flex; align-items: center;
++ }
++ .openvibe-navbar-login:hover { background: var(--accent-dark, #6d28d9); }
++
++ .openvibe-navbar-dropdown {
++ position: absolute; top: 48px; right: 8px;
++ width: 260px; background: var(--bg-card, #22222c);
++ border: 1px solid var(--border, #333340); border-radius: 10px;
++ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
++ display: none; flex-direction: column; overflow: hidden;
++ animation: openvibe-slide-down .2s ease;
++ }
++ .openvibe-navbar-dropdown.open { display: flex; }
++ @keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
++
++ .openvibe-navbar-dropdown-header {
++ padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);
++ display: flex; align-items: center; gap: 10px;
++ }
++ .openvibe-navbar-dropdown-header img { width: 36px; height: 36px; border-radius: 50%; }
++ .openvibe-navbar-dropdown-header .info { line-height: 1.3; }
++ .openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; }
++ .openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); }
++ .openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #a78bfa); }
++
++ .openvibe-navbar-dropdown-accounts {
++ padding: 6px 8px; border-bottom: 1px solid var(--border, #333340);
++ max-height: 140px; overflow-y: auto;
++ }
++ .openvibe-navbar-dropdown-accounts .account-item {
++ display: flex; align-items: center; gap: 8px; padding: 6px 8px;
++ border-radius: 6px; cursor: pointer; font-size: 12px;
++ color: var(--text-secondary, #b0b0b8); transition: background .12s;
++ }
++ .openvibe-navbar-dropdown-accounts .account-item:hover { background: var(--bg-hover, #2f2f3d); }
++ .openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }
++ .openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #a78bfa); font-weight: 600; }
++ .openvibe-navbar-dropdown-accounts .add-account {
++ display: flex; align-items: center; gap: 8px; padding: 6px 8px;
++ border-radius: 6px; cursor: pointer; font-size: 12px;
++ color: var(--text-muted, #707080); transition: background .12s;
++ text-decoration: none;
++ }
++ .openvibe-navbar-dropdown-accounts .add-account:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
++
++ .openvibe-navbar-dropdown-menu { padding: 6px 8px; }
++ .openvibe-navbar-dropdown-menu a, .openvibe-navbar-dropdown-menu button {
++ display: flex; align-items: center; gap: 8px; width: 100%;
++ padding: 8px; border-radius: 6px; font-size: 12px; font-weight: 500;
++ background: none; border: none; color: var(--text-primary, #e0e0e0);
++ cursor: pointer; text-align: left; text-decoration: none;
++ transition: background .12s;
++ }
++ .openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); }
++ .openvibe-navbar-dropdown-menu .danger { color: var(--live-red, #e74c3c); }
++ .openvibe-navbar-dropdown-menu .icon { width: 18px; text-align: center; font-size: 14px; }
++
++ .openvibe-navbar .openvibe-network-badge {
++ font-size: 10px; padding: 2px 8px; border-radius: 4px;
++ background: rgba(139,92,246,0.1); color: var(--accent-light, #a78bfa);
++ font-weight: 500; cursor: pointer; border: 1px solid transparent;
++ transition: all .15s;
++ }
++ .openvibe-navbar .openvibe-network-badge:hover { border-color: var(--accent-dark, #6d28d9); }
++
++ @media (max-width: 600px) {
++ .openvibe-navbar-links { display: none; }
++ .openvibe-navbar .openvibe-network-badge { display: none; }
++ }
++ `;
++ document.head.appendChild(s);
++ }
++
++ // Primary services (per CONTRACTS branding) + tool sub-brands (<Name>.OpenVibe)
++ const SERVICE_NAMES = {
++ live: 'OpenVibe.Live', tools: 'OpenVibe.Tools', games: 'OpenVibe.Games',
++ media: 'OpenVibe.Media', network: 'OpenVibe.Network',
++ net: 'Net.OpenVibe', dev: 'Dev.OpenVibe', paste: 'Paste.OpenVibe',
++ maps: 'Maps.OpenVibe', food: 'Food.OpenVibe', img: 'Img.OpenVibe',
++ yt: 'YT.OpenVibe', audio: 'Audio.OpenVibe', text: 'Text.OpenVibe',
++ logo: 'Logo.OpenVibe', docs: 'Docs.OpenVibe',
++ };
++
++ const SERVICE_ICONS = {
++ live: 'fa-tower-broadcast', tools: 'fa-screwdriver-wrench', games: 'fa-gamepad',
++ media: 'fa-photo-film', network: 'fa-circle-nodes',
++ net: 'fa-network-wired', dev: 'fa-code', paste: 'fa-paste',
++ maps: 'fa-map-location-dot', food: 'fa-utensils', img: 'fa-images',
++ yt: 'fa-circle-play', audio: 'fa-headphones', text: 'fa-pen-fancy',
++ logo: 'fa-wand-magic-sparkles', docs: 'fa-file-pdf',
++ };
++
++ // Subdomain → brand override for multi-subdomain services (Img.OpenVibe)
++ const SUBDOMAIN_BRANDS = {
++ 'png.openvibe.tools': { name: 'OpenVibePNG', icon: 'fa-file-image' },
++ 'jpg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },
++ 'jpeg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },
++ 'webp.openvibe.tools': { name: 'OpenVibeWebP', icon: 'fa-file-image' },
++ 'avif.openvibe.tools': { name: 'OpenVibeAVIF', icon: 'fa-file-image' },
++ 'heic.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },
++ 'heif.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },
++ 'svg.openvibe.tools': { name: 'OpenVibeSVG', icon: 'fa-bezier-curve' },
++ 'gif.openvibe.tools': { name: 'OpenVibeGIF', icon: 'fa-film' },
++ 'ico.openvibe.tools': { name: 'OpenVibeICO', icon: 'fa-icons' },
++ 'tiff.openvibe.tools': { name: 'OpenVibeTIFF', icon: 'fa-file-image' },
++ 'bmp.openvibe.tools': { name: 'OpenVibeBMP', icon: 'fa-file-image' },
++ 'compress.openvibe.tools': { name: 'OpenVibeCompress', icon: 'fa-compress' },
++ 'resize.openvibe.tools': { name: 'OpenVibeResize', icon: 'fa-up-right-and-down-left-from-center' },
++ 'crop.openvibe.tools': { name: 'OpenVibeCrop', icon: 'fa-crop-simple' },
++ 'convert.openvibe.tools': { name: 'OpenVibeConvert', icon: 'fa-arrows-rotate' },
++ 'favicon.openvibe.tools': { name: 'OpenVibeFavicon', icon: 'fa-icons' },
++ 'yt.openvibe.tools': { name: 'YT.OpenVibe', icon: 'fa-circle-play' },
++ 'maps.openvibe.tools': { name: 'Maps.OpenVibe', icon: 'fa-map-location-dot' },
++ 'food.openvibe.tools': { name: 'Food.OpenVibe', icon: 'fa-utensils' },
++ // Audio tool subdomains
++ 'audio.openvibe.tools': { name: 'Audio.OpenVibe', icon: 'fa-headphones' },
++ 'mp3.openvibe.tools': { name: 'OpenVibeMP3', icon: 'fa-file-audio' },
++ 'wav.openvibe.tools': { name: 'OpenVibeWAV', icon: 'fa-file-audio' },
++ 'flac.openvibe.tools': { name: 'OpenVibeFLAC', icon: 'fa-file-audio' },
++ 'ogg.openvibe.tools': { name: 'OpenVibeOGG', icon: 'fa-file-audio' },
++ 'm4a.openvibe.tools': { name: 'OpenVibeM4A', icon: 'fa-file-audio' },
++ 'aac.openvibe.tools': { name: 'OpenVibeAAC', icon: 'fa-file-audio' },
++ 'opus.openvibe.tools': { name: 'OpenVibeOPUS', icon: 'fa-file-audio' },
++ 'wma.openvibe.tools': { name: 'OpenVibeWMA', icon: 'fa-file-audio' },
++ 'aiff.openvibe.tools': { name: 'OpenVibeAIFF', icon: 'fa-file-audio' },
++ 'ac3.openvibe.tools': { name: 'OpenVibeAC3', icon: 'fa-file-audio' },
++ 'trim.openvibe.tools': { name: 'OpenVibeTrim', icon: 'fa-scissors' },
++ 'pitch.openvibe.tools': { name: 'OpenVibePitch', icon: 'fa-wave-square' },
++ 'speed.openvibe.tools': { name: 'OpenVibeSpeed', icon: 'fa-gauge-high' },
++ 'normalize.openvibe.tools':{ name: 'OpenVibeNormalize', icon: 'fa-sliders' },
++ 'fade.openvibe.tools': { name: 'OpenVibeFade', icon: 'fa-volume-low' },
++ 'bass.openvibe.tools': { name: 'OpenVibeBass', icon: 'fa-volume-high' },
++ 'equalizer.openvibe.tools':{ name: 'OpenVibeEQ', icon: 'fa-bars-staggered' },
++ 'echo.openvibe.tools': { name: 'OpenVibeEcho', icon: 'fa-tower-broadcast' },
++ 'reverb.openvibe.tools': { name: 'OpenVibeReverb', icon: 'fa-church' },
++ 'voice.openvibe.tools': { name: 'OpenVibeVoiceFX', icon: 'fa-user-astronaut' },
++ 'extract.openvibe.tools': { name: 'OpenVibeExtract', icon: 'fa-music' },
++ 'ringtone.openvibe.tools': { name: 'OpenVibeRingtone', icon: 'fa-bell' },
++ // Text tool subdomains
++ 'text.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },
++ 'type.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },
++ 'fonts.openvibe.tools': { name: 'OpenVibeFonts', icon: 'fa-font' },
++ 'fancy.openvibe.tools': { name: 'OpenVibeFancy', icon: 'fa-wand-sparkles' },
++ 'zalgo.openvibe.tools': { name: 'OpenVibeZalgo', icon: 'fa-skull' },
++ 'ascii.openvibe.tools': { name: 'OpenVibeASCII', icon: 'fa-terminal' },
++ 'symbols.openvibe.tools': { name: 'OpenVibeSymbols', icon: 'fa-icons' },
++ 'unicode.openvibe.tools': { name: 'OpenVibeUnicode', icon: 'fa-magnifying-glass' },
++ 'bubble.openvibe.tools': { name: 'OpenVibeBubble', icon: 'fa-circle' },
++ 'glitch.openvibe.tools': { name: 'OpenVibeGlitch', icon: 'fa-bug' },
++ 'smallcaps.openvibe.tools': { name: 'OpenVibeSmallCaps', icon: 'fa-text-height' },
++ 'cursive.openvibe.tools': { name: 'OpenVibeCursive', icon: 'fa-pen-nib' },
++ 'gothic.openvibe.tools': { name: 'OpenVibeGothic', icon: 'fa-book-skull' },
++ 'wide.openvibe.tools': { name: 'OpenVibeWide', icon: 'fa-arrows-left-right' },
++ 'monospaced.openvibe.tools': { name: 'OpenVibeMono', icon: 'fa-code' },
++ 'braille.openvibe.tools': { name: 'OpenVibeBraille', icon: 'fa-braille' },
++ 'morse.openvibe.tools': { name: 'OpenVibeMorse', icon: 'fa-tower-broadcast' },
++ 'binary.openvibe.tools': { name: 'OpenVibeBinary', icon: 'fa-microchip' },
++ 'case.openvibe.tools': { name: 'OpenVibeCase', icon: 'fa-text-height' },
++ 'caps.openvibe.tools': { name: 'OpenVibeCaps', icon: 'fa-text-height' },
++ 'titlecase.openvibe.tools': { name: 'OpenVibeTitleCase', icon: 'fa-heading' },
++ 'reverse.openvibe.tools': { name: 'OpenVibeReverse', icon: 'fa-right-left' },
++ 'clean.openvibe.tools': { name: 'OpenVibeClean', icon: 'fa-broom' },
++ 'strip.openvibe.tools': { name: 'OpenVibeStrip', icon: 'fa-broom' },
++ 'count.openvibe.tools': { name: 'OpenVibeCount', icon: 'fa-calculator' },
++ 'lines.openvibe.tools': { name: 'OpenVibeLines', icon: 'fa-list-ol' },
++ 'sort.openvibe.tools': { name: 'OpenVibeSort', icon: 'fa-arrow-down-a-z' },
++ 'dedupe.openvibe.tools': { name: 'OpenVibeDedupe', icon: 'fa-filter' },
++ 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },
++ 'compare.openvibe.tools': { name: 'OpenVibeCompare', icon: 'fa-code-compare' },
++ 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
++ 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
++ 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-brackets-curly' },
++ 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
++ 'bio.openvibe.tools': { name: 'OpenVibeBio', icon: 'fa-id-card' },
++ 'nickname.openvibe.tools': { name: 'OpenVibeNickname', icon: 'fa-signature' },
++ 'username.openvibe.tools': { name: 'OpenVibeUsername', icon: 'fa-at' },
++ 'gamertag.openvibe.tools': { name: 'OpenVibeGamertag', icon: 'fa-gamepad' },
++ 'kaomoji.openvibe.tools': { name: 'OpenVibeKaomoji', icon: 'fa-face-smile' },
++ 'emojis.openvibe.tools': { name: 'OpenVibeEmojis', icon: 'fa-face-grin' },
++ 'copypaste.openvibe.tools': { name: 'OpenVibeCopyPaste', icon: 'fa-paste' },
++ 'banner.openvibe.tools': { name: 'OpenVibeBanner', icon: 'fa-rectangle-ad' },
++ 'textart.openvibe.tools': { name: 'Text.OpenVibeArt', icon: 'fa-border-all' },
++ 'figlet.openvibe.tools': { name: 'OpenVibeFiglet', icon: 'fa-terminal' },
++ // Logo / design subdomains
++ 'logo.openvibe.tools': { name: 'Logo.OpenVibe', icon: 'fa-wand-magic-sparkles' },
++ 'title.openvibe.tools': { name: 'OpenVibeTitle', icon: 'fa-heading' },
++ 'wordmark.openvibe.tools': { name: 'OpenVibeWordmark', icon: 'fa-font' },
++ 'textlogo.openvibe.tools': { name: 'Text.OpenVibeLogo', icon: 'fa-font' },
++ 'transparent.openvibe.tools':{ name: 'OpenVibeTransparent', icon: 'fa-eye-slash' },
++ 'badge.openvibe.tools': { name: 'OpenVibeBadge', icon: 'fa-certificate' },
++ 'sticker.openvibe.tools': { name: 'OpenVibeSticker', icon: 'fa-note-sticky' },
++ 'thumbnail.openvibe.tools': { name: 'OpenVibeThumbnail', icon: 'fa-photo-film' },
++ 'cover.openvibe.tools': { name: 'OpenVibeCover', icon: 'fa-image' },
++ 'channelart.openvibe.tools': { name: 'OpenVibeChannelArt', icon: 'fa-panorama' },
++ 'watermark.openvibe.tools': { name: 'OpenVibeWatermark', icon: 'fa-droplet' },
++ 'neon.openvibe.tools': { name: 'OpenVibeNeon', icon: 'fa-lightbulb' },
++ // Document / PDF subdomains
++ 'docs.openvibe.tools': { name: 'Docs.OpenVibe', icon: 'fa-file-pdf' },
++ 'pdf.openvibe.tools': { name: 'OpenVibePDF', icon: 'fa-file-pdf' },
++ 'mergepdf.openvibe.tools': { name: 'MergePDF', icon: 'fa-object-group' },
++ 'splitpdf.openvibe.tools': { name: 'SplitPDF', icon: 'fa-scissors' },
++ 'compresspdf.openvibe.tools':{ name: 'CompressPDF', icon: 'fa-compress' },
++ 'rotatepdf.openvibe.tools': { name: 'RotatePDF', icon: 'fa-rotate' },
++ 'reorderpdf.openvibe.tools': { name: 'ReorderPDF', icon: 'fa-sort' },
++ 'watermarkpdf.openvibe.tools':{ name: 'WatermarkPDF', icon: 'fa-stamp' },
++ 'protectpdf.openvibe.tools': { name: 'ProtectPDF', icon: 'fa-lock' },
++ 'unlockpdf.openvibe.tools': { name: 'UnlockPDF', icon: 'fa-lock-open' },
++ 'image2pdf.openvibe.tools': { name: 'Image2PDF', icon: 'fa-file-image' },
++ 'jpg2pdf.openvibe.tools': { name: 'JPG2PDF', icon: 'fa-file-image' },
++ 'png2pdf.openvibe.tools': { name: 'PNG2PDF', icon: 'fa-file-image' },
++ 'pdf2jpg.openvibe.tools': { name: 'PDF2JPG', icon: 'fa-image' },
++ 'pdf2png.openvibe.tools': { name: 'PDF2PNG', icon: 'fa-image' },
++ // Network tool subdomains
++ 'net.openvibe.tools': { name: 'Net.OpenVibe', icon: 'fa-network-wired' },
++ 'lookup.openvibe.tools': { name: 'OpenVibeLookup', icon: 'fa-magnifying-glass' },
++ 'myip.openvibe.tools': { name: 'OpenVibeMyIP', icon: 'fa-location-crosshairs' },
++ 'ip.openvibe.tools': { name: 'OpenVibeIP', icon: 'fa-at' },
++ 'geoip.openvibe.tools': { name: 'OpenVibeGeoIP', icon: 'fa-earth-americas' },
++ 'hostname.openvibe.tools': { name: 'OpenVibeHostname', icon: 'fa-server' },
++ 'isp.openvibe.tools': { name: 'OpenVibeISP', icon: 'fa-building' },
++ 'asn.openvibe.tools': { name: 'OpenVibeASN', icon: 'fa-diagram-project' },
++ 'ipv4.openvibe.tools': { name: 'OpenVibeIPv4', icon: 'fa-hashtag' },
++ 'ipv6.openvibe.tools': { name: 'OpenVibeIPv6', icon: 'fa-code' },
++ 'rdns.openvibe.tools': { name: 'OpenVibeReverseDNS',icon: 'fa-rotate-left' },
++ 'whois.openvibe.tools': { name: 'OpenVibeWhois', icon: 'fa-address-book' },
++ 'rdap.openvibe.tools': { name: 'OpenVibeRDAP', icon: 'fa-id-card' },
++ 'dns.openvibe.tools': { name: 'OpenVibeDNS', icon: 'fa-sitemap' },
++ 'dig.openvibe.tools': { name: 'OpenVibeDig', icon: 'fa-terminal' },
++ 'nslookup.openvibe.tools': { name: 'OpenVibeNSLookup', icon: 'fa-magnifying-glass-arrow-right' },
++ 'dnspropagation.openvibe.tools': { name: 'OpenVibeDNSPropagation', icon: 'fa-globe' },
++ 'mx.openvibe.tools': { name: 'OpenVibeMX', icon: 'fa-envelope' },
++ 'txt.openvibe.tools': { name: 'OpenVibeTXT', icon: 'fa-file-lines' },
++ 'ns.openvibe.tools': { name: 'OpenVibeNS', icon: 'fa-server' },
++ 'spf.openvibe.tools': { name: 'OpenVibeSPF', icon: 'fa-shield-halved' },
++ 'dkim.openvibe.tools': { name: 'OpenVibeDKIM', icon: 'fa-key' },
++ 'dmarc.openvibe.tools': { name: 'OpenVibeDMARC', icon: 'fa-user-shield' },
++ 'ping.openvibe.tools': { name: 'OpenVibePing', icon: 'fa-satellite-dish' },
++ 'traceroute.openvibe.tools': { name: 'OpenVibeTraceroute', icon: 'fa-route' },
++ 'mtr.openvibe.tools': { name: 'OpenVibeMTR', icon: 'fa-chart-line' },
++ 'port.openvibe.tools': { name: 'OpenVibePortCheck', icon: 'fa-door-open' },
++ 'headers.openvibe.tools': { name: 'OpenVibeHeaders', icon: 'fa-list' },
++ 'redirects.openvibe.tools': { name: 'OpenVibeRedirects', icon: 'fa-share' },
++ 'ssl.openvibe.tools': { name: 'OpenVibeSSL', icon: 'fa-lock' },
++ 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-download' },
++ 'httpstatus.openvibe.tools': { name: 'OpenVibeHTTPStatus', icon: 'fa-circle-check' },
++ 'latency.openvibe.tools': { name: 'OpenVibeLatency', icon: 'fa-gauge-high' },
++ // Dev.OpenVibe subdomains
++ 'dev.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
++ 'code.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
++ 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-code' },
++ 'yaml.openvibe.tools': { name: 'OpenVibeYAML', icon: 'fa-file-code' },
++ 'xml.openvibe.tools': { name: 'OpenVibeXML', icon: 'fa-file-code' },
++ 'csv.openvibe.tools': { name: 'OpenVibeCSV', icon: 'fa-table' },
++ 'sql.openvibe.tools': { name: 'OpenVibeSQL', icon: 'fa-database' },
++ 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
++ 'html.openvibe.tools': { name: 'OpenVibeHTML', icon: 'fa-file-code' },
++ 'base64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },
++ 'url.openvibe.tools': { name: 'OpenVibeURL', icon: 'fa-link' },
++ 'jwt.openvibe.tools': { name: 'OpenVibeJWT', icon: 'fa-key' },
++ 'uuid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },
++ 'hash.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },
++ 'hex.openvibe.tools': { name: 'OpenVibeHex', icon: 'fa-barcode' },
++ 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
++ 'timestamp.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
++ 'cron.openvibe.tools': { name: 'OpenVibeCron', icon: 'fa-calendar-check' },
++ 'beautify.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
++ 'minify.openvibe.tools': { name: 'OpenVibeMinify', icon: 'fa-compress' },
++ 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
++ 'regex.openvibe.tools': { name: 'OpenVibeRegex', icon: 'fa-magnifying-glass' },
++ 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },
++ 'lorem.openvibe.tools': { name: 'OpenVibeLorem', icon: 'fa-paragraph' },
++ 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },
++ 'webhook.openvibe.tools': { name: 'OpenVibeWebhook', icon: 'fa-satellite-dish' },
++ 'color.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
++ 'opengraph.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },
++ // Dev.OpenVibe aliases
++ 'build.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
++ 'debug.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
++ 'compare.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
++ 'format.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
++ 'prettier.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
++ 'md.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
++ 'unix.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
++ 'epoch.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
++ 'b64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },
++ 'guid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },
++ 'sha256.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },
++ 'entities.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
++ 'http.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },
++ 'og.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },
++ 'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
++ };
++
++ // ─── Brand from hostname ───────────────────────────────────
++ // Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the
++ // whole name — Pastes.OpenVibe.Tools, not "Paste.OpenVibe" — because the subdomain and
++ // the TLD are what tell a visitor where they are in the network.
++ const TLD_LABELS = {
++ live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games',
++ community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki',
++ news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host',
++ deals: 'Deals', coupons: 'Coupons',
++ };
++ const SUB_LABELS = {
++ json: 'JSON', yaml: 'YAML', xml: 'XML', csv: 'CSV', sql: 'SQL', html: 'HTML', jwt: 'JWT',
++ uuid: 'UUID', guid: 'GUID', url: 'URL', b64: 'B64', sha256: 'SHA256', og: 'OG', md: 'MD',
++ yt: 'YT', ip: 'IP', myip: 'MyIP', ipv4: 'IPv4', ipv6: 'IPv6', geoip: 'GeoIP', asn: 'ASN',
++ rdns: 'rDNS', dns: 'DNS', mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM',
++ dmarc: 'DMARC', mtr: 'MTR', ssl: 'SSL', tls: 'TLS', ptr: 'PTR', smtp: 'SMTP', http: 'HTTP',
++ httpstatus: 'HTTPStatus', rdap: 'RDAP', isp: 'ISP', pdf: 'PDF', mergepdf: 'MergePDF',
++ splitpdf: 'SplitPDF', compresspdf: 'CompressPDF', rotatepdf: 'RotatePDF',
++ reorderpdf: 'ReorderPDF', watermarkpdf: 'WatermarkPDF', protectpdf: 'ProtectPDF',
++ unlockpdf: 'UnlockPDF', image2pdf: 'Image2PDF', jpg2pdf: 'JPG2PDF', png2pdf: 'PNG2PDF',
++ pdf2jpg: 'PDF2JPG', pdf2png: 'PDF2PNG', png: 'PNG', jpg: 'JPG', jpeg: 'JPEG', webp: 'WebP',
++ avif: 'AVIF', heic: 'HEIC', heif: 'HEIF', svg: 'SVG', gif: 'GIF', ico: 'ICO', tiff: 'TIFF',
++ bmp: 'BMP', mp3: 'MP3', wav: 'WAV', flac: 'FLAC', ogg: 'OGG', m4a: 'M4A', aac: 'AAC',
++ opus: 'Opus', wma: 'WMA', aiff: 'AIFF', ac3: 'AC3', eq: 'EQ', equalizer: 'EQ', mxn: 'MXN',
++ ascii: 'ASCII', smallcaps: 'SmallCaps', titlecase: 'TitleCase', textlogo: 'TextLogo',
++ textart: 'TextArt', channelart: 'ChannelArt', lowerthird: 'LowerThird', copypaste: 'CopyPaste',
++ dnspropagation: 'DNSPropagation', opengraph: 'OpenGraph', whip: 'WHIP', ingest: 'Ingest',
++ play: 'Play', my: 'My', auth: 'Auth', api: 'API', admin: 'Admin', docs: 'Docs', dev: 'Dev',
++ net: 'Net', img: 'Img', pastes: 'Pastes', paste: 'Pastes', maps: 'Maps', food: 'Food',
++ text: 'Text', logo: 'Logo', audio: 'Audio', ai: 'AI', cdn: 'CDN', status: 'Status',
++ };
++ const SERVICE_TLD = { live: 'live', tools: 'tools', games: 'games', media: 'media', network: 'network', community: 'community' };
++ const SERVICE_SUB = { net: 'net', dev: 'dev', paste: 'pastes', maps: 'maps', food: 'food', img: 'img', yt: 'yt', audio: 'audio', text: 'text', logo: 'logo', docs: 'docs' };
++
++ function titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; }
++ function subLabel(sub) { return SUB_LABELS[sub] || titleCase(sub); }
++
++ /**
++ * { sub, core, tld, name, short, icon, variant } for the current page.
++ * sub 'Pastes' | null tld 'Tools' core 'OpenVibe'
++ * name 'Pastes.OpenVibe.Tools' short 'Pastes' (what compact mode keeps)
++ * variant the ov-mark flavour: the TLD id ('live', 'tools', …) — every site gets its own twist
++ */
++ function resolveBrand() {
++ const b = Object.assign({}, _config.brand || {});
++ const host = currentHost().toLowerCase();
++ let sub = null, tld = null, core = 'OpenVibe';
++ let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
++ if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }
++ else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; }
++ // Off-network hosts (localhost, previews): fall back to the service id.
++ if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }
++ // Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.
++ if (_config.brandName && !b.sub && !b.tld) {
++ const parts = String(_config.brandName).split('.');
++ if (parts.length >= 2 && /^openvibe$/i.test(parts[0])) tld = parts[1].toLowerCase();
++ else if (parts.length >= 2 && /^openvibe$/i.test(parts[1])) sub = parts[0].toLowerCase();
++ else if (parts.length === 1) sub = parts[0].toLowerCase();
++ }
++ if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;
++ if (b.tld) tld = String(b.tld).toLowerCase();
++ const subText = b.subLabel || (sub ? subLabel(sub) : null);
++ const tldText = b.tldLabel || TLD_LABELS[tld] || titleCase(tld);
++ const name = b.name || [subText, core, tldText].filter(Boolean).join('.');
++ const icon = b.icon || _config.brandIcon || null;
++ const variant = b.variant || (core === 'OpenRe' ? 'stream' : tld);
++ return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };
++ }
++
++ function brandHTML(brand) {
++ const seg = (cls, text) => `<span class="${cls}">${escapeAttr(text)}</span>`;
++ const dot = '<span class="b-dot">.</span>';
++ const text = brand.subText
++ ? seg('b-sub', brand.subText) + dot + seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText)
++ : seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText);
++ const mark = brand.icon
++ ? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
++ : `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
++ return `<a class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}" href="${escapeAttr(brand.href)}" title="${escapeAttr(brand.name)}" aria-label="${escapeAttr(brand.name)}">
++ <span class="flame">${mark}</span>
++ <span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
++ </a>`;
++ }
++
++ function menuItemHTML(item) {
++ if (!item) return '';
++ if (item.sep) return '<div class="sep"></div>';
++ const icon = item.icon ? `<span class="icon"><i class="fa-solid ${escapeAttr(item.icon)}"></i></span>` : '<span class="icon"></span>';
++ const cls = item.danger ? ' class="danger"' : '';
++ const id = item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : '';
++ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}</a>`;
++ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}</button>`;
++ }
++
++ function bindMenuItems(container, items) {
++ for (const item of items) {
++ if (!item || !item.id || typeof item.onClick !== 'function') continue;
++ container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); item.onClick(e); });
++ }
++ }
++
++ function currentLinks() {
++ const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
++ const path = (typeof location !== 'undefined' && location.pathname) || '/';
++ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (l.href === path && path !== '/') }));
++ }
++
++ const SERVICE_LINKS = {
++ live: [
++ { label: 'Watch', href: '/' },
++ { label: 'Chat', href: '/chat' },
++ { label: 'VODs', href: '/vods' },
++ { label: 'Game', href: '/game' },
++ ],
++ tools: [
++ { label: 'Home', href: '/' },
++ ],
++ games: [
++ { label: 'Play', href: '/game' },
++ { label: 'Canvas', href: '/canvas' },
++ { label: 'Leaderboard', href: '/leaderboard' },
++ ],
++ media: [
++ { label: 'Home', href: '/' },
++ { label: 'Network', href: 'https://openvibe.network' },
++ { label: 'Live', href: 'https://openvibe.live' },
++ { label: 'Tools', href: 'https://openvibe.tools' },
++ ],
++ network: [
++ { label: 'Home', href: '/' },
++ { label: 'My Account', href: '/my' },
++ { label: 'Themes', href: '/themes' },
++ ],
++ maps: [
++ { label: 'Map', href: '/' },
++ { label: 'Camps', href: '/camps' },
++ ],
++ food: [
++ { label: 'Food Banks', href: '/' },
++ { label: 'Meal Plan', href: '/#meal-plan' },
++ ],
++ img: [
++ { label: 'Convert', href: 'https://convert.openvibe.tools' },
++ { label: 'Compress', href: 'https://compress.openvibe.tools' },
++ { label: 'Resize', href: 'https://resize.openvibe.tools' },
++ { label: 'Crop', href: 'https://crop.openvibe.tools' },
++ ],
++ yt: [
++ { label: 'Download', href: '/' },
++ ],
++ audio: [
++ { label: 'Convert', href: 'https://audio.openvibe.tools' },
++ { label: 'Trim', href: 'https://trim.openvibe.tools' },
++ { label: 'Pitch', href: 'https://pitch.openvibe.tools' },
++ { label: 'Reverb', href: 'https://reverb.openvibe.tools' },
++ ],
++ text: [
++ { label: 'Fancy', href: 'https://fancy.openvibe.tools' },
++ { label: 'Zalgo', href: 'https://zalgo.openvibe.tools' },
++ { label: 'ASCII', href: 'https://ascii.openvibe.tools' },
++ { label: 'Symbols', href: 'https://symbols.openvibe.tools' },
++ ],
++ logo: [
++ { label: 'Title', href: 'https://title.openvibe.tools' },
++ { label: 'Wordmark', href: 'https://wordmark.openvibe.tools' },
++ { label: 'Badge', href: 'https://badge.openvibe.tools' },
++ { label: 'Thumbnail', href: 'https://thumbnail.openvibe.tools' },
++ ],
++ docs: [
++ { label: 'Merge', href: 'https://mergepdf.openvibe.tools' },
++ { label: 'Split', href: 'https://splitpdf.openvibe.tools' },
++ { label: 'Compress', href: 'https://compresspdf.openvibe.tools' },
++ { label: 'Images→PDF', href: 'https://image2pdf.openvibe.tools' },
++ ],
++ net: [
++ { label: 'Lookup', href: 'https://lookup.openvibe.tools' },
++ { label: 'My IP', href: 'https://myip.openvibe.tools' },
++ { label: 'DNS', href: 'https://dns.openvibe.tools' },
++ { label: 'Ping', href: 'https://ping.openvibe.tools' },
++ { label: 'SSL', href: 'https://ssl.openvibe.tools' },
++ ],
++ dev: [
++ { label: 'JSON', href: 'https://json.openvibe.tools' },
++ { label: 'Base64', href: 'https://base64.openvibe.tools' },
++ { label: 'JWT', href: 'https://jwt.openvibe.tools' },
++ { label: 'Regex', href: 'https://regex.openvibe.tools' },
++ { label: 'Diff', href: 'https://diff.openvibe.tools' },
++ ],
++ };
++
++ function getAccounts() {
++ try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
++ }
++
++ // ─── SSO auth resolution ───────────────────────────────────
++ // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,
++ // and Domain=.openvibe.tools on the tools apex + every tool subdomain.
++ // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,
++ // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,
++ // img., yt., docs., food., …) do NOT mount /auth/* — they only read the
++ // shared cookie — so sign-in/out always goes through the gateway apex.
++ // Detection order: ov_token cookie → localStorage → page-provided token →
++ // app-specific session endpoint (opt-in via config.sessionUrl).
++ const TOOLS_APEX = 'openvibe.tools';
++ const TOKEN_KEY = 'ov_token';
++
++ function currentHost() {
++ return (typeof location !== 'undefined' && location.hostname) || '';
++ }
++
++ function onToolsDomain(host) {
++ const h = host !== undefined ? host : currentHost();
++ return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX);
++ }
++
++ function onNetworkDomain(host) {
++ const h = host !== undefined ? host : currentHost();
++ try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; }
++ }
++
++ function parseCookieToken(cookieStr) {
++ const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/);
++ if (!m || !m[1]) return null;
++ try { return decodeURIComponent(m[1]); } catch { return m[1]; }
++ }
++
++ function getCookieToken() {
++ if (typeof document === 'undefined') return null;
++ return parseCookieToken(document.cookie);
++ }
++
++ function getStoredToken() {
++ try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; }
++ }
++
++ function resolveToken() {
++ return getCookieToken() || getStoredToken() || _config.token || null;
++ }
++
++ /** Where "Sign In" goes on this host. */
++ function resolveLoginHref(host, returnUrl) {
++ if (_config.loginUrl) return _config.loginUrl;
++ if (onToolsDomain(host)) {
++ // Every *.openvibe.tools host signs in through the gateway apex: it
++ // holds the OAuth state cookie + redirect_uri (both apex-host-only)
++ // and sets ov_token with Domain=.openvibe.tools, so the session
++ // reaches all satellites. A host-local /auth/login would 404 on
++ // satellites and break the OAuth state check on gateway subdomains.
++ return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`;
++ }
++ if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`;
++ return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`;
++ }
++
++ function setAuthCookie(token) {
++ if (typeof document === 'undefined') return;
++ const maxAge = 24 * 60 * 60; // matches the 24h access JWT
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`;
++ } else {
++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
++ document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
++ }
++ }
++
++ function clearAuthState() {
++ if (typeof document !== 'undefined') {
++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`;
++ }
++ }
++ try {
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.removeItem('openvibe_anon_token');
++ localStorage.removeItem('openvibe_active_account');
++ } catch { /* storage unavailable */ }
++ }
++
++ function persistToken(token) {
++ try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ }
++ setAuthCookie(token);
++ }
++
++ /** GET {apiBase}/api/auth/me with the Bearer token. */
++ async function fetchMe(token) {
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/me`, {
++ headers: { Authorization: `Bearer ${token}` },
++ });
++ if (res.ok) {
++ const data = await res.json();
++ return { ok: true, user: (data && (data.user || data)) || null };
++ }
++ return { ok: false, unauthorized: res.status === 401 || res.status === 403 };
++ } catch {
++ return { ok: false, unauthorized: false };
++ }
++ }
++
++ /** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */
++ async function gatewayRefresh() {
++ try {
++ const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' });
++ if (!res.ok) return null;
++ const data = await res.json();
++ return data && data.token ? data : null;
++ } catch { return null; }
++ }
++
++ /** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */
++ async function networkRefresh(token) {
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${token}` },
++ });
++ if (!res.ok) return null;
++ const data = await res.json();
++ return data && data.token ? data : null;
++ } catch { return null; }
++ }
++
++ /**
++ * Resolve the signed-in user from the shared SSO state.
++ * With a token: validate it against the Network. If it is rejected, make
++ * ONE refresh attempt (same-origin gateway refresh first on tools hosts,
++ * then the Network's grace refresh) before giving up — never leave a
++ * stale "Sign In" when a recoverable session exists.
++ */
++ async function resolveSessionUser() {
++ const token = resolveToken();
++ if (token) {
++ const me = await fetchMe(token);
++ if (me.ok && me.user) return { user: me.user, token };
++ if (me.unauthorized) {
++ if (onToolsDomain()) {
++ const r = await gatewayRefresh();
++ if (r) {
++ const user = r.user || (await fetchMe(r.token)).user;
++ if (user) { persistToken(r.token); return { user, token: r.token }; }
++ }
++ }
++ const n = await networkRefresh(token);
++ if (n) {
++ const user = n.user || (await fetchMe(n.token)).user;
++ if (user) { persistToken(n.token); return { user, token: n.token }; }
++ }
++ }
++ return null;
++ }
++ // No token anywhere — ask the page's own session endpoint if it has one
++ if (_config.sessionUrl) {
++ try {
++ const res = await fetch(_config.sessionUrl, { credentials: 'include' });
++ if (res.ok) {
++ const data = await res.json();
++ const user = (data && (data.user || data)) || null;
++ if (user && (user.username || user.id)) return { user, token: null };
++ }
++ } catch { /* signed out */ }
++ }
++ return null;
++ }
++
++ let _authInFlight = null;
++
++ function ssoHint() {
++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
++ if (m) return m[1];
++ try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }
++ }
++
++ /**
++ * One silent sign-in attempt per tab: only when this browser has signed in to the network
++ * before (the hint survives token expiry), never after an explicit sign-out ('guest'), and
++ * never for bots. The site's login route turns silent=1 into prompt=none and comes straight
++ * back on error=login_required, so a signed-out visitor sees one quick redirect at most.
++ */
++ function maybeSilentLogin() {
++ if (!_config.silentLogin || typeof location === 'undefined') return false;
++ if (ssoHint() !== 'account') return false;
++ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
++ try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }
++ if (/[?&]sso=none\b/.test(location.search)) return false;
++ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
++ location.replace(url);
++ return true;
++ }
++
++ function recordHistory() {
++ const h = _config.history;
++ if (!h || !_config.user || _config.user.is_anon) return;
++ const rec = Object.assign({ url: location.href, title: document.title, service: resolveBrand().tld }, h);
++ const H = root.OpenVibeHistory;
++ if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
++ if (!document.getElementById('ov-history-loader')) {
++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
++ sc.src = `${_config.apiBase}/shared/history.js`;
++ sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
++ document.head.appendChild(sc);
++ } else {
++ document.getElementById('ov-history-loader').addEventListener('load', () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } });
++ }
++ }
++
++ function refreshAuthState() {
++ if (_authInFlight) return _authInFlight;
++ _authInFlight = resolveSessionUser()
++ .then((session) => {
++ _authInFlight = null;
++ if (session && session.user) {
++ _config.user = session.user;
++ if (session.token) _config.token = session.token;
++ render();
++ recordHistory();
++ } else {
++ maybeSilentLogin();
++ }
++ try {
++ document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
++ detail: { user: session ? session.user : null, token: session ? session.token : null },
++ }));
++ } catch { /* non-DOM environment */ }
++ return session ? session.user : null;
++ })
++ .catch(() => { _authInFlight = null; return null; });
++ return _authInFlight;
++ }
++
++ function escapeAttr(value) {
++ return String(value ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
++ }
++
++ function getAvatarInitial(user) {
++ const source = user?.display_name || user?.username || 'O';
++ return String(source).trim().charAt(0).toUpperCase() || 'O';
++ }
++
++ function makeAvatarPlaceholder(user, size = 64) {
++ const initial = getAvatarInitial(user);
++ const bg = user?.profile_color || '#8b5cf6';
++ const svg = `
++ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
++ <rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/>
++ <text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="${Math.round(size * 0.42)}" font-weight="700" fill="#ffffff">${initial}</text>
++ </svg>`;
++ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;
++ }
++
++ function avatarSrc(user, size = 64) {
++ return user?.avatar_url || makeAvatarPlaceholder(user, size);
++ }
++
++ function avatarImg(user, size = 64, className = 'openvibe-navbar-avatar', id = '') {
++ const fallback = makeAvatarPlaceholder(user, size);
++ const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
++ const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');
++ return `<img class="${escapeAttr(className)}" src="${escapeAttr(avatarSrc(user, size))}" data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;
++ }
++
++ function attachAvatarFallbacks(rootEl) {
++ rootEl?.querySelectorAll('img[data-fallback-src]').forEach((img) => {
++ img.addEventListener('error', () => {
++ const fallback = img.dataset.fallbackSrc;
++ if (fallback && img.src !== fallback) {
++ img.src = fallback;
++ }
++ }, { once: true });
++ });
++ }
++
++ /**
++ * "Recently used" rows in the dropdown: the last few things this account touched anywhere
++ * on the network, from the shared history module (loaded lazily from the Network).
++ */
++ function renderRecent(el) {
++ if (!el || !_config.user || _config.user.is_anon) return;
++ const draw = (items) => {
++ if (!items || !items.length) { el.hidden = true; return; }
++ el.hidden = false;
++ el.innerHTML = `<div class="label"><span>Recently used</span><a href="https://openvibe.network/my#history">All history</a></div>` +
++ items.slice(0, 4).map(h => `<a class="item" href="${escapeAttr(h.url)}"><span class="icon"><i class="fa-solid ${escapeAttr(h.icon || 'fa-clock-rotate-left')}"></i></span><span class="t">${escapeAttr(h.title || h.url)}</span><span class="s">${escapeAttr(h.service_label || h.service || '')}</span></a>`).join('');
++ };
++ const H = root.OpenVibeHistory;
++ if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token }).then(draw).catch(() => draw(null)); return; }
++ if (document.getElementById('ov-history-loader')) return;
++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
++ sc.src = `${_config.apiBase}/shared/history.js`;
++ sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token }).then(draw).catch(() => draw(null)); } catch { /* */ } };
++ document.head.appendChild(sc);
++ }
++
++ function render() {
++ if (_navEl) _navEl.remove();
++
++ const nav = document.createElement('nav');
++ nav.className = 'openvibe-navbar';
++ const svc = _config.service;
++
++ const brand = resolveBrand();
++ nav.setAttribute('data-compact', _config.compact || 'auto');
++ nav.setAttribute('data-service', svc);
++ const links = currentLinks();
++
++ const u = _config.user;
++ const accounts = getAccounts();
++ const isAnon = u && u.is_anon;
++ const loginHref = resolveLoginHref(currentHost(), window.location.href);
++ const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
++
++ // The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds).
++ if (!window.__ovMark && !document.getElementById('ov-mark-loader')) {
++ try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = 'https://openvibe.network/shared/ov-mark.js'; sc.async = true; document.head.appendChild(sc); } catch { /* */ }
++ }
++ nav.innerHTML = `
++ ${brandHTML(brand)}
++ <div class="openvibe-navbar-links">
++ ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
++ ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
++ </div>
++ <div class="openvibe-navbar-spacer"></div>
++ <div class="openvibe-navbar-right">
++ <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><span class="ov-mark" data-size="16" data-static="1"></span> OpenVibe</a>
++ <div id="openvibe-bell-mount"></div>
++ ${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
++ `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
++ </div>
++ `;
++
++ // Dropdown
++ if (u) {
++ const dropdown = document.createElement('div');
++ dropdown.className = 'openvibe-navbar-dropdown';
++ dropdown.id = 'openvibe-user-dropdown';
++
++ const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
++ const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);
++ const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);
++
++ dropdown.innerHTML = `
++ <div class="openvibe-navbar-dropdown-header">
++ ${avatarImg(u, 72, '', '')}
++ <div class="info">
++ <div class="name">${u.display_name || u.username}</div>
++ <div class="email">${u.email || `@${u.username}`}</div>
++ ${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
++ </div>
++ </div>
++ <div class="openvibe-navbar-dropdown-accounts">
++ ${otherAccounts.map(a => `
++ <div class="account-item" data-account-id="${a.id}">
++ ${avatarImg(a, 48, '', '')}
++ <span>${a.display_name || a.username}${a.is_anon ? ' (anon)' : ''}</span>
++ </div>
++ `).join('')}
++ <div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">
++ <span style="width:24px;text-align:center"><i class="fa-solid fa-user-secret"></i></span>
++ <span>Switch to Anonymous</span>
++ </div>
++ <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">
++ <span style="width:24px;text-align:center"><i class="fa-solid fa-plus"></i></span>
++ <span>Add another account</span>
++ </a>
++ </div>
++ <div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div>
++ <div class="openvibe-navbar-dropdown-menu">
++ ${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}
++ <a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>
++ <a href="https://openvibe.network/my#history"><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History</a>
++ <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notifications</a>
++ <a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>
++ <a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>
++ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}
++ ${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
++ <div class="sep"></div>
++ <button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
++ </div>
++ `;
++ nav.appendChild(dropdown);
++ bindMenuItems(dropdown, before.concat(after));
++ if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
++
++ // Avatar click toggles dropdown
++ nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
++ dropdown.classList.toggle('open');
++ });
++
++ // Close on outside click
++ document.addEventListener('click', e => {
++ if (!nav.contains(e.target)) dropdown.classList.remove('open');
++ });
++
++ // Account switching
++ dropdown.querySelectorAll('[data-account-id]').forEach(el => {
++ el.addEventListener('click', () => {
++ const id = el.dataset.accountId;
++ document.dispatchEvent(new CustomEvent('openvibe-switch-account', { detail: { accountId: id } }));
++ dropdown.classList.remove('open');
++ });
++ });
++
++ dropdown.querySelector('#openvibe-logout-btn')?.addEventListener('click', () => {
++ dropdown.classList.remove('open');
++ if (_config.onLogout) _config.onLogout();
++ else {
++ clearAuthState();
++ _config.user = null;
++ _config.token = null;
++ if (onToolsDomain()) {
++ // The gateway also clears the Domain=.openvibe.tools cookie
++ // and the httpOnly refresh cookie, then sends us back here.
++ window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;
++ } else {
++ window.location.reload();
++ }
++ }
++ });
++ } else {
++ nav.querySelector('#openvibe-login-btn')?.addEventListener('click', (event) => {
++ if (!_config.onLogin) return;
++ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
++ event.preventDefault();
++ _config.onLogin();
++ });
++ }
++
++ // Insert into page — use navbar-mount placeholder if available, otherwise prepend to body
++ const mount = document.getElementById('navbar-mount');
++ if (mount) {
++ mount.appendChild(nav);
++ } else {
++ document.body.prepend(nav);
++ }
++ _navEl = nav;
++ attachAvatarFallbacks(nav);
++ return nav;
++ }
++
++ const OpenVibeNavbar = {
++ init(opts = {}) {
++ Object.assign(_config, opts);
++ injectStyles();
++ const el = render();
++ // Pages that hand us a resolved user (openvibe.network, the tools
++ // gateway hub) keep full control. Everyone else — pages that pass
++ // only a token, or nothing at all — gets the user resolved from
++ // the shared SSO state (ov_token cookie / localStorage / optional
++ // sessionUrl) and the navbar re-renders when it arrives.
++ if (!_config.user) refreshAuthState();
++ else recordHistory();
++ return el;
++ },
++
++ /** Record something the signed-in user did here ({type, title, url, icon}) in their network history. */
++ record(entry) { const h = _config.history; _config.history = Object.assign({}, h || {}, entry || {}); recordHistory(); _config.history = h; },
++
++ /** Re-resolve the signed-in user from the shared SSO state. */
++ refreshAuth() { return refreshAuthState(); },
++
++ /** Update user (after account switch). */
++ setUser(user) {
++ _config.user = user;
++ render();
++ },
++
++ /** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */
++ setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
++
++ /** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
++ addMenuItem(item) {
++ if (!item || !item.label) return;
++ const it = Object.assign({ id: item.id || `mi-${Math.random().toString(36).slice(2, 8)}` }, item);
++ (it.position === 'before' ? _runtimeMenu.before : _runtimeMenu.after).push(it);
++ if (_navEl && _config.user) render();
++ return it.id;
++ },
++
++ removeMenuItem(id) {
++ for (const k of ['before', 'after']) _runtimeMenu[k] = _runtimeMenu[k].filter(i => i.id !== id);
++ if (_navEl && _config.user) render();
++ },
++
++ /** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
++ brand() { return resolveBrand(); },
++
++ setToken(token) { _config.token = token; },
++
++ /** Get the bell mount point for OpenVibeNotifications. */
++ getBellMount() {
++ return _navEl?.querySelector('#openvibe-bell-mount') || null;
++ },
++
++ getElement() { return _navEl; },
++
++ destroy() {
++ _navEl?.remove();
++ _navEl = null;
++ },
++
++ /** Internal auth helpers — exposed for tests, not a public API. */
++ _auth: {
++ parseCookieToken,
++ onToolsDomain,
++ onNetworkDomain,
++ resolveLoginHref,
++ resolveToken,
++ resolveSessionUser,
++ clearAuthState,
++ persistToken,
++ },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
++ else root.OpenVibeNavbar = OpenVibeNavbar;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
ddiff --git a/vendor/openvibe-shared/notification-ui.js b/vendor/openvibe-shared/notification-ui.js
nnew file mode 100644
iindex 0000000..5ce5f21
---- /dev/null
++++ b/vendor/openvibe-shared/notification-ui.js
@@@ -0,0 +1,676 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Notification UI Client (shared across every OpenVibe site)
++//
++// One store (openvibe.network) → one bell everywhere. This file renders the bell +
++// dropdown panel, toasts, a full-page inbox, and handles Web Push subscription.
++//
++// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
++// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })
++// OpenVibeNotifications.createBell(mountEl)
++// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox
++//
++// Reads: GET /api/notifications?limit&offset&unread_only&category&q&since
++// GET /api/notifications/unread-count
++// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}
++// ═══════════════════════════════════════════════════════════════
++
++(function (root) {
++ 'use strict';
++
++ const POLL_INTERVAL = 15_000;
++ const PAGE_SIZE = 30;
++ const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = sticky
++ const MAX_TOASTS = 3;
++ const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
++ const CATEGORY_LABELS = {
++ social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams', economy: 'Economy',
++ achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements',
++ };
++ const SERVICE_LABELS = { live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network' };
++
++ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
++ let _pollTimer = null;
++ let _lastSeenAt = null; // newest created_at we have toasted / seen
++ let _unreadCount = 0;
++ let _toastContainer = null;
++ let _panelEl = null;
++ let _bellEl = null;
++ let _audioCache = {};
++ let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
++ let _channel = null; // BroadcastChannel for same-origin tab sync
++ const _views = new Map(); // container → view state (panel + inboxes)
++
++ // ── Styles ──────────────────────────────────────────────
++ function injectStyles() {
++ if (document.getElementById('openvibe-notif-styles')) return;
++ const style = document.createElement('style');
++ style.id = 'openvibe-notif-styles';
++ style.textContent = `
++ @keyframes openvibe-toast-in { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
++ @keyframes openvibe-toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }
++ @keyframes openvibe-badge-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } }
++ @keyframes openvibe-bell-ring { 0%{ transform: rotate(0); } 10%{ transform: rotate(14deg); } 20%{ transform: rotate(-14deg); } 30%{ transform: rotate(10deg); } 40%{ transform: rotate(-6deg); } 50%{ transform: rotate(0); } }
++ @keyframes openvibe-slide-down { from { transform: translateY(-10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
++ @keyframes openvibe-spin { to { transform: rotate(360deg); } }
++
++ .openvibe-toast-container { position: fixed; top: 82px; right: 14px; z-index: 100000; display: flex; flex-direction: column; gap: 6px; pointer-events: none; max-width: 340px; width: 100%; }
++ .openvibe-toast { pointer-events: all; background: color-mix(in srgb, var(--bg-card, #22222c) 94%, transparent); border: 1px solid color-mix(in srgb, var(--border, #333340) 82%, transparent); border-radius: 10px; padding: 11px 12px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 10px 24px rgba(0,0,0,0.28); animation: openvibe-toast-in .24s cubic-bezier(.34,1.56,.64,1); cursor: pointer; position: relative; overflow: hidden; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); transition: border-color .2s; }
++ .openvibe-toast:hover { border-color: var(--accent, #8b5cf6); }
++ .openvibe-toast.removing { animation: openvibe-toast-out .3s ease forwards; }
++ .openvibe-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; margin-top: 1px; }
++ .openvibe-toast-icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
++ .openvibe-toast-body { flex: 1; min-width: 0; }
++ .openvibe-toast-title { font-weight: 600; font-size: 12px; margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }
++ .openvibe-toast-title .service-badge { font-size: 9px; padding: 1px 6px; border-radius: 3px; background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); color: var(--accent-light, #a78bfa); font-weight: 500; }
++ .openvibe-toast-msg { font-size: 11px; color: var(--text-secondary, #b0b0b8); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
++ .openvibe-toast-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; padding: 2px; line-height: 1; }
++ .openvibe-toast-close:hover { color: var(--text-primary, #e0e0e0); }
++ .openvibe-toast-progress { position: absolute; bottom: 0; left: 0; height: 2px; background: var(--accent, #8b5cf6); }
++ .openvibe-toast.priority-high { border-left: 3px solid var(--warning, #f39c12); }
++ .openvibe-toast.priority-critical { border-left: 3px solid var(--live-red, #e74c3c); background: rgba(231,76,60,0.06); }
++ .openvibe-toast-actions { display: flex; gap: 6px; margin-top: 8px; }
++ .openvibe-toast-actions button { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border, #333340); background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); font-size: 11px; font-weight: 600; cursor: pointer; }
++ .openvibe-toast-actions button.primary { background: var(--accent, #8b5cf6); color: #fff; border-color: var(--accent, #8b5cf6); }
++
++ .openvibe-bell { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; }
++ .openvibe-bell svg { width: 22px; height: 22px; fill: var(--text-secondary, #b0b0b8); transition: fill .2s; }
++ .openvibe-bell:hover svg, .openvibe-bell.open svg { fill: var(--accent-light, #a78bfa); }
++ .openvibe-bell .badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; background: var(--live-red, #e74c3c); color: #fff; border-radius: 8px; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; pointer-events: none; animation: openvibe-badge-pulse .4s ease; }
++ .openvibe-bell .badge.hidden { display: none; }
++ .openvibe-bell.ringing svg { animation: openvibe-bell-ring .6s ease; }
++
++ .openvibe-notif-panel { position: fixed; top: 62px; right: 16px; width: 400px; max-height: calc(100vh - 86px); background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5)); z-index: 99999; display: none; flex-direction: column; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e0e0e0); animation: openvibe-slide-down .2s ease; overflow: hidden; }
++ .openvibe-notif-panel.open { display: flex; }
++ .openvibe-notif-inbox { display: flex; flex-direction: column; background: var(--bg-secondary, #252530); border: 1px solid var(--border, #333340); border-radius: 12px; overflow: hidden; color: var(--text-primary, #e0e0e0); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; min-height: 420px; max-height: 78vh; }
++
++ .openvibe-nv-head { padding: 12px 14px; border-bottom: 1px solid var(--border, #333340); display: flex; align-items: center; justify-content: space-between; gap: 8px; }
++ .openvibe-nv-head h3 { font-size: 15px; font-weight: 600; margin: 0; display: flex; align-items: center; gap: 8px; }
++ .openvibe-nv-head h3 .count { font-size: 11px; font-weight: 600; background: var(--live-red, #e74c3c); color: #fff; border-radius: 9px; padding: 1px 7px; }
++ .openvibe-nv-head h3 .count.zero { display: none; }
++ .openvibe-nv-actions { display: flex; gap: 4px; align-items: center; }
++ .openvibe-nv-actions button, .openvibe-nv-actions a { background: none; border: 1px solid transparent; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 5px 8px; border-radius: 6px; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; line-height: 1; }
++ .openvibe-nv-actions button:hover, .openvibe-nv-actions a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
++ .openvibe-nv-actions button.on { color: var(--accent-light, #a78bfa); border-color: color-mix(in srgb, var(--accent, #8b5cf6) 40%, transparent); }
++ .openvibe-nv-actions button:disabled { opacity: .45; cursor: default; }
++ .openvibe-nv-actions svg { width: 14px; height: 14px; fill: currentColor; }
++
++ .openvibe-nv-tools { padding: 8px 12px; border-bottom: 1px solid var(--border, #333340); display: flex; flex-direction: column; gap: 8px; }
++ .openvibe-nv-search { position: relative; }
++ .openvibe-nv-search input { width: 100%; box-sizing: border-box; padding: 7px 28px 7px 30px; border-radius: 8px; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-primary, #e0e0e0); font-size: 12px; outline: none; }
++ .openvibe-nv-search input:focus { border-color: var(--accent, #8b5cf6); }
++ .openvibe-nv-search svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; fill: var(--text-muted, #707080); pointer-events: none; }
++ .openvibe-nv-search .clear { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 14px; display: none; }
++ .openvibe-nv-search.has-q .clear { display: block; }
++ .openvibe-nv-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
++ .openvibe-nv-chips button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 10px; border-radius: 999px; cursor: pointer; line-height: 1.2; }
++ .openvibe-nv-chips button:hover { border-color: var(--accent, #8b5cf6); color: var(--text-primary, #e0e0e0); }
++ .openvibe-nv-chips button.active { background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); border-color: var(--accent, #8b5cf6); color: var(--accent-light, #a78bfa); }
++ .openvibe-nv-chips select { margin-left: auto; border: 1px solid var(--border, #333340); background: var(--bg-input, var(--bg-tertiary, #1e1e28)); color: var(--text-secondary, #b0b0b8); font-size: 11px; padding: 4px 8px; border-radius: 8px; cursor: pointer; }
++
++ .openvibe-nv-list { flex: 1; overflow-y: auto; padding: 4px 0; overscroll-behavior: contain; }
++ .openvibe-notif-item { padding: 10px 12px 10px 14px; display: flex; gap: 10px; align-items: flex-start; cursor: pointer; transition: background .15s; border-left: 3px solid transparent; position: relative; }
++ .openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }
++ .openvibe-notif-item.unread { background: color-mix(in srgb, var(--accent, #8b5cf6) 7%, transparent); border-left-color: var(--accent, #8b5cf6); }
++ .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; width: 32px; text-align: center; }
++ .openvibe-notif-item .icon img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block; }
++ .openvibe-notif-item .content { flex: 1; min-width: 0; }
++ .openvibe-notif-item .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; padding-right: 18px; }
++ .openvibe-notif-item.unread .title { font-weight: 650; }
++ .openvibe-notif-item .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
++ .openvibe-notif-item .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
++ .openvibe-notif-item .meta .tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }
++ .openvibe-notif-item .dismiss { position: absolute; top: 8px; right: 8px; background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; opacity: 0; transition: opacity .15s; }
++ .openvibe-notif-item:hover .dismiss { opacity: 1; }
++ .openvibe-notif-item .dismiss:hover { color: var(--live-red, #e74c3c); background: var(--bg-tertiary, #2a2a38); }
++ .openvibe-nv-empty { text-align: center; padding: 44px 24px; color: var(--text-muted, #707080); font-size: 13px; }
++ .openvibe-nv-empty .icon { font-size: 34px; margin-bottom: 8px; display: block; opacity: .5; }
++ .openvibe-nv-more { display: flex; justify-content: center; padding: 10px; }
++ .openvibe-nv-more button { border: 1px solid var(--border, #333340); background: transparent; color: var(--text-secondary, #b0b0b8); font-size: 12px; padding: 6px 14px; border-radius: 8px; cursor: pointer; }
++ .openvibe-nv-more .spinner { width: 16px; height: 16px; border: 2px solid var(--border, #333340); border-top-color: var(--accent, #8b5cf6); border-radius: 50%; animation: openvibe-spin .8s linear infinite; }
++ .openvibe-nv-foot { padding: 8px 12px; border-top: 1px solid var(--border, #333340); font-size: 11px; color: var(--text-muted, #707080); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
++ .openvibe-nv-foot a { color: var(--accent-light, #a78bfa); text-decoration: none; }
++ .openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }
++
++ @media (max-width: 460px) {
++ .openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
++ .openvibe-notif-panel { right: 6px; left: 6px; width: auto; top: 56px; max-height: calc(100vh - 66px); }
++ }
++ `;
++ document.head.appendChild(style);
++ }
++
++ // ── Helpers ─────────────────────────────────────────────
++ function esc(v) {
++ if (v == null) return '';
++ return String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++ }
++ function richOf(n) {
++ const rc = n && (n.rich_content ?? n.richContent);
++ if (!rc) return {};
++ if (typeof rc === 'object') return rc;
++ try { return JSON.parse(rc) || {}; } catch { return {}; }
++ }
++ function urlOf(n) { return (n && n.url) || richOf(n).url || null; }
++ function isRead(n) { return !!(n && (n.is_read === 1 || n.is_read === true || n.read === true)); }
++ function parseDate(v) {
++ if (!v) return null;
++ // SQLite "YYYY-MM-DD HH:MM:SS" is UTC without a zone marker.
++ const s = String(v);
++ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? s.replace(' ', 'T') + 'Z' : s);
++ return isNaN(d.getTime()) ? null : d;
++ }
++ function timeAgo(v) {
++ const d = parseDate(v); if (!d) return '';
++ const diff = (Date.now() - d.getTime()) / 1000;
++ if (diff < 45) return 'just now';
++ if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m ago`;
++ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
++ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
++ return d.toLocaleDateString();
++ }
++ function inboxUrl() {
++ if (_config.inboxUrl) return _config.inboxUrl;
++ try { return new URL('/notifications', _config.apiBase).toString(); } catch { return 'https://openvibe.network/notifications'; }
++ }
++ function iconHtml(n) {
++ const rich = richOf(n);
++ const img = n.sender_avatar || rich.thumbnail || (rich.user && rich.user.avatar_url);
++ if (img && /^https?:\/\//.test(String(img))) return `<img src="${esc(img)}" alt="" loading="lazy" onerror="this.replaceWith(document.createTextNode('${esc(n.icon || '🔔')}'))">`;
++ return esc(n.icon || '🔔');
++ }
++ function navigateTo(url) {
++ if (!url) return;
++ try {
++ const u = new URL(url, window.location.href);
++ window.location.href = u.toString();
++ } catch { window.location.href = url; }
++ }
++
++ // ── API ─────────────────────────────────────────────────
++ function isCrossOrigin() {
++ try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
++ }
++ function apiFetch(path, opts = {}) {
++ const headers = { ...(opts.headers || {}) };
++ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
++ if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
++ headers['Content-Type'] = 'application/json';
++ opts = { ...opts, body: JSON.stringify(opts.body) };
++ }
++ return fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: isCrossOrigin() ? 'omit' : 'include' });
++ }
++ async function apiJson(path, opts) {
++ const res = await apiFetch(path, opts);
++ let data = null; try { data = await res.json(); } catch { /* */ }
++ if (!res.ok) { const e = new Error((data && data.error) || `HTTP ${res.status}`); e.status = res.status; e.body = data; throw e; }
++ return data || {};
++ }
++ function hasSession() { return !!_config.token || !isCrossOrigin(); }
++
++ // ── Cross-tab sync (same origin) ────────────────────────
++ function channel() {
++ if (_channel !== null) return _channel || null;
++ try { _channel = new BroadcastChannel('openvibe-notifications'); _channel.onmessage = (e) => onSyncMessage(e.data || {}); }
++ catch { _channel = false; }
++ return _channel || null;
++ }
++ function broadcast(msg) { try { channel()?.postMessage(msg); } catch { /* */ } }
++ function onSyncMessage(msg) {
++ if (msg.type === 'unread') setBadge(msg.count, { quiet: true });
++ else if (msg.type === 'read') { for (const v of _views.values()) applyReadLocally(v, msg.ids || [], msg.all); }
++ }
++
++ // ── Sound ───────────────────────────────────────────────
++ function playSound(priority) {
++ if (!_preferences.sound) return;
++ const file = SOUNDS[priority]; if (!file) return;
++ const url = `${_config.soundBase}/${file}`;
++ try {
++ if (!_audioCache[url]) _audioCache[url] = new Audio(url);
++ const a = _audioCache[url]; a.currentTime = 0; a.volume = priority === 'critical' ? 0.8 : 0.5; a.play().catch(() => {});
++ } catch { /* */ }
++ }
++
++ // ── Toasts ──────────────────────────────────────────────
++ function ensureToastContainer() {
++ if (_toastContainer) return;
++ _toastContainer = document.createElement('div');
++ _toastContainer.className = 'openvibe-toast-container';
++ document.body.appendChild(_toastContainer);
++ }
++ function showToast(n) {
++ if (!_preferences.toasts) return;
++ if (_preferences.muted_categories.includes(n.category)) return;
++ ensureToastContainer();
++ while (_toastContainer.children.length >= MAX_TOASTS) _toastContainer.firstChild.remove();
++ const rich = richOf(n);
++ const toast = document.createElement('div');
++ toast.className = `openvibe-toast priority-${esc(n.priority || 'normal')}`;
++ toast.dataset.notifId = n.id || '';
++ const serviceLabel = n.service && n.service !== 'network' ? `<span class="service-badge">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
++ const actions = Array.isArray(rich.actions) ? rich.actions.filter(a => a && a.type !== 'input') : [];
++ toast.innerHTML = `
++ <div class="openvibe-toast-icon">${iconHtml(n)}</div>
++ <div class="openvibe-toast-body">
++ <div class="openvibe-toast-title">${esc(n.title)}${serviceLabel}</div>
++ <div class="openvibe-toast-msg">${esc(n.message || '')}</div>
++ ${actions.length ? `<div class="openvibe-toast-actions">${actions.map(a => `<button data-action="${esc(a.id)}" class="${a.style === 'primary' ? 'primary' : ''}">${esc(a.label)}</button>`).join('')}</div>` : ''}
++ </div>
++ <button class="openvibe-toast-close" aria-label="Dismiss">&times;</button>`;
++ const duration = TOAST_DURATION[n.priority] ?? 5000;
++ if (duration > 0) {
++ const bar = document.createElement('div');
++ bar.className = 'openvibe-toast-progress'; bar.style.width = '100%'; bar.style.transition = `width ${duration}ms linear`;
++ toast.appendChild(bar);
++ requestAnimationFrame(() => { bar.style.width = '0%'; });
++ setTimeout(() => dismissToast(toast), duration);
++ }
++ toast.querySelector('.openvibe-toast-close').addEventListener('click', (e) => { e.stopPropagation(); dismissToast(toast); });
++ toast.addEventListener('click', () => {
++ if (n.id) markRead([n.id]);
++ dismissToast(toast);
++ const u = urlOf(n); if (u) navigateTo(u);
++ });
++ toast.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', (e) => {
++ e.stopPropagation();
++ const a = actions.find(x => String(x.id) === btn.dataset.action);
++ if (a && a.url) { navigateTo(a.url); return; }
++ if (_config.onAction) _config.onAction(n.id, btn.dataset.action, n);
++ dismissToast(toast);
++ }));
++ _toastContainer.appendChild(toast);
++ }
++ function dismissToast(el) {
++ if (!el || !el.parentNode) return;
++ el.classList.add('removing');
++ setTimeout(() => el.remove(), 300);
++ }
++
++ // ── Bell / badge ────────────────────────────────────────
++ function createBellEl() {
++ const bell = document.createElement('div');
++ bell.className = 'openvibe-bell';
++ bell.setAttribute('role', 'button'); bell.setAttribute('aria-label', 'Notifications'); bell.tabIndex = 0;
++ bell.innerHTML = `<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg><span class="badge hidden">0</span>`;
++ bell.addEventListener('click', () => togglePanel());
++ bell.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(); } });
++ _bellEl = bell;
++ return bell;
++ }
++ function setBadge(count, { quiet = false } = {}) {
++ const prev = _unreadCount;
++ _unreadCount = Math.max(0, Number(count) || 0);
++ if (_bellEl) {
++ const badge = _bellEl.querySelector('.badge');
++ if (_unreadCount > 0) {
++ badge.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount);
++ badge.classList.remove('hidden');
++ if (!quiet && _unreadCount > prev) { _bellEl.classList.add('ringing'); setTimeout(() => _bellEl.classList.remove('ringing'), 600); }
++ } else badge.classList.add('hidden');
++ }
++ for (const v of _views.values()) {
++ const c = v.root.querySelector('.openvibe-nv-head .count');
++ if (c) { c.textContent = _unreadCount > 99 ? '99+' : String(_unreadCount); c.classList.toggle('zero', _unreadCount === 0); }
++ }
++ if (!quiet) broadcast({ type: 'unread', count: _unreadCount });
++ }
++
++ // ── List views (panel + inbox share this) ───────────────
++ const SVG = {
++ search: '<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>',
++ check: '<svg viewBox="0 0 24 24"><path d="M18 7l-1.41-1.41-6.34 6.34 1.41 1.41L18 7zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12-1.42-1.41zM.41 13.41L6 19l1.41-1.41L1.83 12 .41 13.41z"/></svg>',
++ bellOn: '<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>',
++ bellOff: '<svg viewBox="0 0 24 24"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31 0 0-.01 0-.01.01L18 14.68z"/></svg>',
++ gear: '<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>',
++ open: '<svg viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>',
++ };
++
++ function buildView(root, { mode }) {
++ const v = { root, mode, tab: 'all', category: '', q: '', items: [], offset: 0, hasMore: false, loading: false, seq: 0, ids: new Set() };
++ root.innerHTML = `
++ <div class="openvibe-nv-head">
++ <h3>Notifications <span class="count zero">0</span></h3>
++ <div class="openvibe-nv-actions">
++ <button type="button" data-act="read-all" title="Mark all as read">${SVG.check}<span>Mark all read</span></button>
++ <button type="button" data-act="push" title="Desktop / mobile push alerts on this device">${SVG.bellOff}</button>
++ ${mode === 'panel' ? `<a href="${esc(inboxUrl())}" data-act="inbox" title="Open inbox">${SVG.open}</a>` : `<a href="${esc(inboxUrl().replace(/\/notifications$/, '/notifications#prefs'))}" data-act="prefs" title="Notification preferences">${SVG.gear}</a>`}
++ </div>
++ </div>
++ <div class="openvibe-nv-note" data-role="note" style="display:none"></div>
++ <div class="openvibe-nv-tools">
++ <div class="openvibe-nv-search">${SVG.search}<input type="search" placeholder="Search notifications…" autocomplete="off" spellcheck="false"><button type="button" class="clear" aria-label="Clear search">&times;</button></div>
++ <div class="openvibe-nv-chips">
++ <button type="button" data-tab="all" class="active">All</button>
++ <button type="button" data-tab="unread">Unread</button>
++ <select data-role="category"><option value="">All categories</option>${Object.entries(CATEGORY_LABELS).map(([k, l]) => `<option value="${k}">${l}</option>`).join('')}</select>
++ </div>
++ </div>
++ <div class="openvibe-nv-list" data-role="list"></div>
++ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Open full inbox →</a>` : ''}</div>`;
++ const list = root.querySelector('[data-role="list"]');
++ const input = root.querySelector('input[type="search"]');
++ let qTimer = null;
++ input.addEventListener('input', () => {
++ root.querySelector('.openvibe-nv-search').classList.toggle('has-q', !!input.value);
++ clearTimeout(qTimer); qTimer = setTimeout(() => { v.q = input.value.trim(); reload(v); }, 260);
++ });
++ root.querySelector('.openvibe-nv-search .clear').addEventListener('click', () => { input.value = ''; v.q = ''; root.querySelector('.openvibe-nv-search').classList.remove('has-q'); reload(v); });
++ root.querySelectorAll('[data-tab]').forEach(btn => btn.addEventListener('click', () => {
++ root.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active')); btn.classList.add('active');
++ v.tab = btn.dataset.tab; reload(v);
++ }));
++ root.querySelector('[data-role="category"]').addEventListener('change', (e) => { v.category = e.target.value; reload(v); });
++ root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());
++ root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));
++ // Infinite scroll: load the next page when the user nears the bottom.
++ list.addEventListener('scroll', () => {
++ if (v.loading || !v.hasMore) return;
++ if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);
++ });
++ list.addEventListener('click', (e) => {
++ const dismissBtn = e.target.closest('.dismiss');
++ const item = e.target.closest('.openvibe-notif-item');
++ if (!item) return;
++ const id = item.dataset.id;
++ if (dismissBtn) { e.stopPropagation(); dismiss(v, id); return; }
++ const n = v.items.find(x => String(x.id) === id);
++ if (n && !isRead(n)) markRead([id]);
++ const u = urlOf(n);
++ if (u) navigateTo(u);
++ });
++ refreshPushButton(v);
++ _views.set(root, v);
++ return v;
++ }
++
++ function renderItems(v, { append = false } = {}) {
++ const list = v.root.querySelector('[data-role="list"]');
++ const html = v.items.map(n => {
++ try {
++ const cat = CATEGORY_LABELS[n.category] || n.category || '';
++ const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
++ return `<div class="openvibe-notif-item ${isRead(n) ? '' : 'unread'}" data-id="${esc(n.id)}" title="${esc(urlOf(n) ? 'Open' : '')}">
++ <span class="icon">${iconHtml(n)}</span>
++ <div class="content">
++ <div class="title">${esc(n.title)}</div>
++ ${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}
++ <div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>
++ </div>
++ <button type="button" class="dismiss" title="Dismiss" aria-label="Dismiss">&times;</button>
++ </div>`;
++ } catch { return ''; }
++ }).join('');
++ if (!append) list.innerHTML = '';
++ if (!v.items.length) {
++ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">${v.q ? '🔎' : '🔔'}</span>${v.q ? 'Nothing matches that search' : (v.tab === 'unread' ? 'You\'re all caught up' : 'No notifications yet')}</div>`;
++ } else {
++ const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();
++ list.insertAdjacentHTML('beforeend', html);
++ if (v.hasMore) list.insertAdjacentHTML('beforeend', `<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>`);
++ const more = list.querySelector('[data-act="more"]'); if (more) more.addEventListener('click', () => loadMore(v));
++ }
++ const st = v.root.querySelector('[data-role="status"]');
++ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';
++ }
++
++ async function fetchPage(v, offset) {
++ const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });
++ if (v.tab === 'unread') params.set('unread_only', '1');
++ if (v.category) params.set('category', v.category);
++ if (v.q) params.set('q', v.q);
++ return apiJson(`/api/notifications?${params}`);
++ }
++ async function reload(v) {
++ const seq = ++v.seq;
++ v.loading = true; v.offset = 0; v.items = []; v.ids = new Set(); v.hasMore = false; v.total = null;
++ const list = v.root.querySelector('[data-role="list"]');
++ list.innerHTML = '<div class="openvibe-nv-more"><span class="spinner"></span></div>';
++ try {
++ const data = await fetchPage(v, 0);
++ if (seq !== v.seq) return;
++ v.items = data.notifications || []; v.items.forEach(n => v.ids.add(String(n.id)));
++ v.hasMore = !!data.has_more; v.total = data.total ?? null; v.offset = v.items.length;
++ renderItems(v);
++ } catch (e) {
++ if (seq !== v.seq) return;
++ list.innerHTML = `<div class="openvibe-nv-empty"><span class="icon">⚠️</span>${e.status === 401 ? 'Sign in to see your notifications' : 'Could not load notifications'}</div>`;
++ } finally { if (seq === v.seq) v.loading = false; }
++ }
++ async function loadMore(v) {
++ if (v.loading || !v.hasMore) return;
++ const seq = v.seq; v.loading = true;
++ const more = v.root.querySelector('.openvibe-nv-more'); if (more) more.innerHTML = '<span class="spinner"></span>';
++ try {
++ const data = await fetchPage(v, v.offset);
++ if (seq !== v.seq) return;
++ const fresh = (data.notifications || []).filter(n => !v.ids.has(String(n.id)));
++ fresh.forEach(n => v.ids.add(String(n.id)));
++ v.items = v.items.concat(fresh); v.hasMore = !!data.has_more; v.offset += (data.notifications || []).length; v.total = data.total ?? v.total;
++ // Re-render only the tail to keep the scroll position.
++ const old = v.root.querySelector('.openvibe-nv-more'); if (old) old.remove();
++ if (fresh.length) renderItems({ ...v, items: fresh }, { append: true });
++ else if (v.hasMore) { const list = v.root.querySelector('[data-role="list"]'); list.insertAdjacentHTML('beforeend', '<div class="openvibe-nv-more"><button type="button" data-act="more">Load more</button></div>'); list.querySelector('[data-act="more"]').addEventListener('click', () => loadMore(v)); }
++ const st = v.root.querySelector('[data-role="status"]');
++ if (st) st.textContent = v.total != null ? `${v.items.length} of ${v.total}${v.tab === 'unread' ? ' unread' : ''}` : '';
++ } catch { /* keep what we have */ }
++ finally { v.loading = false; }
++ }
++ function applyReadLocally(v, ids, all) {
++ const set = new Set((ids || []).map(String));
++ for (const n of v.items) if (all || set.has(String(n.id))) n.is_read = 1;
++ v.root.querySelectorAll('.openvibe-notif-item.unread').forEach(el => { if (all || set.has(el.dataset.id)) el.classList.remove('unread'); });
++ }
++
++ // ── Mutations ───────────────────────────────────────────
++ async function markRead(ids) {
++ ids = (ids || []).map(String).filter(Boolean);
++ if (!ids.length) return;
++ for (const v of _views.values()) applyReadLocally(v, ids, false);
++ broadcast({ type: 'read', ids });
++ try {
++ const data = ids.length === 1
++ ? await apiJson(`/api/notifications/${encodeURIComponent(ids[0])}/read`, { method: 'POST' })
++ : await apiJson('/api/notifications/read-batch', { method: 'POST', body: { ids } });
++ if (data && typeof data.unread === 'number') setBadge(data.unread, { quiet: true }), broadcast({ type: 'unread', count: data.unread });
++ else pollUnread();
++ } catch { pollUnread(); }
++ }
++ async function markAllRead() {
++ for (const v of _views.values()) applyReadLocally(v, [], true);
++ setBadge(0);
++ broadcast({ type: 'read', all: true });
++ try { await apiJson('/api/notifications/read-all', { method: 'POST', body: {} }); } catch { pollUnread(); }
++ }
++ async function dismiss(v, id) {
++ const n = v.items.find(x => String(x.id) === String(id));
++ v.items = v.items.filter(x => String(x.id) !== String(id));
++ v.root.querySelector(`.openvibe-notif-item[data-id="${CSS.escape(String(id))}"]`)?.remove();
++ if (v.total != null) v.total = Math.max(0, v.total - 1);
++ if (!v.items.length) renderItems(v);
++ try {
++ await apiJson(`/api/notifications/${encodeURIComponent(id)}/dismiss`, { method: 'POST' });
++ if (n && !isRead(n)) setBadge(Math.max(0, _unreadCount - 1), { quiet: true });
++ } catch { /* */ }
++ }
++
++ // ── Polling / toasts ────────────────────────────────────
++ let _polling = false;
++ async function pollUnread() {
++ if (_polling || !hasSession()) return;
++ _polling = true;
++ try {
++ const data = await apiJson('/api/notifications/unread-count');
++ const prev = _unreadCount;
++ const count = Number(data.count) || 0;
++ setBadge(count);
++ if (count > prev && _lastSeenAt) {
++ // Something new arrived: fetch what's newer than the last thing we saw.
++ const params = new URLSearchParams({ unread_only: '1', limit: '5', since: _lastSeenAt });
++ const nd = await apiJson(`/api/notifications?${params}`);
++ const fresh = (nd.notifications || []).slice().reverse();
++ for (const n of fresh) { showToast(n); playSound(n.priority); }
++ // Any open list should reflect the new items too.
++ for (const v of _views.values()) if (v.mode === 'inbox' || (_panelEl && _panelEl.classList.contains('open'))) reload(v);
++ }
++ const newest = await newestCreatedAt();
++ if (newest) _lastSeenAt = newest;
++ } catch { /* offline / signed out — try again next tick */ }
++ finally { _polling = false; }
++ }
++ async function newestCreatedAt() {
++ try { const d = await apiJson('/api/notifications/newest'); const c = d.notification && d.notification.created_at; return c ? String(c) : null; } catch { return null; }
++ }
++ function startPolling() {
++ stopPolling();
++ if (!hasSession()) return;
++ newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });
++ _pollTimer = setInterval(pollUnread, POLL_INTERVAL);
++ }
++ function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }
++ document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });
++
++ // ── Panel ───────────────────────────────────────────────
++ function createPanel() {
++ const panel = document.createElement('div');
++ panel.className = 'openvibe-notif-panel';
++ document.body.appendChild(panel);
++ buildView(panel, { mode: 'panel' });
++ document.addEventListener('click', (e) => {
++ if (panel.classList.contains('open') && !panel.contains(e.target) && !(_bellEl && _bellEl.contains(e.target))) closePanel();
++ });
++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && panel.classList.contains('open')) closePanel(); });
++ _panelEl = panel;
++ return panel;
++ }
++ function closePanel() { _panelEl?.classList.remove('open'); _bellEl?.classList.remove('open'); }
++ function togglePanel() {
++ if (!_panelEl) createPanel();
++ const opening = !_panelEl.classList.contains('open');
++ _panelEl.classList.toggle('open', opening);
++ _bellEl?.classList.toggle('open', opening);
++ if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }
++ }
++
++ // ── Web Push ────────────────────────────────────────────
++ function pushSupported() { return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window && window.isSecureContext; }
++ function urlBase64ToUint8Array(b64) {
++ const padding = '='.repeat((4 - (b64.length % 4)) % 4);
++ const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');
++ const raw = window.atob(base64); const out = new Uint8Array(raw.length);
++ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
++ return out;
++ }
++ async function currentSubscription() {
++ if (!pushSupported()) return null;
++ try { const reg = await navigator.serviceWorker.getRegistration(_config.swPath); return reg ? await reg.pushManager.getSubscription() : null; } catch { return null; }
++ }
++ async function pushStatus() {
++ const supported = pushSupported();
++ const sub = supported ? await currentSubscription() : null;
++ return { supported, permission: supported ? Notification.permission : 'unsupported', subscribed: !!sub };
++ }
++ async function enablePush() {
++ if (!pushSupported()) throw new Error('This browser does not support push notifications');
++ if (!hasSession()) throw new Error('Sign in first');
++ const perm = await Notification.requestPermission();
++ if (perm !== 'granted') throw new Error(perm === 'denied' ? 'Notifications are blocked for this site in your browser settings' : 'Permission not granted');
++ const reg = await navigator.serviceWorker.register(_config.swPath, { scope: '/' });
++ await navigator.serviceWorker.ready;
++ const { publicKey } = await apiJson('/api/push/vapid-key');
++ if (!publicKey) throw new Error('Push is not configured on the server');
++ let sub = await reg.pushManager.getSubscription();
++ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });
++ await apiJson('/api/push/subscribe', { method: 'POST', body: { subscription: { ...sub.toJSON(), userAgent: navigator.userAgent.slice(0, 200) } } });
++ try { localStorage.setItem('ov_push_enabled', '1'); } catch { /* */ }
++ return true;
++ }
++ async function disablePush() {
++ const sub = await currentSubscription();
++ if (sub) {
++ try { await apiJson('/api/push/unsubscribe', { method: 'POST', body: { endpoint: sub.endpoint } }); } catch { /* */ }
++ try { await sub.unsubscribe(); } catch { /* */ }
++ }
++ try { localStorage.removeItem('ov_push_enabled'); } catch { /* */ }
++ return true;
++ }
++ async function refreshPushButton(v) {
++ const btn = v && v.root.querySelector('[data-act="push"]');
++ if (!btn) return;
++ const st = await pushStatus();
++ if (!st.supported) { btn.style.display = 'none'; return; }
++ btn.style.display = '';
++ btn.classList.toggle('on', st.subscribed);
++ btn.innerHTML = st.subscribed ? `${SVG.bellOn}<span>Push on</span>` : `${SVG.bellOff}<span>Enable push</span>`;
++ btn.title = st.subscribed ? 'Push alerts are on for this device — click to turn off' : (st.permission === 'denied' ? 'Notifications are blocked in your browser settings' : 'Get desktop / mobile alerts on this device even when the tab is closed');
++ btn.disabled = st.permission === 'denied' && !st.subscribed;
++ }
++ async function togglePush(v) {
++ const note = v.root.querySelector('[data-role="note"]');
++ const say = (msg, ms = 4000) => { if (!note) return; note.textContent = msg; note.style.display = ''; clearTimeout(note._t); note._t = setTimeout(() => { note.style.display = 'none'; }, ms); };
++ try {
++ const st = await pushStatus();
++ if (st.subscribed) { await disablePush(); say('Push alerts turned off for this device.'); }
++ else { await enablePush(); say('Push alerts enabled — you\'ll get alerts here even when this site is closed.'); }
++ } catch (e) { say(e.message || 'Could not change push setting', 6000); }
++ for (const view of _views.values()) refreshPushButton(view);
++ }
++
++ // ── Public API ──────────────────────────────────────────
++ const OpenVibeNotifications = {
++ init(opts = {}) {
++ Object.assign(_config, opts);
++ injectStyles();
++ if (opts.preferences) Object.assign(_preferences, opts.preferences);
++ channel();
++ startPolling();
++ // If the user enabled push on this device before, make sure the worker is registered.
++ try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }
++ },
++ destroy() {
++ stopPolling();
++ _toastContainer?.remove(); _panelEl?.remove();
++ _toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;
++ },
++ /** Create and inject the bell icon. Returns the DOM element. */
++ createBell(container) {
++ injectStyles();
++ const bell = createBellEl();
++ if (container) container.appendChild(bell);
++ pollUnread();
++ return bell;
++ },
++ /** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */
++ renderInbox(container, opts = {}) {
++ injectStyles();
++ if (!container) return null;
++ container.classList.add('openvibe-notif-inbox');
++ const v = buildView(container, { mode: 'inbox' });
++ if (opts.tab) { v.tab = opts.tab; container.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === opts.tab)); }
++ reload(v);
++ return v;
++ },
++ /** Programmatically show a toast (real-time events). Marks nothing on the server. */
++ push(notification) {
++ showToast(notification);
++ playSound(notification.priority);
++ if (!notification.id) return;
++ setBadge(_unreadCount + 1);
++ },
++ /** Re-fetch the badge and any open lists (call after an in-page action). */
++ refresh() { pollUnread(); for (const v of _views.values()) reload(v); },
++ markRead(ids) { return markRead(Array.isArray(ids) ? ids : [ids]); },
++ markAllRead,
++ setPreferences(prefs) { Object.assign(_preferences, prefs); },
++ setToken(token) {
++ _config.token = token;
++ if (token || !isCrossOrigin()) startPolling(); else { stopPolling(); setBadge(0, { quiet: true }); }
++ },
++ togglePanel, closePanel,
++ enablePush, disablePush, pushStatus,
++ get unreadCount() { return _unreadCount; },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;
++ else root.OpenVibeNotifications = OpenVibeNotifications;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
ddiff --git a/vendor/openvibe-shared/notifications.js b/vendor/openvibe-shared/notifications.js
nnew file mode 100644
iindex 0000000..24e15cc
---- /dev/null
++++ b/vendor/openvibe-shared/notifications.js
@@@ -0,0 +1,194 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Universal Notification System
++// Types, priorities, categories, and helpers shared across all
++// services. Both server-side (Node) and client-side (browser).
++// ═══════════════════════════════════════════════════════════════
++
++// ── Priority Levels ──────────────────────────────────────────
++// Determines toast style, sound, persistence, and email eligibility.
++const PRIORITY = Object.freeze({
++ LOW: 'low', // Silent, no toast, inbox only
++ NORMAL: 'normal', // Toast + badge, default sound
++ HIGH: 'high', // Sticky toast, alert sound, badge pulse
++ CRITICAL: 'critical', // Sticky + email-eligible, alarm sound, cannot dismiss silently
++});
++
++// ── Categories ───────────────────────────────────────────────
++// Users can configure per-category mute/sound/email preferences.
++const CATEGORY = Object.freeze({
++ SOCIAL: 'social', // Follows, friend requests, mentions, kisses
++ CHAT: 'chat', // DMs, @mentions, whispers
++ GAME: 'game', // Level-ups, loot, dungeon invites, canvas events
++ STREAM: 'stream', // Stream went live, raid, host, clip created
++ ECONOMY: 'economy', // Coins received, shop sales, trade offers
++ ACHIEVEMENT: 'achievement', // Badges, milestones, unlocks
++ MODERATION: 'moderation', // Warnings, bans, mutes, reports
++ SYSTEM: 'system', // Password changed, new login, security alerts
++ SERVICE: 'service', // Cross-service: linked accounts, SSO events
++ ADMIN: 'admin', // Admin broadcasts, maintenance notices
++});
++
++// ── Notification Types (expandable per-service) ──────────────
++// Each type maps to a category + default priority + template.
++const TYPES = Object.freeze({
++ // Social
++ FOLLOW: { category: 'social', priority: 'normal', icon: '👤', title: 'New Follower' },
++ MENTION: { category: 'social', priority: 'normal', icon: '@', title: 'Mentioned You' },
++ FRIEND_REQUEST: { category: 'social', priority: 'normal', icon: '🤝', title: 'Friend Request' },
++
++ // Chat
++ DIRECT_MESSAGE: { category: 'chat', priority: 'normal', icon: '💬', title: 'New Message' },
++ WHISPER: { category: 'chat', priority: 'normal', icon: '🔇', title: 'Whisper' },
++
++ // Game
++ LEVEL_UP: { category: 'game', priority: 'normal', icon: '⬆️', title: 'Level Up!' },
++ LOOT_DROP: { category: 'game', priority: 'normal', icon: '🎁', title: 'Loot Drop' },
++ DUNGEON_INVITE: { category: 'game', priority: 'high', icon: '🏰', title: 'Dungeon Invite' },
++ BATTLE_CHALLENGE: { category: 'game', priority: 'high', icon: '⚔️', title: 'Battle Challenge' },
++ CANVAS_OVERWRITE: { category: 'game', priority: 'low', icon: '🎨', title: 'Pixel Overwritten' },
++ ACHIEVEMENT_UNLOCK: { category: 'achievement', priority: 'normal', icon: '🏆', title: 'Achievement Unlocked' },
++
++ // Stream
++ STREAM_LIVE: { category: 'stream', priority: 'high', icon: '🔴', title: 'Stream Live' },
++ RAID: { category: 'stream', priority: 'high', icon: '🚀', title: 'Incoming Raid' },
++ CLIP_CREATED: { category: 'stream', priority: 'normal', icon: '🎬', title: 'Clip Created' },
++ VC_CALL_INVITE: { category: 'chat', priority: 'high', icon: '📞', title: 'Call Invite' },
++ CONTENT_COMMENT: { category: 'social', priority: 'normal', icon: '💬', title: 'New Comment' },
++ CONTENT_REPLY: { category: 'social', priority: 'normal', icon: '↩️', title: 'New Reply' },
++ TIP_RECEIVED: { category: 'economy', priority: 'normal', icon: '💜', title: 'Tip Received' },
++ SUBSCRIPTION: { category: 'economy', priority: 'normal', icon: '⭐', title: 'New Subscriber' },
++
++ // Economy
++ COINS_RECEIVED: { category: 'economy', priority: 'normal', icon: '🪙', title: 'Coins Received' },
++ TRADE_OFFER: { category: 'economy', priority: 'normal', icon: '🤝', title: 'Trade Offer' },
++
++ // Moderation
++ WARNING: { category: 'moderation', priority: 'high', icon: '⚠️', title: 'Warning' },
++ TIMEOUT: { category: 'moderation', priority: 'high', icon: '⏰', title: 'Timeout' },
++ BAN: { category: 'moderation', priority: 'critical', icon: '🚫', title: 'Account Banned' },
++
++ // System / Security
++ PASSWORD_CHANGED: { category: 'system', priority: 'critical', icon: '🔒', title: 'Password Changed' },
++ NEW_LOGIN: { category: 'system', priority: 'high', icon: '🔐', title: 'New Login Detected' },
++ EMAIL_VERIFIED: { category: 'system', priority: 'normal', icon: '✅', title: 'Email Verified' },
++ ACCOUNT_LINKED: { category: 'system', priority: 'normal', icon: '🔗', title: 'Account Linked' },
++
++ // Service / Cross-platform
++ WELCOME: { category: 'service', priority: 'normal', icon: '🔥', title: 'Welcome to OpenVibe' },
++ SERVICE_ANNOUNCEMENT:{ category: 'admin', priority: 'high', icon: '📢', title: 'Announcement' },
++ MAINTENANCE: { category: 'admin', priority: 'critical', icon: '🛠️', title: 'Scheduled Maintenance' },
++});
++
++// ── Sound Map ────────────────────────────────────────────────
++// Priority → sound file (relative to /assets/sounds/ on each service).
++const SOUNDS = Object.freeze({
++ low: null,
++ normal: 'notification.mp3',
++ high: 'notification-high.mp3',
++ critical: 'notification-alarm.mp3',
++});
++
++// ── Email Eligibility ────────────────────────────────────────
++// Categories that are email-worthy by default when the user has not
++// explicitly enabled email for a category-specific preference.
++const EMAIL_ELIGIBLE_CATEGORIES = new Set([
++ 'system', 'moderation', 'admin',
++]);
++
++// Types that email BY DEFAULT (no explicit per-category opt-in needed) — as long as the
++// account's address is verified and not bouncing. Go-live alerts are the headline case:
++// followers expect them in their inbox unless they turn the 'stream' category's email off.
++const EMAIL_DEFAULT_TYPES = new Set(['STREAM_LIVE']);
++
++function isEmailEligible(notification) {
++ return notification.priority === PRIORITY.CRITICAL
++ && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);
++}
++
++// ── Rich Content Schema ──────────────────────────────────────
++// Notifications can carry optional rich payloads.
++//
++// richContent: {
++// image?: string, // URL to preview image
++// thumbnail?: string, // Small avatar/icon URL
++// body?: string, // Markdown-safe extended body
++// url?: string, // Click-through URL
++// service?: string, // Origin service ID
++// actions?: [ // Interactive buttons/inputs
++// { id: string, label: string, style: 'primary'|'secondary'|'danger', url?: string },
++// { id: string, type: 'input', placeholder: string, submitLabel: string },
++// ],
++// user?: { // Linked user display info
++// id: number, username: string, display_name: string,
++// avatar_url: string, profile_color: string, role: string,
++// name_effect?: string, particle_effect?: string,
++// },
++// context?: object, // Arbitrary service-specific data
++// }
++
++// ── Notification Object Shape ────────────────────────────────
++// {
++// id: string, // UUID
++// type: string, // Key from TYPES
++// category: string, // From CATEGORY
++// priority: string, // From PRIORITY
++// title: string, // Short headline
++// message: string, // Plain-text body
++// icon: string, // Emoji or icon class
++// user_id: number, // Recipient
++// sender_id?: number, // Who triggered it (nullable)
++// service: string, // Origin service ('live', 'games', 'network')
++// richContent?: object, // See schema above
++// read: boolean,
++// dismissed: boolean,
++// emailed: boolean,
++// created_at: string, // ISO 8601
++// expires_at?: string, // Optional auto-expire
++// }
++
++function createNotification({ type, message, userId, senderId, service, richContent, expiresAt }) {
++ const typeDef = TYPES[type];
++ if (!typeDef) throw new Error(`Unknown notification type: ${type}`);
++
++ return {
++ type,
++ category: typeDef.category,
++ priority: typeDef.priority,
++ title: typeDef.title,
++ icon: typeDef.icon,
++ message: message || '',
++ user_id: userId,
++ sender_id: senderId || null,
++ service: service || 'network',
++ richContent: richContent || null,
++ read: false,
++ dismissed: false,
++ emailed: false,
++ expires_at: expiresAt || null,
++ };
++}
++
++// ── User Preferences Defaults ────────────────────────────────
++const DEFAULT_NOTIFICATION_PREFS = Object.freeze({
++ enabled: true,
++ sound: true,
++ toasts: true,
++ email_critical: true, // Email for CRITICAL only
++ muted_categories: [], // Categories the user has silenced
++ do_not_disturb: false,
++ dnd_schedule: null, // { start: '22:00', end: '08:00', timezone: 'America/Los_Angeles' }
++});
++
++module.exports = {
++ EMAIL_DEFAULT_TYPES,
++ PRIORITY,
++ CATEGORY,
++ TYPES,
++ SOUNDS,
++ EMAIL_ELIGIBLE_CATEGORIES,
++ isEmailEligible,
++ createNotification,
++ DEFAULT_NOTIFICATION_PREFS,
++};
ddiff --git a/vendor/openvibe-shared/openvibe-sw.js b/vendor/openvibe-shared/openvibe-sw.js
nnew file mode 100644
iindex 0000000..2de4f37
---- /dev/null
++++ b/vendor/openvibe-shared/openvibe-sw.js
@@@ -0,0 +1,41 @@
++/* OpenVibe — shared Web Push service worker.
++ * Served by every OpenVibe site at /openvibe-sw.js (must be same-origin, scope "/").
++ * Payload (from openvibe.network push-service): { title, body, icon, url, tag } */
++self.addEventListener('install', () => self.skipWaiting());
++self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
++
++self.addEventListener('push', (event) => {
++ let data = {};
++ try { data = event.data ? event.data.json() : {}; } catch { data = { title: 'OpenVibe', body: event.data ? event.data.text() : '' }; }
++ const title = data.title || 'OpenVibe';
++ const options = {
++ body: data.body || '',
++ icon: data.icon || 'https://openvibe.network/assets/logo-192.png',
++ badge: data.badge || 'https://openvibe.network/assets/logo-72.png',
++ tag: data.tag || 'openvibe',
++ renotify: !!data.renotify,
++ data: { url: data.url || 'https://openvibe.network/notifications', id: data.id || null },
++ requireInteraction: false,
++ };
++ event.waitUntil(self.registration.showNotification(title, options));
++});
++
++self.addEventListener('notificationclick', (event) => {
++ event.notification.close();
++ const url = (event.notification.data && event.notification.data.url) || 'https://openvibe.network/notifications';
++ event.waitUntil((async () => {
++ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
++ let target = null;
++ try { target = new URL(url); } catch { /* */ }
++ for (const c of all) {
++ try {
++ if (target && new URL(c.url).origin === target.origin && 'focus' in c) {
++ await c.focus();
++ if ('navigate' in c) { try { await c.navigate(url); } catch { /* cross-origin navigate blocked */ } }
++ return;
++ }
++ } catch { /* */ }
++ }
++ await self.clients.openWindow(url);
++ })());
++});
ddiff --git a/vendor/openvibe-shared/ov-mark.js b/vendor/openvibe-shared/ov-mark.js
nnew file mode 100644
iindex 0000000..2c018cf
---- /dev/null
++++ b/vendor/openvibe-shared/ov-mark.js
@@@ -0,0 +1,59 @@
++/**
++ * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.
++ * <span class="ov-mark"></span> → 32px animated mark
++ * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
++ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
++ * The O is a ring with a comet arc running around it, the V is drawn inside with a light
++ * sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.
++ */
++(function () {
++ 'use strict';
++ if (window.__ovMark) return; window.__ovMark = true;
++ let n = 0;
++ const CSS = `
++.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0}
++.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%;animation:ovmFloat 5s ease-in-out infinite}
++.ov-mark .g{fill:currentColor;opacity:.1;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}
++.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}
++.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}
++.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}
++.ov-mark .d{fill:#fff;transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
++.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}
++.ov-mark .o2{opacity:.5}
++.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
++.ov-mark:hover .c{animation-duration:.9s}
++.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}
++.ov-mark[data-static] .o{display:none}
++@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}
++@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
++@keyframes ovmComet{to{transform:rotate(360deg)}}
++@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}
++@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}
++@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}
++@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d{animation:none!important}.ov-mark .o{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;
++ function svg(id) {
++ return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
++<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
++<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
++<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
++<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
++<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
++<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
++<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
++ }
++ function mount(root) {
++ (root || document).querySelectorAll('.ov-mark:not([data-ov])').forEach(el => {
++ el.setAttribute('data-ov', '1');
++ const size = parseInt(el.getAttribute('data-size'), 10);
++ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }
++ el.innerHTML = svg('ovm' + (++n) + '_');
++ });
++ }
++ function init() {
++ if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
++ mount();
++ try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
++ }
++ window.ovMarkMount = mount;
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
++})();
ddiff --git a/vendor/openvibe-shared/package.json b/vendor/openvibe-shared/package.json
nnew file mode 100644
iindex 0000000..9f56460
---- /dev/null
++++ b/vendor/openvibe-shared/package.json
@@@ -0,0 +1,20 @@
++{
++ "name": "openvibe-shared",
++ "version": "1.0.0",
++ "description": "Shared auth, theme, and brand utilities for the OpenVibe",
++ "main": "index.js",
++ "exports": {
++ ".": "./index.js",
++ "./analytics": "./analytics.js",
++ "./auth-client": "./auth-client.js",
++ "./theme-sync": "./theme-sync.js",
++ "./brand": "./brand.js",
++ "./middleware": "./middleware.js",
++ "./notifications": "./notifications.js",
++ "./url-resolver": "./url-resolver.js"
++ },
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++}
ddiff --git a/vendor/openvibe-shared/scripts/build-theme-loader.js b/vendor/openvibe-shared/scripts/build-theme-loader.js
nnew file mode 100644
iindex 0000000..1e10ea1
---- /dev/null
++++ b/vendor/openvibe-shared/scripts/build-theme-loader.js
@@@ -0,0 +1,51 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Regenerates the built-in theme map inside theme-loader.js from builtin-themes.js.
++ *
++ * theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so
++ * it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:
++ *
++ * node packages/openvibe-shared/scripts/build-theme-loader.js # rewrite
++ * node packages/openvibe-shared/scripts/build-theme-loader.js --check # exit 1 if stale
++ *
++ * Every entry carries the FULL variable set (the theme's own values over the default theme's), so
++ * switching themes replaces every token — a theme that does not name --success no longer keeps the
++ * previous theme's --success on <html>.
++ */
++const fs = require('fs');
++const path = require('path');
++const { BUILTIN_THEMES, DEFAULT_VARS } = require('../builtin-themes');
++
++const LOADER = path.join(__dirname, '..', 'theme-loader.js');
++const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';
++const END = ' // THEMES:END';
++
++function render() {
++ const lines = BUILTIN_THEMES.map((t) => {
++ const vars = { ...DEFAULT_VARS, ...t.variables };
++ const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');
++ return ` '${t.id}': { ${body} },`;
++ });
++ return `${BEGIN}\n const THEMES = {\n${lines.join('\n')}\n };\n${END}`;
++}
++
++function currentBlock(src) {
++ const a = src.indexOf(BEGIN);
++ const b = src.indexOf(END);
++ if (a >= 0 && b > a) return { a, b: b + END.length };
++ // First run: the hand-written block.
++ const start = src.indexOf(' const THEMES = {');
++ if (start < 0) throw new Error('theme-loader.js: THEMES block not found');
++ const close = src.indexOf('\n };', start);
++ return { a: start, b: close + '\n };'.length };
++}
++
++const src = fs.readFileSync(LOADER, 'utf8');
++const { a, b } = currentBlock(src);
++const next = src.slice(0, a) + render() + src.slice(b);
++const check = process.argv.includes('--check');
++if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
++if (check) { console.error('theme-loader.js is stale: run node packages/openvibe-shared/scripts/build-theme-loader.js'); process.exit(1); }
++fs.writeFileSync(LOADER, next);
++console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);
ddiff --git a/vendor/openvibe-shared/test/navbar-auth.test.js b/vendor/openvibe-shared/test/navbar-auth.test.js
nnew file mode 100644
iindex 0000000..c6199fa
---- /dev/null
++++ b/vendor/openvibe-shared/test/navbar-auth.test.js
@@@ -0,0 +1,301 @@
++// ═══════════════════════════════════════════════════════════════
++// navbar.js — SSO auth resolution tests (plain node, no browser)
++// Covers: ov_token cookie parsing, host branching (network vs tools
++// apex/gateway/satellite), sign-in link resolution, and the async
++// user-resolution flow (me → gateway refresh → network grace refresh)
++// using stubbed document/location/localStorage/fetch globals.
++// ═══════════════════════════════════════════════════════════════
++
++'use strict';
++
++const assert = require('assert');
++const path = require('path');
++
++const NAVBAR_PATH = path.join(__dirname, '..', 'navbar.js');
++
++function stubElement() {
++ return {
++ style: {},
++ textContent: '',
++ className: '',
++ id: '',
++ innerHTML: '',
++ dataset: {},
++ classList: { add() {}, remove() {}, toggle() {} },
++ appendChild() {},
++ prepend() {},
++ remove() {},
++ addEventListener() {},
++ querySelector() { return stubElement(); },
++ querySelectorAll() { return []; },
++ };
++}
++
++/**
++ * Build a fresh browser-ish environment and load navbar.js into it.
++ * @param {Object} opts
++ * @param {string} opts.hostname - location.hostname
++ * @param {string} [opts.href] - location.href
++ * @param {string} [opts.cookie] - document.cookie contents
++ * @param {Object} [opts.storage] - initial localStorage entries
++ * @param {Function} [opts.fetch] - fetch stub
++ */
++function loadNavbar(opts) {
++ const calls = [];
++ global.location = {
++ hostname: opts.hostname,
++ href: opts.href || `https://${opts.hostname}/`,
++ protocol: 'https:',
++ };
++ global.window = { location: global.location };
++ global.document = {
++ cookie: opts.cookie || '',
++ head: stubElement(),
++ body: stubElement(),
++ getElementById: () => null,
++ createElement: () => stubElement(),
++ addEventListener() {},
++ removeEventListener() {},
++ dispatchEvent() {},
++ };
++ global.CustomEvent = class CustomEvent {
++ constructor(type, init) { this.type = type; this.detail = init && init.detail; }
++ };
++ const store = { ...(opts.storage || {}) };
++ global.localStorage = {
++ getItem: (k) => (k in store ? store[k] : null),
++ setItem: (k, v) => { store[k] = String(v); },
++ removeItem: (k) => { delete store[k]; },
++ _store: store,
++ };
++ global.fetch = async (url, init) => {
++ calls.push({ url, init: init || {} });
++ if (opts.fetch) return opts.fetch(url, init || {});
++ return { ok: false, status: 404, json: async () => ({}) };
++ };
++
++ delete require.cache[require.resolve(NAVBAR_PATH)];
++ const navbar = require(NAVBAR_PATH);
++ return { navbar, calls, storage: global.localStorage };
++}
++
++function jsonResponse(status, body) {
++ return { ok: status >= 200 && status < 300, status, json: async () => body };
++}
++
++const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null, role: 'user' };
++
++(async () => {
++ // ── 1. Cookie parsing ────────────────────────────────────
++ {
++ const { navbar } = loadNavbar({ hostname: 'openvibe.network' });
++ const p = navbar._auth.parseCookieToken;
++ assert.strictEqual(p('ov_token=abc123'), 'abc123');
++ assert.strictEqual(p('theme=dark; ov_token=abc123; other=1'), 'abc123');
++ assert.strictEqual(p('prefix_ov_token=nope; ov_token=real'), 'real');
++ assert.strictEqual(p('ov_token=a%2Fb'), 'a/b');
++ assert.strictEqual(p('ov_token='), null);
++ assert.strictEqual(p('other=1'), null);
++ assert.strictEqual(p(''), null);
++ }
++
++ // ── 2. Host branching ────────────────────────────────────
++ {
++ const { navbar } = loadNavbar({ hostname: 'openvibe.network' });
++ const a = navbar._auth;
++ assert.strictEqual(a.onToolsDomain('openvibe.tools'), true);
++ assert.strictEqual(a.onToolsDomain('net.openvibe.tools'), true);
++ assert.strictEqual(a.onToolsDomain('maps.openvibe.tools'), true);
++ assert.strictEqual(a.onToolsDomain('mergepdf.openvibe.tools'), true);
++ assert.strictEqual(a.onToolsDomain('openvibe.network'), false);
++ assert.strictEqual(a.onToolsDomain('openvibe.live'), false);
++ assert.strictEqual(a.onToolsDomain('evilopenvibe.tools'), false);
++ assert.strictEqual(a.onNetworkDomain('openvibe.network'), true);
++ assert.strictEqual(a.onNetworkDomain('net.openvibe.tools'), false);
++ }
++
++ // ── 3. Sign-in link resolution ───────────────────────────
++ {
++ const { navbar } = loadNavbar({ hostname: 'openvibe.network' });
++ const a = navbar._auth;
++ const ret = 'https://net.openvibe.tools/?q=8.8.8.8';
++ const enc = encodeURIComponent(ret);
++ // Any tools host — gateway subdomain, satellite, or apex — signs in
++ // through the gateway apex (sets the Domain=.openvibe.tools cookie).
++ assert.strictEqual(a.resolveLoginHref('net.openvibe.tools', ret), `https://openvibe.tools/auth/login?next=${enc}`);
++ assert.strictEqual(a.resolveLoginHref('maps.openvibe.tools', ret), `https://openvibe.tools/auth/login?next=${enc}`);
++ assert.strictEqual(a.resolveLoginHref('openvibe.tools', ret), `https://openvibe.tools/auth/login?next=${enc}`);
++ // Network host uses its own login page.
++ assert.strictEqual(a.resolveLoginHref('openvibe.network', ret), `/login?return=${enc}`);
++ // Anything else (live, games, localhost) keeps the legacy behavior.
++ assert.strictEqual(a.resolveLoginHref('openvibe.live', ret), `https://openvibe.network/login?return=${enc}`);
++ assert.strictEqual(a.resolveLoginHref('localhost', ret), `https://openvibe.network/login?return=${enc}`);
++ }
++
++ // ── 4. loginUrl override hook ────────────────────────────
++ {
++ const { navbar } = loadNavbar({ hostname: 'maps.openvibe.tools' });
++ navbar.init({ service: 'maps', loginUrl: '/custom-login' });
++ assert.strictEqual(navbar._auth.resolveLoginHref('maps.openvibe.tools', 'https://x/'), '/custom-login');
++ }
++
++ // ── 5. Valid cookie token → user chip (the reported bug) ─
++ {
++ const { navbar, calls } = loadNavbar({
++ hostname: 'net.openvibe.tools',
++ href: 'https://net.openvibe.tools/?q=8.8.8.8',
++ cookie: 'ov_token=valid-jwt',
++ fetch: async (url, init) => {
++ if (url === 'https://openvibe.network/api/auth/me') {
++ assert.strictEqual(init.headers.Authorization, 'Bearer valid-jwt');
++ return jsonResponse(200, { user: USER, preferences: { theme_id: 'vibe' } });
++ }
++ return jsonResponse(404, {});
++ },
++ });
++ // Mirrors net.html: token passed, user NOT passed.
++ navbar.init({ service: 'net', apiBase: 'https://openvibe.network', token: 'valid-jwt' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ assert.ok(navbar.getElement().innerHTML.includes('openvibe-navbar-avatar'), 'renders the avatar chip');
++ assert.ok(!navbar.getElement().innerHTML.includes('openvibe-navbar-login'), 'no Sign In button');
++ assert.strictEqual(calls.length, 1, 'exactly one auth call');
++ }
++
++ // ── 6. No token, no sessionUrl → signed out, zero fetches ─
++ {
++ const { navbar, calls } = loadNavbar({ hostname: 'maps.openvibe.tools' });
++ navbar.init({ service: 'maps', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user, null);
++ assert.strictEqual(calls.length, 0, 'anonymous visits make no auth traffic');
++ const html = navbar.getElement().innerHTML;
++ assert.ok(html.includes('https://openvibe.tools/auth/login?next='), 'Sign In goes through the gateway apex');
++ }
++
++ // ── 7. Expired token on a gateway host → same-origin refresh ─
++ {
++ const { navbar, storage } = loadNavbar({
++ hostname: 'openvibe.tools',
++ cookie: 'ov_token=expired-jwt',
++ fetch: async (url, init) => {
++ if (url === 'https://openvibe.network/api/auth/me') {
++ return init.headers.Authorization === 'Bearer fresh-jwt'
++ ? jsonResponse(200, { user: USER })
++ : jsonResponse(401, { error: 'Invalid or expired token' });
++ }
++ if (url === '/auth/refresh') return jsonResponse(200, { token: 'fresh-jwt', user: USER });
++ return jsonResponse(404, {});
++ },
++ });
++ navbar.init({ service: 'tools', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ assert.strictEqual(storage.getItem('ov_token'), 'fresh-jwt', 'refreshed token persisted');
++ assert.ok(global.document.cookie.includes('ov_token=fresh-jwt'), 'cookie updated');
++ assert.ok(global.document.cookie.includes('domain=.openvibe.tools'), 'cookie scoped to .openvibe.tools');
++ }
++
++ // ── 8. Expired token on a satellite → Network grace refresh ─
++ // Satellites do not mount /auth/refresh (404) — the Network's
++ // 7-day-grace POST /api/auth/refresh recovers the session.
++ {
++ const { navbar, storage } = loadNavbar({
++ hostname: 'maps.openvibe.tools',
++ cookie: 'ov_token=expired-jwt',
++ fetch: async (url, init) => {
++ if (url === 'https://openvibe.network/api/auth/me') {
++ return init.headers.Authorization === 'Bearer graced-jwt'
++ ? jsonResponse(200, { user: USER })
++ : jsonResponse(401, {});
++ }
++ if (url === '/auth/refresh') return jsonResponse(404, {}); // satellite: no gateway routes
++ if (url === 'https://openvibe.network/api/auth/refresh') {
++ assert.strictEqual(init.headers.Authorization, 'Bearer expired-jwt');
++ return jsonResponse(200, { token: 'graced-jwt', user: USER });
++ }
++ return jsonResponse(404, {});
++ },
++ });
++ navbar.init({ service: 'maps', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ assert.strictEqual(storage.getItem('ov_token'), 'graced-jwt');
++ assert.ok(global.document.cookie.includes('domain=.openvibe.tools'));
++ }
++
++ // ── 9. Token dead beyond recovery → signed out ───────────
++ {
++ const { navbar } = loadNavbar({
++ hostname: 'net.openvibe.tools',
++ cookie: 'ov_token=dead-jwt',
++ fetch: async (url) => {
++ if (url === 'https://openvibe.network/api/auth/me') return jsonResponse(401, {});
++ return jsonResponse(401, {});
++ },
++ });
++ navbar.init({ service: 'net', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user, null);
++ assert.ok(navbar.getElement().innerHTML.includes('openvibe-navbar-login'), 'renders signed-out');
++ }
++
++ // ── 10. localStorage fallback when no cookie ─────────────
++ {
++ const { navbar } = loadNavbar({
++ hostname: 'openvibe.network',
++ storage: { ov_token: 'ls-jwt' },
++ fetch: async (url, init) => {
++ if (url === 'https://openvibe.network/api/auth/me') {
++ assert.strictEqual(init.headers.Authorization, 'Bearer ls-jwt');
++ return jsonResponse(200, { user: USER });
++ }
++ return jsonResponse(404, {});
++ },
++ });
++ navbar.init({ service: 'network', apiBase: 'https://openvibe.network' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ }
++
++ // ── 11. Cookie wins over stale localStorage ──────────────
++ {
++ const { navbar } = loadNavbar({
++ hostname: 'net.openvibe.tools',
++ cookie: 'ov_token=cookie-jwt',
++ storage: { ov_token: 'stale-ls-jwt' },
++ });
++ assert.strictEqual(navbar._auth.resolveToken(), 'cookie-jwt');
++ }
++
++ // ── 12. sessionUrl hook (app-specific session, no token) ─
++ {
++ const { navbar } = loadNavbar({
++ hostname: 'openvibe.live',
++ fetch: async (url) => {
++ if (url === '/api/session') return jsonResponse(200, { user: USER });
++ return jsonResponse(404, {});
++ },
++ });
++ navbar.init({ service: 'live', apiBase: 'https://openvibe.network', sessionUrl: '/api/session' });
++ const user = await navbar.refreshAuth();
++ assert.strictEqual(user.username, 'vibe');
++ }
++
++ // ── 13. Explicit user passed → no auth traffic at all ────
++ {
++ const { navbar, calls } = loadNavbar({
++ hostname: 'openvibe.network',
++ cookie: 'ov_token=whatever',
++ });
++ navbar.init({ service: 'network', token: 'whatever', user: USER });
++ await new Promise((r) => setImmediate(r));
++ assert.strictEqual(calls.length, 0, 'pages that pass a user keep full control');
++ assert.ok(navbar.getElement().innerHTML.includes('openvibe-navbar-avatar'));
++ }
++
++ console.log('navbar-auth: all tests passed');
++})().catch((err) => {
++ console.error(err);
++ process.exit(1);
++});
ddiff --git a/vendor/openvibe-shared/test/theme-parity.test.js b/vendor/openvibe-shared/test/theme-parity.test.js
nnew file mode 100644
iindex 0000000..5b35078
---- /dev/null
++++ b/vendor/openvibe-shared/test/theme-parity.test.js
@@@ -0,0 +1,49 @@
++'use strict';
++// The theme catalog and the browser loader's copy of it must agree, every theme must define a
++// complete token set once defaults are merged, and the default is the blue Vibe.
++const assert = require('assert');
++const { execFileSync } = require('child_process');
++const path = require('path');
++const { BUILTIN_THEMES, DEFAULT_THEME, DEFAULT_VARS } = require('../builtin-themes');
++const { CSS_VARIABLES } = (() => { try { return require('../theme-sync'); } catch { return { CSS_VARIABLES: null }; } })();
++
++// 1. Loader copy is generated from the catalog and not stale.
++execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'build-theme-loader.js'), '--check'], { stdio: 'pipe' });
++
++// 2. Default and the three themes this round is about.
++assert.strictEqual(DEFAULT_THEME.id, 'vibe');
++const by = Object.fromEntries(BUILTIN_THEMES.map((t) => [t.id, t]));
++assert.ok(by['violet-hour'], 'the original violet look is kept as Violet Hour');
++assert.strictEqual(by['violet-hour'].variables['--accent'], '#8b5cf6');
++assert.notStrictEqual(by.vibe.variables['--accent'], by.arctic.variables['--accent'], 'Vibe and Arctic are different blues');
++assert.notStrictEqual(by.vibe.variables['--bg-primary'], by.arctic.variables['--bg-primary']);
++
++// 3. Slugs unique, every theme complete after defaults.
++const REQUIRED = CSS_VARIABLES || Object.keys(DEFAULT_VARS);
++const seen = new Set();
++for (const t of BUILTIN_THEMES) {
++ assert.ok(!seen.has(t.id), `duplicate theme id ${t.id}`);
++ seen.add(t.id);
++ const merged = { ...DEFAULT_VARS, ...t.variables };
++ for (const k of REQUIRED) assert.ok(merged[k], `${t.id} is missing ${k}`);
++ for (const [k, v] of Object.entries(t.variables)) {
++ assert.ok(k.startsWith('--'), `${t.id}: ${k} is not a CSS variable`);
++ assert.ok(!/url\(|expression|javascript:|var\(/i.test(String(v)), `${t.id}: ${k} has a dangerous value`);
++ }
++}
++
++// 4. Loader: full sets and stale-token clearing are present.
++const loader = require('fs').readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');
++assert.ok(loader.includes("'violet-hour': {"), 'loader knows Violet Hour');
++assert.ok(/'arctic': \{[^\n]*'--success'/.test(loader), 'loader entries carry the full token set');
++assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');
++
++// 5. Derived tokens: text on the accent always reads, on every theme.
++const { contrast, hexToRgb } = require('../builtin-themes');
++for (const t of BUILTIN_THEMES) {
++ const v = t.variables;
++ assert.ok(v['--on-accent'] && v['--accent-rgb'] && v['--accent-glow'] && v['--color-scheme'], `${t.id} lacks derived tokens`);
++ assert.ok(contrast(hexToRgb(v['--on-accent']), hexToRgb(v['--accent'])) >= 3, `${t.id}: --on-accent does not read on --accent`);
++ assert.strictEqual(v['--color-scheme'], t.mode === 'light' ? 'light' : 'dark');
++}
++console.log('theme parity: all checks passed');
ddiff --git a/vendor/openvibe-shared/test/url-resolver-deploy.test.js b/vendor/openvibe-shared/test/url-resolver-deploy.test.js
nnew file mode 100644
iindex 0000000..da731d6
---- /dev/null
++++ b/vendor/openvibe-shared/test/url-resolver-deploy.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++
++const assert = require('assert');
++const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('../url-resolver');
++
++// ── Test: Deploy infrastructure keys exist ───────────────────
++const deployKeys = [
++ 'DEPLOY_ACME_EMAIL',
++ 'DEPLOY_CERT_MODE',
++ 'DEPLOY_CLOUDFLARE_TOKEN',
++ 'DEPLOY_DOMAINS',
++ 'DEPLOY_NGINX_MODE',
++ 'DEPLOY_NGINX_SITES_PATH',
++ 'DEPLOY_NGINX_BACKUP_PATH',
++ 'DEPLOY_SERVICE_MAP',
++];
++
++for (const key of deployKeys) {
++ assert.ok(URL_DEFINITIONS[key], `Missing deploy key: ${key}`);
++ assert.strictEqual(URL_DEFINITIONS[key].key, key);
++ assert.ok(URL_DEFINITIONS[key].category.startsWith('deploy_'), `Deploy key ${key} should have deploy_ category`);
++}
++
++// ── Test: Deploy key defaults ────────────────────────────────
++assert.strictEqual(URL_DEFINITIONS.DEPLOY_CERT_MODE.default, 'manual');
++assert.strictEqual(URL_DEFINITIONS.DEPLOY_NGINX_MODE.default, 'preview');
++assert.strictEqual(URL_DEFINITIONS.DEPLOY_NGINX_SITES_PATH.default, '/etc/nginx/sites-enabled');
++assert.strictEqual(URL_DEFINITIONS.DEPLOY_CLOUDFLARE_TOKEN.type, 'secret');
++
++// ── Test: Secret type normalization ──────────────────────────
++assert.strictEqual(normalizeValue(' my-secret-token ', 'secret'), 'my-secret-token');
++assert.strictEqual(normalizeValue(null, 'secret'), null);
++assert.strictEqual(normalizeValue(undefined, 'secret'), null);
++
++// ── Test: Deploy config resolves through registry chain ──────
++const env = {};
++const bootstrap = {
++ DEPLOY_ACME_EMAIL: '[email protected]',
++ DEPLOY_CERT_MODE: 'cloudflare',
++};
++const overrides = {
++ DEPLOY_CERT_MODE: 'manual',
++};
++
++const resolved = resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);
++
++assert.strictEqual(resolved.DEPLOY_ACME_EMAIL.value, '[email protected]');
++assert.strictEqual(resolved.DEPLOY_ACME_EMAIL.source, 'bootstrap');
++assert.strictEqual(resolved.DEPLOY_CERT_MODE.value, 'manual');
++assert.strictEqual(resolved.DEPLOY_CERT_MODE.source, 'admin');
++assert.strictEqual(resolved.DEPLOY_NGINX_MODE.value, 'preview');
++assert.strictEqual(resolved.DEPLOY_NGINX_MODE.source, 'default');
++
++// ── Test: DEPLOY_DOMAINS as JSON array ───────────────────────
++const domainsResolved = resolveRegistryValues({}, {
++ DEPLOY_DOMAINS: JSON.stringify([
++ { domain: 'openvibe.tools', wildcard: true, certName: 'openvibe.tools', services: ['network'] },
++ { domain: 'openvibe.live', wildcard: true, certName: 'openvibe.live-0001', services: ['live'] },
++ ]),
++}, {}, URL_DEFINITIONS);
++
++assert.ok(Array.isArray(domainsResolved.DEPLOY_DOMAINS.value));
++assert.strictEqual(domainsResolved.DEPLOY_DOMAINS.value.length, 2);
++assert.strictEqual(domainsResolved.DEPLOY_DOMAINS.value[0].domain, 'openvibe.tools');
++
++// ── Test: DEPLOY_SERVICE_MAP as JSON map ─────────────────────
++const svcMapResolved = resolveRegistryValues({}, {
++ DEPLOY_SERVICE_MAP: JSON.stringify({ custom: { port: 9000, domains: ['custom.example.com'] } }),
++}, {}, URL_DEFINITIONS);
++
++assert.ok(typeof svcMapResolved.DEPLOY_SERVICE_MAP.value === 'object');
++assert.strictEqual(svcMapResolved.DEPLOY_SERVICE_MAP.value.custom.port, 9000);
++
++// ── Test: White-label defaults still work ────────────────────
++const whitelabelResolved = resolveRegistryValues({}, {
++ NETWORK_NAME: 'CoolNet',
++ TOOLS_SERVICE_NAME: 'CoolTools',
++ DEPLOY_ACME_EMAIL: '[email protected]',
++ DEPLOY_CERT_MODE: 'cloudflare',
++}, {}, URL_DEFINITIONS);
++
++assert.strictEqual(whitelabelResolved.NETWORK_NAME.value, 'CoolNet');
++assert.strictEqual(whitelabelResolved.DEPLOY_ACME_EMAIL.value, '[email protected]');
++assert.strictEqual(whitelabelResolved.DEPLOY_CERT_MODE.value, 'cloudflare');
++
++console.log('✅ url-resolver deploy config tests passed');
ddiff --git a/vendor/openvibe-shared/test/url-resolver.test.js b/vendor/openvibe-shared/test/url-resolver.test.js
nnew file mode 100644
iindex 0000000..130b479
---- /dev/null
++++ b/vendor/openvibe-shared/test/url-resolver.test.js
@@@ -0,0 +1,38 @@
++const assert = require('assert');
++const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('../url-resolver');
++
++const env = {
++ OV_LIVE_URL: 'https://env.example.com',
++ WEBRTC_PUBLIC_URL: 'https://webrtc.env.example.com',
++ OV_NETWORK_URL: 'https://network.env.example.com',
++};
++
++const bootstrap = {
++ OV_LIVE_URL: 'https://bootstrap.example.com',
++ WHIP_PUBLIC_URL: 'https://bootstrap.whip.example.com',
++};
++
++const overrides = {
++ OV_LIVE_URL: 'https://admin.example.com',
++};
++
++const resolved = resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);
++assert.strictEqual(resolved.OV_LIVE_URL.value, 'https://admin.example.com');
++assert.strictEqual(resolved.OV_LIVE_URL.source, 'admin');
++assert.strictEqual(resolved.WHIP_PUBLIC_URL.value, 'https://bootstrap.whip.example.com');
++assert.strictEqual(resolved.WHIP_PUBLIC_URL.source, 'bootstrap');
++assert.strictEqual(resolved.WEBRTC_PUBLIC_URL.value, 'https://webrtc.env.example.com');
++assert.strictEqual(resolved.WEBRTC_PUBLIC_URL.source, 'env');
++assert.strictEqual(resolved.OV_NETWORK_URL.value, 'https://network.env.example.com');
++assert.strictEqual(resolved.OV_NETWORK_URL.source, 'env');
++assert.strictEqual(resolved.OV_GAMES_URL.value, 'https://openvibe.games');
++assert.strictEqual(resolved.OV_MEDIA_URL.value, 'https://openvibe.media');
++assert.strictEqual(resolved.JSMPEG_PUBLIC_URL.source, 'default');
++
++assert.strictEqual(normalizeValue('turn:turn.example.com:3478', 'turn_url'), 'turn:turn.example.com:3478');
++assert.strictEqual(normalizeValue('turn://turn.example.com:3478', 'turn_url'), 'turn:turn.example.com:3478');
++assert.strictEqual(normalizeValue('turns://turn.example.com:5349', 'turn_url'), 'turns:turn.example.com:5349');
++assert.strictEqual(normalizeValue('turn://user:[email protected]:3478', 'turn_url'), null);
++assert.strictEqual(normalizeValue('turn://invalid host', 'turn_url'), null);
++
++console.log('✅ openvibe-shared url-resolver precedence test passed');
ddiff --git a/vendor/openvibe-shared/theme-loader.js b/vendor/openvibe-shared/theme-loader.js
nnew file mode 100644
iindex 0000000..9090ae7
---- /dev/null
++++ b/vendor/openvibe-shared/theme-loader.js
@@@ -0,0 +1,272 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Theme Loader (Browser)
++// Synchronous theme application from cookie/localStorage.
++// Load this BEFORE navbar.js to prevent FOUC.
++// Sets CSS custom properties on <html> from the user's theme.
++// ═══════════════════════════════════════════════════════════════
++
++(function (root) {
++ 'use strict';
++
++ const COOKIE_NAME = 'ov_theme_id';
++ const STORAGE_KEY = 'ov_theme';
++ // The user's theme is owned centrally by openvibe.network, so EVERY app (openvibe.live,
++ // openvibe.tools + subdomains, openvibe.games, openvibe.media) fetches it from there —
++ // that's what makes the theme chosen at openvibe.network/themes sync everywhere.
++ // Apps may override via init({apiBase}).
++ const DEFAULT_API_BASE = 'https://openvibe.network';
++ let _config = { apiBase: DEFAULT_API_BASE };
++
++ // ── Condensed built-in theme variable maps ───────────────
++ // Kept in sync with packages/openvibe-shared/builtin-themes.js
++ // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand
++ const THEMES = {
++ 'vibe': { '--bg-primary':'#0a0f1c','--bg-secondary':'#101828','--bg-tertiary':'#172238','--bg-card':'#131c2e','--bg-hover':'#1c2a44','--bg-input':'#0d1424','--text-primary':'#e6edf7','--text-secondary':'#96a7c2','--text-muted':'#7386a3','--accent':'#3b82f6','--accent-light':'#60a5fa','--accent-dark':'#1d4ed8','--border':'#1f2d47','--border-light':'#2c3d5c','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#ffffff','--accent-rgb':'59,130,246','--accent-glow':'rgba(59,130,246,0.35)','--color-scheme':'dark' },
++ 'violet-hour': { '--bg-primary':'#0d0d0f','--bg-secondary':'#16161a','--bg-tertiary':'#1e1e24','--bg-card':'#1a1a20','--bg-hover':'#242430','--bg-input':'#12121a','--text-primary':'#e8e6e3','--text-secondary':'#9a9a9a','--text-muted':'#838282','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#2a2a32','--border-light':'#3a3a44','--live-red':'#e74c3c','--success':'#2ecc71','--warning':'#f39c12','--danger':'#e74c3c','--info':'#3498db','--shadow':'0 2px 12px rgba(0,0,0,0.4)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#ffffff','--accent-rgb':'139,92,246','--accent-glow':'rgba(139,92,246,0.35)','--color-scheme':'dark' },
++ 'midnight': { '--bg-primary':'#0a0e17','--bg-secondary':'#111827','--bg-tertiary':'#1a2235','--bg-card':'#151d2e','--bg-hover':'#1e2a42','--bg-input':'#0d1220','--text-primary':'#e2e8f0','--text-secondary':'#94a3b8','--text-muted':'#78879b','--accent':'#6366f1','--accent-light':'#818cf8','--accent-dark':'#4f46e5','--border':'#1e293b','--border-light':'#334155','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#ffffff','--accent-rgb':'99,102,241','--accent-glow':'rgba(99,102,241,0.35)','--color-scheme':'dark' },
++ 'forest': { '--bg-primary':'#0a120a','--bg-secondary':'#121e12','--bg-tertiary':'#1a2a1a','--bg-card':'#152015','--bg-hover':'#1e3420','--bg-input':'#0d160d','--text-primary':'#d4e8d4','--text-secondary':'#8aaa8a','--text-muted':'#708e70','--accent':'#4ade80','--accent-light':'#86efac','--accent-dark':'#22c55e','--border':'#1a3a1a','--border-light':'#2a4a2a','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'74,222,128','--accent-glow':'rgba(74,222,128,0.35)','--color-scheme':'dark' },
++ 'neon-tokyo': { '--bg-primary':'#0a0a0f','--bg-secondary':'#12101a','--bg-tertiary':'#1a1625','--bg-card':'#15111f','--bg-hover':'#221a32','--bg-input':'#0d0b14','--text-primary':'#f0e6ff','--text-secondary':'#b4a0d0','--text-muted':'#87799c','--accent':'#ff006e','--accent-light':'#ff4d94','--accent-dark':'#c9005a','--border':'#2a1a3a','--border-light':'#3a2a4a','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#ffffff','--accent-rgb':'255,0,110','--accent-glow':'rgba(255,0,110,0.35)','--color-scheme':'dark' },
++ 'dracula': { '--bg-primary':'#282a36','--bg-secondary':'#1e1f29','--bg-tertiary':'#343746','--bg-card':'#2c2e3c','--bg-hover':'#3a3d50','--bg-input':'#21222c','--text-primary':'#f8f8f2','--text-secondary':'#bfbfb2','--text-muted':'#8995b8','--accent':'#bd93f9','--accent-light':'#d4b8ff','--accent-dark':'#9b6ddb','--border':'#44475a','--border-light':'#565970','--live-red':'#ff5555','--success':'#50fa7b','--warning':'#f1fa8c','--danger':'#ff5555','--info':'#8be9fd','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'189,147,249','--accent-glow':'rgba(189,147,249,0.35)','--color-scheme':'dark' },
++ 'monokai': { '--bg-primary':'#272822','--bg-secondary':'#1e1f1c','--bg-tertiary':'#3e3d32','--bg-card':'#2d2e27','--bg-hover':'#49483e','--bg-input':'#22231e','--text-primary':'#f8f8f2','--text-secondary':'#cfcfc2','--text-muted':'#979484','--accent':'#a6e22e','--accent-light':'#c4ff50','--accent-dark':'#7dac1c','--border':'#49483e','--border-light':'#5b5a50','--live-red':'#ef4444','--success':'#22c55e','--warning':'#e6db74','--danger':'#f92672','--info':'#66d9ef','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'166,226,46','--accent-glow':'rgba(166,226,46,0.35)','--color-scheme':'dark' },
++ 'ocean-deep': { '--bg-primary':'#0a1215','--bg-secondary':'#0f1a1f','--bg-tertiary':'#162228','--bg-card':'#121c22','--bg-hover':'#1a2a32','--bg-input':'#0c1418','--text-primary':'#d0e8ef','--text-secondary':'#7aa8ba','--text-muted':'#658896','--accent':'#00bcd4','--accent-light':'#4dd0e1','--accent-dark':'#0097a7','--border':'#1a3040','--border-light':'#2a4050','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'0,188,212','--accent-glow':'rgba(0,188,212,0.35)','--color-scheme':'dark' },
++ 'sunset': { '--bg-primary':'#120a0a','--bg-secondary':'#1a1010','--bg-tertiary':'#241616','--bg-card':'#1e1212','--bg-hover':'#2e1a1a','--bg-input':'#140c0c','--text-primary':'#ffe8e0','--text-secondary':'#c49080','--text-muted':'#977970','--accent':'#ff6b35','--accent-light':'#ff8f5e','--accent-dark':'#d04a1a','--border':'#3a2020','--border-light':'#4a3030','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'255,107,53','--accent-glow':'rgba(255,107,53,0.35)','--color-scheme':'dark' },
++ 'arctic': { '--bg-primary':'#0b1620','--bg-secondary':'#0f1e2b','--bg-tertiary':'#163040','--bg-card':'#122534','--bg-hover':'#1a3a4c','--bg-input':'#0c1a26','--text-primary':'#eaf6ff','--text-secondary':'#9ec3d8','--text-muted':'#7ea3ba','--accent':'#22d3ee','--accent-light':'#a5f3fc','--accent-dark':'#0891b2','--border':'#1e3f52','--border-light':'#2a5268','--live-red':'#ef4444','--success':'#34d399','--warning':'#f59e0b','--danger':'#ef4444','--info':'#7dd3fc','--shadow':'0 2px 12px rgba(2,12,20,0.5)','--shadow-lg':'0 8px 32px rgba(2,12,20,0.65)','--on-accent':'#0b0d10','--accent-rgb':'34,211,238','--accent-glow':'rgba(34,211,238,0.35)','--color-scheme':'dark' },
++ 'ember': { '--bg-primary':'#120808','--bg-secondary':'#1a0e0e','--bg-tertiary':'#241414','--bg-card':'#1e1010','--bg-hover':'#2e1818','--bg-input':'#140a0a','--text-primary':'#f0dada','--text-secondary':'#b0808a','--text-muted':'#95787c','--accent':'#ef4444','--accent-light':'#f87171','--accent-dark':'#b91c1c','--border':'#3a1a20','--border-light':'#4a2a30','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#ffffff','--accent-rgb':'239,68,68','--accent-glow':'rgba(239,68,68,0.35)','--color-scheme':'dark' },
++ 'vapor': { '--bg-primary':'#0e0a14','--bg-secondary':'#14101c','--bg-tertiary':'#1c1628','--bg-card':'#181220','--bg-hover':'#241a34','--bg-input':'#100c16','--text-primary':'#f0e0ff','--text-secondary':'#c090e0','--text-muted':'#8f73b7','--accent':'#e040fb','--accent-light':'#ea80fc','--accent-dark':'#aa00d4','--border':'#2a1a40','--border-light':'#3a2a50','--live-red':'#ef4444','--success':'#69f0ae','--warning':'#f59e0b','--danger':'#ef4444','--info':'#00e5ff','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#ffffff','--accent-rgb':'224,64,251','--accent-glow':'rgba(224,64,251,0.35)','--color-scheme':'dark' },
++ 'slate': { '--bg-primary':'#0f1118','--bg-secondary':'#161820','--bg-tertiary':'#1e2028','--bg-card':'#1a1c24','--bg-hover':'#24262e','--bg-input':'#12141c','--text-primary':'#e4e6ea','--text-secondary':'#a0a4b0','--text-muted':'#828690','--accent':'#a78bfa','--accent-light':'#c4b5fd','--accent-dark':'#7c3aed','--border':'#2a2c38','--border-light':'#3a3c48','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'167,139,250','--accent-glow':'rgba(167,139,250,0.35)','--color-scheme':'dark' },
++ 'matrix': { '--bg-primary':'#000000','--bg-secondary':'#050a05','--bg-tertiary':'#0a140a','--bg-card':'#071007','--bg-hover':'#0e1e0e','--bg-input':'#030803','--text-primary':'#00ff41','--text-secondary':'#00b030','--text-muted':'#008f25','--accent':'#00ff41','--accent-light':'#66ff8a','--accent-dark':'#00aa2a','--border':'#0a2a0a','--border-light':'#144014','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'0,255,65','--accent-glow':'rgba(0,255,65,0.35)','--color-scheme':'dark' },
++ 'nord': { '--bg-primary':'#2e3440','--bg-secondary':'#272c36','--bg-tertiary':'#3b4252','--bg-card':'#333a48','--bg-hover':'#434c5e','--bg-input':'#2a303c','--text-primary':'#eceff4','--text-secondary':'#d8dee9','--text-muted':'#9ba5b8','--accent':'#88c0d0','--accent-light':'#8fbcbb','--accent-dark':'#5e81ac','--border':'#3b4252','--border-light':'#4c566a','--live-red':'#ef4444','--success':'#a3be8c','--warning':'#ebcb8b','--danger':'#bf616a','--info':'#81a1c1','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'136,192,208','--accent-glow':'rgba(136,192,208,0.35)','--color-scheme':'dark' },
++ 'gruvbox-dark': { '--bg-primary':'#1d2021','--bg-secondary':'#282828','--bg-tertiary':'#3c3836','--bg-card':'#32302f','--bg-hover':'#504945','--bg-input':'#242424','--text-primary':'#ebdbb2','--text-secondary':'#d5c4a1','--text-muted':'#a69682','--accent':'#fabd2f','--accent-light':'#fce566','--accent-dark':'#d79921','--border':'#3c3836','--border-light':'#504945','--live-red':'#ef4444','--success':'#b8bb26','--warning':'#fe8019','--danger':'#fb4934','--info':'#83a598','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'250,189,47','--accent-glow':'rgba(250,189,47,0.35)','--color-scheme':'dark' },
++ 'abyss': { '--bg-primary':'#000000','--bg-secondary':'#080808','--bg-tertiary':'#111111','--bg-card':'#0a0a0a','--bg-hover':'#181818','--bg-input':'#050505','--text-primary':'#cccccc','--text-secondary':'#888888','--text-muted':'#7a7a7a','--accent':'#ffffff','--accent-light':'#ffffff','--accent-dark':'#aaaaaa','--border':'#1a1a1a','--border-light':'#252525','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'255,255,255','--accent-glow':'rgba(255,255,255,0.35)','--color-scheme':'dark' },
++ 'copper': { '--bg-primary':'#110d09','--bg-secondary':'#1a140e','--bg-tertiary':'#231c14','--bg-card':'#1e1710','--bg-hover':'#2c2218','--bg-input':'#13100b','--text-primary':'#e8ddd0','--text-secondary':'#b49a80','--text-muted':'#8e7f6a','--accent':'#cd7f32','--accent-light':'#daa060','--accent-dark':'#a06020','--border':'#2a2016','--border-light':'#3a3020','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#ffffff','--accent-rgb':'205,127,50','--accent-glow':'rgba(205,127,50,0.35)','--color-scheme':'dark' },
++ 'sakura-night': { '--bg-primary':'#0e0a14','--bg-secondary':'#150f1c','--bg-tertiary':'#1c1526','--bg-card':'#181020','--bg-hover':'#221830','--bg-input':'#100c16','--text-primary':'#f0e0f0','--text-secondary':'#c0a0c0','--text-muted':'#927592','--accent':'#f472b6','--accent-light':'#f9a8d4','--accent-dark':'#db2777','--border':'#2a1a30','--border-light':'#3a2a40','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'244,114,182','--accent-glow':'rgba(244,114,182,0.35)','--color-scheme':'dark' },
++ 'hacker': { '--bg-primary':'#000000','--bg-secondary':'#0a0800','--bg-tertiary':'#141000','--bg-card':'#0e0c00','--bg-hover':'#1c1800','--bg-input':'#060400','--text-primary':'#ffb000','--text-secondary':'#cc8d00','--text-muted':'#a37100','--accent':'#ffb000','--accent-light':'#ffd050','--accent-dark':'#cc8d00','--border':'#1a1400','--border-light':'#2a2200','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'255,176,0','--accent-glow':'rgba(255,176,0,0.35)','--color-scheme':'dark' },
++ 'daylight': { '--bg-primary':'#ffffff','--bg-secondary':'#f7f7f8','--bg-tertiary':'#eeeef0','--bg-card':'#ffffff','--bg-hover':'#f0f0f2','--bg-input':'#f5f5f7','--text-primary':'#1a1a2e','--text-secondary':'#4a4a6a','--text-muted':'#747489','--accent':'#8756f3','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#e0e0e6','--border-light':'#d0d0d8','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.08)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.12)','--on-accent':'#ffffff','--accent-rgb':'135,86,243','--accent-glow':'rgba(135,86,243,0.35)','--color-scheme':'light' },
++ 'paper': { '--bg-primary':'#faf8f5','--bg-secondary':'#f0ece6','--bg-tertiary':'#e8e2d8','--bg-card':'#faf8f5','--bg-hover':'#f0ece6','--bg-input':'#f5f2ed','--text-primary':'#2c2416','--text-secondary':'#5a4e3e','--text-muted':'#7b7061','--accent':'#8b6914','--accent-light':'#b08a30','--accent-dark':'#6a5010','--border':'#ddd6c8','--border-light':'#ccc4b0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)','--on-accent':'#ffffff','--accent-rgb':'139,105,20','--accent-glow':'rgba(139,105,20,0.35)','--color-scheme':'light' },
++ 'cloud': { '--bg-primary':'#f0f5ff','--bg-secondary':'#e8eef8','--bg-tertiary':'#dde5f0','--bg-card':'#f5f8ff','--bg-hover':'#e4ecf8','--bg-input':'#edf2fa','--text-primary':'#1a2040','--text-secondary':'#4a5070','--text-muted':'#6b718c','--accent':'#2867ed','--accent-light':'#60a5fa','--accent-dark':'#2563eb','--border':'#d0d8ea','--border-light':'#bcc8e0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,20,60,0.06)','--shadow-lg':'0 8px 32px rgba(0,20,60,0.10)','--on-accent':'#ffffff','--accent-rgb':'40,103,237','--accent-glow':'rgba(40,103,237,0.35)','--color-scheme':'light' },
++ 'meadow': { '--bg-primary':'#f2f8f0','--bg-secondary':'#e8f0e4','--bg-tertiary':'#dce8d6','--bg-card':'#f5faf2','--bg-hover':'#e4f0de','--bg-input':'#edf5ea','--text-primary':'#1a2e16','--text-secondary':'#3a5a32','--text-muted':'#5d7a54','--accent':'#15823e','--accent-light':'#22c55e','--accent-dark':'#15803d','--border':'#c8dcc0','--border-light':'#b0ccaa','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,40,0,0.10)','--on-accent':'#ffffff','--accent-rgb':'21,130,62','--accent-glow':'rgba(21,130,62,0.35)','--color-scheme':'light' },
++ 'peach': { '--bg-primary':'#fff5f0','--bg-secondary':'#fbeae2','--bg-tertiary':'#f5ddd2','--bg-card':'#fff7f2','--bg-hover':'#fce8dc','--bg-input':'#fdf0ea','--text-primary':'#2e1a14','--text-secondary':'#6a4438','--text-muted':'#8f6a5c','--accent':'#c04808','--accent-light':'#fb923c','--accent-dark':'#ea580c','--border':'#f0d0c0','--border-light':'#e4bfab','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,20,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,20,0,0.10)','--on-accent':'#ffffff','--accent-rgb':'192,72,8','--accent-glow':'rgba(192,72,8,0.35)','--color-scheme':'light' },
++ 'lavender': { '--bg-primary':'#f8f5ff','--bg-secondary':'#f0eaf8','--bg-tertiary':'#e6ddf0','--bg-card':'#faf7ff','--bg-hover':'#eee6f8','--bg-input':'#f4f0fa','--text-primary':'#1e1430','--text-secondary':'#4a3a60','--text-muted':'#796a92','--accent':'#844cf2','--accent-light':'#a78bfa','--accent-dark':'#7c3aed','--border':'#ddd0ee','--border-light':'#ccc0dd','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(40,0,60,0.06)','--shadow-lg':'0 8px 32px rgba(40,0,60,0.10)','--on-accent':'#ffffff','--accent-rgb':'132,76,242','--accent-glow':'rgba(132,76,242,0.35)','--color-scheme':'light' },
++ 'gruvbox-light': { '--bg-primary':'#fbf1c7','--bg-secondary':'#f2e5bc','--bg-tertiary':'#ebdbb2','--bg-card':'#fbf1c7','--bg-hover':'#f2e5bc','--bg-input':'#f5ecc4','--text-primary':'#3c3836','--text-secondary':'#504945','--text-muted':'#766b60','--accent':'#be4707','--accent-light':'#fe8019','--accent-dark':'#af3a03','--border':'#d5c4a1','--border-light':'#bdae93','--live-red':'#ef4444','--success':'#98971a','--warning':'#d79921','--danger':'#cc241d','--info':'#458588','--shadow':'0 2px 12px rgba(40,30,0,0.08)','--shadow-lg':'0 8px 32px rgba(40,30,0,0.12)','--on-accent':'#ffffff','--accent-rgb':'190,71,7','--accent-glow':'rgba(190,71,7,0.35)','--color-scheme':'light' },
++ 'snow': { '--bg-primary':'#ffffff','--bg-secondary':'#f8fafc','--bg-tertiary':'#f1f5f9','--bg-card':'#ffffff','--bg-hover':'#f1f5f9','--bg-input':'#f8fafc','--text-primary':'#0f172a','--text-secondary':'#334155','--text-muted':'#69768b','--accent':'#027cbb','--accent-light':'#38bdf8','--accent-dark':'#0284c7','--border':'#e2e8f0','--border-light':'#cbd5e1','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.05)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.08)','--on-accent':'#ffffff','--accent-rgb':'2,124,187','--accent-glow':'rgba(2,124,187,0.35)','--color-scheme':'light' },
++ 'sand': { '--bg-primary':'#f8f4ef','--bg-secondary':'#f0e8de','--bg-tertiary':'#e8ddd0','--bg-card':'#faf6f0','--bg-hover':'#f0e8de','--bg-input':'#f5f0e8','--text-primary':'#2e2418','--text-secondary':'#5a4e3e','--text-muted':'#7a6e5f','--accent':'#8c6707','--accent-light':'#daa520','--accent-dark':'#8b6508','--border':'#ddd0be','--border-light':'#ccc0aa','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)','--on-accent':'#ffffff','--accent-rgb':'140,103,7','--accent-glow':'rgba(140,103,7,0.35)','--color-scheme':'light' },
++ 'rose': { '--bg-primary':'#fff5f7','--bg-secondary':'#fce7ec','--bg-tertiary':'#f5d5de','--bg-card':'#fff7f9','--bg-hover':'#fce0e8','--bg-input':'#fdeef2','--text-primary':'#2e1420','--text-secondary':'#6a3850','--text-muted':'#96617e','--accent':'#de1c47','--accent-light':'#f43f5e','--accent-dark':'#be123c','--border':'#f0c8d4','--border-light':'#e4b0c0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,0,20,0.06)','--shadow-lg':'0 8px 32px rgba(60,0,20,0.10)','--on-accent':'#ffffff','--accent-rgb':'222,28,71','--accent-glow':'rgba(222,28,71,0.35)','--color-scheme':'light' },
++ 'solarized-dark': { '--bg-primary':'#002b36','--bg-secondary':'#073642','--bg-tertiary':'#0a3f4e','--bg-card':'#053542','--bg-hover':'#0d4a58','--bg-input':'#01303d','--text-primary':'#fdf6e3','--text-secondary':'#93a1a1','--text-muted':'#909c9a','--accent':'#b58900','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#0a4050','--border-light':'#105060','--live-red':'#ef4444','--success':'#859900','--warning':'#cb4b16','--danger':'#dc322f','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#ffffff','--accent-rgb':'181,137,0','--accent-glow':'rgba(181,137,0,0.35)','--color-scheme':'dark' },
++ 'solarized-light': { '--bg-primary':'#fdf6e3','--bg-secondary':'#eee8d5','--bg-tertiary':'#e4ddc6','--bg-card':'#fdf6e3','--bg-hover':'#eee8d5','--bg-input':'#f5eedb','--text-primary':'#002b36','--text-secondary':'#586e75','--text-muted':'#5b7478','--accent':'#8d6c00','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#d6ceb5','--border-light':'#c8c0a5','--live-red':'#ef4444','--success':'#859900','--warning':'#cb4b16','--danger':'#dc322f','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,30,40,0.08)','--shadow-lg':'0 8px 32px rgba(0,30,40,0.12)','--on-accent':'#ffffff','--accent-rgb':'141,108,0','--accent-glow':'rgba(141,108,0,0.35)','--color-scheme':'light' },
++ 'catppuccin-mocha': { '--bg-primary':'#1e1e2e','--bg-secondary':'#181825','--bg-tertiary':'#313244','--bg-card':'#1e1e2e','--bg-hover':'#313244','--bg-input':'#1a1a2c','--text-primary':'#cdd6f4','--text-secondary':'#bac2de','--text-muted':'#81869e','--accent':'#cba6f7','--accent-light':'#dcc0ff','--accent-dark':'#b07de0','--border':'#313244','--border-light':'#45475a','--live-red':'#f38ba8','--success':'#a6e3a1','--warning':'#fab387','--danger':'#f38ba8','--info':'#89b4fa','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'203,166,247','--accent-glow':'rgba(203,166,247,0.35)','--color-scheme':'dark' },
++ 'catppuccin-latte': { '--bg-primary':'#eff1f5','--bg-secondary':'#e6e9ef','--bg-tertiary':'#dce0e8','--bg-card':'#eff1f5','--bg-hover':'#dce0e8','--bg-input':'#e8ebf0','--text-primary':'#4c4f69','--text-secondary':'#5c5f77','--text-muted':'#696c83','--accent':'#8839ef','--accent-light':'#a05cff','--accent-dark':'#7028d4','--border':'#ccd0da','--border-light':'#bcc0cc','--live-red':'#d20f39','--success':'#40a02b','--warning':'#fe640b','--danger':'#d20f39','--info':'#1e66f5','--shadow':'0 2px 12px rgba(0,0,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.10)','--on-accent':'#ffffff','--accent-rgb':'136,57,239','--accent-glow':'rgba(136,57,239,0.35)','--color-scheme':'light' },
++ 'high-contrast': { '--bg-primary':'#000000','--bg-secondary':'#0a0a0a','--bg-tertiary':'#1a1a1a','--bg-card':'#0f0f0f','--bg-hover':'#222222','--bg-input':'#0a0a0a','--text-primary':'#ffffff','--text-secondary':'#f0f0f0','--text-muted':'#aaaaaa','--accent':'#ffd700','--accent-light':'#ffed4a','--accent-dark':'#ccac00','--border':'#444444','--border-light':'#666666','--live-red':'#ff0000','--success':'#00ff00','--warning':'#ffff00','--danger':'#ff0000','--info':'#00ffff','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--on-accent':'#0b0d10','--accent-rgb':'255,215,0','--accent-glow':'rgba(255,215,0,0.35)','--color-scheme':'dark' },
++ };
++ // THEMES:END
++
++ function getCookie(name) {
++ const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
++ return match ? decodeURIComponent(match[1]) : null;
++ }
++
++ function setCookie(name, value, days) {
++ const maxAge = days * 24 * 60 * 60;
++ const encoded = encodeURIComponent(value);
++ const host = (typeof location !== 'undefined' && location.hostname) || '';
++ // Cookie policy (CONTRACTS.md): a Domain=.openvibe.tools cookie is set ONLY
++ // when actually running on openvibe.tools or one of its subdomains, so the
++ // theme follows the user across the tool satellites. Everywhere else
++ // (openvibe.network, openvibe.live, openvibe.games, localhost, ...) the
++ // cookie is host-only — NO Domain attribute.
++ if (host && (host.endsWith('.openvibe.tools') || host === 'openvibe.tools')) {
++ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};domain=.openvibe.tools;SameSite=Lax;Secure`;
++ } else {
++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
++ document.cookie = `${name}=${encoded};path=/;max-age=${maxAge};SameSite=Lax${secure}`;
++ }
++ }
++
++ /**
++ * Apply a theme's CSS variables to document.documentElement.
++ * If themeId is not found in the built-in map, does nothing.
++ */
++ function applyById(themeId) {
++ const vars = THEMES[themeId];
++ if (!vars) return false;
++ applyVars(vars);
++ return true;
++ }
++
++ /**
++ * Apply arbitrary CSS variables object to <html>.
++ */
++ // Every token any built-in theme sets. Applying a theme clears the ones it does not set, so a
++ // theme that names no --success cannot inherit the previous theme's --success on <html>.
++ const KNOWN_PROPS = (function () { const out = {}; for (const id in THEMES) for (const k in THEMES[id]) out[k] = true; return Object.keys(out); })();
++ function applyVars(vars) {
++ if (!vars || typeof vars !== 'object') return;
++ const el = document.documentElement;
++ for (let i = 0; i < KNOWN_PROPS.length; i++) if (!(KNOWN_PROPS[i] in vars)) el.style.removeProperty(KNOWN_PROPS[i]);
++ for (const [prop, val] of Object.entries(vars)) {
++ if (prop.startsWith('--')) el.style.setProperty(prop, val);
++ }
++ // Native controls, scrollbars and form fields follow the theme's light/dark mode.
++ const scheme = vars['--color-scheme'];
++ if (scheme === 'light' || scheme === 'dark') { el.style.colorScheme = scheme; el.setAttribute('data-theme-mode', scheme); }
++ }
++
++ /**
++ * Resolve the active theme from (in order):
++ * 1. localStorage 'ov_theme' (has id + variables — instant)
++ * 2. Cookie 'ov_theme_id' (just slug — look up in built-in map)
++ * Falls back to 'vibe'.
++ */
++ function resolveAndApply() {
++ // 1. Try localStorage (same-origin cache with full variables)
++ try {
++ const raw = localStorage.getItem(STORAGE_KEY);
++ if (raw) {
++ const saved = JSON.parse(raw);
++ if (saved && saved.variables && typeof saved.variables === 'object') {
++ applyVars(saved.variables);
++ const cachedId = saved.id || saved.slug || 'vibe';
++ try { document.documentElement.setAttribute('data-theme', cachedId); } catch { /* no DOM */ }
++ return cachedId;
++ }
++ if (saved && (saved.id || saved.slug)) {
++ const id = saved.id || saved.slug;
++ if (applyById(id)) return id;
++ }
++ }
++ } catch { /* corrupt localStorage */ }
++
++ // 2. Try cross-domain cookie
++ const cookieId = getCookie(COOKIE_NAME);
++ if (cookieId && applyById(cookieId)) {
++ // Cache the full variable set so the next load paints from localStorage instantly.
++ cacheLocally(cookieId, THEMES[cookieId]);
++ return cookieId;
++ }
++
++ // 3. Default — vibe (already the CSS fallback values, but apply explicitly)
++ applyById('vibe');
++ return 'vibe';
++ }
++
++ function authToken() {
++ return getCookie('ov_token') ||
++ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token'))) ||
++ null;
++ }
++
++ /**
++ * Write the resolved theme to every local cache. Always stores the FULL variable set —
++ * including for community/custom themes that aren't in the built-in map — so the next
++ * page load can paint the right colours synchronously with no network call and no flash.
++ */
++ function cacheLocally(themeId, vars) {
++ try {
++ localStorage.setItem(STORAGE_KEY, JSON.stringify({
++ id: themeId, slug: themeId, variables: vars, at: Date.now(),
++ }));
++ } catch { /* quota */ }
++ setCookie(COOKIE_NAME, themeId, 365);
++ try { document.documentElement.setAttribute('data-theme', themeId); } catch { /* no DOM */ }
++ }
++
++ /**
++ * Save theme choice locally AND to the user's openvibe.network account, so the choice
++ * follows them to every OpenVibe site. Pass opts.custom for per-user variable overrides;
++ * opts.sync === false keeps it device-local.
++ */
++ function save(themeId, variables, opts) {
++ const custom = (opts && opts.custom) || null;
++ const base = variables || THEMES[themeId] || {};
++ const vars = custom ? Object.assign({}, base, custom) : base;
++ if (!vars || !Object.keys(vars).length) return;
++ cacheLocally(themeId, vars);
++ if (opts && opts.sync === false) return;
++ if (typeof fetch === 'undefined') return;
++ const token = authToken();
++ const headers = { 'Content-Type': 'application/json' };
++ if (token) headers['Authorization'] = 'Bearer ' + token;
++ fetch(_config.apiBase + '/api/themes/me', {
++ method: 'PUT', headers: headers, credentials: 'include',
++ body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),
++ }).catch(function () { /* offline / signed out — local cache still holds */ });
++ }
++
++ /**
++ * Get the current theme ID (from cookie or localStorage).
++ */
++ function getCurrent() {
++ try {
++ const raw = localStorage.getItem(STORAGE_KEY);
++ if (raw) {
++ const saved = JSON.parse(raw);
++ if (saved && (saved.id || saved.slug)) return saved.id || saved.slug;
++ }
++ } catch {}
++ return getCookie(COOKIE_NAME) || 'vibe';
++ }
++
++ // ── Auto-apply on load (synchronous, prevents FOUC) ─────
++ const activeId = resolveAndApply();
++
++ // Look up a theme we don't ship in the built-in map (community/custom themes) from the
++ // central catalog. Without this, any non-built-in id fell through to the default 'vibe'
++ // — which is why a custom theme could "disappear" and flash purple on every load.
++ function fetchThemeVars(themeId) {
++ return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (d) { return (d && d.theme && d.theme.variables) || null; })
++ .catch(function () { return null; });
++ }
++
++ // ── Pull the account's theme from openvibe.network ───────
++ // Runs IMMEDIATELY, not on a 500ms timer: the cached theme is already painted, so this is
++ // a silent correction — delaying it only widened the window where a cold or stale cache
++ // showed the wrong theme. There is also no token gate any more; the ov_token cookie rides
++ // along via credentials:'include' (openvibe.network allows credentialed CORS per origin),
++ // so this now works on sites where the cookie isn't readable from JS — previously the sync
++ // silently never ran there and the user's theme appeared to be "lost".
++ function syncFromServer() {
++ if (typeof fetch === 'undefined') return;
++ const token = authToken();
++ const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
++ fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (data) {
++ if (!data || !data.theme_id) return;
++ const serverId = data.theme_id;
++ const custom = (data.custom_variables && typeof data.custom_variables === 'object'
++ && Object.keys(data.custom_variables).length) ? data.custom_variables : null;
++ const builtin = THEMES[serverId];
++ const settle = function (base) {
++ const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
++ if (!vars || !Object.keys(vars).length) return;
++ applyVars(vars);
++ cacheLocally(serverId, vars); // cacheLocally, not save() — no write-back loop
++ };
++ if (builtin) return settle(builtin);
++ fetchThemeVars(serverId).then(settle);
++ })
++ .catch(function () { /* offline / signed out — keep the cached theme */ });
++ }
++ syncFromServer();
++
++ // ── Public API ───────────────────────────────────────────
++ const OpenVibeThemeLoader = {
++ THEMES: THEMES,
++ apply: applyById,
++ applyVars: applyVars,
++ save: save,
++ getCurrent: getCurrent,
++ resolveAndApply: resolveAndApply,
++ init: function (opts) {
++ if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {
++ _config.apiBase = opts.apiBase.trim();
++ }
++ return OpenVibeThemeLoader;
++ },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeThemeLoader;
++ else root.OpenVibeThemeLoader = OpenVibeThemeLoader;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
ddiff --git a/vendor/openvibe-shared/theme-sync.js b/vendor/openvibe-shared/theme-sync.js
nnew file mode 100644
iindex 0000000..389a671
---- /dev/null
++++ b/vendor/openvibe-shared/theme-sync.js
@@@ -0,0 +1,129 @@
++'use strict';
++
++const { BUILTIN_THEMES, DEFAULT_VARS, deriveTokens } = require('./builtin-themes');
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Theme Sync Engine
++// Isomorphic theme system shared across all OpenVibe services.
++// Works in both Node.js (SSR) and browser environments.
++// ═══════════════════════════════════════════════════════════════
++
++const CSS_VARIABLES = [
++ '--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-card',
++ '--bg-hover', '--bg-input',
++ '--text-primary', '--text-secondary', '--text-muted',
++ '--accent', '--accent-light', '--accent-dark',
++ '--live-red', '--success', '--warning', '--danger', '--info',
++ '--border', '--border-light', '--shadow', '--shadow-lg',
++ // Derived on every theme (builtin-themes.js deriveTokens); community themes get them too.
++ '--on-accent', '--accent-rgb', '--accent-glow', '--color-scheme',
++];
++
++const NORMALIZED_DEFAULT_VARS = Object.freeze({ ...DEFAULT_VARS });
++
++// ── Dangerous CSS patterns (XSS prevention) ─────────────────
++const DANGEROUS_PATTERNS = [
++ /url\s*\(/i, /expression\s*\(/i, /javascript:/i,
++ /@import/i, /behavior\s*:/i, /var\s*\(/i, /-moz-binding/i,
++];
++
++function sanitizeCssValue(value) {
++ if (typeof value !== 'string') return null;
++ const trimmed = value.trim();
++ if (trimmed.length > 200) return null;
++ for (const pat of DANGEROUS_PATTERNS) {
++ if (pat.test(trimmed)) return null;
++ }
++ return trimmed;
++}
++
++// ── Theme Application ────────────────────────────────────────
++
++/**
++ * Apply a theme's variables to a target (DOM element or plain object).
++ * In browser: pass `document.documentElement` as target.
++ * In Node: pass an empty object to collect computed values.
++ */
++function applyTheme(theme, target) {
++ if (!theme || !theme.variables) return;
++ const vars = deriveTokens({ ...NORMALIZED_DEFAULT_VARS, ...theme.variables }, theme.mode);
++ for (const [key, value] of Object.entries(vars)) {
++ if (!CSS_VARIABLES.includes(key)) continue;
++ const safe = sanitizeCssValue(value);
++ if (!safe) continue;
++ if (target && typeof target.style !== 'undefined' && target.style.setProperty) {
++ // DOM element (browser)
++ target.style.setProperty(key, safe);
++ } else if (typeof target === 'object') {
++ // Plain object (Node SSR)
++ target[key] = safe;
++ }
++ }
++}
++
++/**
++ * Resolve a theme by ID from the built-in catalog.
++ * Falls back to 'vibe' if not found.
++ */
++function resolveBuiltinTheme(themeId) {
++ return BUILTIN_THEMES.find(t => t.id === themeId || t.slug === themeId)
++ || BUILTIN_THEMES[0];
++}
++
++// ── Browser-Only Helpers ─────────────────────────────────────
++// These are safe to call in Node (they no-op if `window` is undefined).
++
++const STORAGE_KEY = 'ov_theme';
++const THEME_API_BASE = 'https://openvibe.network/api/themes';
++
++function loadFromStorage() {
++ if (typeof localStorage === 'undefined') return null;
++ try {
++ const raw = localStorage.getItem(STORAGE_KEY);
++ return raw ? JSON.parse(raw) : null;
++ } catch { return null; }
++}
++
++function saveToStorage(theme) {
++ if (typeof localStorage === 'undefined') return;
++ try {
++ localStorage.setItem(STORAGE_KEY, JSON.stringify({
++ id: theme.id,
++ slug: theme.slug,
++ name: theme.name,
++ mode: theme.mode,
++ variables: theme.variables,
++ }));
++ } catch { /* quota exceeded, etc. */ }
++}
++
++/**
++ * Sync theme choice to the central OpenVibe.Network server.
++ * Requires an auth token. Fails silently if offline.
++ */
++async function syncThemeToServer(themeId, customVars, token) {
++ if (typeof fetch === 'undefined' || !token) return;
++ try {
++ await fetch(`${THEME_API_BASE}/me`, {
++ method: 'PUT',
++ headers: {
++ 'Content-Type': 'application/json',
++ 'Authorization': `Bearer ${token}`,
++ },
++ body: JSON.stringify({ theme_id: themeId, custom_variables: customVars || null }),
++ });
++ } catch { /* offline, CORS, etc. */ }
++}
++
++module.exports = {
++ CSS_VARIABLES,
++ DEFAULT_VARS: NORMALIZED_DEFAULT_VARS,
++ BUILTIN_THEMES,
++ sanitizeCssValue,
++ applyTheme,
++ resolveBuiltinTheme,
++ loadFromStorage,
++ saveToStorage,
++ syncThemeToServer,
++ STORAGE_KEY,
++};
ddiff --git a/vendor/openvibe-shared/tooltip.js b/vendor/openvibe-shared/tooltip.js
nnew file mode 100644
iindex 0000000..21fa61a
---- /dev/null
++++ b/vendor/openvibe-shared/tooltip.js
@@@ -0,0 +1,198 @@
++/**
++ * openvibe-shared/tooltip.js — rich tooltips for every OpenVibe property.
++ *
++ * Markup-driven, so a site never writes tooltip code:
++ *
++ * <a data-ovtip="Plain text tip">…</a>
++ * <a data-ovtip-title="API docs"
++ * data-ovtip="<b>Vibe code it.</b> Point an AI at these docs…"
++ * data-ovtip-icon="fa-wand-magic-sparkles"
++ * data-ovtip-place="top"
++ * data-ovtip-accent="#f472b6">…</a>
++ *
++ * One floating element is reused for every tooltip, positioned with fixed coordinates so it is
++ * never clipped by an overflow:hidden ancestor — the usual reason CSS-only tooltips get cut off
++ * inside cards and scroll containers. It flips to stay on screen, follows the trigger while the
++ * page scrolls, opens on hover, focus and long-press, and closes on Escape, scroll-away or tap
++ * elsewhere. Touch devices get a tap-to-open behaviour instead of hover, which is what makes
++ * these usable on a phone at all.
++ */
++(function () {
++ 'use strict';
++ if (typeof window === 'undefined' || window.OpenVibeTooltip) return;
++
++ const CSS = `
++.ovtip{position:fixed;z-index:30000;max-width:min(330px,calc(100vw - 24px));padding:12px 14px;border-radius:14px;
++ background:linear-gradient(165deg,var(--bg-card,#171826) 0%,var(--bg-secondary,#12131c) 100%);
++ border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)));
++ box-shadow:0 20px 50px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.04),0 0 30px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 22%, transparent);
++ color:var(--text-secondary,#b6bdd2);font-size:.83rem;line-height:1.55;pointer-events:none;opacity:0;
++ transform:translateY(6px) scale(.97);transition:opacity .18s ease,transform .22s cubic-bezier(.2,1.3,.3,1);
++ overflow:hidden}
++.ovtip.is-on{opacity:1;transform:none}
++.ovtip.is-interactive{pointer-events:auto}
++.ovtip::before{content:'';position:absolute;inset:0;pointer-events:none;
++ background:radial-gradient(120% 90% at 0% 0%, color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 16%, transparent), transparent 60%)}
++/* A light sweep on open — draws the eye without moving anything around it. */
++.ovtip::after{content:'';position:absolute;top:0;bottom:0;left:-60%;width:45%;pointer-events:none;
++ background:linear-gradient(100deg,transparent,color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 26%, transparent),transparent)}
++.ovtip.is-on::after{animation:ovtipSweep .9s cubic-bezier(.3,.7,.3,1) .1s 1}
++@keyframes ovtipSweep{to{transform:translateX(420%)}}
++.ovtip-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;position:relative}
++.ovtip-ico{width:26px;height:26px;border-radius:8px;display:grid;place-items:center;flex:none;font-size:.78rem;color:#fff;
++ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#a78bfa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 55%,#000));
++ box-shadow:0 4px 12px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, transparent)}
++.ovtip-title{font-size:.9rem;font-weight:800;color:var(--text-primary,#f1f4fb);letter-spacing:-.01em}
++.ovtip-body{position:relative}
++.ovtip-body b,.ovtip-body strong{color:var(--text-primary,#f1f4fb);font-weight:700}
++.ovtip-body em{color:var(--ovtip-accent,var(--accent,#a78bfa));font-style:normal;font-weight:600}
++.ovtip-body code{background:var(--bg-tertiary,#1a1b28);padding:1px 6px;border-radius:5px;font-size:.78rem}
++.ovtip-foot{margin-top:8px;padding-top:8px;border-top:1px dashed var(--border,rgba(255,255,255,.1));
++ font-size:.74rem;color:var(--text-muted,#8b93ad);display:flex;align-items:center;gap:6px;position:relative}
++.ovtip-arrow{position:fixed;z-index:30001;width:10px;height:10px;transform:rotate(45deg);opacity:0;transition:opacity .18s;
++ background:var(--bg-card,#171826);border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)))}
++.ovtip-arrow.is-on{opacity:1}
++.ovtip-arrow.from-top{border-top:0;border-left:0}
++.ovtip-arrow.from-bottom{border-bottom:0;border-right:0}
++.ovtip-actions{display:flex;gap:8px;margin-top:11px;padding-top:10px;border-top:1px solid var(--border,rgba(255,255,255,.1))}
++.ovtip-go,.ovtip-dismiss{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:7px;
++ padding:9px 12px;border-radius:10px;font:inherit;font-size:.8rem;font-weight:750;cursor:pointer;
++ transition:transform .15s,filter .15s}
++.ovtip-go{color:#fff;border:0;
++ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#a78bfa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 60%,#000));
++ box-shadow:0 6px 16px color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 40%,transparent)}
++.ovtip-dismiss{flex:0 0 auto;color:var(--text-muted,#8b93ad);background:none;border:1px solid var(--border,rgba(255,255,255,.12))}
++.ovtip-go:active,.ovtip-dismiss:active{transform:scale(.975)}
++.ovtip-go i{font-size:.72rem}
++/* On a phone a 330px tip pinned beside a button is unreadable and often half off-screen. Dock it
++ to the bottom of the viewport as a full-width card instead. */
++@media (hover:none){
++ .ovtip{position:fixed;left:12px!important;right:12px;top:auto!important;bottom:calc(14px + env(safe-area-inset-bottom,0px));
++ max-width:none;width:auto;padding:14px 16px;border-radius:18px;font-size:.88rem;
++ transform:translateY(14px) scale(1);box-shadow:0 -10px 40px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.05)}
++ .ovtip.is-on{transform:none}
++ .ovtip-arrow{display:none}
++ .ovtip-title{font-size:1rem}
++}
++@media (prefers-reduced-motion:reduce){.ovtip{transition:opacity .12s}.ovtip.is-on::after{animation:none}}`;
++
++ let tip = null, arrow = null, current = null, raf = 0, hideTimer = 0;
++ const isTouch = () => window.matchMedia('(hover: none)').matches;
++
++ function ensure() {
++ if (tip) return;
++ const st = document.createElement('style'); st.id = 'ovtip-css'; st.textContent = CSS; document.head.appendChild(st);
++ tip = document.createElement('div'); tip.className = 'ovtip'; tip.setAttribute('role', 'tooltip');
++ arrow = document.createElement('div'); arrow.className = 'ovtip-arrow';
++ document.body.append(arrow, tip);
++ tip.addEventListener('pointerenter', () => clearTimeout(hideTimer));
++ tip.addEventListener('pointerleave', () => hide());
++ }
++
++ function content(el) {
++ const title = el.getAttribute('data-ovtip-title');
++ const icon = el.getAttribute('data-ovtip-icon');
++ const body = el.getAttribute('data-ovtip') || '';
++ const foot = el.getAttribute('data-ovtip-foot');
++ // On touch the first tap opens the tip and swallows the click, so without an explicit
++ // action the visitor is left tapping a button that appears to do nothing. Give them the
++ // destination as a real button inside the tip — and a way out that isn't "tap elsewhere".
++ const act = isTouch() ? `<div class="ovtip-actions">
++ <button type="button" class="ovtip-dismiss" data-ovtip-dismiss>Got it</button>
++ <button type="button" class="ovtip-go" data-ovtip-go>${esc(el.getAttribute('data-ovtip-go') || 'Open')}<i class="fa-solid fa-arrow-right"></i></button>
++ </div>` : '';
++ return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}
++ <div class="ovtip-body">${body}</div>
++ ${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}${act}`;
++ }
++ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++ function place() {
++ if (!current || !tip) return;
++ if (!current.isConnected) return hide();
++ // Docked to the bottom on touch — CSS owns the position, and following the trigger would
++ // fight it (and hide the tip the moment the page scrolls a little).
++ if (isTouch()) { arrow.classList.remove('is-on'); return; }
++ const r = current.getBoundingClientRect();
++ // Off-screen trigger: nothing to point at.
++ if (r.bottom < -40 || r.top > window.innerHeight + 40) return hide();
++ const tw = tip.offsetWidth, th = tip.offsetHeight, gap = 12;
++ const prefer = current.getAttribute('data-ovtip-place') || 'top';
++ let top, from;
++ const fitsAbove = r.top - th - gap > 8;
++ const fitsBelow = r.bottom + th + gap < window.innerHeight - 8;
++ if ((prefer === 'top' && fitsAbove) || (!fitsBelow && fitsAbove)) { top = r.top - th - gap; from = 'from-bottom'; }
++ else { top = r.bottom + gap; from = 'from-top'; }
++ let left = r.left + r.width / 2 - tw / 2;
++ left = Math.max(12, Math.min(left, window.innerWidth - tw - 12));
++ tip.style.top = `${Math.round(top)}px`;
++ tip.style.left = `${Math.round(left)}px`;
++ const ax = Math.max(left + 14, Math.min(r.left + r.width / 2 - 5, left + tw - 24));
++ arrow.className = `ovtip-arrow is-on ${from}`;
++ arrow.style.left = `${Math.round(ax)}px`;
++ arrow.style.top = `${Math.round(from === 'from-top' ? top - 5 : top + th - 5)}px`;
++ raf = requestAnimationFrame(place);
++ }
++
++ function show(el) {
++ ensure();
++ clearTimeout(hideTimer);
++ if (current === el && tip.classList.contains('is-on')) return;
++ current = el;
++ tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
++ arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
++ tip.innerHTML = content(el);
++ tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive') || isTouch());
++ tip.classList.remove('is-on'); arrow.classList.remove('is-on');
++ cancelAnimationFrame(raf);
++ place();
++ requestAnimationFrame(() => { tip.classList.add('is-on'); arrow.classList.add('is-on'); });
++ el.setAttribute('aria-describedby', 'ovtip');
++ }
++
++ function hide() {
++ if (!tip) return;
++ cancelAnimationFrame(raf);
++ tip.classList.remove('is-on'); arrow.classList.remove('is-on');
++ if (current) current.removeAttribute('aria-describedby');
++ current = null;
++ }
++ const hideSoon = () => { clearTimeout(hideTimer); hideTimer = setTimeout(hide, 120); };
++
++ function trigger(e) { return e.target.closest && e.target.closest('[data-ovtip]'); }
++
++ function bind() {
++ const fromTouch = (e) => e.pointerType === 'touch' || (!e.pointerType && isTouch());
++ document.addEventListener('pointerover', (e) => { if (fromTouch(e)) return; const t = trigger(e); if (t) show(t); });
++ document.addEventListener('pointerout', (e) => { if (fromTouch(e)) return; if (trigger(e)) hideSoon(); });
++ document.addEventListener('focusin', (e) => { const t = trigger(e); if (t) show(t); });
++ document.addEventListener('focusout', (e) => { if (trigger(e)) hideSoon(); });
++ // Touch: first tap opens the tip, a tap elsewhere closes it. The trigger still works —
++ // we never swallow the click, so a tap on an already-open tip follows the link.
++ document.addEventListener('click', (e) => {
++ const t = trigger(e);
++ if (!t) return hide();
++ // On touch the first tap opens the tip; a second tap follows the link.
++ if (isTouch() && current !== t) { e.preventDefault(); show(t); }
++ }, true);
++ // The touch action buttons.
++ document.addEventListener('click', (e) => {
++ const dismiss = e.target.closest && e.target.closest('[data-ovtip-dismiss]');
++ if (dismiss) { e.preventDefault(); e.stopPropagation(); hide(); return; }
++ const go = e.target.closest && e.target.closest('[data-ovtip-go]');
++ if (go && current) {
++ e.preventDefault(); e.stopPropagation();
++ const target = current;
++ hide();
++ // Replay the trigger's own behaviour: its click handler, then its href.
++ if (typeof target.click === 'function') target.click();
++ }
++ }, true);
++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
++ window.addEventListener('scroll', () => { if (current && isTouch()) hide(); }, { passive: true });
++ window.addEventListener('resize', hide, { passive: true });
++ }
++
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind); else bind();
++ window.OpenVibeTooltip = { show, hide, ensure };
++})();
ddiff --git a/vendor/openvibe-shared/url-resolver.js b/vendor/openvibe-shared/url-resolver.js
nnew file mode 100644
iindex 0000000..404d9aa
---- /dev/null
++++ b/vendor/openvibe-shared/url-resolver.js
@@@ -0,0 +1,597 @@
++'use strict';
++
++const URL_DEFINITIONS = Object.freeze({
++ OV_LIVE_URL: {
++ key: 'OV_LIVE_URL',
++ label: 'OpenVibe.Live Public URL',
++ category: 'public_first_party_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.live',
++ description: 'Canonical public origin for OpenVibe.Live.',
++ },
++ OV_TOOLS_URL: {
++ key: 'OV_TOOLS_URL',
++ label: 'OpenVibe.Tools Public URL',
++ category: 'public_first_party_urls',
++ service: 'tools',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.tools',
++ description: 'Canonical public origin for the OpenVibe.Tools gateway.',
++ },
++ OV_GAMES_URL: {
++ key: 'OV_GAMES_URL',
++ label: 'OpenVibe.Games Public URL',
++ category: 'public_first_party_urls',
++ service: 'games',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.games',
++ description: 'Canonical public origin for OpenVibe.Games.',
++ },
++ OV_MEDIA_URL: {
++ key: 'OV_MEDIA_URL',
++ label: 'OpenVibe.Media Public URL',
++ category: 'public_first_party_urls',
++ service: 'media',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.media',
++ description: 'Canonical public origin for OpenVibe.Media.',
++ },
++ WEBRTC_PUBLIC_URL: {
++ key: 'WEBRTC_PUBLIC_URL',
++ label: 'WebRTC Public URL',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'origin',
++ default: 'http://localhost:3000',
++ description: 'Public origin used for WebRTC endpoint discovery and browser consumers.',
++ },
++ WHIP_PUBLIC_URL: {
++ key: 'WHIP_PUBLIC_URL',
++ label: 'WHIP Public URL',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'origin',
++ default: 'http://localhost:3000',
++ description: 'Canonical WHIP ingestion origin for OBS and other WHIP clients.',
++ },
++ WHIP_PUBLIC_URL_ENABLED: {
++ key: 'WHIP_PUBLIC_URL_ENABLED',
++ label: 'Dedicated WHIP Host Enabled',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'boolean',
++ default: false,
++ description: 'Enable use of the dedicated WHIP hostname configured by WHIP_PUBLIC_URL. If disabled, the app falls back to the safe public WebRTC origin.',
++ },
++ JSMPEG_PUBLIC_URL: {
++ key: 'JSMPEG_PUBLIC_URL',
++ label: 'JSMPEG Public URL',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'origin',
++ default: 'http://localhost:3000',
++ description: 'Public origin used to construct JSMPEG relay endpoints for browsers and FFmpeg clients.',
++ },
++ TURN_URL: {
++ key: 'TURN_URL',
++ label: 'TURN URL',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'turn_url',
++ default: '',
++ description: 'TURN server connection string used for WebRTC NAT traversal.',
++ },
++ MEDIASOUP_ANNOUNCED_IP: {
++ key: 'MEDIASOUP_ANNOUNCED_IP',
++ label: 'Mediasoup Announced IP/Hostname',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'hostname',
++ default: 'localhost',
++ description: 'Hostname or public IP advertised in Mediasoup ICE candidates.',
++ },
++ RTMP_HOST: {
++ key: 'RTMP_HOST',
++ label: 'RTMP Host',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'rtmp_host',
++ default: '',
++ description: 'Hostname used for RTMP ingest. Do not include protocol or path.',
++ },
++ OV_NETWORK_URL: {
++ key: 'OV_NETWORK_URL',
++ label: 'OpenVibe.Network URL',
++ category: 'public_first_party_urls',
++ service: 'network',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.network',
++ description: 'Public URL for the OpenVibe.Network SSO, account, and registry service.',
++ },
++ OV_NETWORK_LOGIN_URL: {
++ key: 'OV_NETWORK_LOGIN_URL',
++ label: 'OpenVibe.Network Login URL',
++ category: 'public_first_party_urls',
++ service: 'network',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.network',
++ description: 'Public login URL for OpenVibe.Network. Typically the same as the public OpenVibe.Network origin.',
++ },
++ OV_NETWORK_INTERNAL_URL: {
++ key: 'OV_NETWORK_INTERNAL_URL',
++ label: 'OpenVibe.Network Internal URL',
++ category: 'internal_service_urls',
++ service: 'network',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4000',
++ description: 'Internal URL used by services to call the OpenVibe.Network internal API.',
++ },
++ OV_LIVE_INTERNAL_URL: {
++ key: 'OV_LIVE_INTERNAL_URL',
++ label: 'OpenVibe.Live Internal URL',
++ category: 'internal_service_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:3000',
++ description: 'Internal URL used by OpenVibe.Network or other OpenVibe services to reach OpenVibe.Live.',
++ },
++ OV_TOOLS_INTERNAL_URL: {
++ key: 'OV_TOOLS_INTERNAL_URL',
++ label: 'OpenVibe.Tools Internal URL',
++ category: 'internal_service_urls',
++ service: 'tools',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4001',
++ description: 'Internal URL used by OpenVibe.Network to reach the OpenVibe.Tools gateway.',
++ },
++ OV_GAMES_INTERNAL_URL: {
++ key: 'OV_GAMES_INTERNAL_URL',
++ label: 'OpenVibe.Games Internal URL',
++ category: 'internal_service_urls',
++ service: 'games',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:8000',
++ description: 'Internal URL used by OpenVibe.Network to reach OpenVibe.Games.',
++ },
++ OV_MEDIA_INTERNAL_URL: {
++ key: 'OV_MEDIA_INTERNAL_URL',
++ label: 'OpenVibe.Media Internal URL',
++ category: 'internal_service_urls',
++ service: 'media',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4100',
++ description: 'Internal URL used by OpenVibe.Network to reach OpenVibe.Media.',
++ },
++ OV_MAPS_INTERNAL_URL: {
++ key: 'OV_MAPS_INTERNAL_URL',
++ label: 'Maps.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'maps',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4010',
++ description: 'Internal URL of the Maps.OpenVibe tools satellite.',
++ },
++ OV_FOOD_INTERNAL_URL: {
++ key: 'OV_FOOD_INTERNAL_URL',
++ label: 'Food.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'food',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4011',
++ description: 'Internal URL of the Food.OpenVibe tools satellite.',
++ },
++ OV_IMG_INTERNAL_URL: {
++ key: 'OV_IMG_INTERNAL_URL',
++ label: 'Img.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'img',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4012',
++ description: 'Internal URL of the Img.OpenVibe tools satellite.',
++ },
++ OV_YT_INTERNAL_URL: {
++ key: 'OV_YT_INTERNAL_URL',
++ label: 'YT.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'yt',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4013',
++ description: 'Internal URL of the YT.OpenVibe tools satellite.',
++ },
++ OV_AUDIO_INTERNAL_URL: {
++ key: 'OV_AUDIO_INTERNAL_URL',
++ label: 'Audio.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'audio',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4014',
++ description: 'Internal URL of the Audio.OpenVibe tools satellite.',
++ },
++ OV_TEXT_INTERNAL_URL: {
++ key: 'OV_TEXT_INTERNAL_URL',
++ label: 'Text.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'text',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4015',
++ description: 'Internal URL of the Text.OpenVibe tools satellite.',
++ },
++ OV_DOCS_INTERNAL_URL: {
++ key: 'OV_DOCS_INTERNAL_URL',
++ label: 'Docs.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'docs',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4016',
++ description: 'Internal URL of the Docs.OpenVibe tools satellite.',
++ },
++
++ // ── Brand / Site Identity ─────────────────────────────────────────────────
++ // Configurable during first-time setup and editable in admin.
++ // These values drive display names, JWT metadata, email copy, and CORS policy.
++ // Defaults match the OpenVibe — change for white-label installs.
++
++ NETWORK_NAME: {
++ key: 'NETWORK_NAME',
++ label: 'Network / Organization Name',
++ category: 'brand_identity',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe',
++ description: 'Human-readable name for the overall platform (e.g. "OpenVibe" or your custom brand).',
++ },
++ TOOLS_SERVICE_NAME: {
++ key: 'TOOLS_SERVICE_NAME',
++ label: 'Tools Service Name',
++ category: 'brand_identity',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe.Tools',
++ description: 'Display name for the SSO/tools service (e.g. "OpenVibe.Tools" or "CoolTools").',
++ },
++ STREAMER_SERVICE_NAME: {
++ key: 'STREAMER_SERVICE_NAME',
++ label: 'Streamer Service Name',
++ category: 'brand_identity',
++ service: 'live',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe.Live',
++ description: 'Display name for the streaming service (e.g. "OpenVibe.Live").',
++ },
++ GAMES_SERVICE_NAME: {
++ key: 'GAMES_SERVICE_NAME',
++ label: 'Games Service Name',
++ category: 'brand_identity',
++ service: 'games',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe.Games',
++ description: 'Display name for the games service (e.g. "OpenVibe.Games").',
++ },
++ MEDIA_SERVICE_NAME: {
++ key: 'MEDIA_SERVICE_NAME',
++ label: 'Media Service Name',
++ category: 'brand_identity',
++ service: 'media',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe.Media',
++ description: 'Display name for the media service (e.g. "OpenVibe.Media").',
++ },
++
++ // ── CORS / Origin Policy ──────────────────────────────────────────────────
++ // Controls which additional origins are allowed cross-origin access.
++ // The public service URLs above are always auto-allowed.
++
++ TOOLS_SUBDOMAIN_BASE: {
++ key: 'TOOLS_SUBDOMAIN_BASE',
++ label: 'Tools Subdomain Base Domain',
++ category: 'cors_policy',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'openvibe.tools',
++ description: 'Base domain for which all https://*.{domain} subdomains are trusted first-party origins (e.g. "openvibe.tools"). Leave empty to disable subdomain wildcarding.',
++ },
++ ALLOWED_EXTRA_ORIGINS: {
++ key: 'ALLOWED_EXTRA_ORIGINS',
++ label: 'Extra Allowed Origins (JSON array)',
++ category: 'cors_policy',
++ service: 'network',
++ scope: 'global',
++ type: 'json_array',
++ default: '[]',
++ description: 'JSON array of additional origins allowed by CORS across all services (e.g. ["https://my-cdn.com"]). Auto-includes all configured service public URLs.',
++ },
++
++ // ── Deploy Infrastructure ─────────────────────────────────────────────────
++ // Certificate, DNS, and reverse-proxy management config.
++ // These drive the automated TLS issuance and Nginx generation system.
++
++ DEPLOY_ACME_EMAIL: {
++ key: 'DEPLOY_ACME_EMAIL',
++ label: 'ACME / Let\'s Encrypt Email',
++ category: 'deploy_tls',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: '',
++ description: 'Email address used for Let\'s Encrypt certificate registration and expiry notifications.',
++ },
++ DEPLOY_CERT_MODE: {
++ key: 'DEPLOY_CERT_MODE',
++ label: 'Certificate Mode',
++ category: 'deploy_tls',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'manual',
++ description: 'Certificate provisioning mode: "cloudflare" for automated DNS-01 via Cloudflare API, "manual" for manual DNS challenge, or "none" to skip.',
++ },
++ DEPLOY_CLOUDFLARE_TOKEN: {
++ key: 'DEPLOY_CLOUDFLARE_TOKEN',
++ label: 'Cloudflare API Token (DNS Edit)',
++ category: 'deploy_tls',
++ service: 'network',
++ scope: 'global',
++ type: 'secret',
++ default: '',
++ description: 'Cloudflare API token with Zone:DNS:Edit permissions. Used for automated DNS-01 wildcard certificate challenges.',
++ },
++ DEPLOY_DOMAINS: {
++ key: 'DEPLOY_DOMAINS',
++ label: 'Managed Domains (JSON)',
++ category: 'deploy_tls',
++ service: 'network',
++ scope: 'global',
++ type: 'json_array',
++ default: '[]',
++ description: 'JSON array of domain objects managed by the deploy system. Each entry: {"domain":"openvibe.network","wildcard":false,"certName":"openvibe.network","services":["network"]}.',
++ },
++ DEPLOY_NGINX_MODE: {
++ key: 'DEPLOY_NGINX_MODE',
++ label: 'Nginx Management Mode',
++ category: 'deploy_nginx',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'preview',
++ description: 'Nginx config management mode: "preview" (generate only), "apply" (write + validate + reload), or "disabled".',
++ },
++ DEPLOY_NGINX_SITES_PATH: {
++ key: 'DEPLOY_NGINX_SITES_PATH',
++ label: 'Nginx sites-enabled Path',
++ category: 'deploy_nginx',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: '/etc/nginx/sites-enabled',
++ description: 'Directory where Nginx site configs are written. Defaults to /etc/nginx/sites-enabled.',
++ },
++ DEPLOY_NGINX_BACKUP_PATH: {
++ key: 'DEPLOY_NGINX_BACKUP_PATH',
++ label: 'Nginx Backup Path',
++ category: 'deploy_nginx',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: '/etc/nginx/sites-backup',
++ description: 'Directory for Nginx config backups before apply. Created automatically.',
++ },
++ DEPLOY_SERVICE_MAP: {
++ key: 'DEPLOY_SERVICE_MAP',
++ label: 'Service → Port / Domain Map (JSON)',
++ category: 'deploy_nginx',
++ service: 'network',
++ scope: 'global',
++ type: 'json_map',
++ default: '{}',
++ description: 'JSON map of service configurations for Nginx generation. Each key is a service ID, value has port, domains[], wildcardDomain, maxBodySize, etc.',
++ },
++});
++
++function normalizeOrigin(value) {
++ if (!value || typeof value !== 'string') return null;
++ try {
++ const url = new URL(value.trim());
++ if (!url.protocol || !url.hostname) return null;
++ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
++ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;
++ } catch {
++ return null;
++ }
++}
++
++function normalizeInternalBaseUrl(value) {
++ if (!value || typeof value !== 'string') return null;
++ try {
++ const url = new URL(value.trim());
++ if (!url.protocol || !url.hostname) return null;
++ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
++ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;
++ } catch {
++ return null;
++ }
++}
++
++function normalizeWsOrigin(value) {
++ if (!value || typeof value !== 'string') return null;
++ try {
++ const url = new URL(value.trim());
++ if (!url.hostname) return null;
++ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return null;
++ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;
++ } catch {
++ return null;
++ }
++}
++
++function normalizeHostname(value) {
++ if (!value || typeof value !== 'string') return null;
++ const trimmed = value.trim();
++ if (/^\w+:\/\//.test(trimmed)) {
++ try {
++ const url = new URL(trimmed);
++ return url.hostname || null;
++ } catch {
++ return null;
++ }
++ }
++ if (/[^a-zA-Z0-9.\-:]/.test(trimmed)) return null;
++ return trimmed;
++}
++
++function normalizeTurnUrl(value) {
++ if (!value || typeof value !== 'string') return null;
++ let trimmed = value.trim();
++ if (/^turns?:[^/]/i.test(trimmed)) {
++ trimmed = trimmed.replace(/^turns?:/i, (s) => s + '//');
++ }
++ if (!/^turns?:\/\//i.test(trimmed)) return null;
++ try {
++ const url = new URL(trimmed);
++ if (!url.hostname) return null;
++ const protocol = url.protocol.toLowerCase();
++ if (protocol !== 'turn:' && protocol !== 'turns:') return null;
++ if (url.username || url.password) return null;
++ return `${protocol}${url.hostname}${url.port ? `:${url.port}` : ''}${url.pathname}${url.search}`;
++ } catch {
++ return null;
++ }
++}
++
++function normalizeBoolean(value) {
++ if (value === true || value === 1 || value === '1' || value === 'true' || value === 'TRUE' || value === 'True') {
++ return true;
++ }
++ if (value === false || value === 0 || value === '0' || value === 'false' || value === 'FALSE' || value === 'False') {
++ return false;
++ }
++ return null;
++}
++
++function normalizeJson(value, type) {
++ if (typeof value === 'string') {
++ try {
++ return JSON.parse(value);
++ } catch {
++ return null;
++ }
++ }
++ if (type === 'json_map' && typeof value === 'object' && value !== null && !Array.isArray(value)) {
++ return value;
++ }
++ if (type === 'json_array' && Array.isArray(value)) {
++ return value;
++ }
++ return null;
++}
++
++function normalizeValue(value, type) {
++ if (value === undefined || value === null) return null;
++ switch (type) {
++ case 'origin': return normalizeOrigin(value);
++ case 'internal_base_url': return normalizeInternalBaseUrl(value);
++ case 'ws_origin': return normalizeWsOrigin(value);
++ case 'hostname': return normalizeHostname(value);
++ case 'rtmp_host': return normalizeHostname(value);
++ case 'turn_url': return normalizeTurnUrl(value);
++ case 'json_map': return normalizeJson(value, 'json_map');
++ case 'json_array': return normalizeJson(value, 'json_array');
++ case 'boolean': return normalizeBoolean(value);
++ case 'secret': return typeof value === 'string' ? value.trim() : String(value);
++ case 'string': return typeof value === 'string' ? value.trim() : String(value);
++ default: return typeof value === 'string' ? value.trim() : String(value);
++ }
++}
++
++function validateValue(value, type) {
++ return normalizeValue(value, type) !== null;
++}
++
++function resolveRegistryValues(env = {}, overrides = {}, bootstrap = {}, definitions = URL_DEFINITIONS) {
++ const resolved = {};
++ for (const [key, def] of Object.entries(definitions)) {
++ let rawValue = def.default;
++ let source = 'default';
++ if (env[key]) {
++ rawValue = env[key];
++ source = 'env';
++ }
++ if (bootstrap[key] != null && bootstrap[key] !== '') {
++ rawValue = bootstrap[key];
++ source = 'bootstrap';
++ }
++ if (overrides[key] != null && overrides[key] !== '') {
++ rawValue = overrides[key];
++ source = 'admin';
++ }
++ const normalized = normalizeValue(rawValue, def.type);
++ resolved[key] = {
++ key,
++ label: def.label,
++ category: def.category,
++ service: def.service,
++ scope: def.scope,
++ type: def.type,
++ description: def.description,
++ value: normalized,
++ source,
++ };
++ }
++ return resolved;
++}
++
++function formatRegistryEntry(entry) {
++ if (!entry || typeof entry !== 'object') return null;
++ return {
++ key: entry.key,
++ label: entry.label,
++ category: entry.category,
++ service: entry.service,
++ scope: entry.scope,
++ type: entry.type,
++ description: entry.description,
++ value: entry.value || null,
++ source: entry.source || 'admin',
++ updatedBy: entry.updated_by || null,
++ updatedAt: entry.updated_at || null,
++ };
++}
++
++module.exports = {
++ URL_DEFINITIONS,
++ normalizeValue,
++ validateValue,
++ resolveRegistryValues,
++ formatRegistryEntry,
++};
ddiff --git a/vendor/openvibe-shared/user-card.js b/vendor/openvibe-shared/user-card.js
nnew file mode 100644
iindex 0000000..c0d258e
---- /dev/null
++++ b/vendor/openvibe-shared/user-card.js
@@@ -0,0 +1,252 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — User Card & Context Menu
++// Right-click any username to see profile card, quick actions,
++// linked services, name effects, and moderator tools.
++// Usage: OpenVibeUserCard.init({ apiBase, token, currentUserId })
++// OpenVibeUserCard.attach(element, userId)
++// ═══════════════════════════════════════════════════════════════
++
++(function (root) {
++ 'use strict';
++
++ let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };
++ let _cardEl = null;
++ let _cache = new Map();
++
++ function injectStyles() {
++ if (document.getElementById('openvibe-usercard-styles')) return;
++ const s = document.createElement('style');
++ s.id = 'openvibe-usercard-styles';
++ s.textContent = `
++ @keyframes openvibe-card-in { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
++ .openvibe-usercard-overlay { position: fixed; inset: 0; z-index: 99998; }
++ .openvibe-usercard {
++ position: fixed; z-index: 99999;
++ width: 300px; background: var(--bg-card, #22222c);
++ border: 1px solid var(--border, #333340); border-radius: 12px;
++ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
++ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
++ color: var(--text-primary, #e0e0e0);
++ animation: openvibe-card-in .2s ease; overflow: hidden;
++ }
++ .openvibe-usercard-banner {
++ height: 60px; background: linear-gradient(135deg, var(--accent-dark, #a07840), var(--accent, #8b5cf6));
++ position: relative;
++ }
++ .openvibe-usercard-avatar {
++ width: 56px; height: 56px; border-radius: 50%;
++ border: 3px solid var(--bg-card, #22222c);
++ position: absolute; bottom: -28px; left: 16px;
++ background: var(--bg-tertiary, #2a2a38);
++ object-fit: cover;
++ }
++ .openvibe-usercard-body { padding: 36px 16px 12px; }
++ .openvibe-usercard-name {
++ font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 6px;
++ }
++ .openvibe-usercard-name .role-badge {
++ font-size: 9px; padding: 2px 6px; border-radius: 3px;
++ font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
++ }
++ .openvibe-usercard-name .role-admin { background: rgba(231,76,60,0.15); color: #e74c3c; }
++ .openvibe-usercard-name .role-global_mod { background: rgba(46,204,113,0.15); color: #2ecc71; }
++ .openvibe-usercard-name .role-streamer { background: rgba(155,89,182,0.15); color: #9b59b6; }
++ .openvibe-usercard-username { font-size: 12px; color: var(--text-muted, #707080); margin-bottom: 8px; }
++ .openvibe-usercard-bio { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; margin-bottom: 10px; max-height: 40px; overflow: hidden; }
++ .openvibe-usercard-services { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
++ .openvibe-usercard-services .svc {
++ font-size: 10px; padding: 3px 8px; border-radius: 4px;
++ background: var(--bg-tertiary, #2a2a38); color: var(--text-secondary, #b0b0b8);
++ display: flex; align-items: center; gap: 4px;
++ }
++ .openvibe-usercard-services .svc .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success, #2ecc71); }
++ .openvibe-usercard-divider { height: 1px; background: var(--border, #333340); margin: 8px -16px; }
++ .openvibe-usercard-actions { display: flex; flex-direction: column; gap: 2px; }
++ .openvibe-usercard-actions button {
++ display: flex; align-items: center; gap: 8px;
++ width: 100%; padding: 8px 12px; background: none;
++ border: none; border-radius: 6px; color: var(--text-primary, #e0e0e0);
++ font-size: 12px; cursor: pointer; text-align: left;
++ transition: background .12s;
++ }
++ .openvibe-usercard-actions button:hover { background: var(--bg-hover, #2f2f3d); }
++ .openvibe-usercard-actions button.danger { color: var(--live-red, #e74c3c); }
++ .openvibe-usercard-actions button .icon { font-size: 14px; width: 20px; text-align: center; }
++
++ /* Name effects (CSS classes applied to usernames) */
++ .openvibe-name-fx-rainbow { background: linear-gradient(90deg, #ff0000, #ff7700, #ffff00, #00ff00, #0000ff, #8b00ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; background-size: 200%; animation: openvibe-rainbow 3s linear infinite; }
++ @keyframes openvibe-rainbow { to { background-position: 200% center; } }
++ .openvibe-name-fx-glow { text-shadow: 0 0 8px currentColor, 0 0 16px currentColor; }
++ .openvibe-name-fx-fire { text-shadow: 0 0 4px #ff6600, 0 -2px 8px #ff3300, 0 -4px 12px #cc0000; }
++ .openvibe-name-fx-ice { text-shadow: 0 0 6px #00ccff, 0 0 12px #0088ff; color: #aaddff; }
++ .openvibe-name-fx-gold { background: linear-gradient(180deg, #ffd700, #daa520, #ffd700); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: none; }
++ .openvibe-name-fx-glitch { animation: openvibe-glitch .1s infinite; }
++ @keyframes openvibe-glitch { 0%,100%{transform:none} 25%{transform:translate(-1px,1px)} 50%{transform:translate(1px,-1px)} 75%{transform:translate(-1px,-1px)} }
++ .openvibe-name-fx-neon { color: #39ff14; text-shadow: 0 0 5px #39ff14, 0 0 10px #39ff14, 0 0 20px #39ff14; }
++ .openvibe-name-fx-shadow { text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }
++ `;
++ document.head.appendChild(s);
++ }
++
++ // ── Fetch User Data ──────────────────────────────────────
++ async function fetchUser(userId) {
++ if (_cache.has(userId)) return _cache.get(userId);
++ try {
++ const headers = {};
++ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
++ const res = await fetch(`${_config.apiBase}/api/users/${userId}/card`, { headers, credentials: 'include' });
++ if (!res.ok) return null;
++ const data = await res.json();
++ _cache.set(userId, data);
++ setTimeout(() => _cache.delete(userId), 120_000); // 2min cache
++ return data;
++ } catch { return null; }
++ }
++
++ // ── Render Card ──────────────────────────────────────────
++ function renderCard(data, x, y) {
++ closeCard();
++ const user = data.user;
++ const linked = data.linked_accounts || [];
++
++ const roleBadge = user.role !== 'user'
++ ? `<span class="role-badge role-${user.role}">${user.role.replace('_', ' ')}</span>` : '';
++
++ const nameClass = data.name_effect ? `openvibe-name-fx-${data.name_effect}` : '';
++ const nameColor = user.profile_color ? `color:${user.profile_color}` : '';
++
++ const services = linked.map(l => {
++ const icons = { openvibelive: '📡', openvibegames: '⚔️', openvibemaps: '🗺️' };
++ return `<span class="svc"><span class="dot"></span>${icons[l.service] || '🔗'} ${l.service_username || l.service}</span>`;
++ }).join('');
++
++ const isSelf = _config.currentUserId && String(_config.currentUserId) === String(user.id);
++ const isMod = ['admin', 'global_mod'].includes(_config.currentRole);
++
++ let actions = '';
++ if (!isSelf) {
++ actions += `<button data-act="profile"><span class="icon">👤</span> View Profile</button>`;
++ actions += `<button data-act="message"><span class="icon">💬</span> Send Message</button>`;
++ actions += `<button data-act="follow"><span class="icon">➕</span> Follow</button>`;
++ if (isMod) {
++ actions += `<div class="openvibe-usercard-divider"></div>`;
++ actions += `<button data-act="warn"><span class="icon">⚠️</span> Warn User</button>`;
++ actions += `<button data-act="mute"><span class="icon">🔇</span> Mute</button>`;
++ actions += `<button data-act="ban" class="danger"><span class="icon">🚫</span> Ban User</button>`;
++ }
++ } else {
++ actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;
++ actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;
++ }
++
++ const card = document.createElement('div');
++ card.className = 'openvibe-usercard';
++
++ const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;
++ card.innerHTML = `
++ <div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>
++ <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" onerror="this.style.display='none'">
++ <div class="openvibe-usercard-body">
++ <div class="openvibe-usercard-name">
++ <span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>
++ ${roleBadge}
++ </div>
++ <div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>
++ ${user.bio ? `<div class="openvibe-usercard-bio">${user.bio}</div>` : ''}
++ ${services ? `<div class="openvibe-usercard-services">${services}</div>` : ''}
++ <div class="openvibe-usercard-divider"></div>
++ <div class="openvibe-usercard-actions">${actions}</div>
++ </div>
++ `;
++
++ // Position
++ const vw = window.innerWidth, vh = window.innerHeight;
++ card.style.left = Math.min(x, vw - 320) + 'px';
++ card.style.top = Math.min(y, vh - 400) + 'px';
++
++ // Overlay to close on click-outside
++ const overlay = document.createElement('div');
++ overlay.className = 'openvibe-usercard-overlay';
++ overlay.addEventListener('click', closeCard);
++ overlay.addEventListener('contextmenu', e => { e.preventDefault(); closeCard(); });
++
++ document.body.appendChild(overlay);
++ document.body.appendChild(card);
++ _cardEl = { card, overlay };
++
++ // Action handlers
++ card.querySelectorAll('[data-act]').forEach(btn => {
++ btn.addEventListener('click', () => {
++ const act = btn.dataset.act;
++ closeCard();
++ if (act === 'profile') {
++ const url = isSelf ? `${_config.apiBase}/my`
++ : `${_config.apiBase}/user/${user.username}`;
++ window.open(url, '_blank');
++ } else if (act === 'message') {
++ // Dispatch custom event for the host app to handle
++ document.dispatchEvent(new CustomEvent('openvibe-user-action', {
++ detail: { action: act, user }
++ }));
++ } else {
++ document.dispatchEvent(new CustomEvent('openvibe-user-action', {
++ detail: { action: act, user }
++ }));
++ }
++ });
++ });
++ }
++
++ function closeCard() {
++ if (_cardEl) {
++ _cardEl.card.remove();
++ _cardEl.overlay.remove();
++ _cardEl = null;
++ }
++ }
++
++ // ── Public API ───────────────────────────────────────────
++ const OpenVibeUserCard = {
++ init(opts = {}) {
++ Object.assign(_config, opts);
++ injectStyles();
++ },
++
++ /** Attach right-click card to an element. */
++ attach(el, userId) {
++ el.style.cursor = 'pointer';
++ el.addEventListener('contextmenu', async e => {
++ e.preventDefault();
++ const data = await fetchUser(userId);
++ if (data) renderCard(data, e.clientX, e.clientY);
++ });
++ // Also support click on mobile
++ el.addEventListener('click', async e => {
++ if (e.ctrlKey || e.metaKey) {
++ e.preventDefault();
++ const data = await fetchUser(userId);
++ if (data) renderCard(data, e.clientX, e.clientY);
++ }
++ });
++ },
++
++ /** Programmatically show a card at position. */
++ async show(userId, x, y) {
++ const data = await fetchUser(userId);
++ if (data) renderCard(data, x, y);
++ },
++
++ close: closeCard,
++ clearCache() { _cache.clear(); },
++
++ /** Apply name effect CSS class to an element. */
++ applyNameEffect(el, effectName) {
++ if (!effectName) return;
++ el.classList.add(`openvibe-name-fx-${effectName}`);
++ },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;
++ else root.OpenVibeUserCard = OpenVibeUserCard;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);