openvibers/openvibe.media Commits

Initial OpenVibe release

OP OpenVibers committed 17 Aug 2026 · 065dac9d84 · 46 files, +14772 −0


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..3a60647
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,59 @@
++# ── OpenVibe.Media — environment ─────────────────────────────
++# Copy to .env (or /etc/openvibe/media.env in production) and adjust.
++
++# HTTP
++PORT=4100
++HOST=0.0.0.0
++NODE_ENV=development
++# Public base URL used when building absolute playback/asset URLs.
++MEDIA_PUBLIC_URL=https://openvibe.media
++
++# Database (better-sqlite3, WAL)
++DB_PATH=./data/media.db
++
++# Storage paths (all created on boot)
++VOD_PATH=./data/vods
++CLIPS_PATH=./data/clips
++PASTES_PATH=./data/pastes
++THUMBNAILS_PATH=./data/thumbnails
++FILES_PATH=./data/files
++
++# RTP ingest port pool (UDP, bound on 127.0.0.1 by ffmpeg)
++RTP_PORT_MIN=12000
++RTP_PORT_MAX=12199
++
++# Identity provider (OpenVibe.Network) — JWKS fetched at boot for offline
++# RS256 verification of user JWTs on browser endpoints.
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++# Per-service key for Network's /internal/* endpoints (header X-Internal-Key).
++INTERNAL_API_KEY=
++
++# ── Tenant (app) seeding ─────────────────────────────────────
++# JSON array, upserted into the `apps` table on every boot (api_key is hashed,
++# never stored in plaintext). allowed_origins guards browser (user-JWT) calls.
++# MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"change-me","webhook_url":"http://127.0.0.1:3000/api/media-webhook","webhook_secret":"change-me-too","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'
++MEDIA_APPS_SEED=
++# Fallback short form (no webhooks/origins): "live:key1,games:key2"
++MEDIA_APP_KEYS=
++
++# ── Object storage tiering (optional; local-only when unset) ─
++# Backblaze B2 (canonical cold tier)
++MEDIA_B2_ENDPOINT=
++MEDIA_B2_REGION=us-west-004
++MEDIA_B2_BUCKET=
++MEDIA_B2_KEY_ID=
++MEDIA_B2_APP_KEY=
++# Cloudflare R2 (popularity cache, free egress)
++MEDIA_R2_ENDPOINT=
++MEDIA_R2_REGION=auto
++MEDIA_R2_BUCKET=
++MEDIA_R2_ACCESS_KEY_ID=
++MEDIA_R2_SECRET_ACCESS_KEY=
++
++# ── Recording knobs (defaults match the inherited recorder) ──
++# VOD_STOP_GRACE_MS=60000
++# VOD_DISK_WARN_GB=15
++# VOD_DISK_CRIT_GB=5
++# MAX_VOD_SIZE_MB=2048
++# VOD_NO_NORMALIZE=0
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..e58ad08
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,5 @@
++node_modules/
++data/
++.env
++*.log
++.smoke/
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..1bd380e
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,223 @@
++# OpenVibe.Media
++
++Standalone multi-tenant media service (port **4100**) extracted from the
++OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
++(local → Backblaze B2 → Cloudflare R2), clips, pastes, generic files, and
++thumbnails, for all OpenVibe apps (`live`, `games`, `tools`, `network`).
++
++Implements **Media API v1** from `../CONTRACTS.md`.
++
++```
++npm install
++cp .env.example .env # set MEDIA_APPS_SEED at minimum
++npm start # node server/index.js
++```
++
++Requires Node ≥ 20, `ffmpeg`/`ffprobe` on PATH. SQLite (better-sqlite3, WAL).
++
++## Layout
++
++```
++server/
++ index.js boot, route mounts, background jobs, graceful shutdown
++ config.js env config (PORT, DB_PATH, *_PATH, MEDIA_B2_*/R2_*, RTP pool, OV_NETWORK_URL)
++ auth.js tenancy + auth middleware, JWKS fetch, app seeding
++ webhooks.js HMAC-signed outbound webhooks
++ db/schema.sql apps, vods, clips, pastes(+likes/comments), files, content_views, media_settings
++ db/database.js better-sqlite3 helpers, all app_id-scoped; legacy row importer
++ vod/recorder.js ffmpeg recording: RTMP pull + RTP (SDP) ingest, codec passthrough
++ vod/media-tools.js probes, seekable remux, DVR sidecar, chunk-segment concat
++ vod/finalize.js finalize pipeline (remux → probe → master recovery → thumbnail → webhook)
++ vod/routes.js /api/v1/:app/vods (+ ingest + chunks)
++ vod/clip-cutter.js re-encoding clip cutter (local file or presigned URL source)
++ vod/clips-routes.js /api/v1/:app/clips
++ vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
++ vod/health-scanner.js probe/decode/master-recovery primitives
++ vod/health-job.js background health scan + quarantine cleanup + master sweep
++ pastes/routes.js /api/v1/:app/pastes
++ files/routes.js /api/v1/:app/files
++ thumbnails/ thumbnail service + /api/v1/:app/thumbnails
++ public/routes.js public /v /c /p /t /f
++vendor/openvibe-shared/ vendored shared helpers (do not edit; re-sync from canonical)
++scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
++```
++
++## Tenancy & auth
++
++Tenant = the `:app` path segment. Rows in the `apps` table define tenants:
++
++| column | meaning |
++|---|---|
++| `app_id` | tenant id (`live`, `games`, …) |
++| `api_key_hash` | sha256 of the app API key (never stored in plaintext) |
++| `webhook_url` / `webhook_secret` | outbound event delivery (HMAC) |
++| `allowed_origins` | JSON array; guards browser (user-JWT) calls |
++| `quota_bytes` | files-subsystem quota (0 = unlimited) |
++
++Two credential types on `/api/v1/:app/...`:
++
++1. **App API key** — `Authorization: Bearer <app_api_key>`. Compared in
++ constant time against the app's stored hash. A key is only valid for its
++ own `:app` segment; presenting another app's key returns **403**.
++2. **Network user JWT** (browser endpoints only — chunks/complete, clips,
++ pastes, files, thumbnails) — RS256, verified **offline** against the JWKS
++ public key fetched from `OV_NETWORK_URL/api/.well-known/jwks` at boot
++ (cached, refreshed every 6 h). `aud` must include `openvibe.media` when
++ present. If the request carries an `Origin` header it must be in the app's
++ `allowed_origins` (CORS is reflected for allow-listed origins only).
++
++### App seeding
++
++On every boot the service upserts apps from env (keys are hashed):
++
++```bash
++MEDIA_APPS_SEED='[{"app_id":"live","name":"OpenVibe.Live","api_key":"…","webhook_url":"http://127.0.0.1:3000/api/media-webhook","webhook_secret":"…","allowed_origins":["https://openvibe.live"],"quota_bytes":0}]'
++# fallback short form (no webhooks/origins):
++MEDIA_APP_KEYS="live:key1,games:key2"
++```
++
++## API (all under `/api/v1/:app`)
++
++### VODs
++
++| method | path | notes |
++|---|---|---|
++| POST | `/vods` | `{ title?, stream_id?, stream_key?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |
++| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4` |
++| POST | `/vods/:id/ingest/rtp/start` | `{ video: {payloadType, codec, clockRate, ssrc?, parameters?}, audio?: {…} }` → `{ videoPort, audioPort }` from UDP 12000-12199; point PlainRtpTransports at `127.0.0.1` (RTCP = port+1) |
++| POST | `/vods/:id/ingest/rtp/stop` | finalizes the recording |
++| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |
++| POST | `/vods/:id/complete` | finalize chunked upload (user JWT ok) |
++| POST | `/vods/:id/finalize` | close recording; remux, probe, thumbnail, webhook |
++| GET | `/vods/:id` | `{ id, title, status, duration, playback_url, thumbnail_url, storage_provider, … }` |
++| GET | `/vods?limit&offset&user_id&stream_id` | list |
++| PUT | `/vods/:id` | `{ title?, description?, visibility? }` |
++| DELETE | `/vods/:id` | deletes local + B2 + R2 objects + row |
++
++`status`: `pending → recording → ready | failed` (derived; failures come from
++health quarantine: corrupt / zero-byte / missing file).
++
++**Recording formats** (inherited codec-passthrough behavior — VODs are *not*
++always `.webm`): RTMP and RTP-H.264 record by lossless stream copy into a
++fragmented **`.mp4`** (audio → AAC on the RTP path); RTP-VP8/VP9 copies into
++**`.webm`** (Opus copied); exotic codecs fall back to a libvpx re-encode with a
++lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
++60 s during recording so `/v/:id` is DVR-seekable while live.
++
++### Clips
++
++| method | path | notes |
++|---|---|---|
++| POST | `/clips` | `{ vod_id, start_s, end_s, title?, user_id? }` → **202** `{ id, status: 'processing' }`; cut runs in background (from the local file or a presigned B2/R2 URL); duplicate windows are deduplicated; live recordings are clamped to flushed footage |
++| GET | `/clips/:id` | status: `processing | ready | failed` |
++| GET | `/clips?limit&offset&vod_id&stream_id&user_id` | list |
++| PUT | `/clips/:id` | `{ title?, visibility? }` |
++| DELETE | `/clips/:id` | local + offloaded objects + row |
++
++### Pastes
++
++| method | path | notes |
++|---|---|---|
++| POST | `/pastes` | `{ title?, content?, language?, user_id?, visibility?, burn_after_read?, is_nsfw? }` or multipart with `screenshot` image (EXIF-stripped via sharp) → `{ id, slug, url }` |
++| GET | `/pastes?limit&offset&type&search&user_id` | public list |
++| GET | `/pastes/:slug` | full paste (private: owner/app only) |
++| PUT | `/pastes/:slug` | update (owner/app) |
++| DELETE | `/pastes/:slug` | delete + screenshot (local & legacy B2 object) |
++| POST | `/pastes/:slug/fork` | fork a text paste |
++| POST | `/pastes/:slug/like` | toggle like (needs a user identity) |
++| POST | `/pastes/:slug/copy` | track a copy event |
++| GET/POST | `/pastes/:slug/comments` | threaded comments (anon supported) |
++| DELETE | `/pastes/:slug/comments/:id` | author/paste-owner/app |
++
++Cooldowns and daily limits (`media_settings`: `paste_cooldown_seconds`,
++`paste_max_per_user_per_day`, sizes) apply to user-JWT callers; app-key callers
++are trusted server-to-server. AI summary/tags generation was **dropped** (Live
++owns AI) but the columns remain for imported rows.
++
++### Files
++
++| method | path | notes |
++|---|---|---|
++| POST | `/files` | multipart `file` → `{ key, url, size, mime }`; key = `<sha256[0:12]>-<name>`; per-app `quota_bytes` enforced (413 on exceed) |
++| GET | `/files` | list + `used_bytes`/`quota_bytes` |
++| GET | `/files/:key` | meta |
++| DELETE | `/files/:key` | delete |
++
++### Thumbnails
++
++| method | path | notes |
++|---|---|---|
++| POST | `/thumbnails/:kind/:id` | kind `vod`/`clip`: multipart `thumbnail` (or `{image: base64}`) uploads a custom image, or with no body (re)generates from the media (VOD @10%, clip near first frame); kind `live`: upload a broadcaster frame, stored under the stable name `stream-<app>-<id>.jpg` → `{ url }` |
++
++### Webhooks (outbound)
++
++`POST` to the app's `webhook_url` with body
++`{ "event": "vod.ready"|"vod.failed"|"clip.ready"|"clip.failed", "app_id", "data" }`
++and header `X-OVMedia-Signature: sha256=<hex hmac-sha256 of the raw body with
++the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
++
++## Public serving (no auth unless the item is private)
++
++| path | behavior |
++|---|---|
++| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex` |
++| `GET /c/:id` | clip playback, same logic, `noindex` |
++| `GET /p/:slug` | server-rendered paste HTML page (**indexable** — Media is the canonical home for pastes) |
++| `GET /p/:slug/raw` | `text/plain` |
++| `GET /p/:slug/screenshot` | paste screenshot image |
++| `GET /t/:id` | thumbnail (id = filename), `noindex` |
++| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
++
++Private items respond 403/404 unless the request bears the owning app's API
++key or the owning user's JWT.
++
++## Storage tiering
++
++`vod/vod-storage.js` (ported nearly as-is): local → B2 canonical cold tier →
++R2 popularity cache; verified uploads before any deletion; periodic sweep
++(offload by age/views/last-access, aggressive drain under disk pressure, R2
++promote/demote); presigned-302 playback. Knobs live in `media_settings` under
++`storage_tier.*` (JSON values). CLI:
++
++```
++node server/vod/vod-storage.js check|migrate-legacy|drain [targetPct]
++```
++
++Background jobs wired in `index.js`: tiering sweep, VOD health job (probe scan,
++master-recovery repair, quarantine + cleanup, orphaned-master sweep), disk
++guardian (refuses new recordings when free space is critical), stale live-thumb
++cleanup, and an on-boot sweep that finalizes recordings orphaned by an unclean
++shutdown.
++
++## Migration from the predecessor DB
++
++Every domain table carries `app_id TEXT NOT NULL DEFAULT 'live'`, and the new
++schema keeps all predecessor columns (including legacy `storage_tier`), so the
++cutover script can bulk-copy rows unchanged:
++
++```js
++const media = require('./server/db/database');
++const old = require('better-sqlite3')('/path/to/old-live.db', { readonly: true });
++for (const table of ['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'content_views']) {
++ const rows = old.prepare(`SELECT * FROM ${table}`).all();
++ console.log(table, media.importLegacyRows(table, rows, 'live'));
++}
++```
++
++`importLegacyRows` inserts by column-name intersection (unknown legacy columns
++ignored, new columns take defaults), preserves ids, backfills `app_id`, and is
++idempotent (`INSERT OR IGNORE`). After the copy, run
++`node server/vod/vod-storage.js migrate-legacy` to map legacy `storage_tier =
++'cold'` rows to provider `b2`.
++
++## Not ported
++
++- **Song-request media queue** (`media/` yt-dlp downloader/queue): deeply
++ coupled to Live's channels/chat/coins subsystems; Live keeps it local.
++- Server-side live thumbnail grabbers (RTMP-FLV / JSMPEG-WS / SFU PlainRTP)
++ and JSMPEG stdin recording: sources live inside the apps now; apps POST
++ frames / use RTMP-RTP-chunk ingest instead.
++- VOD part rotation + live-recording reconciler: tied to the predecessor's
++ stream registry; apps re-trigger ingest on their side.
++- Comments on VODs/clips, notifications, permissions/moderation ranks: owned
++ by the apps.
ddiff --git a/deploy/nginx/openvibe.media.conf b/deploy/nginx/openvibe.media.conf
nnew file mode 100644
iindex 0000000..a27cb06
---- /dev/null
++++ b/deploy/nginx/openvibe.media.conf
@@@ -0,0 +1,29 @@
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.media www.openvibe.media;
++ return 301 https://openvibe.media$request_uri;
++}
++
++server {
++ listen 443 ssl http2;
++ listen [::]:443 ssl http2;
++ server_name openvibe.media www.openvibe.media;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.media/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.media/privkey.pem;
++
++ client_max_body_size 4096m;
++ proxy_read_timeout 3600s;
++ proxy_send_timeout 3600s;
++
++ location / {
++ proxy_pass http://127.0.0.1:4100;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ proxy_request_buffering off;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-media.service b/deploy/systemd/openvibe-media.service
nnew file mode 100644
iindex 0000000..d5f7944
---- /dev/null
++++ b/deploy/systemd/openvibe-media.service
@@@ -0,0 +1,25 @@
++# OpenVibe.Media — multi-tenant media service (VOD recording/serving, clips, pastes, files).
++# Secrets live in /etc/openvibe/media.env (0600).
++[Unit]
++Description=OpenVibe.Media service
++After=network.target
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.media
++EnvironmentFile=/etc/openvibe/media.env
++ExecStart=/usr/bin/env node /opt/openvibe.media/server/index.js
++Restart=always
++RestartSec=3
++TimeoutStopSec=30
++KillSignal=SIGTERM
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++ReadWritePaths=/opt/openvibe.media/data
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..4260f1a
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,2508 @@
++{
++ "name": "openvibe-media",
++ "version": "1.0.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-media",
++ "version": "1.0.0",
++ "license": "MIT",
++ "dependencies": {
++ "@aws-sdk/client-s3": "^3.600.0",
++ "@aws-sdk/lib-storage": "^3.600.0",
++ "@aws-sdk/s3-request-presigner": "^3.600.0",
++ "better-sqlite3": "^11.0.0",
++ "dotenv": "^16.4.5",
++ "express": "^4.19.2",
++ "multer": "^1.4.5-lts.1",
++ "openvibe-shared": "file:./vendor/openvibe-shared",
++ "sharp": "^0.33.4"
++ }
++ },
++ "node_modules/@aws-sdk/checksums": {
++ "version": "3.1000.28",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz",
++ "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/client-s3": {
++ "version": "3.1111.0",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz",
++ "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/checksums": "^3.1000.28",
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/credential-provider-node": "^3.972.80",
++ "@aws-sdk/middleware-sdk-s3": "^3.972.74",
++ "@aws-sdk/signature-v4-multi-region": "^3.996.45",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/fetch-http-handler": "^5.6.13",
++ "@smithy/node-http-handler": "^4.9.13",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/core": {
++ "version": "3.977.8",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz",
++ "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/types": "^3.974.4",
++ "@aws-sdk/xml-builder": "^3.972.39",
++ "@aws/lambda-invoke-store": "^0.3.0",
++ "@smithy/core": "^3.31.1",
++ "@smithy/signature-v4": "^5.6.12",
++ "@smithy/types": "^4.16.1",
++ "bowser": "^2.11.0",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/credential-provider-env": {
++ "version": "3.972.69",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz",
++ "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/credential-provider-http": {
++ "version": "3.972.71",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz",
++ "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/fetch-http-handler": "^5.6.13",
++ "@smithy/node-http-handler": "^4.9.13",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/credential-provider-ini": {
++ "version": "3.973.14",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz",
++ "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/credential-provider-env": "^3.972.69",
++ "@aws-sdk/credential-provider-http": "^3.972.71",
++ "@aws-sdk/credential-provider-login": "^3.972.76",
++ "@aws-sdk/credential-provider-process": "^3.972.69",
++ "@aws-sdk/credential-provider-sso": "^3.973.13",
++ "@aws-sdk/credential-provider-web-identity": "^3.972.75",
++ "@aws-sdk/nested-clients": "^3.997.43",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/credential-provider-imds": "^4.4.16",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/credential-provider-login": {
++ "version": "3.972.76",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz",
++ "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/nested-clients": "^3.997.43",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/credential-provider-node": {
++ "version": "3.972.80",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz",
++ "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/credential-provider-env": "^3.972.69",
++ "@aws-sdk/credential-provider-http": "^3.972.71",
++ "@aws-sdk/credential-provider-ini": "^3.973.14",
++ "@aws-sdk/credential-provider-process": "^3.972.69",
++ "@aws-sdk/credential-provider-sso": "^3.973.13",
++ "@aws-sdk/credential-provider-web-identity": "^3.972.75",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/credential-provider-imds": "^4.4.16",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/credential-provider-process": {
++ "version": "3.972.69",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz",
++ "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/credential-provider-sso": {
++ "version": "3.973.13",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz",
++ "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/nested-clients": "^3.997.43",
++ "@aws-sdk/token-providers": "3.1111.0",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/credential-provider-web-identity": {
++ "version": "3.972.75",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz",
++ "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/nested-clients": "^3.997.43",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/lib-storage": {
++ "version": "3.1111.0",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1111.0.tgz",
++ "integrity": "sha512-UjLJE0Zxv/m8Xgd3DLBXssZYISgoEPZpJiPWY+MadEdP36oOcjHVsR8ToUq6MMmKdzuBFYB6qGHWS/yKAG4fow==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "buffer": "5.6.0",
++ "events": "3.3.0",
++ "stream-browserify": "3.0.0",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ },
++ "peerDependencies": {
++ "@aws-sdk/client-s3": "^3.1111.0"
++ }
++ },
++ "node_modules/@aws-sdk/middleware-sdk-s3": {
++ "version": "3.972.74",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz",
++ "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/signature-v4-multi-region": "^3.996.45",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/nested-clients": {
++ "version": "3.997.43",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz",
++ "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/signature-v4-multi-region": "^3.996.45",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/fetch-http-handler": "^5.6.13",
++ "@smithy/node-http-handler": "^4.9.13",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/s3-request-presigner": {
++ "version": "3.1111.0",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1111.0.tgz",
++ "integrity": "sha512-ACp/VtDTw6AjFW5Q3M59uAMrBbAHeQS0UBIOIgUROO98Z3uhpiy37k9RmvxbXYVugmTcdNTy4DPVQtLG8sDy6g==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/signature-v4-multi-region": "^3.996.45",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/signature-v4-multi-region": {
++ "version": "3.996.45",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz",
++ "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/signature-v4": "^5.6.12",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/token-providers": {
++ "version": "3.1111.0",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz",
++ "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@aws-sdk/core": "^3.977.8",
++ "@aws-sdk/nested-clients": "^3.997.43",
++ "@aws-sdk/types": "^3.974.4",
++ "@smithy/core": "^3.31.1",
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/types": {
++ "version": "3.974.4",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz",
++ "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws-sdk/xml-builder": {
++ "version": "3.972.39",
++ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz",
++ "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@smithy/types": "^4.16.1",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/@aws/lambda-invoke-store": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
++ "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
++ "node_modules/@emnapi/runtime": {
++ "version": "1.11.3",
++ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
++ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
++ "license": "MIT",
++ "optional": true,
++ "dependencies": {
++ "tslib": "^2.4.0"
++ }
++ },
++ "node_modules/@img/sharp-darwin-arm64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
++ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-darwin-arm64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-darwin-x64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
++ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-darwin-x64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-libvips-darwin-arm64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
++ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-darwin-x64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
++ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-arm": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
++ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
++ "cpu": [
++ "arm"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-arm64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
++ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-s390x": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
++ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
++ "cpu": [
++ "s390x"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-x64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
++ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
++ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
++ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-linux-arm": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
++ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
++ "cpu": [
++ "arm"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-arm": "1.0.5"
++ }
++ },
++ "node_modules/@img/sharp-linux-arm64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
++ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-arm64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-s390x": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
++ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
++ "cpu": [
++ "s390x"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-s390x": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-x64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
++ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-x64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-linuxmusl-arm64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
++ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-linuxmusl-x64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
++ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-wasm32": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
++ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
++ "cpu": [
++ "wasm32"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
++ "optional": true,
++ "dependencies": {
++ "@emnapi/runtime": "^1.2.0"
++ },
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-win32-ia32": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
++ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
++ "cpu": [
++ "ia32"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-win32-x64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
++ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@smithy/core": {
++ "version": "3.33.2",
++ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz",
++ "integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@smithy/types": "^4.17.2",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
++ "node_modules/@smithy/credential-provider-imds": {
++ "version": "4.5.2",
++ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
++ "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@smithy/core": "^3.33.2",
++ "@smithy/types": "^4.17.2",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
++ "node_modules/@smithy/fetch-http-handler": {
++ "version": "5.7.2",
++ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz",
++ "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@smithy/core": "^3.33.2",
++ "@smithy/types": "^4.17.2",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
++ "node_modules/@smithy/node-http-handler": {
++ "version": "4.11.2",
++ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.2.tgz",
++ "integrity": "sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@smithy/core": "^3.33.2",
++ "@smithy/types": "^4.17.2",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
++ "node_modules/@smithy/signature-v4": {
++ "version": "5.7.2",
++ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.2.tgz",
++ "integrity": "sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@smithy/core": "^3.33.2",
++ "@smithy/types": "^4.17.2",
++ "tslib": "^2.6.2"
++ },
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
++ "node_modules/@smithy/types": {
++ "version": "4.17.2",
++ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz",
++ "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "tslib": "^2.6.2"
++ },
++ "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/append-field": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
++ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
++ "license": "MIT"
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/bl/node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.6",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
++ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
++ "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.15.1",
++ "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/bowser": {
++ "version": "2.14.1",
++ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
++ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
++ "license": "MIT"
++ },
++ "node_modules/buffer": {
++ "version": "5.6.0",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
++ "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.0.2",
++ "ieee754": "^1.1.4"
++ }
++ },
++ "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/buffer-from": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
++ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
++ "license": "MIT"
++ },
++ "node_modules/busboy": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
++ "dependencies": {
++ "streamsearch": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=10.16.0"
++ }
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/color": {
++ "version": "4.2.3",
++ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
++ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
++ "license": "MIT",
++ "dependencies": {
++ "color-convert": "^2.0.1",
++ "color-string": "^1.9.0"
++ },
++ "engines": {
++ "node": ">=12.5.0"
++ }
++ },
++ "node_modules/color-convert": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
++ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
++ "license": "MIT",
++ "dependencies": {
++ "color-name": "~1.1.4"
++ },
++ "engines": {
++ "node": ">=7.0.0"
++ }
++ },
++ "node_modules/color-name": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
++ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
++ "license": "MIT"
++ },
++ "node_modules/color-string": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
++ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
++ "license": "MIT",
++ "dependencies": {
++ "color-name": "^1.0.0",
++ "simple-swizzle": "^0.2.2"
++ }
++ },
++ "node_modules/concat-stream": {
++ "version": "1.6.2",
++ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
++ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
++ "engines": [
++ "node >= 0.8"
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "buffer-from": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^2.2.2",
++ "typedarray": "^0.0.6"
++ }
++ },
++ "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-signature": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
++ "license": "MIT"
++ },
++ "node_modules/core-util-is": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
++ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
++ "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/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/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/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/events": {
++ "version": "3.3.0",
++ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
++ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.8.x"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.2",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
++ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
++ "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.12",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.15.1",
++ "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/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "license": "MIT"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "license": "MIT"
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "license": "MIT"
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/is-arrayish": {
++ "version": "0.3.4",
++ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
++ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
++ "license": "MIT"
++ },
++ "node_modules/isarray": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
++ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
++ "license": "MIT"
++ },
++ "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/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp": {
++ "version": "0.5.6",
++ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
++ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
++ "license": "MIT",
++ "dependencies": {
++ "minimist": "^1.2.6"
++ },
++ "bin": {
++ "mkdirp": "bin/cmd.js"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/multer": {
++ "version": "1.4.5-lts.2",
++ "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
++ "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
++ "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
++ "license": "MIT",
++ "dependencies": {
++ "append-field": "^1.0.0",
++ "busboy": "^1.0.0",
++ "concat-stream": "^1.5.2",
++ "mkdirp": "^0.5.4",
++ "object-assign": "^4.1.1",
++ "type-is": "^1.6.4",
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">= 6.0.0"
++ }
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/node-abi": {
++ "version": "3.94.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
++ "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-assign": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
++ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-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/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/process-nextick-args": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
++ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
++ "license": "MIT"
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.7",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
++ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.15.3",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
++ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "2.3.8",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
++ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
++ "license": "MIT",
++ "dependencies": {
++ "core-util-is": "~1.0.0",
++ "inherits": "~2.0.3",
++ "isarray": "~1.0.0",
++ "process-nextick-args": "~2.0.0",
++ "safe-buffer": "~5.1.1",
++ "string_decoder": "~1.1.1",
++ "util-deprecate": "~1.0.1"
++ }
++ },
++ "node_modules/readable-stream/node_modules/safe-buffer": {
++ "version": "5.1.2",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
++ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
++ "license": "MIT"
++ },
++ "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/sharp": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
++ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
++ "hasInstallScript": true,
++ "license": "Apache-2.0",
++ "dependencies": {
++ "color": "^4.2.3",
++ "detect-libc": "^2.0.3",
++ "semver": "^7.6.3"
++ },
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-darwin-arm64": "0.33.5",
++ "@img/sharp-darwin-x64": "0.33.5",
++ "@img/sharp-libvips-darwin-arm64": "1.0.4",
++ "@img/sharp-libvips-darwin-x64": "1.0.4",
++ "@img/sharp-libvips-linux-arm": "1.0.5",
++ "@img/sharp-libvips-linux-arm64": "1.0.4",
++ "@img/sharp-libvips-linux-s390x": "1.0.4",
++ "@img/sharp-libvips-linux-x64": "1.0.4",
++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
++ "@img/sharp-linux-arm": "0.33.5",
++ "@img/sharp-linux-arm64": "0.33.5",
++ "@img/sharp-linux-s390x": "0.33.5",
++ "@img/sharp-linux-x64": "0.33.5",
++ "@img/sharp-linuxmusl-arm64": "0.33.5",
++ "@img/sharp-linuxmusl-x64": "0.33.5",
++ "@img/sharp-wasm32": "0.33.5",
++ "@img/sharp-win32-ia32": "0.33.5",
++ "@img/sharp-win32-x64": "0.33.5"
++ }
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/simple-swizzle": {
++ "version": "0.2.4",
++ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
++ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
++ "license": "MIT",
++ "dependencies": {
++ "is-arrayish": "^0.3.1"
++ }
++ },
++ "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/stream-browserify": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
++ "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "~2.0.4",
++ "readable-stream": "^3.5.0"
++ }
++ },
++ "node_modules/stream-browserify/node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/streamsearch": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
++ "engines": {
++ "node": ">=10.0.0"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
++ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.1.0"
++ }
++ },
++ "node_modules/string_decoder/node_modules/safe-buffer": {
++ "version": "5.1.2",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
++ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
++ "license": "MIT"
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/tar-stream/node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/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/tslib": {
++ "version": "2.8.1",
++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
++ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
++ "license": "0BSD"
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/typedarray": {
++ "version": "0.0.6",
++ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
++ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
++ "license": "MIT"
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
++ },
++ "vendor/openvibe-shared": {
++ "version": "1.0.0",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..3e567be
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,23 @@
++{
++ "name": "openvibe-media",
++ "version": "1.0.0",
++ "description": "OpenVibe.Media — multi-tenant media service (VOD ingest/recording, storage tiering, clips, pastes, files, thumbnails)",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "init-db": "node server/db/init.js",
++ "smoke": "bash scripts/smoke-test.sh"
++ },
++ "license": "MIT",
++ "dependencies": {
++ "@aws-sdk/client-s3": "^3.600.0",
++ "@aws-sdk/lib-storage": "^3.600.0",
++ "@aws-sdk/s3-request-presigner": "^3.600.0",
++ "better-sqlite3": "^11.0.0",
++ "dotenv": "^16.4.5",
++ "express": "^4.19.2",
++ "multer": "^1.4.5-lts.1",
++ "openvibe-shared": "file:./vendor/openvibe-shared",
++ "sharp": "^0.33.4"
++ }
++}
ddiff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
nnew file mode 100755
iindex 0000000..323b00a
---- /dev/null
++++ b/scripts/smoke-test.sh
@@@ -0,0 +1,150 @@
++#!/usr/bin/env bash
++# OpenVibe.Media — end-to-end smoke test.
++# Boots the service against a throwaway data dir, then exercises:
++# vod create → chunk upload → complete → meta (duration+thumbnail) →
++# clip cut → /v range playback → paste (+ /p page + /raw) → file upload →
++# /f fetch → bad API key rejected → cross-tenant key rejected.
++set -u
++ROOT="$(cd "$(dirname "$0")/.." && pwd)"
++WORK="$ROOT/.smoke"
++PORT="${SMOKE_PORT:-41300}"
++BASE="http://127.0.0.1:$PORT"
++LIVE_KEY="smoke-live-key-1234567890"
++GAMES_KEY="smoke-games-key-0987654321"
++
++PASS=0; FAIL=0
++ok() { PASS=$((PASS+1)); echo " PASS: $1"; }
++bad() { FAIL=$((FAIL+1)); echo " FAIL: $1"; }
++check() { # check <description> <expected> <actual>
++ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (expected '$2', got '$3')"; fi
++}
++jsonget() { # jsonget <json> <dotted.path>
++ node -e 'const o=JSON.parse(process.argv[1]);const v=process.argv[2].split(".").reduce((a,k)=>a?.[k],o);console.log(v===undefined?"":v)' "$1" "$2"
++}
++
++echo "== OpenVibe.Media smoke test =="
++rm -rf "$WORK"; mkdir -p "$WORK/data"
++
++echo "-- generating 5s test clip (ffmpeg testsrc)"
++ffmpeg -y -f lavfi -i "testsrc=duration=5:size=640x360:rate=30" \
++ -f lavfi -i "sine=frequency=440:duration=5" \
++ -c:v libx264 -preset veryfast -pix_fmt yuv420p -c:a aac -shortest \
++ "$WORK/test.mp4" >/dev/null 2>&1
++[ -s "$WORK/test.mp4" ] && ok "test.mp4 generated" || { bad "test.mp4 generation"; exit 1; }
++
++echo "-- booting server on :$PORT (temp data dir)"
++SEED="[{\"app_id\":\"live\",\"name\":\"Live\",\"api_key\":\"$LIVE_KEY\",\"allowed_origins\":[\"http://localhost:3000\"]},{\"app_id\":\"games\",\"name\":\"Games\",\"api_key\":\"$GAMES_KEY\",\"quota_bytes\":10485760}]"
++env PORT="$PORT" HOST=127.0.0.1 \
++ DB_PATH="$WORK/data/media.db" \
++ VOD_PATH="$WORK/data/vods" CLIPS_PATH="$WORK/data/clips" \
++ PASTES_PATH="$WORK/data/pastes" THUMBNAILS_PATH="$WORK/data/thumbnails" \
++ FILES_PATH="$WORK/data/files" \
++ MEDIA_APPS_SEED="$SEED" \
++ OV_NETWORK_URL="http://127.0.0.1:1" \
++ node "$ROOT/server/index.js" > "$WORK/server.log" 2>&1 &
++SRV=$!
++trap 'kill "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null' EXIT
++
++for i in $(seq 1 50); do
++ sleep 0.2
++ if curl -sf "$BASE/healthz" >/dev/null 2>&1; then break; fi
++ if [ "$i" = 50 ]; then bad "server boot"; cat "$WORK/server.log"; exit 1; fi
++done
++ok "server is up ($(curl -s "$BASE/healthz"))"
++
++AUTH="Authorization: Bearer $LIVE_KEY"
++
++echo "-- vod: create"
++R=$(curl -s -X POST "$BASE/api/v1/live/vods" -H "$AUTH" -H 'Content-Type: application/json' \
++ -d '{"title":"Smoke VOD","stream_id":42,"user_id":7}')
++VOD_ID=$(jsonget "$R" id)
++[ -n "$VOD_ID" ] && ok "created vod id=$VOD_ID" || { bad "vod create ($R)"; exit 1; }
++
++echo "-- vod: upload media via chunks endpoint + complete"
++R=$(curl -s -X POST "$BASE/api/v1/live/vods/$VOD_ID/chunks" -H "$AUTH" \
++ -F "chunk=@$WORK/test.mp4;type=video/mp4")
++check "chunk accepted" "created" "$(jsonget "$R" status)"
++R=$(curl -s -X POST "$BASE/api/v1/live/vods/$VOD_ID/complete" -H "$AUTH")
++ST=$(jsonget "$R" vod.status)
++check "complete → vod ready" "ready" "$ST"
++
++echo "-- vod: meta shows duration + thumbnail"
++R=$(curl -s "$BASE/api/v1/live/vods/$VOD_ID" -H "$AUTH")
++DUR=$(jsonget "$R" vod.duration)
++THUMB=$(jsonget "$R" vod.thumbnail_url)
++[ "$DUR" = "5" ] && ok "duration probed = 5s" || bad "duration (got '$DUR')"
++case "$THUMB" in /t/*) ok "thumbnail_url = $THUMB";; *) bad "thumbnail_url (got '$THUMB')";; esac
++TCODE=$(curl -s -o "$WORK/thumb.jpg" -w '%{http_code}' "$BASE$THUMB")
++check "GET $THUMB serves image" "200" "$TCODE"
++
++echo "-- clips: cut a 2s clip"
++R=$(curl -s -X POST "$BASE/api/v1/live/clips" -H "$AUTH" -H 'Content-Type: application/json' \
++ -d "{\"vod_id\":$VOD_ID,\"start_s\":1,\"end_s\":3,\"title\":\"Smoke clip\"}")
++CLIP_ID=$(jsonget "$R" id)
++[ -n "$CLIP_ID" ] && ok "clip queued id=$CLIP_ID (status=$(jsonget "$R" status))" || bad "clip create ($R)"
++CLIP_ST=""
++for i in $(seq 1 60); do
++ sleep 0.5
++ CLIP_ST=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" clip.status)
++ [ "$CLIP_ST" = "ready" ] && break
++ [ "$CLIP_ST" = "failed" ] && break
++done
++check "clip processed" "ready" "$CLIP_ST"
++CDUR=$(jsonget "$(curl -s "$BASE/api/v1/live/clips/$CLIP_ID" -H "$AUTH")" clip.duration)
++echo " (clip duration: ${CDUR}s)"
++CCODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/c/$CLIP_ID")
++check "GET /c/$CLIP_ID plays" "200" "$CCODE"
++
++echo "-- playback: /v/:id with Range"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/v/$VOD_ID")
++check "GET /v/$VOD_ID" "200" "$CODE"
++HDRS=$(curl -s -D - -o /dev/null -H 'Range: bytes=0-99' "$BASE/v/$VOD_ID")
++echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Content" || bad "Range request ($(echo "$HDRS" | head -1))"
++echo "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"
++echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"
++
++echo "-- pastes: create + public page + raw"
++R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \
++ -d '{"title":"Smoke paste","content":"hello from the smoke test\nline two","user_id":7}')
++SLUG=$(jsonget "$R" slug)
++[ -n "$SLUG" ] && ok "paste created slug=$SLUG" || bad "paste create ($R)"
++PAGE=$(curl -s "$BASE/p/$SLUG")
++echo "$PAGE" | grep -q 'Smoke paste' && ok "/p/$SLUG HTML page renders title" || bad "/p page content"
++echo "$PAGE" | grep -q 'OpenVibe' && ok "/p page carries OpenVibe branding" || bad "/p page branding"
++RAW=$(curl -s "$BASE/p/$SLUG/raw")
++check "/p/$SLUG/raw returns content" "hello from the smoke test
++line two" "$RAW"
++
++echo "-- files: upload + public fetch"
++echo "smoke-file-payload-$(date +%s)" > "$WORK/upload.txt"
++R=$(curl -s -X POST "$BASE/api/v1/live/files" -H "$AUTH" -F "file=@$WORK/upload.txt;type=text/plain")
++FKEY=$(jsonget "$R" key)
++[ -n "$FKEY" ] && ok "file stored key=$FKEY" || bad "file upload ($R)"
++FBODY=$(curl -s "$BASE/f/$FKEY")
++check "GET /f/$FKEY returns payload" "$(cat "$WORK/upload.txt")" "$FBODY"
++FCT=$(curl -s -o /dev/null -w '%{content_type}' "$BASE/f/$FKEY")
++check "file served with stored MIME" "text/plain" "$FCT"
++
++echo "-- auth: bad API key rejected"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/vods" -H 'Authorization: Bearer wrong-key')
++check "bad key → 401" "401" "$CODE"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/vods")
++check "no key → 401" "401" "$CODE"
++
++echo "-- tenancy: live key cannot touch /api/v1/games/*"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/games/vods" -H "$AUTH")
++check "live key on games → 403" "403" "$CODE"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/games/vods" -H "Authorization: Bearer $GAMES_KEY")
++check "games key on games → 200" "200" "$CODE"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/nosuchapp/vods" -H "$AUTH")
++check "unknown app → 404" "404" "$CODE"
++
++echo "-- shutting down server"
++kill "$SRV" 2>/dev/null
++wait "$SRV" 2>/dev/null
++trap - EXIT
++ok "server stopped"
++
++echo
++echo "== RESULT: $PASS passed, $FAIL failed =="
++[ "$FAIL" = 0 ]
ddiff --git a/server/auth.js b/server/auth.js
nnew file mode 100644
iindex 0000000..8328adf
---- /dev/null
++++ b/server/auth.js
@@@ -0,0 +1,247 @@
++/**
++ * OpenVibe.Media — Tenancy & Auth middleware
++ *
++ * Two credential types (per CONTRACTS.md, Media API v1):
++ * 1. App API key — `Authorization: Bearer <app_api_key>` (server-to-server).
++ * Constant-time hash compare against the `apps` row; a key is ONLY valid
++ * for its own `:app` path segment.
++ * 2. Network user JWT — RS256, verified OFFLINE against the JWKS public key
++ * fetched from OV_NETWORK_URL at boot (cached, refreshed periodically).
++ * Browser endpoints additionally check the Origin header against the
++ * app's allowed_origins list.
++ *
++ * App seeding: MEDIA_APPS_SEED (JSON array) and/or MEDIA_APP_KEYS ("id:key,…")
++ * are upserted on boot; keys are stored as sha256 hashes.
++ */
++'use strict';
++
++const crypto = require('crypto');
++const db = require('./db/database');
++const config = require('./config');
++
++// ── JWKS → PEM (offline user-JWT verification) ───────────────
++
++let _networkPublicKeyPem = null;
++let _jwksTimer = null;
++
++async function fetchNetworkPublicKey() {
++ const url = `${config.network.url}/api/.well-known/jwks`;
++ try {
++ const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
++ if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
++ const body = await res.json();
++ const jwk = (body.keys || []).find(k => k.kty === 'RSA') || (body.keys || [])[0];
++ if (!jwk) throw new Error('JWKS contained no keys');
++ const keyObj = crypto.createPublicKey({ key: jwk, format: 'jwk' });
++ _networkPublicKeyPem = keyObj.export({ type: 'spki', format: 'pem' });
++ console.log('[Auth] Network JWKS public key loaded');
++ return _networkPublicKeyPem;
++ } catch (err) {
++ console.warn(`[Auth] Could not fetch JWKS from ${url}: ${err.message} — user-JWT auth unavailable until it loads`);
++ return null;
++ }
++}
++
++function startJwksRefresh() {
++ fetchNetworkPublicKey().catch(() => {});
++ _jwksTimer = setInterval(() => fetchNetworkPublicKey().catch(() => {}), 6 * 60 * 60 * 1000);
++ if (_jwksTimer.unref) _jwksTimer.unref();
++}
++
++function stopJwksRefresh() {
++ if (_jwksTimer) { clearInterval(_jwksTimer); _jwksTimer = null; }
++}
++
++/** Verify an RS256 user JWT offline. Returns the payload or null. */
++function verifyUserJwt(token) {
++ if (!_networkPublicKeyPem || !token) return null;
++ const parts = String(token).split('.');
++ if (parts.length !== 3) return null;
++ try {
++ const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
++ if (header.alg !== 'RS256') return null;
++ const ok = crypto.verify(
++ 'RSA-SHA256',
++ Buffer.from(`${parts[0]}.${parts[1]}`),
++ _networkPublicKeyPem,
++ Buffer.from(parts[2], 'base64url')
++ );
++ if (!ok) return null;
++ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ if (payload.exp && Date.now() / 1000 > payload.exp) return null;
++ if (payload.iss && payload.iss !== config.network.url) return null;
++ // aud must include this service when present.
++ if (payload.aud) {
++ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
++ if (!aud.includes('openvibe.media')) return null;
++ }
++ return payload;
++ } catch {
++ return null;
++ }
++}
++
++// ── App key check (constant-time) ────────────────────────────
++
++function checkAppKey(app, presentedKey) {
++ if (!app || !presentedKey) return false;
++ const presentedHash = Buffer.from(db.hashApiKey(presentedKey), 'hex');
++ const storedHash = Buffer.from(String(app.api_key_hash || ''), 'hex');
++ if (presentedHash.length !== storedHash.length || !storedHash.length) return false;
++ return crypto.timingSafeEqual(presentedHash, storedHash);
++}
++
++function bearerToken(req) {
++ const h = req.headers.authorization;
++ if (h && h.startsWith('Bearer ')) return h.slice(7).trim();
++ return null;
++}
++
++// ── Middleware ───────────────────────────────────────────────
++
++/**
++ * Resolve the `:app` path segment to req.appRow / req.appId, then authenticate.
++ *
++ * @param {object} opts
++ * @param {boolean} [opts.allowUser=false] also accept a Network user JWT
++ * (browser endpoints); Origin, when present, must be allow-listed.
++ */
++function tenantAuth({ allowUser = false } = {}) {
++ return (req, res, next) => {
++ const appId = String(req.params.app || '').trim();
++ const app = appId ? db.getApp(appId) : null;
++ if (!app) return res.status(404).json({ error: 'Unknown app' });
++ req.appId = appId;
++ req.appRow = app;
++
++ const token = bearerToken(req);
++
++ // 1) App API key — only valid for its own :app segment. A valid key of a
++ // DIFFERENT app must NOT fall through to anything else.
++ if (token && checkAppKey(app, token)) {
++ req.authType = 'app';
++ return next();
++ }
++ if (token && isKeyOfOtherApp(token, appId)) {
++ return res.status(403).json({ error: 'API key not valid for this app' });
++ }
++
++ // 2) Network user JWT (browser endpoints only).
++ if (allowUser && token) {
++ const payload = verifyUserJwt(token);
++ if (payload) {
++ const origin = req.headers.origin;
++ if (origin && !db.appAllowedOrigins(app).includes(origin)) {
++ return res.status(403).json({ error: 'Origin not allowed for this app' });
++ }
++ req.authType = 'user';
++ req.user = payload;
++ req.userId = payload.sub != null ? Number(payload.sub) || payload.sub : (payload.id ?? null);
++ return next();
++ }
++ }
++
++ return res.status(401).json({ error: 'Authentication required' });
++ };
++}
++
++/** Does the presented key belong to some OTHER app? (for a precise 403) */
++function isKeyOfOtherApp(presentedKey, exceptAppId) {
++ const hash = db.hashApiKey(presentedKey);
++ const row = db.get('SELECT app_id FROM apps WHERE api_key_hash = ?', [hash]);
++ return !!(row && row.app_id !== exceptAppId);
++}
++
++/**
++ * Optional identity on PUBLIC serving routes (/v /c /p /t /f): attaches the
++ * owning app (via any valid app key) or user payload if a credential is sent.
++ * Never rejects.
++ */
++function optionalIdentity(req, _res, next) {
++ const token = bearerToken(req);
++ if (token) {
++ const hash = db.hashApiKey(token);
++ const appRow = db.get('SELECT * FROM apps WHERE api_key_hash = ?', [hash]);
++ if (appRow) {
++ req.authType = 'app';
++ req.appRow = appRow;
++ req.appId = appRow.app_id;
++ } else {
++ const payload = verifyUserJwt(token);
++ if (payload) {
++ req.authType = 'user';
++ req.user = payload;
++ req.userId = payload.sub != null ? Number(payload.sub) || payload.sub : (payload.id ?? null);
++ }
++ }
++ }
++ next();
++}
++
++/** CORS for tenant API routes — reflects allow-listed origins per app. */
++function tenantCors(req, res, next) {
++ const origin = req.headers.origin;
++ if (origin && req.appRow && db.appAllowedOrigins(req.appRow).includes(origin)) {
++ res.set('Access-Control-Allow-Origin', origin);
++ res.set('Vary', 'Origin');
++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
++ }
++ next();
++}
++
++// ── App seeding ──────────────────────────────────────────────
++
++function seedApps() {
++ let seeded = 0;
++
++ if (config.apps.seedJson) {
++ try {
++ const list = JSON.parse(config.apps.seedJson);
++ for (const entry of Array.isArray(list) ? list : []) {
++ if (!entry || !entry.app_id || !entry.api_key) continue;
++ db.upsertApp(entry);
++ seeded++;
++ }
++ } catch (err) {
++ console.error('[Auth] MEDIA_APPS_SEED is not valid JSON:', err.message);
++ }
++ }
++
++ if (config.apps.seedKeys) {
++ for (const pair of config.apps.seedKeys.split(',')) {
++ const idx = pair.indexOf(':');
++ if (idx < 1) continue;
++ const app_id = pair.slice(0, idx).trim();
++ const api_key = pair.slice(idx + 1).trim();
++ if (!app_id || !api_key) continue;
++ // Don't clobber a richer MEDIA_APPS_SEED entry for the same app.
++ const existing = db.getApp(app_id);
++ if (existing && config.apps.seedJson && config.apps.seedJson.includes(`"${app_id}"`)) continue;
++ db.upsertApp({
++ app_id,
++ name: existing?.name || app_id,
++ api_key,
++ webhook_url: existing?.webhook_url || null,
++ webhook_secret: existing?.webhook_secret || null,
++ allowed_origins: existing ? db.appAllowedOrigins(existing) : [],
++ quota_bytes: existing?.quota_bytes || 0,
++ });
++ seeded++;
++ }
++ }
++
++ if (seeded) console.log(`[Auth] Seeded/updated ${seeded} app(s): ${db.listApps().map(a => a.app_id).join(', ')}`);
++ else if (!db.listApps().length) console.warn('[Auth] No apps configured — set MEDIA_APPS_SEED (all API calls will 404)');
++}
++
++module.exports = {
++ tenantAuth,
++ tenantCors,
++ optionalIdentity,
++ verifyUserJwt,
++ seedApps,
++ startJwksRefresh,
++ stopJwksRefresh,
++ fetchNetworkPublicKey,
++};
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..09b6d9b
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,72 @@
++/**
++ * OpenVibe.Media — Config
++ *
++ * Lean env-driven configuration per CONTRACTS.md (Media API v1).
++ * Shapes (config.vod.path, config.vod.clipsPath, config.thumbnails.path)
++ * are kept compatible with the inherited vod/thumbnail modules.
++ */
++require('dotenv').config();
++
++const path = require('path');
++
++function intEnv(name, fallback) {
++ const v = parseInt(process.env[name] || '', 10);
++ return Number.isFinite(v) ? v : fallback;
++}
++
++const config = {
++ port: intEnv('PORT', 4100),
++ host: process.env.HOST || '0.0.0.0',
++ nodeEnv: process.env.NODE_ENV || 'development',
++ publicUrl: (process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/$/, ''),
++
++ db: {
++ path: process.env.DB_PATH || './data/media.db',
++ },
++
++ vod: {
++ path: process.env.VOD_PATH || './data/vods',
++ clipsPath: process.env.CLIPS_PATH || './data/clips',
++ maxSizeMb: intEnv('MAX_VOD_SIZE_MB', 2048),
++ },
++ pastes: {
++ path: process.env.PASTES_PATH || './data/pastes',
++ },
++ thumbnails: {
++ path: process.env.THUMBNAILS_PATH || './data/thumbnails',
++ },
++ files: {
++ path: process.env.FILES_PATH || './data/files',
++ maxSizeMb: intEnv('MAX_FILE_SIZE_MB', 256),
++ },
++
++ rtp: {
++ portMin: intEnv('RTP_PORT_MIN', 12000),
++ portMax: intEnv('RTP_PORT_MAX', 12199),
++ },
++
++ network: {
++ url: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
++ internalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
++ internalApiKey: process.env.INTERNAL_API_KEY || '',
++ },
++
++ apps: {
++ // JSON array: [{app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes}]
++ seedJson: process.env.MEDIA_APPS_SEED || '',
++ // Fallback short form: "live:key1,games:key2"
++ seedKeys: process.env.MEDIA_APP_KEYS || '',
++ },
++};
++
++// Resolve all storage paths to absolute
++for (const p of ['path']) {
++ config.vod[p] = path.resolve(config.vod[p]);
++}
++config.vod.clipsPath = path.resolve(config.vod.clipsPath);
++config.pastes.path = path.resolve(config.pastes.path);
++config.thumbnails.path = path.resolve(config.thumbnails.path);
++config.files.path = path.resolve(config.files.path);
++config.db.path = path.resolve(config.db.path);
++
++module.exports = config;
ddiff --git a/server/db/database.js b/server/db/database.js
nnew file mode 100644
iindex 0000000..36ef81f
---- /dev/null
++++ b/server/db/database.js
@@@ -0,0 +1,456 @@
++/**
++ * OpenVibe.Media — Database
++ *
++ * Lean port of the predecessor's vod/clip/paste helpers with app_id (tenant)
++ * scoping on every query. better-sqlite3 in WAL mode.
++ *
++ * Migration path: `importLegacyRows(table, rows, appId)` bulk-inserts rows from
++ * the old streamer DB unchanged — only columns that exist in the new table are
++ * used (extras ignored), ids preserved, app_id backfilled. The cutover script
++ * can do: ATTACH old db, SELECT * per table, importLegacyRows('vods', rows, 'live').
++ */
++'use strict';
++
++const path = require('path');
++const fs = require('fs');
++const crypto = require('crypto');
++const Database = require('better-sqlite3');
++const config = require('../config');
++
++let database = null;
++
++function getDb() {
++ if (database) return database;
++ const dataDir = path.dirname(config.db.path);
++ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
++ database = new Database(config.db.path);
++ database.pragma('journal_mode = WAL');
++ const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
++ database.exec(schema);
++ seedSettings();
++ return database;
++}
++
++function seedSettings() {
++ const defaults = [
++ // Clip system
++ ['max_clip_duration', '60', 'Maximum clip length in seconds', 'number'],
++ // Paste system (same keys the inherited routes read)
++ ['paste_max_size_kb', '512', 'Maximum paste content size in KB', 'number'],
++ ['paste_screenshot_max_size_mb', '8', 'Maximum screenshot upload size in MB', 'number'],
++ ['paste_cooldown_seconds', '30', 'Cooldown between paste submissions in seconds (user-JWT callers)', 'number'],
++ ['paste_max_per_user_per_day', '200', 'Maximum pastes per user per day (0 = unlimited)', 'number'],
++ ['paste_comment_cooldown_seconds', '10', 'Cooldown between paste comments in seconds', 'number'],
++ ['paste_comment_max_length', '2000', 'Maximum paste comment length in characters', 'number'],
++ ['paste_comment_anon_allowed', 'true', 'Allow anonymous comments on pastes', 'boolean'],
++ ];
++ const stmt = database.prepare('INSERT OR IGNORE INTO media_settings (key, value, description, type) VALUES (?, ?, ?, ?)');
++ for (const [k, v, d, t] of defaults) stmt.run(k, v, d, t);
++}
++
++// ── Generic helpers ──────────────────────────────────────────
++
++function run(sql, params = []) {
++ return getDb().prepare(sql).run(...(Array.isArray(params) ? params : [params]));
++}
++
++function get(sql, params = []) {
++ return getDb().prepare(sql).get(...(Array.isArray(params) ? params : [params]));
++}
++
++function all(sql, params = []) {
++ return getDb().prepare(sql).all(...(Array.isArray(params) ? params : [params]));
++}
++
++function close() {
++ if (database) { try { database.close(); } catch { /* ignore */ } database = null; }
++}
++
++// ── Settings ─────────────────────────────────────────────────
++
++function getSetting(key) {
++ const row = get('SELECT value, type FROM media_settings WHERE key = ?', [key]);
++ if (!row) return null;
++ if (row.type === 'number') return Number(row.value);
++ if (row.type === 'boolean') return row.value === 'true' || row.value === '1';
++ return row.value;
++}
++
++function setSetting(key, value, type = 'string') {
++ run(`INSERT INTO media_settings (key, value, type) VALUES (?, ?, ?)
++ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
++ [key, String(value), type]);
++}
++
++// ── Apps (tenants) ───────────────────────────────────────────
++
++function hashApiKey(key) {
++ return crypto.createHash('sha256').update(String(key)).digest('hex');
++}
++
++function getApp(appId) {
++ return get('SELECT * FROM apps WHERE app_id = ?', [appId]);
++}
++
++function listApps() {
++ return all('SELECT app_id, name, webhook_url, allowed_origins, quota_bytes, created_at FROM apps ORDER BY app_id');
++}
++
++function upsertApp({ app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes }) {
++ if (!app_id || !api_key) throw new Error('app_id and api_key required');
++ const origins = JSON.stringify(Array.isArray(allowed_origins) ? allowed_origins : []);
++ run(`INSERT INTO apps (app_id, name, api_key_hash, webhook_url, webhook_secret, allowed_origins, quota_bytes)
++ VALUES (?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT(app_id) DO UPDATE SET
++ name = excluded.name,
++ api_key_hash = excluded.api_key_hash,
++ webhook_url = excluded.webhook_url,
++ webhook_secret = excluded.webhook_secret,
++ allowed_origins = excluded.allowed_origins,
++ quota_bytes = excluded.quota_bytes`,
++ [app_id, name || app_id, hashApiKey(api_key), webhook_url || null, webhook_secret || null, origins, quota_bytes || 0]);
++ return getApp(app_id);
++}
++
++function appAllowedOrigins(app) {
++ try { return JSON.parse(app.allowed_origins || '[]'); } catch { return []; }
++}
++
++// ── VOD helpers ──────────────────────────────────────────────
++
++function createVod({ app_id, stream_id, stream_key, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta }) {
++ return run(
++ `INSERT INTO vods (app_id, stream_id, stream_key, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
++ [app_id, stream_id || null, stream_key || null, user_id || null, title || 'Recording', description || '',
++ file_path || null, master_file_path || null, file_size || 0, duration_seconds || 0, thumbnail_url || null,
++ JSON.stringify(meta || {})]
++ );
++}
++
++function getVodById(id, appId = null) {
++ const clause = appId ? ' AND app_id = ?' : '';
++ const params = appId ? [id, appId] : [id];
++ return get(`SELECT *, COALESCE(duration_seconds, probe_duration_seconds, 0) AS duration_seconds
++ FROM vods WHERE id = ?${clause}`, params);
++}
++
++function listVods(appId, { limit = 50, offset = 0, user_id = null, stream_id = null, includeRecording = true } = {}) {
++ const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
++ const params = [appId];
++ if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');
++ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
++ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
++ params.push(limit, offset);
++ return all(`SELECT * FROM vods WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);
++}
++
++function countVods(appId, { user_id = null, stream_id = null } = {}) {
++ const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
++ const params = [appId];
++ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
++ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
++ return get(`SELECT COUNT(*) AS count FROM vods WHERE ${conds.join(' AND ')}`, params)?.count || 0;
++}
++
++const VALID_VISIBILITY = new Set(['public', 'unlisted', 'private']);
++function _normVisibility(v) { return VALID_VISIBILITY.has(v) ? v : 'public'; }
++
++// Set VOD/clip visibility; is_public mirrors (1 iff public) so listing filters hold.
++function setVodVisibility(vodId, visibility) {
++ const vis = _normVisibility(visibility);
++ return run('UPDATE vods SET visibility = ?, is_public = ? WHERE id = ?', [vis, vis === 'public' ? 1 : 0, vodId]);
++}
++
++function updateVodHealth(vodId, { status, score, issues = [], probeDuration, probeFormat, quarantine = false, keepPublic = false }) {
++ const updates = [];
++ const params = [];
++ if (status) { updates.push('health_status = ?'); params.push(status); }
++ if (typeof score === 'number') { updates.push('health_score = ?'); params.push(score); }
++ if (issues) { updates.push('health_issues_json = ?'); params.push(JSON.stringify(issues)); }
++ if (typeof probeDuration === 'number') { updates.push('probe_duration_seconds = ?'); params.push(probeDuration); }
++ if (probeFormat !== undefined) { updates.push('probe_format_json = ?'); params.push(JSON.stringify(probeFormat || {})); }
++ if (quarantine) {
++ updates.push("quarantined_at = datetime('now')");
++ if (!keepPublic) updates.push('is_public = 0');
++ }
++ updates.push("last_health_scan_at = datetime('now')");
++ params.push(vodId);
++ if (!updates.length) return null;
++ return run(`UPDATE vods SET ${updates.join(', ')} WHERE id = ?`, params);
++}
++
++function repairVodDuration(vodId, duration, fileSize) {
++ return run(
++ `UPDATE vods SET duration_seconds = ?, file_size = ?, probe_duration_seconds = ?, last_health_scan_at = datetime('now') WHERE id = ?`,
++ [duration, fileSize, duration, vodId]
++ );
++}
++
++// VODs the periodic health job should scan: finished (not recording), and either never
++// scanned or last scanned longer ago than `staleDays`. Never-scanned + oldest-scanned first.
++function getVodsNeedingHealthScan({ staleDays = 30, limit = 3 } = {}) {
++ return all(`SELECT * FROM vods
++ WHERE COALESCE(is_recording, 0) = 0
++ AND (health_status IS NULL OR health_status NOT IN ('corrupt','zero_byte','missing_file'))
++ AND (last_health_scan_at IS NULL OR last_health_scan_at <= datetime('now', ?))
++ ORDER BY (last_health_scan_at IS NULL) DESC, last_health_scan_at ASC
++ LIMIT ?`, [`-${Math.max(1, staleDays)} days`, limit]);
++}
++
++// Genuinely-broken VODs quarantined long enough that they should be cleaned up.
++function getQuarantinedVodsForCleanup({ graceDays = 14, limit = 5 } = {}) {
++ return all(`SELECT * FROM vods
++ WHERE quarantined_at IS NOT NULL
++ AND quarantined_at <= datetime('now', ?)
++ AND health_status IN ('corrupt','zero_byte','missing_file')
++ AND COALESCE(is_recording, 0) = 0
++ ORDER BY quarantined_at ASC
++ LIMIT ?`, [`-${Math.max(1, graceDays)} days`, limit]);
++}
++
++// Derived VOD status for the API: pending → recording → ready | failed.
++function vodStatus(vod) {
++ if (!vod) return 'unknown';
++ if (vod.is_recording) return 'recording';
++ if (['corrupt', 'zero_byte', 'missing_file'].includes(vod.health_status)) return 'failed';
++ if (vod.file_path && (vod.duration_seconds > 0 || vod.file_size > 0)) return 'ready';
++ return 'pending';
++}
++
++// ── Clip helpers ─────────────────────────────────────────────
++
++function createClip({ app_id, vod_id, stream_id, user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, auto_generated, status }) {
++ const pub = (is_public === 0 || is_public === false) ? 0 : 1;
++ return run(
++ `INSERT INTO clips (app_id, vod_id, stream_id, user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [app_id, vod_id || null, stream_id || null, user_id || null, title || 'Untitled Clip', description || '',
++ file_path || '', thumbnail_url || null, start_time || 0, end_time || 0, duration_seconds || 0,
++ pub, pub ? 'public' : 'unlisted', auto_generated ? 1 : 0, status || 'ready']
++ );
++}
++
++function getClipById(id, appId = null) {
++ const clause = appId ? ' AND app_id = ?' : '';
++ const params = appId ? [id, appId] : [id];
++ return get(`SELECT * FROM clips WHERE id = ?${clause}`, params);
++}
++
++function listClips(appId, { limit = 50, offset = 0, vod_id = null, stream_id = null, user_id = null } = {}) {
++ const conds = ['app_id = ?'];
++ const params = [appId];
++ if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }
++ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
++ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
++ params.push(limit, offset);
++ return all(`SELECT * FROM clips WHERE ${conds.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`, params);
++}
++
++function countClips(appId, { vod_id = null, stream_id = null, user_id = null } = {}) {
++ const conds = ['app_id = ?'];
++ const params = [appId];
++ if (vod_id != null) { conds.push('vod_id = ?'); params.push(vod_id); }
++ if (stream_id != null) { conds.push('stream_id = ?'); params.push(stream_id); }
++ if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
++ return get(`SELECT COUNT(*) AS count FROM clips WHERE ${conds.join(' AND ')}`, params)?.count || 0;
++}
++
++function setClipVisibility(clipId, visibility) {
++ const vis = _normVisibility(visibility);
++ return run('UPDATE clips SET visibility = ?, is_public = ? WHERE id = ?', [vis, vis === 'public' ? 1 : 0, clipId]);
++}
++
++function findDuplicateClip({ appId, streamId = null, vodId = null, startTime = 0, endTime = 0, startWindow = 8, endWindow = 10, createdSinceMinutes = 10 }) {
++ const filters = [];
++ const params = [appId];
++ if (streamId) { filters.push('stream_id = ?'); params.push(streamId); }
++ if (vodId) { filters.push('vod_id = ?'); params.push(vodId); }
++ if (!filters.length) return null;
++ return get(`
++ SELECT * FROM clips
++ WHERE app_id = ? AND (${filters.join(' OR ')})
++ AND ABS(COALESCE(start_time, 0) - ?) <= ?
++ AND ABS(COALESCE(end_time, 0) - ?) <= ?
++ AND created_at >= datetime('now', ?)
++ ORDER BY created_at DESC
++ LIMIT 1
++ `, [...params, startTime || 0, startWindow, endTime || 0, endWindow, `-${Math.max(1, createdSinceMinutes)} minutes`]);
++}
++
++// ── Paste helpers ────────────────────────────────────────────
++
++function getPasteBySlug(slug, appId = null) {
++ const clause = appId ? ' AND app_id = ?' : '';
++ const params = appId ? [slug, appId] : [slug];
++ return get(`SELECT * FROM pastes WHERE slug = ?${clause}`, params);
++}
++
++function likePaste(pasteId, userId) {
++ run('INSERT OR IGNORE INTO paste_likes (paste_id, user_id) VALUES (?, ?)', [pasteId, userId]);
++ run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);
++ return get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);
++}
++
++function unlikePaste(pasteId, userId) {
++ run('DELETE FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);
++ run('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?', [pasteId, pasteId]);
++ return get('SELECT likes FROM pastes WHERE id = ?', [pasteId]);
++}
++
++function hasUserLikedPaste(pasteId, userId) {
++ return !!get('SELECT 1 FROM paste_likes WHERE paste_id = ? AND user_id = ?', [pasteId, userId]);
++}
++
++function incrementPasteCopies(slug) {
++ return run('UPDATE pastes SET copies = copies + 1 WHERE slug = ?', [slug]);
++}
++
++function countUserPastesToday(appId, userId, ip) {
++ if (userId) {
++ return get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND user_id = ? AND created_at > datetime('now', '-1 day')", [appId, userId])?.c || 0;
++ }
++ if (ip) {
++ return get("SELECT COUNT(*) as c FROM pastes WHERE app_id = ? AND ip_address = ? AND created_at > datetime('now', '-1 day')", [appId, ip])?.c || 0;
++ }
++ return 0;
++}
++
++function getLastPasteTime(appId, userId, ip) {
++ let row;
++ if (userId) {
++ row = get('SELECT created_at FROM pastes WHERE app_id = ? AND user_id = ? ORDER BY created_at DESC LIMIT 1', [appId, userId]);
++ } else if (ip) {
++ row = get('SELECT created_at FROM pastes WHERE app_id = ? AND ip_address = ? ORDER BY created_at DESC LIMIT 1', [appId, ip]);
++ }
++ return row ? new Date(row.created_at + (row.created_at.includes('Z') ? '' : 'Z')).getTime() : 0;
++}
++
++// ── Paste comment helpers ────────────────────────────────────
++
++function createPasteComment({ paste_id, user_id, parent_id, anon_name, message, ip_address }) {
++ return run(
++ `INSERT INTO paste_comments (paste_id, user_id, parent_id, anon_name, message, ip_address)
++ VALUES (?, ?, ?, ?, ?, ?)`,
++ [paste_id, user_id || null, parent_id || null, anon_name || null, message, ip_address || null]
++ );
++}
++
++function getPasteComments(pasteId, limit = 50, offset = 0) {
++ return all(`
++ SELECT * FROM paste_comments
++ WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL
++ ORDER BY created_at DESC
++ LIMIT ? OFFSET ?
++ `, [pasteId, limit, offset]);
++}
++
++function getPasteCommentReplies(parentId) {
++ return all(`
++ SELECT * FROM paste_comments
++ WHERE parent_id = ? AND is_deleted = 0
++ ORDER BY created_at ASC
++ `, [parentId]);
++}
++
++function getPasteCommentById(commentId) {
++ return get('SELECT * FROM paste_comments WHERE id = ?', [commentId]);
++}
++
++function getPasteCommentCount(pasteId) {
++ return get('SELECT COUNT(*) as count FROM paste_comments WHERE paste_id = ? AND is_deleted = 0', [pasteId])?.count || 0;
++}
++
++function deletePasteComment(commentId) {
++ return run('UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [commentId]);
++}
++
++function getRecentPasteCommentsByIp(ip, seconds = 10) {
++ return all(`
++ SELECT * FROM paste_comments
++ WHERE ip_address = ? AND created_at > datetime('now', '-' || ? || ' seconds')
++ ORDER BY created_at DESC
++ `, [ip, seconds]);
++}
++
++// ── File helpers ─────────────────────────────────────────────
++
++function createFile({ key, app_id, user_id, original_name, size, mime, sha256 }) {
++ return run(
++ `INSERT INTO files (key, app_id, user_id, original_name, size, mime, sha256)
++ VALUES (?, ?, ?, ?, ?, ?, ?)`,
++ [key, app_id, user_id || null, original_name || null, size || 0, mime || 'application/octet-stream', sha256 || null]
++ );
++}
++
++function getFileByKey(key, appId = null) {
++ const clause = appId ? ' AND app_id = ?' : '';
++ const params = appId ? [key, appId] : [key];
++ return get(`SELECT * FROM files WHERE key = ?${clause}`, params);
++}
++
++function listFiles(appId, { limit = 100, offset = 0 } = {}) {
++ return all('SELECT * FROM files WHERE app_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', [appId, limit, offset]);
++}
++
++function deleteFileRow(key) {
++ return run('DELETE FROM files WHERE key = ?', [key]);
++}
++
++function appFilesBytes(appId) {
++ return get('SELECT COALESCE(SUM(size), 0) AS bytes FROM files WHERE app_id = ?', [appId])?.bytes || 0;
++}
++
++// ── Legacy import (cutover from the old streamer DB) ─────────
++
++/**
++ * Bulk-insert rows exported from the predecessor DB. Only columns present in
++ * the destination table are used; missing ones take schema defaults; app_id is
++ * backfilled. Row ids are preserved (INSERT OR IGNORE keeps re-runs idempotent).
++ * Returns { inserted, skipped }.
++ */
++function importLegacyRows(table, rows, appId = 'live') {
++ const allowed = new Set(['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'content_views', 'files']);
++ if (!allowed.has(table)) throw new Error(`importLegacyRows: table ${table} not importable`);
++ if (!Array.isArray(rows) || !rows.length) return { inserted: 0, skipped: 0 };
++
++ const cols = getDb().prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
++ const colSet = new Set(cols);
++ const hasAppId = colSet.has('app_id');
++
++ let inserted = 0, skipped = 0;
++ const tx = getDb().transaction((batch) => {
++ for (const raw of batch) {
++ const row = { ...raw };
++ if (hasAppId && row.app_id == null) row.app_id = appId;
++ const keys = Object.keys(row).filter(k => colSet.has(k));
++ if (!keys.length) { skipped++; continue; }
++ const sql = `INSERT OR IGNORE INTO ${table} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`;
++ const res = getDb().prepare(sql).run(...keys.map(k => row[k]));
++ if (res.changes > 0) inserted++; else skipped++;
++ }
++ });
++ tx(rows);
++ return { inserted, skipped };
++}
++
++module.exports = {
++ getDb, run, get, all, close,
++ getSetting, setSetting,
++ // apps
++ hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,
++ // vods
++ createVod, getVodById, listVods, countVods, setVodVisibility, vodStatus,
++ updateVodHealth, repairVodDuration, getVodsNeedingHealthScan, getQuarantinedVodsForCleanup,
++ // clips
++ createClip, getClipById, listClips, countClips, setClipVisibility, findDuplicateClip,
++ // pastes
++ getPasteBySlug, likePaste, unlikePaste, hasUserLikedPaste, incrementPasteCopies,
++ countUserPastesToday, getLastPasteTime,
++ createPasteComment, getPasteComments, getPasteCommentReplies, getPasteCommentById,
++ getPasteCommentCount, deletePasteComment, getRecentPasteCommentsByIp,
++ // files
++ createFile, getFileByKey, listFiles, deleteFileRow, appFilesBytes,
++ // migration
++ importLegacyRows,
++};
ddiff --git a/server/db/init.js b/server/db/init.js
nnew file mode 100644
iindex 0000000..9acc1bc
---- /dev/null
++++ b/server/db/init.js
@@@ -0,0 +1,10 @@
++/**
++ * OpenVibe.Media — Database Initialization Script
++ * Run: npm run init-db
++ */
++const db = require('./database');
++
++db.getDb();
++console.log(`Database initialized at: ${require('../config').db.path}`);
++db.close();
++process.exit(0);
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
nnew file mode 100644
iindex 0000000..9793ce8
---- /dev/null
++++ b/server/db/schema.sql
@@@ -0,0 +1,182 @@
++-- OpenVibe.Media — Schema
++-- Multi-tenant media service. Every domain table carries app_id (tenant key,
++-- default 'live' so rows bulk-imported from the predecessor DB need no rewrite).
++
++PRAGMA journal_mode = WAL;
++
++-- ── Tenants ──────────────────────────────────────────────────
++CREATE TABLE IF NOT EXISTS apps (
++ app_id TEXT PRIMARY KEY,
++ name TEXT NOT NULL DEFAULT '',
++ api_key_hash TEXT NOT NULL,
++ webhook_url TEXT,
++ webhook_secret TEXT,
++ allowed_origins TEXT DEFAULT '[]', -- JSON array of origins for browser (user-JWT) calls
++ quota_bytes INTEGER DEFAULT 0, -- files quota; 0 = unlimited
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++
++-- ── VODs ─────────────────────────────────────────────────────
++-- Full column set inherited from the predecessor (health, probe, tiering,
++-- clips_only, is_recording, last_accessed_at) + tenant/ingest metadata.
++CREATE TABLE IF NOT EXISTS vods (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ app_id TEXT NOT NULL DEFAULT 'live',
++ stream_id INTEGER,
++ stream_key TEXT,
++ user_id INTEGER,
++ title TEXT,
++ description TEXT DEFAULT '',
++ file_path TEXT,
++ thumbnail_url TEXT,
++ master_file_path TEXT,
++ file_size INTEGER DEFAULT 0,
++ duration_seconds INTEGER DEFAULT 0,
++ probe_duration_seconds REAL DEFAULT 0,
++ probe_format_json TEXT DEFAULT '',
++ health_status TEXT DEFAULT 'unknown',
++ health_score INTEGER DEFAULT 0,
++ health_issues_json TEXT DEFAULT '[]',
++ last_health_scan_at DATETIME,
++ quarantined_at DATETIME,
++ is_public INTEGER DEFAULT 1,
++ visibility TEXT DEFAULT 'public',
++ is_recording INTEGER DEFAULT 0,
++ clips_only INTEGER DEFAULT 0,
++ view_count INTEGER DEFAULT 0,
++ storage_tier TEXT DEFAULT 'hot', -- legacy column (pre object-store); kept for import
++ storage_provider TEXT DEFAULT 'local',
++ storage_key TEXT,
++ last_accessed_at DATETIME,
++ ai_overview TEXT, -- kept for import; Live owns AI generation
++ ai_transcript TEXT,
++ ai_analyzed_at DATETIME,
++ meta_json TEXT DEFAULT '{}',
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++CREATE INDEX IF NOT EXISTS idx_vods_app ON vods(app_id, created_at DESC);
++CREATE INDEX IF NOT EXISTS idx_vods_recording ON vods(is_recording);
++CREATE INDEX IF NOT EXISTS idx_vods_provider ON vods(storage_provider);
++
++-- ── Clips ────────────────────────────────────────────────────
++CREATE TABLE IF NOT EXISTS clips (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ app_id TEXT NOT NULL DEFAULT 'live',
++ vod_id INTEGER,
++ stream_id INTEGER,
++ user_id INTEGER,
++ title TEXT DEFAULT 'Untitled Clip',
++ description TEXT DEFAULT '',
++ file_path TEXT,
++ thumbnail_url TEXT,
++ start_time REAL NOT NULL DEFAULT 0,
++ end_time REAL NOT NULL DEFAULT 0,
++ duration_seconds REAL DEFAULT 0,
++ is_public INTEGER DEFAULT 1,
++ visibility TEXT DEFAULT 'public',
++ status TEXT DEFAULT 'ready', -- processing | ready | failed (imports default ready)
++ view_count INTEGER DEFAULT 0,
++ auto_generated INTEGER DEFAULT 0,
++ storage_provider TEXT DEFAULT 'local',
++ storage_key TEXT,
++ ai_overview TEXT,
++ ai_transcript TEXT,
++ ai_analyzed_at DATETIME,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++CREATE INDEX IF NOT EXISTS idx_clips_app ON clips(app_id, created_at DESC);
++CREATE INDEX IF NOT EXISTS idx_clips_vod ON clips(vod_id);
++CREATE INDEX IF NOT EXISTS idx_clips_stream ON clips(stream_id);
++
++-- ── Unique view tracking (IP dedup for VODs and clips) ───────
++CREATE TABLE IF NOT EXISTS content_views (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ content_type TEXT NOT NULL CHECK(content_type IN ('vod', 'clip')),
++ content_id INTEGER NOT NULL,
++ ip TEXT NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ UNIQUE(content_type, content_id, ip)
++);
++CREATE INDEX IF NOT EXISTS idx_content_views_lookup ON content_views(content_type, content_id);
++
++-- ── Pastes ───────────────────────────────────────────────────
++CREATE TABLE IF NOT EXISTS pastes (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ app_id TEXT NOT NULL DEFAULT 'live',
++ slug TEXT UNIQUE NOT NULL,
++ user_id INTEGER,
++ type TEXT DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),
++ title TEXT NOT NULL DEFAULT 'Untitled',
++ content TEXT,
++ language TEXT DEFAULT 'text',
++ visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),
++ stream_id INTEGER,
++ screenshot_path TEXT,
++ metadata TEXT,
++ burn_after_read INTEGER DEFAULT 0,
++ forked_from INTEGER,
++ pinned INTEGER DEFAULT 0,
++ views INTEGER DEFAULT 0,
++ copies INTEGER DEFAULT 0,
++ likes INTEGER DEFAULT 0,
++ is_nsfw INTEGER DEFAULT 0,
++ ip_address TEXT,
++ ai_summary TEXT, -- kept for import; Live owns AI generation
++ ai_tags TEXT,
++ ai_analyzed_at DATETIME,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (forked_from) REFERENCES pastes(id) ON DELETE SET NULL
++);
++CREATE INDEX IF NOT EXISTS idx_pastes_slug ON pastes(slug);
++CREATE INDEX IF NOT EXISTS idx_pastes_app ON pastes(app_id, created_at DESC);
++CREATE INDEX IF NOT EXISTS idx_pastes_user ON pastes(user_id);
++CREATE INDEX IF NOT EXISTS idx_pastes_visibility ON pastes(visibility);
++
++CREATE TABLE IF NOT EXISTS paste_likes (
++ paste_id INTEGER NOT NULL,
++ user_id INTEGER NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (paste_id, user_id),
++ FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE
++);
++
++CREATE TABLE IF NOT EXISTS paste_comments (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ paste_id INTEGER NOT NULL,
++ user_id INTEGER,
++ parent_id INTEGER,
++ anon_name TEXT,
++ message TEXT NOT NULL,
++ ip_address TEXT,
++ is_deleted INTEGER DEFAULT 0,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (paste_id) REFERENCES pastes(id) ON DELETE CASCADE,
++ FOREIGN KEY (parent_id) REFERENCES paste_comments(id) ON DELETE CASCADE
++);
++CREATE INDEX IF NOT EXISTS idx_paste_comments_paste ON paste_comments(paste_id);
++CREATE INDEX IF NOT EXISTS idx_paste_comments_ip ON paste_comments(ip_address);
++
++-- ── Files (new: generic per-app file storage) ────────────────
++CREATE TABLE IF NOT EXISTS files (
++ key TEXT PRIMARY KEY,
++ app_id TEXT NOT NULL DEFAULT 'live',
++ user_id INTEGER,
++ original_name TEXT,
++ size INTEGER NOT NULL DEFAULT 0,
++ mime TEXT DEFAULT 'application/octet-stream',
++ sha256 TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
++CREATE INDEX IF NOT EXISTS idx_files_app ON files(app_id, created_at DESC);
++
++-- ── Service settings (storage-tier knobs, paste limits, …) ───
++-- Same key names the inherited modules used in site_settings.
++CREATE TABLE IF NOT EXISTS media_settings (
++ key TEXT PRIMARY KEY,
++ value TEXT,
++ description TEXT DEFAULT '',
++ type TEXT DEFAULT 'string',
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++);
ddiff --git a/server/files/routes.js b/server/files/routes.js
nnew file mode 100644
iindex 0000000..3466af4
---- /dev/null
++++ b/server/files/routes.js
@@@ -0,0 +1,186 @@
++/**
++ * OpenVibe.Media — Files API (mounted at /api/v1/:app/files)
++ *
++ * Generic per-app file storage (new subsystem — no predecessor equivalent):
++ *
++ * POST / multipart 'file' → { key, url, size, mime }
++ * GET / list (?limit&offset)
++ * GET /:key meta
++ * DELETE /:key delete
++ *
++ * Files live at FILES_PATH/<app>/<key> with key = <sha256-prefix>-<name>.
++ * Public serving (Content-Type + Range) is at GET /f/:key.
++ * Per-app quota: apps.quota_bytes (0 = unlimited) checked against the sum of
++ * the app's stored file sizes.
++ */
++'use strict';
++
++const express = require('express');
++const crypto = require('crypto');
++const path = require('path');
++const fs = require('fs');
++const multer = require('multer');
++const db = require('../db/database');
++const config = require('../config');
++const { tenantAuth, tenantCors } = require('../auth');
++
++const router = express.Router({ mergeParams: true });
++router.use(tenantCors);
++
++const upload = multer({
++ dest: path.join(config.files.path, '.tmp'),
++ limits: { fileSize: config.files.maxSizeMb * 1024 * 1024 },
++});
++
++function sanitizeName(name) {
++ const base = path.basename(String(name || 'file'));
++ return base.replace(/[^a-zA-Z0-9._\-]/g, '_').slice(0, 120) || 'file';
++}
++
++function appDir(appId) {
++ const dir = path.join(config.files.path, appId);
++ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
++ return dir;
++}
++
++function filePathForKey(row) {
++ return path.join(config.files.path, row.app_id, row.key);
++}
++
++function filePublic(row) {
++ return {
++ key: row.key,
++ app_id: row.app_id,
++ user_id: row.user_id,
++ original_name: row.original_name,
++ size: row.size,
++ mime: row.mime,
++ sha256: row.sha256,
++ url: `/f/${row.key}`,
++ created_at: row.created_at,
++ };
++}
++
++function sha256File(filePath) {
++ return new Promise((resolve, reject) => {
++ const hash = crypto.createHash('sha256');
++ const stream = fs.createReadStream(filePath);
++ stream.on('data', d => hash.update(d));
++ stream.on('end', () => resolve(hash.digest('hex')));
++ stream.on('error', reject);
++ });
++}
++
++// ── Upload ───────────────────────────────────────────────────
++router.post('/', tenantAuth({ allowUser: true }), upload.single('file'), async (req, res) => {
++ try {
++ if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
++
++ // Per-app quota check against quota_bytes (0 = unlimited).
++ const quota = Number(req.appRow.quota_bytes) || 0;
++ if (quota > 0) {
++ const used = db.appFilesBytes(req.appId);
++ if (used + req.file.size > quota) {
++ try { fs.unlinkSync(req.file.path); } catch { /* */ }
++ return res.status(413).json({
++ error: 'App file quota exceeded',
++ quota_bytes: quota,
++ used_bytes: used,
++ });
++ }
++ }
++
++ const digest = await sha256File(req.file.path);
++ const name = sanitizeName(req.file.originalname);
++ const key = `${digest.slice(0, 12)}-${name}`;
++
++ const existing = db.getFileByKey(key);
++ if (existing) {
++ // Same content + name already stored (any app hash-collides only on
++ // identical bytes+name for its own app; cross-app: key is global, so
++ // treat a foreign owner as a conflict).
++ try { fs.unlinkSync(req.file.path); } catch { /* */ }
++ if (existing.app_id !== req.appId) {
++ return res.status(409).json({ error: 'Key conflict — rename the file and retry' });
++ }
++ return res.status(200).json({ ...filePublic(existing), deduplicated: true });
++ }
++
++ const dest = path.join(appDir(req.appId), key);
++ try {
++ fs.renameSync(req.file.path, dest);
++ } catch {
++ fs.copyFileSync(req.file.path, dest);
++ try { fs.unlinkSync(req.file.path); } catch { /* */ }
++ }
++
++ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
++ db.createFile({
++ key,
++ app_id: req.appId,
++ user_id: userId,
++ original_name: req.file.originalname || name,
++ size: req.file.size,
++ mime: req.file.mimetype || 'application/octet-stream',
++ sha256: digest,
++ });
++
++ const row = db.getFileByKey(key, req.appId);
++ console.log(`[Files] Stored ${key} for app ${req.appId} (${(req.file.size / 1024).toFixed(1)} KB)`);
++ res.status(201).json(filePublic(row));
++ } catch (err) {
++ console.error('[Files] Upload error:', err.message);
++ if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
++ res.status(500).json({ error: 'Failed to store file' });
++ }
++});
++
++// ── List ─────────────────────────────────────────────────────
++router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);
++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
++ const files = db.listFiles(req.appId, { limit, offset });
++ res.json({
++ files: files.map(filePublic),
++ used_bytes: db.appFilesBytes(req.appId),
++ quota_bytes: Number(req.appRow.quota_bytes) || 0,
++ limit, offset,
++ });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to list files' });
++ }
++});
++
++// ── Meta ─────────────────────────────────────────────────────
++router.get('/:key', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const row = db.getFileByKey(String(req.params.key), req.appId);
++ if (!row) return res.status(404).json({ error: 'File not found' });
++ res.json(filePublic(row));
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get file' });
++ }
++});
++
++// ── Delete ───────────────────────────────────────────────────
++router.delete('/:key', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const row = db.getFileByKey(String(req.params.key), req.appId);
++ if (!row) return res.status(404).json({ error: 'File not found' });
++ if (req.authType === 'user' && !(req.userId != null && row.user_id === req.userId)) {
++ return res.status(403).json({ error: 'Not authorized to delete this file' });
++ }
++
++ const filePath = filePathForKey(row);
++ try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { /* */ }
++ db.deleteFileRow(row.key);
++ res.json({ message: 'File deleted' });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to delete file' });
++ }
++});
++
++module.exports = router;
++module.exports.filePathForKey = filePathForKey;
++module.exports.filePublic = filePublic;
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..ffaa466
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,139 @@
++/**
++ * OpenVibe.Media — service entrypoint (port 4100)
++ *
++ * Multi-tenant media service: VOD ingest/recording (RTMP pull, RTP, browser
++ * chunks), local/B2/R2 storage tiering with presigned-302 playback, clips,
++ * pastes, files, thumbnails, and HMAC-signed webhooks. See CONTRACTS.md
++ * (Media API v1) and README.md.
++ */
++'use strict';
++
++const fs = require('fs');
++const express = require('express');
++const config = require('./config');
++const db = require('./db/database');
++const auth = require('./auth');
++
++// ── Bootstrap ────────────────────────────────────────────────
++
++for (const dir of [config.vod.path, config.vod.clipsPath, config.pastes.path, config.thumbnails.path, config.files.path]) {
++ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
++}
++
++db.getDb(); // init schema (WAL)
++auth.seedApps(); // upsert MEDIA_APPS_SEED / MEDIA_APP_KEYS
++auth.startJwksRefresh();
++
++const app = express();
++app.set('trust proxy', true);
++app.use(express.json({ limit: '2mb' }));
++app.use(express.urlencoded({ extended: true, limit: '2mb' }));
++
++// CORS preflight for tenant API routes (per-app allow-list; auth-less OPTIONS).
++app.options('/api/v1/:app/*', (req, res) => {
++ const origin = req.headers.origin;
++ const appRow = db.getApp(String(req.params.app || ''));
++ if (origin && appRow && db.appAllowedOrigins(appRow).includes(origin)) {
++ res.set('Access-Control-Allow-Origin', origin);
++ res.set('Vary', 'Origin');
++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
++ res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
++ }
++ res.sendStatus(204);
++});
++
++// ── Routes ───────────────────────────────────────────────────
++
++app.get('/healthz', (req, res) => {
++ res.json({
++ ok: true,
++ service: 'openvibe-media',
++ apps: db.listApps().length,
++ recordings: require('./vod/recorder').activeCount(),
++ uptime_s: Math.round(process.uptime()),
++ });
++});
++
++app.use('/api/v1/:app/vods', require('./vod/routes'));
++app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
++app.use('/api/v1/:app/pastes', require('./pastes/routes'));
++app.use('/api/v1/:app/files', require('./files/routes'));
++app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
++app.use('/', require('./public/routes')); // /v /c /p /t /f
++
++// eslint-disable-next-line no-unused-vars
++app.use((err, req, res, next) => {
++ console.error('[HTTP] Unhandled error:', err.message);
++ if (!res.headersSent) res.status(err.status || 500).json({ error: err.message || 'Internal error' });
++});
++
++// ── Background jobs ──────────────────────────────────────────
++
++const recorder = require('./vod/recorder');
++const vodStorage = require('./vod/vod-storage');
++const healthJob = require('./vod/health-job');
++const thumbService = require('./thumbnails/thumbnail-service');
++
++const timers = [];
++function every(ms, fn) {
++ const t = setInterval(fn, ms);
++ if (t.unref) t.unref();
++ timers.push(t);
++}
++
++vodStorage.checkProviders()
++ .then(() => vodStorage.migrateLegacy().catch(() => {}))
++ .catch(err => console.warn('[Boot] Provider check failed:', err.message));
++vodStorage.start(); // tiering sweep
++healthJob.start(); // health scan + quarantine cleanup
++every(60 * 1000, () => recorder.checkDisk()); // disk guardian
++every(60 * 60 * 1000, () => thumbService.cleanupOldThumbnails()); // stale live thumbs
++
++// Recover from an unclean shutdown: rows stuck in is_recording with no live
++// ffmpeg are finalized from whatever hit the disk.
++setTimeout(() => {
++ try {
++ const stuck = db.all('SELECT id FROM vods WHERE is_recording = 1');
++ for (const row of stuck) {
++ if (recorder.isRecording(row.id)) continue;
++ console.log(`[Boot] Finalizing orphaned recording vod ${row.id}`);
++ require('./vod/finalize').finalizeVod(row.id).catch(() => {});
++ }
++ } catch (err) {
++ console.warn('[Boot] Orphan finalize sweep failed:', err.message);
++ }
++}, 5000).unref?.();
++
++// ── Listen + graceful shutdown ───────────────────────────────
++
++const server = app.listen(config.port, config.host, () => {
++ console.log(`[Media] OpenVibe.Media listening on ${config.host}:${config.port} (${config.nodeEnv})`);
++ console.log(`[Media] Data: db=${config.db.path} vods=${config.vod.path} clips=${config.vod.clipsPath}`);
++ console.log(`[Media] RTP ingest pool: udp ${config.rtp.portMin}-${config.rtp.portMax} (127.0.0.1)`);
++});
++
++let shuttingDown = false;
++function shutdown(signal) {
++ if (shuttingDown) return;
++ shuttingDown = true;
++ console.log(`[Media] ${signal} received — shutting down`);
++ try { recorder.stopAll(); } catch { /* */ }
++ try { vodStorage.stop(); } catch { /* */ }
++ try { healthJob.stop(); } catch { /* */ }
++ try { auth.stopJwksRefresh(); } catch { /* */ }
++ for (const t of timers) clearInterval(t);
++ server.close(() => {
++ try { db.close(); } catch { /* */ }
++ process.exit(0);
++ });
++ // Recordings get STOP_GRACE_MS to flush trailers; don't hang forever.
++ setTimeout(() => {
++ console.warn('[Media] Forced exit after shutdown grace');
++ try { db.close(); } catch { /* */ }
++ process.exit(0);
++ }, 70_000).unref();
++}
++process.on('SIGINT', () => shutdown('SIGINT'));
++process.on('SIGTERM', () => shutdown('SIGTERM'));
++
++module.exports = { app, server };
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
nnew file mode 100644
iindex 0000000..25cccd0
---- /dev/null
++++ b/server/pastes/routes.js
@@@ -0,0 +1,627 @@
++/**
++ * OpenVibe.Media — Pastes API (mounted at /api/v1/:app/pastes)
++ *
++ * Pastebin-style sharing ported from the predecessor: text pastes, code
++ * snippets, and screenshots (EXIF-stripped via sharp). Public pages live at
++ * /p/:slug (HTML) and /p/:slug/raw.
++ *
++ * POST / { title?, content?, language?, user_id?, visibility?,
++ * burn_after_read?, is_nsfw?, screenshot (multipart)? }
++ * → { id, slug, url }
++ * GET / list (?limit&offset&type&search&user_id)
++ * GET /:slug paste meta/content
++ * PUT /:slug update
++ * DELETE /:slug delete (+ screenshot local & legacy B2)
++ * POST /:slug/fork fork a text paste
++ * POST /:slug/like toggle-like (user identity required)
++ * POST /:slug/copy track a copy event
++ * GET /:slug/comments list comments (+replies)
++ * POST /:slug/comments add comment
++ * DELETE /:slug/comments/:id delete comment
++ *
++ * Rate limits (cooldown/daily) apply to user-JWT callers; app-key callers are
++ * trusted server-to-server. AI summary/tags hooks are dropped (Live owns AI)
++ * but the columns remain for imported rows.
++ */
++'use strict';
++
++const express = require('express');
++const crypto = require('crypto');
++const path = require('path');
++const fs = require('fs');
++const multer = require('multer');
++const sharp = require('sharp');
++const db = require('../db/database');
++const config = require('../config');
++const { tenantAuth, tenantCors } = require('../auth');
++
++const router = express.Router({ mergeParams: true });
++router.use(tenantCors);
++
++// ── Screenshot upload storage ───────────────────────────────
++const SCREENSHOTS_DIR = path.join(config.pastes.path, 'screenshots');
++if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
++
++const MIME_TO_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };
++const screenshotStorage = multer.diskStorage({
++ destination: (_req, _file, cb) => cb(null, SCREENSHOTS_DIR),
++ filename: (_req, file, cb) => {
++ const ext = MIME_TO_EXT[file.mimetype] || '.png';
++ cb(null, `ss-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`);
++ },
++});
++const screenshotUpload = multer({
++ storage: screenshotStorage,
++ limits: { fileSize: 16 * 1024 * 1024 }, // hard cap; per-app setting enforced below
++ fileFilter: (_req, file, cb) => {
++ if (/^image\/(png|jpeg|webp|gif)$/.test(file.mimetype)) cb(null, true);
++ else cb(new Error('Only PNG, JPEG, WebP, or GIF images allowed'));
++ },
++});
++
++// ── Helpers ─────────────────────────────────────────────────
++
++// Word pools for readable paste slugs (adj-noun-number)
++const SLUG_ADJECTIVES = [
++ 'amber', 'blue', 'bold', 'brave', 'bright', 'calm', 'clean', 'clever',
++ 'cold', 'cool', 'coral', 'crisp', 'dark', 'dawn', 'deep', 'dry',
++ 'dusk', 'dusty', 'fair', 'fast', 'fierce', 'fine', 'foggy', 'free',
++ 'fresh', 'frost', 'glad', 'gold', 'grand', 'gray', 'green', 'grim',
++ 'hazy', 'heavy', 'hidden', 'hollow', 'honey', 'hot', 'icy', 'iron',
++ 'jade', 'keen', 'kind', 'late', 'lazy', 'light', 'lime', 'lit',
++ 'lone', 'lost', 'loud', 'lucky', 'lush', 'mild', 'misty', 'mossy',
++ 'muddy', 'neon', 'new', 'noble', 'odd', 'old', 'opal', 'open',
++ 'pale', 'pink', 'plain', 'plum', 'prime', 'proud', 'pure', 'quick',
++ 'quiet', 'rare', 'raw', 'red', 'rich', 'rocky', 'rosy', 'rough',
++ 'ruby', 'rusty', 'safe', 'sage', 'sandy', 'sharp', 'shy', 'silver',
++ 'slim', 'slow', 'smoky', 'snowy', 'soft', 'sour', 'steep', 'still',
++ 'stone', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'teal', 'thin',
++ 'tidy', 'tiny', 'torn', 'vast', 'vivid', 'warm', 'wavy', 'west',
++ 'wet', 'white', 'wide', 'wild', 'windy', 'wise', 'worn', 'young',
++];
++const SLUG_NOUNS = [
++ 'acorn', 'arch', 'arrow', 'aspen', 'badger', 'basil', 'bay', 'bear',
++ 'birch', 'blade', 'bloom', 'bolt', 'brook', 'brush', 'cairn', 'cave',
++ 'cedar', 'cliff', 'cloud', 'clover', 'coast', 'coral', 'crane', 'creek',
++ 'crow', 'dale', 'deer', 'delta', 'dew', 'dock', 'dove', 'drift',
++ 'drum', 'dune', 'eagle', 'echo', 'edge', 'elm', 'ember', 'fawn',
++ 'fern', 'field', 'finch', 'flame', 'flare', 'flint', 'fog', 'ford',
++ 'forge', 'fox', 'frost', 'gale', 'gate', 'gem', 'glen', 'goat',
++ 'grove', 'gull', 'hawk', 'haze', 'heath', 'hedge', 'heron', 'hill',
++ 'holly', 'horse', 'hound', 'isle', 'ivy', 'jade', 'jay', 'kelp',
++ 'lake', 'lark', 'leaf', 'ledge', 'lily', 'lion', 'lodge', 'lynx',
++ 'maple', 'marsh', 'mesa', 'mill', 'mint', 'mist', 'moon', 'moss',
++ 'moth', 'mule', 'nest', 'oak', 'orca', 'otter', 'owl', 'palm',
++ 'path', 'peak', 'pearl', 'petal', 'pike', 'pine', 'plum', 'pond',
++ 'quail', 'rain', 'raven', 'reed', 'reef', 'ridge', 'river', 'robin',
++ 'root', 'rose', 'sage', 'seal', 'shade', 'shell', 'shore', 'slate',
++ 'snail', 'spark', 'stone', 'storm', 'stork', 'thorn', 'tide', 'trail',
++ 'trout', 'tulip', 'vale', 'vine', 'viper', 'wave', 'wren', 'wolf',
++];
++
++function generateSlug(attempts = 0) {
++ if (attempts >= 10) throw new Error('Could not generate a unique paste slug after 10 attempts');
++ const adj = SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)];
++ const noun = SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)];
++ const num = crypto.randomInt(10, 100); // 10–99
++ const slug = `${adj}-${noun}-${num}`;
++ const existing = db.get('SELECT 1 FROM pastes WHERE slug = ?', [slug]);
++ if (existing) return generateSlug(attempts + 1);
++ return slug;
++}
++
++function sanitizeTitle(title) {
++ return String(title || '').trim().slice(0, 200) || 'Untitled';
++}
++
++function detectLanguage(content, hint) {
++ if (hint && hint !== 'auto') return hint;
++ const first = String(content || '').slice(0, 500);
++ if (/^<(!DOCTYPE|html|div|span|head|body)/im.test(first)) return 'html';
++ if (/^(import |from |const |let |var |function |=>|class )/m.test(first)) return 'javascript';
++ if (/^(def |class |import |from |print\(|if __name__)/m.test(first)) return 'python';
++ if (/^(package |func |import \(|fmt\.)/m.test(first)) return 'go';
++ if (/^\{[\s\n]*"/.test(first)) return 'json';
++ if (/^---\n|^[a-z_]+:\s/m.test(first)) return 'yaml';
++ if (/^#!\/(bin|usr)/m.test(first)) return 'bash';
++ if (/```|^#{1,6} |^\* |\*\*|^\[.*\]\(.*\)/m.test(first)) return 'markdown';
++ if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE|DELETE)\s/im.test(first)) return 'sql';
++ if (/^<\?php/m.test(first)) return 'php';
++ if (/^(use |fn |let mut |pub |impl |struct )/m.test(first)) return 'rust';
++ return 'text';
++}
++
++function getPasteConfig() {
++ return {
++ maxSizeKb: Number(db.getSetting('paste_max_size_kb')) || 512,
++ screenshotMaxSizeMb: Number(db.getSetting('paste_screenshot_max_size_mb')) || 8,
++ cooldownSeconds: Number(db.getSetting('paste_cooldown_seconds')) || 30,
++ maxPerUserPerDay: Number(db.getSetting('paste_max_per_user_per_day')) || 0,
++ };
++}
++
++function screenshotUrl(paste) {
++ return paste.screenshot_path ? `/p/${paste.slug}/screenshot` : null;
++}
++
++function pastePublic(paste) {
++ if (!paste) return null;
++ return {
++ id: paste.id,
++ app_id: paste.app_id,
++ slug: paste.slug,
++ user_id: paste.user_id,
++ type: paste.type,
++ title: paste.title,
++ content: paste.content,
++ language: paste.language,
++ visibility: paste.visibility,
++ stream_id: paste.stream_id,
++ screenshot_url: screenshotUrl(paste),
++ metadata: paste.metadata,
++ burn_after_read: !!paste.burn_after_read,
++ forked_from: paste.forked_from,
++ pinned: !!paste.pinned,
++ views: paste.views || 0,
++ copies: paste.copies || 0,
++ likes: paste.likes || 0,
++ is_nsfw: !!paste.is_nsfw,
++ url: `/p/${paste.slug}`,
++ raw_url: `/p/${paste.slug}/raw`,
++ created_at: paste.created_at,
++ updated_at: paste.updated_at,
++ };
++}
++
++// Effective actor for rate limits: user-JWT callers get cooldowns; app-key
++// callers are trusted server-to-server (their platform enforces its own).
++function _actor(req) {
++ if (req.authType === 'user') return { userId: req.userId, ip: req.ip, limited: true };
++ return { userId: (req.body && req.body.user_id) || null, ip: req.ip, limited: false };
++}
++
++function _rateLimitCheck(req, res) {
++ const actor = _actor(req);
++ if (!actor.limited) return true;
++ const cfg = getPasteConfig();
++ if (cfg.cooldownSeconds > 0) {
++ const lastTime = db.getLastPasteTime(req.appId, actor.userId, actor.ip);
++ const elapsed = (Date.now() - lastTime) / 1000;
++ if (elapsed < cfg.cooldownSeconds) {
++ const wait = Math.ceil(cfg.cooldownSeconds - elapsed);
++ res.status(429).json({ error: `Please wait ${wait}s before creating another paste`, cooldown: wait });
++ return false;
++ }
++ }
++ if (cfg.maxPerUserPerDay > 0) {
++ const todayCount = db.countUserPastesToday(req.appId, actor.userId, actor.ip);
++ if (todayCount >= cfg.maxPerUserPerDay) {
++ res.status(429).json({ error: `Daily paste limit reached (${cfg.maxPerUserPerDay}/day)` });
++ return false;
++ }
++ }
++ return true;
++}
++
++function _getPasteScoped(req, res) {
++ const paste = db.getPasteBySlug(String(req.params.slug), req.appId);
++ if (!paste) { res.status(404).json({ error: 'Paste not found' }); return null; }
++ return paste;
++}
++
++// Fully remove a paste's screenshot from local disk AND any legacy B2 object.
++function removePasteScreenshot(paste) {
++ if (!paste) return;
++ if (paste.screenshot_path) {
++ try { fs.unlinkSync(paste.screenshot_path); } catch { /* ignore */ }
++ }
++ try { require('../vod/vod-storage').deleteLegacyPasteScreenshot(paste.id).catch(() => {}); } catch { /* ignore */ }
++}
++
++// ── List pastes ─────────────────────────────────────────────
++router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
++ const offset = Math.max(parseInt(req.query.offset) || 0, 0);
++ const type = req.query.type; // 'paste', 'screenshot', or undefined for all
++ const search = req.query.search ? `%${req.query.search}%` : null;
++ const userId = req.query.user_id != null ? req.query.user_id : null;
++
++ let sql = `SELECT * FROM pastes WHERE app_id = ? AND visibility = 'public'`;
++ const params = [req.appId];
++
++ if (type === 'paste' || type === 'screenshot') { sql += ` AND type = ?`; params.push(type); }
++ if (userId != null) { sql += ` AND user_id = ?`; params.push(userId); }
++ if (search) { sql += ` AND (title LIKE ? OR content LIKE ?)`; params.push(search, search); }
++
++ const dir = req.query.sort === 'oldest' ? 'ASC' : 'DESC';
++ sql += ` ORDER BY pinned DESC, created_at ${dir} LIMIT ? OFFSET ?`;
++ params.push(limit, offset);
++
++ const pastes = db.all(sql, params).map(p => ({
++ ...pastePublic(p),
++ content: p.type === 'paste' ? (p.content || '').slice(0, 300) : null, // Preview only in list
++ }));
++
++ let countSql = `SELECT COUNT(*) as total FROM pastes WHERE app_id = ? AND visibility = 'public'`;
++ const countParams = [req.appId];
++ if (type === 'paste' || type === 'screenshot') { countSql += ` AND type = ?`; countParams.push(type); }
++ if (userId != null) { countSql += ` AND user_id = ?`; countParams.push(userId); }
++ if (search) { countSql += ` AND (title LIKE ? OR content LIKE ?)`; countParams.push(search, search); }
++ const { total } = db.get(countSql, countParams);
++
++ res.json({ pastes, total, limit, offset });
++ } catch (err) {
++ console.error('[Pastes] List error:', err.message);
++ res.status(500).json({ error: 'Failed to list pastes' });
++ }
++});
++
++// ── Get single paste by slug ────────────────────────────────
++router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const paste = _getPasteScoped(req, res);
++ if (!paste) return;
++
++ // Private pastes: owner (or the app itself) only.
++ if (paste.visibility === 'private') {
++ const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);
++ if (!allowed) return res.status(404).json({ error: 'Paste not found' });
++ }
++
++ paste.liked = req.userId != null ? db.hasUserLikedPaste(paste.id, req.userId) : false;
++ res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });
++ } catch (err) {
++ console.error('[Pastes] Get error:', err.message);
++ res.status(500).json({ error: 'Failed to get paste' });
++ }
++});
++
++// ── Create paste (text, or screenshot via multipart) ────────
++router.post('/', tenantAuth({ allowUser: true }), screenshotUpload.single('screenshot'), async (req, res) => {
++ try {
++ const body = req.body || {};
++ const cfg = getPasteConfig();
++
++ if (!_rateLimitCheck(req, res)) {
++ if (req.file) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
++ return;
++ }
++
++ const actor = _actor(req);
++ const userId = req.authType === 'user' ? req.userId : (body.user_id != null ? body.user_id : null);
++ const vis = ['unlisted', 'private'].includes(body.visibility) ? body.visibility : 'public';
++ const burn = body.burn_after_read ? 1 : 0;
++ const nsfw = body.is_nsfw ? 1 : 0;
++ const slug = generateSlug();
++
++ // ── Screenshot paste ────────────────────────────────
++ if (req.file) {
++ if (req.file.size > cfg.screenshotMaxSizeMb * 1024 * 1024) {
++ try { fs.unlinkSync(req.file.path); } catch { /* */ }
++ return res.status(400).json({ error: `File too large (max ${cfg.screenshotMaxSizeMb} MB)` });
++ }
++
++ // Strip EXIF/GPS metadata by re-encoding with sharp (GIFs kept as-is
++ // so animations survive).
++ const mime = req.file.mimetype;
++ if (mime === 'image/png' || mime === 'image/jpeg' || mime === 'image/webp') {
++ try {
++ const img = sharp(req.file.path);
++ let buf;
++ if (mime === 'image/png') buf = await img.png().toBuffer();
++ else if (mime === 'image/jpeg') buf = await img.jpeg({ quality: 90 }).toBuffer();
++ else buf = await img.webp({ quality: 90 }).toBuffer();
++ fs.writeFileSync(req.file.path, buf);
++ req.file.size = buf.length;
++ } catch (stripErr) {
++ console.warn('[Pastes] Metadata strip warning:', stripErr.message);
++ // Continue with original file if re-encoding fails
++ }
++ }
++
++ const metadata = JSON.stringify({
++ page_url: body.page_url || null,
++ user_agent: body.user_agent || req.get('user-agent') || null,
++ original_name: req.file.originalname,
++ size_bytes: req.file.size,
++ mime_type: req.file.mimetype,
++ });
++
++ db.run(
++ `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, screenshot_path, metadata, burn_after_read, is_nsfw, ip_address)
++ VALUES (?, ?, ?, 'screenshot', ?, ?, 'text', ?, ?, ?, ?, ?, ?, ?)`,
++ [req.appId, slug, userId, sanitizeTitle(body.title || 'Screenshot'),
++ body.description || body.content || '', vis, body.stream_id || null, req.file.path, metadata, burn, nsfw, actor.ip]
++ );
++
++ const paste = db.getPasteBySlug(slug, req.appId);
++ return res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });
++ }
++
++ // ── Text paste ──────────────────────────────────────
++ const content = body.content;
++ if (!content || typeof content !== 'string' || content.trim().length === 0) {
++ return res.status(400).json({ error: 'Content is required' });
++ }
++ const maxBytes = cfg.maxSizeKb * 1024;
++ if (content.length > maxBytes) {
++ return res.status(400).json({ error: `Paste too large (max ${cfg.maxSizeKb} KB)` });
++ }
++
++ const lang = detectLanguage(content, body.language);
++ db.run(
++ `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, stream_id, burn_after_read, is_nsfw, ip_address)
++ VALUES (?, ?, ?, 'paste', ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [req.appId, slug, userId, sanitizeTitle(body.title), content.trim(), lang, vis,
++ body.stream_id || null, burn, nsfw, actor.ip]
++ );
++
++ const paste = db.getPasteBySlug(slug, req.appId);
++ res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });
++ } catch (err) {
++ console.error('[Pastes] Create error:', err.message);
++ if (req.file?.path) { try { fs.unlinkSync(req.file.path); } catch { /* */ } }
++ res.status(500).json({ error: 'Failed to create paste' });
++ }
++});
++
++// ── Update paste ────────────────────────────────────────────
++router.put('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const paste = _getPasteScoped(req, res);
++ if (!paste) return;
++ if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {
++ return res.status(403).json({ error: 'Not authorized for this paste' });
++ }
++
++ const { title, content, language, visibility, pinned, is_nsfw } = req.body || {};
++ const updates = [];
++ const params = [];
++
++ if (title !== undefined) { updates.push('title = ?'); params.push(sanitizeTitle(title)); }
++ if (content !== undefined && paste.type === 'paste') {
++ const cfg = getPasteConfig();
++ if (content.length > cfg.maxSizeKb * 1024) return res.status(400).json({ error: 'Too large' });
++ updates.push('content = ?'); params.push(content);
++ updates.push('language = ?'); params.push(detectLanguage(content, language));
++ }
++ if (visibility !== undefined) { updates.push('visibility = ?'); params.push(['unlisted', 'private'].includes(visibility) ? visibility : 'public'); }
++ if (is_nsfw !== undefined) { updates.push('is_nsfw = ?'); params.push(is_nsfw ? 1 : 0); }
++ if (pinned !== undefined && req.authType === 'app') {
++ updates.push('pinned = ?'); params.push(pinned ? 1 : 0);
++ }
++
++ if (updates.length === 0) return res.status(400).json({ error: 'Nothing to update' });
++
++ updates.push('updated_at = CURRENT_TIMESTAMP');
++ params.push(paste.id);
++ db.run(`UPDATE pastes SET ${updates.join(', ')} WHERE id = ?`, params);
++ res.json({ paste: pastePublic(db.getPasteBySlug(paste.slug, req.appId)) });
++ } catch (err) {
++ console.error('[Pastes] Update error:', err.message);
++ res.status(500).json({ error: 'Failed to update paste' });
++ }
++});
++
++// ── Delete paste ────────────────────────────────────────────
++router.delete('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const paste = _getPasteScoped(req, res);
++ if (!paste) return;
++ if (req.authType === 'user' && !(req.userId != null && paste.user_id === req.userId)) {
++ return res.status(403).json({ error: 'Not authorized for this paste' });
++ }
++
++ removePasteScreenshot(paste);
++ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
++ res.json({ success: true });
++ } catch (err) {
++ console.error('[Pastes] Delete error:', err.message);
++ res.status(500).json({ error: 'Failed to delete paste' });
++ }
++});
++
++// ── Fork (copy) a paste ─────────────────────────────────────
++router.post('/:slug/fork', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const original = _getPasteScoped(req, res);
++ if (!original) return;
++ if (original.type !== 'paste') return res.status(400).json({ error: 'Only text pastes can be forked' });
++ if (!_rateLimitCheck(req, res)) return;
++
++ const actor = _actor(req);
++ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
++ const slug = generateSlug();
++ db.run(
++ `INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, forked_from, ip_address)
++ VALUES (?, ?, ?, 'paste', ?, ?, ?, 'public', ?, ?)`,
++ [req.appId, slug, userId, `Fork of ${original.title}`,
++ original.content, original.language, original.id, actor.ip]
++ );
++
++ const paste = db.getPasteBySlug(slug, req.appId);
++ res.status(201).json({ id: paste.id, slug, url: `/p/${slug}`, paste: pastePublic(paste) });
++ } catch (err) {
++ console.error('[Pastes] Fork error:', err.message);
++ res.status(500).json({ error: 'Failed to fork paste' });
++ }
++});
++
++// ── Like / Unlike a paste ───────────────────────────────────
++router.post('/:slug/like', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const paste = _getPasteScoped(req, res);
++ if (!paste) return;
++ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
++ if (userId == null) return res.status(400).json({ error: 'user_id required to like' });
++
++ const alreadyLiked = db.hasUserLikedPaste(paste.id, userId);
++ const result = alreadyLiked ? db.unlikePaste(paste.id, userId) : db.likePaste(paste.id, userId);
++ res.json({ liked: !alreadyLiked, likes: result?.likes || 0 });
++ } catch (err) {
++ console.error('[Pastes] Like error:', err.message);
++ res.status(500).json({ error: 'Failed to toggle like' });
++ }
++});
++
++// ── Track a copy event ──────────────────────────────────────
++router.post('/:slug/copy', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const paste = _getPasteScoped(req, res);
++ if (!paste) return;
++ db.incrementPasteCopies(paste.slug);
++ res.json({ copies: (paste.copies || 0) + 1 });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to track copy' });
++ }
++});
++
++// ═════════════════════════════════════════════════════════════
++// ── Paste Comments (supports anonymous via app callers) ─────
++// ═════════════════════════════════════════════════════════════
++
++// Rate-limit state: IP → timestamp of last comment
++const commentCooldowns = new Map();
++const _cooldownSweep = setInterval(() => {
++ const now = Date.now();
++ for (const [ip, ts] of commentCooldowns) {
++ if (now - ts > 120_000) commentCooldowns.delete(ip);
++ }
++}, 600_000);
++if (_cooldownSweep.unref) _cooldownSweep.unref();
++
++router.get('/:slug/comments', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const paste = _getPasteScoped(req, res);
++ if (!paste) return;
++
++ const limit = Math.min(parseInt(req.query.limit || '50'), 100);
++ const offset = parseInt(req.query.offset || '0');
++
++ const comments = db.getPasteComments(paste.id, limit, offset);
++ const total = db.getPasteCommentCount(paste.id);
++
++ for (const c of comments) {
++ c.replies = db.getPasteCommentReplies(c.id);
++ c.reply_count = c.replies.length;
++ }
++
++ res.json({ comments, total });
++ } catch (err) {
++ console.error('[PasteComments] List error:', err.message);
++ res.status(500).json({ error: 'Failed to load comments' });
++ }
++});
++
++router.post('/:slug/comments', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const paste = _getPasteScoped(req, res);
++ if (!paste) return;
++
++ const ip = req.ip || 'unknown';
++ const userId = req.authType === 'user' ? req.userId : (req.body?.user_id ?? null);
++
++ // ── Anon check ──────────────────────────────────────
++ if (userId == null && db.getSetting('paste_comment_anon_allowed') === false) {
++ return res.status(401).json({ error: 'You must be logged in to comment' });
++ }
++
++ // ── Message validation ──────────────────────────────
++ const message = (req.body?.message || '').trim();
++ const maxLen = Number(db.getSetting('paste_comment_max_length')) || 2000;
++ if (!message) return res.status(400).json({ error: 'Comment cannot be empty' });
++ if (message.length > maxLen) {
++ return res.status(400).json({ error: `Comment must be under ${maxLen} characters` });
++ }
++
++ // ── Anon name validation ────────────────────────────
++ let anonName = null;
++ if (userId == null) {
++ anonName = (req.body?.anon_name || '').trim().substring(0, 32) || 'Anonymous';
++ anonName = anonName.replace(/[^a-zA-Z0-9 _\-]/g, '').trim() || 'Anonymous';
++ }
++
++ // ── Rate limits (user-JWT/browser callers) ──────────
++ if (req.authType === 'user') {
++ const cooldownSec = Number(db.getSetting('paste_comment_cooldown_seconds')) || 10;
++ const lastComment = commentCooldowns.get(ip);
++ if (lastComment && (Date.now() - lastComment) < cooldownSec * 1000) {
++ const wait = Math.ceil((cooldownSec * 1000 - (Date.now() - lastComment)) / 1000);
++ return res.status(429).json({ error: `Please wait ${wait}s before commenting again` });
++ }
++ const recentFromIp = db.getRecentPasteCommentsByIp(ip, 60);
++ if (recentFromIp.length >= 5) {
++ return res.status(429).json({ error: 'Too many comments. Please slow down.' });
++ }
++ if (recentFromIp.length > 0 && recentFromIp[0].message === message) {
++ return res.status(400).json({ error: 'Duplicate comment' });
++ }
++ }
++
++ // ── Parent comment validation ───────────────────────
++ const parentId = req.body?.parent_id ? parseInt(req.body.parent_id) : null;
++ let parent = null;
++ if (parentId) {
++ parent = db.getPasteCommentById(parentId);
++ if (!parent || parent.paste_id !== paste.id) {
++ return res.status(400).json({ error: 'Invalid parent comment' });
++ }
++ // Prevent deeply nested replies — only allow replies to top-level
++ if (parent.parent_id) {
++ return res.status(400).json({ error: 'Cannot reply to a reply — reply to the original comment instead' });
++ }
++ }
++
++ const result = db.createPasteComment({
++ paste_id: paste.id,
++ user_id: userId,
++ parent_id: parentId,
++ anon_name: anonName,
++ message,
++ ip_address: ip,
++ });
++
++ commentCooldowns.set(ip, Date.now());
++
++ const comment = db.getPasteCommentById(result.lastInsertRowid);
++ res.status(201).json({ comment });
++ } catch (err) {
++ console.error('[PasteComments] Create error:', err.message);
++ res.status(500).json({ error: 'Failed to post comment' });
++ }
++});
++
++router.delete('/:slug/comments/:commentId', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const paste = _getPasteScoped(req, res);
++ if (!paste) return;
++
++ const comment = db.getPasteCommentById(parseInt(req.params.commentId));
++ if (!comment || comment.paste_id !== paste.id) {
++ return res.status(404).json({ error: 'Comment not found' });
++ }
++
++ // App callers moderate freely; user callers must be the comment author
++ // or the paste owner.
++ if (req.authType === 'user') {
++ const isAuthor = comment.user_id != null && comment.user_id === req.userId;
++ const isPasteOwner = paste.user_id != null && paste.user_id === req.userId;
++ if (!isAuthor && !isPasteOwner) {
++ return res.status(403).json({ error: 'Not authorized to delete this comment' });
++ }
++ }
++
++ db.deletePasteComment(comment.id);
++ res.json({ message: 'Comment deleted' });
++ } catch (err) {
++ console.error('[PasteComments] Delete error:', err.message);
++ res.status(500).json({ error: 'Failed to delete comment' });
++ }
++});
++
++module.exports = router;
++module.exports.pastePublic = pastePublic;
++module.exports.screenshotUrl = screenshotUrl;
++module.exports.removePasteScreenshot = removePasteScreenshot;
++module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;
ddiff --git a/server/public/routes.js b/server/public/routes.js
nnew file mode 100644
iindex 0000000..35f88ec
---- /dev/null
++++ b/server/public/routes.js
@@@ -0,0 +1,351 @@
++/**
++ * OpenVibe.Media — Public serving routes (no auth unless the item is private)
++ *
++ * GET /v/:id VOD playback: local stream w/ Range, or 302 presigned
++ * B2/R2 (inherited logic). Live recordings serve the
++ * fully-indexed .seekable sidecar for DVR.
++ * GET /c/:id clip playback (same tiering/range logic)
++ * GET /p/:slug paste HTML page (indexable)
++ * GET /p/:slug/raw paste raw text
++ * GET /p/:slug/screenshot paste screenshot image
++ * GET /t/:id thumbnails (id = filename)
++ * GET /f/:key files with correct Content-Type + Range
++ *
++ * /v /c /t /f carry X-Robots-Tag: noindex — the owning app has the canonical
++ * page. Pastes are Media-canonical and stay indexable.
++ */
++'use strict';
++
++const express = require('express');
++const path = require('path');
++const fs = require('fs');
++const db = require('../db/database');
++const tools = require('../vod/media-tools');
++const { optionalIdentity } = require('../auth');
++
++const router = express.Router();
++
++const MIME_TYPES = {
++ '.webm': 'video/webm', '.mp4': 'video/mp4', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',
++ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
++};
++
++function getRequesterIp(req) {
++ return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.socket?.remoteAddress || 'unknown';
++}
++
++function trackUniqueView(kind, id, req) {
++ try {
++ const ip = getRequesterIp(req);
++ const inserted = db.run(
++ 'INSERT OR IGNORE INTO content_views (content_type, content_id, ip) VALUES (?, ?, ?)',
++ [kind, id, ip]
++ );
++ if (inserted.changes > 0) {
++ const count = db.get('SELECT COUNT(*) as c FROM content_views WHERE content_type = ? AND content_id = ?', [kind, id]);
++ const table = kind === 'vod' ? 'vods' : 'clips';
++ db.run(`UPDATE ${table} SET view_count = ? WHERE id = ?`, [count.c, id]);
++ }
++ } catch { /* non-critical */ }
++}
++
++function canAccessPrivate(record, req) {
++ // Private items: the owning app (its API key) or the owning user's JWT.
++ if (req.authType === 'app' && req.appId === record.app_id) return true;
++ if (req.authType === 'user' && req.userId != null && record.user_id === req.userId) return true;
++ return false;
++}
++
++function streamFileWithRange(req, res, filePath, extraHeaders = {}) {
++ const stat = fs.statSync(filePath);
++ const range = req.headers.range;
++ const ext = path.extname(filePath).toLowerCase();
++ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
++
++ if (range) {
++ const parts = range.replace(/bytes=/, '').split('-');
++ const start = parseInt(parts[0], 10) || 0;
++ const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
++ if (start >= stat.size || end < start) {
++ res.writeHead(416, { 'Content-Range': `bytes */${stat.size}`, ...extraHeaders });
++ return res.end();
++ }
++ const chunkSize = Math.min(end, stat.size - 1) - start + 1;
++ res.writeHead(206, {
++ 'Content-Range': `bytes ${start}-${Math.min(end, stat.size - 1)}/${stat.size}`,
++ 'Accept-Ranges': 'bytes',
++ 'Content-Length': chunkSize,
++ 'Content-Type': contentType,
++ ...extraHeaders,
++ });
++ fs.createReadStream(filePath, { start, end: Math.min(end, stat.size - 1) }).pipe(res);
++ } else {
++ res.writeHead(200, {
++ 'Content-Length': stat.size,
++ 'Content-Type': contentType,
++ 'Accept-Ranges': 'bytes',
++ ...extraHeaders,
++ });
++ fs.createReadStream(filePath).pipe(res);
++ }
++}
++
++/**
++ * Serve a vods/clips row: local file (Range + live-DVR sidecar) or 302
++ * presigned B2/R2 — the inherited playback logic.
++ */
++async function serveMediaRecord(kind, record, req, res) {
++ const vodStorage = require('../vod/vod-storage');
++ const noindex = { 'X-Robots-Tag': 'noindex' };
++
++ const visibility = record.visibility || (record.is_public ? 'public' : 'private');
++ if (visibility === 'private' && !canAccessPrivate(record, req)) {
++ return res.status(403).json({ error: 'This media is private' });
++ }
++
++ trackUniqueView(kind, record.id, req);
++
++ // Track last access time for storage tier decisions
++ if (kind === 'vod') {
++ try { db.run("UPDATE vods SET last_accessed_at = datetime('now') WHERE id = ?", [record.id]); } catch {}
++ }
++
++ // Resolve the local file. Clips keep their own absolute path; VODs resolve
++ // by basename under VOD_PATH (legacy rows carry old absolute paths).
++ let filePath = null;
++ if (record.file_path) {
++ const candidates = kind === 'vod'
++ ? [vodStorage.localPathForVod(record), record.file_path]
++ : [record.file_path, path.join(path.resolve(require('../config').vod.clipsPath), path.basename(record.file_path))];
++ for (const p of candidates) {
++ if (p && fs.existsSync(p)) { filePath = p; break; }
++ }
++ }
++
++ // Offloaded (B2/R2) — redirect to a presigned object-store URL. Range
++ // requests are handled natively by the object store.
++ if (!filePath && vodStorage.isRemote(record)) {
++ const plan = await vodStorage.resolvePlayback(record);
++ if (plan?.kind === 'redirect') {
++ res.set('Cache-Control', 'private, max-age=0');
++ res.set('X-Robots-Tag', 'noindex');
++ return res.redirect(302, plan.url);
++ }
++ if (plan?.kind === 'file') filePath = plan.path;
++ }
++
++ if (!filePath) return res.status(404).json({ error: 'Media file unavailable' });
++
++ // For a live recording, serve the fully-indexed seekable sidecar so DVR
++ // viewers can seek anywhere.
++ const seekablePath = tools.seekableSidecarPath(filePath);
++ const isLiveSeekable = !!record.is_recording && !!seekablePath && seekablePath !== filePath && fs.existsSync(seekablePath);
++ if (isLiveSeekable) filePath = seekablePath;
++ // A still-recording MP4 grows in place — serve it no-cache so the player
++ // keeps discovering new bytes (DVR to the live edge).
++ const isLiveMp4 = !!record.is_recording && filePath.endsWith('.mp4');
++
++ const cacheHeaders = (isLiveSeekable || isLiveMp4)
++ ? { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }
++ : {};
++
++ streamFileWithRange(req, res, filePath, { ...cacheHeaders, ...noindex });
++}
++
++// ── VOD playback ─────────────────────────────────────────────
++router.get('/v/:id', optionalIdentity, async (req, res) => {
++ try {
++ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });
++ const vod = db.getVodById(parseInt(req.params.id, 10));
++ if (!vod || vod.clips_only) return res.status(404).json({ error: 'Not found' });
++ await serveMediaRecord('vod', vod, req, res);
++ } catch (err) {
++ console.error('[Public] /v error:', err.message);
++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
++ }
++});
++
++// ── Clip playback ────────────────────────────────────────────
++router.get('/c/:id', optionalIdentity, async (req, res) => {
++ try {
++ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });
++ const clip = db.getClipById(parseInt(req.params.id, 10));
++ if (!clip || !clip.file_path) return res.status(404).json({ error: 'Not found' });
++ await serveMediaRecord('clip', clip, req, res);
++ } catch (err) {
++ console.error('[Public] /c error:', err.message);
++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
++ }
++});
++
++// ── Thumbnails ───────────────────────────────────────────────
++router.get('/t/:id', (req, res) => {
++ require('../thumbnails/thumbnail-service').serveThumbnail(req, res);
++});
++
++// ── Files ────────────────────────────────────────────────────
++router.get('/f/:key', (req, res) => {
++ try {
++ const row = db.getFileByKey(String(req.params.key));
++ if (!row) return res.status(404).json({ error: 'Not found' });
++ const filesRoutes = require('../files/routes');
++ const filePath = filesRoutes.filePathForKey(row);
++ if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Not found' });
++
++ const stat = fs.statSync(filePath);
++ const range = req.headers.range;
++ const contentType = row.mime || 'application/octet-stream';
++ const headers = {
++ 'X-Robots-Tag': 'noindex',
++ 'Cache-Control': 'public, max-age=86400',
++ };
++ if (range) {
++ const parts = range.replace(/bytes=/, '').split('-');
++ const start = parseInt(parts[0], 10) || 0;
++ const end = parts[1] ? Math.min(parseInt(parts[1], 10), stat.size - 1) : stat.size - 1;
++ if (start >= stat.size || end < start) {
++ res.writeHead(416, { 'Content-Range': `bytes */${stat.size}`, ...headers });
++ return res.end();
++ }
++ res.writeHead(206, {
++ 'Content-Range': `bytes ${start}-${end}/${stat.size}`,
++ 'Accept-Ranges': 'bytes',
++ 'Content-Length': end - start + 1,
++ 'Content-Type': contentType,
++ ...headers,
++ });
++ fs.createReadStream(filePath, { start, end }).pipe(res);
++ } else {
++ res.writeHead(200, {
++ 'Content-Length': stat.size,
++ 'Content-Type': contentType,
++ 'Accept-Ranges': 'bytes',
++ ...headers,
++ });
++ fs.createReadStream(filePath).pipe(res);
++ }
++ } catch (err) {
++ console.error('[Public] /f error:', err.message);
++ if (!res.headersSent) res.status(500).json({ error: 'Failed to serve file' });
++ }
++});
++
++// ── Pastes ───────────────────────────────────────────────────
++
++function escapeHtml(s) {
++ return String(s ?? '')
++ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
++ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++}
++
++function renderPastePage(paste) {
++ const title = escapeHtml(paste.title || 'Untitled');
++ const isScreenshot = paste.type === 'screenshot' && paste.screenshot_path;
++ const body = isScreenshot
++ ? `<figure class="shot"><img src="/p/${escapeHtml(paste.slug)}/screenshot" alt="${title}"></figure>
++ ${paste.content ? `<p class="desc">${escapeHtml(paste.content)}</p>` : ''}`
++ : `<pre class="code" data-language="${escapeHtml(paste.language || 'text')}"><code>${escapeHtml(paste.content || '')}</code></pre>`;
++ const created = escapeHtml(String(paste.created_at || ''));
++ return `<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>${title} — OpenVibe.Media</title>
++<meta name="description" content="${title} — shared via OpenVibe.Media pastes">
++<style>
++ :root { --accent: #8b5cf6; --accent-light: #a78bfa; --bg: #131318; --panel: #1b1b22; --text: #e8e8ee; --muted: #9a9aa8; }
++ * { box-sizing: border-box; }
++ body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; }
++ header { display: flex; align-items: center; gap: .6rem; padding: .8rem 1.2rem; border-bottom: 1px solid #26262f; }
++ header .brand { font-weight: 700; color: var(--accent-light); text-decoration: none; font-size: 1.05rem; }
++ header .brand span { color: var(--text); }
++ main { max-width: 960px; margin: 0 auto; padding: 1.2rem; }
++ h1 { font-size: 1.3rem; margin: .2rem 0 .3rem; }
++ .meta { color: var(--muted); font-size: .85rem; margin-bottom: 1rem; }
++ .meta a { color: var(--accent-light); text-decoration: none; }
++ .code { background: var(--panel); border: 1px solid #26262f; border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; white-space: pre; font: 13px/1.5 ui-monospace, 'Cascadia Code', Menlo, monospace; }
++ .shot img { max-width: 100%; border-radius: 10px; border: 1px solid #26262f; }
++ .desc { color: var(--muted); }
++ footer { max-width: 960px; margin: 0 auto; padding: 1rem 1.2rem 2rem; color: var(--muted); font-size: .82rem; }
++ footer a { color: var(--accent-light); text-decoration: none; }
++</style>
++</head>
++<body>
++<header>
++ <a class="brand" href="https://openvibe.network">OpenVibe<span>.Media</span></a>
++</header>
++<main>
++ <h1>${title}</h1>
++ <p class="meta">${escapeHtml(paste.language || 'text')} · ${paste.views || 0} views · ${created}${isScreenshot ? '' : ` · <a href="/p/${escapeHtml(paste.slug)}/raw">raw</a>`}</p>
++ ${body}
++</main>
++<footer>Shared via OpenVibe.Media — <a href="https://openvibe.network">One Account. All of OpenVibe.</a></footer>
++</body>
++</html>`;
++}
++
++router.get('/p/:slug', optionalIdentity, (req, res) => {
++ try {
++ const paste = db.getPasteBySlug(String(req.params.slug));
++ if (!paste) return res.status(404).send('Paste not found');
++
++ // Unlisted pastes stay reachable by direct link (that's the point).
++ // Private pastes: owning app/user only.
++ if (paste.visibility === 'private' && !canAccessPrivate(paste, req)) {
++ return res.status(404).send('Paste not found');
++ }
++
++ // Increment view count (don't count the owner's views)
++ const isOwner = req.userId != null && paste.user_id === req.userId;
++ if (!isOwner) {
++ db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);
++ paste.views += 1;
++ }
++
++ // Burn-after-read: allow one non-owner read, then delete.
++ if (paste.burn_after_read && !isOwner && paste.views > 1) {
++ require('../pastes/routes').removePasteScreenshot(paste);
++ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
++ return res.status(410).send('This paste has been burned after reading.');
++ }
++
++ res.type('html').send(renderPastePage(paste));
++ } catch (err) {
++ console.error('[Public] /p error:', err.message);
++ res.status(500).send('Error');
++ }
++});
++
++router.get('/p/:slug/raw', (req, res) => {
++ try {
++ const paste = db.getPasteBySlug(String(req.params.slug));
++ if (!paste || paste.type !== 'paste') return res.status(404).send('Not found');
++ if (paste.visibility === 'private') return res.status(404).send('Not found');
++
++ // Burn after read
++ if (paste.burn_after_read && paste.views > 0) {
++ db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
++ return res.status(410).send('This paste has been burned after reading.');
++ }
++
++ db.run('UPDATE pastes SET views = views + 1 WHERE id = ?', [paste.id]);
++ res.type('text/plain').send(paste.content);
++ } catch {
++ res.status(500).send('Error');
++ }
++});
++
++router.get('/p/:slug/screenshot', (req, res) => {
++ try {
++ const paste = db.getPasteBySlug(String(req.params.slug));
++ if (!paste || !paste.screenshot_path) return res.status(404).send('Not found');
++ if (paste.visibility === 'private') return res.status(404).send('Not found');
++ if (!fs.existsSync(paste.screenshot_path)) return res.status(404).send('Not found');
++ streamFileWithRange(req, res, paste.screenshot_path, { 'Cache-Control': 'public, max-age=86400' });
++ } catch {
++ res.status(500).send('Error');
++ }
++});
++
++module.exports = router;
ddiff --git a/server/thumbnails/routes.js b/server/thumbnails/routes.js
nnew file mode 100644
iindex 0000000..fa49a06
---- /dev/null
++++ b/server/thumbnails/routes.js
@@@ -0,0 +1,89 @@
++/**
++ * OpenVibe.Media — Thumbnail API routes (mounted at /api/v1/:app/thumbnails)
++ *
++ * POST /:kind/:id (upload or generate) → { url }
++ * kind = 'vod' | 'clip' → multipart 'thumbnail' (or JSON { image: base64 })
++ * uploads a custom image; with no image, the
++ * thumbnail is (re)generated from the media file.
++ * kind = 'live'|'stream' → upload only; stored under a stable per-app/id
++ * filename so the URL survives refreshes.
++ *
++ * Public serving is at GET /t/:id (see public routes).
++ */
++'use strict';
++
++const express = require('express');
++const path = require('path');
++const fs = require('fs');
++const multer = require('multer');
++const db = require('../db/database');
++const thumbService = require('./thumbnail-service');
++const { tenantAuth, tenantCors } = require('../auth');
++
++const router = express.Router({ mergeParams: true });
++router.use(tenantCors);
++
++// Multer for raw image upload (max 2 MB)
++const upload = multer({
++ storage: multer.memoryStorage(),
++ limits: { fileSize: 2 * 1024 * 1024 },
++});
++
++function _imageFromRequest(req) {
++ if (req.file) return req.file.buffer;
++ if (req.body && req.body.image) return req.body.image;
++ return null;
++}
++
++router.post('/:kind/:id', tenantAuth({ allowUser: true }), upload.single('thumbnail'), async (req, res) => {
++ try {
++ const kind = String(req.params.kind || '').toLowerCase();
++ const id = String(req.params.id);
++ const imageData = _imageFromRequest(req);
++
++ // ── Live stream thumbnail (upload from the owning app's broadcaster) ──
++ if (kind === 'live' || kind === 'stream') {
++ if (!imageData) return res.status(400).json({ error: 'No image data provided' });
++ const url = thumbService.saveLiveThumbnail(req.appId, id, imageData);
++ if (!url) return res.status(400).json({ error: 'Invalid image data' });
++ return res.json({ url });
++ }
++
++ if (kind !== 'vod' && kind !== 'clip') {
++ return res.status(400).json({ error: "kind must be 'vod', 'clip', or 'live'" });
++ }
++
++ const numId = parseInt(id, 10);
++ const row = kind === 'vod' ? db.getVodById(numId, req.appId) : db.getClipById(numId, req.appId);
++ if (!row) return res.status(404).json({ error: `${kind} not found` });
++
++ // Custom upload → save as the entity's thumbnail.
++ if (imageData) {
++ let buffer = Buffer.isBuffer(imageData)
++ ? imageData
++ : Buffer.from(String(imageData).replace(/^data:image\/\w+;base64,/, ''), 'base64');
++ if (buffer.length < 4) return res.status(400).json({ error: 'Invalid image data' });
++ const filename = `${kind}-${numId}-${Date.now()}.jpg`;
++ fs.writeFileSync(path.join(thumbService.THUMB_DIR, filename), buffer);
++ const url = `/t/${filename}`;
++ const table = kind === 'vod' ? 'vods' : 'clips';
++ db.run(`UPDATE ${table} SET thumbnail_url = ? WHERE id = ?`, [url, numId]);
++ return res.json({ url });
++ }
++
++ // Generate from the media file (local or presigned remote).
++ const vodStorage = require('../vod/vod-storage');
++ const source = await vodStorage.resolveMediaSource(row);
++ if (!source) return res.status(404).json({ error: 'Media file unavailable' });
++ const url = kind === 'vod'
++ ? await thumbService.generateVodThumbnail(numId, source.value)
++ : await thumbService.generateClipThumbnail(numId, source.value);
++ if (!url) return res.status(500).json({ error: 'Failed to generate thumbnail' });
++ res.json({ url });
++ } catch (err) {
++ console.error('[Thumbnails] Error:', err.message);
++ res.status(500).json({ error: 'Failed to process thumbnail' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/thumbnails/thumbnail-service.js b/server/thumbnails/thumbnail-service.js
nnew file mode 100644
iindex 0000000..63081ef
---- /dev/null
++++ b/server/thumbnails/thumbnail-service.js
@@@ -0,0 +1,290 @@
++/**
++ * OpenVibe.Media — Universal Thumbnail Service
++ *
++ * Handles thumbnail generation, storage, and serving for:
++ * • Live streams — JPEG frames POSTed by the owning app's broadcaster
++ * (stable per-app/id filename, refreshed in place)
++ * • VODs — extracted via ffmpeg at ~10% into the video
++ * • Clips — extracted via ffmpeg near the first frames
++ *
++ * Thumbnails are stored as JPEGs in THUMBNAILS_PATH and served publicly at
++ * GET /t/:id (id = filename). The vods/clips `thumbnail_url` column holds the
++ * public path (e.g. "/t/vod-3-1672531200.jpg").
++ *
++ * The predecessor's server-side live grabbers (RTMP-FLV / JSMPEG-WS / SFU
++ * PlainRTP) are dropped — live sources belong to the apps now; apps push
++ * frames via POST /api/v1/:app/thumbnails/live/:id instead.
++ */
++'use strict';
++
++const path = require('path');
++const fs = require('fs');
++const { spawn } = require('child_process');
++const config = require('../config');
++const db = require('../db/database');
++
++// ── Constants ────────────────────────────────────────────────
++const THUMB_DIR = path.resolve(config.thumbnails.path);
++const THUMB_WIDTH = 640;
++const THUMB_QUALITY = 6; // ffmpeg qscale:v (2=best, 31=worst, 6 is good balance)
++const CLIENT_THUMB_WRITE_MIN_INTERVAL_MS = 15000;
++
++if (!fs.existsSync(THUMB_DIR)) {
++ fs.mkdirSync(THUMB_DIR, { recursive: true });
++}
++
++// ── Generate Thumbnail from Video File (VODs & Clips) ────────
++const _activeThumbJobs = new Set(); // Dedup concurrent VOD/clip thumbnail generation
++
++/**
++ * Extract a thumbnail frame from a video file using ffmpeg.
++ * @param {string} videoPath – Absolute path (or presigned URL) of the video
++ * @param {string} prefix – Filename prefix ('vod' or 'clip')
++ * @param {number} entityId – DB id (vod or clip id)
++ * @param {object} opts – { seekPercent?, seekSeconds? }
++ * @returns {Promise<string|null>} public thumbnail URL (/t/<file>), or null
++ */
++function generateFromVideo(videoPath, prefix, entityId, opts = {}) {
++ const jobKey = `${prefix}-${entityId}`;
++ if (_activeThumbJobs.has(jobKey)) {
++ return Promise.resolve(null); // Already generating for this entity
++ }
++ _activeThumbJobs.add(jobKey);
++
++ return new Promise((resolve) => {
++ const isUrl = /^https?:\/\//i.test(String(videoPath || ''));
++ if (!videoPath || (!isUrl && !fs.existsSync(videoPath))) {
++ _activeThumbJobs.delete(jobKey);
++ return resolve(null);
++ }
++
++ const outFilename = `${prefix}-${entityId}-${Date.now()}.jpg`;
++ const outPath = path.join(THUMB_DIR, outFilename);
++
++ // First, probe the duration
++ const probe = spawn('ffprobe', [
++ '-v', 'quiet', '-print_format', 'json', '-show_format', videoPath,
++ ]);
++ let probeData = '';
++ probe.stdout.on('data', (d) => (probeData += d));
++
++ probe.on('close', (probeCode) => {
++ let seekTime = opts.seekSeconds || 1;
++
++ if (probeCode === 0 && !opts.seekSeconds) {
++ try {
++ const info = JSON.parse(probeData);
++ const duration = parseFloat(info.format?.duration || '0');
++ if (duration > 2) {
++ seekTime = Math.min(
++ duration * ((opts.seekPercent || 10) / 100),
++ duration - 0.5
++ );
++ }
++ } catch {}
++ }
++
++ // Extract frame
++ const args = [
++ '-y',
++ '-ss', String(Math.max(0, seekTime)),
++ '-i', videoPath,
++ '-vframes', '1',
++ '-vf', `scale=${THUMB_WIDTH}:-1`,
++ '-q:v', String(THUMB_QUALITY),
++ outPath,
++ ];
++
++ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });
++ const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch {} }, isUrl ? 35000 : 20000);
++ ff.on('close', (code) => {
++ clearTimeout(killTimer);
++ if (code === 0 && fs.existsSync(outPath)) {
++ resolve(`/t/${outFilename}`);
++ } else {
++ resolve(null);
++ }
++ });
++ ff.on('error', () => { clearTimeout(killTimer); resolve(null); });
++ });
++
++ probe.on('error', () => resolve(null));
++ }).finally(() => _activeThumbJobs.delete(jobKey));
++}
++
++// ── Generate VOD Thumbnail (~10% in) ─────────────────────────
++async function generateVodThumbnail(vodId, filePath) {
++ const thumbUrl = await generateFromVideo(filePath, 'vod', vodId, { seekPercent: 10 });
++ if (thumbUrl) {
++ // Clean up the previous thumbnail for this VOD
++ _removeOldThumb(db.get('SELECT thumbnail_url FROM vods WHERE id = ?', [vodId])?.thumbnail_url, thumbUrl);
++ db.run('UPDATE vods SET thumbnail_url = ? WHERE id = ?', [thumbUrl, vodId]);
++ }
++ return thumbUrl;
++}
++
++// ── Generate Clip Thumbnail (first frames) ───────────────────
++async function generateClipThumbnail(clipId, filePath) {
++ const thumbUrl = await generateFromVideo(filePath, 'clip', clipId, { seekSeconds: 0.5 });
++ if (thumbUrl) {
++ _removeOldThumb(db.get('SELECT thumbnail_url FROM clips WHERE id = ?', [clipId])?.thumbnail_url, thumbUrl);
++ db.run('UPDATE clips SET thumbnail_url = ? WHERE id = ?', [thumbUrl, clipId]);
++ }
++ return thumbUrl;
++}
++
++function _removeOldThumb(oldUrl, newUrl) {
++ if (!oldUrl || oldUrl === newUrl) return;
++ const oldFile = path.join(THUMB_DIR, path.basename(oldUrl));
++ try { if (fs.existsSync(oldFile)) fs.unlinkSync(oldFile); } catch { /* */ }
++}
++
++// ── Save Live Stream Thumbnail (POSTed by the owning app) ────
++/**
++ * Save a live-stream thumbnail for an app's stream. Accepts a Buffer of
++ * JPEG/PNG data or a base64 string (with or without data: prefix). Stored
++ * under a STABLE filename (stream-<app>-<id>.jpg) refreshed in place so the
++ * URL is stable and files never accumulate.
++ * @returns {string|null} public thumbnail URL, or null on failure
++ */
++function saveLiveThumbnail(appId, streamId, imageData) {
++ try {
++ const filename = `stream-${appId}-${streamId}.jpg`;
++ const outPath = path.join(THUMB_DIR, filename);
++
++ // Rate-limit rewrites (the predecessor's client-write cooldown)
++ try {
++ const stat = fs.statSync(outPath);
++ if (Date.now() - stat.mtimeMs < CLIENT_THUMB_WRITE_MIN_INTERVAL_MS) {
++ return `/t/${filename}`;
++ }
++ } catch { /* no existing file */ }
++
++ let buffer;
++ if (Buffer.isBuffer(imageData)) {
++ buffer = imageData;
++ } else if (typeof imageData === 'string') {
++ const base64 = imageData.replace(/^data:image\/\w+;base64,/, '');
++ buffer = Buffer.from(base64, 'base64');
++ } else {
++ return null;
++ }
++
++ // Validate it's a JPEG (FF D8) or PNG (89 50)
++ if (buffer.length < 3 || buffer[0] !== 0xFF || buffer[1] !== 0xD8) {
++ if (buffer.length < 4 || buffer[0] !== 0x89 || buffer[1] !== 0x50) {
++ console.warn('[Thumbnails] Invalid image data for stream', streamId);
++ return null;
++ }
++ }
++
++ fs.writeFileSync(outPath, buffer);
++ return `/t/${filename}`;
++ } catch (err) {
++ console.error('[Thumbnails] Save live thumbnail error:', err.message);
++ return null;
++ }
++}
++
++// ── Serve Thumbnail File (public /t/:id) ─────────────────────
++function serveThumbnail(req, res) {
++ try {
++ const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversal
++ const filePath = path.join(THUMB_DIR, filename);
++
++ if (!filename || !fs.existsSync(filePath)) {
++ // Return a 1x1 transparent pixel JPEG as fallback
++ const pixel = Buffer.from(
++ '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AKwA=',
++ 'base64'
++ );
++ res.writeHead(200, {
++ 'Content-Type': 'image/jpeg',
++ 'Content-Length': pixel.length,
++ 'Cache-Control': 'no-cache',
++ 'X-Robots-Tag': 'noindex',
++ });
++ return res.end(pixel);
++ }
++
++ const stat = fs.statSync(filePath);
++ const ext = path.extname(filename).toLowerCase();
++ const contentType = ext === '.png' ? 'image/png' : 'image/jpeg';
++
++ // Cache briefly for live thumbnails, longer for VOD/clip
++ const isLive = filename.startsWith('stream-');
++ const maxAge = isLive ? 30 : 86400;
++
++ res.writeHead(200, {
++ 'Content-Type': contentType,
++ 'Content-Length': stat.size,
++ 'Cache-Control': `public, max-age=${maxAge}`,
++ 'X-Robots-Tag': 'noindex',
++ });
++ fs.createReadStream(filePath).pipe(res);
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to serve thumbnail' });
++ }
++}
++
++// ── Cleanup Old Live Thumbnails ──────────────────────────────
++/**
++ * Remove live-stream thumbnails not refreshed within `maxAgeMs` (default 24h —
++ * filenames are stable per stream, so this only reaps dead streams).
++ */
++function cleanupOldThumbnails(maxAgeMs = 24 * 3600000) {
++ try {
++ const files = fs.readdirSync(THUMB_DIR);
++ const now = Date.now();
++ let cleaned = 0;
++
++ for (const file of files) {
++ // Only auto-clean live stream thumbs (vod/clip thumbs are permanent)
++ if (!file.startsWith('stream-')) continue;
++
++ const filePath = path.join(THUMB_DIR, file);
++ const stat = fs.statSync(filePath);
++ if (now - stat.mtimeMs > maxAgeMs) {
++ fs.unlinkSync(filePath);
++ cleaned++;
++ }
++ }
++
++ if (cleaned > 0) {
++ console.log(`[Thumbnails] Cleaned up ${cleaned} old live thumbnails`);
++ }
++ } catch (err) {
++ console.error('[Thumbnails] Cleanup error:', err.message);
++ }
++}
++
++/**
++ * Extract one frame to an arbitrary file. `source` may be a local path OR an
++ * http(s) URL (e.g. a presigned B2/R2 URL) — with `-ss` before `-i`, ffmpeg
++ * seeks via HTTP range requests and only pulls the bytes around that
++ * timestamp, so remote/cold VODs work without downloading. Resolves boolean.
++ */
++function extractFrameToFile(source, seekSeconds, outAbsPath) {
++ return new Promise((resolve) => {
++ const isUrl = /^https?:\/\//i.test(String(source || ''));
++ if (!source || (!isUrl && !fs.existsSync(source))) return resolve(false);
++ try { fs.mkdirSync(path.dirname(outAbsPath), { recursive: true }); } catch { /* */ }
++ const args = ['-y', '-ss', String(Math.max(0.5, Number(seekSeconds) || 1)), '-i', source,
++ '-vframes', '1', '-vf', `scale=${THUMB_WIDTH}:-1`, '-q:v', String(THUMB_QUALITY), outAbsPath];
++ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });
++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } resolve(false); }, isUrl ? 35000 : 15000);
++ ff.on('close', (code) => { clearTimeout(to); resolve(code === 0 && fs.existsSync(outAbsPath)); });
++ ff.on('error', () => { clearTimeout(to); resolve(false); });
++ });
++}
++
++module.exports = {
++ generateFromVideo,
++ generateVodThumbnail,
++ generateClipThumbnail,
++ saveLiveThumbnail,
++ serveThumbnail,
++ cleanupOldThumbnails,
++ extractFrameToFile,
++ THUMB_DIR,
++};
ddiff --git a/server/vod/clip-cutter.js b/server/vod/clip-cutter.js
nnew file mode 100644
iindex 0000000..5c986ae
---- /dev/null
++++ b/server/vod/clip-cutter.js
@@@ -0,0 +1,115 @@
++/**
++ * OpenVibe.Media — server-side clip cutter
++ *
++ * Cuts a clip from any source ffmpeg can read (a local VOD/recording file OR a
++ * presigned B2/R2 URL) and creates/updates the clip row + thumbnail. Ported
++ * from the predecessor (AI overview + chat-notify hooks dropped — Live owns
++ * those); no req/res coupling.
++ */
++'use strict';
++
++const fs = require('node:fs');
++const path = require('node:path');
++const { spawn } = require('node:child_process');
++const db = require('../db/database');
++const config = require('../config');
++
++const CLIPS_DIR = path.resolve(config.vod.clipsPath);
++const MAX_CONCURRENT = parseInt(process.env.CLIP_MAX_CONCURRENT_FFMPEG || '3', 10);
++let _active = 0;
++
++function _ffprobe(file) {
++ return new Promise((resolve) => {
++ const ff = spawn('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file], { stdio: ['ignore', 'pipe', 'ignore'] });
++ let out = '';
++ ff.stdout.on('data', d => { out += d; });
++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } resolve(null); }, 15000);
++ ff.on('close', () => { clearTimeout(to); try { resolve(JSON.parse(out)); } catch { resolve(null); } });
++ ff.on('error', () => { clearTimeout(to); resolve(null); });
++ });
++}
++
++function isBusy() {
++ return _active >= MAX_CONCURRENT;
++}
++
++/**
++ * Cut a clip file. Returns { ok, filePath, duration } or { ok: false, error }.
++ * @param {object} o
++ * @param {string} o.source local path OR http(s) URL ffmpeg can read
++ * @param {number} o.startTime seconds into the source to start
++ * @param {number} o.duration clip length in seconds
++ */
++async function cutClipFile({ source, startTime, duration }) {
++ if (!source || !(duration >= 1)) return { ok: false, error: 'Invalid cut parameters' };
++ const isUrl = /^https?:\/\//i.test(String(source));
++ if (!isUrl && !fs.existsSync(source)) return { ok: false, error: 'Source missing' };
++ if (isBusy()) return { ok: false, error: 'busy' };
++ try { if (!fs.existsSync(CLIPS_DIR)) fs.mkdirSync(CLIPS_DIR, { recursive: true }); } catch { /* */ }
++
++ const ss = Math.max(0, Number(startTime) || 0);
++ const dur = Math.max(1, Number(duration) || 20);
++ const filename = `clip-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.webm`;
++ const outPath = path.join(CLIPS_DIR, filename);
++
++ _active++;
++ // Re-encode the (short) clip instead of stream-copying. `-ss` before `-i`
++ // is a fast seek to the nearest keyframe, and because we re-encode, ffmpeg
++ // then decodes to the EXACT requested start — the clip begins precisely
++ // where asked with a fresh keyframe at t=0. A `-c copy` cut can only start
++ // on a source keyframe, which on sparsely-keyframed live recordings means
++ // an opening of black/frozen frames.
++ const ok = await new Promise((resolve) => {
++ const args = [
++ '-y', '-ss', String(ss), '-i', source, '-t', String(dur),
++ '-c:v', 'libvpx', '-b:v', '2000k', '-crf', '18', '-deadline', 'realtime', '-cpu-used', '4',
++ '-force_key_frames', 'expr:gte(t,n_forced*2)', '-c:a', 'libopus', '-b:a', '128k',
++ '-avoid_negative_ts', 'make_zero', '-f', 'webm', outPath,
++ ];
++ const ff = spawn('ffmpeg', args, { stdio: 'ignore' });
++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } resolve(false); }, Math.round(dur) * 2000 + (isUrl ? 40000 : 20000));
++ ff.on('close', (code) => { clearTimeout(to); resolve(code === 0); });
++ ff.on('error', () => { clearTimeout(to); resolve(false); });
++ }).finally(() => { _active = Math.max(0, _active - 1); });
++
++ if (!ok) { try { fs.existsSync(outPath) && fs.unlinkSync(outPath); } catch { /* */ } return { ok: false, error: 'ffmpeg cut failed' }; }
++
++ // Validate real decodable footage (a seek near EOF can yield a header-only file).
++ const pi = await _ffprobe(outPath);
++ const hasVideo = pi && (pi.streams || []).some(s => s.codec_type === 'video');
++ const realDur = pi && pi.format ? Number(pi.format.duration) : 0;
++ if (!hasVideo || !(realDur >= 1)) {
++ try { fs.unlinkSync(outPath); } catch { /* */ }
++ return { ok: false, error: 'No decodable footage in the requested window' };
++ }
++
++ return { ok: true, filePath: outPath, duration: realDur };
++}
++
++/**
++ * Cut a clip and create its row (used by internal jobs; the API route creates
++ * the row first and updates it). Returns the created clip row, or null.
++ */
++async function cutClip(o) {
++ const { appId, source, startTime, duration, streamId, vodId = null, userId, title, description = '', isPublic = 1, autoGenerated = true } = o || {};
++ if (!appId) return null;
++ const cut = await cutClipFile({ source, startTime, duration });
++ if (!cut.ok) return null;
++
++ const ss = Math.max(0, Number(startTime) || 0);
++ const res = db.createClip({
++ app_id: appId, vod_id: vodId, stream_id: streamId, user_id: userId,
++ title: (title || 'Auto Clip').slice(0, 200), description: description || '',
++ file_path: cut.filePath, start_time: ss, end_time: ss + cut.duration, duration_seconds: cut.duration,
++ is_public: (isPublic === 0 || isPublic === false) ? 0 : 1,
++ auto_generated: autoGenerated ? 1 : 0,
++ status: 'ready',
++ });
++ const clipId = res && res.lastInsertRowid;
++ if (!clipId) return null;
++ const clip = db.getClipById(clipId);
++ try { require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, cut.filePath).catch(() => {}); } catch { /* */ }
++ return clip;
++}
++
++module.exports = { cutClip, cutClipFile, isBusy, CLIPS_DIR };
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
nnew file mode 100644
iindex 0000000..123ef00
---- /dev/null
++++ b/server/vod/clips-routes.js
@@@ -0,0 +1,240 @@
++/**
++ * OpenVibe.Media — Clips API routes (mounted at /api/v1/:app/clips)
++ *
++ * POST / { vod_id, start_s, end_s, title?, user_id? } → { id, status }
++ * The cut runs in the background (works on the local file or a
++ * presigned B2/R2 URL); webhook clip.ready|clip.failed fires on
++ * completion. Also accepts a live cut against a still-recording
++ * VOD (clamped to flushed footage, like the predecessor).
++ * GET /:id clip meta (status: processing | ready | failed)
++ * GET / list (?limit&offset&vod_id&stream_id&user_id)
++ * PUT /:id update title/visibility
++ * DELETE /:id delete file + offloaded objects + row
++ */
++'use strict';
++
++const express = require('express');
++const fs = require('fs');
++const db = require('../db/database');
++const tools = require('./media-tools');
++const cutter = require('./clip-cutter');
++const { tenantAuth, tenantCors } = require('../auth');
++const { sendWebhook } = require('../webhooks');
++
++const router = express.Router({ mergeParams: true });
++router.use(tenantCors);
++
++function clipPublic(clip) {
++ if (!clip) return null;
++ return {
++ id: clip.id,
++ app_id: clip.app_id,
++ vod_id: clip.vod_id,
++ stream_id: clip.stream_id,
++ user_id: clip.user_id,
++ title: clip.title,
++ description: clip.description,
++ status: clip.status || (clip.file_path ? 'ready' : 'processing'),
++ start_time: clip.start_time,
++ end_time: clip.end_time,
++ duration: clip.duration_seconds || 0,
++ playback_url: `/c/${clip.id}`,
++ thumbnail_url: clip.thumbnail_url || null,
++ visibility: clip.visibility || 'public',
++ storage_provider: clip.storage_provider || 'local',
++ auto_generated: !!clip.auto_generated,
++ view_count: clip.view_count || 0,
++ created_at: clip.created_at,
++ };
++}
++
++/** Sanitize user-provided title: strip HTML tags, limit length */
++function sanitizeClipTitle(title, fallback = 'Untitled Clip') {
++ if (!title || typeof title !== 'string') return fallback;
++ return title.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/gi, '').trim().slice(0, 200) || fallback;
++}
++
++function _getClipScoped(req, res) {
++ const id = parseInt(req.params.id, 10);
++ if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }
++ const clip = db.getClipById(id, req.appId);
++ if (!clip) { res.status(404).json({ error: 'Clip not found' }); return null; }
++ return clip;
++}
++
++// ── Create clip ──────────────────────────────────────────────
++router.post('/', tenantAuth({ allowUser: true }), async (req, res) => {
++ try {
++ const body = req.body || {};
++ const vodId = parseInt(body.vod_id, 10);
++ if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
++ const vod = db.getVodById(vodId, req.appId);
++ if (!vod) return res.status(404).json({ error: 'VOD not found' });
++
++ let startTime = Number.parseFloat(body.start_s ?? body.start_time);
++ let endTime = Number.parseFloat(body.end_s ?? body.end_time);
++ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) {
++ return res.status(400).json({ error: 'start_s and end_s are required' });
++ }
++ if (startTime < 0 || endTime < 0) return res.status(400).json({ error: 'Time values cannot be negative' });
++
++ let duration = endTime - startTime;
++ const maxClipDuration = db.getSetting('max_clip_duration') || 60;
++ if (duration < 1) return res.status(400).json({ error: 'Clip must be at least 1 second' });
++ if (duration > maxClipDuration) return res.status(400).json({ error: `Clips are limited to ${maxClipDuration} seconds` });
++
++ // Duplicate detection — reuse a just-made clip for the same window.
++ const duplicate = db.findDuplicateClip({
++ appId: req.appId, vodId, streamId: vod.stream_id || null,
++ startTime, endTime,
++ });
++ if (duplicate && duplicate.status !== 'failed') {
++ return res.status(200).json({ id: duplicate.id, status: duplicate.status, deduplicated: true });
++ }
++
++ if (cutter.isBusy()) {
++ return res.status(503).json({ error: 'Server is busy processing other clips. Please try again in a few seconds.' });
++ }
++
++ // Resolve a source ffmpeg can read: local file, or presigned B2/R2 URL.
++ const vodStorage = require('./vod-storage');
++ const source = await vodStorage.resolveMediaSource(vod);
++ if (!source) return res.status(404).json({ error: 'VOD media unavailable' });
++
++ // Live recording: clamp to what's ACTUALLY on disk, not wall-clock — a
++ // seek past end-of-file emits a header-only "broken clip".
++ if (vod.is_recording && source.kind === 'file') {
++ let recordedDur = 0;
++ try { const pi = await tools.probeVodInfo(source.value); recordedDur = pi.duration || 0; } catch { /* */ }
++ if (recordedDur > 0) {
++ const safeEdge = Math.max(0, recordedDur - 0.5);
++ endTime = Math.min(endTime, safeEdge);
++ startTime = Math.max(0, Math.min(startTime, endTime - 1));
++ duration = endTime - startTime;
++ if (duration < 1) {
++ return res.status(422).json({ error: 'Not enough recorded footage yet — try again in a moment.' });
++ }
++ }
++ } else if (vod.duration_seconds && Number.isFinite(vod.duration_seconds)) {
++ if (startTime > vod.duration_seconds + 5) return res.status(400).json({ error: 'Start time exceeds video duration' });
++ if (endTime > vod.duration_seconds + 10) endTime = vod.duration_seconds;
++ duration = endTime - startTime;
++ if (duration < 1) return res.status(400).json({ error: 'Clip window is outside the video' });
++ }
++
++ const userId = body.user_id != null ? body.user_id : (req.userId ?? null);
++ const result = db.createClip({
++ app_id: req.appId,
++ vod_id: vodId,
++ stream_id: vod.stream_id || null,
++ user_id: userId,
++ title: sanitizeClipTitle(body.title, vod.title ? `Clip: ${vod.title}`.slice(0, 200) : 'Untitled Clip'),
++ file_path: '',
++ start_time: startTime,
++ end_time: endTime,
++ duration_seconds: duration,
++ is_public: 1,
++ status: 'processing',
++ });
++ const clipId = result.lastInsertRowid;
++ const appId = req.appId;
++
++ // Cut in the background; webhook on completion.
++ (async () => {
++ const cut = await cutter.cutClipFile({ source: source.value, startTime, duration });
++ if (cut.ok) {
++ db.run('UPDATE clips SET file_path = ?, duration_seconds = ?, end_time = ?, status = ? WHERE id = ?',
++ [cut.filePath, cut.duration, startTime + cut.duration, 'ready', clipId]);
++ try {
++ await require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, cut.filePath);
++ } catch { /* */ }
++ console.log(`[Clips] Clip ${clipId} cut from vod ${vodId} (${startTime.toFixed(1)}-${(startTime + cut.duration).toFixed(1)}s)`);
++ sendWebhook(appId, 'clip.ready', clipPublic(db.getClipById(clipId))).catch(() => {});
++ } else {
++ console.warn(`[Clips] Clip ${clipId} failed: ${cut.error}`);
++ db.run('UPDATE clips SET status = ? WHERE id = ?', ['failed', clipId]);
++ sendWebhook(appId, 'clip.failed', clipPublic(db.getClipById(clipId))).catch(() => {});
++ }
++ })().catch(err => console.error('[Clips] Background cut error:', err.message));
++
++ res.status(202).json({ id: clipId, status: 'processing' });
++ } catch (err) {
++ console.error('[Clips] Create error:', err.message);
++ res.status(500).json({ error: 'Failed to create clip' });
++ }
++});
++
++// ── List ─────────────────────────────────────────────────────
++router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
++ const filters = {
++ limit, offset,
++ vod_id: req.query.vod_id != null ? parseInt(req.query.vod_id, 10) : null,
++ stream_id: req.query.stream_id != null ? parseInt(req.query.stream_id, 10) : null,
++ user_id: req.query.user_id != null ? req.query.user_id : null,
++ };
++ const clips = db.listClips(req.appId, filters);
++ const total = db.countClips(req.appId, filters);
++ res.json({ clips: clips.map(clipPublic), total, limit, offset });
++ } catch (err) {
++ console.error('[Clips] List error:', err.message);
++ res.status(500).json({ error: 'Failed to list clips' });
++ }
++});
++
++// ── Get clip ─────────────────────────────────────────────────
++router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const clip = _getClipScoped(req, res);
++ if (!clip) return;
++ res.json({ clip: clipPublic(clip) });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to get clip' });
++ }
++});
++
++// ── Update (title / visibility) ──────────────────────────────
++router.put('/:id', tenantAuth(), (req, res) => {
++ try {
++ const clip = _getClipScoped(req, res);
++ if (!clip) return;
++ const { title, visibility } = req.body || {};
++ if (title !== undefined) {
++ const t = sanitizeClipTitle(title, '');
++ if (!t) return res.status(400).json({ error: 'Title must be 1-200 characters' });
++ db.run('UPDATE clips SET title = ? WHERE id = ?', [t, clip.id]);
++ }
++ if (visibility !== undefined) db.setClipVisibility(clip.id, visibility);
++ res.json({ clip: clipPublic(db.getClipById(clip.id, req.appId)) });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to update clip' });
++ }
++});
++
++// ── Delete ───────────────────────────────────────────────────
++router.delete('/:id', tenantAuth(), (req, res) => {
++ try {
++ const clip = _getClipScoped(req, res);
++ if (!clip) return;
++
++ // Delete the local file + any offloaded B2/R2 object (clips carry
++ // storage_provider/storage_key like VODs).
++ if (clip.file_path && fs.existsSync(clip.file_path)) {
++ try { fs.unlinkSync(clip.file_path); } catch { /* ignore */ }
++ }
++ if (clip.storage_provider && clip.storage_provider !== 'local' && clip.storage_key) {
++ require('./vod-storage').deleteVodObjects(clip).catch(err =>
++ console.warn(`[Clips] Remote object cleanup failed for clip ${clip.id}:`, err.message));
++ }
++
++ db.run('DELETE FROM clips WHERE id = ?', [clip.id]);
++ res.json({ message: 'Clip deleted' });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to delete clip' });
++ }
++});
++
++module.exports = router;
++module.exports.clipPublic = clipPublic;
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
nnew file mode 100644
iindex 0000000..7654c9b
---- /dev/null
++++ b/server/vod/finalize.js
@@@ -0,0 +1,282 @@
++/**
++ * OpenVibe.Media — VOD finalization pipeline
++ *
++ * Ported from the predecessor's finalizeVodRecording/_doFinalize, keyed by
++ * vodId (there is no stream registry here — apps own stream state):
++ * merge pending chunk segments → seekable remux → probe → master recovery
++ * (truncated-webm rebuild) → DB update → thumbnail → webhook vod.ready.
++ *
++ * Guarded against double-invocation; refuses to finalize while the recorder
++ * still holds the file open (stops it gracefully instead — its ffmpeg exit
++ * handler re-invokes finalize on the fully-flushed file).
++ */
++'use strict';
++
++const path = require('path');
++const fs = require('fs');
++const { spawn } = require('child_process');
++const db = require('../db/database');
++const tools = require('./media-tools');
++const { sendWebhook } = require('../webhooks');
++
++const _finalizing = new Set();
++
++function isFinalizing(vodId) {
++ return _finalizing.has(Number(vodId));
++}
++
++// Public shape of a vod row for API responses + webhooks.
++function vodPublic(vod) {
++ if (!vod) return null;
++ return {
++ id: vod.id,
++ app_id: vod.app_id,
++ stream_id: vod.stream_id,
++ user_id: vod.user_id,
++ title: vod.title,
++ description: vod.description,
++ status: db.vodStatus(vod),
++ duration: vod.duration_seconds || 0,
++ file_size: vod.file_size || 0,
++ playback_url: `/v/${vod.id}`,
++ thumbnail_url: vod.thumbnail_url || null,
++ storage_provider: vod.storage_provider || 'local',
++ visibility: vod.visibility || 'public',
++ health_status: vod.health_status,
++ clips_only: !!vod.clips_only,
++ is_recording: !!vod.is_recording,
++ view_count: vod.view_count || 0,
++ created_at: vod.created_at,
++ meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),
++ };
++}
++
++// Re-encode the lossless master into the served WebM format. Heavy (a real
++// transcode), so it only runs on the recovery path when the primary webm came
++// out truncated.
++function rebuildWebmFromMaster(masterPath, webmPath) {
++ return new Promise((resolve) => {
++ const tmp = webmPath + '.recover.webm';
++ const args = ['-y', '-i', masterPath,
++ '-c:v', 'libvpx', '-b:v', '1500k', '-crf', '20', '-deadline', 'good', '-cpu-used', '2',
++ '-force_key_frames', 'expr:gte(t,n_forced*2)', '-g', '240',
++ '-c:a', 'libvorbis', '-b:a', '128k', '-f', 'webm', tmp];
++ let ff;
++ try { ff = spawn('ffmpeg', args, { stdio: 'ignore' }); } catch { return resolve(false); }
++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, 45 * 60 * 1000);
++ ff.on('close', (code) => {
++ clearTimeout(to);
++ try {
++ if (code === 0 && fs.existsSync(tmp) && fs.statSync(tmp).size > 1024) {
++ fs.renameSync(tmp, webmPath);
++ return resolve(true);
++ }
++ } catch { /* fall through */ }
++ try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ }
++ resolve(false);
++ });
++ ff.on('error', () => { clearTimeout(to); try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ } resolve(false); });
++ });
++}
++
++function _webhookForVod(vod, event) {
++ try {
++ sendWebhook(vod.app_id, event, vodPublic(vod)).catch(() => {});
++ } catch { /* non-critical */ }
++}
++
++/**
++ * Finalize a VOD recording. Returns the updated vod row (or null).
++ * @param {number} vodId
++ * @param {object} [opts]
++ * @param {number} [opts.startTimeMs] wall-clock recording start (for duration sanity)
++ * @param {boolean} [opts.ffmpegCorrupted] recorder flagged heavy corruption
++ * @param {string} [opts.segmentPath] trailing chunk segment not yet merged
++ */
++async function finalizeVod(vodId, opts = {}) {
++ vodId = Number(vodId);
++ if (_finalizing.has(vodId)) return null;
++
++ // If the recorder still holds this VOD open, stop it gracefully instead of
++ // locking in a truncated duration — its exit handler re-finalizes.
++ try {
++ const recorder = require('./recorder');
++ if (recorder.isRecording(vodId)) {
++ console.log(`[VOD] finalize requested for vod ${vodId} while still recording — stopping gracefully first`);
++ recorder.stopRecording(vodId);
++ return null;
++ }
++ } catch { /* proceed */ }
++ _finalizing.add(vodId);
++
++ try {
++ return await _doFinalize(vodId, opts);
++ } finally {
++ _finalizing.delete(vodId);
++ }
++}
++
++async function _doFinalize(vodId, opts) {
++ const vod = db.getVodById(vodId);
++ if (!vod) return null;
++ const filePath = vod.file_path;
++ const startTime = opts.startTimeMs || new Date(String(vod.created_at).replace(' ', 'T') + 'Z').getTime();
++
++ // Ephemeral clips-only recording: it existed only to serve live clips. Never
++ // published — delete the file (+ any offloaded object) and its row.
++ if (vod.clips_only) {
++ try { if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { /* */ }
++ try { tools.cleanupSeekableFile(filePath); } catch { /* */ }
++ try { if (vod.master_file_path && fs.existsSync(vod.master_file_path)) fs.unlinkSync(vod.master_file_path); } catch { /* */ }
++ try { if (vod.storage_provider && vod.storage_provider !== 'local') require('./vod-storage').deleteVodObjects(vod).catch(() => {}); } catch { /* */ }
++ try { db.run('DELETE FROM vods WHERE id = ?', [vodId]); } catch { /* */ }
++ console.log(`[VOD] Discarded ephemeral clips-only recording (vod ${vodId})`);
++ return null;
++ }
++
++ if (!filePath || !fs.existsSync(filePath)) {
++ db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
++ const failed = db.getVodById(vodId);
++ _webhookForVod(failed, 'vod.failed');
++ return failed;
++ }
++
++ // Merge any pending browser-chunk segments before remuxing.
++ if (opts.segmentPath && opts.segmentPath !== filePath) {
++ await tools.concatWebmFiles(filePath, opts.segmentPath);
++ }
++ await tools.mergePendingSegments(filePath);
++
++ // A recording that never received media (e.g. an RTP ingest that was
++ // started and stopped without packets) leaves a zero-byte file. Quarantine
++ // it as failed — the wall-clock fallback below must never mark it ready.
++ if (tools.getFileSizeSafe(filePath) === 0) {
++ console.warn(`[VOD] vod ${vodId}: zero-byte recording — quarantining`);
++ db.run(`UPDATE vods SET is_recording = 0, health_status = 'zero_byte', health_issues_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
++ [JSON.stringify(['zero_byte']), vodId]);
++ const failed = db.getVodById(vodId);
++ _webhookForVod(failed, 'vod.failed');
++ return failed;
++ }
++
++ // Remux for proper seeking support (fast copy-mode, no re-encode)
++ await tools.remuxForSeeking(filePath);
++
++ // Clean up the live seekable copy (no longer needed after final remux)
++ tools.cleanupSeekableFile(filePath);
++
++ // Probe actual duration with ffprobe
++ const wallClockSeconds = Math.max(0, Math.round((Date.now() - startTime) / 1000));
++ let durationSeconds = wallClockSeconds;
++ let probeFormatJson = JSON.stringify({});
++ try {
++ const probeInfo = await tools.probeVodInfo(filePath);
++ if (probeInfo.duration > 0) {
++ // Guard against a corrupt/inflated container duration: real footage can
++ // never be meaningfully longer than the wall-clock recording time.
++ if (wallClockSeconds > 0 && probeInfo.duration > wallClockSeconds * 1.5 + 30) {
++ console.warn(`[VOD] vod ${vodId}: probed duration ${probeInfo.duration}s >> wall-clock ${wallClockSeconds}s — using wall-clock (inflated/corrupt container)`);
++ durationSeconds = wallClockSeconds;
++ } else {
++ durationSeconds = probeInfo.duration;
++ }
++ }
++ probeFormatJson = JSON.stringify(probeInfo.format || {});
++ } catch (probeErr) {
++ console.warn(`[VOD] ffprobe failed for vod ${vodId}:`, probeErr.message);
++ }
++
++ // ── Recover from the lossless master if the served webm came out truncated ──
++ // Only WebM recordings have a separate lossless master; an RTMP/H.264 MP4 is
++ // ITSELF a lossless stream-copy, so the .webm regex must NOT fall through.
++ const masterPath = filePath.endsWith('.webm')
++ ? (vod.master_file_path || filePath.replace(/\.webm$/, '.master.mkv'))
++ : null;
++ let masterDur = 0;
++ if (masterPath && fs.existsSync(masterPath)) {
++ try { const mi = await tools.probeVodInfo(masterPath); masterDur = mi.duration || 0; } catch { /* */ }
++ if (masterDur > 0 && masterDur > durationSeconds + 15 && masterDur > durationSeconds * 1.15) {
++ console.warn(`[VOD] vod ${vodId}: webm ${durationSeconds}s is short vs master ${masterDur}s — rebuilding webm from master`);
++ const ok = await rebuildWebmFromMaster(masterPath, filePath);
++ if (ok) {
++ try { await tools.remuxForSeeking(filePath); tools.cleanupSeekableFile(filePath); } catch { /* */ }
++ try {
++ const mi2 = await tools.probeVodInfo(filePath);
++ if (mi2.duration > durationSeconds) { durationSeconds = mi2.duration; probeFormatJson = JSON.stringify(mi2.format || {}); }
++ } catch { /* */ }
++ console.log(`[VOD] vod ${vodId}: recovered from master → ${durationSeconds}s`);
++ } else {
++ console.warn(`[VOD] vod ${vodId}: master recovery failed — keeping master for manual recovery`);
++ }
++ }
++ }
++
++ if (opts.ffmpegCorrupted) {
++ console.warn(`[VOD] Finalized VOD ${vodId} marked corrupt by FFmpeg diagnostics; quarantining without deletion`);
++ db.run(`UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, probe_duration_seconds = ?, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
++ ['corrupt', JSON.stringify(['ffmpeg-corruption-detected']), durationSeconds > 0 ? durationSeconds : 0, probeFormatJson, vodId]);
++ const failed = db.getVodById(vodId);
++ _webhookForVod(failed, 'vod.failed');
++ return failed;
++ }
++
++ // Very short recordings are quarantined for review instead of deleted.
++ const MIN_VOD_SECONDS = parseInt(process.env.MIN_VOD_SECONDS || '3', 10);
++ if (durationSeconds < MIN_VOD_SECONDS) {
++ console.log(`[VOD] Quarantining short vod ${vodId}: duration ${durationSeconds}s`);
++ db.run(`UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, probe_duration_seconds = ?, probe_format_json = ?, last_health_scan_at = datetime('now'), quarantined_at = datetime('now'), is_public = 0 WHERE id = ?`,
++ ['needs_review', JSON.stringify(['short_duration']), durationSeconds > 0 ? durationSeconds : 0, probeFormatJson, vodId]);
++ const failed = db.getVodById(vodId);
++ _webhookForVod(failed, 'vod.failed');
++ return failed;
++ }
++
++ let stat;
++ try {
++ stat = fs.statSync(filePath);
++ } catch (err) {
++ console.error(`[VOD] Failed to stat finalized VOD ${vodId}:`, err.message);
++ db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
++ return null;
++ }
++ db.run('UPDATE vods SET is_recording = 0, duration_seconds = ?, file_size = ?, probe_duration_seconds = ?, probe_format_json = ?, health_status = ? WHERE id = ?',
++ [durationSeconds, stat.size, durationSeconds, probeFormatJson, 'ok', vodId]);
++
++ console.log(`[VOD] Finalized: vod ${vodId}, ${durationSeconds}s, ${(stat.size / 1024 / 1024).toFixed(1)}MB`);
++
++ // The lossless .master.mkv archive is only a fallback. Delete it ONLY once
++ // the served webm is confirmed complete; if recovery failed, KEEP it.
++ if (masterPath) {
++ try {
++ const webmComplete = !masterDur || durationSeconds >= masterDur - 8;
++ if (fs.existsSync(masterPath)) {
++ if (webmComplete) {
++ fs.unlinkSync(masterPath);
++ console.log(`[VOD] Removed master archive for vod ${vodId} (${path.basename(masterPath)})`);
++ db.run('UPDATE vods SET master_file_path = NULL WHERE id = ?', [vodId]);
++ } else {
++ console.warn(`[VOD] vod ${vodId}: KEEPING master (webm ${durationSeconds}s still < master ${masterDur}s)`);
++ }
++ } else {
++ db.run('UPDATE vods SET master_file_path = NULL WHERE id = ?', [vodId]);
++ }
++ } catch (e) {
++ console.warn(`[VOD] Master cleanup failed for vod ${vodId}:`, e.message);
++ }
++ }
++
++ // Thumbnail, then webhook vod.ready (thumbnail awaited so the payload has it;
++ // failure is non-fatal).
++ try {
++ const thumbService = require('../thumbnails/thumbnail-service');
++ await thumbService.generateVodThumbnail(vodId, filePath);
++ } catch (err) {
++ console.warn(`[VOD] Thumbnail generation failed for vod ${vodId}:`, err.message);
++ }
++
++ const finalVod = db.getVodById(vodId);
++ _webhookForVod(finalVod, 'vod.ready');
++ return finalVod;
++}
++
++module.exports = { finalizeVod, isFinalizing, vodPublic, rebuildWebmFromMaster };
ddiff --git a/server/vod/health-job.js b/server/vod/health-job.js
nnew file mode 100644
iindex 0000000..a2e2af7
---- /dev/null
++++ b/server/vod/health-job.js
@@@ -0,0 +1,242 @@
++/**
++ * OpenVibe.Media — health-job: keep the VOD library healthy in the background.
++ *
++ * Two responsibilities, both throttled and concurrency-safe:
++ * 1. SCAN finished VODs that were never health-checked (or not checked in a
++ * while) for corruption / zero-byte / bad-duration. On a problem it first
++ * tries to REPAIR from the lossless .master.mkv archive (and repairs a
++ * missing duration); only genuinely unrecoverable files are quarantined
++ * (hidden, is_public=0) — never silently deleted.
++ * 2. CLEAN UP VODs quarantined as unrecoverably-broken for a grace period:
++ * one last recovery attempt, else delete the dead files + row.
++ *
++ * Design: a single serialized worker, at most one ffmpeg pass at a time, small
++ * batches. Backs off to lighter (probe-only) work while any INGEST recording
++ * is active so it never competes with live ffmpeg (the predecessor keyed this
++ * off live streams; here active recordings are the equivalent signal).
++ */
++'use strict';
++
++const fs = require('fs');
++const path = require('path');
++const db = require('../db/database');
++const config = require('../config');
++const scanner = require('./health-scanner');
++
++const TICK_MS = 5 * 60 * 1000; // scan pass every 5 minutes
++const SCAN_STALE_DAYS = 45; // re-scan a healthy VOD at most this often
++const SCAN_BATCH_IDLE = 3; // VODs per pass when nothing is recording
++const SCAN_BATCH_LIVE = 1; // ...and while recording (probe-only, cheap)
++const QUARANTINE_GRACE_DAYS = 21; // clean up unrecoverable quarantined VODs after this
++const CLEANUP_BATCH = 3;
++
++let _running = false;
++let _timer = null;
++let _busy = false;
++
++function _anyRecording() {
++ try { return require('./recorder').activeCount() > 0; }
++ catch { return false; }
++}
++
++// Scan (and, if needed, repair) a single VOD. The routine scan is PROBE-based
++// (fast, non-destructive); the expensive full decode + re-encode only runs to
++// REPAIR a file that already looks broken, and only while idle (`deep`).
++async function _scanOne(vod, { deep }) {
++ let scan = await scanner.scanVod(vod, { decode: false, repairDuration: true, quarantineBad: false });
++
++ const broken = ['corrupt', 'zero_byte', 'missing_file', 'needs_review'].includes(scan.status)
++ || (scan.issues || []).some(i => /decode_failed|probe_failed|invalid_duration/.test(i));
++
++ if (!broken) {
++ // Healthy (or duration just repaired) — record the clean scan.
++ try { db.updateVodHealth(vod.id, { status: 'ok', issues: scan.issues || [] }); } catch { /* */ }
++ // The lossless .master.mkv is only a finalize-time recovery fallback. This
++ // VOD probed healthy and is long finished — reclaim a lingering master.
++ try {
++ // Only WebM recordings have a separate .master.mkv. Guard the extension:
++ // for an MP4 the regex wouldn't match and `master` would resolve to the
++ // VOD file itself — deleting the recording.
++ const master = vod.master_file_path
++ || (vod.file_path && vod.file_path.endsWith('.webm') ? vod.file_path.replace(/\.webm$/, '.master.mkv') : null);
++ if (master && fs.existsSync(master)) {
++ const freedMb = (fs.statSync(master).size / 1024 / 1024).toFixed(0);
++ fs.unlinkSync(master);
++ if (vod.master_file_path) { try { db.run('UPDATE vods SET master_file_path = NULL WHERE id = ?', [vod.id]); } catch { /* */ } }
++ console.log(`[VOD-Health] Reclaimed orphaned master for vod ${vod.id} (${freedMb}MB)`);
++ }
++ } catch { /* */ }
++ return { id: vod.id, status: 'ok' };
++ }
++
++ // Broken but recordings are active → defer the heavy recovery/quarantine to
++ // the next idle pass. Don't stamp last_health_scan_at, so it stays first in
++ // the queue and is handled promptly once idle.
++ if (!deep) return { id: vod.id, status: 'deferred' };
++
++ // Broken → try to rebuild from the lossless master before quarantining.
++ let recovered = false;
++ if (scan.status !== 'missing_file') {
++ try {
++ const r = await scanner.recoverFromMaster(vod);
++ if (r.recovered) {
++ // Re-scan the rebuilt file to confirm it's actually good now.
++ const fresh = db.getVodById(vod.id) || vod;
++ const rescan = await scanner.scanVod(fresh, { decode: deep, repairDuration: true, quarantineBad: false });
++ recovered = !['corrupt', 'zero_byte', 'missing_file'].includes(rescan.status)
++ && !(rescan.issues || []).some(i => /decode_failed|probe_failed/.test(i));
++ if (recovered) {
++ console.log(`[VOD-Health] Recovered vod ${vod.id} from master (${Math.round(r.duration)}s)`);
++ return { id: vod.id, status: 'recovered' };
++ }
++ }
++ } catch (e) { console.warn(`[VOD-Health] master recovery error for vod ${vod.id}:`, e.message); }
++ }
++
++ // Still broken → quarantine (hide, keep the file for possible manual recovery).
++ // 'needs_review' (e.g. very short) is flagged but NOT hidden — it may be watchable.
++ const terminal = ['corrupt', 'zero_byte', 'missing_file'].includes(scan.status);
++ try {
++ db.updateVodHealth(vod.id, {
++ status: scan.status,
++ issues: scan.issues || [],
++ probeDuration: scan.probe ? scan.probe.duration : undefined,
++ probeFormat: scan.probe ? scan.probe.format : undefined,
++ quarantine: terminal, // only hide genuinely-broken files
++ keepPublic: !terminal,
++ });
++ } catch { /* */ }
++ if (terminal) console.warn(`[VOD-Health] Quarantined vod ${vod.id} — ${scan.status} (${(scan.issues || []).slice(0, 3).join(', ')})`);
++ return { id: vod.id, status: scan.status };
++}
++
++async function _scanPass(deep) {
++ const limit = deep ? SCAN_BATCH_IDLE : SCAN_BATCH_LIVE;
++ let vods = [];
++ try { vods = db.getVodsNeedingHealthScan({ staleDays: SCAN_STALE_DAYS, limit }); } catch { return; }
++ for (const vod of vods) {
++ try { await _scanOne(vod, { deep }); }
++ catch (e) { console.warn(`[VOD-Health] scan error for vod ${vod.id}:`, e.message); }
++ }
++}
++
++// Delete a VOD's files everywhere + its DB row. Mirrors the manual delete route.
++function _hardDeleteVod(vod) {
++ try {
++ if (vod.file_path) {
++ try { require('./vod-storage').deleteVodObjects(vod).catch(() => {}); } catch { /* */ }
++ // Local file + sidecars (.seekable.webm / .seekable.mp4, .master.mkv).
++ for (const p of [vod.file_path, vod.file_path.replace(/\.webm$/, '.seekable.webm'), vod.file_path.replace(/\.mp4$/, '.seekable.mp4'), vod.file_path.replace(/\.webm$/, '.master.mkv'), vod.master_file_path].filter(Boolean)) {
++ try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* */ }
++ }
++ }
++ db.run('DELETE FROM vods WHERE id = ?', [vod.id]);
++ return true;
++ } catch (e) {
++ console.warn(`[VOD-Health] cleanup delete failed for vod ${vod.id}:`, e.message);
++ return false;
++ }
++}
++
++async function _cleanupPass(deep) {
++ let vods = [];
++ try { vods = db.getQuarantinedVodsForCleanup({ graceDays: QUARANTINE_GRACE_DAYS, limit: CLEANUP_BATCH }); } catch { return; }
++ for (const vod of vods) {
++ // One last recovery attempt before deleting — the master may have survived
++ // even if the served file didn't. If it recovers, un-quarantine and keep it.
++ if (deep && vod.file_path && vod.health_status !== 'missing_file') {
++ try {
++ const r = await scanner.recoverFromMaster(vod);
++ if (r.recovered) {
++ const fresh = db.getVodById(vod.id) || vod;
++ const rescan = await scanner.scanVod(fresh, { decode: true, repairDuration: true, quarantineBad: false });
++ if (!['corrupt', 'zero_byte', 'missing_file'].includes(rescan.status)) {
++ db.run("UPDATE vods SET quarantined_at = NULL, health_status = 'ok' WHERE id = ?", [vod.id]);
++ console.log(`[VOD-Health] Cleanup recovered vod ${vod.id} from master — un-quarantined`);
++ continue;
++ }
++ }
++ } catch { /* fall through to delete */ }
++ }
++ if (_hardDeleteVod(vod)) {
++ console.log(`[VOD-Health] Cleaned up unrecoverable vod ${vod.id} (quarantined ${vod.health_status}, ${vod.quarantined_at})`);
++ }
++ }
++}
++
++// Filesystem sweep for orphaned .master.mkv files: reclaim masters whose VOD is
++// gone or finished-and-healthy. PROTECTS masters that are still needed — any
++// recording, and any broken/quarantined VOD whose master is a recovery source —
++// plus a 30-min mtime grace so a just-finished finalize is never raced.
++function _masterSweep() {
++ let dir, files;
++ try {
++ dir = path.resolve(config.vod.path);
++ files = fs.readdirSync(dir).filter(f => f.endsWith('.master.mkv'));
++ } catch { return; }
++ if (!files.length) return;
++
++ const protectedNames = new Set();
++ try {
++ const rows = db.all(`SELECT file_path, master_file_path FROM vods
++ WHERE COALESCE(is_recording,0)=1
++ OR quarantined_at IS NOT NULL
++ OR health_status IN ('corrupt','zero_byte','needs_review')`);
++ for (const v of rows) {
++ if (v.master_file_path) protectedNames.add(path.basename(v.master_file_path));
++ if (v.file_path) protectedNames.add(path.basename(v.file_path.replace(/\.webm$/, '.master.mkv')));
++ }
++ } catch { /* on error, protect nothing extra — the mtime guard still applies */ }
++ // Belt-and-suspenders: never touch a master an active recording holds right now.
++ try {
++ const rec = require('./recorder');
++ for (const [, r] of (rec.activeRecordings || new Map())) if (r && r.masterFilePath) protectedNames.add(path.basename(r.masterFilePath));
++ } catch { /* */ }
++
++ const now = Date.now();
++ let n = 0, freed = 0;
++ for (const f of files) {
++ if (protectedNames.has(f)) continue;
++ const fp = path.join(dir, f);
++ try {
++ const st = fs.statSync(fp);
++ if (now - st.mtimeMs < 30 * 60 * 1000) continue; // too fresh — finalize may still need it
++ fs.unlinkSync(fp);
++ freed += st.size; n++;
++ if (n >= 25) break; // cap per sweep
++ } catch { /* */ }
++ }
++ if (n) console.log(`[VOD-Health] Master sweep: removed ${n} orphaned master(s), freed ${(freed / 1024 / 1024).toFixed(0)}MB`);
++}
++
++async function _tick() {
++ if (_busy) return;
++ _busy = true;
++ try {
++ const deep = !_anyRecording(); // full decode + recovery only while idle
++ await _scanPass(deep);
++ if (deep) { await _cleanupPass(deep); _masterSweep(); }
++ } catch (e) {
++ console.warn('[VOD-Health] tick error:', e.message);
++ } finally {
++ _busy = false;
++ }
++}
++
++function start() {
++ if (_running) return;
++ _running = true;
++ // First pass shortly after boot (idle window), then on the interval.
++ const first = setTimeout(() => { _tick().catch(() => {}); }, 90 * 1000);
++ if (first.unref) first.unref();
++ _timer = setInterval(() => { _tick().catch(() => {}); }, TICK_MS);
++ if (_timer.unref) _timer.unref();
++ console.log('[VOD] Health job started (scan + master-recovery + quarantine cleanup)');
++}
++
++function stop() {
++ _running = false;
++ if (_timer) { clearInterval(_timer); _timer = null; }
++}
++
++module.exports = { start, stop, _tick };
ddiff --git a/server/vod/health-scanner.js b/server/vod/health-scanner.js
nnew file mode 100644
iindex 0000000..68ce83a
---- /dev/null
++++ b/server/vod/health-scanner.js
@@@ -0,0 +1,250 @@
++/**
++ * OpenVibe.Media — VOD health scanner
++ *
++ * Ported from the predecessor: probe / decode / remux / master-recovery
++ * primitives used by the background health job.
++ */
++'use strict';
++
++const { spawn } = require('child_process');
++const fs = require('fs');
++const db = require('../db/database');
++
++function probeMediaInfo(filePath) {
++ return new Promise((resolve) => {
++ const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath]);
++ let out = '';
++ proc.stdout.on('data', d => out += d.toString());
++ proc.on('close', () => {
++ try {
++ const info = JSON.parse(out);
++ const duration = parseFloat(info.format?.duration || '0');
++ resolve({
++ ok: true,
++ duration: Number.isFinite(duration) ? duration : 0,
++ format: info.format || {},
++ streams: info.streams || [],
++ });
++ } catch {
++ resolve({ ok: false, duration: 0, format: null, streams: [] });
++ }
++ });
++ proc.on('error', () => resolve({ ok: false, duration: 0, format: null, streams: [] }));
++ setTimeout(() => { try { proc.kill(); } catch {} }, 10000);
++ });
++}
++
++function decodeVodFile(filePath) {
++ return new Promise((resolve) => {
++ const proc = spawn('ffmpeg', ['-v', 'warning', '-xerror', '-i', filePath, '-map', '0:v:0', '-f', 'null', '-']);
++ let stderr = '';
++ proc.stderr.on('data', d => stderr += d.toString());
++ proc.on('close', (code) => {
++ const warnings = stderr.split('\n').filter(line => /error|warn|corrupt|invalid|timestamp|RTP|concealing|Non-monotonous DTS/i.test(line));
++ resolve({ code, stderr, warnings, ok: code === 0 });
++ });
++ proc.on('error', () => resolve({ code: -1, stderr: '', warnings: [], ok: false }));
++ setTimeout(() => { try { proc.kill(); } catch {} }, 60000);
++ });
++}
++
++function remuxForSeeking(filePath) {
++ return new Promise((resolve) => {
++ // Match the tmp container to the source so an H.264 .mp4 VOD isn't
++ // force-written into a VP8-only WebM (which just fails). For .mp4,
++ // faststart makes it instantly seekable.
++ const isMp4 = filePath.toLowerCase().endsWith('.mp4');
++ const tmpPath = isMp4 ? `${filePath}.remux.tmp.mp4` : `${filePath}.remux.tmp.webm`;
++ const args = isMp4
++ ? ['-y', '-i', filePath, '-c', 'copy', '-fflags', '+genpts', '-movflags', '+faststart', tmpPath]
++ : ['-y', '-i', filePath, '-c', 'copy', '-fflags', '+genpts', tmpPath];
++ const proc = spawn('ffmpeg', args);
++ proc.on('close', (code) => {
++ if (code === 0 && fs.existsSync(tmpPath)) {
++ try {
++ fs.renameSync(tmpPath, filePath);
++ resolve({ ok: true });
++ } catch (err) {
++ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
++ resolve({ ok: false, error: err.message });
++ }
++ } else {
++ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
++ resolve({ ok: false, error: `ffmpeg remux failed code ${code}` });
++ }
++ });
++ proc.on('error', (err) => {
++ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
++ resolve({ ok: false, error: err.message });
++ });
++ setTimeout(() => { try { proc.kill(); } catch {} }, 30000);
++ });
++}
++
++// Re-encode a served .webm from the lossless .master.mkv archive. Used by the
++// health job to REPAIR a broken VOD before resorting to quarantine.
++function _rebuildWebmFromMaster(masterPath, webmPath) {
++ return new Promise((resolve) => {
++ const tmp = webmPath + '.recover.webm';
++ const args = ['-y', '-i', masterPath,
++ '-c:v', 'libvpx', '-b:v', '1500k', '-crf', '20', '-deadline', 'good', '-cpu-used', '2',
++ '-force_key_frames', 'expr:gte(t,n_forced*2)', '-g', '240',
++ '-c:a', 'libvorbis', '-b:a', '128k', '-f', 'webm', tmp];
++ let ff;
++ try { ff = spawn('ffmpeg', args, { stdio: 'ignore' }); } catch { return resolve(false); }
++ const to = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, 45 * 60 * 1000);
++ ff.on('close', (code) => {
++ clearTimeout(to);
++ try {
++ if (code === 0 && fs.existsSync(tmp) && fs.statSync(tmp).size > 1024) {
++ fs.renameSync(tmp, webmPath);
++ return resolve(true);
++ }
++ } catch { /* fall through */ }
++ try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ }
++ resolve(false);
++ });
++ ff.on('error', () => { clearTimeout(to); try { fs.existsSync(tmp) && fs.unlinkSync(tmp); } catch { /* */ } resolve(false); });
++ });
++}
++
++// Attempt to recover a broken VOD from its master archive. Rebuilds the webm,
++// remuxes it for seeking, re-probes, and (if longer) repairs the stored
++// duration. Returns { recovered: bool, duration }.
++async function recoverFromMaster(vod) {
++ // Only WebM recordings have a separate .master.mkv to rebuild from. An MP4
++ // has no master (it's itself a lossless copy); guard the extension so the
++ // regex can't fall through to the VOD's own path and overwrite the mp4.
++ const masterPath = vod.master_file_path
++ || (vod.file_path && vod.file_path.endsWith('.webm') ? vod.file_path.replace(/\.webm$/, '.master.mkv') : null);
++ if (!masterPath || !fs.existsSync(masterPath) || !vod.file_path) return { recovered: false, duration: 0 };
++ let masterDur = 0;
++ try { const mi = await probeMediaInfo(masterPath); masterDur = mi.ok ? mi.duration : 0; } catch { /* */ }
++ if (!(masterDur > 1)) return { recovered: false, duration: 0 };
++ const ok = await _rebuildWebmFromMaster(masterPath, vod.file_path);
++ if (!ok) return { recovered: false, duration: 0 };
++ try { await remuxForSeeking(vod.file_path); } catch { /* */ }
++ let dur = masterDur;
++ try { const mi2 = await probeMediaInfo(vod.file_path); if (mi2.ok && mi2.duration > 0) dur = mi2.duration; } catch { /* */ }
++ try {
++ const stat = fs.statSync(vod.file_path);
++ db.repairVodDuration(vod.id, Math.round(dur), stat.size);
++ db.updateVodHealth(vod.id, { status: 'ok', issues: ['recovered_from_master'], probeDuration: dur });
++ } catch { /* */ }
++ return { recovered: true, duration: dur };
++}
++
++async function scanVod(vod, options = {}) {
++ const filePath = vod.file_path;
++ const result = {
++ vodId: vod.id,
++ filePath,
++ status: 'unknown',
++ issues: [],
++ probe: null,
++ decode: null,
++ remux: null,
++ durationMatch: false,
++ };
++
++ if (!filePath || !fs.existsSync(filePath)) {
++ // Offloaded to object storage (B2/R2) — the upload was size-verified,
++ // so skip local file checks instead of flagging it missing.
++ try {
++ if (require('./vod-storage').isRemote(vod)) {
++ result.status = 'offloaded';
++ return result;
++ }
++ } catch { /* fall through to missing_file */ }
++ result.status = 'missing_file';
++ result.issues.push('missing_file');
++ return result;
++ }
++
++ let stat;
++ try {
++ stat = fs.statSync(filePath);
++ if (stat.size <= 0) {
++ result.status = 'zero_byte';
++ result.issues.push('zero_byte');
++ return result;
++ }
++ } catch (err) {
++ result.status = 'missing_file';
++ result.issues.push('stat_error');
++ return result;
++ }
++
++ const probeInfo = await probeMediaInfo(filePath);
++ result.probe = probeInfo;
++ if (!probeInfo.ok) {
++ result.status = 'needs_review';
++ result.issues.push('probe_failed');
++ } else {
++ if (!probeInfo.duration || probeInfo.duration <= 0) {
++ result.status = 'needs_review';
++ result.issues.push('invalid_duration');
++ }
++ if (vod.duration_seconds <= 0 && probeInfo.duration > 0) {
++ result.durationMatch = false;
++ result.issues.push('duration_repair_available');
++ } else if (Math.abs((vod.duration_seconds || 0) - probeInfo.duration) > 2) {
++ result.durationMatch = false;
++ result.issues.push('duration_mismatch');
++ } else {
++ result.durationMatch = true;
++ }
++ }
++
++ if (options.decode && probeInfo.ok && probeInfo.duration > 0) {
++ const decodeResult = await decodeVodFile(filePath);
++ result.decode = decodeResult;
++ if (!decodeResult.ok) {
++ result.issues.push('decode_failed');
++ }
++ if (decodeResult.warnings.length > 0) {
++ result.issues.push(...decodeResult.warnings.map(w => w.trim()).filter(Boolean));
++ }
++ }
++
++ if (options.remux && probeInfo.ok) {
++ result.remux = await remuxForSeeking(filePath);
++ if (!result.remux.ok) {
++ result.issues.push('remux_failed');
++ }
++ }
++
++ if (result.issues.length === 0) {
++ result.status = 'ok';
++ } else if (result.status === 'unknown') {
++ result.status = result.issues.includes('decode_failed') || result.issues.some(i => /invalid|corrupt|failed/.test(i))
++ ? 'corrupt'
++ : 'needs_review';
++ }
++
++ if (options.repairDuration && probeInfo.ok && probeInfo.duration > 0 && (vod.duration_seconds || 0) <= 0) {
++ result.repair = db.repairVodDuration(vod.id, Math.round(probeInfo.duration), stat.size);
++ result.issues.push('duration_repaired');
++ result.status = 'duration_repaired';
++ }
++
++ if (options.quarantineBad && ['corrupt', 'missing_file', 'zero_byte'].includes(result.status)) {
++ db.updateVodHealth(vod.id, {
++ status: result.status,
++ issues: result.issues,
++ probeDuration: probeInfo.duration,
++ probeFormat: probeInfo.format,
++ quarantine: true,
++ });
++ }
++
++ return result;
++}
++
++module.exports = {
++ probeMediaInfo,
++ decodeVodFile,
++ remuxForSeeking,
++ recoverFromMaster,
++ scanVod,
++};
ddiff --git a/server/vod/media-tools.js b/server/vod/media-tools.js
nnew file mode 100644
iindex 0000000..5208ddc
---- /dev/null
++++ b/server/vod/media-tools.js
@@@ -0,0 +1,315 @@
++/**
++ * OpenVibe.Media — shared ffmpeg/ffprobe helpers
++ *
++ * Extracted from the predecessor's VOD routes: seekable remux (WebM cues /
++ * MP4 faststart), DVR sidecar remux for live recordings, probes, and the
++ * browser-chunk segment concat used by the chunked upload flow.
++ */
++'use strict';
++
++const path = require('path');
++const fs = require('fs');
++const { spawn } = require('child_process');
++
++/**
++ * Remux a WebM/MP4 file with ffmpeg to add proper seek metadata.
++ * WebM files from MediaRecorder lack Cues and often have Inf duration,
++ * which prevents browser-side seeking. A fast copy-remux fixes this.
++ * MP4: rewrite the fragmented recording into a plain, faststart (moov-at-front)
++ * MP4 so finished-VOD playback seeks instantly. Replaces the original in-place.
++ */
++function remuxForSeeking(filePath) {
++ return new Promise((resolve) => {
++ const ext = path.extname(filePath).toLowerCase();
++ if (ext !== '.webm' && ext !== '.mp4') return resolve(false); // WebM + MP4 only
++
++ const tmpPath = filePath + '.remux' + ext;
++ const outArgs = ext === '.mp4'
++ ? ['-c', 'copy', '-movflags', '+faststart', '-fflags', '+genpts']
++ : ['-c', 'copy', '-fflags', '+genpts'];
++ const proc = spawn('ffmpeg', [
++ '-y', '-i', filePath,
++ ...outArgs,
++ tmpPath,
++ ], { stdio: ['ignore', 'ignore', 'pipe'] });
++
++ let stderr = '';
++ proc.stderr.on('data', d => stderr += d);
++
++ proc.on('close', (code) => {
++ if (code === 0 && fs.existsSync(tmpPath)) {
++ try {
++ fs.renameSync(tmpPath, filePath);
++ console.log(`[VOD] Remuxed for seeking: ${path.basename(filePath)}`);
++ resolve(true);
++ } catch (err) {
++ console.warn(`[VOD] Remux rename failed:`, err.message);
++ try { fs.unlinkSync(tmpPath); } catch {}
++ resolve(false);
++ }
++ } else {
++ console.warn(`[VOD] Remux failed (code ${code}): ${stderr.slice(-200)}`);
++ try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}
++ resolve(false);
++ }
++ });
++
++ proc.on('error', () => resolve(false));
++
++ // Timeout: 60s should be plenty for copy-mode remux
++ setTimeout(() => { try { proc.kill(); } catch {} }, 60000);
++ });
++}
++
++/** Probe a media file's start_time (seconds), or 0 if probing fails. */
++function probeStartTime(filePath) {
++ return new Promise((resolve) => {
++ const probe = spawn('ffprobe', [
++ '-v', 'quiet', '-print_format', 'json',
++ '-show_entries', 'format=start_time',
++ filePath,
++ ]);
++ let out = '';
++ probe.stdout.on('data', d => out += d);
++ probe.on('close', () => {
++ try {
++ const info = JSON.parse(out);
++ const startTime = parseFloat(info.format?.start_time || '0');
++ resolve(Number.isFinite(startTime) && startTime > 0 ? startTime : 0);
++ } catch { resolve(0); }
++ });
++ probe.on('error', () => resolve(0));
++ setTimeout(() => { try { probe.kill(); } catch {} resolve(0); }, 5000);
++ });
++}
++
++function probeVodDuration(filePath) {
++ return new Promise((resolve) => {
++ const probe = spawn('ffprobe', [
++ '-v', 'quiet', '-print_format', 'json',
++ '-show_format', filePath,
++ ]);
++ let out = '';
++ probe.stdout.on('data', d => out += d);
++ probe.on('close', () => {
++ try {
++ const info = JSON.parse(out);
++ const duration = Math.round(parseFloat(info.format?.duration || '0'));
++ resolve(duration > 0 ? duration : 0);
++ } catch { resolve(0); }
++ });
++ probe.on('error', () => resolve(0));
++ setTimeout(() => { try { probe.kill(); } catch {} resolve(0); }, 10000);
++ });
++}
++
++function probeVodInfo(filePath) {
++ return new Promise((resolve) => {
++ const probe = spawn('ffprobe', [
++ '-v', 'quiet', '-print_format', 'json',
++ '-show_format', '-show_streams',
++ filePath,
++ ]);
++ let out = '';
++ probe.stdout.on('data', d => out += d);
++ probe.on('close', () => {
++ try {
++ const info = JSON.parse(out);
++ const duration = Math.round(parseFloat(info.format?.duration || '0'));
++ resolve({
++ duration: duration > 0 ? duration : 0,
++ format: info.format || null,
++ streams: info.streams || [],
++ });
++ } catch { resolve({ duration: 0, format: null, streams: [] }); }
++ });
++ probe.on('error', () => resolve({ duration: 0, format: null, streams: [] }));
++ setTimeout(() => { try { probe.kill(); } catch {} resolve({ duration: 0, format: null, streams: [] }); }, 10000);
++ });
++}
++
++function getFileSizeSafe(filePath) {
++ try { return filePath && fs.existsSync(filePath) ? fs.statSync(filePath).size : 0; } catch { return 0; }
++}
++
++function cleanupTempFile(filePath) {
++ try { if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch { /* ignore */ }
++}
++
++// ── Live DVR sidecar ─────────────────────────────────────────
++
++const _liveRemuxInProgress = new Set();
++
++function seekableSidecarPath(filePath) {
++ if (!filePath) return null;
++ if (filePath.endsWith('.webm')) return filePath.replace(/\.webm$/, '.seekable.webm');
++ if (filePath.endsWith('.mp4')) return filePath.replace(/\.mp4$/, '.seekable.mp4');
++ return null;
++}
++
++/**
++ * Produce a fully-indexed seekable snapshot of the growing recording so DVR
++ * viewers can seek anywhere without the whole file. WebM: copy-remux to write
++ * cues. MP4: copy-remux with +faststart so a complete moov sits at the front.
++ * Writes to <file>.seekable.<ext> WITHOUT touching the growing original.
++ */
++function remuxForLiveSeeking(filePath) {
++ const seekablePath = seekableSidecarPath(filePath);
++ if (!seekablePath) return Promise.resolve(false);
++ if (_liveRemuxInProgress.has(filePath)) return Promise.resolve(false);
++ _liveRemuxInProgress.add(filePath);
++
++ const isMp4 = filePath.endsWith('.mp4');
++ const tmpPath = filePath + (isMp4 ? '.live-remux.tmp.mp4' : '.live-remux.tmp.webm');
++ const outArgs = isMp4
++ ? ['-c', 'copy', '-movflags', '+faststart', '-fflags', '+genpts']
++ : ['-c', 'copy', '-fflags', '+genpts'];
++
++ return new Promise((resolve) => {
++ const proc = spawn('ffmpeg', [
++ '-y', '-i', filePath,
++ ...outArgs,
++ tmpPath,
++ ], { stdio: ['ignore', 'ignore', 'pipe'] });
++
++ let stderr = '';
++ proc.stderr.on('data', d => stderr += d);
++
++ proc.on('close', (code) => {
++ _liveRemuxInProgress.delete(filePath);
++ if (code === 0 && fs.existsSync(tmpPath)) {
++ try {
++ fs.renameSync(tmpPath, seekablePath);
++ resolve(true);
++ } catch (err) {
++ console.warn(`[VOD] Live remux rename failed:`, err.message);
++ try { fs.unlinkSync(tmpPath); } catch {}
++ resolve(false);
++ }
++ } else {
++ try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}
++ resolve(false);
++ }
++ });
++
++ proc.on('error', () => {
++ _liveRemuxInProgress.delete(filePath);
++ resolve(false);
++ });
++
++ setTimeout(() => {
++ _liveRemuxInProgress.delete(filePath);
++ try { proc.kill(); } catch {}
++ }, 30000);
++ });
++}
++
++/**
++ * Clean up the .seekable.* sidecar after finalization. Guard: the sidecar path
++ * must differ from the VOD itself (a bad regex fall-through would otherwise
++ * delete the recording); seekableSidecarPath returns null for unknown exts.
++ */
++function cleanupSeekableFile(filePath) {
++ const seekablePath = seekableSidecarPath(filePath);
++ if (!seekablePath || seekablePath === filePath) return;
++ try { if (fs.existsSync(seekablePath)) fs.unlinkSync(seekablePath); } catch {}
++}
++
++// ── Browser-chunk segments (chunked upload flow) ─────────────
++
++function makeSegmentPath(filePath, segmentId) {
++ const base = filePath.replace(/\.webm$/, '');
++ return `${base}.seg-${segmentId}-${Date.now()}.webm`;
++}
++
++function getPendingSegmentFiles(filePath) {
++ const dir = path.dirname(filePath);
++ const base = path.basename(filePath).replace(/\.webm$/, '');
++ try {
++ return fs.readdirSync(dir)
++ .filter((name) => name.startsWith(`${base}.seg-`) && name.endsWith('.webm'))
++ .map((name) => path.join(dir, name))
++ .sort((a, b) => {
++ const am = /\.seg-(\d+)-(\d+)\.webm$/.exec(a);
++ const bm = /\.seg-(\d+)-(\d+)\.webm$/.exec(b);
++ const aSeg = parseInt(am?.[1] || '0', 10);
++ const bSeg = parseInt(bm?.[1] || '0', 10);
++ if (aSeg !== bSeg) return aSeg - bSeg;
++ const aTs = parseInt(am?.[2] || '0', 10);
++ const bTs = parseInt(bm?.[2] || '0', 10);
++ return aTs - bTs;
++ });
++ } catch {
++ return [];
++ }
++}
++
++function concatWebmFiles(basePath, appendPath) {
++ if (!appendPath || !fs.existsSync(appendPath)) return Promise.resolve(true);
++ if (!basePath || !fs.existsSync(basePath)) {
++ fs.renameSync(appendPath, basePath);
++ return Promise.resolve(true);
++ }
++
++ const listPath = `${basePath}.concat.${Date.now()}.txt`;
++ const tmpPath = `${basePath}.concat.tmp.webm`;
++ const escapedBase = basePath.replace(/'/g, `'\\''`);
++ const escapedAppend = appendPath.replace(/'/g, `'\\''`);
++ fs.writeFileSync(listPath, `file '${escapedBase}'\nfile '${escapedAppend}'\n`, 'utf8');
++
++ const runConcat = (args) => new Promise((resolve) => {
++ const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
++ let stderr = '';
++ proc.stderr.on('data', (d) => stderr += d);
++ proc.on('close', (code) => resolve({ code, stderr }));
++ proc.on('error', () => resolve({ code: -1, stderr: 'spawn error' }));
++ setTimeout(() => { try { proc.kill(); } catch {} }, 120000);
++ });
++
++ return (async () => {
++ try {
++ let result = await runConcat(['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', '-fflags', '+genpts', tmpPath]);
++ if (result.code !== 0 || !fs.existsSync(tmpPath)) {
++ result = await runConcat(['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c:v', 'libvpx-vp9', '-crf', '32', '-b:v', '0', '-c:a', 'libopus', '-b:a', '128k', tmpPath]);
++ }
++ if (result.code === 0 && fs.existsSync(tmpPath)) {
++ fs.renameSync(tmpPath, basePath);
++ cleanupTempFile(appendPath);
++ cleanupTempFile(listPath);
++ return true;
++ }
++ cleanupTempFile(tmpPath);
++ cleanupTempFile(listPath);
++ return false;
++ } catch {
++ cleanupTempFile(tmpPath);
++ cleanupTempFile(listPath);
++ return false;
++ }
++ })();
++}
++
++async function mergePendingSegments(filePath) {
++ for (const segmentPath of getPendingSegmentFiles(filePath)) {
++ const ok = await concatWebmFiles(filePath, segmentPath);
++ if (!ok) {
++ console.warn(`[VOD] Failed to merge segment into ${path.basename(filePath)}: ${path.basename(segmentPath)}`);
++ }
++ }
++}
++
++module.exports = {
++ remuxForSeeking,
++ remuxForLiveSeeking,
++ seekableSidecarPath,
++ cleanupSeekableFile,
++ probeStartTime,
++ probeVodDuration,
++ probeVodInfo,
++ getFileSizeSafe,
++ cleanupTempFile,
++ makeSegmentPath,
++ getPendingSegmentFiles,
++ concatWebmFiles,
++ mergePendingSegments,
++};
ddiff --git a/server/vod/recorder.js b/server/vod/recorder.js
nnew file mode 100644
iindex 0000000..3f13ad3
---- /dev/null
++++ b/server/vod/recorder.js
@@@ -0,0 +1,569 @@
++/**
++ * OpenVibe.Media — Stream Recorder (RTMP pull + RTP ingest)
++ *
++ * Adapted from the predecessor's ffmpeg recorder, keyed by vodId (tenant apps
++ * own their stream state and point ingest at a VOD they created):
++ *
++ * RTMP: POST ingest/rtmp { rtmp_url } → ffmpeg pulls the URL, lossless
++ * stream-copy into a fragmented MP4 (H.264/AAC passthrough — near-zero
++ * CPU, live-seekable, kill-tolerant, and IS its own lossless master).
++ * RTP: POST ingest/rtp/start { video, audio } → allocates a UDP port pair
++ * from the pool (RTP_PORT_MIN..MAX on 127.0.0.1), writes an SDP and
++ * starts ffmpeg listening. Codec passthrough (no decode → no re-encode):
++ * H.264 → fragmented .mp4 (video copy, audio→AAC); VP8/VP9 → .webm
++ * (video + Opus copied). Anything exotic falls back to a libvpx
++ * re-encode (with an H.264 lossless master when applicable).
++ *
++ * Do NOT hardcode .webm — the served extension follows the copied container.
++ *
++ * A periodic remux writes a .seekable sidecar every 60s so DVR viewers can
++ * seek into the growing recording (see media-tools.remuxForLiveSeeking).
++ */
++'use strict';
++
++const { spawn } = require('child_process');
++const path = require('path');
++const fs = require('fs');
++const os = require('os');
++const db = require('../db/database');
++const config = require('../config');
++const tools = require('./media-tools');
++
++// Grace period for ffmpeg to flush the trailer/cues after SIGINT before we
++// force-kill. Generous on purpose — a long recording's trailer flush must not
++// be cut short (that truncates the VOD). Cleared the instant ffmpeg exits.
++const STOP_GRACE_MS = parseInt(process.env.VOD_STOP_GRACE_MS, 10) || 60000;
++
++// Disk guardian thresholds (free bytes on the VOD volume).
++const DISK_WARN_BYTES = (parseFloat(process.env.VOD_DISK_WARN_GB) || 15) * 1024 * 1024 * 1024;
++const DISK_CRIT_BYTES = (parseFloat(process.env.VOD_DISK_CRIT_GB) || 5) * 1024 * 1024 * 1024;
++
++function _isControlledFfmpegError(line, expectedShutdown) {
++ if (!line || !expectedShutdown) return false;
++ const normalized = line.toLowerCase();
++ return /demux.*timeout|timeout|broken pipe|connection.*reset|closed|end of file|sigterm|sigint|error while reading/i.test(normalized);
++}
++
++function _isFfmpegCorruptionLine(line) {
++ if (!line) return false;
++ const normalized = line.toLowerCase();
++ return /error while decoding|concealing|non[- ]monotonically increasing dts|missing picture in access unit|invalid .* header|invalid .* nal unit|could not find codec parameters|moov atom|invalid packet/i.test(normalized);
++}
++
++function _trackFfmpegDiagnostics(line, recording) {
++ if (!recording || !_isFfmpegCorruptionLine(line)) return;
++ recording.ffmpegCorruptionWarnings = (recording.ffmpegCorruptionWarnings || 0) + 1;
++ if (recording.ffmpegCorruptionWarnings >= 5) {
++ recording._ffmpegCorrupted = true;
++ }
++}
++
++// ── RTP port pool (UDP 12000-12199 on 127.0.0.1 per CONTRACTS) ──
++
++const _usedPorts = new Set();
++
++/** Allocate an even base port with base+1 free (RTP + RTCP). */
++function allocatePortPair() {
++ let start = config.rtp.portMin;
++ if (start % 2 !== 0) start += 1;
++ for (let p = start; p + 1 <= config.rtp.portMax; p += 2) {
++ if (!_usedPorts.has(p) && !_usedPorts.has(p + 1)) {
++ _usedPorts.add(p);
++ _usedPorts.add(p + 1);
++ return p;
++ }
++ }
++ return null;
++}
++
++function releasePortPair(basePort) {
++ if (!basePort) return;
++ _usedPorts.delete(basePort);
++ _usedPorts.delete(basePort + 1);
++}
++
++// ── SDP for ffmpeg receiving RTP (PlainRtpTransport senders) ──
++
++function _formatFmtpParameters(params) {
++ return Object.entries(params || {}).map(([k, v]) => `${k}=${v}`).join(';');
++}
++
++/**
++ * Build an SDP string for FFmpeg to receive RTP.
++ * video/audio descriptors: { payloadType, codec, clockRate, channels?, parameters?, ssrc?, rtcpFeedback? }
++ */
++function buildRtpSdp(video, audio, videoPort, videoRtcpPort, audioPort, audioRtcpPort) {
++ const lines = [
++ 'v=0',
++ 'o=- 0 0 IN IP4 127.0.0.1',
++ 's=OpenVibe.Media VOD Recording',
++ 'c=IN IP4 127.0.0.1',
++ 't=0 0',
++ ];
++
++ const vPT = video.payloadType;
++ const vCodecName = String(video.codec || 'VP8').split('/').pop();
++ const vFeedback = Array.isArray(video.rtcpFeedback) ? video.rtcpFeedback : [];
++ const videoProtocol = vFeedback.length > 0 ? 'RTP/AVPF' : 'RTP/AVP';
++ lines.push(`m=video ${videoPort} ${videoProtocol} ${vPT}`);
++ lines.push(`a=rtpmap:${vPT} ${vCodecName}/${video.clockRate || 90000}`);
++ if (videoRtcpPort) lines.push(`a=rtcp:${videoRtcpPort} IN IP4 127.0.0.1`);
++ if (video.ssrc) lines.push(`a=ssrc:${video.ssrc} cname:record-video`);
++ if (video.parameters) {
++ const fmtp = _formatFmtpParameters(video.parameters);
++ if (fmtp) lines.push(`a=fmtp:${vPT} ${fmtp}`);
++ }
++ for (const fb of vFeedback) {
++ if (!fb || !fb.type) continue;
++ lines.push(`a=rtcp-fb:${vPT} ${fb.type}${fb.parameter ? ` ${fb.parameter}` : ''}`);
++ }
++ lines.push('a=recvonly');
++
++ if (audio && audioPort) {
++ const aPT = audio.payloadType;
++ const aCodecName = String(audio.codec || 'opus').split('/').pop();
++ const channels = audio.channels || 2;
++ const aFeedback = Array.isArray(audio.rtcpFeedback) ? audio.rtcpFeedback : [];
++ const audioProtocol = aFeedback.length > 0 ? 'RTP/AVPF' : 'RTP/AVP';
++ lines.push(`m=audio ${audioPort} ${audioProtocol} ${aPT}`);
++ lines.push(`a=rtpmap:${aPT} ${aCodecName}/${audio.clockRate || 48000}/${channels}`);
++ if (audioRtcpPort) lines.push(`a=rtcp:${audioRtcpPort} IN IP4 127.0.0.1`);
++ if (audio.ssrc) lines.push(`a=ssrc:${audio.ssrc} cname:record-audio`);
++ if (audio.parameters) {
++ const fmtp = _formatFmtpParameters(audio.parameters);
++ if (fmtp) lines.push(`a=fmtp:${aPT} ${fmtp}`);
++ }
++ for (const fb of aFeedback) {
++ if (!fb || !fb.type) continue;
++ lines.push(`a=rtcp-fb:${aPT} ${fb.type}${fb.parameter ? ` ${fb.parameter}` : ''}`);
++ }
++ lines.push('a=recvonly');
++ }
++ lines.push('');
++ return lines.join('\r\n');
++}
++
++class StreamRecorder {
++ constructor() {
++ /** @type {Map<number, object>} vodId → recording state */
++ this.activeRecordings = new Map();
++ this._diskState = 'ok'; // 'ok' | 'warning' | 'critical'
++
++ const vodDir = path.resolve(config.vod.path);
++ if (!fs.existsSync(vodDir)) fs.mkdirSync(vodDir, { recursive: true });
++ }
++
++ isRecording(vodId) {
++ return this.activeRecordings.has(Number(vodId));
++ }
++
++ activeCount() {
++ return this.activeRecordings.size;
++ }
++
++ _cleanupFailedVod(vodId, filePath) {
++ if (!filePath || !fs.existsSync(filePath)) {
++ try {
++ db.run('DELETE FROM vods WHERE id = ?', [vodId]);
++ console.log(`[VOD] Deleted stale failed VOD ${vodId}`);
++ } catch (err) {
++ console.warn(`[VOD] Failed to delete stale VOD ${vodId}:`, err.message);
++ }
++ return;
++ }
++ try {
++ db.run(
++ 'UPDATE vods SET is_recording = 0, health_status = ?, health_issues_json = ?, quarantined_at = datetime(\'now\'), is_public = 0 WHERE id = ?',
++ ['corrupt', JSON.stringify(['failed_recording_start']), vodId]
++ );
++ console.log(`[VOD] Marked failed VOD ${vodId} as corrupt`);
++ } catch (err) {
++ console.warn(`[VOD] Failed to mark VOD ${vodId} as corrupt:`, err.message);
++ }
++ }
++
++ _guardCanRecord(vod) {
++ if (!vod) return { ok: false, error: 'VOD not found' };
++ if (this.activeRecordings.has(vod.id)) return { ok: false, error: 'Already recording this VOD' };
++ // Disk guardian: when the VOD volume is critically low, refuse to start
++ // rather than risk filling the disk (which corrupts every active recording).
++ if (this._diskState === 'critical') {
++ return { ok: false, error: 'Disk critically low — recording refused' };
++ }
++ return { ok: true };
++ }
++
++ _registerCommon(vod, proc, filePath, masterPath, extra = {}) {
++ const recording = {
++ vodId: vod.id,
++ appId: vod.app_id,
++ process: proc,
++ filePath,
++ masterFilePath: masterPath || null,
++ startTime: Date.now(),
++ remuxTimer: null,
++ _expectedShutdown: false,
++ ffmpegCorruptionWarnings: 0,
++ _ffmpegCorrupted: false,
++ ...extra,
++ };
++
++ // Periodic live-seeking remux: .seekable sidecar every 60s so DVR viewers
++ // can seek into the growing recording; first pass at 30s for early DVR.
++ recording.remuxTimer = setInterval(() => this._periodicRemux(vod.id), 60000);
++ setTimeout(() => {
++ if (this.activeRecordings.has(vod.id)) this._periodicRemux(vod.id);
++ }, 30000);
++
++ this.activeRecordings.set(vod.id, recording);
++ db.run('UPDATE vods SET is_recording = 1, file_path = ? WHERE id = ?', [filePath, vod.id]);
++ if (masterPath) db.run('UPDATE vods SET master_file_path = ? WHERE id = ?', [masterPath, vod.id]);
++ return recording;
++ }
++
++ _wireProcess(recording, label) {
++ const { process: proc, vodId } = recording;
++
++ proc.stderr?.on('data', (data) => {
++ const line = data.toString();
++ const rec = this.activeRecordings.get(vodId);
++ _trackFfmpegDiagnostics(line, rec);
++ if (line.includes('Error') || line.includes('error')) {
++ if (_isControlledFfmpegError(line, rec?._expectedShutdown)) return;
++ console.error(`[VOD] FFmpeg error (${label}, vod ${vodId}):`, line.trim());
++ }
++ });
++
++ proc.on('exit', (code, signal) => {
++ console.log(`[VOD] FFmpeg exited for vod ${vodId} (${label}, code: ${code}, signal: ${signal})`);
++ const rec = this.activeRecordings.get(vodId);
++ const finalizeOpts = {
++ startTimeMs: rec?.startTime,
++ ffmpegCorrupted: rec?._ffmpegCorrupted || false,
++ };
++ this._teardown(vodId);
++ // Let finalizeVod handle remux, probe, thumbnail, webhook.
++ // Short delay to ensure the file is fully flushed to disk.
++ setTimeout(() => {
++ require('./finalize').finalizeVod(vodId, finalizeOpts).catch(err => {
++ console.error(`[VOD] Finalization failed for vod ${vodId}:`, err.message);
++ });
++ }, 2000);
++ });
++
++ proc.on('error', (err) => {
++ console.error(`[VOD] FFmpeg spawn error (${label}, vod ${vodId}):`, err.message);
++ const rec = this.activeRecordings.get(vodId);
++ const filePath = rec?.filePath;
++ this._teardown(vodId);
++ require('./finalize').finalizeVod(vodId, { startTimeMs: rec?.startTime }).catch(() => {
++ db.run('UPDATE vods SET is_recording = 0 WHERE id = ?', [vodId]);
++ });
++ if (filePath && !fs.existsSync(filePath)) this._cleanupFailedVod(vodId, filePath);
++ });
++ }
++
++ _teardown(vodId) {
++ const rec = this.activeRecordings.get(vodId);
++ if (!rec) return;
++ if (rec._killTimer) { clearTimeout(rec._killTimer); rec._killTimer = null; }
++ if (rec.remuxTimer) { clearInterval(rec.remuxTimer); rec.remuxTimer = null; }
++ if (rec.sdpPath) { try { fs.unlinkSync(rec.sdpPath); } catch { /* */ } }
++ if (rec.rtpVideoPort) releasePortPair(rec.rtpVideoPort);
++ if (rec.rtpAudioPort) releasePortPair(rec.rtpAudioPort);
++ this.activeRecordings.delete(vodId);
++ }
++
++ /**
++ * Run periodic live-seeking remux and update DB duration/file size.
++ */
++ _periodicRemux(vodId) {
++ const rec = this.activeRecordings.get(vodId);
++ if (!rec || !rec.filePath || !fs.existsSync(rec.filePath)) return;
++
++ const elapsed = Math.round((Date.now() - rec.startTime) / 1000);
++ try {
++ const stat = fs.statSync(rec.filePath);
++ db.run('UPDATE vods SET duration_seconds = ?, file_size = ? WHERE id = ?',
++ [elapsed, stat.size, rec.vodId]);
++ } catch {}
++
++ tools.remuxForLiveSeeking(rec.filePath).catch(() => {});
++ }
++
++ // ── RTMP ingest ──────────────────────────────────────────
++
++ /**
++ * Pull an RTMP URL into a fragmented MP4 via lossless stream-copy.
++ * Exactly the predecessor's RTMP path — no re-encode, always real-time,
++ * live-seekable, kill-tolerant, its own lossless master.
++ */
++ startRtmp(vod, rtmpUrl) {
++ const guard = this._guardCanRecord(vod);
++ if (!guard.ok) return guard;
++ if (!/^rtmps?:\/\//i.test(String(rtmpUrl || ''))) {
++ return { ok: false, error: 'rtmp_url must be an rtmp:// or rtmps:// URL' };
++ }
++
++ const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}.mp4`;
++ const filePath = path.resolve(config.vod.path, filename);
++
++ const ffmpegArgs = [
++ '-y',
++ '-rw_timeout', '15000000',
++ '-i', rtmpUrl,
++ // ── Lossless stream-copy → fragmented MP4 ──
++ // No re-encode (H.264/AAC pass straight through from the RTMP feed), so
++ // this ALWAYS keeps real-time regardless of source resolution/bitrate.
++ // Fragmented (frag_keyframe/empty_moov/default_base_moof) so the growing
++ // file is seekable live for DVR and survives an abrupt kill without a
++ // moov-atom rewrite.
++ '-c', 'copy',
++ '-fflags', '+genpts',
++ '-max_muxing_queue_size', '1024',
++ '-movflags', '+frag_keyframe+empty_moov+default_base_moof',
++ '-f', 'mp4',
++ filePath,
++ ];
++
++ let proc;
++ try {
++ proc = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
++ } catch (err) {
++ return { ok: false, error: `ffmpeg spawn failed: ${err.message}` };
++ }
++
++ const recording = this._registerCommon(vod, proc, filePath, null, { protocol: 'rtmp' });
++ this._wireProcess(recording, 'rtmp');
++
++ console.log(`[VOD] Recording started: vod ${vod.id} → ${filename} (rtmp pull)`);
++ return { ok: true, filePath };
++ }
++
++ // ── RTP ingest ───────────────────────────────────────────
++
++ /**
++ * Allocate ports, write SDP, start ffmpeg listening for RTP.
++ * @param {object} vod vods row
++ * @param {object} video { payloadType, codec, clockRate, ssrc?, parameters?, rtcpFeedback? }
++ * @param {object} [audio] same shape (codec e.g. 'opus')
++ * @returns {ok, videoPort, audioPort} — caller points PlainRtpTransports at 127.0.0.1
++ */
++ startRtp(vod, video, audio) {
++ const guard = this._guardCanRecord(vod);
++ if (!guard.ok) return guard;
++ if (!video || video.payloadType == null || !video.codec) {
++ return { ok: false, error: 'video { payloadType, codec, clockRate } required' };
++ }
++
++ const videoPort = allocatePortPair();
++ if (videoPort == null) return { ok: false, error: 'RTP port pool exhausted' };
++ const videoRtcpPort = videoPort + 1;
++ let audioPort = null;
++ let audioRtcpPort = null;
++ if (audio && audio.payloadType != null) {
++ audioPort = allocatePortPair();
++ if (audioPort == null) {
++ releasePortPair(videoPort);
++ return { ok: false, error: 'RTP port pool exhausted' };
++ }
++ audioRtcpPort = audioPort + 1;
++ }
++
++ const sdpContent = buildRtpSdp(video, audioPort ? audio : null, videoPort, videoRtcpPort, audioPort, audioRtcpPort);
++ const sdpPath = path.join(os.tmpdir(), `openvibe-media-vod-${vod.id}-${Date.now()}.sdp`);
++ try {
++ fs.writeFileSync(sdpPath, sdpContent, 'utf8');
++ } catch (err) {
++ releasePortPair(videoPort);
++ releasePortPair(audioPort);
++ return { ok: false, error: `SDP write failed: ${err.message}` };
++ }
++
++ const ffmpegArgs = [
++ '-y',
++ '-use_wallclock_as_timestamps', '1',
++ '-protocol_whitelist', 'file,rtp,udp',
++ '-thread_queue_size', '2048',
++ '-analyzeduration', '10000000',
++ '-probesize', '5000000',
++ '-avoid_negative_ts', 'make_zero',
++ // Don't honor any rotation display-matrix the source may (mis)send
++ // mid-stream — that flipped VODs 90° partway through.
++ '-noautorotate',
++ '-i', sdpPath,
++ '-fflags', '+genpts+discardcorrupt+nobuffer+igndts',
++ '-err_detect', 'ignore_err',
++ ];
++
++ // ── Codec-passthrough recording (no decode → no re-encode) ──────────
++ // Copying the bitstream straight through (exactly like the proven RTMP
++ // path) removes the decode entirely: zero re-encode CPU, no rotation/
++ // shear/melt (there is no decode to desync), no separate .master.mkv
++ // (the copied file IS lossless), kill-tolerant fragmented output.
++ // Mapping: H.264 → fragmented .mp4 (video copy, audio → AAC for universal
++ // browser support since Opus-in-MP4 is patchy); VP8/VP9 → .webm (video +
++ // Opus copied, both native to WebM). Anything exotic falls back to the
++ // legacy libvpx re-encode (+ lossless master when H.264/Opus).
++ const vCodec = String(video.codec || '').toLowerCase();
++ const isH264 = vCodec.includes('h264');
++ const isVpx = vCodec.includes('vp8') || vCodec.includes('vp9');
++ const passthrough = isH264 || isVpx;
++
++ const ext = isH264 ? '.mp4' : '.webm';
++ const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}${ext}`;
++ const filePath = path.resolve(config.vod.path, filename);
++ let masterPath = null;
++
++ const normalizeVod = process.env.VOD_NO_NORMALIZE !== '1';
++ const VOD_SCALE_VF = 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1';
++
++ if (passthrough) {
++ ffmpegArgs.push('-map', '0:v:0', '-c:v', 'copy');
++ if (audioPort) {
++ ffmpegArgs.push('-map', '0:a?');
++ if (isH264) {
++ ffmpegArgs.push('-c:a', 'aac', '-b:a', '160k'); // Opus → AAC (cheap, audio-only)
++ } else {
++ ffmpegArgs.push('-c:a', 'copy'); // Opus is native to WebM
++ }
++ } else {
++ ffmpegArgs.push('-an');
++ }
++ if (isH264) {
++ // Fragmented MP4: seekable while still growing (live DVR) and it
++ // survives an abrupt kill without a moov rewrite — same rationale
++ // as the RTMP path, which is why neither needs a lossless master.
++ ffmpegArgs.push('-max_muxing_queue_size', '1024',
++ '-movflags', '+frag_keyframe+empty_moov+default_base_moof', '-f', 'mp4', filePath);
++ } else {
++ ffmpegArgs.push('-f', 'webm', filePath);
++ }
++ } else {
++ // ── Legacy fallback: decode + re-encode to VP8/WebM, with a lossless
++ // stream-copy .master.mkv alongside (recovery fallback) unless the
++ // disk is under pressure. H.264/VP8/VP9 never reach this branch —
++ // they passthrough above — so this only handles exotic codecs.
++ masterPath = this._diskState === 'ok' ? filePath.replace(/\.webm$/, '.master.mkv') : null;
++ const libvpxVideo = [
++ ...(normalizeVod ? ['-vf', VOD_SCALE_VF] : []),
++ '-c:v', 'libvpx',
++ '-b:v', '2000k',
++ '-crf', '18',
++ '-deadline', 'realtime',
++ '-cpu-used', '4',
++ // Keyframe every 2s → seekable WebM
++ '-force_key_frames', 'expr:gte(t,n_forced*2)',
++ '-g', '240',
++ ];
++ if (masterPath) {
++ ffmpegArgs.push('-map', '0', '-c:v', 'copy', '-c:a', 'copy', '-f', 'matroska', masterPath, '-map', '0', ...libvpxVideo);
++ } else {
++ ffmpegArgs.push(...libvpxVideo);
++ }
++ if (audioPort) {
++ ffmpegArgs.push('-c:a', 'libopus', '-b:a', '128k', '-application', 'audio');
++ } else {
++ ffmpegArgs.push('-an');
++ }
++ ffmpegArgs.push('-f', 'webm', filePath);
++ }
++
++ let proc;
++ try {
++ proc = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
++ } catch (err) {
++ try { fs.unlinkSync(sdpPath); } catch { /* */ }
++ releasePortPair(videoPort);
++ releasePortPair(audioPort);
++ return { ok: false, error: `ffmpeg spawn failed: ${err.message}` };
++ }
++
++ const recording = this._registerCommon(vod, proc, filePath, masterPath, {
++ protocol: 'rtp',
++ sdpPath,
++ rtpVideoPort: videoPort,
++ rtpAudioPort: audioPort,
++ });
++ this._wireProcess(recording, 'rtp');
++
++ const recMode = passthrough ? `passthrough ${isH264 ? 'H.264→mp4' : 'VP8/9→webm'} copy` : 'libvpx re-encode';
++ console.log(`[VOD] RTP recording started: vod ${vod.id} → ${filename} [${recMode}] (video:${videoPort}${audioPort ? ` audio:${audioPort}` : ''})`);
++ return { ok: true, videoPort, audioPort, filePath };
++ }
++
++ /**
++ * Gracefully stop recording a VOD.
++ * FFmpeg SIGINT triggers trailer write → exit handler → finalizeVod.
++ */
++ stopRecording(vodId) {
++ vodId = Number(vodId);
++ const recording = this.activeRecordings.get(vodId);
++ if (!recording) return false;
++
++ console.log(`[VOD] Stopping recording for vod ${vodId}`);
++ recording._expectedShutdown = true;
++
++ if (recording.remuxTimer) {
++ clearInterval(recording.remuxTimer);
++ recording.remuxTimer = null;
++ }
++
++ if (!recording.process) {
++ this._teardown(vodId);
++ this._cleanupFailedVod(vodId, recording.filePath);
++ return true;
++ }
++
++ try {
++ // SIGINT lets FFmpeg write cues/trailer for seekability
++ recording.process.kill('SIGINT');
++ } catch {
++ try { recording.process.kill('SIGTERM'); } catch { /* ignore */ }
++ }
++
++ // Safety net: force-kill only if FFmpeg is REALLY stuck. Force-killing too
++ // early truncates the file and produces short/unseekable VODs.
++ recording._killTimer = setTimeout(() => {
++ try {
++ if (recording.process && !recording.process.killed) {
++ console.warn(`[VOD] FFmpeg didn't exit within grace for vod ${vodId} — force-killing (VOD may be truncated)`);
++ recording.process.kill('SIGKILL');
++ }
++ } catch { /* ignore */ }
++ }, STOP_GRACE_MS);
++ return true;
++ }
++
++ /**
++ * Disk guardian: sample free space on the VOD volume. Under 'critical' no
++ * new recordings start (existing ones keep running). Called on an interval.
++ */
++ checkDisk() {
++ let free = 0;
++ try { free = require('./vod-storage').diskUsage(config.vod.path).available || 0; } catch { return; }
++ if (!free) return;
++ const prev = this._diskState;
++ const state = free < DISK_CRIT_BYTES ? 'critical' : free < DISK_WARN_BYTES ? 'warning' : 'ok';
++ this._diskState = state;
++ if (state !== prev) {
++ const gb = (free / 1024 / 1024 / 1024).toFixed(1);
++ if (state === 'ok') console.log(`[VOD] Disk recovered — ${gb}GB free (recordings back to normal)`);
++ else console.warn(`[VOD] Disk ${state.toUpperCase()} — only ${gb}GB free on the VOD volume`);
++ }
++ if (state === 'ok') return;
++
++ // Under pressure: nudge the storage sweep to offload cold VODs.
++ try { const vs = require('./vod-storage'); if (typeof vs.runSweep === 'function') vs.runSweep().catch(() => {}); } catch { /* */ }
++ }
++
++ /** Stop all active recordings (for graceful shutdown). */
++ stopAll() {
++ for (const [vodId] of this.activeRecordings) {
++ this.stopRecording(vodId);
++ }
++ }
++}
++
++module.exports = new StreamRecorder();
++module.exports.buildRtpSdp = buildRtpSdp;
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
nnew file mode 100644
iindex 0000000..c621100
---- /dev/null
++++ b/server/vod/routes.js
@@@ -0,0 +1,336 @@
++/**
++ * OpenVibe.Media — VOD API routes (mounted at /api/v1/:app/vods)
++ *
++ * POST / create VOD → { id }
++ * POST /:id/ingest/rtmp { rtmp_url } → 202 (ffmpeg pulls it)
++ * POST /:id/ingest/rtp/start { video, audio } → { videoPort, audioPort }
++ * POST /:id/ingest/rtp/stop finalize RTP recording → 200
++ * POST /:id/chunks multipart chunk append (user JWT ok)
++ * POST /:id/complete finalize chunked upload
++ * POST /:id/finalize close recording, kick off thumbnail+probe
++ * GET /:id VOD meta
++ * GET / list (?limit&offset&user_id&stream_id)
++ * PUT /:id update title/description/visibility
++ * DELETE /:id delete everywhere (local + B2 + R2)
++ */
++'use strict';
++
++const express = require('express');
++const path = require('path');
++const fs = require('fs');
++const multer = require('multer');
++const db = require('../db/database');
++const config = require('../config');
++const recorder = require('./recorder');
++const tools = require('./media-tools');
++const { finalizeVod, vodPublic } = require('./finalize');
++const { tenantAuth, tenantCors } = require('../auth');
++
++const router = express.Router({ mergeParams: true });
++router.use(tenantCors);
++
++// ── Chunk upload storage (browser MediaRecorder chunks) ──────
++const VOD_MIME_TO_EXT = { 'video/webm': '.webm', 'video/mp4': '.mp4', 'video/x-matroska': '.mkv', 'video/ogg': '.ogg' };
++
++/** Strip codec params from MIME types like "video/webm;codecs=vp9,opus" → "video/webm" */
++function baseMediaType(mime) {
++ return (mime || '').split(';')[0].trim().toLowerCase();
++}
++
++const chunkStorage = multer.diskStorage({
++ destination: (req, file, cb) => {
++ const vodDir = path.resolve(config.vod.path);
++ if (!fs.existsSync(vodDir)) fs.mkdirSync(vodDir, { recursive: true });
++ cb(null, vodDir);
++ },
++ filename: (req, file, cb) => {
++ const ext = VOD_MIME_TO_EXT[baseMediaType(file.mimetype)] || '.webm';
++ cb(null, `chunk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
++ },
++});
++const chunkUpload = multer({
++ storage: chunkStorage,
++ limits: { fileSize: config.vod.maxSizeMb * 1024 * 1024 },
++ fileFilter: (req, file, cb) => {
++ const base = baseMediaType(file.mimetype);
++ if (VOD_MIME_TO_EXT[base] || base === 'application/octet-stream' || base === '' || base.startsWith('video/')) cb(null, true);
++ else cb(new Error('Only video chunks are allowed'));
++ },
++});
++
++/**
++ * Active chunked uploads: vodId → { filePath, startTime, chunkCount,
++ * currentSegmentId, currentSegmentPath }. Rebuilt from the DB row if the
++ * server restarted mid-upload.
++ */
++const activeChunkUploads = new Map();
++
++function _getVodScoped(req, res) {
++ const id = parseInt(req.params.id, 10);
++ if (!Number.isFinite(id)) { res.status(404).json({ error: 'VOD not found' }); return null; }
++ const vod = db.getVodById(id, req.appId);
++ if (!vod) { res.status(404).json({ error: 'VOD not found' }); return null; }
++ return vod;
++}
++
++// ── Create VOD ───────────────────────────────────────────────
++router.post('/', tenantAuth(), (req, res) => {
++ try {
++ const { title, stream_id, stream_key, user_id, meta, visibility, clips_only } = req.body || {};
++ const result = db.createVod({
++ app_id: req.appId,
++ stream_id: stream_id != null ? parseInt(stream_id, 10) || null : null,
++ stream_key: stream_key || null,
++ user_id: user_id != null ? user_id : null,
++ title: (title || 'Recording').toString().slice(0, 300),
++ meta,
++ });
++ const id = result.lastInsertRowid;
++ if (clips_only) db.run('UPDATE vods SET clips_only = 1 WHERE id = ?', [id]);
++ if (visibility) db.setVodVisibility(id, visibility);
++ res.status(201).json({ id });
++ } catch (err) {
++ console.error('[VOD] Create error:', err.message);
++ res.status(500).json({ error: 'Failed to create VOD' });
++ }
++});
++
++// ── RTMP ingest ──────────────────────────────────────────────
++router.post('/:id/ingest/rtmp', tenantAuth(), (req, res) => {
++ try {
++ const vod = _getVodScoped(req, res);
++ if (!vod) return;
++ const result = recorder.startRtmp(vod, req.body?.rtmp_url);
++ if (!result.ok) return res.status(409).json({ error: result.error });
++ res.status(202).json({ id: vod.id, status: 'recording' });
++ } catch (err) {
++ console.error('[VOD] RTMP ingest error:', err.message);
++ res.status(500).json({ error: 'Failed to start RTMP ingest' });
++ }
++});
++
++// ── RTP ingest ───────────────────────────────────────────────
++router.post('/:id/ingest/rtp/start', tenantAuth(), (req, res) => {
++ try {
++ const vod = _getVodScoped(req, res);
++ if (!vod) return;
++ const { video, audio } = req.body || {};
++ const result = recorder.startRtp(vod, video, audio);
++ if (!result.ok) return res.status(409).json({ error: result.error });
++ res.json({ videoPort: result.videoPort, audioPort: result.audioPort || null });
++ } catch (err) {
++ console.error('[VOD] RTP start error:', err.message);
++ res.status(500).json({ error: 'Failed to start RTP ingest' });
++ }
++});
++
++router.post('/:id/ingest/rtp/stop', tenantAuth(), (req, res) => {
++ try {
++ const vod = _getVodScoped(req, res);
++ if (!vod) return;
++ const stopped = recorder.stopRecording(vod.id);
++ if (!stopped && !vod.is_recording) return res.status(409).json({ error: 'VOD is not recording' });
++ // Finalization runs from the ffmpeg exit handler.
++ res.json({ id: vod.id, status: 'finalizing' });
++ } catch (err) {
++ console.error('[VOD] RTP stop error:', err.message);
++ res.status(500).json({ error: 'Failed to stop RTP ingest' });
++ }
++});
++
++// ── Chunked upload (browser MediaRecorder; user JWT ok) ──────
++router.post('/:id/chunks', tenantAuth({ allowUser: true }), chunkUpload.single('chunk'), async (req, res) => {
++ try {
++ const vod = _getVodScoped(req, res);
++ if (!vod) { if (req.file) tools.cleanupTempFile(req.file.path); return; }
++ if (!req.file) return res.status(400).json({ error: 'No chunk data' });
++ const segmentId = Math.max(1, parseInt(req.body?.segmentId || req.query.segmentId || '1', 10) || 1);
++
++ let rec = activeChunkUploads.get(vod.id);
++ if (!rec && vod.file_path && fs.existsSync(vod.file_path)) {
++ // Server restarted mid-upload — resume against the existing file.
++ rec = {
++ filePath: vod.file_path,
++ startTime: new Date(String(vod.created_at).replace(' ', 'T') + 'Z').getTime(),
++ chunkCount: 0,
++ currentSegmentId: segmentId,
++ currentSegmentPath: vod.file_path,
++ };
++ activeChunkUploads.set(vod.id, rec);
++ }
++
++ if (!rec) {
++ // First chunk — establish the VOD file (contains the WebM header).
++ const ext = VOD_MIME_TO_EXT[baseMediaType(req.file.mimetype)] || '.webm';
++ const filename = `vod-${req.appId}-${vod.id}-${Date.now()}${ext}`;
++ const filePath = path.resolve(config.vod.path, filename);
++ fs.copyFileSync(req.file.path, filePath);
++ tools.cleanupTempFile(req.file.path);
++
++ db.run('UPDATE vods SET file_path = ?, file_size = ?, is_recording = 1 WHERE id = ?',
++ [filePath, fs.statSync(filePath).size, vod.id]);
++
++ rec = { filePath, startTime: Date.now(), chunkCount: 1, currentSegmentId: segmentId, currentSegmentPath: filePath };
++ activeChunkUploads.set(vod.id, rec);
++
++ console.log(`[VOD] Chunked recording started: vod ${vod.id} (${req.appId})`);
++ return res.json({ vodId: vod.id, chunkIndex: 0, status: 'created' });
++ }
++
++ if (rec.currentSegmentId !== segmentId) {
++ // New MediaRecorder segment (fresh WebM header) — buffer separately,
++ // concat into the base file (ffmpeg concat demuxer) between segments.
++ if (rec.currentSegmentPath && rec.currentSegmentPath !== rec.filePath) {
++ await tools.concatWebmFiles(rec.filePath, rec.currentSegmentPath);
++ }
++ rec.currentSegmentId = segmentId;
++ rec.currentSegmentPath = tools.makeSegmentPath(rec.filePath, segmentId);
++ fs.copyFileSync(req.file.path, rec.currentSegmentPath);
++ tools.cleanupTempFile(req.file.path);
++ rec.chunkCount++;
++ } else {
++ // Append chunk to the current file/segment
++ const targetPath = rec.currentSegmentPath || rec.filePath;
++ const chunkData = fs.readFileSync(req.file.path);
++ fs.appendFileSync(targetPath, chunkData);
++ tools.cleanupTempFile(req.file.path);
++ rec.chunkCount++;
++ }
++
++ // Update file size and duration estimate in DB
++ const size = tools.getFileSizeSafe(rec.filePath)
++ + (rec.currentSegmentPath && rec.currentSegmentPath !== rec.filePath ? tools.getFileSizeSafe(rec.currentSegmentPath) : 0);
++ const elapsed = Math.round((Date.now() - rec.startTime) / 1000);
++ db.run('UPDATE vods SET file_size = ?, duration_seconds = ? WHERE id = ?', [size, elapsed, vod.id]);
++
++ // Seekable sidecar for live DVR (every 2 chunks ≈ ~60s)
++ if (rec.chunkCount >= 2 && rec.chunkCount % 2 === 0) {
++ tools.remuxForLiveSeeking(rec.filePath).catch(() => {});
++ }
++
++ res.json({ vodId: vod.id, chunkIndex: rec.chunkCount, status: 'appended' });
++ } catch (err) {
++ console.error('[VOD] Chunk upload error:', err.message);
++ if (req.file) tools.cleanupTempFile(req.file.path);
++ res.status(500).json({ error: 'Failed to save chunk' });
++ }
++});
++
++async function _finalizeHandler(req, res) {
++ try {
++ const vod = _getVodScoped(req, res);
++ if (!vod) return;
++
++ // Live ffmpeg recording → stop gracefully; its exit handler finalizes.
++ if (recorder.isRecording(vod.id)) {
++ recorder.stopRecording(vod.id);
++ return res.status(202).json({ id: vod.id, status: 'finalizing' });
++ }
++
++ const chunkRec = activeChunkUploads.get(vod.id);
++ activeChunkUploads.delete(vod.id);
++ const result = await finalizeVod(vod.id, {
++ startTimeMs: chunkRec?.startTime,
++ segmentPath: chunkRec && chunkRec.currentSegmentPath !== chunkRec.filePath ? chunkRec.currentSegmentPath : null,
++ });
++ if (!result) return res.status(409).json({ error: 'Nothing to finalize (finalization already in progress or VOD discarded)' });
++ res.json({ vod: vodPublic(result) });
++ } catch (err) {
++ console.error('[VOD] Finalize error:', err.message);
++ res.status(500).json({ error: 'Failed to finalize VOD' });
++ }
++}
++
++router.post('/:id/complete', tenantAuth({ allowUser: true }), _finalizeHandler);
++router.post('/:id/finalize', tenantAuth(), _finalizeHandler);
++
++// ── List ─────────────────────────────────────────────────────
++router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
++ try {
++ const limit = Math.min(Math.max(parseInt(req.query.limit || '50', 10), 1), 500);
++ const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
++ const user_id = req.query.user_id != null ? req.query.user_id : null;
++ const stream_id = req.query.stream_id != null ? parseInt(req.query.stream_id, 10) : null;
++ const vods = db.listVods(req.appId, { limit, offset, user_id, stream_id });
++ const total = db.countVods(req.appId, { user_id, stream_id });
++ res.json({ vods: vods.map(vodPublic), total, limit, offset });
++ } catch (err) {
++ console.error('[VOD] List error:', err.message);
++ res.status(500).json({ error: 'Failed to list VODs' });
++ }
++});
++
++// ── Get VOD meta ─────────────────────────────────────────────
++router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
++ try {
++ const vod = _getVodScoped(req, res);
++ if (!vod) return;
++
++ // Self-heal a missing duration on a finished local VOD.
++ if (!vod.is_recording && (!vod.duration_seconds || vod.duration_seconds <= 0) && vod.file_path && fs.existsSync(vod.file_path)) {
++ const duration = await tools.probeVodDuration(vod.file_path);
++ if (duration > 0) {
++ const fileSize = tools.getFileSizeSafe(vod.file_path);
++ db.run('UPDATE vods SET duration_seconds = ?, file_size = ? WHERE id = ?', [duration, fileSize, vod.id]);
++ vod.duration_seconds = duration;
++ vod.file_size = fileSize;
++ }
++ }
++
++ res.json({ vod: vodPublic(vod) });
++ } catch (err) {
++ console.error('[VOD] Get error:', err.message);
++ res.status(500).json({ error: 'Failed to get VOD' });
++ }
++});
++
++// ── Update ───────────────────────────────────────────────────
++router.put('/:id', tenantAuth(), (req, res) => {
++ try {
++ const vod = _getVodScoped(req, res);
++ if (!vod) return;
++ const { title, description, visibility } = req.body || {};
++ const updates = [];
++ const params = [];
++ if (title !== undefined) { updates.push('title = ?'); params.push(String(title).slice(0, 300)); }
++ if (description !== undefined) { updates.push('description = ?'); params.push(String(description)); }
++ if (updates.length) {
++ params.push(vod.id);
++ db.run(`UPDATE vods SET ${updates.join(', ')} WHERE id = ?`, params);
++ }
++ if (visibility !== undefined) db.setVodVisibility(vod.id, visibility);
++ res.json({ vod: vodPublic(db.getVodById(vod.id, req.appId)) });
++ } catch (err) {
++ console.error('[VOD] Update error:', err.message);
++ res.status(500).json({ error: 'Failed to update VOD' });
++ }
++});
++
++// ── Delete ───────────────────────────────────────────────────
++router.delete('/:id', tenantAuth(), (req, res) => {
++ try {
++ const vod = _getVodScoped(req, res);
++ if (!vod) return;
++
++ if (recorder.isRecording(vod.id)) recorder.stopRecording(vod.id);
++ activeChunkUploads.delete(vod.id);
++
++ // Delete media everywhere (local + B2 + R2)
++ if (vod.file_path) {
++ require('./vod-storage').deleteVodObjects(vod).catch((err) => {
++ console.warn(`[VOD] Object cleanup failed for VOD ${vod.id}:`, err.message);
++ });
++ tools.cleanupSeekableFile(vod.file_path);
++ try { if (vod.master_file_path && fs.existsSync(vod.master_file_path)) fs.unlinkSync(vod.master_file_path); } catch { /* */ }
++ }
++
++ db.run('DELETE FROM vods WHERE id = ?', [vod.id]);
++ res.json({ message: 'VOD deleted' });
++ } catch (err) {
++ console.error('[VOD] Delete error:', err.message);
++ res.status(500).json({ error: 'Failed to delete VOD' });
++ }
++});
++
++router.activeChunkUploads = activeChunkUploads;
++module.exports = router;
ddiff --git a/server/vod/vod-storage.js b/server/vod/vod-storage.js
nnew file mode 100644
iindex 0000000..22d2be6
---- /dev/null
++++ b/server/vod/vod-storage.js
@@@ -0,0 +1,851 @@
++/**
++ * OpenVibe.Media — VOD Storage Engine (local / Backblaze B2 / Cloudflare R2)
++ *
++ * Ported from the predecessor's object-storage tier manager:
++ *
++ * local — ./data/vods recordings + recent VODs (fast, limited)
++ * b2 — Backblaze B2 bucket canonical cold tier: unpopular VODs are
++ * uploaded here and deleted locally
++ * r2 — Cloudflare R2 bucket popularity cache: high-traffic VODs get a
++ * copy here (free egress) and play from R2
++ *
++ * Invariants:
++ * - vods.storage_provider ∈ ('local','b2','r2') = where playback comes from
++ * - every offloaded VOD has a canonical B2 object at vods/<basename>
++ * - provider 'r2' implies the B2 canonical object also exists; demoting
++ * from R2 just deletes the R2 copy and flips back to 'b2'
++ *
++ * Playback for b2/r2 is a 302 redirect to a presigned GET URL (range
++ * requests are handled by the object store; R2 egress is free, B2 gives
++ * 3x stored volume per day free).
++ *
++ * The periodic sweep:
++ * 1. offloads unpopular local VODs to B2 (age + views + last access);
++ * under disk pressure it drains oldest/least-watched VODs down to a
++ * low-water mark regardless of the popularity thresholds
++ * 2. promotes popular VODs to R2
++ * 3. demotes stale R2 VODs back to B2-only
++ */
++'use strict';
++
++const fs = require('fs');
++const path = require('path');
++const { execSync } = require('child_process');
++const config = require('../config');
++const db = require('../db/database');
++
++let S3; // @aws-sdk/client-s3 (lazy)
++let LibStorage; // @aws-sdk/lib-storage (lazy)
++let Presigner; // @aws-sdk/s3-request-presigner (lazy)
++function loadSdk() {
++ if (!S3) {
++ S3 = require('@aws-sdk/client-s3');
++ LibStorage = require('@aws-sdk/lib-storage');
++ Presigner = require('@aws-sdk/s3-request-presigner');
++ }
++}
++
++// ── Provider configuration (env) ─────────────────────────────
++const PROVIDER_ENV = {
++ b2: {
++ endpoint: process.env.MEDIA_B2_ENDPOINT || '',
++ region: process.env.MEDIA_B2_REGION || 'us-west-004',
++ bucket: process.env.MEDIA_B2_BUCKET || '',
++ accessKeyId: process.env.MEDIA_B2_KEY_ID || '',
++ secretAccessKey: process.env.MEDIA_B2_APP_KEY || '',
++ forcePathStyle: true,
++ },
++ r2: {
++ endpoint: process.env.MEDIA_R2_ENDPOINT || '',
++ region: process.env.MEDIA_R2_REGION || 'auto',
++ bucket: process.env.MEDIA_R2_BUCKET || '',
++ accessKeyId: process.env.MEDIA_R2_ACCESS_KEY_ID || '',
++ secretAccessKey: process.env.MEDIA_R2_SECRET_ACCESS_KEY || '',
++ forcePathStyle: false,
++ },
++};
++
++const REMOTE_PROVIDERS = ['b2', 'r2'];
++const KEY_PREFIX = 'vods/';
++
++// ── Settings (media_settings, JSON values under storage_tier.*) ──
++const DEFAULTS = {
++ enabled: true,
++ // B2 cold-offload eligibility (a VOD must meet ALL of these)
++ minAgeDays: 7,
++ maxViewsForCold: 5,
++ minLastAccessDays: 3,
++ sweepIntervalMs: 15 * 60 * 1000,
++ // Disk pressure: above this % the sweep drains aggressively…
++ hotDiskPressurePct: 70,
++ // …until local disk is back under this %
++ localLowWaterPct: 60,
++ maxPerSweep: 40,
++ // R2 popularity tier
++ r2Enabled: true,
++ r2MinViews: 20,
++ r2RecentAccessDays: 3,
++ r2MaxIdleDays: 14,
++ r2MaxPerSweep: 5,
++};
++
++let sweepTimer = null;
++let sweepRunning = false;
++const clients = {}; // providerName → S3Client
++const providerHealthy = {}; // providerName → bool
++
++function getSetting(key) {
++ try {
++ const row = db.get('SELECT value FROM media_settings WHERE key = ?', [`storage_tier.${key}`]);
++ if (row) return JSON.parse(row.value);
++ } catch { /* use default */ }
++ return DEFAULTS[key];
++}
++
++function getSettings() {
++ const s = {};
++ for (const key of Object.keys(DEFAULTS)) s[key] = getSetting(key);
++ return s;
++}
++
++function setSetting(key, value) {
++ if (!(key in DEFAULTS)) return;
++ const dbKey = `storage_tier.${key}`;
++ const existing = db.get('SELECT key FROM media_settings WHERE key = ?', [dbKey]);
++ if (existing) db.run('UPDATE media_settings SET value = ? WHERE key = ?', [JSON.stringify(value), dbKey]);
++ else db.run('INSERT INTO media_settings (key, value) VALUES (?, ?)', [dbKey, JSON.stringify(value)]);
++}
++
++// ── Provider clients ─────────────────────────────────────────
++
++function providerConfigured(name) {
++ const p = PROVIDER_ENV[name];
++ return !!(p && p.endpoint && p.bucket && p.accessKeyId && p.secretAccessKey);
++}
++
++function clientFor(name) {
++ if (!providerConfigured(name)) return null;
++ if (!clients[name]) {
++ loadSdk();
++ const p = PROVIDER_ENV[name];
++ clients[name] = new S3.S3Client({
++ region: p.region,
++ endpoint: p.endpoint,
++ forcePathStyle: p.forcePathStyle,
++ credentials: { accessKeyId: p.accessKeyId, secretAccessKey: p.secretAccessKey },
++ });
++ }
++ return clients[name];
++}
++
++function keyForVod(vod) {
++ if (vod.storage_key) return vod.storage_key;
++ return KEY_PREFIX + path.basename(vod.file_path || '');
++}
++
++function localPathForVod(vod) {
++ return path.join(path.resolve(config.vod.path), path.basename(vod.file_path || ''));
++}
++
++function providerOf(vod) {
++ const p = String(vod?.storage_provider || 'local').toLowerCase();
++ return REMOTE_PROVIDERS.includes(p) ? p : 'local';
++}
++
++function isRemote(vod) {
++ return providerOf(vod) !== 'local';
++}
++
++// ── S3 primitives ────────────────────────────────────────────
++
++async function headObject(provider, key) {
++ const client = clientFor(provider);
++ if (!client) return null;
++ try {
++ const res = await client.send(new S3.HeadObjectCommand({ Bucket: PROVIDER_ENV[provider].bucket, Key: key }));
++ return { size: Number(res.ContentLength || 0), etag: res.ETag || null };
++ } catch (err) {
++ if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NotFound' || err?.name === 'NoSuchKey') return null;
++ throw err;
++ }
++}
++
++async function uploadFile(provider, key, filePath, contentType = 'video/webm') {
++ const client = clientFor(provider);
++ if (!client) throw new Error(`Provider ${provider} not configured`);
++ loadSdk();
++ const upload = new LibStorage.Upload({
++ client,
++ params: {
++ Bucket: PROVIDER_ENV[provider].bucket,
++ Key: key,
++ Body: fs.createReadStream(filePath),
++ ContentType: contentType,
++ },
++ partSize: 64 * 1024 * 1024,
++ queueSize: 3,
++ leavePartsOnError: false,
++ });
++ await upload.done();
++ // Verify size before anything destructive happens
++ const localSize = fs.statSync(filePath).size;
++ const head = await headObject(provider, key);
++ if (!head || head.size !== localSize) {
++ throw new Error(`Upload verification failed for ${provider}:${key} (local ${localSize}, remote ${head ? head.size : 'missing'})`);
++ }
++ return { size: localSize };
++}
++
++async function copyBetweenProviders(srcProvider, dstProvider, key) {
++ const src = clientFor(srcProvider);
++ const dst = clientFor(dstProvider);
++ if (!src || !dst) throw new Error(`Provider not configured for copy ${srcProvider}→${dstProvider}`);
++ loadSdk();
++ const obj = await src.send(new S3.GetObjectCommand({ Bucket: PROVIDER_ENV[srcProvider].bucket, Key: key }));
++ const upload = new LibStorage.Upload({
++ client: dst,
++ params: {
++ Bucket: PROVIDER_ENV[dstProvider].bucket,
++ Key: key,
++ Body: obj.Body,
++ ContentType: obj.ContentType || 'video/webm',
++ },
++ partSize: 64 * 1024 * 1024,
++ queueSize: 3,
++ leavePartsOnError: false,
++ });
++ await upload.done();
++ const expected = Number(obj.ContentLength || 0);
++ const head = await headObject(dstProvider, key);
++ if (!head || (expected && head.size !== expected)) {
++ throw new Error(`Copy verification failed for ${key} → ${dstProvider}`);
++ }
++ return { size: head.size };
++}
++
++async function deleteObject(provider, key) {
++ const client = clientFor(provider);
++ if (!client) return;
++ try {
++ await client.send(new S3.DeleteObjectCommand({ Bucket: PROVIDER_ENV[provider].bucket, Key: key }));
++ } catch (err) {
++ console.warn(`[VodStorage] Delete ${provider}:${key} failed:`, err.message);
++ }
++}
++
++/**
++ * Best-effort removal of a paste's legacy B2 screenshot object(s). Legacy paste
++ * screenshots live in B2 under `community.attachments/objects/<date>/…paste-
++ * screenshot-<pasteId>.<ext>` (date-partitioned, key not stored in the DB), so we
++ * list the prefix and delete anything matching the paste id. Silent no-op if B2 is off.
++ */
++async function deleteLegacyPasteScreenshot(pasteId) {
++ const client = clientFor('b2');
++ if (!client || !pasteId) return;
++ const marker = `paste-screenshot-${pasteId}.`;
++ try {
++ let token;
++ do {
++ const r = await client.send(new S3.ListObjectsV2Command({
++ Bucket: PROVIDER_ENV.b2.bucket, Prefix: 'community.attachments/', ContinuationToken: token, MaxKeys: 1000,
++ }));
++ for (const o of (r.Contents || [])) {
++ if (o.Key.split('/').pop().includes(marker)) await deleteObject('b2', o.Key);
++ }
++ token = r.IsTruncated ? r.NextContinuationToken : null;
++ } while (token);
++ } catch (err) {
++ console.warn(`[VodStorage] Legacy paste screenshot cleanup failed for paste ${pasteId}:`, err.message);
++ }
++}
++
++async function presignGet(provider, key, expiresInSeconds = 900) {
++ const client = clientFor(provider);
++ if (!client) return null;
++ loadSdk();
++ // Force the response MIME so the browser plays the media even when the stored
++ // object metadata is generic (legacy clips were uploaded as octet-stream).
++ const ext = path.extname(key || '').toLowerCase();
++ const mime = { '.webm': 'video/webm', '.mp4': 'video/mp4', '.mkv': 'video/x-matroska', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' }[ext];
++ return Presigner.getSignedUrl(client, new S3.GetObjectCommand({
++ Bucket: PROVIDER_ENV[provider].bucket,
++ Key: key,
++ ...(mime ? { ResponseContentType: mime } : {}),
++ }), { expiresIn: expiresInSeconds });
++}
++
++// ── Playback resolution ──────────────────────────────────────
++
++/**
++ * Returns { kind: 'file', path } for local VODs or
++ * { kind: 'redirect', url, provider } for offloaded ones.
++ * Falls back through r2 → b2 → local if an object is missing.
++ */
++async function resolvePlayback(vod) {
++ const provider = providerOf(vod);
++ const key = keyForVod(vod);
++
++ if (provider !== 'local') {
++ const order = provider === 'r2' ? ['r2', 'b2'] : ['b2', 'r2'];
++ for (const p of order) {
++ if (!providerConfigured(p) || providerHealthy[p] === false) continue;
++ try {
++ const url = await presignGet(p, key);
++ if (url) return { kind: 'redirect', url, provider: p };
++ } catch (err) {
++ console.warn(`[VodStorage] Presign failed for ${p}:${key}:`, err.message);
++ }
++ }
++ }
++
++ const local = localPathForVod(vod);
++ if (fs.existsSync(local)) return { kind: 'file', path: local };
++ return null;
++}
++
++/**
++ * A source ffmpeg/ffprobe can consume: local path, or a presigned https URL.
++ */
++async function resolveMediaSource(vod) {
++ const local = localPathForVod(vod);
++ if (fs.existsSync(local)) return { kind: 'file', value: local };
++ if (isRemote(vod)) {
++ const plan = await resolvePlayback(vod);
++ if (plan?.kind === 'redirect') return { kind: 'url', value: plan.url };
++ }
++ return null;
++}
++
++// ── Tier moves ───────────────────────────────────────────────
++
++function cleanupSidecar(localPath) {
++ for (const seekable of [localPath.replace(/\.webm$/, '.seekable.webm'), localPath.replace(/\.mp4$/, '.seekable.mp4')]) {
++ if (seekable !== localPath && fs.existsSync(seekable)) {
++ try { fs.unlinkSync(seekable); } catch { /* ignore */ }
++ }
++ }
++}
++
++/** Offload a local VOD to B2 (cold). */
++async function moveToCold(vodId) {
++ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);
++ if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };
++ if (vod.is_recording) return { ok: false, error: 'VOD is currently recording' };
++ if (!providerConfigured('b2')) return { ok: false, error: 'B2 not configured' };
++
++ const key = keyForVod(vod);
++ const local = localPathForVod(vod);
++
++ try {
++ if (!fs.existsSync(local)) {
++ // Not local — maybe already offloaded
++ if (await headObject('b2', key)) {
++ db.run("UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?", [key, vodId]);
++ return { ok: true, already: true };
++ }
++ return { ok: false, error: 'Source file missing' };
++ }
++
++ const existing = await headObject('b2', key);
++ const localSize = fs.statSync(local).size;
++ if (!existing || existing.size !== localSize) {
++ await uploadFile('b2', key, local);
++ }
++
++ db.run("UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?", [key, vodId]);
++ try { fs.unlinkSync(local); } catch (err) {
++ console.error(`[VodStorage] Uploaded but failed to remove local file for VOD ${vodId}:`, err.message);
++ }
++ cleanupSidecar(local);
++
++ console.log(`[VodStorage] VOD ${vodId} offloaded to B2: ${key} (${(localSize / 1048576).toFixed(1)} MB)`);
++ return { ok: true, bytes: localSize };
++ } catch (err) {
++ console.error(`[VodStorage] Offload of VOD ${vodId} failed:`, err.message);
++ return { ok: false, error: err.message };
++ }
++}
++
++/** Restore an offloaded VOD to local disk. */
++async function moveToHot(vodId) {
++ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);
++ if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };
++
++ const key = keyForVod(vod);
++ const local = localPathForVod(vod);
++
++ if (fs.existsSync(local)) {
++ db.run("UPDATE vods SET storage_provider = 'local' WHERE id = ?", [vodId]);
++ return { ok: true, already: true };
++ }
++
++ const srcProvider = ['b2', 'r2'].find(p => providerConfigured(p));
++ if (!srcProvider) return { ok: false, error: 'No remote provider configured' };
++
++ try {
++ loadSdk();
++ let provider = providerOf(vod) === 'r2' ? 'r2' : 'b2';
++ let head = await headObject(provider, key);
++ if (!head) {
++ provider = provider === 'r2' ? 'b2' : 'r2';
++ head = providerConfigured(provider) ? await headObject(provider, key) : null;
++ }
++ if (!head) return { ok: false, error: 'Object missing from remote storage' };
++
++ const client = clientFor(provider);
++ const obj = await client.send(new S3.GetObjectCommand({ Bucket: PROVIDER_ENV[provider].bucket, Key: key }));
++ const tmp = local + '.download';
++ await new Promise((resolve, reject) => {
++ const out = fs.createWriteStream(tmp);
++ obj.Body.pipe(out);
++ obj.Body.on('error', reject);
++ out.on('error', reject);
++ out.on('finish', resolve);
++ });
++ if (fs.statSync(tmp).size !== head.size) {
++ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
++ return { ok: false, error: 'Download verification failed' };
++ }
++ fs.renameSync(tmp, local);
++
++ // Restoring to local keeps the B2 canonical copy; drop any R2 copy.
++ if (providerOf(vod) === 'r2' && providerConfigured('r2')) await deleteObject('r2', key);
++ db.run("UPDATE vods SET storage_provider = 'local' WHERE id = ?", [vodId]);
++ console.log(`[VodStorage] VOD ${vodId} restored to local (${(head.size / 1048576).toFixed(1)} MB)`);
++ return { ok: true, bytes: head.size };
++ } catch (err) {
++ console.error(`[VodStorage] Restore of VOD ${vodId} failed:`, err.message);
++ return { ok: false, error: err.message };
++ }
++}
++
++/** Promote a popular VOD to R2 (free egress). Keeps the B2 canonical copy. */
++async function promoteToR2(vodId) {
++ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);
++ if (!vod || !vod.file_path) return { ok: false, error: 'VOD not found' };
++ if (vod.is_recording) return { ok: false, error: 'VOD is currently recording' };
++ if (!providerConfigured('r2') || providerHealthy.r2 === false) return { ok: false, error: 'R2 not available' };
++ if (!providerConfigured('b2')) return { ok: false, error: 'B2 not configured' };
++
++ const key = keyForVod(vod);
++ const local = localPathForVod(vod);
++
++ try {
++ // Ensure canonical B2 copy first
++ let b2Head = await headObject('b2', key);
++ if (!b2Head) {
++ if (!fs.existsSync(local)) return { ok: false, error: 'No source available for promotion' };
++ await uploadFile('b2', key, local);
++ b2Head = await headObject('b2', key);
++ }
++
++ // Put the R2 copy
++ const r2Head = await headObject('r2', key);
++ if (!r2Head || r2Head.size !== b2Head.size) {
++ if (fs.existsSync(local)) await uploadFile('r2', key, local);
++ else await copyBetweenProviders('b2', 'r2', key);
++ }
++
++ db.run("UPDATE vods SET storage_provider = 'r2', storage_key = ? WHERE id = ?", [key, vodId]);
++
++ // Popular VODs live in R2+B2; free the local copy
++ let freed = 0;
++ if (fs.existsSync(local)) {
++ freed = fs.statSync(local).size;
++ try { fs.unlinkSync(local); cleanupSidecar(local); } catch { freed = 0; }
++ }
++
++ console.log(`[VodStorage] VOD ${vodId} promoted to R2: ${key}`);
++ return { ok: true, bytes: freed };
++ } catch (err) {
++ console.error(`[VodStorage] R2 promotion of VOD ${vodId} failed:`, err.message);
++ return { ok: false, error: err.message };
++ }
++}
++
++/** Demote a stale R2 VOD back to B2-only. */
++async function demoteFromR2(vodId) {
++ const vod = db.get('SELECT * FROM vods WHERE id = ?', [vodId]);
++ if (!vod) return { ok: false, error: 'VOD not found' };
++ if (providerOf(vod) !== 'r2') return { ok: true, already: true };
++
++ const key = keyForVod(vod);
++ try {
++ // Canonical must exist in B2 before we delete the R2 copy
++ const b2Head = await headObject('b2', key);
++ if (!b2Head) {
++ const copied = await copyBetweenProviders('r2', 'b2', key).catch(() => null);
++ if (!copied) return { ok: false, error: 'No B2 canonical and copy-back failed' };
++ }
++ await deleteObject('r2', key);
++ db.run("UPDATE vods SET storage_provider = 'b2' WHERE id = ?", [vodId]);
++ console.log(`[VodStorage] VOD ${vodId} demoted from R2 to B2`);
++ return { ok: true };
++ } catch (err) {
++ return { ok: false, error: err.message };
++ }
++}
++
++/** Delete a VOD's (or clip's — same columns) media everywhere (local + B2 + R2). */
++async function deleteVodObjects(vod) {
++ if (!vod?.file_path) return;
++ const local = localPathForVod(vod);
++ if (fs.existsSync(local)) {
++ try { fs.unlinkSync(local); } catch { /* ignore */ }
++ }
++ // Clips live in the clips dir — also try the record's own absolute path.
++ if (vod.file_path !== local && fs.existsSync(vod.file_path)) {
++ try { fs.unlinkSync(vod.file_path); } catch { /* ignore */ }
++ }
++ cleanupSidecar(local);
++ const key = keyForVod(vod);
++ for (const p of REMOTE_PROVIDERS) {
++ if (providerConfigured(p)) await deleteObject(p, key);
++ }
++}
++
++// ── Disk usage ───────────────────────────────────────────────
++
++function diskUsage(targetPath) {
++ try {
++ const resolved = path.resolve(targetPath);
++ const output = execSync(`df -B1 "${resolved}" 2>/dev/null | tail -1`, { encoding: 'utf8' });
++ const parts = output.trim().split(/\s+/);
++ if (parts.length >= 6) {
++ return {
++ total: parseInt(parts[1], 10) || 0,
++ used: parseInt(parts[2], 10) || 0,
++ available: parseInt(parts[3], 10) || 0,
++ usePct: parseFloat(parts[4]) || 0,
++ mount: parts[5] || '/',
++ };
++ }
++ } catch { /* ignore */ }
++ return { total: 0, used: 0, available: 0, usePct: 0, mount: '/' };
++}
++
++function dirStats(dirPath) {
++ let bytes = 0, files = 0;
++ try {
++ const resolved = path.resolve(dirPath);
++ if (!fs.existsSync(resolved)) return { bytes: 0, files: 0 };
++ for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) {
++ if (entry.isFile()) {
++ try { bytes += fs.statSync(path.join(resolved, entry.name)).size; files++; } catch { /* ignore */ }
++ }
++ }
++ } catch { /* ignore */ }
++ return { bytes, files };
++}
++
++// ── Sweep ────────────────────────────────────────────────────
++
++async function runSweep() {
++ if (sweepRunning) return { skipped: true, reason: 'already running' };
++ sweepRunning = true;
++
++ try {
++ const settings = getSettings();
++ if (!settings.enabled) return { skipped: true, reason: 'disabled' };
++ if (!providerConfigured('b2')) return { skipped: true, reason: 'B2 not configured' };
++ if (providerHealthy.b2 === false) return { skipped: true, reason: 'B2 unhealthy' };
++
++ let migrated = 0, bytesFreed = 0, promoted = 0, demoted = 0;
++ const errors = [];
++
++ const disk = diskUsage(config.vod.path);
++ const underPressure = disk.usePct >= settings.hotDiskPressurePct;
++
++ // 1) Cold offload to B2
++ let candidates;
++ if (underPressure) {
++ console.log(`[VodStorage] Disk at ${disk.usePct}% (pressure ≥ ${settings.hotDiskPressurePct}%) — draining to ${settings.localLowWaterPct}%`);
++ candidates = db.all(`
++ SELECT id, file_path, file_size FROM vods
++ WHERE COALESCE(storage_provider, 'local') = 'local'
++ AND COALESCE(is_recording, 0) = 0
++ AND created_at <= datetime('now', '-1 day')
++ ORDER BY (last_accessed_at IS NOT NULL), last_accessed_at ASC, view_count ASC, file_size DESC
++ LIMIT 100
++ `);
++ } else {
++ candidates = db.all(`
++ SELECT id, file_path, file_size FROM vods
++ WHERE COALESCE(storage_provider, 'local') = 'local'
++ AND COALESCE(is_recording, 0) = 0
++ AND created_at <= datetime('now', ?)
++ AND COALESCE(view_count, 0) <= ?
++ AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))
++ ORDER BY view_count ASC, file_size DESC
++ LIMIT ?
++ `, [`-${settings.minAgeDays} days`, settings.maxViewsForCold, `-${settings.minLastAccessDays} days`, settings.maxPerSweep]);
++ }
++
++ for (const vod of candidates) {
++ if (underPressure && diskUsage(config.vod.path).usePct <= settings.localLowWaterPct) break;
++ const result = await moveToCold(vod.id);
++ if (result.ok && !result.already) { migrated++; bytesFreed += result.bytes || 0; }
++ else if (!result.ok) errors.push({ id: vod.id, error: result.error });
++ }
++
++ // 2) R2 promotion for popular VODs
++ if (settings.r2Enabled && providerConfigured('r2') && providerHealthy.r2 !== false) {
++ const popular = db.all(`
++ SELECT id FROM vods
++ WHERE COALESCE(storage_provider, 'local') IN ('local', 'b2')
++ AND COALESCE(is_recording, 0) = 0
++ AND COALESCE(view_count, 0) >= ?
++ AND last_accessed_at IS NOT NULL
++ AND last_accessed_at >= datetime('now', ?)
++ LIMIT ?
++ `, [settings.r2MinViews, `-${settings.r2RecentAccessDays} days`, settings.r2MaxPerSweep]);
++ for (const vod of popular) {
++ const result = await promoteToR2(vod.id);
++ if (result.ok && !result.already) { promoted++; bytesFreed += result.bytes || 0; }
++ else if (!result.ok) errors.push({ id: vod.id, error: result.error });
++ }
++
++ // 3) R2 demotion for stale VODs
++ const stale = db.all(`
++ SELECT id FROM vods
++ WHERE storage_provider = 'r2'
++ AND (last_accessed_at IS NULL OR last_accessed_at <= datetime('now', ?))
++ LIMIT 10
++ `, [`-${settings.r2MaxIdleDays} days`]);
++ for (const vod of stale) {
++ const result = await demoteFromR2(vod.id);
++ if (result.ok && !result.already) demoted++;
++ }
++ }
++
++ const summary = {
++ checked: candidates.length,
++ migrated,
++ promoted,
++ demoted,
++ bytesFreed,
++ underPressure,
++ errors: errors.length ? errors : undefined,
++ timestamp: new Date().toISOString(),
++ };
++ if (migrated || promoted || demoted) {
++ console.log(`[VodStorage] Sweep: ${migrated} → B2, ${promoted} → R2, ${demoted} R2→B2, ${(bytesFreed / 1048576).toFixed(1)} MB freed locally`);
++ }
++ return summary;
++ } catch (err) {
++ console.error('[VodStorage] Sweep error:', err.message);
++ return { error: err.message };
++ } finally {
++ sweepRunning = false;
++ }
++}
++
++// ── Health + lifecycle ───────────────────────────────────────
++
++async function checkProviders() {
++ if (!providerConfigured('b2') && !providerConfigured('r2')) {
++ providerHealthy.b2 = false;
++ providerHealthy.r2 = false;
++ console.log('[VodStorage] No object-storage providers configured — local-only mode');
++ return { ...providerHealthy };
++ }
++ loadSdk();
++ for (const name of REMOTE_PROVIDERS) {
++ if (!providerConfigured(name)) { providerHealthy[name] = false; continue; }
++ try {
++ await clientFor(name).send(new S3.HeadBucketCommand({ Bucket: PROVIDER_ENV[name].bucket }));
++ providerHealthy[name] = true;
++ } catch (err) {
++ // R2 bucket may not exist yet — try to create it
++ if (name === 'r2') {
++ try {
++ await clientFor(name).send(new S3.CreateBucketCommand({ Bucket: PROVIDER_ENV[name].bucket }));
++ providerHealthy[name] = true;
++ console.log('[VodStorage] Created R2 bucket:', PROVIDER_ENV[name].bucket);
++ continue;
++ } catch (createErr) {
++ console.warn('[VodStorage] R2 bucket create failed:', createErr.message);
++ }
++ }
++ providerHealthy[name] = false;
++ console.warn(`[VodStorage] Provider ${name} unavailable:`, err.message);
++ }
++ }
++ console.log(`[VodStorage] Providers — b2: ${providerHealthy.b2 ? 'ok' : 'unavailable'}, r2: ${providerHealthy.r2 ? 'ok' : 'unavailable'}`);
++ return { ...providerHealthy };
++}
++
++/**
++ * One-time migration from the legacy hot/cold columns: rows imported with
++ * storage_tier='cold' were uploaded to B2 at vods/<basename> by the old rclone
++ * mount — verify and flip them to provider 'b2'.
++ */
++async function migrateLegacy() {
++ if (!providerConfigured('b2')) return;
++ const legacy = db.all(`
++ SELECT id, file_path FROM vods
++ WHERE storage_tier = 'cold' AND COALESCE(storage_provider, 'local') = 'local'
++ `);
++ if (!legacy.length) return;
++ let flipped = 0, restoredLocal = 0;
++ for (const vod of legacy) {
++ const key = KEY_PREFIX + path.basename(vod.file_path || '');
++ try {
++ const head = await headObject('b2', key);
++ if (head) {
++ db.run("UPDATE vods SET storage_provider = 'b2', storage_key = ? WHERE id = ?", [key, vod.id]);
++ flipped++;
++ } else if (fs.existsSync(localPathForVod(vod))) {
++ restoredLocal++; // still local, sweep will re-offload
++ } else {
++ console.warn(`[VodStorage] Legacy cold VOD ${vod.id} missing from B2 and local disk`);
++ }
++ } catch (err) {
++ console.warn(`[VodStorage] Legacy migration check failed for VOD ${vod.id}:`, err.message);
++ }
++ }
++ console.log(`[VodStorage] Legacy migration: ${flipped} cold VOD(s) mapped to B2${restoredLocal ? `, ${restoredLocal} still local` : ''}`);
++}
++
++function start() {
++ stop();
++ const settings = getSettings();
++ if (!settings.enabled) {
++ console.log('[VodStorage] Disabled — not starting sweep timer');
++ return;
++ }
++ console.log(`[VodStorage] Starting sweep timer (every ${(settings.sweepIntervalMs / 60000).toFixed(0)} min)`);
++ setTimeout(() => {
++ runSweep().catch(err => console.error('[VodStorage] Sweep failed:', err.message));
++ sweepTimer = setInterval(() => {
++ runSweep().catch(err => console.error('[VodStorage] Sweep failed:', err.message));
++ }, settings.sweepIntervalMs);
++ if (sweepTimer.unref) sweepTimer.unref();
++ }, 30_000).unref?.();
++}
++
++function stop() {
++ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
++}
++
++// ── Status ───────────────────────────────────────────────────
++
++function getStatus() {
++ const settings = getSettings();
++ const localDisk = diskUsage(config.vod.path);
++ const localStats = dirStats(config.vod.path);
++
++ const counts = db.get(`
++ SELECT
++ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount,
++ SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count,
++ SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count,
++ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN file_size ELSE 0 END) as localBytes,
++ SUM(CASE WHEN storage_provider = 'b2' THEN file_size ELSE 0 END) as b2Bytes,
++ SUM(CASE WHEN storage_provider = 'r2' THEN file_size ELSE 0 END) as r2Bytes
++ FROM vods
++ `) || {};
++
++ let clipCounts = {};
++ try {
++ clipCounts = db.get(`
++ SELECT
++ SUM(CASE WHEN COALESCE(storage_provider, 'local') = 'local' THEN 1 ELSE 0 END) as localCount,
++ SUM(CASE WHEN storage_provider = 'b2' THEN 1 ELSE 0 END) as b2Count,
++ SUM(CASE WHEN storage_provider = 'r2' THEN 1 ELSE 0 END) as r2Count
++ FROM clips
++ `) || {};
++ } catch { clipCounts = {}; }
++
++ return {
++ settings,
++ engine: 'local+b2+r2',
++ providers: {
++ b2: { configured: providerConfigured('b2'), healthy: providerHealthy.b2 !== false, bucket: PROVIDER_ENV.b2.bucket },
++ r2: { configured: providerConfigured('r2'), healthy: providerHealthy.r2 !== false, bucket: PROVIDER_ENV.r2.bucket },
++ },
++ local: { disk: localDisk, vods: { bytes: localStats.bytes, files: localStats.files } },
++ tiers: {
++ local: { count: counts.localCount || 0, bytes: counts.localBytes || 0 },
++ b2: { count: counts.b2Count || 0, bytes: counts.b2Bytes || 0 },
++ r2: { count: counts.r2Count || 0, bytes: counts.r2Bytes || 0 },
++ },
++ clipTiers: {
++ local: { count: clipCounts.localCount || 0 },
++ b2: { count: clipCounts.b2Count || 0 },
++ r2: { count: clipCounts.r2Count || 0 },
++ },
++ sweepRunning,
++ };
++}
++
++module.exports = {
++ DEFAULTS,
++ providerOf,
++ isRemote,
++ keyForVod,
++ localPathForVod,
++ resolvePlayback,
++ resolveMediaSource,
++ moveToCold,
++ moveToHot,
++ promoteToR2,
++ demoteFromR2,
++ deleteVodObjects,
++ deleteObject,
++ deleteLegacyPasteScreenshot,
++ presignGet,
++ runSweep,
++ checkProviders,
++ migrateLegacy,
++ start,
++ stop,
++ getStatus,
++ getSettings,
++ setSetting,
++ diskUsage,
++ dirStats,
++};
++
++// ── CLI: node server/vod/vod-storage.js <check|migrate-legacy|drain [pct]> ──
++if (require.main === module) {
++ (async () => {
++ const cmd = process.argv[2];
++ if (cmd === 'check') {
++ await checkProviders();
++ console.log(JSON.stringify(getStatus().tiers, null, 2));
++ process.exit(0);
++ }
++ if (cmd === 'migrate-legacy') {
++ await checkProviders();
++ await migrateLegacy();
++ process.exit(0);
++ }
++ if (cmd === 'drain') {
++ await checkProviders();
++ const target = Number(process.argv[3] || getSetting('localLowWaterPct'));
++ console.log(`[VodStorage] CLI drain to ${target}% disk usage`);
++ for (let round = 0; round < 50; round++) {
++ const disk = diskUsage(config.vod.path);
++ console.log(`[VodStorage] Disk at ${disk.usePct}%`);
++ if (disk.usePct <= target) break;
++ const eligible = db.all(`
++ SELECT id FROM vods
++ WHERE COALESCE(storage_provider, 'local') = 'local'
++ AND COALESCE(is_recording, 0) = 0
++ AND created_at <= datetime('now', '-1 day')
++ ORDER BY (last_accessed_at IS NOT NULL), last_accessed_at ASC, view_count ASC, file_size DESC
++ LIMIT 10
++ `);
++ if (!eligible.length) { console.log('[VodStorage] Nothing left to drain'); break; }
++ for (const vod of eligible) {
++ const r = await moveToCold(vod.id);
++ if (!r.ok) console.warn(` VOD ${vod.id}: ${r.error}`);
++ }
++ }
++ process.exit(0);
++ }
++ console.log('Usage: node server/vod/vod-storage.js <check|migrate-legacy|drain [targetPct]>');
++ process.exit(1);
++ })().catch(err => { console.error(err); process.exit(1); });
++}
ddiff --git a/server/webhooks.js b/server/webhooks.js
nnew file mode 100644
iindex 0000000..5bd6aff
---- /dev/null
++++ b/server/webhooks.js
@@@ -0,0 +1,68 @@
++/**
++ * OpenVibe.Media — Outbound webhooks
++ *
++ * POSTs { event, app_id, data } to the app's configured webhook_url with
++ * `X-OVMedia-Signature: sha256=<hmac>` — HMAC-SHA256 of the raw body using the
++ * app's webhook_secret (per CONTRACTS.md). Fire-and-forget with small retries.
++ *
++ * Events: vod.ready | vod.failed | clip.ready | clip.failed
++ */
++'use strict';
++
++const crypto = require('crypto');
++const db = require('./db/database');
++
++const MAX_ATTEMPTS = 3;
++const RETRY_BASE_MS = 3000;
++const TIMEOUT_MS = 10000;
++
++function sign(secret, rawBody) {
++ return 'sha256=' + crypto.createHmac('sha256', String(secret || '')).update(rawBody).digest('hex');
++}
++
++async function _post(url, rawBody, signature) {
++ const res = await fetch(url, {
++ method: 'POST',
++ headers: {
++ 'Content-Type': 'application/json',
++ 'X-OVMedia-Signature': signature,
++ },
++ body: rawBody,
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ if (!res.ok) throw new Error(`HTTP ${res.status}`);
++}
++
++/**
++ * Send an event to an app's webhook. Silently no-ops when the app has no
++ * webhook_url. Never throws.
++ * @param {string|object} appOrId app_id or apps row
++ * @param {string} event e.g. 'vod.ready'
++ * @param {object} data event payload
++ */
++async function sendWebhook(appOrId, event, data) {
++ let app = appOrId;
++ if (typeof appOrId === 'string') {
++ try { app = db.getApp(appOrId); } catch { app = null; }
++ }
++ if (!app || !app.webhook_url) return false;
++
++ const rawBody = JSON.stringify({ event, app_id: app.app_id, data });
++ const signature = sign(app.webhook_secret, rawBody);
++
++ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
++ try {
++ await _post(app.webhook_url, rawBody, signature);
++ return true;
++ } catch (err) {
++ if (attempt === MAX_ATTEMPTS) {
++ console.warn(`[Webhooks] ${event} → ${app.app_id} failed after ${MAX_ATTEMPTS} attempts: ${err.message}`);
++ return false;
++ }
++ await new Promise(r => setTimeout(r, RETRY_BASE_MS * attempt));
++ }
++ }
++ return false;
++}
++
++module.exports = { sendWebhook, sign };
ddiff --git a/vendor/openvibe-shared/account-switcher.js b/vendor/openvibe-shared/account-switcher.js
nnew file mode 100644
iindex 0000000..cc16ff3
---- /dev/null
++++ b/vendor/openvibe-shared/account-switcher.js
@@@ -0,0 +1,676 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Account Switcher
++// Google-style multi-account management with anonymous mode.
++// Persists sessions in localStorage, coordinates with server
++// for token swapping. Integrates with OpenVibeNavbar dropdown.
++// Usage: OpenVibeAccountSwitcher.init({ apiBase, onSwitch })
++// ═══════════════════════════════════════════════════════════════
++
++(function (root) {
++ 'use strict';
++
++ const STORAGE_KEY = 'openvibe_accounts';
++ const TOKEN_KEY = 'ov_token';
++ const ACTIVE_KEY = 'openvibe_active_account';
++ const ANON_KEY = 'openvibe_anon_token';
++ const ANON_TOKENS_KEY = 'openvibe_anon_tokens'; // Map<anonId, sessionToken>
++ const MAX_ACCOUNTS = 10;
++
++ let _config = { apiBase: 'https://openvibe.network', onSwitch: null };
++ let _panelEl = null;
++
++ function isAnonId(id) {
++ return String(id) === 'anon' || String(id).startsWith('anon_');
++ }
++
++ /** Store of session tokens per anon identity */
++ function getAnonTokens() {
++ try { return JSON.parse(localStorage.getItem(ANON_TOKENS_KEY) || '{}'); } catch { return {}; }
++ }
++ function saveAnonTokens(tokens) {
++ localStorage.setItem(ANON_TOKENS_KEY, JSON.stringify(tokens));
++ }
++ function setAnonToken(anonId, token) {
++ const tokens = getAnonTokens();
++ tokens[anonId] = token;
++ saveAnonTokens(tokens);
++ }
++ function getAnonToken(anonId) {
++ return getAnonTokens()[anonId] || null;
++ }
++ function removeAnonToken(anonId) {
++ const tokens = getAnonTokens();
++ delete tokens[anonId];
++ saveAnonTokens(tokens);
++ }
++
++ // Cookie policy (CONTRACTS.md): ov_token is host-only everywhere EXCEPT on
++ // openvibe.tools + its subdomains, where a Domain=.openvibe.tools cookie is
++ // shared across the tool satellites.
++ function onToolsDomain() {
++ const host = (typeof location !== 'undefined' && location.hostname) || '';
++ return host === 'openvibe.tools' || host.endsWith('.openvibe.tools');
++ }
++
++ function clearAuthCookie() {
++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
++ if (onToolsDomain()) {
++ document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools;SameSite=Lax';
++ }
++ }
++
++ function setAuthCookie(token) {
++ const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : '';
++ if (onToolsDomain()) {
++ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};domain=.openvibe.tools;SameSite=Lax;Secure`;
++ } else {
++ document.cookie = `ov_token=${token};path=/;max-age=${60 * 60 * 24 * 30};SameSite=Lax${secure}`;
++ }
++ }
++
++ function normalizeAccount(user, token) {
++ const isAnon = !!user?.is_anon || isAnonId(user?.id);
++ const anonNum = user.anon_number || null;
++ // Each anon gets a unique ID based on its anon_number
++ const id = isAnon ? (anonNum ? `anon_${anonNum}` : 'anon') : user.id;
++ return {
++ id,
++ username: user.username,
++ display_name: user.display_name || user.username,
++ avatar_url: user.avatar_url || null,
++ email: user.email || null,
++ is_anon: isAnon,
++ anon_number: anonNum,
++ token,
++ added_at: Date.now(),
++ };
++ }
++
++ function getAnonAccount(id) {
++ if (id) return getAccounts().find(a => a && a.is_anon && String(a.id) === String(id)) || null;
++ return getAccounts().find(a => a && a.is_anon) || null;
++ }
++
++ function getAnonAccounts() {
++ return getAccounts().filter(a => a && a.is_anon);
++ }
++
++ // ─── Account Store ─────────────────────────────────────────
++ function getAccounts() {
++ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
++ }
++
++ function saveAccounts(accounts) {
++ localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts));
++ }
++
++ function getActiveId() {
++ return localStorage.getItem(ACTIVE_KEY);
++ }
++
++ function setActiveId(id) {
++ localStorage.setItem(ACTIVE_KEY, id);
++ }
++
++ function addAccount(user, token) {
++ const accounts = getAccounts();
++ const entry = normalizeAccount(user, token);
++ const existing = accounts.findIndex(a => String(a.id) === String(entry.id));
++ if (existing !== -1) {
++ accounts[existing] = entry;
++ } else {
++ if (accounts.length >= MAX_ACCOUNTS) {
++ accounts.shift(); // Remove oldest
++ }
++ accounts.push(entry);
++ }
++ saveAccounts(accounts);
++ return accounts;
++ }
++
++ function removeAccount(id) {
++ const targetId = String(id);
++ const accounts = getAccounts().filter(a => String(a.id) !== targetId);
++ saveAccounts(accounts);
++ // Clean up anon token if removing an anon identity
++ if (isAnonId(targetId)) removeAnonToken(targetId);
++ if (getActiveId() === targetId) {
++ const fallback = accounts.find(a => !a.is_anon) || accounts[0];
++ if (fallback) switchTo(fallback.id);
++ else logout();
++ }
++ return accounts;
++ }
++
++ /** Read ov_token from the cookie (host-only; Domain=.openvibe.tools on tools hosts) */
++ function getCookieToken() {
++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
++ return m ? decodeURIComponent(m[1]) : null;
++ }
++
++ // ─── API ───────────────────────────────────────────────────
++ async function apiFetch(path, opts = {}) {
++ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
++ const headers = { 'Content-Type': 'application/json', ...opts.headers };
++ if (token) headers.Authorization = `Bearer ${token}`;
++ const res = await fetch(`${_config.apiBase}${path}`, { ...opts, headers, credentials: 'include' });
++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
++ return res.json();
++ }
++
++ async function switchTo(accountId) {
++ if (isAnonId(accountId)) return switchToAnon(accountId);
++
++ const accounts = getAccounts();
++ const target = accounts.find(a => String(a.id) === String(accountId));
++ if (target?.is_anon) return switchToAnon(accountId);
++ if (!target || !target.token) {
++ // Token expired — need re-login
++ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
++ return;
++ }
++
++ // Validate token is still good
++ try {
++ const headers = { Authorization: `Bearer ${target.token}` };
++ const res = await fetch(`${_config.apiBase}/api/auth/me`, { headers });
++ if (!res.ok) throw new Error('Token invalid');
++ } catch {
++ // Token expired
++ removeAccount(target.id);
++ window.location.href = `${_config.apiBase}/login?switch_to=${accountId}&return=${encodeURIComponent(window.location.href)}`;
++ return;
++ }
++
++ localStorage.setItem(TOKEN_KEY, target.token);
++ setActiveId(target.id);
++ setAuthCookie(target.token);
++
++ if (_config.onSwitch) _config.onSwitch(target);
++ else window.location.reload();
++ }
++
++ /**
++ * Switch to an anonymous identity. If anonId is provided, switch to that
++ * specific anon. Otherwise use the most recently active or create a new one.
++ */
++ async function switchToAnon(anonId) {
++ async function fetchAnonUser(token) {
++ const res = await fetch(`${_config.apiBase}/api/auth/anon/${encodeURIComponent(token)}`, { credentials: 'include' });
++ if (!res.ok) throw new Error('Anon session invalid');
++ const data = await res.json();
++ return data.user || null;
++ }
++
++ let anonToken = null;
++ let anonUser = null;
++
++ // If switching to a specific anon identity, look up its token
++ if (anonId && anonId !== 'anon') {
++ anonToken = getAnonToken(anonId);
++ }
++
++ // Fallback to legacy single-anon token
++ if (!anonToken) {
++ anonToken = localStorage.getItem(ANON_KEY);
++ }
++
++ // Try to validate the existing token
++ if (anonToken) {
++ try {
++ anonUser = await fetchAnonUser(anonToken);
++ } catch {
++ // Token invalid — clear it
++ if (anonId) removeAnonToken(anonId);
++ localStorage.removeItem(ANON_KEY);
++ anonToken = null;
++ }
++ }
++
++ // Create new anon session if we don't have a valid one
++ if (!anonToken) {
++ try {
++ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: '{}',
++ credentials: 'include',
++ }).then(async (res) => {
++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
++ return res.json();
++ });
++ anonToken = data.token;
++ anonUser = data.user || null;
++ } catch (err) {
++ console.error('[OpenVibeAccountSwitcher] Failed to create anon session:', err);
++ return;
++ }
++ }
++
++ if (!anonUser) {
++ try {
++ anonUser = await fetchAnonUser(anonToken);
++ } catch (err) {
++ console.error('[OpenVibeAccountSwitcher] Failed to load anon session:', err);
++ return;
++ }
++ }
++
++ // Store the anon account and its token
++ if (anonUser) {
++ const entry = addAccount(anonUser, anonToken);
++ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
++ setAnonToken(realId, anonToken);
++ // Also keep legacy key for backward compat
++ localStorage.setItem(ANON_KEY, anonToken);
++
++ localStorage.removeItem(TOKEN_KEY);
++ setActiveId(realId);
++ clearAuthCookie();
++
++ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number || null });
++ else window.location.reload();
++ }
++ }
++
++ /**
++ * Create a brand-new anonymous identity (even if one already exists).
++ * Useful for the "New Anon Identity" button.
++ */
++ async function createNewAnon() {
++ try {
++ const data = await fetch(`${_config.apiBase}/api/auth/anon-session`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ force_new: true }),
++ credentials: 'include',
++ }).then(async (res) => {
++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
++ return res.json();
++ });
++ const anonToken = data.token;
++ const anonUser = data.user;
++ if (!anonUser) throw new Error('No user returned');
++
++ const entry = addAccount(anonUser, anonToken);
++ const realId = anonUser.anon_number ? `anon_${anonUser.anon_number}` : 'anon';
++ setAnonToken(realId, anonToken);
++
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.setItem(ANON_KEY, anonToken);
++ setActiveId(realId);
++ clearAuthCookie();
++
++ if (_config.onSwitch) _config.onSwitch(getAnonAccount(realId) || { id: realId, is_anon: true, anon_number: anonUser.anon_number });
++ else window.location.reload();
++ } catch (err) {
++ console.error('[OpenVibeAccountSwitcher] Failed to create new anon:', err);
++ }
++ }
++
++ function logout() {
++ const activeId = getActiveId();
++ if (activeId) removeAccount(activeId);
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.removeItem(ACTIVE_KEY);
++ clearAuthCookie();
++ if (_config.onSwitch) _config.onSwitch(null);
++ else window.location.reload();
++ }
++
++ function logoutAll() {
++ localStorage.removeItem(STORAGE_KEY);
++ localStorage.removeItem(TOKEN_KEY);
++ localStorage.removeItem(ACTIVE_KEY);
++ localStorage.removeItem(ANON_KEY);
++ localStorage.removeItem(ANON_TOKENS_KEY);
++ clearAuthCookie();
++ if (_config.onSwitch) _config.onSwitch(null);
++ else window.location.reload();
++ }
++
++ // ─── Switcher Panel UI ─────────────────────────────────────
++ function injectStyles() {
++ if (document.getElementById('openvibe-switcher-styles')) return;
++ const s = document.createElement('style');
++ s.id = 'openvibe-switcher-styles';
++ s.textContent = `
++ .openvibe-switcher-overlay {
++ position: fixed; inset: 0; z-index: 20000;
++ background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
++ display: flex; align-items: center; justify-content: center;
++ animation: openvibe-sw-fadein .2s ease;
++ }
++ @keyframes openvibe-sw-fadein { from { opacity: 0; } to { opacity: 1; } }
++
++ .openvibe-switcher-panel {
++ width: 380px; max-height: 520px;
++ background: var(--bg-card, #22222c);
++ border: 1px solid var(--border, #333340); border-radius: 16px;
++ box-shadow: 0 20px 60px rgba(0,0,0,0.6);
++ overflow: hidden; animation: openvibe-sw-pop .25s cubic-bezier(.34,1.56,.64,1);
++ }
++ @keyframes openvibe-sw-pop { from { transform: scale(.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
++
++ .openvibe-switcher-header {
++ text-align: center; padding: 20px 16px 12px;
++ border-bottom: 1px solid var(--border, #333340);
++ }
++ .openvibe-switcher-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; color: var(--text-primary, #e0e0e0); }
++ .openvibe-switcher-header p { margin: 0; font-size: 12px; color: var(--text-muted, #707080); }
++
++ .openvibe-switcher-list { padding: 8px; max-height: 300px; overflow-y: auto; }
++
++ .openvibe-switcher-item {
++ display: flex; align-items: center; gap: 12px; padding: 10px 12px;
++ border-radius: 10px; cursor: pointer; transition: background .15s;
++ position: relative;
++ }
++ .openvibe-switcher-item:hover { background: var(--bg-hover, #2f2f3d); }
++ .openvibe-switcher-item.active { background: rgba(192,150,92,0.08); }
++ .openvibe-switcher-item.active::after {
++ content: '✓'; position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
++ color: var(--accent, #8b5cf6); font-weight: 700; font-size: 16px;
++ }
++ .openvibe-switcher-item img { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
++ .openvibe-switcher-item .info { flex: 1; line-height: 1.3; }
++ .openvibe-switcher-item .info .name { font-size: 14px; font-weight: 600; color: var(--text-primary, #e0e0e0); }
++ .openvibe-switcher-item .info .sub { font-size: 11px; color: var(--text-muted, #707080); }
++ .openvibe-switcher-item .remove {
++ opacity: 0; font-size: 14px; color: var(--live-red, #e74c3c);
++ cursor: pointer; padding: 4px 6px; border-radius: 4px;
++ transition: opacity .15s, background .15s;
++ }
++ .openvibe-switcher-item:hover .remove { opacity: .6; }
++ .openvibe-switcher-item .remove:hover { opacity: 1; background: rgba(231,76,60,0.1); }
++
++ .openvibe-switcher-anon {
++ display: flex; align-items: center; gap: 12px; padding: 10px 12px;
++ margin: 0 8px 8px; border-radius: 10px; cursor: pointer;
++ border: 1px dashed var(--border, #333340);
++ transition: all .15s; color: var(--text-muted, #707080);
++ }
++ .openvibe-switcher-anon:hover { background: var(--bg-hover, #2f2f3d); border-color: var(--accent-dark, #a07840); }
++ .openvibe-switcher-anon .anon-icon { font-size: 22px; width: 40px; text-align: center; }
++
++ .openvibe-switcher-actions {
++ padding: 8px 12px; border-top: 1px solid var(--border, #333340);
++ display: flex; gap: 8px; justify-content: center;
++ }
++ .openvibe-switcher-actions button {
++ flex: 1; padding: 8px; border: none; border-radius: 8px;
++ font-size: 12px; font-weight: 600; cursor: pointer;
++ transition: all .15s;
++ }
++ .openvibe-switcher-actions .btn-add {
++ background: var(--accent, #8b5cf6); color: #fff;
++ }
++ .openvibe-switcher-actions .btn-add:hover { background: var(--accent-dark, #a07840); }
++ .openvibe-switcher-actions .btn-signout {
++ background: rgba(231,76,60,0.1); color: var(--live-red, #e74c3c);
++ }
++ .openvibe-switcher-actions .btn-signout:hover { background: rgba(231,76,60,0.2); }
++ `;
++ document.head.appendChild(s);
++ }
++
++ function showPanel() {
++ if (_panelEl) return;
++ injectStyles();
++
++ const accounts = getAccounts();
++ const activeId = getActiveId();
++ const isAnonActive = isAnonId(activeId);
++ const loggedAccounts = accounts.filter(a => !a.is_anon);
++ const anonAccounts = accounts.filter(a => a.is_anon);
++
++ const overlay = document.createElement('div');
++ overlay.className = 'openvibe-switcher-overlay';
++ overlay.addEventListener('click', e => {
++ if (e.target === overlay) closePanel();
++ });
++
++ const panel = document.createElement('div');
++ panel.className = 'openvibe-switcher-panel';
++
++ panel.innerHTML = `
++ <div class="openvibe-switcher-header">
++ <h3>\uD83D\uDD25 Switch Account</h3>
++ <p>Choose an account or browse anonymously</p>
++ </div>
++ <div class="openvibe-switcher-list">
++ ${loggedAccounts.map(a => `
++ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
++ <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="">
++ <div class="info">
++ <div class="name">${a.display_name || a.username}</div>
++ <div class="sub">${a.email || `@${a.username}`}</div>
++ </div>
++ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove account">\u2715</span>` : ''}
++ </div>
++ `).join('')}
++
++ ${anonAccounts.length > 0 ? `
++ <div style="padding:6px 12px 2px;font-size:11px;font-weight:600;color:var(--text-muted,#707080);text-transform:uppercase;letter-spacing:0.05em;">Anonymous Identities</div>
++ ${anonAccounts.map(a => `
++ <div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
++ <span style="width:40px;height:40px;display:flex;align-items:center;justify-content:center;font-size:20px;border-radius:50%;background:var(--bg-secondary,#1a1a26);flex-shrink:0;">\uD83E\uDEE5</span>
++ <div class="info">
++ <div class="name">Anonymous #${a.anon_number || '?'}</div>
++ <div class="sub">anon_${a.anon_number || '?'}</div>
++ </div>
++ ${String(a.id) !== String(activeId) ? `<span class="remove" data-remove-id="${a.id}" title="Remove identity">\u2715</span>` : ''}
++ </div>
++ `).join('')}
++ ` : ''}
++ </div>
++ <div class="openvibe-switcher-anon" id="openvibe-sw-new-anon">
++ <span class="anon-icon">+</span>
++ <div class="info">
++ <div class="name" style="color:var(--text-primary,#e0e0e0)">${anonAccounts.length > 0 ? 'New Anon Identity' : 'Go Anonymous'}</div>
++ <div class="sub">${anonAccounts.length > 0 ? 'Create another anonymous identity' : 'Browse without an account \u00b7 Limited features'}</div>
++ </div>
++ </div>
++ <div class="openvibe-switcher-actions">
++ <button class="btn-add" id="openvibe-sw-add">+ Add Account</button>
++ <button class="btn-signout" id="openvibe-sw-signout">Sign Out All</button>
++ </div>
++ `;
++
++ overlay.appendChild(panel);
++ document.body.appendChild(overlay);
++ _panelEl = overlay;
++
++ // Wire events
++ panel.querySelectorAll('.openvibe-switcher-item[data-id]').forEach(el => {
++ el.addEventListener('click', e => {
++ if (e.target.closest('.remove')) return;
++ switchTo(el.dataset.id);
++ closePanel();
++ });
++ });
++
++ panel.querySelectorAll('[data-remove-id]').forEach(el => {
++ el.addEventListener('click', e => {
++ e.stopPropagation();
++ removeAccount(el.dataset.removeId);
++ closePanel();
++ showPanel(); // Re-render
++ });
++ });
++
++ panel.querySelector('#openvibe-sw-new-anon').addEventListener('click', () => {
++ if (anonAccounts.length > 0) {
++ createNewAnon();
++ } else {
++ switchToAnon();
++ }
++ closePanel();
++ });
++
++ panel.querySelector('#openvibe-sw-add').addEventListener('click', () => {
++ window.location.href = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
++ });
++
++ panel.querySelector('#openvibe-sw-signout').addEventListener('click', () => {
++ logoutAll();
++ closePanel();
++ });
++
++ // Esc to close
++ const escHandler = e => { if (e.key === 'Escape') closePanel(); };
++ document.addEventListener('keydown', escHandler);
++ overlay._escHandler = escHandler;
++ }
++
++ function closePanel() {
++ if (!_panelEl) return;
++ document.removeEventListener('keydown', _panelEl._escHandler);
++ _panelEl.remove();
++ _panelEl = null;
++ }
++
++ // ─── Token Refresh ───────────────────────────────────────────
++ // Decode JWT payload without verification (client-side can't verify RS256).
++ // Used only to check `exp` claim for proactive refresh.
++ function decodeJwtPayload(token) {
++ try {
++ const parts = token.split('.');
++ if (parts.length !== 3) return null;
++ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
++ return JSON.parse(atob(payload));
++ } catch { return null; }
++ }
++
++ let _refreshTimer = null;
++ let _refreshInProgress = false;
++
++ /**
++ * Silently refresh the JWT if it expires within the next 2 hours,
++ * or if it has already expired (7-day grace period on the server).
++ * Updates localStorage, cookie, and account list.
++ */
++ async function silentRefresh() {
++ if (_refreshInProgress) return;
++ const token = localStorage.getItem(TOKEN_KEY) || getCookieToken();
++ if (!token) return;
++ const payload = decodeJwtPayload(token);
++ if (!payload || !payload.exp) return;
++
++ const expiresAt = payload.exp * 1000;
++ const now = Date.now();
++ const twoHours = 2 * 60 * 60 * 1000;
++
++ // Only refresh if token expires within 2 hours or is already expired
++ if (expiresAt - now > twoHours) return;
++
++ _refreshInProgress = true;
++ try {
++ const res = await fetch(`${_config.apiBase}/api/auth/refresh`, {
++ method: 'POST',
++ headers: {
++ 'Content-Type': 'application/json',
++ Authorization: `Bearer ${token}`,
++ },
++ credentials: 'include',
++ });
++ if (!res.ok) {
++ // If refresh fails with 401, token is truly dead — don't retry
++ if (res.status === 401) {
++ console.warn('[OpenVibeAuth] Token refresh failed — session expired');
++ }
++ return;
++ }
++ const data = await res.json();
++ if (data.token) {
++ // Update localStorage
++ localStorage.setItem(TOKEN_KEY, data.token);
++ // Update cookie
++ setAuthCookie(data.token);
++ // Update the stored account entry
++ const activeId = getActiveId();
++ if (activeId && !isAnonId(activeId)) {
++ const accounts = getAccounts();
++ const idx = accounts.findIndex(a => String(a.id) === String(activeId));
++ if (idx !== -1) {
++ accounts[idx].token = data.token;
++ saveAccounts(accounts);
++ }
++ }
++ console.log('[OpenVibeAuth] Token refreshed silently');
++ }
++ } catch (err) {
++ console.warn('[OpenVibeAuth] Silent refresh error:', err.message);
++ } finally {
++ _refreshInProgress = false;
++ }
++ }
++
++ /**
++ * Start a periodic timer that checks token expiry and refreshes when needed.
++ * Checks every 15 minutes. Also runs an immediate check.
++ */
++ function startRefreshTimer() {
++ if (_refreshTimer) return;
++ silentRefresh(); // Immediate check
++ _refreshTimer = setInterval(silentRefresh, 15 * 60 * 1000); // Every 15 min
++ }
++
++ function stopRefreshTimer() {
++ if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; }
++ }
++
++ // ─── Listen for navbar events ──────────────────────────────
++ document.addEventListener('openvibe-switch-account', e => {
++ switchTo(e.detail.accountId);
++ });
++
++ // ─── Public API ────────────────────────────────────────────
++ const OpenVibeAccountSwitcher = {
++ init(opts = {}) {
++ Object.assign(_config, opts);
++ // Start silent token refresh for logged-in users
++ const activeId = getActiveId();
++ if (activeId && !isAnonId(activeId)) {
++ startRefreshTimer();
++ }
++ },
++
++ /** Call after login success to store this account. */
++ addAccount,
++ removeAccount,
++ getAccounts,
++ getActiveId,
++ switchTo,
++ switchToAnon,
++ createNewAnon,
++ getAnonAccounts,
++ logout,
++ logoutAll,
++ silentRefresh,
++ startRefreshTimer,
++ stopRefreshTimer,
++
++ /** Show the full-screen account switcher panel. */
++ showPanel,
++ closePanel,
++
++ /** Quick check: is user on an anonymous session? */
++ isAnonymous() {
++ const id = getActiveId();
++ return isAnonId(id);
++ },
++
++ /** Get the current active account object. */
++ getActive() {
++ const id = getActiveId();
++ if (!id) return null;
++ if (isAnonId(id)) return getAnonAccount(id) || getAnonAccount() || null;
++ return getAccounts().find(a => String(a.id) === String(id)) || null;
++ },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeAccountSwitcher;
++ else root.OpenVibeAccountSwitcher = OpenVibeAccountSwitcher;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
ddiff --git a/vendor/openvibe-shared/analytics.js b/vendor/openvibe-shared/analytics.js
nnew file mode 100644
iindex 0000000..600f2ae
---- /dev/null
++++ b/vendor/openvibe-shared/analytics.js
@@@ -0,0 +1,1066 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Unified Analytics Module
++// Shared analytics tracking for all OpenVibe services.
++// Each service stores events in its own SQLite DB using the
++// same schema. The admin panel aggregates via internal API.
++// ═══════════════════════════════════════════════════════════════
++
++// ── Bot Detection ────────────────────────────────────────────
++
++const BOT_USER_AGENTS = [
++ /googlebot/i, /bingbot/i, /slurp/i, /duckduckbot/i, /baiduspider/i,
++ /yandexbot/i, /facebot/i, /ia_archiver/i, /semrushbot/i, /ahrefsbot/i,
++ /mj12bot/i, /dotbot/i, /petalbot/i, /rogerbot/i, /screaming frog/i,
++ /seznambot/i, /sogou/i, /exabot/i, /archive\.org_bot/i, /crawler/i,
++ /spider/i, /python-requests/i, /python-urllib/i, /httpx/i,
++ /go-http-client/i, /java\//i, /libwww-perl/i, /wget/i, /curl/i,
++ /headlesschrome/i, /phantomjs/i, /scrapy/i, /node-fetch/i,
++ /axios/i, /postman/i, /insomnia/i, /lighthouse/i, /pagespeed/i,
++ /gptbot/i, /chatgpt-user/i, /claudebot/i, /anthropic/i,
++ /bytespider/i, /amazonbot/i, /applebot/i, /twitterbot/i,
++ /facebookexternalhit/i, /linkedinbot/i, /whatsapp/i, /telegrambot/i,
++ /discordbot/i, /slackbot/i, /uptimerobot/i, /pingdom/i,
++ /statuscake/i, /monitoring/i, /health.?check/i,
++];
++
++const SUSPICIOUS_PATTERNS = {
++ // Hits per minute thresholds
++ highRequestRate: 60,
++ // Pages per session in under a minute
++ rapidPageViews: 20,
++ // No JS execution (missing beacon)
++ noJsExecution: true,
++ // Accessing well-known bot traps
++ honeypotPaths: ['/wp-login.php', '/xmlrpc.php', '/wp-admin', '/.env', '/admin.php', '/phpmyadmin'],
++};
++
++/**
++ * Classify whether a request is from a bot.
++ * Returns: { isBot: boolean, botType: string|null, confidence: number }
++ */
++function classifyRequest(req) {
++ const ua = req.headers['user-agent'] || '';
++ const ip = req.ip || req.connection?.remoteAddress || '';
++
++ // 1. Known bot user agents
++ for (const pattern of BOT_USER_AGENTS) {
++ if (pattern.test(ua)) {
++ return { isBot: true, botType: 'known_crawler', confidence: 0.95 };
++ }
++ }
++
++ // 2. Missing or empty user agent
++ if (!ua || ua.length < 10) {
++ return { isBot: true, botType: 'no_useragent', confidence: 0.85 };
++ }
++
++ // 3. Honeypot paths
++ const path = req.path || req.url || '';
++ if (SUSPICIOUS_PATTERNS.honeypotPaths.some(p => path.toLowerCase().startsWith(p))) {
++ return { isBot: true, botType: 'honeypot_hit', confidence: 0.90 };
++ }
++
++ // 4. Looks human (default)
++ return { isBot: false, botType: null, confidence: 0.1 };
++}
++
++
++// ── Schema ───────────────────────────────────────────────────
++
++const ANALYTICS_SCHEMA = `
++ -- Page views / API hits
++ CREATE TABLE IF NOT EXISTS analytics_events (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ service TEXT NOT NULL,
++ event_type TEXT NOT NULL DEFAULT 'pageview',
++ path TEXT,
++ method TEXT DEFAULT 'GET',
++ status_code INTEGER,
++ response_time_ms INTEGER,
++ user_id INTEGER,
++ session_id TEXT,
++ ip TEXT,
++ country TEXT,
++ city TEXT,
++ user_agent TEXT,
++ referer TEXT,
++ is_bot INTEGER DEFAULT 0,
++ bot_type TEXT,
++ device_type TEXT,
++ browser TEXT,
++ os TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++
++ -- Aggregated hourly stats (materialized by cron/timer)
++ CREATE TABLE IF NOT EXISTS analytics_hourly (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ service TEXT NOT NULL,
++ hour TEXT NOT NULL,
++ pageviews INTEGER DEFAULT 0,
++ api_calls INTEGER DEFAULT 0,
++ unique_visitors INTEGER DEFAULT 0,
++ unique_users INTEGER DEFAULT 0,
++ bot_hits INTEGER DEFAULT 0,
++ avg_response_ms INTEGER DEFAULT 0,
++ error_count INTEGER DEFAULT 0,
++ bandwidth_bytes INTEGER DEFAULT 0,
++ top_paths TEXT,
++ top_referers TEXT,
++ UNIQUE(service, hour)
++ );
++
++ -- Daily aggregates
++ CREATE TABLE IF NOT EXISTS analytics_daily (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ service TEXT NOT NULL,
++ date TEXT NOT NULL,
++ pageviews INTEGER DEFAULT 0,
++ api_calls INTEGER DEFAULT 0,
++ unique_visitors INTEGER DEFAULT 0,
++ unique_users INTEGER DEFAULT 0,
++ new_users INTEGER DEFAULT 0,
++ bot_hits INTEGER DEFAULT 0,
++ avg_response_ms INTEGER DEFAULT 0,
++ error_count INTEGER DEFAULT 0,
++ top_paths TEXT,
++ top_referers TEXT,
++ top_countries TEXT,
++ device_breakdown TEXT,
++ browser_breakdown TEXT,
++ UNIQUE(service, date)
++ );
++
++ -- Rate tracking for bot detection
++ CREATE TABLE IF NOT EXISTS analytics_rate_tracking (
++ ip TEXT NOT NULL,
++ window_start INTEGER NOT NULL,
++ hit_count INTEGER DEFAULT 1,
++ PRIMARY KEY (ip, window_start)
++ );
++
++ -- Indexes
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events(created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_service ON analytics_events(service, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_path ON analytics_events(service, path, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_ip ON analytics_events(ip, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_bot ON analytics_events(is_bot, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_events_user ON analytics_events(user_id, created_at);
++ CREATE INDEX IF NOT EXISTS idx_analytics_hourly_lookup ON analytics_hourly(service, hour);
++ CREATE INDEX IF NOT EXISTS idx_analytics_daily_lookup ON analytics_daily(service, date);
++`;
++
++
++// ── Device/Browser Parsing (lightweight) ─────────────────────
++
++function parseUserAgent(ua) {
++ if (!ua) return { device: 'unknown', browser: 'unknown', os: 'unknown' };
++
++ // Device
++ let device = 'desktop';
++ if (/mobile|android|iphone|ipod/i.test(ua)) device = 'mobile';
++ else if (/ipad|tablet/i.test(ua)) device = 'tablet';
++
++ // Browser
++ let browser = 'other';
++ if (/edg\//i.test(ua)) browser = 'edge';
++ else if (/opr\/|opera/i.test(ua)) browser = 'opera';
++ else if (/firefox\//i.test(ua)) browser = 'firefox';
++ else if (/chrome\//i.test(ua) && !/chromium/i.test(ua)) browser = 'chrome';
++ else if (/safari\//i.test(ua) && !/chrome/i.test(ua)) browser = 'safari';
++ else if (/trident|msie/i.test(ua)) browser = 'ie';
++
++ // OS
++ let os = 'other';
++ if (/windows/i.test(ua)) os = 'windows';
++ else if (/macintosh|mac os/i.test(ua)) os = 'macos';
++ else if (/linux/i.test(ua) && !/android/i.test(ua)) os = 'linux';
++ else if (/android/i.test(ua)) os = 'android';
++ else if (/iphone|ipad|ipod/i.test(ua)) os = 'ios';
++
++ return { device, browser, os };
++}
++
++
++// ── Analytics Tracker Class ──────────────────────────────────
++
++class AnalyticsTracker {
++ /**
++ * @param {object} db - better-sqlite3 database instance
++ * @param {string} service - service name (e.g., 'live', 'openvibe-network', 'tools')
++ */
++ constructor(db, service) {
++ this.db = db;
++ this.service = service;
++ this._buffer = [];
++ this._flushInterval = null;
++ this._aggregateInterval = null;
++
++ // Initialize schema
++ this.db.exec(ANALYTICS_SCHEMA);
++
++ // Prepared statements
++ this._insertEvent = this.db.prepare(`
++ INSERT INTO analytics_events
++ (service, event_type, path, method, status_code, response_time_ms,
++ user_id, session_id, ip, country, city, user_agent, referer,
++ is_bot, bot_type, device_type, browser, os)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ `);
++
++ this._insertMany = this.db.transaction((events) => {
++ for (const e of events) {
++ this._insertEvent.run(
++ e.service, e.event_type, e.path, e.method, e.status_code, e.response_time_ms,
++ e.user_id, e.session_id, e.ip, e.country, e.city, e.user_agent, e.referer,
++ e.is_bot ? 1 : 0, e.bot_type, e.device_type, e.browser, e.os
++ );
++ }
++ });
++
++ this._updateRate = this.db.prepare(`
++ INSERT INTO analytics_rate_tracking (ip, window_start, hit_count)
++ VALUES (?, ?, 1)
++ ON CONFLICT(ip, window_start) DO UPDATE SET hit_count = hit_count + 1
++ `);
++
++ this._getRate = this.db.prepare(
++ 'SELECT SUM(hit_count) as total FROM analytics_rate_tracking WHERE ip = ? AND window_start >= ?'
++ );
++
++ this._cleanRates = this.db.prepare(
++ 'DELETE FROM analytics_rate_tracking WHERE window_start < ?'
++ );
++
++ // Start flush timer (write buffered events every 5s)
++ this._flushInterval = setInterval(() => this.flush(), 5000);
++
++ // Hourly aggregation timer
++ this._aggregateInterval = setInterval(() => this.aggregate(), 60 * 60 * 1000);
++
++ // Initial aggregation on startup (catch up)
++ setTimeout(() => this.aggregate(), 10_000);
++
++ // Clean old rate tracking every 10 min
++ setInterval(() => {
++ const cutoff = Math.floor(Date.now() / 1000) - 300;
++ try { this._cleanRates.run(cutoff); } catch {}
++ }, 10 * 60 * 1000);
++ }
++
++ /**
++ * Express middleware for automatic request tracking.
++ * Tracks page views and API calls with response times.
++ */
++ middleware() {
++ return (req, res, next) => {
++ // Skip health checks and static assets
++ const path = req.path || '';
++ if (path === '/api/health' || path === '/health' ||
++ /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)$/i.test(path)) {
++ return next();
++ }
++
++ const startTime = Date.now();
++
++ // Hook into response finish
++ const originalEnd = res.end;
++ res.end = (...args) => {
++ res.end = originalEnd;
++ res.end(...args);
++
++ const responseTime = Date.now() - startTime;
++ const botCheck = classifyRequest(req);
++ const parsed = parseUserAgent(req.headers['user-agent'] || '');
++
++ const isApi = path.startsWith('/api/') || path.startsWith('/internal/') || path.startsWith('/oauth/');
++ const eventType = isApi ? 'api_call' : 'pageview';
++
++ // Rate-based bot detection
++ const ip = req.ip || '';
++ const windowStart = Math.floor(Date.now() / 60000); // 1-minute windows
++ try {
++ this._updateRate.run(ip, windowStart);
++ if (!botCheck.isBot) {
++ const rateData = this._getRate.get(ip, windowStart - 1);
++ if (rateData && rateData.total > SUSPICIOUS_PATTERNS.highRequestRate) {
++ botCheck.isBot = true;
++ botCheck.botType = 'rate_limit';
++ botCheck.confidence = 0.80;
++ }
++ }
++ } catch {}
++
++ // Get session ID from cookie or generate from IP+UA
++ const sessionId = req.cookies?.openvibe_session ||
++ req.cookies?.ov_token?.slice(-16) ||
++ null;
++
++ this._buffer.push({
++ service: this.service,
++ event_type: eventType,
++ path: path.substring(0, 500),
++ method: req.method,
++ status_code: res.statusCode,
++ response_time_ms: responseTime,
++ user_id: req.user?.id || req.user?.sub || null,
++ session_id: sessionId,
++ ip: ip,
++ country: req.headers['cf-ipcountry'] || null,
++ city: null,
++ user_agent: (req.headers['user-agent'] || '').substring(0, 500),
++ referer: (req.headers['referer'] || '').substring(0, 500),
++ is_bot: botCheck.isBot,
++ bot_type: botCheck.botType,
++ device_type: parsed.device,
++ browser: parsed.browser,
++ os: parsed.os,
++ });
++
++ // Auto-flush if buffer gets large
++ if (this._buffer.length >= 100) {
++ this.flush();
++ }
++ };
++
++ next();
++ };
++ }
++
++ /**
++ * Track a custom event (not from HTTP request).
++ */
++ trackEvent(eventType, data = {}) {
++ this._buffer.push({
++ service: this.service,
++ event_type: eventType,
++ path: data.path || null,
++ method: data.method || null,
++ status_code: data.status_code || null,
++ response_time_ms: data.response_time_ms || null,
++ user_id: data.user_id || null,
++ session_id: data.session_id || null,
++ ip: data.ip || null,
++ country: data.country || null,
++ city: data.city || null,
++ user_agent: data.user_agent || null,
++ referer: data.referer || null,
++ is_bot: data.is_bot || false,
++ bot_type: data.bot_type || null,
++ device_type: data.device_type || null,
++ browser: data.browser || null,
++ os: data.os || null,
++ });
++ }
++
++ /**
++ * Flush buffered events to database.
++ */
++ flush() {
++ if (this._buffer.length === 0) return;
++ const events = this._buffer.splice(0);
++ try {
++ this._insertMany(events);
++ } catch (err) {
++ console.error(`[Analytics:${this.service}] Flush error:`, err.message);
++ }
++ }
++
++ /**
++ * Run hourly + daily aggregation.
++ */
++ aggregate() {
++ try {
++ this._aggregateHourly();
++ this._aggregateDaily();
++ // Prune raw events older than 90 days
++ this.db.prepare("DELETE FROM analytics_events WHERE created_at < datetime('now', '-90 days')").run();
++ } catch (err) {
++ console.error(`[Analytics:${this.service}] Aggregation error:`, err.message);
++ }
++ }
++
++ _aggregateHourly() {
++ // Aggregate last 2 hours (to catch stragglers)
++ const hours = [];
++ const now = new Date();
++ for (let i = 0; i < 2; i++) {
++ const d = new Date(now.getTime() - i * 3600000);
++ hours.push(d.toISOString().slice(0, 13) + ':00:00');
++ }
++
++ const upsert = this.db.prepare(`
++ INSERT OR REPLACE INTO analytics_hourly
++ (service, hour, pageviews, api_calls, unique_visitors, unique_users, bot_hits, avg_response_ms, error_count, top_paths, top_referers)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ `);
++
++ for (const hour of hours) {
++ const nextHour = new Date(new Date(hour).getTime() + 3600000).toISOString().slice(0, 19).replace('T', ' ');
++ const hourStart = hour.replace('T', ' ');
++
++ const stats = this.db.prepare(`
++ SELECT
++ COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,
++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,
++ COUNT(DISTINCT ip) AS unique_visitors,
++ COUNT(DISTINCT user_id) AS unique_users,
++ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS error_count
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at < ?
++ `).get(this.service, hourStart, nextHour);
++
++ if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) continue;
++
++ const topPaths = this.db.prepare(`
++ SELECT path, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at < ? AND is_bot = 0
++ GROUP BY path ORDER BY cnt DESC LIMIT 10
++ `).all(this.service, hourStart, nextHour);
++
++ const topReferers = this.db.prepare(`
++ SELECT referer, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at < ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0
++ GROUP BY referer ORDER BY cnt DESC LIMIT 10
++ `).all(this.service, hourStart, nextHour);
++
++ upsert.run(
++ this.service, hourStart,
++ stats.pageviews, stats.api_calls, stats.unique_visitors, stats.unique_users,
++ stats.bot_hits, stats.avg_response_ms || 0, stats.error_count,
++ JSON.stringify(topPaths), JSON.stringify(topReferers)
++ );
++ }
++ }
++
++ _aggregateDaily() {
++ // Aggregate last 2 days
++ const days = [];
++ const now = new Date();
++ for (let i = 0; i < 2; i++) {
++ const d = new Date(now.getTime() - i * 86400000);
++ days.push(d.toISOString().slice(0, 10));
++ }
++
++ const upsert = this.db.prepare(`
++ INSERT OR REPLACE INTO analytics_daily
++ (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, bot_hits,
++ avg_response_ms, error_count, top_paths, top_referers, top_countries, device_breakdown, browser_breakdown)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ `);
++
++ for (const date of days) {
++ const dayStart = date + ' 00:00:00';
++ const dayEnd = date + ' 23:59:59';
++
++ const stats = this.db.prepare(`
++ SELECT
++ COUNT(*) FILTER (WHERE event_type = 'pageview') AS pageviews,
++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_calls,
++ COUNT(DISTINCT ip) AS unique_visitors,
++ COUNT(DISTINCT user_id) AS unique_users,
++ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS error_count
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ?
++ `).get(this.service, dayStart, dayEnd);
++
++ if (!stats || (stats.pageviews === 0 && stats.api_calls === 0)) continue;
++
++ const topPaths = this.db.prepare(`
++ SELECT path, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0
++ GROUP BY path ORDER BY cnt DESC LIMIT 15
++ `).all(this.service, dayStart, dayEnd);
++
++ const topReferers = this.db.prepare(`
++ SELECT referer, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND referer != '' AND referer IS NOT NULL AND is_bot = 0
++ GROUP BY referer ORDER BY cnt DESC LIMIT 10
++ `).all(this.service, dayStart, dayEnd);
++
++ const topCountries = this.db.prepare(`
++ SELECT country, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND country IS NOT NULL AND is_bot = 0
++ GROUP BY country ORDER BY cnt DESC LIMIT 10
++ `).all(this.service, dayStart, dayEnd);
++
++ const devices = this.db.prepare(`
++ SELECT device_type, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0
++ GROUP BY device_type ORDER BY cnt DESC
++ `).all(this.service, dayStart, dayEnd);
++
++ const browsers = this.db.prepare(`
++ SELECT browser, COUNT(*) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND is_bot = 0
++ GROUP BY browser ORDER BY cnt DESC
++ `).all(this.service, dayStart, dayEnd);
++
++ // New users (first seen this day)
++ const newUsers = this.db.prepare(`
++ SELECT COUNT(DISTINCT user_id) as cnt FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND created_at <= ? AND user_id IS NOT NULL
++ AND user_id NOT IN (
++ SELECT DISTINCT user_id FROM analytics_events
++ WHERE service = ? AND created_at < ? AND user_id IS NOT NULL
++ )
++ `).get(this.service, dayStart, dayEnd, this.service, dayStart);
++
++ upsert.run(
++ this.service, date, stats.pageviews, stats.api_calls,
++ stats.unique_visitors, stats.unique_users,
++ newUsers?.cnt || 0, stats.bot_hits,
++ stats.avg_response_ms || 0, stats.error_count,
++ JSON.stringify(topPaths), JSON.stringify(topReferers),
++ JSON.stringify(topCountries), JSON.stringify(devices),
++ JSON.stringify(browsers)
++ );
++ }
++ }
++
++ /**
++ * Get analytics data for the admin dashboard.
++ * @param {object} options
++ * @param {number} options.days - Number of days to look back (default 30)
++ * @param {number} options.hours - If set, overrides days with an hour-based range (e.g. 1, 12, 24, 72)
++ * @param {string} options.service - Service name override
++ */
++ getStats(options = {}) {
++ const { days = 30, hours, service } = options;
++ const svc = service || this.service;
++
++ // Compute cutoff timestamp for raw event queries
++ const rangeMs = hours ? hours * 3600000 : days * 86400000;
++ const rangeCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 19).replace('T', ' ');
++ const isSubDay = hours && hours < 24;
++ const effectiveDays = hours ? hours / 24 : days;
++
++ // Daily trend (use daily aggregates for multi-day, raw events for sub-day)
++ let daily = [];
++ if (!isSubDay) {
++ const dateCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 10);
++ daily = this.db.prepare(`
++ SELECT * FROM analytics_daily
++ WHERE service = ? AND date >= ?
++ ORDER BY date ASC
++ `).all(svc, dateCutoff);
++ }
++
++ // Hourly trend (always useful — scope to the requested range)
++ const hourlyLimit = hours ? Math.min(hours, 168) : 24;
++ const hourlyCutoff = new Date(Date.now() - hourlyLimit * 3600000).toISOString().slice(0, 19).replace('T', ' ');
++ const hourly = this.db.prepare(`
++ SELECT * FROM analytics_hourly
++ WHERE service = ? AND hour >= ?
++ ORDER BY hour ASC
++ `).all(svc, hourlyCutoff);
++
++ // For sub-day ranges, generate fine-grained time buckets from raw events
++ let timeBuckets = [];
++ if (isSubDay) {
++ // 5-minute buckets for ≤1h, 15-min for ≤12h, 30-min for ≤24h
++ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
++ timeBuckets = this.db.prepare(`
++ SELECT
++ strftime('%Y-%m-%d %H:', created_at) ||
++ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,
++ COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS pageviews,
++ COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS api_calls,
++ COUNT(DISTINCT ip) FILTER (WHERE is_bot = 0) AS unique_visitors,
++ COUNT(*) FILTER (WHERE is_bot = 1) AS bot_hits,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ GROUP BY bucket
++ ORDER BY bucket ASC
++ `).all(svc, rangeCutoff);
++ }
++
++ // Summary totals — for sub-day ranges, compute from raw events
++ let summary;
++ if (hours && hours < 48) {
++ summary = this.db.prepare(`
++ SELECT
++ COUNT(*) FILTER (WHERE event_type = 'pageview' AND is_bot = 0) AS total_pageviews,
++ COUNT(*) FILTER (WHERE event_type = 'api_call' AND is_bot = 0) AS total_api_calls,
++ COUNT(DISTINCT ip) FILTER (WHERE is_bot = 0) AS total_unique_visitors,
++ COUNT(DISTINCT user_id) FILTER (WHERE is_bot = 0 AND user_id IS NOT NULL) AS total_unique_users,
++ COUNT(*) FILTER (WHERE is_bot = 1) AS total_bot_hits,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS total_errors,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_response_ms
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ `).get(svc, rangeCutoff);
++ } else {
++ const dateCutoff = new Date(Date.now() - rangeMs).toISOString().slice(0, 10);
++ summary = this.db.prepare(`
++ SELECT
++ SUM(pageviews) as total_pageviews,
++ SUM(api_calls) as total_api_calls,
++ SUM(unique_visitors) as total_unique_visitors,
++ SUM(unique_users) as total_unique_users,
++ SUM(bot_hits) as total_bot_hits,
++ SUM(error_count) as total_errors,
++ CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms
++ FROM analytics_daily
++ WHERE service = ? AND date >= ?
++ `).get(svc, dateCutoff);
++ }
++
++ // Real-time (last 5 min)
++ const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');
++ const realtime = this.db.prepare(`
++ SELECT
++ COUNT(*) as requests,
++ COUNT(DISTINCT ip) as visitors,
++ COUNT(*) FILTER (WHERE is_bot = 1) as bots
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ `).get(svc, fiveMinAgo);
++
++ // Top pages (human only)
++ const topPages = this.db.prepare(`
++ SELECT path, COUNT(*) as hits, COUNT(DISTINCT ip) as visitors
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY path ORDER BY hits DESC LIMIT 20
++ `).all(svc, rangeCutoff);
++
++ // Top referers
++ const topReferers = this.db.prepare(`
++ SELECT referer, COUNT(*) as hits
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ AND referer IS NOT NULL AND referer != '' AND is_bot = 0
++ GROUP BY referer ORDER BY hits DESC LIMIT 15
++ `).all(svc, rangeCutoff);
++
++ // Bot breakdown
++ const botBreakdown = this.db.prepare(`
++ SELECT bot_type, COUNT(*) as hits
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 1
++ GROUP BY bot_type ORDER BY hits DESC LIMIT 15
++ `).all(svc, rangeCutoff);
++
++ // Status code distribution
++ const statusCodes = this.db.prepare(`
++ SELECT
++ CASE
++ WHEN status_code >= 200 AND status_code < 300 THEN '2xx'
++ WHEN status_code >= 300 AND status_code < 400 THEN '3xx'
++ WHEN status_code >= 400 AND status_code < 500 THEN '4xx'
++ WHEN status_code >= 500 THEN '5xx'
++ ELSE 'other'
++ END as group_code,
++ COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ GROUP BY group_code ORDER BY cnt DESC
++ `).all(svc, rangeCutoff);
++
++ // Device/browser/OS breakdown
++ const deviceBreakdown = this.db.prepare(`
++ SELECT device_type, COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY device_type ORDER BY cnt DESC
++ `).all(svc, rangeCutoff);
++
++ const browserBreakdown = this.db.prepare(`
++ SELECT browser, COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY browser ORDER BY cnt DESC
++ `).all(svc, rangeCutoff);
++
++ const osBreakdown = this.db.prepare(`
++ SELECT os, COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY os ORDER BY cnt DESC
++ `).all(svc, rangeCutoff);
++
++ // Country breakdown
++ const countryBreakdown = this.db.prepare(`
++ SELECT country, COUNT(*) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ?
++ AND country IS NOT NULL AND is_bot = 0
++ GROUP BY country ORDER BY cnt DESC LIMIT 20
++ `).all(svc, rangeCutoff);
++
++ // ── Enhanced Metrics ─────────────────────────────────
++
++ // Response time percentiles (p50, p90, p95, p99)
++ let responsePercentiles = {};
++ try {
++ const rtRows = this.db.prepare(`
++ SELECT response_time_ms FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0
++ ORDER BY response_time_ms ASC
++ `).all(svc, rangeCutoff);
++ if (rtRows.length > 0) {
++ const vals = rtRows.map(r => r.response_time_ms);
++ const pct = (p) => vals[Math.min(Math.floor(vals.length * p), vals.length - 1)];
++ responsePercentiles = { p50: pct(0.5), p90: pct(0.9), p95: pct(0.95), p99: pct(0.99), max: vals[vals.length - 1] };
++ }
++ } catch {}
++
++ // Bandwidth estimation (response_time_ms * rough factor isn't great,
++ // but we can count requests and estimate from content-length if tracked;
++ // for now, count total requests * avg page weight heuristic)
++ let bandwidth = {};
++ try {
++ const bwData = this.db.prepare(`
++ SELECT
++ COUNT(*) as total_requests,
++ COUNT(*) FILTER (WHERE event_type = 'pageview') AS page_requests,
++ COUNT(*) FILTER (WHERE event_type = 'api_call') AS api_requests,
++ SUM(CASE WHEN event_type = 'pageview' THEN 50000 ELSE 2000 END) AS estimated_bytes
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ `).get(svc, rangeCutoff);
++ if (bwData) {
++ bandwidth = {
++ total_requests: bwData.total_requests || 0,
++ page_requests: bwData.page_requests || 0,
++ api_requests: bwData.api_requests || 0,
++ estimated_bytes: bwData.estimated_bytes || 0,
++ };
++ }
++ } catch {}
++
++ // Authenticated vs anonymous sessions
++ let authBreakdown = {};
++ try {
++ authBreakdown = this.db.prepare(`
++ SELECT
++ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS authenticated,
++ COUNT(*) FILTER (WHERE user_id IS NULL) AS anonymous,
++ COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL) AS unique_authenticated_users,
++ COUNT(DISTINCT session_id) FILTER (WHERE session_id IS NOT NULL) AS total_sessions
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ `).get(svc, rangeCutoff) || {};
++ } catch {}
++
++ // Error rate over time (hourly for multi-day, bucketed for sub-day)
++ let errorTrend = [];
++ try {
++ if (isSubDay) {
++ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
++ errorTrend = this.db.prepare(`
++ SELECT
++ strftime('%Y-%m-%d %H:', created_at) ||
++ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,
++ COUNT(*) AS total,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS errors,
++ COUNT(*) FILTER (WHERE status_code >= 500) AS server_errors
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY bucket ORDER BY bucket ASC
++ `).all(svc, rangeCutoff);
++ } else {
++ errorTrend = this.db.prepare(`
++ SELECT hour as bucket,
++ (pageviews + api_calls) as total,
++ error_count as errors
++ FROM analytics_hourly
++ WHERE service = ? AND hour >= ?
++ ORDER BY hour ASC
++ `).all(svc, hourlyCutoff);
++ }
++ } catch {}
++
++ // Peak hours (which hours of day get most traffic)
++ let peakHours = [];
++ try {
++ peakHours = this.db.prepare(`
++ SELECT
++ CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,
++ COUNT(*) AS hits,
++ COUNT(DISTINCT ip) AS visitors,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY hour_of_day
++ ORDER BY hour_of_day ASC
++ `).all(svc, rangeCutoff);
++ } catch {}
++
++ // Top API endpoints (separate from top pages)
++ let topApiEndpoints = [];
++ try {
++ topApiEndpoints = this.db.prepare(`
++ SELECT path, method, COUNT(*) as hits,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,
++ COUNT(*) FILTER (WHERE status_code >= 400) AS errors
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND event_type = 'api_call' AND is_bot = 0
++ GROUP BY path, method ORDER BY hits DESC LIMIT 20
++ `).all(svc, rangeCutoff);
++ } catch {}
++
++ // Unique sessions count
++ let sessionCount = 0;
++ try {
++ const sc = this.db.prepare(`
++ SELECT COUNT(DISTINCT session_id) as cnt
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND session_id IS NOT NULL AND is_bot = 0
++ `).get(svc, rangeCutoff);
++ sessionCount = sc?.cnt || 0;
++ } catch {}
++
++ // New vs returning visitors (first-time IPs in this period vs seen before)
++ let visitorTypes = {};
++ try {
++ visitorTypes = this.db.prepare(`
++ SELECT
++ COUNT(DISTINCT ip) FILTER (
++ WHERE ip NOT IN (
++ SELECT DISTINCT ip FROM analytics_events
++ WHERE service = ? AND created_at < ? AND is_bot = 0
++ )
++ ) AS new_visitors,
++ COUNT(DISTINCT ip) FILTER (
++ WHERE ip IN (
++ SELECT DISTINCT ip FROM analytics_events
++ WHERE service = ? AND created_at < ? AND is_bot = 0
++ )
++ ) AS returning_visitors
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ `).get(svc, rangeCutoff, svc, rangeCutoff, svc, rangeCutoff) || {};
++ } catch {}
++
++ // Slowest endpoints
++ let slowestEndpoints = [];
++ try {
++ slowestEndpoints = this.db.prepare(`
++ SELECT path, method,
++ COUNT(*) as hits,
++ CAST(AVG(response_time_ms) AS INTEGER) AS avg_ms,
++ MAX(response_time_ms) AS max_ms
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND response_time_ms IS NOT NULL AND is_bot = 0
++ GROUP BY path, method
++ HAVING hits >= 3
++ ORDER BY avg_ms DESC LIMIT 10
++ `).all(svc, rangeCutoff);
++ } catch {}
++
++ // Authenticated vs anonymous time trend
++ let authTrend = [];
++ try {
++ if (isSubDay) {
++ const bucketMin = hours <= 1 ? 5 : hours <= 12 ? 15 : 30;
++ authTrend = this.db.prepare(`
++ SELECT
++ strftime('%Y-%m-%d %H:', created_at) ||
++ CAST((CAST(strftime('%M', created_at) AS INTEGER) / ${bucketMin}) * ${bucketMin} AS TEXT) as bucket,
++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NOT NULL) AS auth_visitors,
++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NULL) AS anon_visitors,
++ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS auth_hits,
++ COUNT(*) FILTER (WHERE user_id IS NULL) AS anon_hits
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY bucket ORDER BY bucket ASC
++ `).all(svc, rangeCutoff);
++ } else {
++ authTrend = this.db.prepare(`
++ SELECT
++ strftime('%Y-%m-%d', created_at) AS bucket,
++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NOT NULL) AS auth_visitors,
++ COUNT(DISTINCT ip) FILTER (WHERE user_id IS NULL) AS anon_visitors,
++ COUNT(*) FILTER (WHERE user_id IS NOT NULL) AS auth_hits,
++ COUNT(*) FILTER (WHERE user_id IS NULL) AS anon_hits
++ FROM analytics_events
++ WHERE service = ? AND created_at >= ? AND is_bot = 0
++ GROUP BY bucket ORDER BY bucket ASC
++ `).all(svc, rangeCutoff);
++ }
++ } catch {}
++
++ return {
++ service: svc,
++ period_days: effectiveDays,
++ period_hours: hours || null,
++ summary: summary || {},
++ realtime: realtime || {},
++ daily,
++ hourly,
++ timeBuckets,
++ topPages,
++ topReferers,
++ botBreakdown,
++ statusCodes,
++ deviceBreakdown,
++ browserBreakdown,
++ osBreakdown,
++ countryBreakdown,
++ // Enhanced metrics
++ responsePercentiles,
++ bandwidth,
++ authBreakdown,
++ errorTrend,
++ peakHours,
++ topApiEndpoints,
++ sessionCount,
++ visitorTypes,
++ slowestEndpoints,
++ authTrend,
++ };
++ }
++
++ /**
++ * Get a cross-service overview (for the admin panel).
++ */
++ getOverview(days = 30) {
++ const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
++
++ const services = this.db.prepare(`
++ SELECT
++ service,
++ SUM(pageviews) as pageviews,
++ SUM(api_calls) as api_calls,
++ SUM(unique_visitors) as unique_visitors,
++ SUM(unique_users) as unique_users,
++ SUM(bot_hits) as bot_hits,
++ SUM(error_count) as errors,
++ CAST(AVG(avg_response_ms) AS INTEGER) as avg_response_ms
++ FROM analytics_daily
++ WHERE date >= ?
++ GROUP BY service
++ ORDER BY pageviews DESC
++ `).all(cutoff);
++
++ // Totals across all services
++ const totals = this.db.prepare(`
++ SELECT
++ SUM(pageviews) as pageviews,
++ SUM(api_calls) as api_calls,
++ SUM(unique_visitors) as unique_visitors,
++ SUM(bot_hits) as bot_hits,
++ SUM(error_count) as errors
++ FROM analytics_daily
++ WHERE date >= ?
++ `).get(cutoff);
++
++ // Daily trend (all services combined)
++ const dailyTrend = this.db.prepare(`
++ SELECT date,
++ SUM(pageviews) as pageviews,
++ SUM(api_calls) as api_calls,
++ SUM(unique_visitors) as unique_visitors,
++ SUM(bot_hits) as bot_hits
++ FROM analytics_daily
++ WHERE date >= ?
++ GROUP BY date
++ ORDER BY date ASC
++ `).all(cutoff);
++
++ // Real-time all services
++ const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');
++ const realtime = this.db.prepare(`
++ SELECT
++ service,
++ COUNT(*) as requests,
++ COUNT(DISTINCT ip) as visitors,
++ COUNT(*) FILTER (WHERE is_bot = 1) as bots
++ FROM analytics_events
++ WHERE created_at >= ?
++ GROUP BY service
++ `).all(fiveMinAgo);
++
++ return {
++ period_days: days,
++ totals: totals || {},
++ services,
++ dailyTrend,
++ realtime,
++ };
++ }
++
++ /**
++ * Get bot analysis data.
++ */
++ getBotAnalysis(days = 30) {
++ const daysCast = Math.min(Math.max(1, days), 365);
++
++ // Top bot IPs
++ const topBotIPs = this.db.prepare(`
++ SELECT ip, COUNT(*) as hits, bot_type, user_agent,
++ MIN(created_at) as first_seen, MAX(created_at) as last_seen
++ FROM analytics_events
++ WHERE is_bot = 1 AND created_at >= datetime('now', '-' || ? || ' days')
++ GROUP BY ip ORDER BY hits DESC LIMIT 25
++ `).all(daysCast);
++
++ // Bot vs human ratio over time
++ const botTrend = this.db.prepare(`
++ SELECT date,
++ SUM(pageviews) + SUM(api_calls) as human_hits,
++ SUM(bot_hits) as bot_hits
++ FROM analytics_daily
++ WHERE date >= date('now', '-' || ? || ' days')
++ GROUP BY date ORDER BY date ASC
++ `).all(daysCast);
++
++ // Bot type breakdown
++ const botTypes = this.db.prepare(`
++ SELECT bot_type, COUNT(*) as hits, COUNT(DISTINCT ip) as unique_ips
++ FROM analytics_events
++ WHERE is_bot = 1 AND created_at >= datetime('now', '-' || ? || ' days')
++ GROUP BY bot_type ORDER BY hits DESC
++ `).all(daysCast);
++
++ // Suspicious behavior patterns
++ const suspiciousIPs = this.db.prepare(`
++ SELECT ip, COUNT(*) as total_hits,
++ COUNT(*) FILTER (WHERE status_code >= 400) as error_hits,
++ COUNT(DISTINCT path) as unique_paths,
++ MIN(created_at) as first_seen,
++ MAX(created_at) as last_seen
++ FROM analytics_events
++ WHERE created_at >= datetime('now', '-7 days') AND is_bot = 0
++ GROUP BY ip
++ HAVING total_hits > 500
++ ORDER BY total_hits DESC LIMIT 20
++ `).all();
++
++ return {
++ topBotIPs,
++ botTrend,
++ botTypes,
++ suspiciousIPs,
++ };
++ }
++
++ /**
++ * Destroy tracker (clean up timers).
++ */
++ destroy() {
++ this.flush();
++ if (this._flushInterval) clearInterval(this._flushInterval);
++ if (this._aggregateInterval) clearInterval(this._aggregateInterval);
++ }
++}
++
++
++module.exports = {
++ AnalyticsTracker,
++ classifyRequest,
++ parseUserAgent,
++ ANALYTICS_SCHEMA,
++ BOT_USER_AGENTS,
++ SUSPICIOUS_PATTERNS,
++};
ddiff --git a/vendor/openvibe-shared/auth-client.js b/vendor/openvibe-shared/auth-client.js
nnew file mode 100644
iindex 0000000..7a53335
---- /dev/null
++++ b/vendor/openvibe-shared/auth-client.js
@@@ -0,0 +1,164 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — OAuth2 Auth Client
++// Used by OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and OpenVibe.Media
++// to authenticate users via the central OpenVibe.Network SSO.
++// ═══════════════════════════════════════════════════════════════
++
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const { BRAND } = require('./brand');
++
++const AUTH_BASE = BRAND.urls.login;
++
++class OpenVibeAuthClient {
++ /**
++ * @param {Object} opts
++ * @param {string} opts.clientId - OAuth2 client_id (e.g. 'live')
++ * @param {string} opts.clientSecret - OAuth2 client_secret
++ * @param {string} opts.redirectUri - Callback URL (e.g. 'https://openvibe.live/auth/callback')
++ * @param {string} opts.publicKey - RS256 public key for JWT verification (PEM)
++ * @param {string} [opts.authBase] - Override auth server URL (default: openvibe.network)
++ * @param {string} [opts.internalBase] - Internal auth API (default: http://127.0.0.1:4000)
++ */
++ constructor(opts) {
++ this.clientId = opts.clientId;
++ this.clientSecret = opts.clientSecret;
++ this.redirectUri = opts.redirectUri;
++ this.publicKey = opts.publicKey;
++ this.authBase = opts.authBase || AUTH_BASE;
++ this.internalBase = opts.internalBase || 'http://127.0.0.1:4000';
++ }
++
++ /**
++ * Generate an authorization URL for the OAuth2 login redirect.
++ * @param {string} [scope='profile theme'] - Requested scopes
++ * @returns {{ url: string, state: string }}
++ */
++ getAuthorizationUrl(scope = 'profile theme') {
++ const state = crypto.randomBytes(16).toString('hex');
++ const params = new URLSearchParams({
++ client_id: this.clientId,
++ redirect_uri: this.redirectUri,
++ response_type: 'code',
++ scope,
++ state,
++ });
++ return {
++ url: `${this.authBase}/oauth/authorize?${params.toString()}`,
++ state,
++ };
++ }
++
++ /**
++ * Exchange an authorization code for tokens.
++ * @param {string} code - The authorization code from the callback
++ * @returns {Promise<{ access_token: string, refresh_token: string, user: Object }>}
++ */
++ async exchangeCode(code) {
++ const res = await fetch(`${this.authBase}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ grant_type: 'authorization_code',
++ client_id: this.clientId,
++ client_secret: this.clientSecret,
++ redirect_uri: this.redirectUri,
++ code,
++ }),
++ });
++ if (!res.ok) {
++ const err = await res.json().catch(() => ({}));
++ throw new Error(err.error || `Token exchange failed: ${res.status}`);
++ }
++ return res.json();
++ }
++
++ /**
++ * Refresh an access token using a refresh token.
++ * @param {string} refreshToken
++ * @returns {Promise<{ access_token: string, refresh_token: string }>}
++ */
++ async refreshAccessToken(refreshToken) {
++ const res = await fetch(`${this.authBase}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ grant_type: 'refresh_token',
++ client_id: this.clientId,
++ client_secret: this.clientSecret,
++ refresh_token: refreshToken,
++ }),
++ });
++ if (!res.ok) {
++ const err = await res.json().catch(() => ({}));
++ throw new Error(err.error || `Token refresh failed: ${res.status}`);
++ }
++ return res.json();
++ }
++
++ /**
++ * Verify and decode a JWT access token.
++ * Uses the RS256 public key from the auth server.
++ * @param {string} token
++ * @returns {Object|null} Decoded payload or null if invalid
++ */
++ verifyToken(token) {
++ try {
++ // issuer is the tools/SSO public URL — derive from authBase to support white-label
++ const issuer = this.authBase || this.issuer;
++ return jwt.verify(token, this.publicKey, {
++ algorithms: ['RS256'],
++ issuer,
++ });
++ } catch {
++ return null;
++ }
++ }
++
++ /**
++ * Verify a token via the internal API (server-to-server).
++ * Useful as a fallback or for getting fresh user data.
++ * @param {string} token
++ * @param {string} internalKey - X-Internal-Key header value
++ * @returns {Promise<Object|null>}
++ */
++ async verifyTokenInternal(token, internalKey) {
++ try {
++ const res = await fetch(`${this.internalBase}/internal/verify-token`, {
++ method: 'POST',
++ headers: {
++ 'Content-Type': 'application/json',
++ 'X-Internal-Key': internalKey,
++ },
++ body: JSON.stringify({ token }),
++ });
++ if (!res.ok) return null;
++ return res.json();
++ } catch {
++ return null;
++ }
++ }
++
++ /**
++ * Get fresh user profile data via internal API.
++ * @param {number} userId
++ * @param {string} internalKey
++ * @returns {Promise<Object|null>}
++ */
++ async getUserProfile(userId, internalKey) {
++ try {
++ const res = await fetch(`${this.internalBase}/internal/user/${userId}/sync`, {
++ method: 'POST',
++ headers: { 'X-Internal-Key': internalKey },
++ });
++ if (!res.ok) return null;
++ return res.json();
++ } catch {
++ return null;
++ }
++ }
++}
++
++module.exports = { OpenVibeAuthClient };
ddiff --git a/vendor/openvibe-shared/brand.js b/vendor/openvibe-shared/brand.js
nnew file mode 100644
iindex 0000000..61ab011
---- /dev/null
++++ b/vendor/openvibe-shared/brand.js
@@@ -0,0 +1,181 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Brand Constants
++// Shared across all OpenVibe services for consistent branding.
++// Values are seeded with OpenVibe defaults and can be
++// overridden at runtime via the URL registry / admin panel.
++// ═══════════════════════════════════════════════════════════════
++
++// ── Default (OpenVibe) values ────────────────────────────
++// These are the canonical defaults for the OpenVibe deployment.
++// White-label installs override via the URL registry.
++
++const BRAND_DEFAULTS = Object.freeze({
++ networkName: 'OpenVibe',
++ networkServiceName: 'OpenVibe.Network',
++ toolsServiceName: 'OpenVibe.Tools',
++ streamerServiceName:'OpenVibe.Live',
++ gamesServiceName: 'OpenVibe.Games',
++ mediaServiceName: 'OpenVibe.Media',
++ tagline: 'One Account. All of OpenVibe.',
++ campTagline: 'Free & Open Live Streaming',
++ toolsSubdomainBase: 'openvibe.tools',
++ discord: 'https://discord.gg/M6MuRUaeJj',
++ github: 'https://github.com/OpenVibe.Live',
++});
++
++function getDefaultBrandUrls(env = process.env) {
++ return {
++ network: env.OV_NETWORK_URL || 'https://openvibe.network',
++ login: env.LOGIN_URL || env.OV_NETWORK_LOGIN_URL || env.OV_NETWORK_URL || 'https://openvibe.network',
++ tools: env.OV_TOOLS_URL || 'https://openvibe.tools',
++ live: env.OV_LIVE_URL || 'https://openvibe.live',
++ games: env.OV_GAMES_URL || 'https://openvibe.games',
++ media: env.OV_MEDIA_URL || 'https://openvibe.media',
++ community:env.OV_COMMUNITY_URL || 'https://openvibe.community',
++ // Tool satellites (Host-routed under openvibe.tools)
++ maps: env.OV_MAPS_URL || 'https://maps.openvibe.tools',
++ food: env.OV_FOOD_URL || 'https://food.openvibe.tools',
++ img: env.OV_IMG_URL || 'https://img.openvibe.tools',
++ yt: env.OV_YT_URL || 'https://yt.openvibe.tools',
++ audio: env.OV_AUDIO_URL || 'https://audio.openvibe.tools',
++ text: env.OV_TEXT_URL || 'https://text.openvibe.tools',
++ logo: env.OV_LOGO_URL || 'https://logo.openvibe.tools',
++ docs: env.OV_DOCS_URL || 'https://docs.openvibe.tools',
++ net: env.OV_NET_URL || 'https://net.openvibe.tools',
++ dev: env.OV_DEV_URL || 'https://dev.openvibe.tools',
++ pastes: env.OV_PASTES_URL || 'https://pastes.openvibe.tools',
++ // Legacy aliases (older callers) — same targets as above
++ streamer: env.OV_LIVE_URL || 'https://openvibe.live',
++ quest: env.OV_GAMES_URL || 'https://openvibe.games',
++ discord: BRAND_DEFAULTS.discord,
++ github: BRAND_DEFAULTS.github,
++ };
++}
++
++const DEFAULT_URLS = Object.freeze(getDefaultBrandUrls());
++
++function resolveBrandUrls(overrides = {}, env = process.env) {
++ return Object.freeze({
++ ...getDefaultBrandUrls(env),
++ ...overrides,
++ });
++}
++
++/**
++ * Build a brand object from a resolved URL registry map.
++ * Call this server-side when the canonical registry has been loaded.
++ *
++ * @param {Object} registry - resolved registry map from resolveRegistryValues()
++ * @param {Object} [env] - process.env fallback
++ * @returns {Object} brand object with names, urls, colors, services, oauth
++ */
++function buildBrandFromRegistry(registry = {}, env = process.env) {
++ const get = (key) => (registry[key]?.value) || null;
++
++ const networkName = get('NETWORK_NAME') || BRAND_DEFAULTS.networkName;
++ const toolsServiceName = get('TOOLS_SERVICE_NAME') || BRAND_DEFAULTS.toolsServiceName;
++ const streamerServiceName= get('STREAMER_SERVICE_NAME')|| BRAND_DEFAULTS.streamerServiceName;
++ const gamesServiceName = get('GAMES_SERVICE_NAME') || BRAND_DEFAULTS.gamesServiceName;
++ const mediaServiceName = get('MEDIA_SERVICE_NAME') || BRAND_DEFAULTS.mediaServiceName;
++ const defaults = getDefaultBrandUrls(env);
++ const networkUrl = get('OV_NETWORK_URL') || defaults.network;
++ const liveUrl = get('OV_LIVE_URL') || defaults.live;
++ const toolsUrl = get('OV_TOOLS_URL') || defaults.tools;
++ const gamesUrl = get('OV_GAMES_URL') || defaults.games;
++ const mediaUrl = get('OV_MEDIA_URL') || defaults.media;
++
++ const urls = resolveBrandUrls({
++ network: networkUrl,
++ login: get('OV_NETWORK_LOGIN_URL') || networkUrl,
++ live: liveUrl,
++ streamer: liveUrl,
++ tools: toolsUrl,
++ games: gamesUrl,
++ quest: gamesUrl,
++ media: mediaUrl,
++ }, env);
++
++ // Build service list using registry URLs where available
++ const services = [
++ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },
++ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },
++ { id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },
++ { id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },
++ { id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },
++ ];
++
++ return Object.freeze({
++ name: networkName,
++ toolsName: toolsServiceName,
++ streamerName:streamerServiceName,
++ gamesName: gamesServiceName,
++ mediaName: mediaServiceName,
++ tagline: BRAND_DEFAULTS.tagline,
++ campTagline: BRAND_DEFAULTS.campTagline,
++ toolsSubdomainBase: get('TOOLS_SUBDOMAIN_BASE') || BRAND_DEFAULTS.toolsSubdomainBase,
++ urls: Object.freeze(urls),
++ colors: BRAND.colors,
++ oauth: BRAND.oauth,
++ services: Object.freeze(services),
++ });
++}
++
++const BRAND = Object.freeze({
++ name: BRAND_DEFAULTS.networkName,
++ tagline: BRAND_DEFAULTS.tagline,
++ campTagline: BRAND_DEFAULTS.campTagline,
++
++ urls: resolveBrandUrls(),
++
++ // Palette per CONTRACTS.md
++ colors: Object.freeze({
++ accent: '#8b5cf6',
++ accentLight: '#a78bfa',
++ accentDark: '#6d28d9',
++ secondary: '#22d3ee',
++ bgDark: '#131318',
++ flame: '#a78bfa', // legacy alias for accentLight
++ signalRed: '#e74c3c',
++ success: '#2ecc71',
++ warning: '#f39c12',
++ danger: '#e74c3c',
++ info: '#3498db',
++ }),
++
++ // OAuth2 client IDs for each service (seeded in the Network DB)
++ oauth: Object.freeze({
++ live: 'live',
++ tools: 'tools',
++ games: 'games',
++ media: 'media',
++ }),
++
++ // Primary services in the network (for dashboard display, health checks, etc.)
++ // Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.
++ services: Object.freeze([
++ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },
++ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },
++ { id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },
++ { id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },
++ { id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },
++ ]),
++
++ // Tool sub-brands hosted by OpenVibe.Tools (<Name>.OpenVibe naming)
++ toolBrands: Object.freeze([
++ { id: 'net', name: 'Net.OpenVibe', url: 'https://net.openvibe.tools', description: 'Network & Internet Diagnostics' },
++ { id: 'dev', name: 'Dev.OpenVibe', url: 'https://dev.openvibe.tools', description: 'Developer & SEO Tools' },
++ { id: 'paste', name: 'Paste.OpenVibe', url: 'https://pastes.openvibe.tools',description: 'Code & Text Sharing' },
++ { id: 'maps', name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools', description: 'Camp & Shelter Locator' },
++ { id: 'food', name: 'Food.OpenVibe', url: 'https://food.openvibe.tools', description: 'Food Resource Finder' },
++ { id: 'img', name: 'Img.OpenVibe', url: 'https://img.openvibe.tools', description: 'Image Converter & Tools' },
++ { id: 'yt', name: 'YT.OpenVibe', url: 'https://yt.openvibe.tools', description: 'YouTube Downloader' },
++ { id: 'audio', name: 'Audio.OpenVibe', url: 'https://audio.openvibe.tools', description: 'Audio Converter & Effects' },
++ { id: 'text', name: 'Text.OpenVibe', url: 'https://text.openvibe.tools', description: 'Text Generation & Unicode' },
++ { id: 'logo', name: 'Logo.OpenVibe', url: 'https://logo.openvibe.tools', description: 'Logo & Title Card Maker' },
++ { id: 'docs', name: 'Docs.OpenVibe', url: 'https://docs.openvibe.tools', description: 'PDF & Document Tools' },
++ ]),
++});
++
++module.exports = { BRAND, BRAND_DEFAULTS, resolveBrandUrls, buildBrandFromRegistry };
ddiff --git a/vendor/openvibe-shared/builtin-themes.js b/vendor/openvibe-shared/builtin-themes.js
nnew file mode 100644
iindex 0000000..073ddae
---- /dev/null
++++ b/vendor/openvibe-shared/builtin-themes.js
@@@ -0,0 +1,878 @@
++'use strict';
++
++const RAW_BUILTIN_THEMES = [
++ {
++ name: 'Vibe',
++ slug: 'vibe',
++ mode: 'dark',
++ description: 'The original OpenVibe.Live look — warm amber tones by firelight.',
++ tags: ['default', 'warm', 'cozy'],
++ 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': '#666',
++ '--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: 'Deep blue-black with cool steel accents.',
++ 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': '#64748b',
++ '--accent': '#6366f1',
++ '--accent-light': '#818cf8',
++ '--accent-dark': '#4f46e5',
++ '--border': '#1e293b',
++ '--border-light': '#334155',
++ },
++ },
++ {
++ name: 'Forest',
++ slug: 'forest',
++ mode: 'dark',
++ description: 'Deep greens and earthy tones — nature at night.',
++ 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': '#5a7a5a',
++ '--accent': '#4ade80',
++ '--accent-light': '#86efac',
++ '--accent-dark': '#22c55e',
++ '--border': '#1a3a1a',
++ '--border-light': '#2a4a2a',
++ },
++ },
++ {
++ name: 'Neon Tokyo',
++ slug: 'neon-tokyo',
++ mode: 'dark',
++ description: 'Cyberpunk neon pink/purple over dark 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': '#6a5a80',
++ '--accent': '#ff006e',
++ '--accent-light': '#ff4d94',
++ '--accent-dark': '#c9005a',
++ '--border': '#2a1a3a',
++ '--border-light': '#3a2a4a',
++ },
++ },
++ {
++ name: 'Dracula',
++ slug: 'dracula',
++ mode: 'dark',
++ description: 'The beloved Dracula color scheme.',
++ 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': '#6272a4',
++ '--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: 'Classic Monokai developer 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': '#75715e',
++ '--accent': '#a6e22e',
++ '--accent-light': '#c4ff50',
++ '--accent-dark': '#7dac1c',
++ '--border': '#49483e',
++ '--border-light': '#5b5a50',
++ '--danger': '#f92672',
++ '--warning': '#e6db74',
++ '--info': '#66d9ef',
++ },
++ },
++ {
++ name: 'Ocean Deep',
++ slug: 'ocean-deep',
++ mode: 'dark',
++ description: 'Dark ocean depths with teal highlights.',
++ 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': '#4a7080',
++ '--accent': '#00bcd4',
++ '--accent-light': '#4dd0e1',
++ '--accent-dark': '#0097a7',
++ '--border': '#1a3040',
++ '--border-light': '#2a4050',
++ },
++ },
++ {
++ name: 'Sunset',
++ slug: 'sunset',
++ mode: 'dark',
++ description: 'Warm orange-to-pink gradient vibes at dusk.',
++ 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': '#7a5a50',
++ '--accent': '#ff6b35',
++ '--accent-light': '#ff8f5e',
++ '--accent-dark': '#d04a1a',
++ '--border': '#3a2020',
++ '--border-light': '#4a3030',
++ },
++ },
++ {
++ name: 'Arctic',
++ slug: 'arctic',
++ mode: 'dark',
++ description: 'Frozen blues with crisp white accents.',
++ tags: ['cool', 'blue', 'clean'],
++ variables: {
++ '--bg-primary': '#0c1220',
++ '--bg-secondary': '#101828',
++ '--bg-tertiary': '#182030',
++ '--bg-card': '#141c2a',
++ '--bg-hover': '#1e2840',
++ '--bg-input': '#0e1422',
++ '--text-primary': '#e0eaf5',
++ '--text-secondary': '#8aa0c0',
++ '--text-muted': '#5a708a',
++ '--accent': '#38bdf8',
++ '--accent-light': '#7dd3fc',
++ '--accent-dark': '#0284c7',
++ '--border': '#1e3050',
++ '--border-light': '#2a4060',
++ },
++ },
++ {
++ name: 'Ember',
++ slug: 'ember',
++ mode: 'dark',
++ description: 'Smoldering reds and 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': '#6a4a50',
++ '--accent': '#ef4444',
++ '--accent-light': '#f87171',
++ '--accent-dark': '#b91c1c',
++ '--border': '#3a1a20',
++ '--border-light': '#4a2a30',
++ },
++ },
++ {
++ name: 'Vapor',
++ slug: 'vapor',
++ mode: 'dark',
++ description: 'Vaporwave aesthetics — pink, purple, and teal.',
++ 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': '#7050a0',
++ '--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 gray with subtle blue undertones.',
++ 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': '#606470',
++ '--accent': '#a78bfa',
++ '--accent-light': '#c4b5fd',
++ '--accent-dark': '#7c3aed',
++ '--border': '#2a2c38',
++ '--border-light': '#3a3c48',
++ },
++ },
++ {
++ name: 'Matrix',
++ slug: 'matrix',
++ mode: 'dark',
++ description: 'Green terminal text on pitch 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': '#005a18',
++ '--accent': '#00ff41',
++ '--accent-light': '#66ff8a',
++ '--accent-dark': '#00aa2a',
++ '--border': '#0a2a0a',
++ '--border-light': '#144014',
++ },
++ },
++ {
++ name: 'Nord',
++ slug: 'nord',
++ mode: 'dark',
++ description: 'Arctic, north-bluish color palette inspired by the Nord theme.',
++ 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': '#7b88a1',
++ '--accent': '#88c0d0',
++ '--accent-light': '#8fbcbb',
++ '--accent-dark': '#5e81ac',
++ '--border': '#3b4252',
++ '--border-light': '#4c566a',
++ '--success': '#a3be8c',
++ '--danger': '#bf616a',
++ '--warning': '#ebcb8b',
++ '--info': '#81a1c1',
++ },
++ },
++ {
++ name: 'Gruvbox Dark',
++ slug: 'gruvbox-dark',
++ mode: 'dark',
++ description: 'Retro groove colors with warm, high-contrast tones.',
++ 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': '#928374',
++ '--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: 'The deepest dark — nearly pure black with dim accents.',
++ 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': '#444444',
++ '--accent': '#ffffff',
++ '--accent-light': '#ffffff',
++ '--accent-dark': '#aaaaaa',
++ '--border': '#1a1a1a',
++ '--border-light': '#252525',
++ },
++ },
++ {
++ name: 'Copper',
++ slug: 'copper',
++ mode: 'dark',
++ description: 'Rich copper and 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': '#7a6a54',
++ '--accent': '#cd7f32',
++ '--accent-light': '#daa060',
++ '--accent-dark': '#a06020',
++ '--border': '#2a2016',
++ '--border-light': '#3a3020',
++ },
++ },
++ {
++ name: 'Sakura Night',
++ slug: 'sakura-night',
++ mode: 'dark',
++ description: 'Soft pink cherry blossom on dark 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': '#7a5a7a',
++ '--accent': '#f472b6',
++ '--accent-light': '#f9a8d4',
++ '--accent-dark': '#db2777',
++ '--border': '#2a1a30',
++ '--border-light': '#3a2a40',
++ },
++ },
++ {
++ name: 'Hacker',
++ slug: 'hacker',
++ mode: 'dark',
++ description: 'Amber-on-black retro terminal look.',
++ 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': '#664700',
++ '--accent': '#ffb000',
++ '--accent-light': '#ffd050',
++ '--accent-dark': '#cc8d00',
++ '--border': '#1a1400',
++ '--border-light': '#2a2200',
++ },
++ },
++ {
++ name: 'Daylight',
++ slug: 'daylight',
++ mode: 'light',
++ description: 'Clean, bright white with warm accents for daytime use.',
++ 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': '#9a9ab0',
++ '--accent': '#8b5cf6',
++ '--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: 'Warm off-white like aged paper with ink accents.',
++ 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': '#9a8e7e',
++ '--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 light blue 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': '#8a90aa',
++ '--accent': '#3b82f6',
++ '--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: 'Fresh spring greens with natural tones.',
++ 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': '#7a9a6e',
++ '--accent': '#16a34a',
++ '--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: 'Soft peachy pinks with warm coral accents.',
++ 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': '#aa8070',
++ '--accent': '#f97316',
++ '--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: 'Gentle purple hues — calming and elegant.',
++ 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': '#9080aa',
++ '--accent': '#8b5cf6',
++ '--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: 'Light retro groove — warm cream with bold 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': '#928374',
++ '--accent': '#d65d0e',
++ '--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 accents.',
++ 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': '#94a3b8',
++ '--accent': '#0ea5e9',
++ '--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 sand and warm tan tones.',
++ 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': '#9a8e7e',
++ '--accent': '#b8860b',
++ '--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: 'Delicate rose pink with warm blush tones.',
++ 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': '#aa7090',
++ '--accent': '#e11d48',
++ '--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 color scheme — dark variant.',
++ 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': '#586e75',
++ '--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 color scheme — light variant.',
++ 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': '#93a1a1',
++ '--accent': '#b58900',
++ '--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 pastel theme for the high-spirited.',
++ 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': '#6c7086',
++ '--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 pastel theme — light variant.',
++ 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': '#9ca0b0',
++ '--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: 'Maximum contrast for accessibility — bold yellow on black.',
++ 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',
++ },
++ },
++];
++
++const BUILTIN_THEMES = RAW_BUILTIN_THEMES.map((theme) => ({
++ id: theme.slug,
++ ...theme,
++ tags: Array.isArray(theme.tags) ? [...theme.tags] : [],
++ variables: { ...(theme.variables || {}) },
++}));
++
++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,
++};
ddiff --git a/vendor/openvibe-shared/index.js b/vendor/openvibe-shared/index.js
nnew file mode 100644
iindex 0000000..2981bd7
---- /dev/null
++++ b/vendor/openvibe-shared/index.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++const { BRAND, BRAND_DEFAULTS, resolveBrandUrls, buildBrandFromRegistry } = require('./brand');
++const { OpenVibeAuthClient } = require('./auth-client');
++const { CSS_VARIABLES, DEFAULT_VARS, BUILTIN_THEMES, applyTheme, resolveBuiltinTheme, sanitizeCssValue, loadFromStorage, saveToStorage, syncThemeToServer } = require('./theme-sync');
++const { extractToken, requireOpenVibeAuth, optionalOpenVibeAuth, internalApiAuth } = require('./middleware');
++const { PRIORITY, CATEGORY, TYPES, SOUNDS, EMAIL_ELIGIBLE_CATEGORIES, createNotification, DEFAULT_NOTIFICATION_PREFS } = require('./notifications');
++const { AnalyticsTracker, classifyRequest, parseUserAgent, ANALYTICS_SCHEMA, BOT_USER_AGENTS, SUSPICIOUS_PATTERNS } = require('./analytics');
++const { URL_DEFINITIONS, normalizeValue, validateValue, resolveRegistryValues, formatRegistryEntry } = require('./url-resolver');
++
++module.exports = {
++ // Brand
++ BRAND,
++ BRAND_DEFAULTS,
++ buildBrandFromRegistry,
++ // Auth
++ OpenVibeAuthClient,
++ extractToken,
++ requireOpenVibeAuth,
++ optionalOpenVibeAuth,
++ internalApiAuth,
++ // Themes
++ CSS_VARIABLES,
++ DEFAULT_VARS,
++ BUILTIN_THEMES,
++ applyTheme,
++ resolveBuiltinTheme,
++ sanitizeCssValue,
++ loadFromStorage,
++ saveToStorage,
++ syncThemeToServer,
++ // Notifications
++ PRIORITY,
++ CATEGORY,
++ NOTIFICATION_TYPES: TYPES,
++ SOUNDS,
++ EMAIL_ELIGIBLE_CATEGORIES,
++ createNotification,
++ DEFAULT_NOTIFICATION_PREFS,
++ // Analytics
++ AnalyticsTracker,
++ classifyRequest,
++ parseUserAgent,
++ ANALYTICS_SCHEMA,
++ BOT_USER_AGENTS,
++ SUSPICIOUS_PATTERNS,
++ // URL Registry Resolver
++ URL_DEFINITIONS,
++ normalizeValue,
++ validateValue,
++ resolveRegistryValues,
++ formatRegistryEntry,
++ resolveBrandUrls,
++ // Client-side modules (browser only — require() for bundlers, <script> tag for direct use)
++ // OpenVibeNotifications: require('./notification-ui'),
++ // OpenVibeUserCard: require('./user-card'),
++ // OpenVibeNavbar: require('./navbar'),
++ // OpenVibeAccountSwitcher: require('./account-switcher'),
++};
ddiff --git a/vendor/openvibe-shared/middleware.js b/vendor/openvibe-shared/middleware.js
nnew file mode 100644
iindex 0000000..4509e16
---- /dev/null
++++ b/vendor/openvibe-shared/middleware.js
@@@ -0,0 +1,94 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Express Middleware
++// Auth middleware for services consuming OpenVibe.Network SSO tokens.
++// ═══════════════════════════════════════════════════════════════
++
++const { OpenVibeAuthClient } = require('./auth-client');
++
++/**
++ * Extract token from request.
++ * Priority: Authorization header → cookie → query param (WebSocket)
++ */
++function extractToken(req) {
++ // 1. Authorization: Bearer <token>
++ const authHeader = req.headers.authorization;
++ if (authHeader && authHeader.startsWith('Bearer ')) {
++ return authHeader.slice(7);
++ }
++ // 2. Cookie
++ if (req.cookies?.ov_token) return req.cookies.ov_token;
++ if (req.cookies?.token) return req.cookies.token; // legacy compat
++ // 3. Query param (WebSocket upgrade)
++ if (req.query?.token) return req.query.token;
++ return null;
++}
++
++/**
++ * Create requireOpenVibeAuth middleware.
++ * Verifies JWT signed by OpenVibe.Network and attaches req.user.
++ *
++ * @param {OpenVibeAuthClient} authClient
++ * @returns {Function} Express middleware
++ */
++function requireOpenVibeAuth(authClient) {
++ return (req, res, next) => {
++ const token = extractToken(req);
++ if (!token) {
++ return res.status(401).json({ error: 'Authentication required' });
++ }
++ const user = authClient.verifyToken(token);
++ if (!user) {
++ return res.status(401).json({ error: 'Invalid or expired token' });
++ }
++ req.user = user;
++ req.token = token;
++ next();
++ };
++}
++
++/**
++ * Create optionalOpenVibeAuth middleware.
++ * Attaches req.user if a valid token is present, otherwise continues.
++ *
++ * @param {OpenVibeAuthClient} authClient
++ * @returns {Function} Express middleware
++ */
++function optionalOpenVibeAuth(authClient) {
++ return (req, _res, next) => {
++ const token = extractToken(req);
++ if (token) {
++ const user = authClient.verifyToken(token);
++ if (user) {
++ req.user = user;
++ req.token = token;
++ }
++ }
++ next();
++ };
++}
++
++/**
++ * Internal API authentication middleware.
++ * Verifies X-Internal-Key header for server-to-server calls.
++ *
++ * @param {string} internalKey - Expected key value
++ * @returns {Function} Express middleware
++ */
++function internalApiAuth(internalKey) {
++ return (req, res, next) => {
++ const provided = req.headers['x-internal-key'];
++ if (!provided || provided !== internalKey) {
++ return res.status(403).json({ error: 'Forbidden' });
++ }
++ next();
++ };
++}
++
++module.exports = {
++ extractToken,
++ requireOpenVibeAuth,
++ optionalOpenVibeAuth,
++ internalApiAuth,
++};
ddiff --git a/vendor/openvibe-shared/navbar.js b/vendor/openvibe-shared/navbar.js
nnew file mode 100644
iindex 0000000..8eecb7d
---- /dev/null
++++ b/vendor/openvibe-shared/navbar.js
@@@ -0,0 +1,651 @@
++// ═══════════════════════════════════════════════════════════════
++// 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 })
++// ═══════════════════════════════════════════════════════════════
++
++(function (root) {
++ 'use strict';
++
++ let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: 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: 8px; text-decoration: none; color: inherit; margin-right: 8px; }
++ .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); }
++ .openvibe-navbar-brand .name { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }
++ .openvibe-navbar-brand .service-name { font-size: 11px; color: var(--accent-light, #a78bfa); font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }
++
++ .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' },
++ };
++
++ 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: '/' },
++ ],
++ 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 []; }
++ }
++
++ function escapeAttr(value) {
++ return String(value ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
++ }
++
++ function getAvatarInitial(user) {
++ const source = user?.display_name || user?.username || 'O';
++ return String(source).trim().charAt(0).toUpperCase() || 'O';
++ }
++
++ function makeAvatarPlaceholder(user, size = 64) {
++ const initial = getAvatarInitial(user);
++ const bg = user?.profile_color || '#8b5cf6';
++ const svg = `
++ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
++ <rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/>
++ <text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="${Math.round(size * 0.42)}" font-weight="700" fill="#ffffff">${initial}</text>
++ </svg>`;
++ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`;
++ }
++
++ function avatarSrc(user, size = 64) {
++ return user?.avatar_url || makeAvatarPlaceholder(user, size);
++ }
++
++ function avatarImg(user, size = 64, className = 'openvibe-navbar-avatar', id = '') {
++ const fallback = makeAvatarPlaceholder(user, size);
++ const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
++ const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');
++ return `<img class="${escapeAttr(className)}" src="${escapeAttr(avatarSrc(user, size))}" data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;
++ }
++
++ function attachAvatarFallbacks(rootEl) {
++ rootEl?.querySelectorAll('img[data-fallback-src]').forEach((img) => {
++ img.addEventListener('error', () => {
++ const fallback = img.dataset.fallbackSrc;
++ if (fallback && img.src !== fallback) {
++ img.src = fallback;
++ }
++ }, { once: true });
++ });
++ }
++
++ function render() {
++ if (_navEl) _navEl.remove();
++
++ const nav = document.createElement('nav');
++ nav.className = 'openvibe-navbar';
++ const svc = _config.service;
++ const links = SERVICE_LINKS[svc] || [];
++
++ // Resolve brand name + icon: config override > subdomain lookup > service defaults
++ const host = (typeof location !== 'undefined' && location.hostname) || '';
++ // Exact match first (e.g., hostname.openvibe.tools), then fallback to service name
++ let subBrand = SUBDOMAIN_BRANDS[host];
++ if (!subBrand && host && host.includes('.openvibe.tools')) {
++ // Try matching against service names
++ const parts = host.split('.');
++ const potential = parts[0]; // e.g., 'json' from 'json.openvibe.tools'
++ subBrand = SUBDOMAIN_BRANDS[`${potential}.openvibe.tools`];
++ }
++ const svcName = _config.brandName || (subBrand && subBrand.name) || SERVICE_NAMES[svc] || 'OpenVibe';
++ const svcIcon = _config.brandIcon || (subBrand && subBrand.icon) || SERVICE_ICONS[svc] || 'fa-circle-nodes';
++
++ const brandHref = '/';
++
++ const u = _config.user;
++ const accounts = getAccounts();
++ const isAnon = u && u.is_anon;
++ const loginHref = `https://openvibe.network/login?return=${encodeURIComponent(window.location.href)}`;
++ const addAccountHref = `https://openvibe.network/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
++
++ nav.innerHTML = `
++ <a class="openvibe-navbar-brand" href="${brandHref}">
++ <span class="flame"><i class="fa-solid ${svcIcon}"></i></span>
++ <div>
++ <div class="name">${svcName}</div>
++ </div>
++ </a>
++ <div class="openvibe-navbar-links">
++ ${links.map(l => `<a href="${l.href}">${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"><i class="fa-solid fa-circle-nodes"></i> 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));
++
++ 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-menu">
++ <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#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Settings</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>` : ''}
++ <div style="height:1px;background:var(--border,#333340);margin:4px -8px"></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);
++
++ // 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 {
++ document.cookie = 'ov_token=;path=/;max-age=0';
++ document.cookie = 'ov_token=;path=/;max-age=0;domain=.openvibe.tools';
++ localStorage.removeItem('ov_token');
++ localStorage.removeItem('openvibe_anon_token');
++ localStorage.removeItem('openvibe_active_account');
++ 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();
++ return render();
++ },
++
++ /** Update user (after account switch). */
++ setUser(user) {
++ _config.user = user;
++ render();
++ },
++
++ 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;
++ },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar;
++ else root.OpenVibeNavbar = OpenVibeNavbar;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
ddiff --git a/vendor/openvibe-shared/notification-ui.js b/vendor/openvibe-shared/notification-ui.js
nnew file mode 100644
iindex 0000000..68c54a5
---- /dev/null
++++ b/vendor/openvibe-shared/notification-ui.js
@@@ -0,0 +1,539 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — Notification UI Client
++// Drop-in vanilla JS library for toast notifications, bell badge,
++// notification panel, sounds, and real-time polling.
++// Usage: <script src="https://openvibe.network/shared/notification-ui.js"></script>
++// OpenVibeNotifications.init({ token, apiBase, onAction })
++// ═══════════════════════════════════════════════════════════════
++
++(function (root) {
++ 'use strict';
++
++ const POLL_INTERVAL = 15_000; // 15s for unread count
++ const TOAST_DURATION = { low: 0, normal: 3800, high: 6000, critical: 0 }; // 0 = sticky
++ const MAX_TOASTS = 3;
++ const SOUNDS = { normal: 'notification.mp3', high: 'notification-high.mp3', critical: 'notification-alarm.mp3' };
++
++ let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', onAction: null };
++ let _pollTimer = null;
++ let _lastCheck = null;
++ let _unreadCount = 0;
++ let _toastContainer = null;
++ let _panelEl = null;
++ let _bellEl = null;
++ let _audioCache = {};
++ let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
++
++ // ── Styles (injected once) ───────────────────────────────
++ 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-fade-in { from { opacity: 0; } to { opacity: 1; } }
++
++ .openvibe-toast-container {
++ position: fixed; top: 82px; right: 14px; z-index: 100000;
++ display: flex; flex-direction: column; gap: 6px;
++ pointer-events: none; max-width: 320px; width: 100%;
++ }
++ .openvibe-toast {
++ pointer-events: all;
++ background: color-mix(in srgb, var(--bg-card, #22222c) 92%, 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-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: rgba(192,150,92,0.15); 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); border-radius: 0 0 0 10px; }
++ .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; transition: all .15s; }
++ .openvibe-toast-actions button:hover { background: var(--accent-dark, #a07840); color: #fff; border-color: var(--accent-dark, #a07840); }
++ .openvibe-toast-actions button.primary { background: var(--accent, #8b5cf6); color: #fff; border-color: var(--accent, #8b5cf6); }
++ .openvibe-toast-user { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
++ .openvibe-toast-user img { width: 24px; height: 24px; border-radius: 50%; }
++ .openvibe-toast-user .username { font-weight: 600; font-size: 12px; cursor: pointer; }
++
++ /* ── Bell ─────────────────────────────────────── */
++ .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 { 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; }
++
++ /* ── Panel ────────────────────────────────────── */
++ .openvibe-notif-panel {
++ position: fixed; top: 62px; right: 16px;
++ width: 380px; 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;
++ }
++ .openvibe-notif-panel.open { display: flex; }
++ .openvibe-notif-panel-header {
++ padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);
++ display: flex; align-items: center; justify-content: space-between;
++ }
++ .openvibe-notif-panel-header h3 { font-size: 15px; font-weight: 600; margin: 0; }
++ .openvibe-notif-panel-header .actions { display: flex; gap: 8px; }
++ .openvibe-notif-panel-header .actions button { background: none; border: none; color: var(--text-muted, #707080); cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; transition: all .15s; }
++ .openvibe-notif-panel-header .actions button:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
++ .openvibe-notif-panel-tabs { display: flex; border-bottom: 1px solid var(--border, #333340); }
++ .openvibe-notif-panel-tabs button { flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-muted, #707080); font-size: 12px; font-weight: 500; cursor: pointer; transition: all .15s; }
++ .openvibe-notif-panel-tabs button.active { color: var(--accent-light, #a78bfa); border-bottom-color: var(--accent, #8b5cf6); }
++ .openvibe-notif-panel-list { flex: 1; overflow-y: auto; padding: 4px 0; }
++ .openvibe-notif-item {
++ padding: 12px 16px; display: flex; gap: 10px; align-items: flex-start;
++ cursor: pointer; transition: background .15s; border-left: 3px solid transparent;
++ }
++ .openvibe-notif-item:hover { background: var(--bg-hover, #2f2f3d); }
++ .openvibe-notif-item.unread { background: rgba(192,150,92,0.04); border-left-color: var(--accent, #8b5cf6); }
++ .openvibe-notif-item .icon { font-size: 20px; flex-shrink: 0; margin-top: 2px; }
++ .openvibe-notif-item .content { flex: 1; min-width: 0; }
++ .openvibe-notif-item .content .title { font-size: 13px; font-weight: 500; margin-bottom: 2px; }
++ .openvibe-notif-item .content .msg { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++ .openvibe-notif-item .content .meta { font-size: 10px; color: var(--text-muted, #707080); margin-top: 4px; display: flex; gap: 8px; }
++ .openvibe-notif-item .content .meta .service-tag { padding: 1px 5px; border-radius: 3px; background: var(--bg-tertiary, #2a2a38); font-weight: 500; }
++ .openvibe-notif-panel-empty { text-align: center; padding: 48px 24px; color: var(--text-muted, #707080); }
++ .openvibe-notif-panel-empty .icon { font-size: 36px; margin-bottom: 8px; display: block; opacity: .5; }
++
++ @media (max-width: 440px) {
++ .openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
++ .openvibe-notif-panel { right: 8px; left: 8px; width: auto; }
++ }
++ `;
++ document.head.appendChild(style);
++ }
++
++ // ── 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 audio = _audioCache[url];
++ audio.currentTime = 0;
++ audio.volume = priority === 'critical' ? 0.8 : 0.5;
++ audio.play().catch(() => {});
++ } catch {}
++ }
++
++ // ── Toast Container ──────────────────────────────────────
++ function ensureToastContainer() {
++ if (_toastContainer) return;
++ _toastContainer = document.createElement('div');
++ _toastContainer.className = 'openvibe-toast-container';
++ document.body.appendChild(_toastContainer);
++ }
++
++ function showToast(notification) {
++ if (!_preferences.toasts) return;
++ if (_preferences.muted_categories.includes(notification.category)) return;
++ ensureToastContainer();
++
++ // Limit visible toasts
++ while (_toastContainer.children.length >= MAX_TOASTS) {
++ const oldest = _toastContainer.firstChild;
++ oldest.remove();
++ }
++
++ const toast = document.createElement('div');
++ toast.className = `openvibe-toast priority-${notification.priority}`;
++ toast.dataset.notifId = notification.id;
++
++ const rich = notification.richContent || {};
++ let userHtml = '';
++ if (rich.user) {
++ const u = rich.user;
++ const nameStyle = u.profile_color ? `color:${u.profile_color}` : '';
++ const nameClass = u.name_effect ? `openvibe-name-fx-${u.name_effect}` : '';
++ userHtml = `<div class="openvibe-toast-user">
++ ${u.avatar_url ? `<img src="${u.avatar_url}" alt="">` : ''}
++ <span class="username ${nameClass}" style="${nameStyle}">${u.display_name || u.username}</span>
++ </div>`;
++ }
++
++ let actionsHtml = '';
++ if (rich.actions && rich.actions.length) {
++ actionsHtml = '<div class="openvibe-toast-actions">' +
++ rich.actions.filter(a => a.type !== 'input').map(a =>
++ `<button data-action="${a.id}" class="${a.style === 'primary' ? 'primary' : ''}">${a.label}</button>`
++ ).join('') + '</div>';
++ }
++
++ const serviceLabel = notification.service && notification.service !== 'network'
++ ? `<span class="service-badge">${notification.service}</span>` : '';
++
++ toast.innerHTML = `
++ <div class="openvibe-toast-icon">${notification.icon || '🔔'}</div>
++ <div class="openvibe-toast-body">
++ ${userHtml}
++ <div class="openvibe-toast-title">${notification.title}${serviceLabel}</div>
++ <div class="openvibe-toast-msg">${notification.message}</div>
++ ${actionsHtml}
++ </div>
++ <button class="openvibe-toast-close">&times;</button>
++ `;
++
++ // Progress bar for auto-dismiss
++ const duration = TOAST_DURATION[notification.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%'; });
++ }
++
++ // Events
++ toast.querySelector('.openvibe-toast-close').addEventListener('click', e => {
++ e.stopPropagation();
++ dismissToast(toast);
++ });
++
++ toast.addEventListener('click', () => {
++ if (rich.url) window.open(rich.url, '_blank');
++ markRead(notification.id);
++ dismissToast(toast);
++ });
++
++ // Action buttons
++ toast.querySelectorAll('[data-action]').forEach(btn => {
++ btn.addEventListener('click', e => {
++ e.stopPropagation();
++ const actionId = btn.dataset.action;
++ const action = rich.actions.find(a => a.id === actionId);
++ if (action?.url) { window.open(action.url, '_blank'); return; }
++ if (_config.onAction) _config.onAction(notification.id, actionId, notification);
++ dismissToast(toast);
++ });
++ });
++
++ _toastContainer.appendChild(toast);
++
++ // Auto dismiss
++ if (duration > 0) {
++ setTimeout(() => dismissToast(toast), duration);
++ }
++ }
++
++ function dismissToast(el) {
++ if (!el || !el.parentNode) return;
++ el.classList.add('removing');
++ setTimeout(() => el.remove(), 300);
++ }
++
++ // ── Bell / Badge ─────────────────────────────────────────
++ function createBell() {
++ const bell = document.createElement('div');
++ bell.className = 'openvibe-bell';
++ 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());
++ _bellEl = bell;
++ return bell;
++ }
++
++ function updateBadge(count) {
++ _unreadCount = count;
++ if (!_bellEl) return;
++ const badge = _bellEl.querySelector('.badge');
++ if (count > 0) {
++ badge.textContent = count > 99 ? '99+' : count;
++ badge.classList.remove('hidden');
++ _bellEl.classList.add('ringing');
++ setTimeout(() => _bellEl.classList.remove('ringing'), 600);
++ } else {
++ badge.classList.add('hidden');
++ }
++ }
++
++ // ── Panel ────────────────────────────────────────────────
++ function createPanel() {
++ const panel = document.createElement('div');
++ panel.className = 'openvibe-notif-panel';
++ panel.innerHTML = `
++ <div class="openvibe-notif-panel-header">
++ <h3>Notifications</h3>
++ <div class="actions">
++ <button data-action="mark-all-read">Mark all read</button>
++ <button data-action="settings">⚙️</button>
++ </div>
++ </div>
++ <div class="openvibe-notif-panel-tabs">
++ <button class="active" data-tab="all">All</button>
++ <button data-tab="unread">Unread</button>
++ <button data-tab="social">Social</button>
++ <button data-tab="game">Game</button>
++ <button data-tab="system">System</button>
++ </div>
++ <div class="openvibe-notif-panel-list"></div>
++ `;
++
++ panel.querySelector('[data-action="mark-all-read"]').addEventListener('click', markAllRead);
++ panel.querySelectorAll('[data-tab]').forEach(btn => {
++ btn.addEventListener('click', () => {
++ panel.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active'));
++ btn.classList.add('active');
++ loadNotifications(btn.dataset.tab);
++ });
++ });
++
++ document.addEventListener('click', e => {
++ if (_panelEl?.classList.contains('open') && !_panelEl.contains(e.target) && !_bellEl?.contains(e.target)) {
++ _panelEl.classList.remove('open');
++ }
++ });
++
++ _panelEl = panel;
++ document.body.appendChild(panel);
++ return panel;
++ }
++
++ function togglePanel() {
++ if (!_panelEl) createPanel();
++ const opening = !_panelEl.classList.contains('open');
++ _panelEl.classList.toggle('open');
++ if (opening) loadNotifications('all');
++ }
++
++ async function loadNotifications(tab = 'all') {
++ if (!_panelEl) return;
++ const list = _panelEl.querySelector('.openvibe-notif-panel-list');
++ list.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-muted)">Loading...</div>';
++
++ try {
++ const params = new URLSearchParams({ limit: '50' });
++ if (tab === 'unread') params.set('unread', '1');
++ else if (tab !== 'all') params.set('category', tab);
++
++ const res = await apiFetch(`/api/notifications?${params}`);
++ const data = await res.json();
++
++ if (!data.notifications || data.notifications.length === 0) {
++ list.innerHTML = `<div class="openvibe-notif-panel-empty"><span class="icon">🔔</span>No notifications yet</div>`;
++ return;
++ }
++
++ list.innerHTML = data.notifications.map(n => {
++ // Render each item in isolation — one malformed notification must
++ // never blank the whole dropdown ("Failed to load").
++ try {
++ const ago = timeAgo(n.created_at);
++ const serviceTag = n.service && n.service !== 'network'
++ ? `<span class="service-tag">${esc(n.service)}</span>` : '';
++ return `<div class="openvibe-notif-item ${n.read ? '' : 'unread'}" data-id="${esc(n.id)}">
++ <span class="icon">${esc(n.icon || '🔔')}</span>
++ <div class="content">
++ <div class="title">${esc(n.title)}</div>
++ <div class="msg">${esc(n.message)}</div>
++ <div class="meta"><span>${esc(ago)}</span>${serviceTag}</div>
++ </div>
++ </div>`;
++ } catch { return ''; }
++ }).join('');
++
++ list.querySelectorAll('.openvibe-notif-item').forEach(item => {
++ item.addEventListener('click', () => {
++ const id = item.dataset.id;
++ markRead(id);
++ item.classList.remove('unread');
++ const notif = data.notifications.find(n => String(n.id) === id);
++ const rich = richOf(notif);
++ if (rich.url) window.open(rich.url, '_blank');
++ });
++ });
++ } catch {
++ list.innerHTML = '<div class="openvibe-notif-panel-empty"><span class="icon">⚠️</span>Failed to load</div>';
++ }
++ }
++
++ // ── API Helpers ──────────────────────────────────────────
++ 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}`;
++ const crossOrigin = isCrossOrigin();
++ return fetch(`${_config.apiBase}${path}`, {
++ ...opts,
++ headers,
++ // Only send credentials for same-origin requests; cross-origin uses Authorization header
++ credentials: crossOrigin ? 'omit' : 'include',
++ });
++ }
++
++ async function markRead(id) {
++ try { await apiFetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch {}
++ _unreadCount = Math.max(0, _unreadCount - 1);
++ updateBadge(_unreadCount);
++ }
++
++ async function markAllRead() {
++ try { await apiFetch('/api/notifications/read-all', { method: 'POST' }); } catch {}
++ updateBadge(0);
++ if (_panelEl?.classList.contains('open')) {
++ _panelEl.querySelectorAll('.openvibe-notif-item.unread').forEach(el => el.classList.remove('unread'));
++ }
++ }
++
++ async function pollUnread() {
++ try {
++ const res = await apiFetch('/api/notifications/unread-count');
++ const data = await res.json();
++ const prev = _unreadCount;
++ updateBadge(data.count || 0);
++
++ // If new notifications arrived, fetch and toast the latest
++ if (data.count > prev && _preferences.toasts) {
++ const newest = await apiFetch(`/api/notifications?unread=1&limit=${data.count - prev}&since=${_lastCheck || ''}`);
++ const nd = await newest.json();
++ if (nd.notifications) {
++ for (const n of nd.notifications.slice(0, 3)) {
++ const rich = n.rich_content ? JSON.parse(n.rich_content) : {};
++ showToast({ ...n, richContent: rich });
++ playSound(n.priority);
++ }
++ }
++ }
++ _lastCheck = new Date().toISOString();
++ } catch {}
++ }
++
++ // ── Helpers ──────────────────────────────────────────────
++ // Escape untrusted text before inserting into innerHTML. Notification
++ // titles/messages can be attacker-controlled (e.g. abusive broadcasts), so
++ // they must never be treated as HTML.
++ function esc(v) {
++ if (v == null) return '';
++ return String(v)
++ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
++ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++ }
++ // rich_content arrives already parsed from the API, but tolerate a raw JSON
++ // string too. Never throw — a bad value just yields {}.
++ function richOf(n) {
++ const rc = n && n.rich_content;
++ if (!rc) return {};
++ if (typeof rc === 'object') return rc;
++ try { return JSON.parse(rc) || {}; } catch { return {}; }
++ }
++
++ // ── Time Formatting ──────────────────────────────────────
++ function timeAgo(dateStr) {
++ const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
++ if (diff < 60) return 'just now';
++ if (diff < 3600) return `${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 new Date(dateStr).toLocaleDateString();
++ }
++
++ // ── Public API ───────────────────────────────────────────
++ const OpenVibeNotifications = {
++ init(opts = {}) {
++ Object.assign(_config, opts);
++ injectStyles();
++ if (opts.preferences) Object.assign(_preferences, opts.preferences);
++ _lastCheck = new Date().toISOString();
++
++ // Start polling
++ pollUnread();
++ _pollTimer = setInterval(pollUnread, POLL_INTERVAL);
++ },
++
++ destroy() {
++ clearInterval(_pollTimer);
++ _toastContainer?.remove();
++ _panelEl?.remove();
++ _toastContainer = null;
++ _panelEl = null;
++ },
++
++ /** Create and inject the bell icon. Returns the DOM element. */
++ createBell(container) {
++ const bell = createBell();
++ if (container) container.appendChild(bell);
++ pollUnread();
++ return bell;
++ },
++
++ /** Programmatically push a toast (for real-time WebSocket events). */
++ push(notification) {
++ showToast(notification);
++ playSound(notification.priority);
++ _unreadCount++;
++ updateBadge(_unreadCount);
++ },
++
++ /** Update preferences at runtime. */
++ setPreferences(prefs) {
++ Object.assign(_preferences, prefs);
++ },
++
++ /** Update auth token (e.g., after account switch). */
++ setToken(token) {
++ _config.token = token;
++ if (token) {
++ pollUnread();
++ if (!_pollTimer) _pollTimer = setInterval(pollUnread, POLL_INTERVAL);
++ } else {
++ clearInterval(_pollTimer);
++ _pollTimer = null;
++ updateBadge(0);
++ }
++ },
++
++ /** Open/close the notification panel. */
++ togglePanel,
++
++ /** Get current unread count. */
++ get unreadCount() { return _unreadCount; },
++ };
++
++ // Export
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNotifications;
++ else root.OpenVibeNotifications = OpenVibeNotifications;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
ddiff --git a/vendor/openvibe-shared/notifications.js b/vendor/openvibe-shared/notifications.js
nnew file mode 100644
iindex 0000000..252f771
---- /dev/null
++++ b/vendor/openvibe-shared/notifications.js
@@@ -0,0 +1,183 @@
++'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' },
++
++ // 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',
++]);
++
++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 = {
++ PRIORITY,
++ CATEGORY,
++ TYPES,
++ SOUNDS,
++ EMAIL_ELIGIBLE_CATEGORIES,
++ isEmailEligible,
++ createNotification,
++ DEFAULT_NOTIFICATION_PREFS,
++};
ddiff --git a/vendor/openvibe-shared/package.json b/vendor/openvibe-shared/package.json
nnew file mode 100644
iindex 0000000..9f56460
---- /dev/null
++++ b/vendor/openvibe-shared/package.json
@@@ -0,0 +1,20 @@
++{
++ "name": "openvibe-shared",
++ "version": "1.0.0",
++ "description": "Shared auth, theme, and brand utilities for the OpenVibe",
++ "main": "index.js",
++ "exports": {
++ ".": "./index.js",
++ "./analytics": "./analytics.js",
++ "./auth-client": "./auth-client.js",
++ "./theme-sync": "./theme-sync.js",
++ "./brand": "./brand.js",
++ "./middleware": "./middleware.js",
++ "./notifications": "./notifications.js",
++ "./url-resolver": "./url-resolver.js"
++ },
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++}
ddiff --git a/vendor/openvibe-shared/test/url-resolver-deploy.test.js b/vendor/openvibe-shared/test/url-resolver-deploy.test.js
nnew file mode 100644
iindex 0000000..da731d6
---- /dev/null
++++ b/vendor/openvibe-shared/test/url-resolver-deploy.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++
++const assert = require('assert');
++const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('../url-resolver');
++
++// ── Test: Deploy infrastructure keys exist ───────────────────
++const deployKeys = [
++ 'DEPLOY_ACME_EMAIL',
++ 'DEPLOY_CERT_MODE',
++ 'DEPLOY_CLOUDFLARE_TOKEN',
++ 'DEPLOY_DOMAINS',
++ 'DEPLOY_NGINX_MODE',
++ 'DEPLOY_NGINX_SITES_PATH',
++ 'DEPLOY_NGINX_BACKUP_PATH',
++ 'DEPLOY_SERVICE_MAP',
++];
++
++for (const key of deployKeys) {
++ assert.ok(URL_DEFINITIONS[key], `Missing deploy key: ${key}`);
++ assert.strictEqual(URL_DEFINITIONS[key].key, key);
++ assert.ok(URL_DEFINITIONS[key].category.startsWith('deploy_'), `Deploy key ${key} should have deploy_ category`);
++}
++
++// ── Test: Deploy key defaults ────────────────────────────────
++assert.strictEqual(URL_DEFINITIONS.DEPLOY_CERT_MODE.default, 'manual');
++assert.strictEqual(URL_DEFINITIONS.DEPLOY_NGINX_MODE.default, 'preview');
++assert.strictEqual(URL_DEFINITIONS.DEPLOY_NGINX_SITES_PATH.default, '/etc/nginx/sites-enabled');
++assert.strictEqual(URL_DEFINITIONS.DEPLOY_CLOUDFLARE_TOKEN.type, 'secret');
++
++// ── Test: Secret type normalization ──────────────────────────
++assert.strictEqual(normalizeValue(' my-secret-token ', 'secret'), 'my-secret-token');
++assert.strictEqual(normalizeValue(null, 'secret'), null);
++assert.strictEqual(normalizeValue(undefined, 'secret'), null);
++
++// ── Test: Deploy config resolves through registry chain ──────
++const env = {};
++const bootstrap = {
++ DEPLOY_ACME_EMAIL: '[email protected]',
++ DEPLOY_CERT_MODE: 'cloudflare',
++};
++const overrides = {
++ DEPLOY_CERT_MODE: 'manual',
++};
++
++const resolved = resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);
++
++assert.strictEqual(resolved.DEPLOY_ACME_EMAIL.value, '[email protected]');
++assert.strictEqual(resolved.DEPLOY_ACME_EMAIL.source, 'bootstrap');
++assert.strictEqual(resolved.DEPLOY_CERT_MODE.value, 'manual');
++assert.strictEqual(resolved.DEPLOY_CERT_MODE.source, 'admin');
++assert.strictEqual(resolved.DEPLOY_NGINX_MODE.value, 'preview');
++assert.strictEqual(resolved.DEPLOY_NGINX_MODE.source, 'default');
++
++// ── Test: DEPLOY_DOMAINS as JSON array ───────────────────────
++const domainsResolved = resolveRegistryValues({}, {
++ DEPLOY_DOMAINS: JSON.stringify([
++ { domain: 'openvibe.tools', wildcard: true, certName: 'openvibe.tools', services: ['network'] },
++ { domain: 'openvibe.live', wildcard: true, certName: 'openvibe.live-0001', services: ['live'] },
++ ]),
++}, {}, URL_DEFINITIONS);
++
++assert.ok(Array.isArray(domainsResolved.DEPLOY_DOMAINS.value));
++assert.strictEqual(domainsResolved.DEPLOY_DOMAINS.value.length, 2);
++assert.strictEqual(domainsResolved.DEPLOY_DOMAINS.value[0].domain, 'openvibe.tools');
++
++// ── Test: DEPLOY_SERVICE_MAP as JSON map ─────────────────────
++const svcMapResolved = resolveRegistryValues({}, {
++ DEPLOY_SERVICE_MAP: JSON.stringify({ custom: { port: 9000, domains: ['custom.example.com'] } }),
++}, {}, URL_DEFINITIONS);
++
++assert.ok(typeof svcMapResolved.DEPLOY_SERVICE_MAP.value === 'object');
++assert.strictEqual(svcMapResolved.DEPLOY_SERVICE_MAP.value.custom.port, 9000);
++
++// ── Test: White-label defaults still work ────────────────────
++const whitelabelResolved = resolveRegistryValues({}, {
++ NETWORK_NAME: 'CoolNet',
++ TOOLS_SERVICE_NAME: 'CoolTools',
++ DEPLOY_ACME_EMAIL: '[email protected]',
++ DEPLOY_CERT_MODE: 'cloudflare',
++}, {}, URL_DEFINITIONS);
++
++assert.strictEqual(whitelabelResolved.NETWORK_NAME.value, 'CoolNet');
++assert.strictEqual(whitelabelResolved.DEPLOY_ACME_EMAIL.value, '[email protected]');
++assert.strictEqual(whitelabelResolved.DEPLOY_CERT_MODE.value, 'cloudflare');
++
++console.log('✅ url-resolver deploy config tests passed');
ddiff --git a/vendor/openvibe-shared/test/url-resolver.test.js b/vendor/openvibe-shared/test/url-resolver.test.js
nnew file mode 100644
iindex 0000000..130b479
---- /dev/null
++++ b/vendor/openvibe-shared/test/url-resolver.test.js
@@@ -0,0 +1,38 @@
++const assert = require('assert');
++const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('../url-resolver');
++
++const env = {
++ OV_LIVE_URL: 'https://env.example.com',
++ WEBRTC_PUBLIC_URL: 'https://webrtc.env.example.com',
++ OV_NETWORK_URL: 'https://network.env.example.com',
++};
++
++const bootstrap = {
++ OV_LIVE_URL: 'https://bootstrap.example.com',
++ WHIP_PUBLIC_URL: 'https://bootstrap.whip.example.com',
++};
++
++const overrides = {
++ OV_LIVE_URL: 'https://admin.example.com',
++};
++
++const resolved = resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);
++assert.strictEqual(resolved.OV_LIVE_URL.value, 'https://admin.example.com');
++assert.strictEqual(resolved.OV_LIVE_URL.source, 'admin');
++assert.strictEqual(resolved.WHIP_PUBLIC_URL.value, 'https://bootstrap.whip.example.com');
++assert.strictEqual(resolved.WHIP_PUBLIC_URL.source, 'bootstrap');
++assert.strictEqual(resolved.WEBRTC_PUBLIC_URL.value, 'https://webrtc.env.example.com');
++assert.strictEqual(resolved.WEBRTC_PUBLIC_URL.source, 'env');
++assert.strictEqual(resolved.OV_NETWORK_URL.value, 'https://network.env.example.com');
++assert.strictEqual(resolved.OV_NETWORK_URL.source, 'env');
++assert.strictEqual(resolved.OV_GAMES_URL.value, 'https://openvibe.games');
++assert.strictEqual(resolved.OV_MEDIA_URL.value, 'https://openvibe.media');
++assert.strictEqual(resolved.JSMPEG_PUBLIC_URL.source, 'default');
++
++assert.strictEqual(normalizeValue('turn:turn.example.com:3478', 'turn_url'), 'turn:turn.example.com:3478');
++assert.strictEqual(normalizeValue('turn://turn.example.com:3478', 'turn_url'), 'turn:turn.example.com:3478');
++assert.strictEqual(normalizeValue('turns://turn.example.com:5349', 'turn_url'), 'turns:turn.example.com:5349');
++assert.strictEqual(normalizeValue('turn://user:[email protected]:3478', 'turn_url'), null);
++assert.strictEqual(normalizeValue('turn://invalid host', 'turn_url'), null);
++
++console.log('✅ openvibe-shared url-resolver precedence test passed');
ddiff --git a/vendor/openvibe-shared/theme-loader.js b/vendor/openvibe-shared/theme-loader.js
nnew file mode 100644
iindex 0000000..6084562
---- /dev/null
++++ b/vendor/openvibe-shared/theme-loader.js
@@@ -0,0 +1,222 @@
++// ═══════════════════════════════════════════════════════════════
++// 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
++ const THEMES = {
++ 'vibe': { '--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':'#666','--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)' },
++ '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':'#64748b','--accent':'#6366f1','--accent-light':'#818cf8','--accent-dark':'#4f46e5','--border':'#1e293b','--border-light':'#334155' },
++ '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':'#5a7a5a','--accent':'#4ade80','--accent-light':'#86efac','--accent-dark':'#22c55e','--border':'#1a3a1a','--border-light':'#2a4a2a' },
++ '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':'#6a5a80','--accent':'#ff006e','--accent-light':'#ff4d94','--accent-dark':'#c9005a','--border':'#2a1a3a','--border-light':'#3a2a4a' },
++ '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':'#6272a4','--accent':'#bd93f9','--accent-light':'#d4b8ff','--accent-dark':'#9b6ddb','--border':'#44475a','--border-light':'#565970','--success':'#50fa7b','--danger':'#ff5555','--warning':'#f1fa8c','--info':'#8be9fd','--live-red':'#ff5555' },
++ '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':'#75715e','--accent':'#a6e22e','--accent-light':'#c4ff50','--accent-dark':'#7dac1c','--border':'#49483e','--border-light':'#5b5a50','--danger':'#f92672','--warning':'#e6db74','--info':'#66d9ef' },
++ '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':'#4a7080','--accent':'#00bcd4','--accent-light':'#4dd0e1','--accent-dark':'#0097a7','--border':'#1a3040','--border-light':'#2a4050' },
++ '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':'#7a5a50','--accent':'#ff6b35','--accent-light':'#ff8f5e','--accent-dark':'#d04a1a','--border':'#3a2020','--border-light':'#4a3030' },
++ 'arctic': { '--bg-primary':'#0c1220','--bg-secondary':'#101828','--bg-tertiary':'#182030','--bg-card':'#141c2a','--bg-hover':'#1e2840','--bg-input':'#0e1422','--text-primary':'#e0eaf5','--text-secondary':'#8aa0c0','--text-muted':'#5a708a','--accent':'#38bdf8','--accent-light':'#7dd3fc','--accent-dark':'#0284c7','--border':'#1e3050','--border-light':'#2a4060' },
++ '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':'#6a4a50','--accent':'#ef4444','--accent-light':'#f87171','--accent-dark':'#b91c1c','--border':'#3a1a20','--border-light':'#4a2a30' },
++ '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':'#7050a0','--accent':'#e040fb','--accent-light':'#ea80fc','--accent-dark':'#aa00d4','--border':'#2a1a40','--border-light':'#3a2a50','--info':'#00e5ff','--success':'#69f0ae' },
++ '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':'#606470','--accent':'#a78bfa','--accent-light':'#c4b5fd','--accent-dark':'#7c3aed','--border':'#2a2c38','--border-light':'#3a3c48' },
++ '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':'#005a18','--accent':'#00ff41','--accent-light':'#66ff8a','--accent-dark':'#00aa2a','--border':'#0a2a0a','--border-light':'#144014' },
++ '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':'#7b88a1','--accent':'#88c0d0','--accent-light':'#8fbcbb','--accent-dark':'#5e81ac','--border':'#3b4252','--border-light':'#4c566a','--success':'#a3be8c','--danger':'#bf616a','--warning':'#ebcb8b','--info':'#81a1c1' },
++ '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':'#928374','--accent':'#fabd2f','--accent-light':'#fce566','--accent-dark':'#d79921','--border':'#3c3836','--border-light':'#504945','--success':'#b8bb26','--danger':'#fb4934','--warning':'#fe8019','--info':'#83a598' },
++ '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':'#444444','--accent':'#ffffff','--accent-light':'#ffffff','--accent-dark':'#aaaaaa','--border':'#1a1a1a','--border-light':'#252525' },
++ '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':'#7a6a54','--accent':'#cd7f32','--accent-light':'#daa060','--accent-dark':'#a06020','--border':'#2a2016','--border-light':'#3a3020' },
++ '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':'#7a5a7a','--accent':'#f472b6','--accent-light':'#f9a8d4','--accent-dark':'#db2777','--border':'#2a1a30','--border-light':'#3a2a40' },
++ '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':'#664700','--accent':'#ffb000','--accent-light':'#ffd050','--accent-dark':'#cc8d00','--border':'#1a1400','--border-light':'#2a2200' },
++ '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':'#9a9ab0','--accent':'#8b5cf6','--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)' },
++ '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':'#9a8e7e','--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)' },
++ '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':'#8a90aa','--accent':'#3b82f6','--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)' },
++ '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':'#7a9a6e','--accent':'#16a34a','--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)' },
++ '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':'#aa8070','--accent':'#f97316','--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)' },
++ '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':'#9080aa','--accent':'#8b5cf6','--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)' },
++ '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':'#928374','--accent':'#d65d0e','--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)' },
++ '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':'#94a3b8','--accent':'#0ea5e9','--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)' },
++ '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':'#9a8e7e','--accent':'#b8860b','--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)' },
++ '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':'#aa7090','--accent':'#e11d48','--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)' },
++ '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':'#586e75','--accent':'#b58900','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#0a4050','--border-light':'#105060','--success':'#859900','--danger':'#dc322f','--warning':'#cb4b16','--info':'#268bd2' },
++ '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':'#93a1a1','--accent':'#b58900','--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)' },
++ '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':'#6c7086','--accent':'#cba6f7','--accent-light':'#dcc0ff','--accent-dark':'#b07de0','--border':'#313244','--border-light':'#45475a','--success':'#a6e3a1','--danger':'#f38ba8','--warning':'#fab387','--info':'#89b4fa','--live-red':'#f38ba8' },
++ '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':'#9ca0b0','--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)' },
++ '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' },
++ };
++
++ 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;
++ const el = document.documentElement;
++ for (const [prop, val] of Object.entries(vars)) {
++ el.style.setProperty(prop, val);
++ }
++ return true;
++ }
++
++ /**
++ * Apply arbitrary CSS variables object to <html>.
++ */
++ function applyVars(vars) {
++ if (!vars || typeof vars !== 'object') return;
++ const el = document.documentElement;
++ for (const [prop, val] of Object.entries(vars)) {
++ if (prop.startsWith('--')) el.style.setProperty(prop, val);
++ }
++ }
++
++ /**
++ * 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);
++ return saved.id || saved.slug || 'vibe';
++ }
++ 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 in localStorage for faster next load
++ try {
++ localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: cookieId, variables: THEMES[cookieId] }));
++ } catch { /* quota */ }
++ return cookieId;
++ }
++
++ // 3. Default — vibe (already the CSS fallback values, but apply explicitly)
++ applyById('vibe');
++ return 'vibe';
++ }
++
++ /**
++ * Save theme choice both locally and cross-domain.
++ * Call this when the user picks a theme.
++ */
++ function save(themeId, variables) {
++ const vars = variables || THEMES[themeId];
++ if (!vars) return;
++ try {
++ localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: themeId, slug: themeId, variables: vars }));
++ } catch { /* quota */ }
++ setCookie(COOKIE_NAME, themeId, 365);
++ }
++
++ /**
++ * 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();
++
++ // ── If user is logged in, try to sync from server ────────
++ // This runs async after page paint to pick up server-side changes
++ if (typeof fetch !== 'undefined') {
++ setTimeout(function () {
++ const token = getCookie('ov_token') ||
++ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token')));
++ if (!token) return;
++ fetch(_config.apiBase + '/api/themes/me/active', {
++ headers: { 'Authorization': 'Bearer ' + token },
++ 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;
++ if (custom && typeof custom === 'object' && Object.keys(custom).length) {
++ // Custom theme: apply the built-in base (if any) then the user's overrides.
++ const base = THEMES[serverId] || {};
++ applyVars(base);
++ applyVars(custom);
++ try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: serverId, variables: Object.assign({}, base, custom) })); } catch { /* quota */ }
++ setCookie(COOKIE_NAME, serverId, 365);
++ } else if (serverId !== getCurrent()) {
++ if (applyById(serverId)) save(serverId, THEMES[serverId]);
++ }
++ }).catch(function () { /* offline / CORS */ });
++ }, 500);
++ }
++
++ // ── Public API ───────────────────────────────────────────
++ const OpenVibeThemeLoader = {
++ THEMES: THEMES,
++ apply: applyById,
++ applyVars: applyVars,
++ save: save,
++ getCurrent: getCurrent,
++ resolveAndApply: resolveAndApply,
++ init: function (opts) {
++ if (opts && typeof opts.apiBase === 'string' && opts.apiBase.trim()) {
++ _config.apiBase = opts.apiBase.trim();
++ }
++ return OpenVibeThemeLoader;
++ },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeThemeLoader;
++ else root.OpenVibeThemeLoader = OpenVibeThemeLoader;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
ddiff --git a/vendor/openvibe-shared/theme-sync.js b/vendor/openvibe-shared/theme-sync.js
nnew file mode 100644
iindex 0000000..a6e8293
---- /dev/null
++++ b/vendor/openvibe-shared/theme-sync.js
@@@ -0,0 +1,127 @@
++'use strict';
++
++const { BUILTIN_THEMES, DEFAULT_VARS } = 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',
++];
++
++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 = { ...NORMALIZED_DEFAULT_VARS, ...theme.variables };
++ for (const [key, value] of Object.entries(vars)) {
++ if (!CSS_VARIABLES.includes(key)) continue;
++ const safe = sanitizeCssValue(value);
++ if (!safe) continue;
++ if (target && typeof target.style !== 'undefined' && target.style.setProperty) {
++ // DOM element (browser)
++ target.style.setProperty(key, safe);
++ } else if (typeof target === 'object') {
++ // Plain object (Node SSR)
++ target[key] = safe;
++ }
++ }
++}
++
++/**
++ * Resolve a theme by ID from the built-in catalog.
++ * Falls back to 'vibe' if not found.
++ */
++function resolveBuiltinTheme(themeId) {
++ return BUILTIN_THEMES.find(t => t.id === themeId || t.slug === themeId)
++ || BUILTIN_THEMES[0];
++}
++
++// ── Browser-Only Helpers ─────────────────────────────────────
++// These are safe to call in Node (they no-op if `window` is undefined).
++
++const STORAGE_KEY = 'ov_theme';
++const THEME_API_BASE = 'https://openvibe.network/api/themes';
++
++function loadFromStorage() {
++ if (typeof localStorage === 'undefined') return null;
++ try {
++ const raw = localStorage.getItem(STORAGE_KEY);
++ return raw ? JSON.parse(raw) : null;
++ } catch { return null; }
++}
++
++function saveToStorage(theme) {
++ if (typeof localStorage === 'undefined') return;
++ try {
++ localStorage.setItem(STORAGE_KEY, JSON.stringify({
++ id: theme.id,
++ slug: theme.slug,
++ name: theme.name,
++ mode: theme.mode,
++ variables: theme.variables,
++ }));
++ } catch { /* quota exceeded, etc. */ }
++}
++
++/**
++ * Sync theme choice to the central OpenVibe.Network server.
++ * Requires an auth token. Fails silently if offline.
++ */
++async function syncThemeToServer(themeId, customVars, token) {
++ if (typeof fetch === 'undefined' || !token) return;
++ try {
++ await fetch(`${THEME_API_BASE}/me`, {
++ method: 'PUT',
++ headers: {
++ 'Content-Type': 'application/json',
++ 'Authorization': `Bearer ${token}`,
++ },
++ body: JSON.stringify({ theme_id: themeId, custom_variables: customVars || null }),
++ });
++ } catch { /* offline, CORS, etc. */ }
++}
++
++module.exports = {
++ CSS_VARIABLES,
++ DEFAULT_VARS: NORMALIZED_DEFAULT_VARS,
++ BUILTIN_THEMES,
++ sanitizeCssValue,
++ applyTheme,
++ resolveBuiltinTheme,
++ loadFromStorage,
++ saveToStorage,
++ syncThemeToServer,
++ STORAGE_KEY,
++};
ddiff --git a/vendor/openvibe-shared/url-resolver.js b/vendor/openvibe-shared/url-resolver.js
nnew file mode 100644
iindex 0000000..404d9aa
---- /dev/null
++++ b/vendor/openvibe-shared/url-resolver.js
@@@ -0,0 +1,597 @@
++'use strict';
++
++const URL_DEFINITIONS = Object.freeze({
++ OV_LIVE_URL: {
++ key: 'OV_LIVE_URL',
++ label: 'OpenVibe.Live Public URL',
++ category: 'public_first_party_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.live',
++ description: 'Canonical public origin for OpenVibe.Live.',
++ },
++ OV_TOOLS_URL: {
++ key: 'OV_TOOLS_URL',
++ label: 'OpenVibe.Tools Public URL',
++ category: 'public_first_party_urls',
++ service: 'tools',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.tools',
++ description: 'Canonical public origin for the OpenVibe.Tools gateway.',
++ },
++ OV_GAMES_URL: {
++ key: 'OV_GAMES_URL',
++ label: 'OpenVibe.Games Public URL',
++ category: 'public_first_party_urls',
++ service: 'games',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.games',
++ description: 'Canonical public origin for OpenVibe.Games.',
++ },
++ OV_MEDIA_URL: {
++ key: 'OV_MEDIA_URL',
++ label: 'OpenVibe.Media Public URL',
++ category: 'public_first_party_urls',
++ service: 'media',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.media',
++ description: 'Canonical public origin for OpenVibe.Media.',
++ },
++ WEBRTC_PUBLIC_URL: {
++ key: 'WEBRTC_PUBLIC_URL',
++ label: 'WebRTC Public URL',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'origin',
++ default: 'http://localhost:3000',
++ description: 'Public origin used for WebRTC endpoint discovery and browser consumers.',
++ },
++ WHIP_PUBLIC_URL: {
++ key: 'WHIP_PUBLIC_URL',
++ label: 'WHIP Public URL',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'origin',
++ default: 'http://localhost:3000',
++ description: 'Canonical WHIP ingestion origin for OBS and other WHIP clients.',
++ },
++ WHIP_PUBLIC_URL_ENABLED: {
++ key: 'WHIP_PUBLIC_URL_ENABLED',
++ label: 'Dedicated WHIP Host Enabled',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'boolean',
++ default: false,
++ description: 'Enable use of the dedicated WHIP hostname configured by WHIP_PUBLIC_URL. If disabled, the app falls back to the safe public WebRTC origin.',
++ },
++ JSMPEG_PUBLIC_URL: {
++ key: 'JSMPEG_PUBLIC_URL',
++ label: 'JSMPEG Public URL',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'origin',
++ default: 'http://localhost:3000',
++ description: 'Public origin used to construct JSMPEG relay endpoints for browsers and FFmpeg clients.',
++ },
++ TURN_URL: {
++ key: 'TURN_URL',
++ label: 'TURN URL',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'turn_url',
++ default: '',
++ description: 'TURN server connection string used for WebRTC NAT traversal.',
++ },
++ MEDIASOUP_ANNOUNCED_IP: {
++ key: 'MEDIASOUP_ANNOUNCED_IP',
++ label: 'Mediasoup Announced IP/Hostname',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'hostname',
++ default: 'localhost',
++ description: 'Hostname or public IP advertised in Mediasoup ICE candidates.',
++ },
++ RTMP_HOST: {
++ key: 'RTMP_HOST',
++ label: 'RTMP Host',
++ category: 'protocol_ingest_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'rtmp_host',
++ default: '',
++ description: 'Hostname used for RTMP ingest. Do not include protocol or path.',
++ },
++ OV_NETWORK_URL: {
++ key: 'OV_NETWORK_URL',
++ label: 'OpenVibe.Network URL',
++ category: 'public_first_party_urls',
++ service: 'network',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.network',
++ description: 'Public URL for the OpenVibe.Network SSO, account, and registry service.',
++ },
++ OV_NETWORK_LOGIN_URL: {
++ key: 'OV_NETWORK_LOGIN_URL',
++ label: 'OpenVibe.Network Login URL',
++ category: 'public_first_party_urls',
++ service: 'network',
++ scope: 'global',
++ type: 'origin',
++ default: 'https://openvibe.network',
++ description: 'Public login URL for OpenVibe.Network. Typically the same as the public OpenVibe.Network origin.',
++ },
++ OV_NETWORK_INTERNAL_URL: {
++ key: 'OV_NETWORK_INTERNAL_URL',
++ label: 'OpenVibe.Network Internal URL',
++ category: 'internal_service_urls',
++ service: 'network',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4000',
++ description: 'Internal URL used by services to call the OpenVibe.Network internal API.',
++ },
++ OV_LIVE_INTERNAL_URL: {
++ key: 'OV_LIVE_INTERNAL_URL',
++ label: 'OpenVibe.Live Internal URL',
++ category: 'internal_service_urls',
++ service: 'live',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:3000',
++ description: 'Internal URL used by OpenVibe.Network or other OpenVibe services to reach OpenVibe.Live.',
++ },
++ OV_TOOLS_INTERNAL_URL: {
++ key: 'OV_TOOLS_INTERNAL_URL',
++ label: 'OpenVibe.Tools Internal URL',
++ category: 'internal_service_urls',
++ service: 'tools',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4001',
++ description: 'Internal URL used by OpenVibe.Network to reach the OpenVibe.Tools gateway.',
++ },
++ OV_GAMES_INTERNAL_URL: {
++ key: 'OV_GAMES_INTERNAL_URL',
++ label: 'OpenVibe.Games Internal URL',
++ category: 'internal_service_urls',
++ service: 'games',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:8000',
++ description: 'Internal URL used by OpenVibe.Network to reach OpenVibe.Games.',
++ },
++ OV_MEDIA_INTERNAL_URL: {
++ key: 'OV_MEDIA_INTERNAL_URL',
++ label: 'OpenVibe.Media Internal URL',
++ category: 'internal_service_urls',
++ service: 'media',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4100',
++ description: 'Internal URL used by OpenVibe.Network to reach OpenVibe.Media.',
++ },
++ OV_MAPS_INTERNAL_URL: {
++ key: 'OV_MAPS_INTERNAL_URL',
++ label: 'Maps.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'maps',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4010',
++ description: 'Internal URL of the Maps.OpenVibe tools satellite.',
++ },
++ OV_FOOD_INTERNAL_URL: {
++ key: 'OV_FOOD_INTERNAL_URL',
++ label: 'Food.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'food',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4011',
++ description: 'Internal URL of the Food.OpenVibe tools satellite.',
++ },
++ OV_IMG_INTERNAL_URL: {
++ key: 'OV_IMG_INTERNAL_URL',
++ label: 'Img.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'img',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4012',
++ description: 'Internal URL of the Img.OpenVibe tools satellite.',
++ },
++ OV_YT_INTERNAL_URL: {
++ key: 'OV_YT_INTERNAL_URL',
++ label: 'YT.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'yt',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4013',
++ description: 'Internal URL of the YT.OpenVibe tools satellite.',
++ },
++ OV_AUDIO_INTERNAL_URL: {
++ key: 'OV_AUDIO_INTERNAL_URL',
++ label: 'Audio.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'audio',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4014',
++ description: 'Internal URL of the Audio.OpenVibe tools satellite.',
++ },
++ OV_TEXT_INTERNAL_URL: {
++ key: 'OV_TEXT_INTERNAL_URL',
++ label: 'Text.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'text',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4015',
++ description: 'Internal URL of the Text.OpenVibe tools satellite.',
++ },
++ OV_DOCS_INTERNAL_URL: {
++ key: 'OV_DOCS_INTERNAL_URL',
++ label: 'Docs.OpenVibe Internal URL',
++ category: 'internal_service_urls',
++ service: 'docs',
++ scope: 'global',
++ type: 'internal_base_url',
++ default: 'http://127.0.0.1:4016',
++ description: 'Internal URL of the Docs.OpenVibe tools satellite.',
++ },
++
++ // ── Brand / Site Identity ─────────────────────────────────────────────────
++ // Configurable during first-time setup and editable in admin.
++ // These values drive display names, JWT metadata, email copy, and CORS policy.
++ // Defaults match the OpenVibe — change for white-label installs.
++
++ NETWORK_NAME: {
++ key: 'NETWORK_NAME',
++ label: 'Network / Organization Name',
++ category: 'brand_identity',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe',
++ description: 'Human-readable name for the overall platform (e.g. "OpenVibe" or your custom brand).',
++ },
++ TOOLS_SERVICE_NAME: {
++ key: 'TOOLS_SERVICE_NAME',
++ label: 'Tools Service Name',
++ category: 'brand_identity',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe.Tools',
++ description: 'Display name for the SSO/tools service (e.g. "OpenVibe.Tools" or "CoolTools").',
++ },
++ STREAMER_SERVICE_NAME: {
++ key: 'STREAMER_SERVICE_NAME',
++ label: 'Streamer Service Name',
++ category: 'brand_identity',
++ service: 'live',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe.Live',
++ description: 'Display name for the streaming service (e.g. "OpenVibe.Live").',
++ },
++ GAMES_SERVICE_NAME: {
++ key: 'GAMES_SERVICE_NAME',
++ label: 'Games Service Name',
++ category: 'brand_identity',
++ service: 'games',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe.Games',
++ description: 'Display name for the games service (e.g. "OpenVibe.Games").',
++ },
++ MEDIA_SERVICE_NAME: {
++ key: 'MEDIA_SERVICE_NAME',
++ label: 'Media Service Name',
++ category: 'brand_identity',
++ service: 'media',
++ scope: 'global',
++ type: 'string',
++ default: 'OpenVibe.Media',
++ description: 'Display name for the media service (e.g. "OpenVibe.Media").',
++ },
++
++ // ── CORS / Origin Policy ──────────────────────────────────────────────────
++ // Controls which additional origins are allowed cross-origin access.
++ // The public service URLs above are always auto-allowed.
++
++ TOOLS_SUBDOMAIN_BASE: {
++ key: 'TOOLS_SUBDOMAIN_BASE',
++ label: 'Tools Subdomain Base Domain',
++ category: 'cors_policy',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'openvibe.tools',
++ description: 'Base domain for which all https://*.{domain} subdomains are trusted first-party origins (e.g. "openvibe.tools"). Leave empty to disable subdomain wildcarding.',
++ },
++ ALLOWED_EXTRA_ORIGINS: {
++ key: 'ALLOWED_EXTRA_ORIGINS',
++ label: 'Extra Allowed Origins (JSON array)',
++ category: 'cors_policy',
++ service: 'network',
++ scope: 'global',
++ type: 'json_array',
++ default: '[]',
++ description: 'JSON array of additional origins allowed by CORS across all services (e.g. ["https://my-cdn.com"]). Auto-includes all configured service public URLs.',
++ },
++
++ // ── Deploy Infrastructure ─────────────────────────────────────────────────
++ // Certificate, DNS, and reverse-proxy management config.
++ // These drive the automated TLS issuance and Nginx generation system.
++
++ DEPLOY_ACME_EMAIL: {
++ key: 'DEPLOY_ACME_EMAIL',
++ label: 'ACME / Let\'s Encrypt Email',
++ category: 'deploy_tls',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: '',
++ description: 'Email address used for Let\'s Encrypt certificate registration and expiry notifications.',
++ },
++ DEPLOY_CERT_MODE: {
++ key: 'DEPLOY_CERT_MODE',
++ label: 'Certificate Mode',
++ category: 'deploy_tls',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'manual',
++ description: 'Certificate provisioning mode: "cloudflare" for automated DNS-01 via Cloudflare API, "manual" for manual DNS challenge, or "none" to skip.',
++ },
++ DEPLOY_CLOUDFLARE_TOKEN: {
++ key: 'DEPLOY_CLOUDFLARE_TOKEN',
++ label: 'Cloudflare API Token (DNS Edit)',
++ category: 'deploy_tls',
++ service: 'network',
++ scope: 'global',
++ type: 'secret',
++ default: '',
++ description: 'Cloudflare API token with Zone:DNS:Edit permissions. Used for automated DNS-01 wildcard certificate challenges.',
++ },
++ DEPLOY_DOMAINS: {
++ key: 'DEPLOY_DOMAINS',
++ label: 'Managed Domains (JSON)',
++ category: 'deploy_tls',
++ service: 'network',
++ scope: 'global',
++ type: 'json_array',
++ default: '[]',
++ description: 'JSON array of domain objects managed by the deploy system. Each entry: {"domain":"openvibe.network","wildcard":false,"certName":"openvibe.network","services":["network"]}.',
++ },
++ DEPLOY_NGINX_MODE: {
++ key: 'DEPLOY_NGINX_MODE',
++ label: 'Nginx Management Mode',
++ category: 'deploy_nginx',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: 'preview',
++ description: 'Nginx config management mode: "preview" (generate only), "apply" (write + validate + reload), or "disabled".',
++ },
++ DEPLOY_NGINX_SITES_PATH: {
++ key: 'DEPLOY_NGINX_SITES_PATH',
++ label: 'Nginx sites-enabled Path',
++ category: 'deploy_nginx',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: '/etc/nginx/sites-enabled',
++ description: 'Directory where Nginx site configs are written. Defaults to /etc/nginx/sites-enabled.',
++ },
++ DEPLOY_NGINX_BACKUP_PATH: {
++ key: 'DEPLOY_NGINX_BACKUP_PATH',
++ label: 'Nginx Backup Path',
++ category: 'deploy_nginx',
++ service: 'network',
++ scope: 'global',
++ type: 'string',
++ default: '/etc/nginx/sites-backup',
++ description: 'Directory for Nginx config backups before apply. Created automatically.',
++ },
++ DEPLOY_SERVICE_MAP: {
++ key: 'DEPLOY_SERVICE_MAP',
++ label: 'Service → Port / Domain Map (JSON)',
++ category: 'deploy_nginx',
++ service: 'network',
++ scope: 'global',
++ type: 'json_map',
++ default: '{}',
++ description: 'JSON map of service configurations for Nginx generation. Each key is a service ID, value has port, domains[], wildcardDomain, maxBodySize, etc.',
++ },
++});
++
++function normalizeOrigin(value) {
++ if (!value || typeof value !== 'string') return null;
++ try {
++ const url = new URL(value.trim());
++ if (!url.protocol || !url.hostname) return null;
++ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
++ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;
++ } catch {
++ return null;
++ }
++}
++
++function normalizeInternalBaseUrl(value) {
++ if (!value || typeof value !== 'string') return null;
++ try {
++ const url = new URL(value.trim());
++ if (!url.protocol || !url.hostname) return null;
++ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
++ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;
++ } catch {
++ return null;
++ }
++}
++
++function normalizeWsOrigin(value) {
++ if (!value || typeof value !== 'string') return null;
++ try {
++ const url = new URL(value.trim());
++ if (!url.hostname) return null;
++ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return null;
++ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}`;
++ } catch {
++ return null;
++ }
++}
++
++function normalizeHostname(value) {
++ if (!value || typeof value !== 'string') return null;
++ const trimmed = value.trim();
++ if (/^\w+:\/\//.test(trimmed)) {
++ try {
++ const url = new URL(trimmed);
++ return url.hostname || null;
++ } catch {
++ return null;
++ }
++ }
++ if (/[^a-zA-Z0-9.\-:]/.test(trimmed)) return null;
++ return trimmed;
++}
++
++function normalizeTurnUrl(value) {
++ if (!value || typeof value !== 'string') return null;
++ let trimmed = value.trim();
++ if (/^turns?:[^/]/i.test(trimmed)) {
++ trimmed = trimmed.replace(/^turns?:/i, (s) => s + '//');
++ }
++ if (!/^turns?:\/\//i.test(trimmed)) return null;
++ try {
++ const url = new URL(trimmed);
++ if (!url.hostname) return null;
++ const protocol = url.protocol.toLowerCase();
++ if (protocol !== 'turn:' && protocol !== 'turns:') return null;
++ if (url.username || url.password) return null;
++ return `${protocol}${url.hostname}${url.port ? `:${url.port}` : ''}${url.pathname}${url.search}`;
++ } catch {
++ return null;
++ }
++}
++
++function normalizeBoolean(value) {
++ if (value === true || value === 1 || value === '1' || value === 'true' || value === 'TRUE' || value === 'True') {
++ return true;
++ }
++ if (value === false || value === 0 || value === '0' || value === 'false' || value === 'FALSE' || value === 'False') {
++ return false;
++ }
++ return null;
++}
++
++function normalizeJson(value, type) {
++ if (typeof value === 'string') {
++ try {
++ return JSON.parse(value);
++ } catch {
++ return null;
++ }
++ }
++ if (type === 'json_map' && typeof value === 'object' && value !== null && !Array.isArray(value)) {
++ return value;
++ }
++ if (type === 'json_array' && Array.isArray(value)) {
++ return value;
++ }
++ return null;
++}
++
++function normalizeValue(value, type) {
++ if (value === undefined || value === null) return null;
++ switch (type) {
++ case 'origin': return normalizeOrigin(value);
++ case 'internal_base_url': return normalizeInternalBaseUrl(value);
++ case 'ws_origin': return normalizeWsOrigin(value);
++ case 'hostname': return normalizeHostname(value);
++ case 'rtmp_host': return normalizeHostname(value);
++ case 'turn_url': return normalizeTurnUrl(value);
++ case 'json_map': return normalizeJson(value, 'json_map');
++ case 'json_array': return normalizeJson(value, 'json_array');
++ case 'boolean': return normalizeBoolean(value);
++ case 'secret': return typeof value === 'string' ? value.trim() : String(value);
++ case 'string': return typeof value === 'string' ? value.trim() : String(value);
++ default: return typeof value === 'string' ? value.trim() : String(value);
++ }
++}
++
++function validateValue(value, type) {
++ return normalizeValue(value, type) !== null;
++}
++
++function resolveRegistryValues(env = {}, overrides = {}, bootstrap = {}, definitions = URL_DEFINITIONS) {
++ const resolved = {};
++ for (const [key, def] of Object.entries(definitions)) {
++ let rawValue = def.default;
++ let source = 'default';
++ if (env[key]) {
++ rawValue = env[key];
++ source = 'env';
++ }
++ if (bootstrap[key] != null && bootstrap[key] !== '') {
++ rawValue = bootstrap[key];
++ source = 'bootstrap';
++ }
++ if (overrides[key] != null && overrides[key] !== '') {
++ rawValue = overrides[key];
++ source = 'admin';
++ }
++ const normalized = normalizeValue(rawValue, def.type);
++ resolved[key] = {
++ key,
++ label: def.label,
++ category: def.category,
++ service: def.service,
++ scope: def.scope,
++ type: def.type,
++ description: def.description,
++ value: normalized,
++ source,
++ };
++ }
++ return resolved;
++}
++
++function formatRegistryEntry(entry) {
++ if (!entry || typeof entry !== 'object') return null;
++ return {
++ key: entry.key,
++ label: entry.label,
++ category: entry.category,
++ service: entry.service,
++ scope: entry.scope,
++ type: entry.type,
++ description: entry.description,
++ value: entry.value || null,
++ source: entry.source || 'admin',
++ updatedBy: entry.updated_by || null,
++ updatedAt: entry.updated_at || null,
++ };
++}
++
++module.exports = {
++ URL_DEFINITIONS,
++ normalizeValue,
++ validateValue,
++ resolveRegistryValues,
++ formatRegistryEntry,
++};
ddiff --git a/vendor/openvibe-shared/user-card.js b/vendor/openvibe-shared/user-card.js
nnew file mode 100644
iindex 0000000..c0d258e
---- /dev/null
++++ b/vendor/openvibe-shared/user-card.js
@@@ -0,0 +1,252 @@
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe — User Card & Context Menu
++// Right-click any username to see profile card, quick actions,
++// linked services, name effects, and moderator tools.
++// Usage: OpenVibeUserCard.init({ apiBase, token, currentUserId })
++// OpenVibeUserCard.attach(element, userId)
++// ═══════════════════════════════════════════════════════════════
++
++(function (root) {
++ 'use strict';
++
++ let _config = { apiBase: 'https://openvibe.network', token: null, currentUserId: null, currentRole: 'user' };
++ let _cardEl = null;
++ let _cache = new Map();
++
++ function injectStyles() {
++ if (document.getElementById('openvibe-usercard-styles')) return;
++ const s = document.createElement('style');
++ s.id = 'openvibe-usercard-styles';
++ s.textContent = `
++ @keyframes openvibe-card-in { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
++ .openvibe-usercard-overlay { position: fixed; inset: 0; z-index: 99998; }
++ .openvibe-usercard {
++ position: fixed; z-index: 99999;
++ width: 300px; background: var(--bg-card, #22222c);
++ border: 1px solid var(--border, #333340); border-radius: 12px;
++ box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
++ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
++ color: var(--text-primary, #e0e0e0);
++ animation: openvibe-card-in .2s ease; overflow: hidden;
++ }
++ .openvibe-usercard-banner {
++ height: 60px; background: linear-gradient(135deg, var(--accent-dark, #a07840), var(--accent, #8b5cf6));
++ position: relative;
++ }
++ .openvibe-usercard-avatar {
++ width: 56px; height: 56px; border-radius: 50%;
++ border: 3px solid var(--bg-card, #22222c);
++ position: absolute; bottom: -28px; left: 16px;
++ background: var(--bg-tertiary, #2a2a38);
++ object-fit: cover;
++ }
++ .openvibe-usercard-body { padding: 36px 16px 12px; }
++ .openvibe-usercard-name {
++ font-size: 16px; font-weight: 700; display: flex; align-items: center; gap: 6px;
++ }
++ .openvibe-usercard-name .role-badge {
++ font-size: 9px; padding: 2px 6px; border-radius: 3px;
++ font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
++ }
++ .openvibe-usercard-name .role-admin { background: rgba(231,76,60,0.15); color: #e74c3c; }
++ .openvibe-usercard-name .role-global_mod { background: rgba(46,204,113,0.15); color: #2ecc71; }
++ .openvibe-usercard-name .role-streamer { background: rgba(155,89,182,0.15); color: #9b59b6; }
++ .openvibe-usercard-username { font-size: 12px; color: var(--text-muted, #707080); margin-bottom: 8px; }
++ .openvibe-usercard-bio { font-size: 12px; color: var(--text-secondary, #b0b0b8); line-height: 1.4; margin-bottom: 10px; max-height: 40px; overflow: hidden; }
++ .openvibe-usercard-services { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
++ .openvibe-usercard-services .svc {
++ font-size: 10px; padding: 3px 8px; border-radius: 4px;
++ background: var(--bg-tertiary, #2a2a38); color: var(--text-secondary, #b0b0b8);
++ display: flex; align-items: center; gap: 4px;
++ }
++ .openvibe-usercard-services .svc .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success, #2ecc71); }
++ .openvibe-usercard-divider { height: 1px; background: var(--border, #333340); margin: 8px -16px; }
++ .openvibe-usercard-actions { display: flex; flex-direction: column; gap: 2px; }
++ .openvibe-usercard-actions button {
++ display: flex; align-items: center; gap: 8px;
++ width: 100%; padding: 8px 12px; background: none;
++ border: none; border-radius: 6px; color: var(--text-primary, #e0e0e0);
++ font-size: 12px; cursor: pointer; text-align: left;
++ transition: background .12s;
++ }
++ .openvibe-usercard-actions button:hover { background: var(--bg-hover, #2f2f3d); }
++ .openvibe-usercard-actions button.danger { color: var(--live-red, #e74c3c); }
++ .openvibe-usercard-actions button .icon { font-size: 14px; width: 20px; text-align: center; }
++
++ /* Name effects (CSS classes applied to usernames) */
++ .openvibe-name-fx-rainbow { background: linear-gradient(90deg, #ff0000, #ff7700, #ffff00, #00ff00, #0000ff, #8b00ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; background-size: 200%; animation: openvibe-rainbow 3s linear infinite; }
++ @keyframes openvibe-rainbow { to { background-position: 200% center; } }
++ .openvibe-name-fx-glow { text-shadow: 0 0 8px currentColor, 0 0 16px currentColor; }
++ .openvibe-name-fx-fire { text-shadow: 0 0 4px #ff6600, 0 -2px 8px #ff3300, 0 -4px 12px #cc0000; }
++ .openvibe-name-fx-ice { text-shadow: 0 0 6px #00ccff, 0 0 12px #0088ff; color: #aaddff; }
++ .openvibe-name-fx-gold { background: linear-gradient(180deg, #ffd700, #daa520, #ffd700); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: none; }
++ .openvibe-name-fx-glitch { animation: openvibe-glitch .1s infinite; }
++ @keyframes openvibe-glitch { 0%,100%{transform:none} 25%{transform:translate(-1px,1px)} 50%{transform:translate(1px,-1px)} 75%{transform:translate(-1px,-1px)} }
++ .openvibe-name-fx-neon { color: #39ff14; text-shadow: 0 0 5px #39ff14, 0 0 10px #39ff14, 0 0 20px #39ff14; }
++ .openvibe-name-fx-shadow { text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }
++ `;
++ document.head.appendChild(s);
++ }
++
++ // ── Fetch User Data ──────────────────────────────────────
++ async function fetchUser(userId) {
++ if (_cache.has(userId)) return _cache.get(userId);
++ try {
++ const headers = {};
++ if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
++ const res = await fetch(`${_config.apiBase}/api/users/${userId}/card`, { headers, credentials: 'include' });
++ if (!res.ok) return null;
++ const data = await res.json();
++ _cache.set(userId, data);
++ setTimeout(() => _cache.delete(userId), 120_000); // 2min cache
++ return data;
++ } catch { return null; }
++ }
++
++ // ── Render Card ──────────────────────────────────────────
++ function renderCard(data, x, y) {
++ closeCard();
++ const user = data.user;
++ const linked = data.linked_accounts || [];
++
++ const roleBadge = user.role !== 'user'
++ ? `<span class="role-badge role-${user.role}">${user.role.replace('_', ' ')}</span>` : '';
++
++ const nameClass = data.name_effect ? `openvibe-name-fx-${data.name_effect}` : '';
++ const nameColor = user.profile_color ? `color:${user.profile_color}` : '';
++
++ const services = linked.map(l => {
++ const icons = { openvibelive: '📡', openvibegames: '⚔️', openvibemaps: '🗺️' };
++ return `<span class="svc"><span class="dot"></span>${icons[l.service] || '🔗'} ${l.service_username || l.service}</span>`;
++ }).join('');
++
++ const isSelf = _config.currentUserId && String(_config.currentUserId) === String(user.id);
++ const isMod = ['admin', 'global_mod'].includes(_config.currentRole);
++
++ let actions = '';
++ if (!isSelf) {
++ actions += `<button data-act="profile"><span class="icon">👤</span> View Profile</button>`;
++ actions += `<button data-act="message"><span class="icon">💬</span> Send Message</button>`;
++ actions += `<button data-act="follow"><span class="icon">➕</span> Follow</button>`;
++ if (isMod) {
++ actions += `<div class="openvibe-usercard-divider"></div>`;
++ actions += `<button data-act="warn"><span class="icon">⚠️</span> Warn User</button>`;
++ actions += `<button data-act="mute"><span class="icon">🔇</span> Mute</button>`;
++ actions += `<button data-act="ban" class="danger"><span class="icon">🚫</span> Ban User</button>`;
++ }
++ } else {
++ actions += `<button data-act="profile"><span class="icon">⚙️</span> Edit Profile</button>`;
++ actions += `<button data-act="accounts"><span class="icon">🔄</span> Switch Account</button>`;
++ }
++
++ const card = document.createElement('div');
++ card.className = 'openvibe-usercard';
++
++ const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;
++ card.innerHTML = `
++ <div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>
++ <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" onerror="this.style.display='none'">
++ <div class="openvibe-usercard-body">
++ <div class="openvibe-usercard-name">
++ <span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>
++ ${roleBadge}
++ </div>
++ <div class="openvibe-usercard-username">@${user.username}${data.anon_number ? ` · Anon #${data.anon_number}` : ''}</div>
++ ${user.bio ? `<div class="openvibe-usercard-bio">${user.bio}</div>` : ''}
++ ${services ? `<div class="openvibe-usercard-services">${services}</div>` : ''}
++ <div class="openvibe-usercard-divider"></div>
++ <div class="openvibe-usercard-actions">${actions}</div>
++ </div>
++ `;
++
++ // Position
++ const vw = window.innerWidth, vh = window.innerHeight;
++ card.style.left = Math.min(x, vw - 320) + 'px';
++ card.style.top = Math.min(y, vh - 400) + 'px';
++
++ // Overlay to close on click-outside
++ const overlay = document.createElement('div');
++ overlay.className = 'openvibe-usercard-overlay';
++ overlay.addEventListener('click', closeCard);
++ overlay.addEventListener('contextmenu', e => { e.preventDefault(); closeCard(); });
++
++ document.body.appendChild(overlay);
++ document.body.appendChild(card);
++ _cardEl = { card, overlay };
++
++ // Action handlers
++ card.querySelectorAll('[data-act]').forEach(btn => {
++ btn.addEventListener('click', () => {
++ const act = btn.dataset.act;
++ closeCard();
++ if (act === 'profile') {
++ const url = isSelf ? `${_config.apiBase}/my`
++ : `${_config.apiBase}/user/${user.username}`;
++ window.open(url, '_blank');
++ } else if (act === 'message') {
++ // Dispatch custom event for the host app to handle
++ document.dispatchEvent(new CustomEvent('openvibe-user-action', {
++ detail: { action: act, user }
++ }));
++ } else {
++ document.dispatchEvent(new CustomEvent('openvibe-user-action', {
++ detail: { action: act, user }
++ }));
++ }
++ });
++ });
++ }
++
++ function closeCard() {
++ if (_cardEl) {
++ _cardEl.card.remove();
++ _cardEl.overlay.remove();
++ _cardEl = null;
++ }
++ }
++
++ // ── Public API ───────────────────────────────────────────
++ const OpenVibeUserCard = {
++ init(opts = {}) {
++ Object.assign(_config, opts);
++ injectStyles();
++ },
++
++ /** Attach right-click card to an element. */
++ attach(el, userId) {
++ el.style.cursor = 'pointer';
++ el.addEventListener('contextmenu', async e => {
++ e.preventDefault();
++ const data = await fetchUser(userId);
++ if (data) renderCard(data, e.clientX, e.clientY);
++ });
++ // Also support click on mobile
++ el.addEventListener('click', async e => {
++ if (e.ctrlKey || e.metaKey) {
++ e.preventDefault();
++ const data = await fetchUser(userId);
++ if (data) renderCard(data, e.clientX, e.clientY);
++ }
++ });
++ },
++
++ /** Programmatically show a card at position. */
++ async show(userId, x, y) {
++ const data = await fetchUser(userId);
++ if (data) renderCard(data, x, y);
++ },
++
++ close: closeCard,
++ clearCache() { _cache.clear(); },
++
++ /** Apply name effect CSS class to an element. */
++ applyNameEffect(el, effectName) {
++ if (!effectName) return;
++ el.classList.add(`openvibe-name-fx-${effectName}`);
++ },
++ };
++
++ if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeUserCard;
++ else root.OpenVibeUserCard = OpenVibeUserCard;
++
++})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);