openvibers/openvibe.community Commits
Scaffold the Community app: package, vendored openvibe-shared, config, env example
Changes
nnew file mode 100644@@@ -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@@@ -2,7 +2,11 @@ node_modules/data/dist/.env++.env.*++!.env.example*.db*.db-wal*.db-shm*.log++*.pem++*.keynnew file mode 100644@@@ -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"++ }++ }++ }++}nnew file mode 100644@@@ -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"++ }++}ddeleted file mode 100644@@@ -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>nnew file mode 100644@@@ -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(/\/$/, ''),++};nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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 }; } };++});nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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'),++};nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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, '&').replace(/"/g, '"');++ }++++ 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);nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');++ }++ 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">×</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">×</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">×</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);nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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);++ })());++});nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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"++ }++}nnew file mode 100644@@@ -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)`);nnew file mode 100644@@@ -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);++});nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };++})();nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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
@@ -2,7 +2,11 @@ node_modules/
@@ -2,7 +2,11 @@ node_modules/
data/
data/
dist/
dist/
.env
.env
+.env.*
+!.env.example
*.db
*.db
*.db-wal
*.db-wal
*.db-shm
*.db-shm
*.log
*.log
+*.pem
+*.key
new file mode 100644
new file mode 100644
@@ -0,0 +1,1032 @@
@@ -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"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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"
+ }
+}
deleted file mode 100644
deleted file mode 100644
@@ -1,40 +0,0 @@
@@ -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>
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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(/\/$/, ''),
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,676 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1066 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,949 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,299 @@
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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 }; } };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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'),
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,1130 @@
@@ -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, '&').replace(/"/g, '"');
+ }
+
+ 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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,676 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+ }
+ 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">×</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">×</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">×</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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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);
+ })());
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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)`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,301 @@
@@ -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);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,272 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,198 @@
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,597 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,252 @@
@@ -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);