openvibers/openvibe.network Commits

Initial OpenVibe release

OP OpenVibers committed 17 Aug 2026 · db04c27fa6 · 61 files, +23790 −0


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 00000000..046d0fd8
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,78 @@
++# ═══════════════════════════════════════════════════════════════
++# openvibe.network — Environment Configuration
++# Copy to .env and fill in values. No secrets belong in this file.
++# Production env lives at /etc/openvibe/network.env (mode 0600).
++# ═══════════════════════════════════════════════════════════════
++
++# Server
++PORT=4000
++HOST=0.0.0.0
++NODE_ENV=development
++BASE_URL=http://localhost:4000
++OV_NETWORK_URL=http://localhost:4000
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++OV_NETWORK_LOGIN_URL=http://localhost:4000
++
++# Security — CHANGE THESE IN PRODUCTION
++# Internal API key shared with OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games,
++# and OpenVibe.Media for /internal/* calls (header X-Internal-Key)
++INTERNAL_API_KEY=change-me-to-a-random-64-char-string
++SETUP_TOKEN=change-me-to-a-second-random-secret
++BOOTSTRAP_PROFILE=local-dev
++
++# Admin account auto-created on first run
++ADMIN_USERNAME=admin
++ADMIN_PASSWORD=change-me
++
++# Owner account (gates owner-only admin areas: Deploy, SSH, secrets)
++OWNER_USERNAME=
++
++# Paths (relative to project root)
++DB_PATH=./data/network.db
++AVATAR_PATH=data/avatars
++JWT_PRIVATE_KEY=data/keys/private.pem
++JWT_PUBLIC_KEY=data/keys/public.pem
++
++# Public service URLs (defaults shown; override for local dev / white-label)
++#OV_LIVE_URL=https://openvibe.live
++#OV_TOOLS_URL=https://openvibe.tools
++#OV_GAMES_URL=https://openvibe.games
++#OV_MEDIA_URL=https://openvibe.media
++
++# Connected services (internal, not proxied through Cloudflare)
++OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
++OV_TOOLS_INTERNAL_URL=http://127.0.0.1:4001
++OV_GAMES_INTERNAL_URL=http://127.0.0.1:8000
++OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
++
++# Optional: SSH admin-tab context (non-secret display values)
++#SSH_SERVER_HOST=
++#SSH_PROJECTS_ROOT=/opt/openvibe.network /opt/openvibe.live
++
++# ═══════════════════════════════════════════════════════════════
++# Email Notifications (optional)
++#
++# Email is configured and managed through the admin panel.
++# For Resend integration, enter your API key and from-addresses in the Admin UI.
++# ═══════════════════════════════════════════════════════════════
++
++# ═══════════════════════════════════════════════════════════════
++# Notifications
++# ═══════════════════════════════════════════════════════════════
++# Enable/disable the notification system globally
++NOTIFICATIONS_ENABLED=true
++# Days to keep old notifications before cleanup (default: 30)
++NOTIFICATION_MAX_AGE_DAYS=30
++# Only send emails for CRITICAL priority notifications (recommended)
++NOTIFICATION_EMAIL_CRITICAL_ONLY=true
++
++# ═══════════════════════════════════════════════════════════════
++# RSA Key Generation (run once during setup):
++#
++# mkdir -p data/keys
++# openssl genrsa -out data/keys/private.pem 2048
++# openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
++#
++# The private key stays on openvibe.network only. Other services verify
++# tokens OFFLINE via the JWKS endpoint (/api/.well-known/jwks).
++# ═══════════════════════════════════════════════════════════════
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 00000000..3a572962
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,8 @@
++node_modules/
++data/
++dist/
++.env
++*.db
++*.db-wal
++*.db-shm
++*.log
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 00000000..4bd6bbf7
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,341 @@
++# OpenVibe.Network
++
++Identity and account service for the OpenVibe network. Manages user accounts, OAuth2 authorization, theme preferences, notifications, email alerts, anonymous identities, the network-wide OpenCoins wallet, and internal APIs that connect all OpenVibe services together.
++
++**Part of the [OpenVibe](../ARCHITECTURE.md)** — `https://openvibe.network`
++
++---
++
++## What it does
++
++- **SSO Identity Provider** — "One Account. All of OpenVibe." Central registration and login, OAuth2 Authorization Code flow for OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and OpenVibe.Media.
++- **Unified Notification System** — Cross-service notifications with priority levels, category filtering, toast popups, bell badge, sounds, and rich content (buttons, inputs, media). All services push notifications to the central API; clients poll every 15 seconds.
++- **Email Alerts** — Critical notifications (moderation actions, system alerts) can be delivered by the built-in email service. Configuration is managed through the admin panel.
++- **Anonymous Users** — Browse and interact without an account. Anon users receive a unique number, can accumulate stats, and optionally link to a registered account later.
++- **Multi-Account Switching** — Google-style account management. Users can add multiple accounts and switch instantly, including an anonymous mode.
++- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
++- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
++- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Protected by `X-Internal-Key`.
++- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
++
++---
++
++## Architecture
++
++```
++openvibe.network (port 4000)
++├── server/
++│ ├── index.js # Express app, CORS, service init, periodic tasks
++│ ├── config.js # Port, JWT, DB, OAuth, connected services
++│ ├── auth/
++│ │ ├── routes.js # Register, login, profile, anon sessions, multi-account, follows
++│ │ └── oauth-routes.js # OAuth2 authorize, token, OIDC discovery
++│ ├── notifications/
++│ │ ├── notification-service.js # CRUD, preferences, email queue, cleanup
++│ │ ├── routes.js # REST API for notification UI
++│ │ └── email-service.js # Resend email service with HTML templates
++│ ├── admin/
++│ │ └── routes.js # Admin panel API (email settings, users, broadcast, health)
++│ ├── themes/
++│ │ └── routes.js # Theme CRUD, user preferences
++│ ├── internal/
++│ │ └── routes.js # Server-to-server API (verify-token, user sync, notif push)
++│ └── db/
++│ └── database.js # SQLite schema, migrations, seeding
++├── public/
++│ ├── index.html # Landing page (hero + service cards)
++│ ├── login.html # Animated login/register page
++│ └── my.html # Account management (profile, sessions, notifications)
++├── deploy/
++│ ├── nginx/ # openvibe.network.conf
++│ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env)
++└── .env.example
++```
++
++---
++
++## Setup
++
++```bash
++# 1. Install dependencies
++cd OpenVibe.Network && npm install
++
++# 2. Generate RSA keys for JWT signing
++mkdir -p data/keys
++openssl genrsa -out data/keys/private.pem 2048
++openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
++
++# 3. Configure environment
++cp .env.example .env
++# Edit .env — set INTERNAL_API_KEY, ADMIN_USERNAME, ADMIN_PASSWORD, and optionally SETUP_TOKEN and BOOTSTRAP_PROFILE
++
++# 4. Run
++npm start
++
++After the first run the service will seed the URL registry with safe defaults and create an admin account from environment values. Visit `/api/setup/status` for setup health and use `/api/setup/bootstrap` to apply registry profiles manually.
++```
++
++The server auto-creates the SQLite database and seeds OAuth2 clients on first run. Client secrets are logged to console once on creation — copy them to the consuming services' env files (`/etc/openvibe/<svc>.env`).
++
++---
++
++## TLS / Certbot Deploy Support
++
++`openvibe.network` includes a built-in deploy subsystem under `server/deploy/` that can manage Let’s Encrypt certificates and preview Nginx config.
++
++### What it does
++
++- wraps local `certbot` calls via `server/deploy/cert-manager.js`
++- supports `cloudflare` mode using `certbot-dns-cloudflare`
++- supports `manual` DNS-01 issuance for wildcard certificates
++- stores certs under `/etc/letsencrypt/live/<domain>/`
++- generates Nginx config templates that reference `/etc/letsencrypt/live/.../fullchain.pem` and `privkey.pem`
++
++### Requirements
++
++- `certbot` installed and available in `PATH`
++- for Cloudflare mode: `certbot-dns-cloudflare` plugin installed
++- `/etc/letsencrypt` writable by the service user
++- `nginx` installed if you want preview/apply support
++
++### Deploy configuration keys
++
++Use the `network` deploy setup API or URL registry to configure:
++
++- `DEPLOY_ACME_EMAIL` — ACME registration email
++- `DEPLOY_CERT_MODE` — `cloudflare`, `manual`, or `none`
++- `DEPLOY_CLOUDFLARE_TOKEN` — Cloudflare API token for DNS-01 challenge
++- `DEPLOY_DOMAINS` — array of domain objects, e.g. `[{domain:'openvibe.network',wildcard:false,certName:'openvibe.network',services:['network']}]`
++- `DEPLOY_NGINX_MODE` — `preview`, `apply`, or `disabled`
++- `DEPLOY_NGINX_SITES_PATH` — path where Nginx site files should be written
++- `DEPLOY_NGINX_BACKUP_PATH` — backup directory for generated Nginx configs
++- `DEPLOY_SERVICE_MAP` — optional service mapping override
++
++### Admin deploy endpoints
++
++The deploy subsystem exposes admin-only APIs at `/api/admin/deploy`:
++
++- `GET /api/admin/deploy/prerequisites` — check `certbot`, plugin, `/etc/letsencrypt`, and `nginx`
++- `GET /api/admin/deploy/config` — read current deploy config
++- `PUT /api/admin/deploy/config` — save deploy config values
++- `GET /api/admin/deploy/certs` — list certificates known to certbot
++- `POST /api/admin/deploy/certs/issue-cloudflare` — issue a wildcard cert via Cloudflare DNS-01
++- `POST /api/admin/deploy/certs/manual-info` — get manual DNS challenge instructions
++- `POST /api/admin/deploy/certs/issue-manual` — run manual DNS issuance after TXT records are in place
++- `POST /api/admin/deploy/certs/renew` — renew all certbot certificates
++- `GET /api/admin/deploy/nginx/preview` — preview generated Nginx configs
++
++### How to use it
++
++1. Install certbot:
++
++```bash
++sudo apt update
++sudo apt install certbot python3-certbot-dns-cloudflare
++```
++
++2. Ensure `nginx` is installed if you want config preview/apply support.
++
++3. Set deploy registry values through the setup API or admin config.
++
++4. For Cloudflare mode, provide a valid Cloudflare token. The wrapper writes `/etc/letsencrypt/cloudflare.ini`.
++
++5. For manual mode, create the required DNS TXT records for `_acme-challenge.<domain>` and `*. <domain>` as instructed by the service.
++
++6. After issuance, Nginx configs reference certs at `/etc/letsencrypt/live/<domain>/fullchain.pem` and `/etc/letsencrypt/live/<domain>/privkey.pem`.
++
++7. Use `POST /api/admin/deploy/certs/renew` to renew existing certs.
++
++### Notes
++
++- The deploy module does not replace a full deployment toolchain; it is a built-in helper for cert issuance and Nginx preview.
++- If `DEPLOY_CERT_MODE` is set to `none`, the service will still generate preview Nginx configs but will leave SSL certificate paths commented out.
++- `certbot` must be installed on the host running openvibe.network, not just in Docker or another container.
++
++---
++
++## Notification System
++
++### How it works
++
++1. **Any service** pushes notifications to openvibe.network via `POST /internal/notifications/push`
++2. **openvibe.network** stores them in SQLite with priority, category, and optional rich content
++3. **Clients** poll `GET /api/notifications` every 15 seconds, rendering toasts and updating the bell badge
++4. **Critical notifications** are queued for email delivery via the built-in email service (Resend by default).
++
++### Priorities
++
++| Priority | Behavior |
++|----------|----------|
++| `low` | Silent — badge only, no toast or sound |
++| `normal` | Standard toast with subtle notification sound |
++| `high` | Persistent toast with attention sound, auto-dismiss 8s |
++| `critical` | Sticky toast (must dismiss), urgent sound, **triggers email** |
++
++### Categories
++
++`social`, `chat`, `game`, `stream`, `economy`, `achievement`, `moderation`, `system`, `service`, `admin`
++
++Users can toggle each category's enabled/sound/toast/email preferences at `openvibe.network/my` → Notifications tab.
++
++### Cross-Service Push
++
++Other services push notifications to openvibe.network via the internal API:
++
++```bash
++curl -X POST http://127.0.0.1:4000/internal/notifications/push \
++ -H "X-Internal-Key: $INTERNAL_API_KEY" \
++ -H "Content-Type: application/json" \
++ -d '{"userId": 42, "type": "new_follower", "data": {"actorName": "someone"}}'
++```
++
++---
++
++## Email Setup
++
++Email is optional — the notification system works without it. Email is only sent for **CRITICAL** priority notifications.
++
++### Quick setup
++
++1. Create a domain or sender identity at <https://resend.com/domains>.
++2. Add the required SPF, DKIM, and MX records to your DNS provider.
++3. Configure the Resend API key and from-addresses in the admin panel.
++4. Verify your domain on Resend before sending production email.
++
++### Admin panel configuration
++
++Instead of `.env`, configure email at runtime via the admin panel:
++- `GET /api/admin/email` — view current email config
++- `PUT /api/admin/email` — update API key, default from address, and per-service from addresses
++- `POST /api/admin/email/test` — send a test email
++
++---
++
++## Anonymous Users
++
++Anonymous users can browse and interact without creating an account:
++
++- Each anon user gets a unique number (e.g., "Anonymous #42")
++- Stats and preferences are tracked via a session token
++- Fingerprint matching reconnects returning anonymous visitors
++- Anon identities can be linked to a registered account at any time, merging stats
++- Multi-account switcher includes a "Continue as Anonymous" option
++
++### API
++
++- `POST /api/auth/anon-session` — create anonymous session
++- `GET /api/auth/anon/:token` — get anon user info
++- `PUT /api/auth/anon/:token/preferences` — update anon preferences
++- `POST /api/auth/anon/:token/link` — link anon identity to registered account
++
++---
++
++## Multi-Account Switching
++
++Google-style account management supporting up to 5 accounts:
++
++- Accounts stored client-side in `localStorage` (`openvibe_accounts`)
++- Server tracks active sessions via `user_sessions` table
++- Switch accounts instantly without re-authentication
++- Includes anonymous mode as a switchable identity
++- Shared `account-switcher.js` component renders the switcher UI on all services
++
++### API
++
++- `GET /api/auth/sessions` — list active sessions
++- `POST /api/auth/sessions` — create session record
++- `DELETE /api/auth/sessions/:id` — revoke single session
++- `DELETE /api/auth/sessions` — revoke all sessions
++
++---
++
++## OAuth2 Flow
++
++1. Service redirects user to `https://openvibe.network/oauth/authorize?client_id=...&redirect_uri=...`
++2. If user has `ov_token` cookie → issue authorization code → redirect to service
++3. If no cookie → redirect to `openvibe.network` → user signs in → cookie set → resume flow
++4. Service exchanges code for access + refresh tokens via `POST /token`
++5. Access tokens are RS256 JWT (24h) verified by any service with the public key
++6. Refresh tokens rotate on use (30d)
++
++---
++
++## Shared Client Libraries
++
++The `packages/openvibe-shared/` directory provides drop-in vanilla JS components served at `/shared/`:
++
++| File | Purpose |
++|------|---------|
++| `navbar.js` | Universal top bar with service links, notification bell mount, user dropdown |
++| `notification-ui.js` | Toast popups, bell badge, notification panel with category tabs |
++| `account-switcher.js` | Multi-account switcher panel with anonymous mode |
++| `user-card.js` | Right-click context menu + user profile card with name effects |
++| `notifications.js` | Shared constants (priorities, categories, types) — also used server-side |
++
++Include them in any service page:
++```html
++<script src="https://openvibe.network/shared/navbar.js"></script>
++<script src="https://openvibe.network/shared/notification-ui.js"></script>
++<script src="https://openvibe.network/shared/account-switcher.js"></script>
++<script src="https://openvibe.network/shared/user-card.js"></script>
++```
++
++---
++
++## Admin Panel
++
++Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
++
++| Endpoint | Description |
++|----------|-------------|
++| `GET /api/admin/email` | Email configuration status |
++| `PUT /api/admin/email` | Update API key and email settings |
++| `POST /api/admin/email/test` | Send test email |
++| `GET /api/admin/settings` | All site settings (secrets masked) |
++| `PUT /api/admin/settings` | Update site settings |
++| `PUT /api/admin/users/:id/role` | Change user role |
++| `PUT /api/admin/users/:id/ban` | Ban/unban user (sends notification) |
++| `POST /api/admin/broadcast` | Send notification to all non-banned users |
++| `GET /api/admin/health` | System health (counts, memory, uptime, email status) |
++| `GET /api/admin/audit` | Audit log |
++
++---
++
++## Connected Services
++
++| Service | Port | OAuth Client ID |
++|---------|------|-----------------|
++| OpenVibe.Live | 3000 | `live` |
++| OpenVibe.Tools | 4001 | `tools` |
++| OpenVibe.Games | 8000 | `games` |
++| OpenVibe.Media | 4100 | `media` |
++
++---
++
++## Database Tables
++
++| Table | Purpose |
++|-------|---------|
++| `users` | Accounts (username, email, password hash, role, bio, effects) |
++| `oauth_clients` | Registered OAuth2 applications |
++| `oauth_codes` | Authorization codes (5-min TTL) |
++| `refresh_tokens` | Rotating refresh tokens (30-day) |
++| `linked_accounts` | Connected external service accounts |
++| `themes` | Theme catalog entries |
++| `user_theme_prefs` | Per-user theme selections |
++| `site_settings` | Key-value admin settings (email config, etc.) |
++| `audit_log` | Admin action audit trail |
++| `notifications` | All user notifications (UUID PK) |
++| `notification_preferences` | Per-user category preferences |
++| `anon_users` | Anonymous user identities |
++| `user_sessions` | Active session tracking for multi-account |
++| `user_effects` | Equipped name/particle effects |
++| `follows` | User follow relationships |
++| `wallets` | OpenCoins balance per Network user |
++| `coin_transactions` | OpenCoins ledger (idempotency-key deduped) |
++
++---
++
++## License
++
++Same as the parent [OpenVibe](../LICENSE) project.
ddiff --git a/deploy/nginx/openvibe.network.conf b/deploy/nginx/openvibe.network.conf
nnew file mode 100644
iindex 00000000..020647ba
---- /dev/null
++++ b/deploy/nginx/openvibe.network.conf
@@@ -0,0 +1,112 @@
++# ═══════════════════════════════════════════════════════════════
++# openvibe.network — Nginx Configuration (REFERENCE)
++#
++# NOTE: This file is a REFERENCE template.
++# Production configs can also be generated and applied via the
++# openvibe.network admin panel (Deploy → Nginx).
++#
++# Proxy to the openvibe-network Node.js server on port 4000.
++# Certificate: /etc/letsencrypt/live/openvibe.network/
++# my.openvibe.network is legacy and 301s to the apex.
++# ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=openvibenetwork_api:10m rate=10r/s;
++limit_req_zone $binary_remote_addr zone=openvibenetwork_auth:10m rate=5r/m;
++
++# ── HTTP → HTTPS redirect ──────────────────────────────────
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.network my.openvibe.network;
++ return 301 https://openvibe.network$request_uri;
++}
++
++# ── Legacy my. subdomain → apex ────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name my.openvibe.network;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.network/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.network/privkey.pem;
++
++ return 301 https://openvibe.network$request_uri;
++}
++
++# ── HTTPS server ───────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.network;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.network/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.network/privkey.pem;
++
++ access_log /var/log/nginx/openvibe.network.access.log;
++ error_log /var/log/nginx/openvibe.network.error.log;
++
++ client_max_body_size 5m;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header X-Frame-Options SAMEORIGIN always;
++ add_header Referrer-Policy strict-origin-when-cross-origin always;
++
++ # ── Auth endpoints (strict rate limit) ──────────────────
++ location ~ ^/(api/auth/(register|login|anon-session)|oauth/(token|authorize)) {
++ limit_req zone=openvibenetwork_auth burst=5 nodelay;
++ proxy_pass http://127.0.0.1:4000;
++ 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;
++ }
++
++ # ── Shared static assets (long cache, CORS *) ───────────
++ location /shared/ {
++ proxy_pass http://127.0.0.1:4000;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_cache_valid 200 1h;
++ add_header Cache-Control "public, max-age=3600";
++ }
++
++ # ── API (moderate rate limit) ───────────────────────────
++ location /api/ {
++ limit_req zone=openvibenetwork_api burst=30 nodelay;
++ proxy_pass http://127.0.0.1:4000;
++ 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;
++ }
++
++ # ── Internal API (server-to-server only, never public) ──
++ location /internal/ {
++ allow 127.0.0.1;
++ allow ::1;
++ deny all;
++ proxy_pass http://127.0.0.1:4000;
++ 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;
++ }
++
++ # ── Static / SPA ────────────────────────────────────────
++ location / {
++ proxy_pass http://127.0.0.1:4000;
++ 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;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
nnew file mode 100755
iindex 00000000..f298dfea
---- /dev/null
++++ b/deploy/scripts/deploy.sh
@@@ -0,0 +1,98 @@
++#!/usr/bin/env bash
++# ═══════════════════════════════════════════════════════════════════════
++# OpenVibe.Network — Deploy Script
++# Pulls latest from GitHub, updates the systemd service unit, and restarts the service.
++#
++# Usage (from the server):
++# sudo /opt/openvibe.network/deploy/scripts/deploy.sh
++#
++# Or remotely:
++# ssh openvibe.network "sudo /opt/openvibe.network/deploy/scripts/deploy.sh"
++# ═══════════════════════════════════════════════════════════════════════
++set -euo pipefail
++
++REPO_DIR="${REPO_DIR:-/opt/openvibe.network}"
++SERVICE="${SERVICE:-openvibe-network}"
++SERVICE_UNIT_SOURCE="${SERVICE_UNIT_SOURCE:-$REPO_DIR/deploy/systemd/${SERVICE}.service}"
++SERVICE_UNIT_DEST="${SERVICE_UNIT_DEST:-/etc/systemd/system/${SERVICE}.service}"
++SITE_URL="${SITE_URL:-https://openvibe.network}"
++API_URL="${API_URL:-http://127.0.0.1:4000}"
++GIT_REMOTE="${GIT_REMOTE:-origin}"
++GIT_BRANCH="${GIT_BRANCH:-main}"
++HEALTH_PATH="${HEALTH_PATH:-/api/health}"
++DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-15}"
++
++cd "$REPO_DIR"
++
++echo "╔══════════════════════════════════════╗"
++echo "║ OpenVibe.Network Deploy Script ║"
++echo "╚══════════════════════════════════════╝"
++echo ""
++
++# 1. Record current commit before pull
++OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo "none")
++echo "[Deploy] Current commit: ${OLD_HASH:0:8}"
++
++# 2. Pull latest from GitHub
++echo "[Deploy] Pulling from ${GIT_REMOTE} ${GIT_BRANCH}..."
++git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-only
++NEW_HASH=$(git rev-parse HEAD)
++echo "[Deploy] New commit: ${NEW_HASH:0:8}"
++
++# 3. Update systemd service config if the repo includes one
++UNIT_UPDATED=false
++if [ -f "$SERVICE_UNIT_SOURCE" ]; then
++ echo "[Deploy] Found service unit source: ${SERVICE_UNIT_SOURCE}"
++ if [ ! -f "$SERVICE_UNIT_DEST" ] || ! cmp -s "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"; then
++ echo "[Deploy] Installing updated service unit to ${SERVICE_UNIT_DEST}"
++ sudo cp "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"
++ sudo chmod 644 "$SERVICE_UNIT_DEST"
++ UNIT_UPDATED=true
++ else
++ echo "[Deploy] Service unit is already up to date."
++ fi
++ if [ "$UNIT_UPDATED" = true ]; then
++ echo "[Deploy] Reloading systemd daemon..."
++ sudo systemctl daemon-reload
++ sudo systemctl enable "$SERVICE" >/dev/null 2>&1 || true
++ fi
++else
++ echo "[Deploy] No service unit file found at ${SERVICE_UNIT_SOURCE}, skipping systemd update."
++fi
++
++# 4. Check if there are actually new commits
++if [ "$OLD_HASH" = "$NEW_HASH" ]; then
++ echo "[Deploy] Already up to date — no new commits."
++ echo "[Deploy] Restarting service anyway..."
++ sudo systemctl restart "$SERVICE"
++ echo "[Deploy] Done. ✅"
++ exit 0
++fi
++
++# 5. Get commit log between old and new
++COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${NEW_HASH}" 2>/dev/null || echo "Update deployed")
++COMMIT_COUNT=$(echo "$COMMIT_LOG" | wc -l | tr -d ' ')
++echo "[Deploy] ${COMMIT_COUNT} new commit(s):"
++echo "$COMMIT_LOG"
++echo ""
++
++# 6. Restart the service
++echo "[Deploy] Restarting ${SERVICE}..."
++sudo systemctl restart "$SERVICE"
++
++# 7. Wait for service to come back up
++if [ "$COMMIT_COUNT" -gt 0 ]; then
++ echo -n "[Deploy] Waiting for server..."
++ for i in $(seq 1 "${DEPLOY_TIMEOUT}"); do
++ sleep 1
++ if curl -sf "${API_URL}${HEALTH_PATH}" > /dev/null 2>&1; then
++ echo " up! ✅"
++ break
++ fi
++ echo -n "."
++ done
++fi
++
++echo ""
++echo "[Deploy] Deployment complete! 🎉"
++echo "[Deploy] ${COMMIT_COUNT} commit(s) deployed: ${OLD_HASH:0:8} → ${NEW_HASH:0:8}"
ddiff --git a/deploy/systemd/openvibe-network.service b/deploy/systemd/openvibe-network.service
nnew file mode 100644
iindex 00000000..b07495ec
---- /dev/null
++++ b/deploy/systemd/openvibe-network.service
@@@ -0,0 +1,23 @@
++[Unit]
++Description=OpenVibe.Network — Identity, SSO & OpenCoins (port 4000)
++After=network.target
++
++[Service]
++Type=simple
++User=ubuntu
++WorkingDirectory=/opt/openvibe.network
++# All secrets and env config live outside the repo (mode 0600):
++EnvironmentFile=/etc/openvibe/network.env
++Environment=NODE_ENV=production
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.network/server/index.js
++Restart=always
++RestartSec=5
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++ReadWritePaths=/opt/openvibe.network/data
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 00000000..2f243fb1
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,2026 @@
++{
++ "name": "openvibe-network",
++ "version": "1.0.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-network",
++ "version": "1.0.0",
++ "license": "MIT",
++ "dependencies": {
++ "bcryptjs": "^2.4.3",
++ "better-sqlite3": "^11.0.0",
++ "cookie-parser": "^1.4.7",
++ "cors": "^2.8.5",
++ "discord.js": "^14.26.3",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "jsonwebtoken": "^9.0.0",
++ "multer": "^1.4.5-lts.1",
++ "openvibe-shared": "file:./packages/openvibe-shared",
++ "uuid": "^11.1.0",
++ "web-push": "^3.6.7"
++ }
++ },
++ "node_modules/@discordjs/builders": {
++ "version": "1.14.1",
++ "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
++ "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@discordjs/formatters": "^0.6.2",
++ "@discordjs/util": "^1.2.0",
++ "@sapphire/shapeshift": "^4.0.0",
++ "discord-api-types": "^0.38.40",
++ "fast-deep-equal": "^3.1.3",
++ "ts-mixer": "^6.0.4",
++ "tslib": "^2.6.3"
++ },
++ "engines": {
++ "node": ">=16.11.0"
++ },
++ "funding": {
++ "url": "https://github.com/discordjs/discord.js?sponsor"
++ }
++ },
++ "node_modules/@discordjs/collection": {
++ "version": "1.5.3",
++ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
++ "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=16.11.0"
++ }
++ },
++ "node_modules/@discordjs/formatters": {
++ "version": "0.6.2",
++ "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
++ "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "discord-api-types": "^0.38.33"
++ },
++ "engines": {
++ "node": ">=16.11.0"
++ },
++ "funding": {
++ "url": "https://github.com/discordjs/discord.js?sponsor"
++ }
++ },
++ "node_modules/@discordjs/rest": {
++ "version": "2.6.3",
++ "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz",
++ "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@discordjs/collection": "^2.1.1",
++ "@discordjs/util": "^1.2.0",
++ "@sapphire/async-queue": "^1.5.3",
++ "@sapphire/snowflake": "^3.5.5",
++ "@vladfrangu/async_event_emitter": "^2.4.6",
++ "discord-api-types": "^0.38.50",
++ "magic-bytes.js": "^1.13.0",
++ "tslib": "^2.6.3",
++ "undici": "^6.27.0"
++ },
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "url": "https://github.com/discordjs/discord.js?sponsor"
++ }
++ },
++ "node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
++ "version": "2.1.1",
++ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
++ "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "url": "https://github.com/discordjs/discord.js?sponsor"
++ }
++ },
++ "node_modules/@discordjs/util": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
++ "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "discord-api-types": "^0.38.33"
++ },
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "url": "https://github.com/discordjs/discord.js?sponsor"
++ }
++ },
++ "node_modules/@discordjs/ws": {
++ "version": "1.2.3",
++ "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
++ "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@discordjs/collection": "^2.1.0",
++ "@discordjs/rest": "^2.5.1",
++ "@discordjs/util": "^1.1.0",
++ "@sapphire/async-queue": "^1.5.2",
++ "@types/ws": "^8.5.10",
++ "@vladfrangu/async_event_emitter": "^2.2.4",
++ "discord-api-types": "^0.38.1",
++ "tslib": "^2.6.2",
++ "ws": "^8.17.0"
++ },
++ "engines": {
++ "node": ">=16.11.0"
++ },
++ "funding": {
++ "url": "https://github.com/discordjs/discord.js?sponsor"
++ }
++ },
++ "node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
++ "version": "2.1.1",
++ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
++ "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "url": "https://github.com/discordjs/discord.js?sponsor"
++ }
++ },
++ "node_modules/@sapphire/async-queue": {
++ "version": "1.5.5",
++ "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
++ "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=v14.0.0",
++ "npm": ">=7.0.0"
++ }
++ },
++ "node_modules/@sapphire/shapeshift": {
++ "version": "4.0.0",
++ "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
++ "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "lodash": "^4.17.21"
++ },
++ "engines": {
++ "node": ">=v16"
++ }
++ },
++ "node_modules/@sapphire/snowflake": {
++ "version": "3.5.5",
++ "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
++ "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=v14.0.0",
++ "npm": ">=7.0.0"
++ }
++ },
++ "node_modules/@types/node": {
++ "version": "26.2.0",
++ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
++ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "undici-types": "~8.3.0"
++ }
++ },
++ "node_modules/@types/ws": {
++ "version": "8.18.1",
++ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
++ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
++ "license": "MIT",
++ "dependencies": {
++ "@types/node": "*"
++ }
++ },
++ "node_modules/@vladfrangu/async_event_emitter": {
++ "version": "2.4.7",
++ "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
++ "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=v14.0.0",
++ "npm": ">=7.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/agent-base": {
++ "version": "7.1.4",
++ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
++ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 14"
++ }
++ },
++ "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/asn1.js": {
++ "version": "5.4.1",
++ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
++ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
++ "license": "MIT",
++ "dependencies": {
++ "bn.js": "^4.0.0",
++ "inherits": "^2.0.1",
++ "minimalistic-assert": "^1.0.0",
++ "safer-buffer": "^2.1.0"
++ }
++ },
++ "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/bcryptjs": {
++ "version": "2.4.3",
++ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
++ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
++ "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/bn.js": {
++ "version": "4.12.5",
++ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
++ "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
++ "license": "MIT"
++ },
++ "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/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/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/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-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/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/cors": {
++ "version": "2.8.6",
++ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
++ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
++ "license": "MIT",
++ "dependencies": {
++ "object-assign": "^4",
++ "vary": "^1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/discord-api-types": {
++ "version": "0.38.53",
++ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz",
++ "integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==",
++ "license": "MIT",
++ "workspaces": [
++ "scripts/actions/documentation"
++ ]
++ },
++ "node_modules/discord.js": {
++ "version": "14.27.0",
++ "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz",
++ "integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "@discordjs/builders": "^1.14.1",
++ "@discordjs/collection": "1.5.3",
++ "@discordjs/formatters": "^0.6.2",
++ "@discordjs/rest": "^2.6.2",
++ "@discordjs/util": "^1.2.0",
++ "@discordjs/ws": "^1.2.3",
++ "@sapphire/snowflake": "3.5.5",
++ "discord-api-types": "^0.38.49",
++ "fast-deep-equal": "3.1.3",
++ "lodash.snakecase": "4.1.1",
++ "magic-bytes.js": "^1.13.0",
++ "tslib": "^2.6.3",
++ "undici": "^6.27.0"
++ },
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "url": "https://github.com/discordjs/discord.js?sponsor"
++ }
++ },
++ "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/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/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/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/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/EvanHahn"
++ }
++ },
++ "node_modules/http_ece": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
++ "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=16"
++ }
++ },
++ "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/https-proxy-agent": {
++ "version": "7.0.6",
++ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
++ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
++ "license": "MIT",
++ "dependencies": {
++ "agent-base": "^7.1.2",
++ "debug": "4"
++ },
++ "engines": {
++ "node": ">= 14"
++ }
++ },
++ "node_modules/https-proxy-agent/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/https-proxy-agent/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/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/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": {
++ "version": "4.18.1",
++ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
++ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
++ "license": "MIT"
++ },
++ "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/lodash.snakecase": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
++ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
++ "license": "MIT"
++ },
++ "node_modules/magic-bytes.js": {
++ "version": "1.13.1",
++ "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz",
++ "integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==",
++ "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/minimalistic-assert": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
++ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
++ "license": "ISC"
++ },
++ "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": "packages/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/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/streamsearch": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
++ "engines": {
++ "node": ">=10.0.0"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.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/ts-mixer": {
++ "version": "6.0.4",
++ "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
++ "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
++ "license": "MIT"
++ },
++ "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/undici": {
++ "version": "6.28.0",
++ "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
++ "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.17"
++ }
++ },
++ "node_modules/undici-types": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
++ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
++ "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/uuid": {
++ "version": "11.1.1",
++ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
++ "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
++ "funding": [
++ "https://github.com/sponsors/broofa",
++ "https://github.com/sponsors/ctavan"
++ ],
++ "license": "MIT",
++ "bin": {
++ "uuid": "dist/esm/bin/uuid"
++ }
++ },
++ "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/web-push": {
++ "version": "3.6.7",
++ "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
++ "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
++ "license": "MPL-2.0",
++ "dependencies": {
++ "asn1.js": "^5.3.0",
++ "http_ece": "1.2.0",
++ "https-proxy-agent": "^7.0.0",
++ "jws": "^4.0.0",
++ "minimist": "^1.2.5"
++ },
++ "bin": {
++ "web-push": "src/cli.js"
++ },
++ "engines": {
++ "node": ">= 16"
++ }
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ },
++ "node_modules/ws": {
++ "version": "8.21.3",
++ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
++ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10.0.0"
++ },
++ "peerDependencies": {
++ "bufferutil": "^4.0.1",
++ "utf-8-validate": ">=5.0.2"
++ },
++ "peerDependenciesMeta": {
++ "bufferutil": {
++ "optional": true
++ },
++ "utf-8-validate": {
++ "optional": true
++ }
++ }
++ },
++ "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"
++ }
++ },
++ "packages/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 00000000..76f9fee1
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,30 @@
++{
++ "name": "openvibe-network",
++ "version": "1.0.0",
++ "description": "OpenVibe.Network — Identity, SSO, Accounts, Themes, Notifications, and OpenCoins Wallet",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/registry-bootstrap.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js",
++ "reset-db": "node server/reset-db.js",
++ "grant-admin": "node server/grant-admin.js"
++ },
++ "license": "MIT",
++ "dependencies": {
++ "bcryptjs": "^2.4.3",
++ "better-sqlite3": "^11.0.0",
++ "cookie-parser": "^1.4.7",
++ "cors": "^2.8.5",
++ "discord.js": "^14.26.3",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-shared": "file:./packages/openvibe-shared",
++ "jsonwebtoken": "^9.0.0",
++ "multer": "^1.4.5-lts.1",
++ "uuid": "^11.1.0",
++ "web-push": "^3.6.7"
++ }
++}
ddiff --git a/packages/openvibe-shared/account-switcher.js b/packages/openvibe-shared/account-switcher.js
nnew file mode 100644
iindex 00000000..cc16ff37
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/analytics.js b/packages/openvibe-shared/analytics.js
nnew file mode 100644
iindex 00000000..600f2ae1
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/auth-client.js b/packages/openvibe-shared/auth-client.js
nnew file mode 100644
iindex 00000000..7a533355
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/brand.js b/packages/openvibe-shared/brand.js
nnew file mode 100644
iindex 00000000..61ab011a
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/builtin-themes.js b/packages/openvibe-shared/builtin-themes.js
nnew file mode 100644
iindex 00000000..073ddaeb
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/index.js b/packages/openvibe-shared/index.js
nnew file mode 100644
iindex 00000000..2981bd7c
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/middleware.js b/packages/openvibe-shared/middleware.js
nnew file mode 100644
iindex 00000000..4509e163
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/navbar.js b/packages/openvibe-shared/navbar.js
nnew file mode 100644
iindex 00000000..8eecb7d6
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/notification-ui.js b/packages/openvibe-shared/notification-ui.js
nnew file mode 100644
iindex 00000000..68c54a55
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/notifications.js b/packages/openvibe-shared/notifications.js
nnew file mode 100644
iindex 00000000..252f7710
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/package.json b/packages/openvibe-shared/package.json
nnew file mode 100644
iindex 00000000..9f564601
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/test/url-resolver-deploy.test.js b/packages/openvibe-shared/test/url-resolver-deploy.test.js
nnew file mode 100644
iindex 00000000..da731d63
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/test/url-resolver.test.js b/packages/openvibe-shared/test/url-resolver.test.js
nnew file mode 100644
iindex 00000000..130b4792
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/theme-loader.js b/packages/openvibe-shared/theme-loader.js
nnew file mode 100644
iindex 00000000..60845627
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/theme-sync.js b/packages/openvibe-shared/theme-sync.js
nnew file mode 100644
iindex 00000000..a6e82937
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/url-resolver.js b/packages/openvibe-shared/url-resolver.js
nnew file mode 100644
iindex 00000000..404d9aa5
---- /dev/null
++++ b/packages/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/packages/openvibe-shared/user-card.js b/packages/openvibe-shared/user-card.js
nnew file mode 100644
iindex 00000000..c0d258eb
---- /dev/null
++++ b/packages/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);
ddiff --git a/public/admin.html b/public/admin.html
nnew file mode 100644
iindex 00000000..367864b6
---- /dev/null
++++ b/public/admin.html
@@@ -0,0 +1,3923 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>Admin Panel — OpenVibe</title>
++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
++<style>
++*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
++:root{
++ --bg:#0d0d14;--bg-secondary:#151520;--bg-card:#1a1a26;--bg-input:#1c1c28;--bg-tertiary:#222233;
++ --border:#2a2a3a;--border-focus:#8b5cf6;
++ --accent:#8b5cf6;--accent-glow:rgba(139,92,246,0.4);--accent-dark:#6d28d9;--accent-light:#a78bfa;
++ --text:#e0e0e0;--text-secondary:#b0b0b8;--text-muted:#707080;
++ --live-red:#e74c3c;--success:#2ecc71;--warning:#f59e0b;--danger:#ef4444;
++ --shadow:0 4px 20px rgba(0,0,0,0.3);
++}
++html{height:100%;background:var(--bg)}
++body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding-bottom:40px}
++
++/* Layout */
++.page{max-width:1200px;margin:0 auto;padding:20px}
++.page-title{display:flex;align-items:center;gap:12px;margin-bottom:24px}
++.page-title h1{font-size:22px;font-weight:800}
++.page-title .badge{font-size:10px;padding:3px 10px;border-radius:4px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;background:rgba(231,76,60,0.15);color:#e74c3c}
++
++/* Section tabs */
++.section-tabs-shell{display:flex;align-items:center;gap:8px;margin-bottom:24px}
++.section-tabs-wrap{flex:1;min-width:0;position:relative;display:flex;align-items:center}
++.section-tabs{
++ display:flex;gap:4px;background:var(--bg-secondary);border-radius:10px;padding:4px;
++ overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;flex:1;min-width:0;
++}
++.section-tabs::-webkit-scrollbar{height:0}
++.section-tabs button{
++ flex-shrink:0;padding:8px 14px;border:none;border-radius:8px;font-size:11px;font-weight:600;
++ background:transparent;color:var(--text-muted);cursor:pointer;transition:all .15s;white-space:nowrap;
++}
++.section-tabs button.active{background:var(--bg-card);color:var(--accent-light)}
++.section-tabs button:hover:not(.active){color:var(--text)}
++.tabs-scroll-btn{
++ width:34px;height:34px;flex:0 0 34px;border:none;border-radius:10px;display:flex;align-items:center;justify-content:center;
++ background:var(--bg-secondary);color:var(--text-muted);cursor:pointer;transition:all .15s;opacity:0;pointer-events:none;
++}
++.tabs-scroll-btn.visible{opacity:1;pointer-events:auto}
++.tabs-scroll-btn:hover{color:var(--accent-light);background:var(--bg-card)}
++.tabs-scroll-btn:disabled{opacity:.3;pointer-events:none}
++
++/* Section */
++.section{display:none;animation:sec-in .2s ease}
++.section.active{display:block}
++@keyframes sec-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
++
++/* Stats grid */
++.stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:24px}
++.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:16px;text-align:center}
++.stat-card .value{font-size:24px;font-weight:800;color:var(--accent-light)}
++.stat-card .label{font-size:11px;color:var(--text-muted);margin-top:4px;display:flex;align-items:center;justify-content:center;gap:6px}
++
++/* Cards */
++.card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}
++.card h3{font-size:14px;font-weight:700;margin-bottom:12px;display:flex;align-items:center;gap:8px}
++.card .chart-wrap{position:relative;width:100%;max-height:320px}
++.card canvas{max-height:320px}
++
++/* Tables */
++.admin-table{width:100%;border-collapse:collapse;font-size:13px}
++.admin-table thead{border-bottom:2px solid var(--border)}
++.admin-table th{padding:10px 8px;text-align:left;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted)}
++.admin-table td{padding:10px 8px;border-bottom:1px solid var(--border)}
++.admin-table tbody tr:hover{background:rgba(139,92,246,0.03)}
++
++/* Buttons */
++.btn{padding:8px 16px;border:none;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px}
++.btn-primary{background:var(--accent);color:#fff}
++.btn-primary:hover{background:var(--accent-dark)}
++.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
++.btn-outline:hover{border-color:var(--accent-dark);color:var(--accent-light)}
++.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red);border:1px solid rgba(231,76,60,0.2)}
++.btn-danger:hover{background:rgba(231,76,60,0.2)}
++.btn-success{background:rgba(46,204,113,0.1);color:var(--success);border:1px solid rgba(46,204,113,0.2)}
++.btn-success:hover{background:rgba(46,204,113,0.2)}
++.btn-sm{padding:5px 10px;font-size:11px}
++.btn:disabled{opacity:.4;cursor:not-allowed}
++
++/* Form fields */
++.form-field{margin-bottom:14px}
++.form-field label{display:block;font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:5px}
++.form-field input,.form-field select,.form-field textarea{
++ width:100%;padding:10px 12px;background:var(--bg-input);border:1px solid var(--border);
++ border-radius:8px;color:var(--text);font-size:13px;outline:none;transition:border-color .2s;
++}
++.form-field input:focus,.form-field select:focus,.form-field textarea:focus{border-color:var(--accent)}
++
++/* Input inline */
++.input-row{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
++.input-row input,.input-row select{
++ padding:8px 12px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;
++ color:var(--text);font-size:13px;outline:none;flex:1;min-width:160px;
++}
++.input-row input:focus{border-color:var(--accent)}
++
++/* Badges */
++.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase}
++.badge-success{background:rgba(46,204,113,0.15);color:#2ecc71}
++.badge-danger{background:rgba(231,76,60,0.15);color:#e74c3c}
++.badge-warning{background:rgba(245,158,11,0.15);color:#f59e0b}
++.badge-info{background:rgba(52,152,219,0.15);color:#3498db}
++.badge-admin{background:rgba(231,76,60,0.15);color:#e74c3c}
++.badge-global_mod{background:rgba(155,89,182,0.15);color:#9b59b6}
++.badge-streamer{background:rgba(155,89,182,0.15);color:#9b59b6}
++.badge-user{background:rgba(52,152,219,0.15);color:#3498db}
++
++/* Loading */
++.muted{color:var(--text-muted);font-size:13px}
++.loading{text-align:center;padding:40px;color:var(--text-muted)}
++
++/* Toast */
++.toast-container{position:fixed;top:60px;right:16px;z-index:200000;display:flex;flex-direction:column;gap:8px}
++.toast{padding:12px 18px;border-radius:8px;font-size:13px;font-weight:600;color:#fff;animation:toast-in .25s ease;box-shadow:var(--shadow)}
++.toast-success{background:#2ecc71}
++.toast-error{background:#e74c3c}
++.toast-info{background:#3498db}
++.toast-warning{background:#f59e0b}
++@keyframes toast-in{from{opacity:0;transform:translateX(20px)}to{opacity:1;transform:translateX(0)}}
++
++/* Sub-tabs */
++.sub-tabs{display:flex;gap:4px;margin-bottom:16px;overflow-x:auto;-webkit-overflow-scrolling:touch;flex-wrap:wrap}
++.sub-tabs button{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text-muted);font-size:11px;font-weight:600;cursor:pointer}
++.sub-tabs button.active{background:var(--bg-card);color:var(--accent-light);border-color:var(--accent-dark)}
++
++/* Setting rows */
++.setting-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)}
++.setting-row label{flex:1}
++.setting-row label strong{font-size:13px}
++.setting-row label small{font-size:11px;color:var(--text-muted)}
++
++/* Storage bars */
++.storage-bar{background:var(--bg-tertiary);border-radius:4px;height:14px;overflow:hidden;position:relative}
++.storage-bar-fill{height:100%;border-radius:4px;transition:width .5s}
++
++/* Sensitive fields */
++.sensitive-field{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
++.sensitive-field input{flex:1}
++.sensitive-actions{display:flex;gap:4px}
++
++@media(max-width:800px){
++ .page{padding:12px}
++ .stats-grid{grid-template-columns:repeat(2,1fr)}
++ #analytics-overview > div[style*="grid-template-columns:1fr 1fr"],
++ #analytics-service-detail > div[style*="grid-template-columns:1fr 1fr"],
++ #analytics-service-detail > div[style*="grid-template-columns:1fr 1fr 1fr"]{display:block!important}
++ #analytics-overview > div[style*="grid-template-columns"] > .card,
++ #analytics-service-detail > div[style*="grid-template-columns"] > .card{margin-bottom:12px}
++}
++@media(max-width:500px){
++ .stats-grid{grid-template-columns:1fr}
++ .admin-table{font-size:11px}
++ .section-tabs-shell{gap:6px}
++ .tabs-scroll-btn{width:30px;height:30px;flex-basis:30px;border-radius:8px}
++}
++.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}
++#sec-ssh code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}
++#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning,#f0b74a);background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}
++#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}
++</style>
++</head>
++<body>
++
++<div id="navbar-mount"></div>
++<div class="toast-container" id="toast-container"></div>
++
++<div class="page">
++ <div class="page-title">
++ <h1><i class="fa-solid fa-shield-halved" style="color:var(--accent)"></i> Admin Panel</h1>
++ <span class="badge" id="admin-role-badge">ADMIN</span>
++ </div>
++
++ <div class="section-tabs-shell">
++ <button class="tabs-scroll-btn" id="main-tabs-left" onclick="scrollMainTabs(-1)" title="Scroll left" aria-label="Scroll left">
++ <i class="fa-solid fa-chevron-left"></i>
++ </button>
++ <div class="section-tabs-wrap">
++ <div class="section-tabs" id="main-tabs">
++ <button class="admin-tab-btn active" onclick="showTab('dashboard',this)"><i class="fa-solid fa-chart-line"></i> Dashboard</button>
++ <button class="admin-tab-btn" onclick="showTab('users',this)"><i class="fa-solid fa-users"></i> Users</button>
++ <button class="admin-tab-btn" onclick="showTab('streams',this)"><i class="fa-solid fa-broadcast-tower"></i> Streams</button>
++ <button class="admin-tab-btn" onclick="showTab('bans',this)"><i class="fa-solid fa-ban"></i> Bans</button>
++ <button class="admin-tab-btn" onclick="showTab('chat-logs',this)"><i class="fa-solid fa-message"></i> Chat Logs</button>
++ <button class="admin-tab-btn" onclick="showTab('moderators',this)"><i class="fa-solid fa-shield-halved"></i> Moderators</button>
++ <button class="admin-tab-btn owner-only" onclick="showTab('settings',this)"><i class="fa-solid fa-gear"></i> Settings</button>
++ <button class="admin-tab-btn" onclick="showTab('tts',this)"><i class="fa-solid fa-volume-high"></i> TTS</button>
++ <button class="admin-tab-btn owner-only" onclick="showTab('cashouts',this)"><i class="fa-solid fa-money-bill-transfer"></i> Cashouts</button>
++ <button class="admin-tab-btn owner-only" onclick="showTab('payments',this)"><i class="fa-solid fa-credit-card"></i> Payments</button>
++ <button class="admin-tab-btn" onclick="showTab('ai',this)"><i class="fa-solid fa-robot"></i> AI</button>
++ <button class="admin-tab-btn" onclick="showTab('vpn',this)"><i class="fa-solid fa-user-shield"></i> VPN Queue</button>
++ <button class="admin-tab-btn" onclick="showTab('pastes',this)"><i class="fa-solid fa-paste"></i> Pastes</button>
++ <button class="admin-tab-btn owner-only" onclick="showTab('storage',this)"><i class="fa-solid fa-hard-drive"></i> Storage</button>
++ <button class="admin-tab-btn" onclick="showTab('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
++ <button class="admin-tab-btn owner-only" onclick="showTab('email',this)"><i class="fa-solid fa-envelope"></i> Email</button>
++ <button class="admin-tab-btn" onclick="showTab('vkeys',this)"><i class="fa-solid fa-key"></i> Keys</button>
++ <button class="admin-tab-btn" onclick="showTab('audit',this)"><i class="fa-solid fa-clipboard-list"></i> Audit</button>
++ <button class="admin-tab-btn" onclick="showTab('analytics',this)"><i class="fa-solid fa-chart-pie"></i> Analytics</button>
++ <button class="admin-tab-btn owner-only" onclick="showTab('deploy',this)"><i class="fa-solid fa-server"></i> Deploy</button>
++ <button class="admin-tab-btn" onclick="showTab('ssh',this)"><i class="fa-solid fa-terminal"></i> SSH</button>
++ </div>
++ </div>
++ <button class="tabs-scroll-btn" id="main-tabs-right" onclick="scrollMainTabs(1)" title="Scroll right" aria-label="Scroll right">
++ <i class="fa-solid fa-chevron-right"></i>
++ </button>
++ </div>
++
++ <!-- Dashboard -->
++ <div class="section active" id="sec-dashboard">
++ <div class="stats-grid" id="dashboard-stats"><div class="loading">Loading stats...</div></div>
++ <div class="card">
++ <h3><i class="fa-solid fa-server"></i> System Health</h3>
++ <div id="health-info" class="muted">Loading...</div>
++ </div>
++ </div>
++
++ <!-- Users -->
++ <div class="section" id="sec-users">
++ <div class="input-row">
++ <input type="text" id="user-search" placeholder="Search users by name or email..." onkeydown="if(event.key==='Enter')loadUsers()">
++ <button class="btn btn-primary" onclick="loadUsers()"><i class="fa-solid fa-search"></i> Search</button>
++ </div>
++ <div id="users-content"><div class="loading">Loading...</div></div>
++ <div id="users-pager" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:12px"></div>
++ </div>
++
++ <!-- Streams -->
++ <div class="section" id="sec-streams">
++ <div id="streams-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- Bans -->
++ <div class="section" id="sec-bans">
++ <div id="bans-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- Chat Logs -->
++ <div class="section" id="sec-chat-logs">
++ <div class="input-row">
++ <input type="text" id="chat-search-q" placeholder="Search messages..." onkeydown="if(event.key==='Enter')searchChatLogs()">
++ <input type="text" id="chat-search-uid" placeholder="Username, user ID, or anon123" style="max-width:200px" onkeydown="if(event.key==='Enter')searchChatLogs()">
++ <button class="btn btn-primary" onclick="searchChatLogs()"><i class="fa-solid fa-search"></i> Search</button>
++ </div>
++ <div id="chat-logs-content"><p class="muted">Enter a search query or user ID</p></div>
++ <div id="chat-logs-pager" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:12px"></div>
++ </div>
++
++ <!-- Moderators -->
++ <div class="section" id="sec-moderators">
++ <div class="input-row">
++ <input type="text" id="mod-username" placeholder="Username to promote..." onkeydown="if(event.key==='Enter')promoteMod()">
++ <button class="btn btn-primary" onclick="promoteMod()"><i class="fa-solid fa-shield-halved"></i> Promote to Global Mod</button>
++ </div>
++ <div id="mods-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- Settings -->
++ <div class="section" id="sec-settings">
++ <div class="sub-tabs" id="settings-sub-tabs">
++ <button class="active" onclick="showSettingsSub('tools',this)">OpenVibe.Tools</button>
++ <button onclick="showSettingsSub('streamer',this)">OpenVibe.Live</button>
++ <button onclick="showSettingsSub('media',this)">Media Tools</button>
++ <button onclick="showSettingsSub('urls',this)">Network URLs</button>
++ <button onclick="showSettingsSub('discord',this)">Discord</button>
++ </div>
++ <div id="settings-tools" class="settings-sub active"></div>
++ <div id="settings-streamer" class="settings-sub" style="display:none"></div>
++ <div id="settings-media" class="settings-sub" style="display:none"></div>
++ <div id="settings-urls" class="settings-sub" style="display:none"></div>
++ <div id="settings-discord" class="settings-sub" style="display:none"></div>
++ </div>
++
++ <!-- TTS -->
++ <div class="section" id="sec-tts">
++ <div id="tts-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- Cashouts -->
++ <div class="section" id="sec-cashouts">
++ <div id="cashouts-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- Payments: configure PayPal / Stripe / CCBill / crypto + subscription pricing -->
++ <div class="section" id="sec-payments">
++ <div id="payments-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- AI: configure the vision/text provider + usage cost breakdown -->
++ <div class="section" id="sec-ai">
++ <div id="ai-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- VPN Queue -->
++ <div class="section" id="sec-vpn">
++ <div id="vpn-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- Pastes -->
++ <div class="section" id="sec-pastes">
++ <div id="pastes-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- Storage -->
++ <div class="section" id="sec-storage">
++ <div id="storage-content"><div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div></div>
++ </div>
++
++ <!-- Notifications (Broadcast) -->
++ <div class="section" id="sec-notifications">
++ <div class="card">
++ <h3><i class="fa-solid fa-bullhorn"></i> Broadcast Notification</h3>
++ <p class="muted" style="margin-bottom:16px">Send a notification to all users across the OpenVibe.</p>
++ <div class="form-field">
++ <label>Title</label>
++ <input type="text" id="broadcast-title" placeholder="Notification title...">
++ </div>
++ <div class="form-field">
++ <label>Message</label>
++ <textarea id="broadcast-msg" rows="3" placeholder="Message body..." style="resize:vertical"></textarea>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
++ <div class="form-field">
++ <label>Icon (emoji)</label>
++ <input type="text" id="broadcast-icon" value="📢" style="text-align:center">
++ </div>
++ <div class="form-field">
++ <label>Priority</label>
++ <select id="broadcast-priority">
++ <option value="normal">Normal</option>
++ <option value="high">High</option>
++ <option value="critical">Critical</option>
++ </select>
++ </div>
++ <div class="form-field">
++ <label>URL (optional)</label>
++ <input type="text" id="broadcast-url" placeholder="https://...">
++ </div>
++ </div>
++ <button class="btn btn-primary" onclick="sendBroadcast()"><i class="fa-solid fa-paper-plane"></i> Send Broadcast</button>
++ <p class="muted" style="margin-top:10px;font-size:0.85rem"><i class="fa-solid fa-clock"></i> Admins can send one global notification every 6 hours. Owners are exempt.</p>
++ </div>
++ </div>
++
++ <!-- Email -->
++ <div class="section" id="sec-email">
++ <div class="card">
++ <h3><i class="fa-solid fa-envelope"></i> Email Configuration</h3>
++ <div id="email-content"><div class="loading">Loading...</div></div>
++ </div>
++ </div>
++
++ <!-- Verification Keys -->
++ <div class="section" id="sec-vkeys">
++ <div class="input-row">
++ <input type="text" id="vkey-username" placeholder="Username to reserve..." onkeydown="if(event.key==='Enter')generateVKey()">
++ <input type="text" id="vkey-note" placeholder="Note (optional)">
++ <button class="btn btn-primary" onclick="generateVKey()"><i class="fa-solid fa-key"></i> Generate Key</button>
++ </div>
++ <div id="vkeys-content"><div class="loading">Loading...</div></div>
++ </div>
++
++ <!-- Audit Log -->
++ <div class="section" id="sec-audit">
++ <div id="audit-content"><div class="loading">Loading...</div></div>
++ <div id="audit-pager" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:12px"></div>
++ </div>
++
++ <!-- Analytics -->
++ <div class="section" id="sec-analytics">
++ <div class="sub-tabs" id="analytics-sub-tabs">
++ <button class="active" onclick="showAnalyticsSubTab('overview',this)">Overview</button>
++ <button onclick="showAnalyticsSubTab('live',this)">OpenVibe.Live</button>
++ <button onclick="showAnalyticsSubTab('openvibe-network',this)">OpenVibe.Network</button>
++ <button onclick="showAnalyticsSubTab('tools',this)">OpenVibe.Tools</button>
++ <button onclick="showAnalyticsSubTab('games',this)">OpenVibe.Games</button>
++ <button onclick="showAnalyticsSubTab('media',this)">OpenVibe.Media</button>
++ <button onclick="showAnalyticsSubTab('bots',this)">Bot Analysis</button>
++ </div>
++ <div class="input-row" style="margin-bottom:16px">
++ <select id="analytics-period" onchange="reloadAnalytics()" style="max-width:180px">
++ <option value="1h">Last hour</option>
++ <option value="12h">Last 12 hours</option>
++ <option value="24h">Last 24 hours</option>
++ <option value="3d">Last 3 days</option>
++ <option value="7">Last 7 days</option>
++ <option value="14">Last 14 days</option>
++ <option value="30" selected>Last 30 days</option>
++ <option value="90">Last 90 days</option>
++ <option value="365">Last year</option>
++ </select>
++ <button class="btn btn-outline" onclick="reloadAnalytics()"><i class="fa-solid fa-rotate"></i> Refresh</button>
++ </div>
++
++ <!-- Overview sub-tab -->
++ <div id="analytics-overview">
++ <div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div>
++ <div class="card" id="analytics-realtime-card" style="display:none">
++ <h3><i class="fa-solid fa-signal" style="color:var(--success)"></i> Real-time (last 5 min)</h3>
++ <div id="analytics-realtime"></div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-chart-area"></i> Traffic Trend</h3>
++ <canvas id="chart-traffic-trend" height="260"></canvas>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-ranking-star"></i> Service Comparison</h3>
++ <canvas id="chart-service-compare" height="260"></canvas>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-users"></i> Unique Visitors Trend</h3>
++ <canvas id="chart-visitors-trend" height="240"></canvas>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-user-check"></i> Logged-in vs Anonymous</h3>
++ <canvas id="chart-auth-trend" height="240"></canvas>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-chart-pie"></i> Per-Service Traffic Share</h3>
++ <canvas id="chart-service-share" height="260"></canvas>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-chart-pie"></i> Per-Service Visitors</h3>
++ <canvas id="chart-service-visitors" height="260"></canvas>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-layer-group"></i> Per-Service Traffic Over Time</h3>
++ <canvas id="chart-per-service-trend" height="280"></canvas>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-robot"></i> Bots vs Humans</h3>
++ <canvas id="chart-bot-ratio" height="220"></canvas>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-network-wired"></i> Bandwidth &amp; Usage</h3>
++ <div id="analytics-bandwidth"></div>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-server"></i> Per-Service Traffic</h3>
++ <div id="analytics-service-table"></div>
++ </div>
++ </div>
++ </div>
++
++ <!-- Service detail sub-tab -->
++ <div id="analytics-service-detail" style="display:none">
++ <div class="stats-grid" id="svc-analytics-stats"><div class="loading">Loading...</div></div>
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-chart-line"></i> Daily Traffic</h3>
++ <canvas id="chart-svc-daily" height="260"></canvas>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-clock"></i> <span id="svc-hourly-title">Hourly (24h)</span></h3>
++ <canvas id="chart-svc-hourly" height="260"></canvas>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-gauge-high"></i> Response Time Percentiles</h3>
++ <div id="svc-response-percentiles"></div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-clock-rotate-left"></i> Peak Hours</h3>
++ <canvas id="chart-svc-peak-hours" height="200"></canvas>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-laptop"></i> Devices</h3>
++ <canvas id="chart-svc-devices" height="220"></canvas>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-globe"></i> Browsers</h3>
++ <canvas id="chart-svc-browsers" height="220"></canvas>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-desktop"></i> OS</h3>
++ <canvas id="chart-svc-os" height="220"></canvas>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-network-wired"></i> Bandwidth &amp; Sessions</h3>
++ <div id="svc-bandwidth-sessions"></div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-user-check"></i> Auth vs Anonymous</h3>
++ <canvas id="chart-svc-auth" height="200"></canvas>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-link"></i> Top Pages</h3>
++ <div id="svc-top-pages"></div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-bolt"></i> Top API Endpoints</h3>
++ <div id="svc-top-api"></div>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-arrow-up-right-from-square"></i> Top Referers</h3>
++ <div id="svc-top-referers"></div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-snail"></i> Slowest Endpoints</h3>
++ <div id="svc-slowest"></div>
++ </div>
++ </div>
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
++ <div class="card">
++ <h3><i class="fa-solid fa-circle-exclamation"></i> Status Codes</h3>
++ <canvas id="chart-svc-status" height="200"></canvas>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-earth-americas"></i> Countries</h3>
++ <div id="svc-countries"></div>
++ </div>
++ </div>
++ </div>
++
++ <!-- Bot analysis sub-tab -->
++ <div id="analytics-bots-detail" style="display:none">
++ <div id="bots-content"><div class="loading">Loading bot analysis...</div></div>
++ </div>
++ </div>
++
++ <!-- Deploy -->
++ <div class="section" id="sec-deploy">
++ <div class="sub-tabs" id="deploy-sub-tabs">
++ <button class="active" onclick="showDeploySubTab('overview',this)">Overview</button>
++ <button onclick="showDeploySubTab('certs',this)">Certificates</button>
++ <button onclick="showDeploySubTab('nginx',this)">Nginx</button>
++ <button onclick="showDeploySubTab('domains',this)">Domains</button>
++ <button onclick="showDeploySubTab('config',this)">Config</button>
++ </div>
++
++ <!-- Deploy: Overview -->
++ <div id="deploy-overview">
++ <div class="stats-grid" id="deploy-stats"><div class="loading">Loading deploy status...</div></div>
++ <div class="card" style="margin-top:16px">
++ <h3><i class="fa-solid fa-circle-check"></i> Prerequisites</h3>
++ <div id="deploy-prereqs"><div class="muted">Checking...</div></div>
++ </div>
++ </div>
++
++ <!-- Deploy: Certificates -->
++ <div id="deploy-certs-panel" style="display:none">
++ <div class="card">
++ <h3><i class="fa-solid fa-lock"></i> SSL/TLS Certificates</h3>
++ <p class="muted" style="margin:8px 0 16px">Manage Let's Encrypt wildcard certificates for all domains.</p>
++ <div id="deploy-certs-list"><div class="loading">Loading certificates...</div></div>
++ </div>
++ <div class="card" style="margin-top:16px">
++ <h3><i class="fa-solid fa-plus-circle"></i> Issue New Certificate</h3>
++ <div class="form-group" style="margin:12px 0">
++ <label>Domain</label>
++ <input type="text" id="cert-issue-domain" placeholder="example.com" style="width:300px">
++ </div>
++ <div style="display:flex;gap:8px;flex-wrap:wrap">
++ <button class="btn btn-primary" onclick="issueCertCloudflare()">
++ <i class="fa-brands fa-cloudflare"></i> Issue via Cloudflare DNS
++ </button>
++ <button class="btn btn-secondary" onclick="showManualDnsInfo()">
++ <i class="fa-solid fa-hand"></i> Manual DNS Mode
++ </button>
++ <button class="btn btn-secondary" onclick="renewAllCerts()">
++ <i class="fa-solid fa-rotate"></i> Renew All
++ </button>
++ </div>
++ <div id="cert-issue-output" style="margin-top:12px"></div>
++ </div>
++ </div>
++
++ <!-- Deploy: Nginx -->
++ <div id="deploy-nginx-panel" style="display:none">
++ <div class="card">
++ <h3><i class="fa-solid fa-sitemap"></i> Nginx Configuration</h3>
++ <p class="muted" style="margin:8px 0 16px">Preview and apply generated Nginx configs derived from your domain and service settings.</p>
++ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
++ <button class="btn btn-primary" onclick="previewNginxConfigs()">
++ <i class="fa-solid fa-eye"></i> Preview All Configs
++ </button>
++ <button class="btn btn-secondary" onclick="validateNginx()">
++ <i class="fa-solid fa-check-circle"></i> Validate Current Config
++ </button>
++ <button class="btn btn-warning" onclick="applyNginxConfigs()">
++ <i class="fa-solid fa-upload"></i> Apply & Reload
++ </button>
++ </div>
++ <div id="nginx-preview-output"></div>
++ </div>
++ </div>
++
++ <!-- Deploy: Domains -->
++ <div id="deploy-domains-panel" style="display:none">
++ <div class="card">
++ <h3><i class="fa-solid fa-globe"></i> Managed Domains</h3>
++ <p class="muted" style="margin:8px 0 16px">Configure which domains this instance manages for TLS and reverse proxy.</p>
++ <div id="deploy-domains-list"><div class="loading">Loading...</div></div>
++ <div style="margin-top:16px;display:flex;gap:8px;align-items:end;flex-wrap:wrap">
++ <div class="form-group" style="margin:0">
++ <label>Add Domain</label>
++ <input type="text" id="domain-add-input" placeholder="example.com" style="width:250px">
++ </div>
++ <label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary)">
++ <input type="checkbox" id="domain-add-wildcard" checked> Wildcard (*.domain)
++ </label>
++ <button class="btn btn-primary" onclick="addManagedDomain()">
++ <i class="fa-solid fa-plus"></i> Add
++ </button>
++ </div>
++ </div>
++ </div>
++
++ <!-- Deploy: Config -->
++ <div id="deploy-config-panel" style="display:none">
++ <div class="card">
++ <h3><i class="fa-solid fa-cog"></i> Deploy Configuration</h3>
++ <div class="form-group" style="margin:12px 0">
++ <label>ACME / Let's Encrypt Email</label>
++ <input type="email" id="deploy-acme-email" placeholder="[email protected]" style="width:350px">
++ </div>
++ <div class="form-group" style="margin:12px 0">
++ <label>Certificate Mode</label>
++ <select id="deploy-cert-mode" style="width:250px">
++ <option value="cloudflare">Cloudflare DNS (Automated)</option>
++ <option value="manual">Manual DNS Challenge</option>
++ <option value="none">None / External</option>
++ </select>
++ </div>
++ <div class="form-group" style="margin:12px 0" id="deploy-cf-token-group">
++ <label>Cloudflare API Token <span class="muted">(Zone → DNS → Edit)</span></label>
++ <input type="password" id="deploy-cf-token" placeholder="••••••••" style="width:400px">
++ <div class="muted" style="font-size:11px;margin-top:4px">Token with Zone:DNS:Edit permissions for all managed domains</div>
++ </div>
++ <div class="form-group" style="margin:12px 0">
++ <label>Nginx Management Mode</label>
++ <select id="deploy-nginx-mode" style="width:250px">
++ <option value="preview">Preview Only</option>
++ <option value="apply">Apply (Write + Validate + Reload)</option>
++ <option value="disabled">Disabled</option>
++ </select>
++ </div>
++ <div class="form-group" style="margin:12px 0">
++ <label>Nginx sites-enabled Path</label>
++ <input type="text" id="deploy-nginx-path" placeholder="/etc/nginx/sites-enabled" style="width:400px">
++ </div>
++ <button class="btn btn-primary" onclick="saveDeployConfig()" style="margin-top:8px">
++ <i class="fa-solid fa-save"></i> Save Deploy Config
++ </button>
++ <span id="deploy-config-status" style="margin-left:12px"></span>
++ </div>
++ </div>
++ </div>
++
++ <!-- SSH access provisioning -->
++ <div class="section" id="sec-ssh">
++ <div id="ssh-content"><div class="loading">Loading...</div></div>
++ </div>
++</div>
++
++<!-- Shared scripts -->
++<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
++<script src="/shared/navbar.js"></script>
++<script src="/shared/notification-ui.js"></script>
++<script src="/shared/account-switcher.js"></script>
++
++<script>
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe — Unified Admin Panel
++ ═══════════════════════════════════════════════════════════════ */
++
++const API = window.location.origin;
++let token = localStorage.getItem('ov_token');
++let currentUser = null;
++
++// ── Helpers ──────────────────────────────────────────────────
++function esc(s) { const d = document.createElement('div'); d.textContent = String(s ?? ''); return d.innerHTML; }
++
++function timeAgo(dateStr) {
++ const d = new Date(dateStr);
++ const s = Math.floor((Date.now() - d.getTime()) / 1000);
++ if (s < 60) return 'just now';
++ if (s < 3600) return Math.floor(s / 60) + 'm ago';
++ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
++ if (s < 604800) return Math.floor(s / 86400) + 'd ago';
++ return d.toLocaleDateString();
++}
++
++function fmtBytes(bytes) {
++ if (!bytes || bytes < 0) return '0 B';
++ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
++ let i = 0, val = bytes;
++ while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
++ return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
++}
++
++function fmtDuration(sec) {
++ if (!sec) return '—';
++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
++ return h > 0 ? `${h}h ${m}m` : `${m}m ${s}s`;
++}
++
++function toast(msg, type = 'info') {
++ const c = document.getElementById('toast-container');
++ const t = document.createElement('div');
++ t.className = `toast toast-${type}`;
++ t.textContent = msg;
++ c.appendChild(t);
++ setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .3s'; setTimeout(() => t.remove(), 300); }, 3500);
++}
++
++async function api(path, opts = {}) {
++ const headers = { 'Content-Type': 'application/json', ...opts.headers };
++ if (token) headers.Authorization = 'Bearer ' + token;
++ const fetchOpts = { ...opts, headers, credentials: 'include' };
++ if (opts.body && typeof opts.body === 'object') fetchOpts.body = JSON.stringify(opts.body);
++ const res = await fetch(API + path, fetchOpts);
++ const data = await res.json();
++ if (!res.ok) throw new Error(data.error || 'Request failed');
++ return data;
++}
++
++// Auth headers for raw fetch() calls (SSH tab, Deploy tab, etc.).
++function authHeaders() {
++ const h = {};
++ if (token) h.Authorization = 'Bearer ' + token;
++ return h;
++}
++
++// ── Tab switching ────────────────────────────────────────────
++let currentTab = 'dashboard';
++// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).
++// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
++// AI configuration form inside it is owner-gated (see loadAi).
++const OWNER_ONLY_TABS = new Set(['deploy', 'payments', 'cashouts', 'storage', 'email', 'settings']);
++const tabLoaders = {
++ dashboard: loadDashboard,
++ users: loadUsers,
++ streams: loadStreams,
++ bans: loadBans,
++ 'chat-logs': () => {}, // manual search
++ moderators: loadModerators,
++ settings: loadSettings,
++ tts: loadTTS,
++ cashouts: loadCashouts,
++ payments: loadPayments,
++ ai: loadAi,
++ vpn: loadVPNQueue,
++ pastes: loadPastesAdmin,
++ storage: loadStorage,
++ notifications: () => {},
++ email: loadEmail,
++ vkeys: loadVKeys,
++ audit: loadAudit,
++ analytics: loadAnalytics,
++ deploy: loadDeployOverview,
++ ssh: loadSSH,
++};
++
++function showTab(id, btn) {
++ if (!tabLoaders[id]) id = 'dashboard';
++ // Owner-only tabs (secrets / money / infra) — bounce non-owners to dashboard.
++ if (OWNER_ONLY_TABS.has(id) && currentUser && !currentUser.is_owner) id = 'dashboard';
++ currentTab = id;
++ btn = btn || _tabButton(id);
++ document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
++ document.querySelectorAll('#main-tabs .admin-tab-btn').forEach(b => b.classList.remove('active'));
++ document.getElementById('sec-' + id)?.classList.add('active');
++ btn?.classList.add('active');
++ btn?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
++ setTimeout(checkMainTabsOverflow, 180);
++ tabLoaders[id]?.();
++ _syncAdminUrl();
++}
++
++/* ── Admin URL routing (History API + slugs + localStorage) ──────────
++ /admin/<tab>[/<subtab>][?last=30d] e.g. /admin/settings,
++ /admin/analytics/openvibelive?last=12h. On load a slug wins; otherwise the
++ last-visited location is restored from localStorage. */
++let deployCurrentSubTab = 'overview';
++let _applyingRoute = false;
++
++function _tabButton(id) {
++ return [...document.querySelectorAll('#main-tabs .admin-tab-btn')]
++ .find(b => (b.getAttribute('onclick') || '').includes(`showTab('${id}'`)) || null;
++}
++function _subBtn(containerId, tab) {
++ return [...document.querySelectorAll(`#${containerId} button`)]
++ .find(b => (b.getAttribute('onclick') || '').includes(`('${tab}'`)) || null;
++}
++
++// Analytics period ↔ URL param. Day options become "<n>d"; hour/3d pass through.
++function periodToUrlParam(v) {
++ if (v == null) return '';
++ v = String(v);
++ return /^\d+$/.test(v) ? v + 'd' : v; // 30 -> 30d ; 12h/3d stay
++}
++function urlParamToPeriod(p) {
++ if (!p) return null;
++ p = String(p).toLowerCase().trim();
++ const alias = { '1y': '365', '365d': '365', '1d': '24h', '24': '24h' };
++ if (alias[p]) return alias[p];
++ if (/^\d+d$/.test(p)) return p.slice(0, -1); // 30d -> 30
++ const valid = ['1h', '12h', '24h', '3d', '7', '14', '30', '90', '365'];
++ if (valid.includes(p)) return p;
++ if (/^\d+$/.test(p)) return p; // bare day count
++ return null;
++}
++
++function _adminPath() {
++ if (currentTab === 'analytics') return '/admin/analytics/' + (analyticsCurrentSubTab || 'overview');
++ if (currentTab === 'deploy') return '/admin/deploy/' + (deployCurrentSubTab || 'overview');
++ if (currentTab && currentTab !== 'dashboard') return '/admin/' + currentTab;
++ return '/admin';
++}
++function _syncAdminUrl(replace) {
++ if (_applyingRoute) return;
++ let url = _adminPath();
++ if (currentTab === 'analytics') {
++ const per = document.getElementById('analytics-period')?.value;
++ if (per) url += '?last=' + periodToUrlParam(per);
++ }
++ try { history[replace ? 'replaceState' : 'pushState']({ adminTab: currentTab }, '', url); } catch { /* */ }
++ try { localStorage.setItem('openvibe_admin_last_path', url); } catch { /* */ }
++}
++
++function routeFromUrl() {
++ _applyingRoute = true;
++ try {
++ let rest = location.pathname.replace(/^\/admin\/?/, '');
++ let parts = rest.split('/').filter(Boolean);
++ let search = location.search;
++ // Bare /admin → restore last-visited location (a real slug always wins).
++ if (parts.length === 0) {
++ const last = localStorage.getItem('openvibe_admin_last_path');
++ if (last && last !== '/admin' && last.startsWith('/admin')) {
++ try { history.replaceState({}, '', last); } catch { /* */ }
++ const u = new URL(last, location.origin);
++ parts = u.pathname.replace(/^\/admin\/?/, '').split('/').filter(Boolean);
++ search = u.search;
++ }
++ }
++ let tab = parts[0];
++ const sub = parts[1] || null;
++ if (!tab || !tabLoaders[tab]) tab = 'dashboard';
++ const per = urlParamToPeriod(new URLSearchParams(search).get('last'));
++ if (tab === 'analytics' && per) {
++ const sel = document.getElementById('analytics-period'); if (sel) sel.value = per;
++ }
++ showTab(tab, _tabButton(tab));
++ // Use the effective tab (showTab may bounce a non-owner off /admin/deploy).
++ if (currentTab === 'analytics' && sub) showAnalyticsSubTab(sub, _subBtn('analytics-sub-tabs', sub));
++ else if (currentTab === 'deploy' && sub) showDeploySubTab(sub, _subBtn('deploy-sub-tabs', sub));
++ } finally {
++ _applyingRoute = false;
++ _syncAdminUrl(true); // normalize (e.g. /admin/analytics -> /admin/analytics/overview)
++ }
++}
++
++window.addEventListener('popstate', () => routeFromUrl());
++
++function checkMainTabsOverflow() {
++ const tabs = document.getElementById('main-tabs');
++ if (!tabs) return;
++ const left = document.getElementById('main-tabs-left');
++ const right = document.getElementById('main-tabs-right');
++ const hasOverflow = tabs.scrollWidth > tabs.clientWidth + 2;
++ const atStart = tabs.scrollLeft <= 1;
++ const atEnd = tabs.scrollLeft >= tabs.scrollWidth - tabs.clientWidth - 1;
++ left?.classList.toggle('visible', hasOverflow && !atStart);
++ right?.classList.toggle('visible', hasOverflow && !atEnd);
++}
++
++function scrollMainTabs(dir) {
++ const tabs = document.getElementById('main-tabs');
++ if (!tabs) return;
++ tabs.scrollBy({ left: dir * 180, behavior: 'smooth' });
++ let checks = 0;
++ let lastPos = tabs.scrollLeft;
++ const poll = setInterval(() => {
++ checkMainTabsOverflow();
++ if (tabs.scrollLeft === lastPos || ++checks > 12) clearInterval(poll);
++ lastPos = tabs.scrollLeft;
++ }, 60);
++}
++
++function initMainTabsOverflow() {
++ const tabs = document.getElementById('main-tabs');
++ if (!tabs) return;
++ tabs.addEventListener('scroll', checkMainTabsOverflow, { passive: true });
++ if (typeof ResizeObserver !== 'undefined') {
++ new ResizeObserver(checkMainTabsOverflow).observe(tabs);
++ }
++ window.addEventListener('resize', checkMainTabsOverflow);
++ setTimeout(checkMainTabsOverflow, 100);
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Dashboard
++// ═══════════════════════════════════════════════════════════════
++async function loadDashboard() {
++ const statsEl = document.getElementById('dashboard-stats');
++ const healthEl = document.getElementById('health-info');
++
++ // Load both openvibe-network health and OpenVibe.Live stats in parallel
++ const [toolsHealth, streamerStats] = await Promise.allSettled([
++ api('/api/admin/health'),
++ api('/api/admin/streamer/stats'),
++ ]);
++
++ const th = toolsHealth.status === 'fulfilled' ? toolsHealth.value.health : null;
++ const ss = streamerStats.status === 'fulfilled' ? (streamerStats.value.stats || streamerStats.value) : null;
++
++ const stats = [
++ { label: 'Registered Users', value: th?.users || 0, icon: 'fa-users' },
++ { label: 'Active Sessions', value: th?.active_sessions || 0, icon: 'fa-plug' },
++ { label: 'Active Streams', value: ss?.streams?.live || ss?.activeStreams || 0, icon: 'fa-broadcast-tower' },
++ { label: 'Total Streams', value: ss?.streams?.total || ss?.totalStreams || 0, icon: 'fa-video' },
++ { label: 'Active Bans', value: ss?.users?.banned || ss?.activeBans || 0, icon: 'fa-ban' },
++ { label: 'Notifications', value: th?.total_notifications || 0, icon: 'fa-bell' },
++ { label: 'Unread Notifs', value: th?.unread_notifications || 0, icon: 'fa-bell-slash' },
++ { label: 'Anon Users', value: th?.anon_users || 0, icon: 'fa-ghost' },
++ ];
++
++ statsEl.innerHTML = stats.map(s => `
++ <div class="stat-card">
++ <div class="value">${typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
++ <div class="label"><i class="fa-solid ${s.icon}"></i> ${s.label}</div>
++ </div>
++ `).join('');
++
++ // Health details
++ if (th) {
++ const mem = th.memory;
++ const upH = Math.floor(th.uptime / 3600);
++ const upM = Math.floor((th.uptime % 3600) / 60);
++ healthEl.innerHTML = `
++ <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;font-size:13px">
++ <div><strong>Uptime:</strong> ${upH}h ${upM}m</div>
++ <div><strong>Heap Used:</strong> ${fmtBytes(mem?.heapUsed)}</div>
++ <div><strong>Heap Total:</strong> ${fmtBytes(mem?.heapTotal)}</div>
++ <div><strong>RSS:</strong> ${fmtBytes(mem?.rss)}</div>
++ <div><strong>Email:</strong> ${th.ses_enabled ? '<span style="color:var(--success)">Enabled</span>' : '<span style="color:var(--text-muted)">Disabled</span>'}</div>
++ </div>
++ `;
++ } else {
++ healthEl.innerHTML = '<p class="muted">Could not load health data</p>';
++ }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Users (from the openvibe.network central user DB)
++// ═══════════════════════════════════════════════════════════════
++let usersOffset = 0;
++async function loadUsers() {
++ const c = document.getElementById('users-content');
++ const pager = document.getElementById('users-pager');
++ const search = document.getElementById('user-search')?.value.trim() || '';
++ c.innerHTML = '<div class="loading">Loading...</div>';
++
++ try {
++ const params = new URLSearchParams({ limit: '50', offset: String(usersOffset) });
++ if (search) params.set('search', search);
++ const data = await api(`/api/admin/users?${params}`);
++ const users = data.users || [];
++ const total = data.total || 0;
++
++ if (!users.length) { c.innerHTML = '<p class="muted">No users found</p>'; pager.innerHTML = ''; return; }
++
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr>
++ <th>Username</th><th>Display Name</th><th>Email</th><th>Role</th><th>Created</th><th>Last Seen</th><th>Actions</th>
++ </tr></thead>
++ <tbody>${users.map(u => `
++ <tr>
++ <td><strong>${esc(u.username)}</strong></td>
++ <td>${esc(u.display_name || '-')}</td>
++ <td style="font-size:11px">${esc(u.email || '-')}</td>
++ <td><span class="badge badge-${u.role}">${esc(u.role)}</span></td>
++ <td style="font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
++ <td style="font-size:11px">${u.last_seen ? timeAgo(u.last_seen) : 'Never'}</td>
++ <td style="display:flex;gap:4px;align-items:center">
++ <select onchange="changeUserRole('${u.id}',this.value)" style="padding:4px 6px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:11px">
++ ${['user','streamer','global_mod','admin'].map(r =>
++ `<option value="${r}" ${r===u.role?'selected':''}>${r}</option>`
++ ).join('')}
++ </select>
++ ${u.is_banned ?
++ `<button class="btn btn-sm btn-success" onclick="toggleBan('${u.id}','${esc(u.username)}',false)"><i class="fa-solid fa-user-check"></i></button>` :
++ `<button class="btn btn-sm btn-danger" onclick="toggleBan('${u.id}','${esc(u.username)}',true)"><i class="fa-solid fa-ban"></i></button>`
++ }
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++
++ const pages = Math.ceil(total / 50);
++ const curPage = Math.floor(usersOffset / 50) + 1;
++ pager.innerHTML = pages > 1 ? `
++ <button class="btn btn-sm btn-outline" ${usersOffset <= 0 ? 'disabled' : ''} onclick="usersOffset=Math.max(0,usersOffset-50);loadUsers()"><i class="fa-solid fa-chevron-left"></i></button>
++ <span class="muted">Page ${curPage} / ${pages} (${total} total)</span>
++ <button class="btn btn-sm btn-outline" ${curPage >= pages ? 'disabled' : ''} onclick="usersOffset+=50;loadUsers()"><i class="fa-solid fa-chevron-right"></i></button>
++ ` : `<span class="muted">${total} users</span>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
++}
++
++async function changeUserRole(userId, role) {
++ try {
++ await api(`/api/admin/users/${userId}/role`, { method: 'PUT', body: { role } });
++ toast(`Role updated to ${role}`, 'success');
++ } catch (e) { toast(e.message, 'error'); loadUsers(); }
++}
++
++async function toggleBan(userId, username, ban) {
++ if (ban) {
++ const reason = prompt(`Ban ${username}? Enter reason:`);
++ if (reason === null) return;
++ try {
++ await api(`/api/admin/users/${userId}/ban`, { method: 'PUT', body: { banned: true, reason } });
++ toast(`${username} banned`, 'success');
++ loadUsers();
++ } catch (e) { toast(e.message, 'error'); }
++ } else {
++ try {
++ await api(`/api/admin/users/${userId}/ban`, { method: 'PUT', body: { banned: false } });
++ toast(`${username} unbanned`, 'success');
++ loadUsers();
++ } catch (e) { toast(e.message, 'error'); }
++ }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Streams (via openvibelive proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadStreams() {
++ const c = document.getElementById('streams-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/streams');
++ const streams = data.streams || [];
++ if (!streams.length) { c.innerHTML = '<p class="muted">No active streams</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr>
++ <th>Title</th><th>Streamer</th><th>Protocol</th><th>Viewers</th><th>Started</th><th>Actions</th>
++ </tr></thead>
++ <tbody>${streams.map(s => `
++ <tr>
++ <td>${esc(s.title || 'Untitled')}</td>
++ <td><strong>${esc(s.username || '-')}</strong></td>
++ <td><span class="badge badge-info">${esc(s.protocol)}</span></td>
++ <td>${s.viewer_count || 0}</td>
++ <td>${s.started_at ? timeAgo(s.started_at) : '-'}</td>
++ <td>
++ <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End</button>
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function endStream(id) {
++ if (!confirm('Force end this stream?')) return;
++ try {
++ await api(`/api/admin/streamer/streams/${id}`, { method: 'DELETE' });
++ toast('Stream ended', 'success');
++ loadStreams();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Bans (via openvibelive proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadBans() {
++ const c = document.getElementById('bans-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/bans');
++ const bans = data.bans || [];
++ if (!bans.length) { c.innerHTML = '<p class="muted">No active bans</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr>
++ <th>User</th><th>Reason</th><th>Banned By</th><th>Banned At</th><th>Expires</th><th>Actions</th>
++ </tr></thead>
++ <tbody>${bans.map(b => `
++ <tr>
++ <td><strong>${esc(b.username || b.user_id)}</strong></td>
++ <td>${esc(b.reason || '-')}</td>
++ <td>${esc(b.banned_by_username || '-')}</td>
++ <td>${b.created_at ? timeAgo(b.created_at) : '-'}</td>
++ <td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td>
++ <td>
++ <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Unban</button>
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function unbanStreamer(userId) {
++ try {
++ await api(`/api/admin/streamer/users/${userId}/ban`, { method: 'DELETE' });
++ toast('User unbanned on OpenVibe.Live', 'success');
++ loadBans();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Chat Logs (via openvibelive mod proxy)
++// ═══════════════════════════════════════════════════════════════
++let chatLogsOffset = 0;
++async function searchChatLogs() {
++ chatLogsOffset = 0;
++ await fetchChatLogs();
++}
++
++async function fetchChatLogs() {
++ const c = document.getElementById('chat-logs-content');
++ const pager = document.getElementById('chat-logs-pager');
++ const q = document.getElementById('chat-search-q')?.value.trim() || '';
++ const uid = document.getElementById('chat-search-uid')?.value.trim() || '';
++ if (!q && !uid) { c.innerHTML = '<p class="muted">Enter a search query or user ID</p>'; return; }
++ c.innerHTML = '<div class="loading">Loading...</div>';
++
++ try {
++ const params = new URLSearchParams({ limit: '50', offset: String(chatLogsOffset) });
++ if (q) params.set('q', q);
++ if (uid) params.set('user_id', uid);
++ const data = await api(`/api/admin/streamer-mod/chat/search?${params}`);
++ const msgs = data.messages || [];
++ const total = data.total || 0;
++
++ if (!msgs.length) { c.innerHTML = '<p class="muted">No messages found</p>'; pager.innerHTML = ''; return; }
++
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>Time</th><th>User</th><th>Message</th><th>Stream</th></tr></thead>
++ <tbody>${msgs.map(m => `
++ <tr>
++ <td style="white-space:nowrap;font-size:11px">${m.timestamp ? timeAgo(m.timestamp) : '-'}</td>
++ <td style="white-space:nowrap;color:${esc(m.profile_color || (m.anon_id ? '#6b7280' : '#999'))}">${esc(m.display_name || m.username || m.anon_id || 'anon')}</td>
++ <td style="word-break:break-word">${esc(m.message)}</td>
++ <td style="font-size:11px">${m.stream_id || '-'}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++
++ const pages = Math.ceil(total / 50);
++ const curPage = Math.floor(chatLogsOffset / 50) + 1;
++ pager.innerHTML = pages > 1 ? `
++ <button class="btn btn-sm btn-outline" ${chatLogsOffset<=0?'disabled':''} onclick="chatLogsOffset=Math.max(0,chatLogsOffset-50);fetchChatLogs()"><i class="fa-solid fa-chevron-left"></i></button>
++ <span class="muted">Page ${curPage}/${pages} (${total} results)</span>
++ <button class="btn btn-sm btn-outline" ${curPage>=pages?'disabled':''} onclick="chatLogsOffset+=50;fetchChatLogs()"><i class="fa-solid fa-chevron-right"></i></button>
++ ` : `<span class="muted">${total} results</span>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Moderators (via openvibelive proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadModerators() {
++ const c = document.getElementById('mods-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/moderators');
++ const mods = data.moderators || [];
++ if (!mods.length) { c.innerHTML = '<p class="muted">No global moderators yet</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>Username</th><th>Display Name</th><th>Last Seen</th><th>Actions</th></tr></thead>
++ <tbody>${mods.map(m => `
++ <tr>
++ <td><strong>${esc(m.username)}</strong></td>
++ <td>${esc(m.display_name || m.username)}</td>
++ <td>${m.last_seen ? timeAgo(m.last_seen) : 'Never'}</td>
++ <td>
++ <button class="btn btn-sm btn-danger" onclick="demoteMod('${m.id}','${esc(m.username)}')"><i class="fa-solid fa-user-minus"></i> Demote</button>
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function promoteMod() {
++ const username = document.getElementById('mod-username')?.value.trim();
++ if (!username) return toast('Enter a username', 'error');
++ try {
++ await api('/api/admin/streamer/moderators', { method: 'POST', body: { username } });
++ toast(`${username} promoted to global moderator`, 'success');
++ document.getElementById('mod-username').value = '';
++ loadModerators();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function demoteMod(id, username) {
++ if (!confirm(`Demote ${username} from global moderator?`)) return;
++ try {
++ await api(`/api/admin/streamer/moderators/${id}`, { method: 'DELETE' });
++ toast(`${username} demoted`, 'success');
++ loadModerators();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Settings (dual: openvibe-network + OpenVibe.Live)
++// ═══════════════════════════════════════════════════════════════
++let currentSettingsSub = 'tools';
++function showSettingsSub(sub, btn) {
++ currentSettingsSub = sub;
++ document.querySelectorAll('#settings-sub-tabs button').forEach(b => b.classList.remove('active'));
++ btn?.classList.add('active');
++ document.getElementById('settings-tools').style.display = sub === 'tools' ? 'block' : 'none';
++ document.getElementById('settings-streamer').style.display = sub === 'streamer' ? 'block' : 'none';
++ document.getElementById('settings-media').style.display = sub === 'media' ? 'block' : 'none';
++ document.getElementById('settings-urls').style.display = sub === 'urls' ? 'block' : 'none';
++ document.getElementById('settings-discord').style.display = sub === 'discord' ? 'block' : 'none';
++ if (sub === 'tools') loadToolsSettings();
++ else if (sub === 'streamer') loadStreamerSettings();
++ else if (sub === 'media') loadStreamerMediaTools();
++ else if (sub === 'urls') loadUrlRegistry();
++ else if (sub === 'discord') loadDiscordSettings();
++}
++
++async function loadSettings() {
++ loadToolsSettings();
++}
++
++async function loadToolsSettings() {
++ const c = document.getElementById('settings-tools');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/settings');
++ const settings = data.settings || {};
++ const keys = Object.keys(settings);
++ if (!keys.length) { c.innerHTML = '<p class="muted">No settings configured</p>'; return; }
++ c.innerHTML = `
++ <form id="tools-settings-form" style="max-width:700px">
++ ${keys.map(key => {
++ const s = settings[key];
++ const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer)/i.test(key);
++ if (s.type === 'boolean') return `
++ <div class="setting-row">
++ <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
++ <input type="checkbox" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
++ </div>`;
++ return `
++ <div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
++ <input type="${isSensitive?'password':'text'}" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
++ </div>`;
++ }).join('')}
++ <div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button></div>
++ </form>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function saveToolsSettings() {
++ const inputs = document.querySelectorAll('#tools-settings-form [data-key]');
++ try {
++ for (const input of inputs) {
++ const key = input.dataset.key;
++ const value = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
++ // Skip masked secrets
++ if (value.startsWith('••••')) continue;
++ await api('/api/admin/settings', { method: 'PUT', body: { key, value, type: input.dataset.type } });
++ }
++ toast('OpenVibe.Tools settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function loadUrlRegistry() {
++ const c = document.getElementById('settings-urls');
++ c.innerHTML = '<div class="loading">Loading URL registry...</div>';
++ try {
++ const data = await api('/api/admin/url-registry');
++ const entries = data.entries || [];
++ if (!entries.length) {
++ c.innerHTML = '<p class="muted">No URL registry overrides configured.</p>';
++ return;
++ }
++ c.innerHTML = `
++ <div style="max-width:900px;display:grid;gap:16px">
++ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
++ <div>
++ <strong>Network registry</strong>
++ <div class="muted" style="font-size:13px">Edit first-party networking values and push refresh to services.</div>
++ </div>
++ <div style="display:flex;gap:8px;flex-wrap:wrap">
++ <button class="btn btn-sm btn-primary" onclick="refreshAllUrlRegistry()"><i class="fa-solid fa-arrows-rotate"></i> Refresh all services</button>
++ </div>
++ </div>
++ <div id="url-registry-refresh-status" class="muted" style="font-size:13px"></div>
++ ${entries.map(entry => `
++ <div class="card" style="padding:16px">
++ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
++ <div>
++ <div style="font-weight:700">${esc(entry.key)}</div>
++ <div class="muted" style="font-size:13px">${esc(entry.label)} — ${esc(entry.type)}</div>
++ </div>
++ <div style="display:flex;gap:8px;flex-wrap:wrap">
++ <button class="btn btn-sm btn-outline" onclick="resetUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-rotate-left"></i> Reset</button>
++ <button class="btn btn-sm btn-primary" onclick="saveUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-floppy-disk"></i> Save</button>
++ </div>
++ </div>
++ <div class="form-field" style="margin-top:12px">
++ <label for="url-registry-${esc(entry.key)}">Value</label>
++ ${entry.type === 'boolean' ? `
++ <div style="display:flex;align-items:center;gap:8px">
++ <input type="checkbox" id="url-registry-${esc(entry.key)}" ${entry.value ? 'checked' : ''}>
++ <span>${esc(entry.label)}</span>
++ </div>
++ ` : `
++ <input type="text" id="url-registry-${esc(entry.key)}" value="${esc(entry.value || '')}" placeholder="${esc(entry.placeholder || '')}">
++ `}
++ <div class="muted" style="margin-top:4px">Source: ${esc(entry.source)}</div>
++ ${entry.key === 'WHIP_PUBLIC_URL' ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Note</div><div class="muted" style="margin-top:4px">A dedicated WHIP hostname also requires explicit enablement via WHIP_PUBLIC_URL_ENABLED, plus DNS/vhost/TLS support.</div>` : ''}
++ ${entry.warning ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Warning</div><div class="muted" style="margin-top:4px">${esc(entry.warning)}</div>` : ''}
++ </div>
++ </div>
++ `).join('')}
++ </div>`;
++ } catch (e) {
++ c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ }
++}
++
++async function saveUrlRegistryEntry(key) {
++ const input = document.getElementById(`url-registry-${key}`);
++ if (!input) return toast('Missing registry field', 'error');
++ const value = input.type === 'checkbox' ? input.checked : input.value.trim();
++ try {
++ await api(`/api/admin/url-registry/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } });
++ toast('URL registry entry saved', 'success');
++ loadUrlRegistry();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function resetUrlRegistryEntry(key) {
++ if (!confirm('Reset this URL registry entry to its default?')) return;
++ try {
++ await api(`/api/admin/url-registry/${encodeURIComponent(key)}/reset`, { method: 'POST' });
++ toast('URL registry entry reset', 'success');
++ loadUrlRegistry();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function refreshAllUrlRegistry() {
++ const statusEl = document.getElementById('url-registry-refresh-status');
++ statusEl.textContent = 'Refreshing all services...';
++ try {
++ const data = await api('/api/admin/url-registry/refresh-all', { method: 'POST' });
++ if (!data.ok) throw new Error(data.error || 'Refresh failed');
++ const results = data.refreshResults || [];
++ statusEl.innerHTML = results.map(service => {
++ const result = (service.results && service.results[0]) || {};
++ const mode = result.mode || (result.ok ? 'success' : 'failed');
++ const badge = result.mode === 'local'
++ ? '<span class="badge badge-info">Local</span>'
++ : result.mode === 'not_configured'
++ ? '<span class="badge badge-warning">Not configured</span>'
++ : result.mode === 'unsupported'
++ ? '<span class="badge badge-warning">Unsupported</span>'
++ : result.ok
++ ? '<span class="badge badge-success">Success</span>'
++ : '<span class="badge badge-danger">Failed</span>';
++ const message = result.error ? ` <span class="muted">${esc(result.error)}</span>` : (result.message ? ` <span class="muted">${esc(result.message)}</span>` : '');
++ return `<div><strong>${esc(service.service)}</strong>: ${badge}${message}</div>`;
++ }).join('');
++ toast('Refresh command sent to services', 'success');
++ } catch (e) {
++ statusEl.textContent = `Refresh failed: ${esc(e.message)}`;
++ toast(e.message, 'error');
++ }
++}
++
++// Grouped, icon'd sections for the (otherwise huge) OpenVibe.Live settings list.
++const STREAMER_SETTINGS_GROUPS = [
++ { title: 'Site & General', icon: 'fa-sliders', test: k => /^(site_|motd$|registration_open$|require_email$|nsfw_enabled$)/.test(k) },
++ { title: 'Payment Processors', icon: 'fa-credit-card', test: k => /^(payments_|bucks_|sub_|paypal_|stripe_|ccbill_|crypto_|min_cashout)/.test(k) },
++ { title: 'Kick / Twitch / YouTube', icon: 'fa-plug', test: k => /^(twitch_|kick_|google_client)/.test(k) },
++ { title: 'AI Config', icon: 'fa-robot', test: k => /^ai_/.test(k) },
++ { title: 'Cloud TTS', icon: 'fa-volume-high', test: k => /^tts_/.test(k) },
++ { title: 'Media & Uploads', icon: 'fa-photo-film', test: k => /^(gif_|soundboard_|paste_|storage_tier|max_emotes|max_clip|max_video|max_audio|max_vod)/.test(k) },
++ { title: 'Chat & Rewards', icon: 'fa-comments', test: k => /^(chat_|coins_per)/.test(k) },
++ { title: 'Other', icon: 'fa-ellipsis', test: () => true },
++];
++
++function _streamerSettingField(s) {
++ const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer|salt)/i.test(s.key);
++ const label = `<label for="ss-${esc(s.key)}"><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>`;
++ if (s.type === 'boolean') return `<div class="setting-row">${label}
++ <input type="checkbox" id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
++ const type = s.type === 'number' ? 'number' : (isSensitive ? 'password' : 'text');
++ const width = s.type === 'number' ? 'width:200px;' : '';
++ return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}
++ <input type="${type}" id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
++ style="margin-top:4px;${width}padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;
++}
++
++async function loadStreamerSettings() {
++ const c = document.getElementById('settings-streamer');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/settings');
++ const settings = data.settings || [];
++ if (!settings.length) { c.innerHTML = '<p class="muted">No OpenVibe.Live settings found</p>'; return; }
++ // Bucket each setting into the first matching group.
++ const buckets = STREAMER_SETTINGS_GROUPS.map(() => []);
++ for (const s of settings) {
++ const gi = STREAMER_SETTINGS_GROUPS.findIndex(g => g.test(s.key));
++ buckets[gi === -1 ? buckets.length - 1 : gi].push(s);
++ }
++ const sections = STREAMER_SETTINGS_GROUPS.map((g, i) => {
++ const items = buckets[i].sort((a, b) => a.key.localeCompare(b.key));
++ if (!items.length) return '';
++ return `<details class="settings-group" ${i < 2 ? 'open' : ''} style="border:1px solid var(--border);border-radius:8px;margin-bottom:10px">
++ <summary style="cursor:pointer;padding:10px 14px;font-weight:700;list-style:none"><i class="fa-solid ${g.icon}"></i> ${esc(g.title)} <span style="opacity:.5;font-weight:400">(${items.length})</span></summary>
++ <div style="padding:4px 14px 12px">${items.map(_streamerSettingField).join('')}</div>
++ </details>`;
++ }).join('');
++ c.innerHTML = `
++ <form id="streamer-settings-form" style="max-width:760px">
++ ${sections}
++ <div style="margin-top:8px"><button type="button" class="btn btn-primary" onclick="saveStreamerSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button></div>
++ </form>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function saveStreamerSettings() {
++ const inputs = document.querySelectorAll('#streamer-settings-form [data-key]');
++ const settings = {};
++ inputs.forEach(input => {
++ const key = input.dataset.key;
++ settings[key] = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
++ });
++ try {
++ await api('/api/admin/streamer/settings', { method: 'PUT', body: { settings } });
++ toast('OpenVibe.Live settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ── Payments: configure PayPal / Stripe / CCBill / crypto + subscriptions ──
++const PAYMENT_GROUPS = [
++ { title: 'General', keys: ['payments_enabled', 'bucks_per_usd', 'bucks_min_purchase_usd', 'sub_price_usd', 'sub_streamer_share_pct'] },
++ { title: 'PayPal', keys: ['paypal_enabled', 'paypal_mode', 'paypal_client_id', 'paypal_client_secret', 'paypal_webhook_id'] },
++ { title: 'Stripe', keys: ['stripe_enabled', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_webhook_secret'] },
++ { title: 'CCBill', keys: ['ccbill_enabled', 'ccbill_client_account', 'ccbill_subaccount', 'ccbill_flexform_id', 'ccbill_salt', 'ccbill_webhook_secret'] },
++ { title: 'Crypto (NOWPayments)', keys: ['crypto_enabled', 'crypto_provider', 'crypto_api_key', 'crypto_ipn_secret'] },
++];
++const PAYMENT_WEBHOOKS = {
++ Stripe: 'https://openvibe.live/api/payments/webhook/stripe',
++ PayPal: 'https://openvibe.live/api/payments/webhook/paypal',
++ CCBill: 'https://openvibe.live/api/payments/webhook/ccbill?secret=YOUR_ccbill_webhook_secret',
++ 'Crypto (NOWPayments)': 'https://openvibe.live/api/payments/webhook/crypto',
++};
++
++async function loadPayments() {
++ const c = document.getElementById('payments-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/settings');
++ const byKey = {}; (data.settings || []).forEach(s => { byKey[s.key] = s; });
++ const field = (s) => {
++ if (!s) return '';
++ const sensitive = /(secret|salt|api[_-]?key|client_secret|ipn)/i.test(s.key);
++ if (s.type === 'boolean') return `
++ <div class="setting-row">
++ <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
++ <input type="checkbox" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
++ </div>`;
++ const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
++ return `
++ <div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
++ <input type="${type}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
++ </div>`;
++ };
++ const groups = PAYMENT_GROUPS.map(g => `
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">${esc(g.title)}</legend>
++ ${g.keys.map(k => field(byKey[k])).join('')}
++ ${PAYMENT_WEBHOOKS[g.title] ? `<p class="muted" style="font-size:12px;margin-top:8px"><i class="fa-solid fa-link"></i> Webhook URL: <code>${esc(PAYMENT_WEBHOOKS[g.title])}</code></p>` : ''}
++ </fieldset>`).join('');
++ c.innerHTML = `
++ <div style="max-width:760px">
++ <p class="muted" style="margin-bottom:12px">Configure real-money payments for Vibes &amp; channel subscriptions. Nothing goes live until <strong>payments_enabled</strong> is on and the provider is enabled with valid keys. Set each provider's webhook to the URL shown, and add the redirect/return URLs from OpenVibe.Live.</p>
++ <form id="payments-form">${groups}
++ <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk"></i> Save Payment Settings</button>
++ </form>
++ </div>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function savePayments() {
++ const inputs = document.querySelectorAll('#payments-form [data-key]');
++ const settings = {};
++ inputs.forEach(input => {
++ settings[input.dataset.key] = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
++ });
++ try {
++ await api('/api/admin/streamer/settings', { method: 'PUT', body: { settings } });
++ toast('Payment settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ── AI: provider config + usage cost breakdown ──
++const AI_KEYS = ['ai_enabled', 'ai_provider', 'ai_api_key', 'ai_base_url', 'ai_model',
++ 'ai_paste_analysis_enabled', 'ai_stream_memory_enabled', 'ai_stream_capture_interval_sec', 'ai_transcription_enabled',
++ 'ai_max_cost_usd_per_day', 'ai_input_cost_per_mtok', 'ai_output_cost_per_mtok'];
++
++async function loadAi() {
++ const c = document.getElementById('ai-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const [data, usageResp] = await Promise.all([
++ api('/api/admin/streamer/settings'),
++ api('/api/admin/streamer/ai/usage?days=30').catch(() => ({ usage: null })),
++ ]);
++ const byKey = {}; (data.settings || []).forEach(s => { byKey[s.key] = s; });
++ const field = (key) => {
++ const s = byKey[key]; if (!s) return '';
++ const sensitive = /(api_key|secret)/i.test(key);
++ // Server redacts API keys / money settings for non-owner admins — show locked.
++ if (s.redacted) return `
++ <div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label><strong>${esc(key)}</strong> <span style="color:var(--muted);font-size:11px;font-weight:600">🔒 Owner only</span><br><small>${esc(s.description||'')}</small></label>
++ <input type="text" value="${esc(s.value)}" disabled data-redacted="1"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:13px;opacity:0.65"></div>`;
++ if (s.type === 'boolean') return `
++ <div class="setting-row"><label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
++ <input type="checkbox" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
++ const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
++ return `<div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
++ <input type="${type}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;
++ };
++ const u = usageResp && usageResp.usage;
++ let costHtml = '<p class="muted">No AI usage recorded yet.</p>';
++ if (u && u.totals) {
++ const money = (n) => '$' + (Number(n||0)).toFixed(4);
++ costHtml = `
++ <div style="display:flex;gap:18px;flex-wrap:wrap;margin-bottom:10px">
++ <div><div style="font-size:22px;font-weight:700">${money(u.today)}</div><small class="muted">Today</small></div>
++ <div><div style="font-size:22px;font-weight:700">${money(u.totals.cost_usd)}</div><small class="muted">30 days</small></div>
++ <div><div style="font-size:22px;font-weight:700">${u.totals.calls||0}</div><small class="muted">Calls (30d)</small></div>
++ <div><div style="font-size:22px;font-weight:700">${((u.totals.input_tokens||0)+(u.totals.output_tokens||0)).toLocaleString()}</div><small class="muted">Tokens (30d)</small></div>
++ </div>
++ ${(u.byKind||[]).length ? `<table style="width:100%;font-size:13px;border-collapse:collapse"><tr><th style="text-align:left">Feature</th><th style="text-align:right">Calls</th><th style="text-align:right">Cost</th></tr>
++ ${u.byKind.map(k=>`<tr><td>${esc(k.kind||'—')}</td><td style="text-align:right">${k.calls}</td><td style="text-align:right">${money(k.cost_usd)}</td></tr>`).join('')}</table>` : ''}`;
++ }
++ c.innerHTML = `
++ <div style="max-width:760px">
++ <p class="muted" style="margin-bottom:12px">AI analysis for pastes + live-stream memories. Nothing runs until <strong>ai_enabled</strong> is on with a valid key. Default provider is Anthropic (Claude vision); set <strong>ai_base_url</strong> for an OpenAI-compatible endpoint. Set <strong>ai_max_cost_usd_per_day</strong> to cap spend.</p>
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">Estimated Cost</legend>
++ ${costHtml}
++ </fieldset>
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">AI Status</legend>
++ <button type="button" class="btn" onclick="testAiStatus()"><i class="fa-solid fa-heart-pulse"></i> Run Status Check</button>
++ <div id="ai-status-out" style="margin-top:10px"></div>
++ </fieldset>
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">AI Explorer</legend>
++ <p class="muted" style="margin:0 0 8px">Browse a streamer's AI memory, analyzed pastes, and VODs — and generate a per-streamer overview aggregated across all of it.</p>
++ <div style="display:flex;gap:8px">
++ <input id="ai-explorer-search" placeholder="Search streamer username…" onkeydown="if(event.key==='Enter'){event.preventDefault();aiExplorerSearch();}"
++ style="flex:1;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
++ <button type="button" class="btn" onclick="aiExplorerSearch()"><i class="fa-solid fa-magnifying-glass"></i></button>
++ </div>
++ <div id="ai-explorer-results" style="margin-top:8px"></div>
++ <div id="ai-explorer-detail" style="margin-top:12px"></div>
++ </fieldset>
++ ${currentUser.is_owner ? `
++ <form id="ai-form">
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">Configuration</legend>
++ ${AI_KEYS.map(field).join('')}
++ </fieldset>
++ <button type="button" class="btn btn-primary" onclick="saveAi()"><i class="fa-solid fa-floppy-disk"></i> Save AI Settings</button>
++ </form>` : `
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px;opacity:0.8">
++ <legend style="padding:0 8px;font-weight:700">Configuration</legend>
++ <p class="muted" style="margin:0"><i class="fa-solid fa-lock"></i> AI provider configuration and API keys are owner-only. You have full access to usage, costs, status, and the AI Explorer above.</p>
++ </fieldset>`}
++ </div>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function saveAi() {
++ const inputs = document.querySelectorAll('#ai-form [data-key]');
++ const settings = {};
++ inputs.forEach(input => { settings[input.dataset.key] = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value; });
++ try {
++ await api('/api/admin/streamer/settings', { method: 'PUT', body: { settings } });
++ toast('AI settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ── AI Status + Explorer ─────────────────────────────────────
++async function testAiStatus() {
++ const out = document.getElementById('ai-status-out');
++ if (!out) return;
++ out.innerHTML = '<span class="muted">Checking… (this makes one tiny live call)</span>';
++ try {
++ const { status } = await api('/api/admin/streamer/ai/status');
++ const badge = status.ok ? '<span style="color:#4ade80">● OK</span>' : '<span style="color:#f87171">● Problem</span>';
++ out.innerHTML = `<div style="font-size:13px;line-height:1.7">
++ ${badge} &nbsp; provider <b>${esc(status.provider)}</b>, model <b>${esc(status.model)}</b>${status.probed ? ` · reply "${esc(status.reply||'')}" · ${status.latency_ms}ms` : ''}
++ <br>enabled: <b>${status.enabled}</b> · key set: <b>${status.has_key}</b> · paste analysis: ${status.paste_analysis} · stream memory: ${status.stream_memory}
++ <br>cost today: <b>$${Number(status.cost_today||0).toFixed(4)}</b> · daily cap: ${status.budget_cap_usd_per_day ? '$'+status.budget_cap_usd_per_day : 'none'}
++ ${status.error ? `<br><span style="color:#f87171">${esc(status.error)}</span>` : ''}
++ </div>`;
++ } catch (e) { out.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; }
++}
++
++async function aiExplorerSearch() {
++ const q = (document.getElementById('ai-explorer-search').value || '').trim();
++ const box = document.getElementById('ai-explorer-results');
++ box.innerHTML = '<span class="muted">Searching…</span>';
++ try {
++ const data = await api('/api/admin/streamer/users?search=' + encodeURIComponent(q) + '&limit=15');
++ const users = data.users || [];
++ box.innerHTML = users.length
++ ? users.map(u => `<button type="button" class="btn btn-sm" style="margin:2px" onclick="aiExplorerLoad(${u.id})">@${esc(u.username)}${u.display_name && u.display_name !== u.username ? ' ('+esc(u.display_name)+')' : ''}</button>`).join('')
++ : '<span class="muted">No matching users.</span>';
++ } catch (e) { box.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; }
++}
++
++async function aiExplorerLoad(userId) {
++ const box = document.getElementById('ai-explorer-detail');
++ box.innerHTML = '<div class="loading">Loading…</div>';
++ try {
++ const d = await api('/api/admin/streamer/ai/explorer/' + userId);
++ const ov = d.overview;
++ const tbl = (rows, head) => `<table style="width:100%;font-size:12px;border-collapse:collapse;margin-top:6px"><thead><tr>${head.map(h=>`<th style="text-align:left;border-bottom:1px solid var(--border);padding:3px 6px">${h}</th>`).join('')}</tr></thead><tbody>${rows||`<tr><td style="padding:6px" class="muted">None</td></tr>`}</tbody></table>`;
++ const scrollCell = (t) => t ? `<div style="max-height:90px;overflow:auto;white-space:pre-wrap;font-size:11px;line-height:1.45">${esc(t)}</div>` : '<span class="muted">—</span>';
++ const isHeard = (s) => /heard:/i.test(s||'');
++ const memRows = (d.memories||[]).map(m => `<tr><td style="padding:3px 6px;white-space:nowrap;color:var(--muted)">${esc((m.created_at||'').slice(0,16))}${isHeard(m.description)?' <span title="contains audio transcript" style="color:var(--accent)">🔊</span>':''}</td><td style="padding:3px 6px">${esc(m.description||'')}</td></tr>`).join('');
++ const pasteRows = (d.pastes||[]).map(p => `<tr><td style="padding:3px 6px">${esc(p.type||'')}</td><td style="padding:3px 6px">${esc(p.title||'')}</td><td style="padding:3px 6px">${esc(p.ai_summary||'—')}</td></tr>`).join('');
++ const vodRows = (d.vods||[]).map(v => `<tr><td style="padding:3px 6px">${esc(v.title||'')}</td><td style="padding:3px 6px">${esc(v.ai_overview||'—')}</td><td style="padding:3px 6px;min-width:180px">${scrollCell(v.ai_transcript)}</td></tr>`).join('');
++ const clipRows = (d.clips||[]).map(c => `<tr><td style="padding:3px 6px">${esc(c.title||'')}</td><td style="padding:3px 6px">${esc(c.ai_overview||'—')}</td><td style="padding:3px 6px;min-width:180px">${scrollCell(c.ai_transcript)}</td></tr>`).join('');
++ const txCount = (d.vods||[]).filter(v=>v.ai_transcript).length + (d.clips||[]).filter(c=>c.ai_transcript).length + (d.memories||[]).filter(m=>isHeard(m.description)).length;
++ box.innerHTML = `
++ <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
++ <h3 style="margin:0">@${esc(d.user.username)}</h3>
++ <button type="button" class="btn btn-sm btn-primary" onclick="aiGenerateOverview(${userId})"><i class="fa-solid fa-wand-magic-sparkles"></i> Generate Overview</button>
++ </div>
++ <div style="padding:10px;background:var(--bg-input);border-radius:6px;margin-bottom:10px">
++ <strong>AI Overview</strong> ${ov?`<small class="muted">(${esc((ov.generated_at||'').slice(0,16))} · ${esc(ov.model||'')})</small>`:''}
++ <div style="margin-top:6px;white-space:pre-wrap;font-size:13px;line-height:1.5">${ov?esc(ov.overview):'<span class="muted">Not generated yet — click “Generate Overview”.</span>'}</div>
++ </div>
++ <p class="muted" style="font-size:12px;margin:0 0 6px">${d.counts.memories} memories · ${d.counts.pastes} pastes · ${d.counts.vods} vods · ${d.counts.clips||0} clips · <span style="color:var(--accent)">🔊 ${txCount} with transcript</span></p>
++ <details style="margin-bottom:6px"><summary style="cursor:pointer">Stream memories (${d.counts.memories}) — 🔊 = includes audio transcript</summary>${tbl(memRows,['When','Description'])}</details>
++ <details style="margin-bottom:6px"><summary style="cursor:pointer">Pastes (${d.counts.pastes})</summary>${tbl(pasteRows,['Type','Title','AI summary'])}</details>
++ <details style="margin-bottom:6px"><summary style="cursor:pointer">VODs (${d.counts.vods}) — overview + transcript</summary>${tbl(vodRows,['Title','AI overview','Transcript'])}</details>
++ <details><summary style="cursor:pointer">Clips (${d.counts.clips||0}) — overview + transcript</summary>${tbl(clipRows,['Title','AI overview','Transcript'])}</details>`;
++ } catch (e) { box.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; }
++}
++
++async function aiGenerateOverview(userId) {
++ toast('Generating overview… (one AI call)', 'info');
++ try {
++ await api('/api/admin/streamer/ai/streamer/' + userId + '/overview', { method: 'POST' });
++ toast('Overview generated', 'success');
++ aiExplorerLoad(userId);
++ } catch (e) { toast(e.message || 'Failed', 'error'); }
++}
++
++async function loadStreamerMediaTools() {
++ const c = document.getElementById('settings-media');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++
++ let status = {};
++ try {
++ status = await api('/api/admin/streamer/media-tools/status');
++ } catch (e) {
++ c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ return;
++ }
++
++ c.innerHTML = `
++ <div style="display:grid;gap:16px;max-width:860px">
++ <div class="card">
++ <h3><i class="fa-solid fa-wrench"></i> OpenVibe.Live yt-dlp Status</h3>
++ <div style="display:grid;gap:8px;font-size:0.95rem">
++ <div><strong>yt-dlp available:</strong> ${status.ytdlp_available
++ ? '<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-check-circle"></i> Yes</span>'
++ : '<span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-xmark-circle"></i> No</span>'
++ }</div>
++ <div><strong>Path:</strong> <span style="font-family:monospace;font-size:0.88rem">${esc(status.ytdlp_path || '')}</span></div>
++ <div><strong>YouTube cookies:</strong> ${status.cookies_configured
++ ? `<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-cookie"></i> Configured (${esc(String(status.cookies_size || 0))} bytes)</span>`
++ : '<span class="muted"><i class="fa-solid fa-cookie-bite"></i> Not configured</span>'
++ }</div>
++ ${!status.cookies_configured ? '<div style="color:var(--warn,#f59e0b);font-size:0.88rem"><i class="fa-solid fa-triangle-exclamation"></i> YouTube server-side downloads will fail until cookies.txt is added.</div>' : ''}
++ </div>
++ </div>
++
++ <div class="card">
++ <h3><i class="fa-solid fa-cookie"></i> YouTube Cookies</h3>
++ <p class="muted" style="margin-top:0">Paste a Netscape-format cookies.txt export from a browser session logged into YouTube. This is required when YouTube bot detection blocks server-side downloads.</p>
++ <textarea id="streamer-media-cookies-input" rows="10" placeholder="# Netscape HTTP Cookie File&#10;.youtube.com&#9;TRUE&#9;/&#9;TRUE&#9;0&#9;SID&#9;value..."
++ style="width:100%;background:var(--bg-input);color:var(--text);border:1px solid var(--border);padding:10px;border-radius:6px;font-family:monospace;font-size:12px;resize:vertical"></textarea>
++ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
++ <button type="button" class="btn btn-primary" onclick="saveStreamerMediaCookies()"><i class="fa-solid fa-floppy-disk"></i> Save Cookies</button>
++ <button type="button" class="btn" onclick="deleteStreamerMediaCookies()" ${status.cookies_configured ? '' : 'disabled'}><i class="fa-solid fa-trash"></i> Remove Cookies</button>
++ </div>
++ </div>
++
++ <div class="card">
++ <h3><i class="fa-solid fa-flask-vial"></i> Test Extraction</h3>
++ <p class="muted" style="margin-top:0">Test a YouTube URL against the live OpenVibe.Live server to confirm cookies and yt-dlp are working.</p>
++ <div class="input-row">
++ <input type="text" id="streamer-media-test-url" placeholder="https://www.youtube.com/watch?v=...">
++ <button type="button" class="btn btn-primary" onclick="testStreamerMediaExtraction()"><i class="fa-solid fa-play"></i> Test</button>
++ </div>
++ <div id="streamer-media-test-results" style="margin-top:12px"></div>
++ </div>
++ </div>`;
++}
++
++async function saveStreamerMediaCookies() {
++ const ta = document.getElementById('streamer-media-cookies-input');
++ const cookies = ta?.value?.trim() || '';
++ if (!cookies) return toast('Paste cookies.txt content first', 'error');
++ try {
++ await api('/api/admin/streamer/media-tools/cookies', { method: 'PUT', body: { cookies } });
++ toast('OpenVibe.Live cookies saved', 'success');
++ loadStreamerMediaTools();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function deleteStreamerMediaCookies() {
++ if (!confirm('Remove OpenVibe.Live yt-dlp cookies?')) return;
++ try {
++ await api('/api/admin/streamer/media-tools/cookies', { method: 'DELETE' });
++ toast('OpenVibe.Live cookies removed', 'success');
++ loadStreamerMediaTools();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testStreamerMediaExtraction() {
++ const url = document.getElementById('streamer-media-test-url')?.value?.trim() || '';
++ const results = document.getElementById('streamer-media-test-results');
++ if (!url) return toast('Enter a URL to test', 'error');
++ results.innerHTML = '<p class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Testing live OpenVibe.Live extraction…</p>';
++ try {
++ const data = await api('/api/admin/streamer/media-tools/test', { method: 'POST', body: { url } });
++ results.innerHTML = `<div style="display:grid;gap:8px;font-size:0.9rem">${(data.steps || []).map(step => {
++ const icon = step.ok
++ ? '<i class="fa-solid fa-check-circle" style="color:var(--success,#22c55e)"></i>'
++ : '<i class="fa-solid fa-xmark-circle" style="color:var(--danger,#ef4444)"></i>';
++ const detail = step.error
++ ? `<span style="color:var(--danger,#ef4444)"> — ${esc(step.error)}</span>`
++ : step.data ? ` — ${esc(JSON.stringify(step.data))}` : '';
++ return `<div>${icon} <strong>${esc(step.name)}</strong>${detail}</div>`;
++ }).join('')}</div>`;
++ } catch (e) {
++ results.innerHTML = `<p style="color:var(--danger,#ef4444)">${esc(e.message)}</p>`;
++ }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// TTS (via openvibelive TTS proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadTTS() {
++ const c = document.getElementById('tts-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const [settingsRes, voicesRes] = await Promise.all([
++ api('/api/admin/streamer-tts/admin/settings'),
++ api('/api/admin/streamer-tts/voices'),
++ ]);
++ const s = settingsRes.settings || {};
++ const voices = voicesRes.voices || [];
++ const byEngine = (engine) => voices.filter(v => v.engine === engine);
++ const voiceOptions = (list) => list.map(v => `<option value="${esc(v.id)}" ${v.id === s.defaultVoice ? 'selected' : ''}>${esc(v.name)} (${esc(v.rarity || 'standard')})${v.available ? '' : ' ⚠ unavailable'}</option>`).join('');
++
++ c.innerHTML = `
++ <form id="tts-form" style="display:grid;gap:16px;max-width:760px">
++ <div class="card">
++ <h3><i class="fa-solid fa-sliders"></i> TTS Configuration</h3>
++ <div class="setting-row"><label><strong>Enabled</strong><br><small>Allow TTS requests on OpenVibe.Live.</small></label><input type="checkbox" id="tts-enabled" ${s.enabled ? 'checked' : ''} style="width:18px;height:18px"></div>
++ <div class="form-field"><label>Provider</label><select id="tts-provider"><option value="espeak-ng" ${s.provider === 'espeak-ng' ? 'selected' : ''}>espeak-ng</option><option value="google-cloud" ${s.provider === 'google-cloud' ? 'selected' : ''}>Google Cloud</option><option value="amazon-polly" ${s.provider === 'amazon-polly' ? 'selected' : ''}>Amazon Polly</option></select></div>
++ <div class="form-field"><label>Default Voice</label><select id="tts-default-voice"><optgroup label="espeak-ng">${voiceOptions(byEngine('espeak-ng'))}</optgroup><optgroup label="Google Cloud">${voiceOptions(byEngine('google-cloud'))}</optgroup><optgroup label="Amazon Polly">${voiceOptions(byEngine('amazon-polly'))}</optgroup></select></div>
++ <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px">
++ <div class="form-field"><label>Max Message Length</label><input type="number" id="tts-max-length" value="${esc(s.maxLength || 200)}"></div>
++ <div class="form-field"><label>Max Queue / User</label><input type="number" id="tts-max-per-user" value="${esc(s.maxQueuePerUser || 3)}"></div>
++ <div class="form-field"><label>Max Global Queue</label><input type="number" id="tts-max-global" value="${esc(s.maxQueueGlobal || 20)}"></div>
++ </div>
++ </div>
++
++ <div class="card">
++ <h3><i class="fa-brands fa-google"></i> Google Cloud</h3>
++ <div class="form-field"><label>API Key</label><input type="password" id="tts-google-api-key" value="${esc(s.googleApiKey || '')}" autocomplete="off"></div>
++ <div class="form-field"><label>Service Account JSON</label><textarea id="tts-google-sa" rows="4" style="resize:vertical">${esc(s.googleServiceAccount || '')}</textarea></div>
++ </div>
++
++ <div class="card">
++ <h3><i class="fa-brands fa-aws"></i> Amazon Polly</h3>
++ <div class="form-field"><label>AWS Access Key ID</label><input type="password" id="tts-aws-key" value="${esc(s.awsAccessKeyId || '')}" autocomplete="off"></div>
++ <div class="form-field"><label>AWS Secret Access Key</label><input type="password" id="tts-aws-secret" value="${esc(s.awsSecretAccessKey || '')}" autocomplete="off"></div>
++ <div class="form-field"><label>AWS Region</label><input type="text" id="tts-aws-region" value="${esc(s.awsRegion || 'us-east-1')}"></div>
++ </div>
++
++ <div style="display:flex;gap:10px;flex-wrap:wrap">
++ <button type="button" class="btn btn-primary" onclick="saveTTS()"><i class="fa-solid fa-floppy-disk"></i> Save TTS Settings</button>
++ <button type="button" class="btn btn-outline" onclick="testTTSVoice()"><i class="fa-solid fa-volume-high"></i> Test Voice</button>
++ </div>
++ </form>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function saveTTS() {
++ try {
++ await api('/api/admin/streamer-tts/admin/settings', {
++ method: 'PUT',
++ body: {
++ settings: {
++ tts_enabled: document.getElementById('tts-enabled').checked,
++ tts_provider: document.getElementById('tts-provider').value,
++ tts_default_voice: document.getElementById('tts-default-voice').value,
++ tts_max_length: parseInt(document.getElementById('tts-max-length').value, 10) || 200,
++ tts_max_queue_per_user: parseInt(document.getElementById('tts-max-per-user').value, 10) || 3,
++ tts_max_queue_global: parseInt(document.getElementById('tts-max-global').value, 10) || 20,
++ tts_google_api_key: document.getElementById('tts-google-api-key').value,
++ tts_google_service_account: document.getElementById('tts-google-sa').value,
++ tts_aws_access_key_id: document.getElementById('tts-aws-key').value,
++ tts_aws_secret_access_key: document.getElementById('tts-aws-secret').value,
++ tts_aws_region: document.getElementById('tts-aws-region').value || 'us-east-1',
++ },
++ },
++ });
++ toast('TTS settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testTTSVoice() {
++ try {
++ const voiceId = document.getElementById('tts-default-voice').value;
++ const result = await api('/api/admin/streamer-tts/admin/test', {
++ method: 'POST',
++ body: { voiceId, text: 'Hello, this is a OpenVibe.Live voice test from the unified admin panel.' },
++ });
++ if (!result.audio || !result.mimeType) {
++ toast('No audio returned from the provider', 'warning');
++ return;
++ }
++ const binary = atob(result.audio);
++ const bytes = new Uint8Array(binary.length);
++ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
++ const url = URL.createObjectURL(new Blob([bytes], { type: result.mimeType }));
++ const audio = new Audio(url);
++ audio.onended = () => URL.revokeObjectURL(url);
++ await audio.play();
++ toast(`Testing ${result.voiceName || voiceId}`, 'info');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Cashouts (via funds proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadCashouts() {
++ const c = document.getElementById('cashouts-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer-funds/cashouts/pending');
++ const cashouts = data.cashouts || [];
++ if (!cashouts.length) { c.innerHTML = '<p class="muted">No pending cashouts</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>User</th><th>Amount</th><th>USD</th><th>PayPal</th><th>Requested</th><th>Actions</th></tr></thead>
++ <tbody>${cashouts.map(co => `
++ <tr>
++ <td><strong>${esc(co.username || '-')}</strong></td>
++ <td>${Number(co.amount || 0).toLocaleString()} CF</td>
++ <td>$${((Number(co.amount) || 0) * 0.01).toFixed(2)}</td>
++ <td>${esc(co.paypal_email || '-')}</td>
++ <td>${co.created_at ? timeAgo(co.created_at) : '-'}</td>
++ <td style="display:flex;gap:6px">
++ <button class="btn btn-sm btn-success" onclick="approveCashout('${co.id}')"><i class="fa-solid fa-check"></i> Approve</button>
++ <button class="btn btn-sm btn-danger" onclick="denyCashout('${co.id}')"><i class="fa-solid fa-xmark"></i> Deny</button>
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function approveCashout(id) {
++ try {
++ await api(`/api/admin/streamer-funds/cashout/${id}/approve`, { method: 'POST' });
++ toast('Cashout approved', 'success');
++ loadCashouts();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function denyCashout(id) {
++ const reason = prompt('Denial reason:');
++ if (reason === null) return;
++ try {
++ await api(`/api/admin/streamer-funds/cashout/${id}/deny`, { method: 'POST', body: { reason } });
++ toast('Cashout denied', 'info');
++ loadCashouts();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// VPN queue (via admin proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadVPNQueue() {
++ const c = document.getElementById('vpn-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/vpn-queue');
++ const queue = data.queue || [];
++ if (!queue.length) { c.innerHTML = '<p class="muted">VPN approval queue empty</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>User</th><th>IP</th><th>Reason</th><th>Status</th><th>Actions</th></tr></thead>
++ <tbody>${queue.map(item => `
++ <tr>
++ <td><strong>${esc(item.username || item.user_id || '-')}</strong></td>
++ <td>${esc(item.ip_address || '-')}</td>
++ <td>${esc(item.reason || '-')}</td>
++ <td><span class="badge badge-${item.status === 'approved' ? 'success' : item.status === 'denied' ? 'danger' : 'warning'}">${esc(item.status || 'pending')}</span></td>
++ <td>${item.status === 'pending' ? `
++ <div style="display:flex;gap:6px">
++ <button class="btn btn-sm btn-success" onclick="updateVPNStatus('${item.id}','approved')"><i class="fa-solid fa-check"></i> Approve</button>
++ <button class="btn btn-sm btn-danger" onclick="updateVPNStatus('${item.id}','denied')"><i class="fa-solid fa-xmark"></i> Deny</button>
++ </div>` : '<span class="muted">Handled</span>'}
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function updateVPNStatus(id, status) {
++ try {
++ await api(`/api/admin/streamer/vpn-queue/${id}`, { method: 'PUT', body: { status } });
++ toast(`VPN request ${status}`, status === 'approved' ? 'success' : 'info');
++ loadVPNQueue();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Pastes (via pastes proxy + streamer settings)
++// ═══════════════════════════════════════════════════════════════
++async function loadPastesAdmin() {
++ const c = document.getElementById('pastes-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const [statsData, configData] = await Promise.all([
++ api('/api/admin/streamer-pastes/admin/stats'),
++ api('/api/admin/streamer-pastes/config'),
++ ]);
++ const s = statsData.stats || {};
++ const cfg = configData || {};
++ c.innerHTML = `
++ <div class="stats-grid">
++ <div class="stat-card"><div class="value">${Number(s.total || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paste"></i> Total Pastes</div></div>
++ <div class="stat-card"><div class="value">${Number(s.textPastes || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code"></i> Text Pastes</div></div>
++ <div class="stat-card"><div class="value">${Number(s.screenshots || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-image"></i> Screenshots</div></div>
++ <div class="stat-card"><div class="value">${Number(s.forks || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code-fork"></i> Forks</div></div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-sliders"></i> Paste Limits</h3>
++ <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">
++ <div class="form-field"><label>Max Paste Size (KB)</label><input type="number" id="paste-max-size-kb" value="${esc(cfg.maxSizeKb || 512)}"></div>
++ <div class="form-field"><label>Max Image Size (MB)</label><input type="number" id="paste-screenshot-max-mb" value="${esc(cfg.screenshotMaxSizeMb || 8)}"></div>
++ <div class="form-field"><label>Cooldown (seconds)</label><input type="number" id="paste-cooldown" value="${esc(cfg.cooldownSeconds || 30)}"></div>
++ <div class="form-field"><label>Max / User / Day</label><input type="number" id="paste-max-per-day" value="${esc(cfg.maxPerUserPerDay || 50)}"></div>
++ </div>
++ <div class="setting-row"><label><strong>Allow anonymous pastes</strong></label><input type="checkbox" id="paste-anon" ${cfg.anonAllowed !== false ? 'checked' : ''} style="width:18px;height:18px"></div>
++ <div class="setting-row"><label><strong>Allow image uploads</strong></label><input type="checkbox" id="paste-image-upload" ${cfg.imageUploadEnabled !== false ? 'checked' : ''} style="width:18px;height:18px"></div>
++ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px">
++ <button class="btn btn-primary" onclick="savePasteAdminConfig()"><i class="fa-solid fa-floppy-disk"></i> Save Paste Settings</button>
++ <button class="btn btn-danger" onclick="deleteAllPasteForks()"><i class="fa-solid fa-trash-can"></i> Delete All Forks (${Number(s.forks || 0).toLocaleString()})</button>
++ </div>
++ </div>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function savePasteAdminConfig() {
++ try {
++ await api('/api/admin/streamer/settings', {
++ method: 'PUT',
++ body: {
++ settings: {
++ paste_max_size_kb: document.getElementById('paste-max-size-kb').value,
++ paste_screenshot_max_size_mb: document.getElementById('paste-screenshot-max-mb').value,
++ paste_cooldown_seconds: document.getElementById('paste-cooldown').value,
++ paste_max_per_user_per_day: document.getElementById('paste-max-per-day').value,
++ paste_anon_allowed: document.getElementById('paste-anon').checked ? 'true' : 'false',
++ paste_image_upload_enabled: document.getElementById('paste-image-upload').checked ? 'true' : 'false',
++ },
++ },
++ });
++ toast('Paste settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function deleteAllPasteForks() {
++ if (!confirm('Delete all forked pastes? This cannot be undone.')) return;
++ try {
++ const data = await api('/api/admin/streamer-pastes/admin/forks', { method: 'DELETE' });
++ toast(`Deleted ${Number(data.deleted || 0).toLocaleString()} forked paste(s)`, 'success');
++ loadPastesAdmin();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Storage (via openvibelive proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadStorage() {
++ const c = document.getElementById('storage-content');
++ c.innerHTML = '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div>';
++ try {
++ const [data, tierData] = await Promise.all([
++ api('/api/admin/streamer/storage'),
++ api('/api/admin/streamer/storage/tiers').catch(() => null),
++ ]);
++ const d = data.disk || {};
++ const usePct = d.total ? ((d.used / d.total) * 100).toFixed(1) : 0;
++ const dataPct = d.total ? ((data.dataTotal?.bytes || 0) / d.total * 100).toFixed(1) : 0;
++ const pctColor = parseFloat(usePct) >= 90 ? 'var(--danger)' : parseFloat(usePct) >= 75 ? 'var(--warning)' : 'var(--success)';
++ const breakdown = (data.breakdown || []).sort((a, b) => b.bytes - a.bytes);
++ const maxB = Math.max(...breakdown.map(b => b.bytes), 1);
++
++ c.innerHTML = `
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px">
++ <div class="card">
++ <h3><i class="fa-solid fa-hard-drive"></i> Disk Usage</h3>
++ <div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:14px">
++ <span>${fmtBytes(d.used)} used of ${fmtBytes(d.total)}</span>
++ <span style="font-weight:700;color:${pctColor}">${usePct}%</span>
++ </div>
++ <div class="storage-bar">
++ <div class="storage-bar-fill" style="background:${pctColor};width:${usePct}%"></div>
++ </div>
++ <div style="display:flex;justify-content:space-between;margin-top:8px;font-size:12px;color:var(--text-muted)">
++ <span>${fmtBytes(d.available)} available</span>
++ <span>Mount: ${esc(d.mount || '/')}</span>
++ </div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-database"></i> Data Summary</h3>
++ <div class="stats-grid" style="margin:0">
++ <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${fmtBytes(data.dataTotal?.bytes || 0)}</div><div class="label">Total Data</div></div>
++ <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${(data.dataTotal?.files || 0).toLocaleString()}</div><div class="label">Files</div></div>
++ <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${fmtBytes(data.database?.bytes || 0)}</div><div class="label">Database</div></div>
++ <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${dataPct}%</div><div class="label">% of Disk</div></div>
++ </div>
++ </div>
++ </div>
++
++ <h3 style="margin-bottom:12px"><i class="fa-solid fa-folder-tree"></i> Storage Breakdown</h3>
++ <div style="display:grid;gap:8px;margin-bottom:24px">
++ ${breakdown.map(b => `
++ <div style="display:grid;grid-template-columns:140px 1fr 100px 80px;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px">
++ <span style="font-weight:600;font-size:13px"><i class="fa-solid ${esc(b.icon || 'fa-folder')}" style="width:18px;text-align:center;margin-right:6px;color:var(--accent)"></i>${esc(b.name)}</span>
++ <div class="storage-bar"><div class="storage-bar-fill" style="background:var(--accent);width:${(b.bytes/maxB*100).toFixed(1)}%"></div></div>
++ <span style="text-align:right;font-weight:600;font-size:12px">${fmtBytes(b.bytes)}</span>
++ <span style="text-align:right;font-size:11px;color:var(--text-muted)">${b.files?.toLocaleString() || 0} files</span>
++ </div>
++ `).join('')}
++ </div>
++
++ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
++ <h3><i class="fa-solid fa-video"></i> VOD Management</h3>
++ <div style="display:flex;gap:8px">
++ <select id="vod-sort" onchange="loadVodTable()" style="padding:6px 10px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px">
++ <option value="size">Sort by Size</option>
++ <option value="date">Sort by Date</option>
++ <option value="duration">Sort by Duration</option>
++ </select>
++ <button class="btn btn-sm btn-danger" id="vod-bulk-btn" disabled onclick="bulkDeleteVods()"><i class="fa-solid fa-trash-can"></i> Delete Selected</button>
++ </div>
++ </div>
++ <div id="vod-table"><div class="loading">Loading VODs...</div></div>
++ `;
++ loadVodTable();
++ loadStreamerStorageExtras(tierData);
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++// ── Streamer storage tiers (Local/B2/R2) + cloud usage/cost (openvibe.network admin) ──
++function buildStreamerTierHtml(tierData) {
++ if (!tierData) return '';
++ const t = tierData.tiers || {};
++ const ct = tierData.clipTiers || {};
++ const prov = tierData.providers || {};
++ const ts = tierData.settings || {};
++ const L = t.local || { count: 0, bytes: 0 };
++ const B = t.b2 || { count: 0, bytes: 0 };
++ const R = t.r2 || { count: 0, bytes: 0 };
++ const totalBytes = (L.bytes || 0) + (B.bytes || 0) + (R.bytes || 0);
++ const totalVods = (L.count || 0) + (B.count || 0) + (R.count || 0);
++ const totalClips = ((ct.local && ct.local.count) || 0) + ((ct.b2 && ct.b2.count) || 0) + ((ct.r2 && ct.r2.count) || 0);
++ const offloaded = (B.bytes || 0) + (R.bytes || 0);
++ const offloadPct = totalBytes > 0 ? ((offloaded / totalBytes) * 100).toFixed(1) : '0.0';
++ const pctOf = (b) => totalBytes > 0 ? (b / totalBytes) * 100 : 0;
++ const provStatus = (p) => {
++ if (!p) return '<span style="color:var(--text-muted)">—</span>';
++ if (!p.configured) return '<span style="color:var(--text-muted)">not configured</span>';
++ return p.healthy
++ ? '<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-circle-check"></i> healthy</span>' + (p.bucket ? ' <span style="color:var(--text-muted);font-size:11px">· ' + esc(p.bucket) + '</span>' : '')
++ : '<span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-circle-xmark"></i> error</span>';
++ };
++ const rows = [
++ { label: 'Local (SSD)', icon: 'fa-hard-drive', color: '#f59e0b', v: L, clips: (ct.local && ct.local.count) || 0, status: '<span style="color:var(--text-muted)">primary disk</span>' },
++ { label: 'Backblaze B2', icon: 'fa-box-archive', color: '#e21e2b', v: B, clips: (ct.b2 && ct.b2.count) || 0, status: provStatus(prov.b2) },
++ { label: 'Cloudflare R2', icon: 'fa-cloud', color: '#f6821f', v: R, clips: (ct.r2 && ct.r2.count) || 0, status: provStatus(prov.r2) },
++ ];
++ const bar = '<div style="display:flex;height:16px;border-radius:8px;overflow:hidden;margin:6px 0 14px;background:var(--bg-tertiary)">'
++ + '<div title="Local ' + pctOf(L.bytes).toFixed(1) + '%" style="width:' + pctOf(L.bytes) + '%;background:#f59e0b"></div>'
++ + '<div title="B2 ' + pctOf(B.bytes).toFixed(1) + '%" style="width:' + pctOf(B.bytes) + '%;background:#e21e2b"></div>'
++ + '<div title="R2 ' + pctOf(R.bytes).toFixed(1) + '%" style="width:' + pctOf(R.bytes) + '%;background:#f6821f"></div></div>';
++ const rowHtml = rows.map(r => '<tr>'
++ + '<td style="padding:8px 10px"><i class="fa-solid ' + r.icon + '" style="color:' + r.color + '"></i> <strong>' + r.label + '</strong></td>'
++ + '<td style="padding:8px 10px;text-align:right">' + (r.v.count || 0).toLocaleString() + '</td>'
++ + '<td style="padding:8px 10px;text-align:right">' + fmtBytes(r.v.bytes || 0) + '</td>'
++ + '<td style="padding:8px 10px;text-align:right">' + pctOf(r.v.bytes).toFixed(1) + '%</td>'
++ + '<td style="padding:8px 10px;text-align:right">' + (r.clips || 0).toLocaleString() + '</td>'
++ + '<td style="padding:8px 10px">' + r.status + '</td></tr>').join('');
++ return '<div class="card" style="margin-bottom:24px">'
++ + '<h3 style="margin:0 0 6px"><i class="fa-solid fa-layer-group"></i> Storage Tiers <span style="color:var(--text-muted);font-size:13px;font-weight:400">— ' + offloadPct + '% of VOD data offloaded to cloud</span></h3>'
++ + bar
++ + '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">'
++ + '<thead><tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">'
++ + '<th style="padding:6px 10px">Tier</th><th style="padding:6px 10px;text-align:right">VODs</th><th style="padding:6px 10px;text-align:right">VOD Size</th><th style="padding:6px 10px;text-align:right">Share</th><th style="padding:6px 10px;text-align:right">Clips</th><th style="padding:6px 10px">Provider</th></tr></thead>'
++ + '<tbody>' + rowHtml
++ + '<tr style="border-top:2px solid var(--border);font-weight:700"><td style="padding:8px 10px">Total</td><td style="padding:8px 10px;text-align:right">' + totalVods.toLocaleString() + '</td><td style="padding:8px 10px;text-align:right">' + fmtBytes(totalBytes) + '</td><td style="padding:8px 10px;text-align:right">100%</td><td style="padding:8px 10px;text-align:right">' + totalClips.toLocaleString() + '</td><td style="padding:8px 10px">' + (tierData.sweepRunning ? '<span style="color:#f59e0b"><i class="fa-solid fa-spinner fa-spin"></i> sweeping</span>' : '<span style="color:var(--text-muted)">idle</span>') + '</td></tr>'
++ + '</tbody></table></div>'
++ + '<div style="display:flex;flex-wrap:wrap;gap:8px;font-size:12px;color:var(--text-muted);margin-top:12px">'
++ + '<span><i class="fa-solid ' + (ts.enabled ? 'fa-toggle-on' : 'fa-toggle-off') + '" style="color:' + (ts.enabled ? '#22c55e' : '#ef4444') + '"></i> Auto-offload: ' + (ts.enabled ? 'ON' : 'OFF') + '</span>'
++ + '<span>| Age &ge; ' + (ts.minAgeDays != null ? ts.minAgeDays : '?') + 'd</span><span>| Views &le; ' + (ts.maxViewsForCold != null ? ts.maxViewsForCold : '?') + '</span><span>| Idle &ge; ' + (ts.minLastAccessDays != null ? ts.minLastAccessDays : '?') + 'd</span>'
++ + '</div></div>';
++}
++
++function loadStreamerStorageExtras(tierData) {
++ const c = document.getElementById('storage-content');
++ if (!c) return;
++ c.insertAdjacentHTML('afterbegin', '<div id="streamer-cloud-storage" style="margin-bottom:24px"></div>');
++ c.insertAdjacentHTML('afterbegin', buildStreamerTierHtml(tierData));
++ loadStreamerCloudStorage();
++}
++
++async function loadStreamerCloudStorage(force) {
++ const el = document.getElementById('streamer-cloud-storage');
++ if (!el) return;
++ const header = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">'
++ + '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud Storage — B2 &amp; R2 Usage &amp; Cost</h3>'
++ + '<button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan</button></div>';
++ el.innerHTML = header + '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Scanning cloud buckets…</div>';
++ try {
++ const data = await api('/api/admin/streamer/storage/buckets' + (force ? '?force=1' : ''));
++ const usage = data.usage || {};
++ const costs = data.costs || {};
++ const money = (n) => '$' + (Number(n) || 0).toFixed(2);
++ const card = (key, label, color) => {
++ const u = usage[key];
++ const cc = costs[key];
++ if (!u || u.configured === false) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--text-muted);margin-top:8px">Not configured</div></div>';
++ if (u.error) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--danger,#ef4444);margin-top:8px">Scan error: ' + esc(u.error) + '</div></div>';
++ const tops = Object.entries(u.prefixes || {}).sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 8);
++ const prefixRows = tops.map(([p, v]) => '<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);padding:2px 0"><span>' + esc(p) + '</span><span>' + fmtBytes(v.bytes) + ' · ' + v.objects.toLocaleString() + '</span></div>').join('');
++ return '<div class="card" style="flex:1;min-width:260px">'
++ + '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><span style="color:var(--text-muted);font-size:12px">' + esc(u.bucket || '') + '</span></div>'
++ + '<div style="display:flex;gap:16px;margin-bottom:10px"><div><div style="font-size:20px;font-weight:700">' + fmtBytes(u.bytes) + '</div><div style="color:var(--text-muted);font-size:11px">' + u.objects.toLocaleString() + ' objects</div></div>'
++ + '<div><div style="font-size:20px;font-weight:700;color:' + color + '">' + (cc ? money(cc.storageMonthly) : '—') + '<span style="font-size:12px;font-weight:400;color:var(--text-muted)">/mo</span></div><div style="color:var(--text-muted);font-size:11px">storage @ $' + (cc ? cc.storagePerGbMonth : '?') + '/GB</div></div></div>'
++ + '<div style="color:var(--text-muted);font-size:11px;margin-bottom:6px">Egress: ' + (cc ? esc(cc.egressNote) : '—') + '</div>'
++ + (prefixRows ? '<div style="border-top:1px solid var(--border);padding-top:6px">' + prefixRows + '</div>' : '') + '</div>';
++ };
++ el.innerHTML = header
++ + '<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px">' + card('b2', 'Backblaze B2', '#e21e2b') + card('r2', 'Cloudflare R2', '#f6821f') + '</div>'
++ + '<div class="card" style="display:flex;align-items:center;justify-content:space-between"><span><i class="fa-solid fa-file-invoice-dollar" style="color:var(--accent)"></i> <strong>Estimated cloud storage cost</strong> <span style="color:var(--text-muted);font-size:12px">(list prices; excludes egress/operations)</span></span><span style="font-size:22px;font-weight:800;color:var(--accent)">' + money(costs.totalStorageMonthly) + '<span style="font-size:13px;font-weight:400;color:var(--text-muted)">/mo</span></span></div>';
++ } catch (e) {
++ el.innerHTML = header + '<div style="color:var(--danger,#ef4444)">Failed to scan buckets: ' + esc(e.message) + '</div>';
++ }
++}
++
++async function loadVodTable() {
++ const c = document.getElementById('vod-table');
++ if (!c) return;
++ const sort = document.getElementById('vod-sort')?.value || 'size';
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api(`/api/admin/streamer/storage/vods?sort=${sort}&order=desc&limit=100`);
++ const vods = data.vods || [];
++ if (!vods.length) { c.innerHTML = '<p class="muted">No VODs found</p>'; return; }
++
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr>
++ <th style="width:32px"><input type="checkbox" onchange="toggleAllVods(this.checked)" style="cursor:pointer"></th>
++ <th>Title</th><th>User</th><th style="text-align:right">Size</th><th style="text-align:right">Duration</th><th>Date</th>
++ </tr></thead>
++ <tbody>${vods.map(v => `
++ <tr>
++ <td><input type="checkbox" class="vod-cb" value="${v.id}" onchange="updateVodSelection()" style="cursor:pointer"></td>
++ <td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(v.title||'')}">${esc(v.title || '(untitled)')}</td>
++ <td>${esc(v.username || '-')}</td>
++ <td style="text-align:right;font-weight:600;font-variant-numeric:tabular-nums">${fmtBytes(v.diskSize || v.file_size || 0)}</td>
++ <td style="text-align:right;color:var(--text-secondary)">${fmtDuration(v.duration_seconds)}</td>
++ <td>${v.created_at ? timeAgo(v.created_at) : '-'}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>
++ ${data.total > 100 ? `<p class="muted" style="margin-top:8px;font-size:11px">Showing top 100 of ${data.total.toLocaleString()} VODs</p>` : ''}`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++function toggleAllVods(checked) { document.querySelectorAll('.vod-cb').forEach(cb => cb.checked = checked); updateVodSelection(); }
++function updateVodSelection() {
++ const n = document.querySelectorAll('.vod-cb:checked').length;
++ const btn = document.getElementById('vod-bulk-btn');
++ if (btn) { btn.disabled = n === 0; btn.innerHTML = `<i class="fa-solid fa-trash-can"></i> Delete Selected${n ? ` (${n})` : ''}`; }
++}
++
++async function bulkDeleteVods() {
++ const ids = [...document.querySelectorAll('.vod-cb:checked')].map(cb => parseInt(cb.value));
++ if (!ids.length) return;
++ if (!confirm(`Permanently delete ${ids.length} VOD(s)?`)) return;
++ try {
++ const data = await api('/api/admin/streamer/storage/vods/bulk', { method: 'DELETE', body: { ids } });
++ toast(`Deleted ${data.deleted || 0} VOD(s), freed ${fmtBytes(data.freed || 0)}`, 'success');
++ loadStorage();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Broadcast Notification
++// ═══════════════════════════════════════════════════════════════
++async function sendBroadcast() {
++ const title = document.getElementById('broadcast-title')?.value.trim();
++ const message = document.getElementById('broadcast-msg')?.value.trim();
++ const icon = document.getElementById('broadcast-icon')?.value.trim() || '📢';
++ const priority = document.getElementById('broadcast-priority')?.value || 'normal';
++ const url = document.getElementById('broadcast-url')?.value.trim();
++ if (!title) return toast('Title required', 'error');
++ if (!confirm(`Send broadcast "${title}" to all users?`)) return;
++ try {
++ const data = await api('/api/admin/broadcast', { method: 'POST', body: { title, message, icon, priority, url } });
++ toast(`Broadcast sent to ${data.sent || 0} users`, 'success');
++ document.getElementById('broadcast-title').value = '';
++ document.getElementById('broadcast-msg').value = '';
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Email Configuration
++// ═══════════════════════════════════════════════════════════════
++async function loadEmail() {
++ const c = document.getElementById('email-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/email');
++ const email = data.email || {};
++ const metrics = data.metrics || {};
++ const summary = metrics.summary || {};
++ const byType = Array.isArray(metrics.by_type) ? metrics.by_type : [];
++ const recent = Array.isArray(metrics.recent) ? metrics.recent : [];
++ c.innerHTML = `
++ <div class="stats-grid" style="margin-bottom:16px">
++ <div class="stat-card"><div class="value">${Number(summary.sent || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paper-plane"></i> Emails Sent</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.failed || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-triangle-exclamation"></i> Failed</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.sent_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-clock"></i> Sent · 24h</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.password_resets_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-key"></i> Password Resets · 24h</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.notifications_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-bell"></i> Notification Emails · 24h</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.tests_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-vial"></i> Test Emails · 24h</div></div>
++ </div>
++
++ <div class="card" style="padding:16px;margin-bottom:16px">
++ <h3 style="margin-bottom:10px"><i class="fa-solid fa-chart-line"></i> Email Delivery Metrics</h3>
++ <div class="muted" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px">
++ <div><strong>Total Logged:</strong> ${Number(summary.total || 0).toLocaleString()}</div>
++ <div><strong>Failed · 24h:</strong> ${Number(summary.failed_24h || 0).toLocaleString()}</div>
++ <div><strong>Last Sent:</strong> ${summary.last_sent_at ? esc(timeAgo(summary.last_sent_at)) : 'Never'}</div>
++ <div><strong>Last Failure:</strong> ${summary.last_failed_at ? esc(timeAgo(summary.last_failed_at)) : 'Never'}</div>
++ </div>
++ </div>
++
++ <form id="email-form" style="max-width:600px;margin-bottom:16px">
++ <div class="setting-row">
++ <label><strong>Email Enabled</strong></label>
++ <input type="checkbox" id="email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer">
++ </div>
++ ${email.issue ? `<div style="background:#e74c3c22;border:1px solid #e74c3c44;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#e74c3c"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(email.issue)}</div>` : email.ready ? `<div style="background:#27ae6022;border:1px solid #27ae6044;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#27ae60"><i class="fa-solid fa-circle-check"></i> Resend is ready — emails will be delivered</div>` : ''}
++
++ <h4 style="margin:12px 0 10px;font-size:14px"><i class="fa-solid fa-key"></i> Resend API</h4>
++ <div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="${esc(email.api_key || '')}" placeholder="re_xxxxxxxxxxxx" autocomplete="off"></div>
++ <p class="muted" style="margin:-4px 0 8px;font-size:11px">Get your API key from <a href="https://resend.com/api-keys" target="_blank" style="color:var(--accent)">resend.com/api-keys</a>. Key is masked after saving.</p>
++
++ <h4 style="margin:20px 0 10px;font-size:14px"><i class="fa-solid fa-envelope"></i> From Addresses</h4>
++ <div class="form-field"><label>Default From Email</label><input type="email" id="email-from-email" value="${esc(email.from_email || '')}" placeholder="[email protected]"></div>
++ <div class="form-field"><label>Default From Name</label><input type="text" id="email-from-name" value="${esc(email.from_name || '')}" placeholder="OpenVibe"></div>
++ <div class="form-field"><label>OpenVibe.Live From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibelive" value="${esc(email.from_email_openvibelive || '')}" placeholder="[email protected]"></div>
++ <div class="form-field"><label>OpenVibe Quest From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibegames" value="${esc(email.from_email_openvibegames || '')}" placeholder="[email protected]"></div>
++ <div class="form-field"><label>OpenVibe Tools From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibenetwork" value="${esc(email.from_email_openvibenetwork || '')}" placeholder="[email protected]"></div>
++
++ <div style="display:flex;gap:8px;margin-top:16px">
++ <button type="button" class="btn btn-primary" onclick="saveEmail()"><i class="fa-solid fa-floppy-disk"></i> Save Email Config</button>
++ <button type="button" class="btn btn-outline" onclick="testEmail()"><i class="fa-solid fa-paper-plane"></i> Send Test Email</button>
++ </div>
++ <div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)">
++ <strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>
++ Add your domain at <a href="https://resend.com/domains" target="_blank" style="color:var(--accent)">resend.com/domains</a> and add the SPF (TXT + MX) and DKIM records to your DNS provider. Once verified, emails will be sent from your domain.
++ </div>
++ </form>
++
++ <div class="card" style="padding:16px;margin-bottom:16px">
++ <h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery Breakdown · 30 days</h3>
++ ${byType.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Email Type</th><th>Status</th><th>Count</th></tr></thead>
++ <tbody>
++ ${byType.map(row => `
++ <tr>
++ <td>${esc(row.email_type)}</td>
++ <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>
++ <td>${Number(row.count || 0).toLocaleString()}</td>
++ </tr>
++ `).join('')}
++ </tbody>
++ </table>`
++ : '<p class="muted">No email activity logged yet.</p>'}
++ </div>
++
++ <div class="card" style="padding:16px">
++ <h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3>
++ ${recent.length ? `
++ <table class="admin-table">
++ <thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Subject</th><th>Error</th></tr></thead>
++ <tbody>
++ ${recent.map(row => `
++ <tr>
++ <td style="white-space:nowrap">${esc(timeAgo(row.created_at))}</td>
++ <td>${esc(row.email_type)}</td>
++ <td>${esc(row.recipient)}</td>
++ <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>
++ <td>${esc(row.subject || '-')}</td>
++ <td style="font-size:11px;color:var(--text-muted)">${esc(row.error_message || '-')}</td>
++ </tr>
++ `).join('')}
++ </tbody>
++ </table>`
++ : '<p class="muted">No email activity logged yet.</p>'}
++ </div>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function saveEmail() {
++ try {
++ await api('/api/admin/email', { method: 'PUT', body: {
++ enabled: document.getElementById('email-enabled').checked,
++ api_key: document.getElementById('email-api-key').value,
++ from_email: document.getElementById('email-from-email').value,
++ from_name: document.getElementById('email-from-name').value,
++ from_email_openvibelive: document.getElementById('email-from-openvibelive').value,
++ from_email_openvibegames: document.getElementById('email-from-openvibegames').value,
++ from_email_openvibenetwork: document.getElementById('email-from-openvibenetwork').value,
++ }});
++ toast('Email configuration saved', 'success');
++ loadEmail();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testEmail() {
++ const email = prompt('Send test email to:');
++ if (!email) return;
++ try {
++ const result = await api('/api/admin/email/test', { method: 'POST', body: { email } });
++ if (result.sent) {
++ toast('Test email sent successfully! Check your inbox.', 'success');
++ } else {
++ toast(result.error || 'Send failed — check delivery log', 'error');
++ }
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Verification Keys
++// ═══════════════════════════════════════════════════════════════
++const secretStore = {};
++async function loadVKeys() {
++ const c = document.getElementById('vkeys-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/verification-keys');
++ const keys = data.keys || [];
++ keys.forEach(k => { if (k.key) secretStore[`vk-${k.id}`] = k.key; });
++ if (!keys.length) { c.innerHTML = '<p class="muted">No verification keys generated yet</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>Key</th><th>Reserved Username</th><th>Status</th><th>Note</th><th>Created</th><th>Actions</th></tr></thead>
++ <tbody>${keys.map(k => `
++ <tr>
++ <td><code id="vk-${k.id}" style="font-size:12px;color:var(--text-muted)">••••••••</code></td>
++ <td><strong>${esc(k.target_username)}</strong></td>
++ <td><span class="badge badge-${k.status==='active'?'success':k.status==='used'?'info':'danger'}">${esc(k.status)}</span></td>
++ <td style="font-size:12px">${esc(k.note || '-')}</td>
++ <td style="font-size:11px">${k.created_at ? new Date(k.created_at).toLocaleDateString() : '-'}</td>
++ <td style="display:flex;gap:4px">
++ ${k.status === 'active' ? `
++ <button class="btn btn-sm btn-outline" onclick="revealVKey('vk-${k.id}')"><i class="fa-solid fa-eye"></i></button>
++ <button class="btn btn-sm btn-outline" onclick="copyVKey('vk-${k.id}')"><i class="fa-solid fa-copy"></i></button>
++ <button class="btn btn-sm btn-danger" onclick="revokeVKey('${k.id}')"><i class="fa-solid fa-trash"></i></button>
++ ` : k.status === 'used' ? `<span class="muted">Used by ${esc(k.used_by_name || '?')}</span>` : '<span class="muted">Revoked</span>'}
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++function revealVKey(id) {
++ const el = document.getElementById(id);
++ if (!el) return;
++ const revealed = el.dataset.revealed === 'true';
++ el.textContent = revealed ? '••••••••' : (secretStore[id] || '••••••••');
++ el.dataset.revealed = revealed ? 'false' : 'true';
++ el.style.color = revealed ? 'var(--text-muted)' : 'var(--accent-light)';
++}
++
++function copyVKey(id) {
++ const val = secretStore[id];
++ if (!val) return toast('No key to copy', 'error');
++ navigator.clipboard.writeText(val).then(() => toast('Key copied', 'success')).catch(() => toast('Copy failed', 'error'));
++}
++
++async function generateVKey() {
++ const username = document.getElementById('vkey-username')?.value.trim();
++ const note = document.getElementById('vkey-note')?.value.trim();
++ if (!username) return toast('Enter a username', 'error');
++ try {
++ const data = await api('/api/admin/verification-keys', { method: 'POST', body: { target_username: username, note } });
++ const key = data.key;
++ if (key?.id && key?.key) secretStore[`vk-${key.id}`] = key.key;
++ toast('Verification key generated', 'success');
++ document.getElementById('vkey-username').value = '';
++ document.getElementById('vkey-note').value = '';
++ loadVKeys();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function revokeVKey(id) {
++ if (!confirm('Revoke this verification key?')) return;
++ try {
++ await api(`/api/admin/verification-keys/${id}`, { method: 'DELETE' });
++ toast('Key revoked', 'success');
++ loadVKeys();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Analytics
++// ═══════════════════════════════════════════════════════════════
++let analyticsCurrentSubTab = 'overview';
++let analyticsCharts = {};
++const CHART_COLORS = {
++ accent: '#8b5cf6',
++ accentLight: '#a78bfa',
++ success: '#2ecc71',
++ danger: '#e74c3c',
++ warning: '#f59e0b',
++ info: '#3498db',
++ purple: '#9b59b6',
++ pink: '#e84393',
++ teal: '#00cec9',
++ navy: '#2d3436',
++};
++const PIE_COLORS = ['#8b5cf6', '#3498db', '#2ecc71', '#e74c3c', '#9b59b6', '#f59e0b', '#e84393', '#00cec9', '#1abc9c', '#fd79a8'];
++
++function getAnalyticsDays() {
++ return parseInt(document.getElementById('analytics-period')?.value) || 30;
++}
++
++/** Parse the period select value into {days, hours, qs} */
++function getAnalyticsPeriod() {
++ const val = document.getElementById('analytics-period')?.value || '30';
++ if (val.endsWith('h')) {
++ const hours = parseInt(val);
++ return { days: Math.ceil(hours / 24), hours, qs: `days=${Math.ceil(hours / 24)}&hours=${hours}` };
++ }
++ if (val.endsWith('d')) {
++ const d = parseInt(val);
++ const hours = d * 24;
++ return { days: d, hours, qs: `days=${d}&hours=${hours}` };
++ }
++ const days = parseInt(val) || 30;
++ return { days, hours: null, qs: `days=${days}` };
++}
++
++function fmtBytes(bytes) {
++ if (!bytes || bytes < 0) return '0 B';
++ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
++ let i = 0;
++ let v = bytes;
++ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
++ return v.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
++}
++
++function showAnalyticsSubTab(tab, btn) {
++ analyticsCurrentSubTab = tab;
++ document.querySelectorAll('#analytics-sub-tabs button').forEach(b => b.classList.remove('active'));
++ btn?.classList.add('active');
++ document.getElementById('analytics-overview').style.display = tab === 'overview' ? 'block' : 'none';
++ const serviceNames = ['live','openvibe-network','tools','games','media'];
++ document.getElementById('analytics-service-detail').style.display = serviceNames.includes(tab) ? 'block' : 'none';
++ document.getElementById('analytics-bots-detail').style.display = tab === 'bots' ? 'block' : 'none';
++ if (tab === 'overview') loadAnalyticsOverview();
++ else if (tab === 'bots') loadBotAnalysis();
++ else loadServiceAnalytics(tab);
++ _syncAdminUrl();
++}
++
++function reloadAnalytics() {
++ if (analyticsCurrentSubTab === 'overview') loadAnalyticsOverview();
++ else if (analyticsCurrentSubTab === 'bots') loadBotAnalysis();
++ else loadServiceAnalytics(analyticsCurrentSubTab);
++ _syncAdminUrl(true); // period lives in ?last= — replace, don't stack history
++}
++
++async function loadAnalytics() {
++ loadAnalyticsOverview();
++}
++
++function destroyChart(id) {
++ if (analyticsCharts[id]) { analyticsCharts[id].destroy(); delete analyticsCharts[id]; }
++}
++
++function createChart(canvasId, config) {
++ destroyChart(canvasId);
++ const ctx = document.getElementById(canvasId);
++ if (!ctx) return null;
++ // Default dark theme
++ config.options = config.options || {};
++ config.options.responsive = true;
++ config.options.maintainAspectRatio = true;
++ config.options.plugins = config.options.plugins || {};
++ config.options.plugins.legend = config.options.plugins.legend || { labels: { color: '#b0b0b8', font: { size: 11 } } };
++ if (config.options.scales) {
++ for (const [, axis] of Object.entries(config.options.scales)) {
++ axis.ticks = axis.ticks || {};
++ axis.ticks.color = axis.ticks.color || '#707080';
++ axis.grid = axis.grid || {};
++ axis.grid.color = axis.grid.color || 'rgba(42,42,58,0.5)';
++ }
++ }
++ analyticsCharts[canvasId] = new Chart(ctx, config);
++ return analyticsCharts[canvasId];
++}
++
++function fmtNum(n) { return (n || 0).toLocaleString(); }
++
++async function loadAnalyticsOverview() {
++ const period = getAnalyticsPeriod();
++ const statsEl = document.getElementById('analytics-stats');
++ statsEl.innerHTML = '<div class="loading">Loading analytics...</div>';
++ try {
++ const data = await api(`/api/admin/analytics/overview?${period.qs}`);
++ const ov = data.overview;
++ const t = ov.totals || {};
++ const bw = ov.bandwidth || {};
++
++ statsEl.innerHTML = [
++ { icon: 'fa-eye', label: 'Page Views', value: fmtNum(t.total_pageviews) },
++ { icon: 'fa-code', label: 'API Calls', value: fmtNum(t.total_api_calls) },
++ { icon: 'fa-users', label: 'Unique Visitors', value: fmtNum(t.total_unique_visitors) },
++ { icon: 'fa-id-badge', label: 'Sessions', value: fmtNum(ov.sessionCount || 0) },
++ { icon: 'fa-robot', label: 'Bot Hits', value: fmtNum(t.total_bot_hits) },
++ { icon: 'fa-circle-exclamation', label: 'Errors', value: fmtNum(t.total_errors) },
++ { icon: 'fa-gauge-high', label: 'Avg Response', value: (t.avg_response_ms || 0) + 'ms' },
++ { icon: 'fa-arrow-up-from-bracket', label: 'Est. Bandwidth', value: fmtBytes(bw.estimated_bytes) },
++ ].map(s => `<div class="stat-card"><div class="value">${s.value}</div><div class="label"><i class="fa-solid ${s.icon}"></i> ${s.label}</div></div>`).join('');
++
++ // Real-time card
++ const rt = ov.realtime || {};
++ const rtCard = document.getElementById('analytics-realtime-card');
++ if (rt.requests > 0) {
++ rtCard.style.display = 'block';
++ document.getElementById('analytics-realtime').innerHTML = `
++ <div style="display:flex;gap:24px;font-size:14px">
++ <span><strong style="color:var(--accent-light)">${fmtNum(rt.requests)}</strong> requests</span>
++ <span><strong style="color:var(--success)">${fmtNum(rt.visitors)}</strong> visitors</span>
++ <span><strong style="color:var(--danger)">${fmtNum(rt.bots)}</strong> bots</span>
++ </div>`;
++ } else {
++ rtCard.style.display = 'none';
++ }
++
++ // Traffic trend chart — use timeBuckets for sub-day, dailyTrend for multi-day
++ const buckets = ov.timeBuckets || [];
++ const trend = ov.dailyTrend || [];
++ const useSubDay = period.hours && period.hours < 24 && buckets.length > 0;
++ const trendData = useSubDay ? buckets : trend;
++ const trendLabels = useSubDay
++ ? trendData.map(d => { const parts = d.bucket?.split(' '); return parts?.[1] || d.bucket; })
++ : trendData.map(d => d.date?.slice(5) || '');
++ if (trendData.length) {
++ createChart('chart-traffic-trend', {
++ type: 'line',
++ data: {
++ labels: trendLabels,
++ datasets: [
++ { label: 'Page Views', data: trendData.map(d => d.pageviews), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3 },
++ { label: 'API Calls', data: trendData.map(d => d.api_calls), borderColor: CHART_COLORS.info, backgroundColor: 'rgba(52,152,219,0.1)', fill: true, tension: 0.3 },
++ { label: 'Visitors', data: trendData.map(d => d.unique_visitors), borderColor: CHART_COLORS.success, backgroundColor: 'rgba(46,204,113,0.1)', fill: true, tension: 0.3 },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } } },
++ });
++ }
++
++ // Service comparison bar chart
++ const svcs = ov.services || [];
++ if (svcs.length) {
++ createChart('chart-service-compare', {
++ type: 'bar',
++ data: {
++ labels: svcs.map(s => s.label || s.name),
++ datasets: [
++ { label: 'Page Views', data: svcs.map(s => s.total_pageviews || 0), backgroundColor: CHART_COLORS.accent },
++ { label: 'API Calls', data: svcs.map(s => s.total_api_calls || 0), backgroundColor: CHART_COLORS.info },
++ { label: 'Bot Hits', data: svcs.map(s => s.total_bot_hits || 0), backgroundColor: CHART_COLORS.danger },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } } },
++ });
++ }
++
++ // ── Unique Visitors Trend (dedicated chart) ──────────
++ if (trendData.length) {
++ createChart('chart-visitors-trend', {
++ type: 'line',
++ data: {
++ labels: trendLabels,
++ datasets: [
++ { label: 'Unique Visitors', data: trendData.map(d => d.unique_visitors || 0), borderColor: CHART_COLORS.success, backgroundColor: 'rgba(46,204,113,0.15)', fill: true, tension: 0.3, pointRadius: 3 },
++ ],
++ },
++ options: {
++ scales: { x: {}, y: { beginAtZero: true } },
++ plugins: { legend: { display: false } },
++ },
++ });
++ }
++
++ // ── Logged-in vs Anonymous Trend ─────────────────────
++ const authTrend = ov.authTrend || [];
++ if (authTrend.length) {
++ const authLabels = useSubDay
++ ? authTrend.map(d => { const parts = d.bucket?.split(' '); return parts?.[1] || d.bucket; })
++ : authTrend.map(d => d.bucket?.slice(5) || '');
++ createChart('chart-auth-trend', {
++ type: 'line',
++ data: {
++ labels: authLabels,
++ datasets: [
++ { label: 'Logged-in Visitors', data: authTrend.map(d => d.auth_visitors || 0), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.15)', fill: true, tension: 0.3, pointRadius: 2 },
++ { label: 'Anonymous Visitors', data: authTrend.map(d => d.anon_visitors || 0), borderColor: '#6c6c80', backgroundColor: 'rgba(108,108,128,0.15)', fill: true, tension: 0.3, pointRadius: 2 },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true, stacked: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // ── Per-Service Traffic Share (doughnut) ─────────────
++ if (svcs.length) {
++ const svcTraffic = svcs.map(s => (s.total_pageviews || 0) + (s.total_api_calls || 0)).filter(v => v > 0);
++ const svcLabelsFiltered = svcs.filter((s, i) => svcTraffic.length > i && ((s.total_pageviews || 0) + (s.total_api_calls || 0)) > 0);
++ if (svcTraffic.length) {
++ createChart('chart-service-share', {
++ type: 'doughnut',
++ data: {
++ labels: svcLabelsFiltered.map(s => s.label || s.name),
++ datasets: [{ data: svcLabelsFiltered.map(s => (s.total_pageviews || 0) + (s.total_api_calls || 0)), backgroundColor: PIE_COLORS.slice(0, svcLabelsFiltered.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Per-service visitors doughnut
++ const svcVis = svcs.filter(s => (s.total_unique_visitors || 0) > 0);
++ if (svcVis.length) {
++ createChart('chart-service-visitors', {
++ type: 'doughnut',
++ data: {
++ labels: svcVis.map(s => s.label || s.name),
++ datasets: [{ data: svcVis.map(s => s.total_unique_visitors || 0), backgroundColor: PIE_COLORS.slice(0, svcVis.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++ }
++
++ // ── Per-Service Traffic Over Time (stacked area) ─────
++ const perSvcTrend = ov.perServiceTrend || [];
++ if (perSvcTrend.length) {
++ // Build unified labels from all series
++ const labelSet = new Set();
++ for (const s of perSvcTrend) {
++ for (const d of s.data) labelSet.add(d.date || d.bucket);
++ }
++ const labels = Array.from(labelSet).sort();
++ const fmtLabel = (l) => useSubDay ? (l.split(' ')[1] || l) : (l.slice(5) || l);
++ const datasets = perSvcTrend.map((s, i) => {
++ const dataMap = new Map(s.data.map(d => [d.date || d.bucket, (d.pageviews || 0) + (d.api_calls || 0)]));
++ const color = PIE_COLORS[i % PIE_COLORS.length];
++ return {
++ label: s.label || s.name,
++ data: labels.map(l => dataMap.get(l) || 0),
++ borderColor: color,
++ backgroundColor: color + '33',
++ fill: true,
++ tension: 0.3,
++ pointRadius: 1,
++ };
++ });
++ createChart('chart-per-service-trend', {
++ type: 'line',
++ data: { labels: labels.map(fmtLabel), datasets },
++ options: { scales: { x: {}, y: { beginAtZero: true, stacked: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // ── Service table with auth breakdown columns ────────
++ const tblEl = document.getElementById('analytics-service-table');
++ tblEl.innerHTML = svcs.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Service</th><th>Views</th><th>API</th><th>Visitors</th><th>Logged-in</th><th>Anon</th><th>Bots</th><th>Errors</th><th>Avg ms</th></tr></thead>
++ <tbody>${svcs.map(s => {
++ const auth = s.authBreakdown || {};
++ return `
++ <tr>
++ <td><strong>${esc(s.label || s.name)}</strong></td>
++ <td>${fmtNum(s.total_pageviews)}</td>
++ <td>${fmtNum(s.total_api_calls)}</td>
++ <td>${fmtNum(s.total_unique_visitors)}</td>
++ <td style="color:var(--accent-light)">${fmtNum(auth.unique_authenticated_users)}</td>
++ <td style="color:var(--text-secondary)">${fmtNum(auth.anonymous)}</td>
++ <td>${fmtNum(s.total_bot_hits)}</td>
++ <td>${fmtNum(s.total_errors)}</td>
++ <td>${s.avg_response_ms || 0}ms</td>
++ </tr>`;
++ }).join('')}</tbody>
++ </table>` : '<p class="muted">No data yet</p>';
++
++ // Bot ratio donut
++ const totalHuman = (t.total_pageviews || 0) + (t.total_api_calls || 0);
++ const totalBots = t.total_bot_hits || 0;
++ if (totalHuman > 0 || totalBots > 0) {
++ createChart('chart-bot-ratio', {
++ type: 'doughnut',
++ data: {
++ labels: ['Human Traffic', 'Bot Traffic'],
++ datasets: [{ data: [totalHuman, totalBots], backgroundColor: [CHART_COLORS.success, CHART_COLORS.danger], borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Bandwidth & usage card
++ const bwEl = document.getElementById('analytics-bandwidth');
++ bwEl.innerHTML = `
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px">
++ <div><strong style="color:var(--accent-light)">${fmtNum(bw.total_requests)}</strong><br><span class="muted">Total Requests</span></div>
++ <div><strong style="color:var(--accent-light)">${fmtBytes(bw.estimated_bytes)}</strong><br><span class="muted">Est. Transfer</span></div>
++ <div><strong style="color:var(--info)">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>
++ <div><strong style="color:var(--info)">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>
++ <div><strong style="color:var(--success)">${fmtNum(ov.sessionCount || 0)}</strong><br><span class="muted">Unique Sessions</span></div>
++ <div><strong style="color:var(--success)">${fmtNum(t.total_unique_users || 0)}</strong><br><span class="muted">Logged-in Users</span></div>
++ </div>`;
++
++ } catch (e) {
++ statsEl.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ }
++}
++
++async function loadServiceAnalytics(serviceName) {
++ const period = getAnalyticsPeriod();
++ const statsEl = document.getElementById('svc-analytics-stats');
++ statsEl.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api(`/api/admin/analytics/service/${serviceName}?${period.qs}`);
++ const a = data.analytics;
++ const s = a.summary || {};
++ const bw = a.bandwidth || {};
++ const auth = a.authBreakdown || {};
++ const vis = a.visitorTypes || {};
++ const rp = a.responsePercentiles || {};
++
++ // Stat cards — more metrics
++ statsEl.innerHTML = [
++ { icon: 'fa-eye', label: 'Page Views', value: fmtNum(s.total_pageviews) },
++ { icon: 'fa-code', label: 'API Calls', value: fmtNum(s.total_api_calls) },
++ { icon: 'fa-users', label: 'Unique Visitors', value: fmtNum(s.total_unique_visitors) },
++ { icon: 'fa-user', label: 'Logged-in Users', value: fmtNum(s.total_unique_users) },
++ { icon: 'fa-id-badge', label: 'Sessions', value: fmtNum(a.sessionCount || 0) },
++ { icon: 'fa-robot', label: 'Bot Hits', value: fmtNum(s.total_bot_hits) },
++ { icon: 'fa-circle-exclamation', label: 'Errors', value: fmtNum(s.total_errors) },
++ { icon: 'fa-gauge-high', label: 'Avg Response', value: (s.avg_response_ms || 0) + 'ms' },
++ { icon: 'fa-arrow-up-from-bracket', label: 'Est. Bandwidth', value: fmtBytes(bw.estimated_bytes) },
++ { icon: 'fa-user-plus', label: 'New Visitors', value: fmtNum(vis.new_visitors) },
++ ].map(s => `<div class="stat-card"><div class="value">${s.value}</div><div class="label"><i class="fa-solid ${s.icon}"></i> ${s.label}</div></div>`).join('');
++
++ // For sub-day ranges, use timeBuckets for the "daily" chart
++ const isSubDay = period.hours && period.hours < 24;
++ const buckets = a.timeBuckets || [];
++
++ // Daily / fine-grained traffic chart
++ if (isSubDay && buckets.length) {
++ document.getElementById('svc-hourly-title').textContent = 'Traffic (' + period.hours + 'h)';
++ createChart('chart-svc-daily', {
++ type: 'line',
++ data: {
++ labels: buckets.map(b => { const parts = b.bucket?.split(' '); return parts?.[1] || b.bucket; }),
++ datasets: [
++ { label: 'Page Views', data: buckets.map(b => b.pageviews), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
++ { label: 'API Calls', data: buckets.map(b => b.api_calls), borderColor: CHART_COLORS.info, tension: 0.3, pointRadius: 2 },
++ { label: 'Visitors', data: buckets.map(b => b.unique_visitors), borderColor: CHART_COLORS.success, tension: 0.3, pointRadius: 2 },
++ { label: 'Bots', data: buckets.map(b => b.bot_hits), borderColor: CHART_COLORS.danger, borderDash: [5,5], tension: 0.3, pointRadius: 1 },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } } },
++ });
++ } else {
++ document.getElementById('svc-hourly-title').textContent = 'Hourly (24h)';
++ const daily = a.daily || [];
++ if (daily.length) {
++ createChart('chart-svc-daily', {
++ type: 'line',
++ data: {
++ labels: daily.map(d => d.date.slice(5)),
++ datasets: [
++ { label: 'Page Views', data: daily.map(d => d.pageviews), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
++ { label: 'API Calls', data: daily.map(d => d.api_calls), borderColor: CHART_COLORS.info, tension: 0.3, pointRadius: 2 },
++ { label: 'Visitors', data: daily.map(d => d.unique_visitors), borderColor: CHART_COLORS.success, tension: 0.3, pointRadius: 2 },
++ { label: 'Bots', data: daily.map(d => d.bot_hits), borderColor: CHART_COLORS.danger, borderDash: [5,5], tension: 0.3, pointRadius: 1 },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } } },
++ });
++ }
++ }
++
++ // Hourly bar chart
++ const hourly = a.hourly || [];
++ if (hourly.length) {
++ createChart('chart-svc-hourly', {
++ type: 'bar',
++ data: {
++ labels: hourly.map(h => h.hour?.slice(11, 16) || ''),
++ datasets: [
++ { label: 'Page Views', data: hourly.map(h => h.pageviews), backgroundColor: 'rgba(139,92,246,0.7)' },
++ { label: 'API Calls', data: hourly.map(h => h.api_calls), backgroundColor: 'rgba(52,152,219,0.7)' },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Response time percentiles
++ const rpEl = document.getElementById('svc-response-percentiles');
++ if (rp.p50 !== undefined) {
++ const pctBar = (label, val, max) => {
++ const pct = max > 0 ? Math.min(100, (val / max) * 100) : 0;
++ const color = val < 100 ? 'var(--success)' : val < 300 ? 'var(--accent-light)' : val < 1000 ? 'var(--warning)' : 'var(--danger)';
++ return `<div style="margin:6px 0"><div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:2px"><span class="muted">${label}</span><strong>${val}ms</strong></div><div class="storage-bar"><div class="storage-bar-fill" style="width:${pct}%;background:${color}"></div></div></div>`;
++ };
++ rpEl.innerHTML = pctBar('Median (p50)', rp.p50, rp.max) + pctBar('p90', rp.p90, rp.max) + pctBar('p95', rp.p95, rp.max) + pctBar('p99', rp.p99, rp.max) + pctBar('Max', rp.max, rp.max);
++ } else {
++ rpEl.innerHTML = '<p class="muted">No response time data</p>';
++ }
++
++ // Peak hours chart
++ const peakHours = a.peakHours || [];
++ if (peakHours.length) {
++ createChart('chart-svc-peak-hours', {
++ type: 'bar',
++ data: {
++ labels: peakHours.map(h => h.hour_of_day + ':00'),
++ datasets: [
++ { label: 'Hits', data: peakHours.map(h => h.hits), backgroundColor: 'rgba(139,92,246,0.7)' },
++ { label: 'Visitors', data: peakHours.map(h => h.visitors), backgroundColor: 'rgba(46,204,113,0.5)' },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Devices pie
++ const devices = a.deviceBreakdown || [];
++ if (devices.length) {
++ createChart('chart-svc-devices', {
++ type: 'doughnut',
++ data: {
++ labels: devices.map(d => d.device_type || 'unknown'),
++ datasets: [{ data: devices.map(d => d.cnt), backgroundColor: PIE_COLORS.slice(0, devices.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Browsers pie
++ const browsers = a.browserBreakdown || [];
++ if (browsers.length) {
++ createChart('chart-svc-browsers', {
++ type: 'doughnut',
++ data: {
++ labels: browsers.map(d => d.browser || 'unknown'),
++ datasets: [{ data: browsers.map(d => d.cnt), backgroundColor: PIE_COLORS.slice(0, browsers.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // OS pie
++ const oses = a.osBreakdown || [];
++ if (oses.length) {
++ createChart('chart-svc-os', {
++ type: 'doughnut',
++ data: {
++ labels: oses.map(d => d.os || 'unknown'),
++ datasets: [{ data: oses.map(d => d.cnt), backgroundColor: PIE_COLORS.slice(0, oses.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Bandwidth & sessions card
++ const bsEl = document.getElementById('svc-bandwidth-sessions');
++ bsEl.innerHTML = `
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
++ <div><strong style="color:var(--accent-light)">${fmtBytes(bw.estimated_bytes)}</strong><br><span class="muted">Est. Transfer</span></div>
++ <div><strong style="color:var(--accent-light)">${fmtNum(bw.total_requests)}</strong><br><span class="muted">Total Requests</span></div>
++ <div><strong style="color:var(--info)">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>
++ <div><strong style="color:var(--info)">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>
++ <div><strong style="color:var(--success)">${fmtNum(a.sessionCount || 0)}</strong><br><span class="muted">Sessions</span></div>
++ <div><strong style="color:var(--success)">${fmtNum(vis.new_visitors || 0)}</strong> new / <strong>${fmtNum(vis.returning_visitors || 0)}</strong> returning<br><span class="muted">Visitors</span></div>
++ </div>`;
++
++ // Auth vs anonymous chart
++ const authCount = auth.authenticated || 0;
++ const anonCount = auth.anonymous || 0;
++ if (authCount > 0 || anonCount > 0) {
++ createChart('chart-svc-auth', {
++ type: 'doughnut',
++ data: {
++ labels: ['Authenticated (' + fmtNum(auth.unique_authenticated_users) + ' users)', 'Anonymous'],
++ datasets: [{ data: [authCount, anonCount], backgroundColor: [CHART_COLORS.accent, '#4a4a5a'], borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Top pages
++ const pages = a.topPages || [];
++ document.getElementById('svc-top-pages').innerHTML = pages.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Path</th><th>Hits</th><th>Visitors</th></tr></thead>
++ <tbody>${pages.slice(0, 15).map(p => `
++ <tr><td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.path)}">${esc(p.path)}</td><td>${fmtNum(p.hits)}</td><td>${fmtNum(p.visitors)}</td></tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No data</p>';
++
++ // Top API endpoints
++ const apiEndpoints = a.topApiEndpoints || [];
++ document.getElementById('svc-top-api').innerHTML = apiEndpoints.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Endpoint</th><th>Method</th><th>Hits</th><th>Avg ms</th><th>Errors</th></tr></thead>
++ <tbody>${apiEndpoints.slice(0, 15).map(e => `
++ <tr>
++ <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.path)}">${esc(e.path)}</td>
++ <td><span class="badge badge-info">${esc(e.method)}</span></td>
++ <td>${fmtNum(e.hits)}</td>
++ <td>${e.avg_ms || 0}ms</td>
++ <td style="color:${e.errors > 0 ? 'var(--danger)' : 'inherit'}">${fmtNum(e.errors)}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No API data</p>';
++
++ // Referers
++ const refs = a.topReferers || [];
++ document.getElementById('svc-top-referers').innerHTML = refs.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Referer</th><th>Hits</th></tr></thead>
++ <tbody>${refs.slice(0, 10).map(r => `
++ <tr><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.referer)}">${esc(r.referer)}</td><td>${fmtNum(r.hits)}</td></tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No referers tracked</p>';
++
++ // Slowest endpoints
++ const slow = a.slowestEndpoints || [];
++ document.getElementById('svc-slowest').innerHTML = slow.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Endpoint</th><th>Method</th><th>Avg ms</th><th>Max ms</th><th>Hits</th></tr></thead>
++ <tbody>${slow.slice(0, 10).map(e => `
++ <tr>
++ <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.path)}">${esc(e.path)}</td>
++ <td><span class="badge badge-warning">${esc(e.method)}</span></td>
++ <td style="color:${e.avg_ms > 500 ? 'var(--danger)' : e.avg_ms > 200 ? 'var(--warning)' : 'inherit'}">${e.avg_ms || 0}ms</td>
++ <td style="color:var(--danger)">${e.max_ms || 0}ms</td>
++ <td>${fmtNum(e.hits)}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No data</p>';
++
++ // Status codes
++ const statuses = a.statusCodes || [];
++ if (statuses.length) {
++ const statusColors = { '2xx': CHART_COLORS.success, '3xx': CHART_COLORS.info, '4xx': CHART_COLORS.warning, '5xx': CHART_COLORS.danger, 'other': '#707080' };
++ createChart('chart-svc-status', {
++ type: 'bar',
++ data: {
++ labels: statuses.map(s => s.group_code),
++ datasets: [{ label: 'Count', data: statuses.map(s => s.cnt), backgroundColor: statuses.map(s => statusColors[s.group_code] || '#707080') }],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { display: false } } },
++ });
++ }
++
++ // Countries
++ const countries = a.countryBreakdown || [];
++ document.getElementById('svc-countries').innerHTML = countries.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Country</th><th>Hits</th></tr></thead>
++ <tbody>${countries.slice(0, 15).map(c => `
++ <tr><td>${esc(c.country || 'Unknown')}</td><td>${fmtNum(c.cnt)}</td></tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No country data (requires Cloudflare CF-IPCountry header)</p>';
++ } catch (e) {
++ statsEl.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ }
++}
++
++async function loadBotAnalysis() {
++ const period = getAnalyticsPeriod();
++ const el = document.getElementById('bots-content');
++ el.innerHTML = '<div class="loading">Loading bot analysis...</div>';
++ try {
++ const data = await api(`/api/admin/analytics/bots?${period.qs}`);
++ const bots = data.bots || {};
++ let html = '';
++
++ for (const [svcName, svcBots] of Object.entries(bots)) {
++ if (!svcBots) continue;
++ const label = svcName === 'live' ? 'OpenVibe.Live' : svcName === 'openvibe-network' ? 'OpenVibe.Network' : svcName === 'tools' ? 'OpenVibe.Tools' : svcName === 'games' ? 'OpenVibe.Games' : svcName === 'media' ? 'OpenVibe.Media' : svcName;
++
++ html += `<div class="card"><h3><i class="fa-solid fa-robot"></i> ${esc(label)}</h3>`;
++
++ // Bot types
++ const types = svcBots.botTypes || [];
++ if (types.length) {
++ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Bot Types</h4>
++ <table class="admin-table"><thead><tr><th>Type</th><th>Hits</th><th>Unique IPs</th></tr></thead>
++ <tbody>${types.map(t => `<tr><td><span class="badge badge-danger">${esc(t.bot_type || 'unknown')}</span></td><td>${fmtNum(t.hits)}</td><td>${fmtNum(t.unique_ips)}</td></tr>`).join('')}</tbody></table>`;
++ }
++
++ // Top bot IPs
++ const topIPs = svcBots.topBotIPs || [];
++ if (topIPs.length) {
++ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Top Bot IPs</h4>
++ <table class="admin-table"><thead><tr><th>IP</th><th>Hits</th><th>Type</th><th>First Seen</th><th>Last Seen</th></tr></thead>
++ <tbody>${topIPs.slice(0, 15).map(ip => `
++ <tr><td style="font-family:monospace;font-size:11px">${esc(ip.ip)}</td><td>${fmtNum(ip.hits)}</td>
++ <td><span class="badge badge-warning">${esc(ip.bot_type || '?')}</span></td>
++ <td style="font-size:11px">${ip.first_seen ? timeAgo(ip.first_seen) : '-'}</td>
++ <td style="font-size:11px">${ip.last_seen ? timeAgo(ip.last_seen) : '-'}</td></tr>
++ `).join('')}</tbody></table>`;
++ }
++
++ // Suspicious human IPs
++ const suspicious = svcBots.suspiciousIPs || [];
++ if (suspicious.length) {
++ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Suspicious IPs (high volume, classified as human)</h4>
++ <table class="admin-table"><thead><tr><th>IP</th><th>Total Hits</th><th>Errors</th><th>Unique Paths</th><th>Period</th></tr></thead>
++ <tbody>${suspicious.map(s => `
++ <tr><td style="font-family:monospace;font-size:11px">${esc(s.ip)}</td><td>${fmtNum(s.total_hits)}</td>
++ <td style="color:var(--danger)">${fmtNum(s.error_hits)}</td><td>${fmtNum(s.unique_paths)}</td>
++ <td style="font-size:11px">${s.first_seen ? timeAgo(s.first_seen) : '-'} — ${s.last_seen ? timeAgo(s.last_seen) : '-'}</td></tr>
++ `).join('')}</tbody></table>`;
++ }
++
++ // Bot trend
++ const trend = svcBots.botTrend || [];
++ if (trend.length) {
++ const canvasId = `chart-bot-trend-${svcName}`;
++ html += `<div style="margin-top:12px"><canvas id="${canvasId}" height="180"></canvas></div>`;
++ // Queue chart creation after DOM update
++ setTimeout(() => {
++ createChart(canvasId, {
++ type: 'line',
++ data: {
++ labels: trend.map(d => d.date?.slice(5) || ''),
++ datasets: [
++ { label: 'Human', data: trend.map(d => d.human_hits || 0), borderColor: CHART_COLORS.success, tension: 0.3, fill: true, backgroundColor: 'rgba(46,204,113,0.1)' },
++ { label: 'Bots', data: trend.map(d => d.bot_hits || 0), borderColor: CHART_COLORS.danger, tension: 0.3, fill: true, backgroundColor: 'rgba(231,76,60,0.1)' },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }, 50);
++ }
++
++ html += '</div>';
++ }
++
++ el.innerHTML = html || '<p class="muted">No bot data available yet. Analytics data will appear once traffic is recorded.</p>';
++ } catch (e) {
++ el.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Audit Log
++// ═══════════════════════════════════════════════════════════════
++let auditOffset = 0;
++async function loadAudit() {
++ const c = document.getElementById('audit-content');
++ const pager = document.getElementById('audit-pager');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api(`/api/admin/audit?limit=50&offset=${auditOffset}`);
++ const entries = data.entries || [];
++ if (!entries.length) { c.innerHTML = '<p class="muted">No audit entries</p>'; pager.innerHTML = ''; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
++ <tbody>${entries.map(e => `
++ <tr>
++ <td style="white-space:nowrap;font-size:11px">${e.created_at ? timeAgo(e.created_at) : '-'}</td>
++ <td><strong>${esc(e.username || e.user_id || '-')}</strong></td>
++ <td><span class="badge badge-info">${esc(e.action)}</span></td>
++ <td style="font-size:11px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.details || '')}">${esc(e.details || '-')}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++
++ const hasMore = entries.length >= 50;
++ pager.innerHTML = `
++ <button class="btn btn-sm btn-outline" ${auditOffset<=0?'disabled':''} onclick="auditOffset=Math.max(0,auditOffset-50);loadAudit()"><i class="fa-solid fa-chevron-left"></i></button>
++ <span class="muted">Offset ${auditOffset}</span>
++ <button class="btn btn-sm btn-outline" ${!hasMore?'disabled':''} onclick="auditOffset+=50;loadAudit()"><i class="fa-solid fa-chevron-right"></i></button>
++ `;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Discord Bot Settings
++// ═══════════════════════════════════════════════════════════════
++async function loadDiscordSettings() {
++ const c = document.getElementById('settings-discord');
++ c.innerHTML = '<p class="muted">Loading Discord settings...</p>';
++ try {
++ const data = await api('/api/admin/discord');
++ const s = data.settings || {};
++ const st = data.status || {};
++ const statusBadge = st.connected
++ ? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Connected as ${esc(st.botTag || '?')}</span>`
++ : `<span class="badge" style="background:var(--danger-bg,#7f1d1d);color:var(--danger-text,#f87171)">● Disconnected</span>`;
++
++ c.innerHTML = `
++ <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
++ <h3 style="margin:0"><i class="fa-brands fa-discord"></i> Discord Bot</h3>
++ ${statusBadge}
++ </div>
++ <form id="discord-settings-form">
++ <div class="setting-row">
++ <label>Bot Token</label>
++ <input type="password" data-key="discord_bot_token" value="${esc(s.discord_bot_token || '')}" placeholder="Bot token from Discord Developer Portal" style="width:100%;max-width:500px">
++ </div>
++ <div class="setting-row">
++ <label>Guild / Server ID</label>
++ <input type="text" data-key="discord_guild_id" value="${esc(s.discord_guild_id || '')}" placeholder="Right-click server → Copy Server ID" style="width:240px">
++ </div>
++ <div class="setting-row">
++ <label>Live Alerts Channel ID</label>
++ <input type="text" data-key="discord_alerts_channel_id" value="${esc(s.discord_alerts_channel_id || '')}" placeholder="Channel for go-live alerts" style="width:240px">
++ </div>
++ <div class="setting-row">
++ <label>System Alerts Channel ID</label>
++ <input type="text" data-key="discord_system_channel_id" value="${esc(s.discord_system_channel_id || '')}" placeholder="Channel for system/admin alerts (optional)" style="width:240px">
++ </div>
++ <div class="setting-row">
++ <label>Dedupe Cooldown (minutes)</label>
++ <input type="number" data-key="discord_dedupe_minutes" value="${esc(s.discord_dedupe_minutes || '15')}" min="1" max="1440" style="width:100px">
++ <span class="muted" style="margin-left:8px">Prevent spam if a streamer flaps live/offline</span>
++ </div>
++ <div class="setting-row">
++ <label>Custom Alert Message (optional)</label>
++ <input type="text" data-key="discord_alert_message" value="${esc(s.discord_alert_message || '')}" placeholder="{display_name} went live: {title}" style="width:100%;max-width:500px">
++ <div class="muted" style="font-size:12px;margin-top:4px">Placeholders: {username}, {display_name}, {title}, {url}</div>
++ </div>
++ <h4 style="margin-top:24px"><i class="fa-brands fa-discord"></i> Account Linking (OAuth2)</h4>
++ <div class="setting-row">
++ <label>OAuth2 Client ID</label>
++ <input type="text" data-key="discord_oauth_client_id" value="${esc(s.discord_oauth_client_id || '')}" placeholder="Discord application client ID" style="width:300px">
++ </div>
++ <div class="setting-row">
++ <label>OAuth2 Client Secret</label>
++ <input type="password" data-key="discord_oauth_client_secret" value="${esc(s.discord_oauth_client_secret || '')}" placeholder="Discord application client secret" style="width:300px">
++ </div>
++ <div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap">
++ <button type="button" class="btn btn-primary" onclick="saveDiscordSettings()"><i class="fa-solid fa-save"></i> Save Settings</button>
++ <button type="button" class="btn btn-outline" onclick="testDiscordAlert()"><i class="fa-solid fa-bell"></i> Test System Alert</button>
++ <button type="button" class="btn btn-outline" onclick="testDiscordLiveAlert()"><i class="fa-solid fa-broadcast-tower"></i> Test Live Alert</button>
++ <button type="button" class="btn btn-outline" onclick="reinitDiscordBot()"><i class="fa-solid fa-rotate"></i> Reconnect Bot</button>
++ </div>
++ </form>`;
++ } catch (e) {
++ c.innerHTML = `<p class="muted">Error loading Discord settings: ${esc(e.message)}</p>`;
++ }
++}
++
++async function saveDiscordSettings() {
++ try {
++ const form = document.getElementById('discord-settings-form');
++ const settings = {};
++ form.querySelectorAll('[data-key]').forEach(el => {
++ settings[el.dataset.key] = el.value;
++ });
++ await api('/api/admin/discord', { method: 'PUT', body: JSON.stringify({ settings }) });
++ toast('Discord settings saved');
++ loadDiscordSettings();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testDiscordAlert() {
++ try {
++ const data = await api('/api/admin/discord/test', { method: 'POST' });
++ toast(data.sent ? 'Test alert sent!' : `Alert not sent: ${data.reason || 'unknown'}`, data.sent ? 'success' : 'error');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testDiscordLiveAlert() {
++ try {
++ const data = await api('/api/admin/discord/test-live', { method: 'POST' });
++ toast(data.sent ? 'Test live alert sent!' : `Alert not sent: ${data.reason || 'unknown'}`, data.sent ? 'success' : 'error');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function reinitDiscordBot() {
++ try {
++ const data = await api('/api/admin/discord/reinit', { method: 'POST' });
++ toast(data.status?.connected ? `Bot reconnected as ${data.status.botTag}` : 'Bot reconnected (not logged in)', data.status?.connected ? 'success' : 'warning');
++ loadDiscordSettings();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Initialization
++// ═══════════════════════════════════════════════════════════════
++function _adminLoginRedirect() {
++ window.location.href = '/login?return=' + encodeURIComponent(window.location.href);
++}
++
++// Silent sliding refresh: exchange the current (possibly just-expired) token for a
++// fresh one, same as index.html/my.html. Returns true if a new token was stored.
++async function _adminTryRefresh() {
++ try {
++ const res = await fetch(API + '/api/auth/refresh', {
++ method: 'POST',
++ headers: { Authorization: 'Bearer ' + (token || ''), 'Content-Type': 'application/json' },
++ credentials: 'include',
++ });
++ if (!res.ok) return false;
++ const data = await res.json().catch(() => ({}));
++ if (data && data.token) { token = data.token; localStorage.setItem('ov_token', data.token); return true; }
++ return false;
++ } catch { return false; }
++}
++
++async function _bootAdmin() {
++ const data = await api('/api/auth/me');
++ currentUser = data.user || data;
++ if (currentUser.role !== 'admin') {
++ document.querySelector('.page').innerHTML = '<div class="card" style="text-align:center;padding:60px"><h3 style="color:var(--live-red)"><i class="fa-solid fa-lock"></i> Access Denied</h3><p class="muted" style="margin-top:8px">Admin privileges required.</p></div>';
++ return;
++ }
++ // Owner-only UI (Deploy tab, etc.) — hide for plain admins.
++ if (!currentUser.is_owner) {
++ document.querySelectorAll('.owner-only').forEach(el => { el.style.display = 'none'; });
++ }
++ // Init navbar
++ if (window.OpenVibeNavbar) {
++ OpenVibeNavbar.init({ service: 'network', token, user: currentUser });
++ const bellMount = OpenVibeNavbar.getBellMount();
++ if (bellMount && window.OpenVibeNotifications) {
++ OpenVibeNotifications.init({ mountEl: bellMount, apiBase: API, token });
++ }
++ }
++ initMainTabsOverflow();
++ // Open the tab from the URL slug (or restore the last-visited location).
++ routeFromUrl();
++}
++
++async function init() {
++ if (!token) { _adminLoginRedirect(); return; }
++ try {
++ await _bootAdmin();
++ } catch (e) {
++ // Invalid/expired token → try one silent refresh, then retry; if that fails,
++ // send the user through the OAuth login instead of showing a dead-end error.
++ if (await _adminTryRefresh()) {
++ try { await _bootAdmin(); return; } catch { /* fall through to login */ }
++ }
++ _adminLoginRedirect();
++ }
++}
++
++init();
++
++// ═══════════════════════════════════════════════════════════════
++// SSH Access — owner provisions locked-down dev accounts; admins get
++// guided instructions + an agent prompt to connect and vibe-code.
++// ═══════════════════════════════════════════════════════════════
++let sshInfo = null;
++
++async function loadSSH() {
++ const el = document.getElementById('ssh-content');
++ if (!el) return;
++ if (!sshInfo) {
++ try {
++ const r = await fetch(`${API}/api/admin/ssh-info`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error || 'Failed to load SSH info');
++ sshInfo = d;
++ } catch (e) {
++ el.innerHTML = `<div class="card"><p class="muted">Could not load SSH info: ${esc(e.message)}</p></div>`;
++ return;
++ }
++ }
++ if (sshInfo.is_owner) renderSSHOwner(el);
++ else renderSSHUser(el);
++}
++
++function _sshSetText(id, t) { const e = document.getElementById(id); if (e) e.textContent = t; }
++function _sshSanitizeUser(v) { return (v || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''); }
++
++function sshCopy(id, btn) {
++ const el = document.getElementById(id);
++ if (!el) return;
++ navigator.clipboard.writeText(el.textContent).then(() => {
++ if (typeof toast === 'function') toast('Prompt copied', 'success');
++ if (btn) { const o = btn.innerHTML; btn.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; setTimeout(() => { btn.innerHTML = o; }, 1400); }
++ }).catch(() => { if (typeof toast === 'function') toast('Copy failed', 'error'); });
++}
++
++// ── Owner view ──────────────────────────────────────────────
++function renderSSHOwner(el) {
++ const hostWarn = sshInfo.host ? '' :
++ `<div class="card" style="border-color:var(--warning)"><p style="color:var(--warning);margin:0"><i class="fa-solid fa-triangle-exclamation"></i> No server host is configured yet. Set <code>SSH_SERVER_HOST</code> in the openvibe-network environment so the connection details fill in automatically.</p></div>`;
++ el.innerHTML = `
++ <div class="card">
++ <h3><i class="fa-solid fa-user-lock"></i> Provision an SSH dev account <span class="staff-pill">Owner</span></h3>
++ <p class="muted">Create a locked-down Linux account so a trusted admin can SSH in and help build the sites (vibe coding with Claude). They get their own home + group access to the project folders — <b>no root</b>. Fill in the fields, copy the prompt, and hand it to your agent.</p>
++ </div>
++ ${hostWarn}
++ <div class="card">
++ <div class="form-group" style="margin-bottom:14px">
++ <label>New Linux username</label>
++ <input type="text" id="ssh-o-user" placeholder="e.g. alex" oninput="sshOwnerUpdate()" style="max-width:280px">
++ </div>
++ <div class="form-group" style="margin-bottom:14px">
++ <label>Their SSH <b>public</b> key <span class="muted">— ask them to run the steps in their own SSH tab and paste their <code>.pub</code> here (recommended)</span></label>
++ <textarea id="ssh-o-pubkey" rows="3" placeholder="ssh-ed25519 AAAA... alex@openvibe" oninput="sshOwnerUpdate()" style="width:100%;font-family:ui-monospace,monospace;font-size:0.82rem"></textarea>
++ </div>
++ <div class="form-group">
++ <label>Access level</label>
++ <select id="ssh-o-access" onchange="sshOwnerUpdate()" style="max-width:440px">
++ <option value="code">Project code only (safest)</option>
++ <option value="restart">Project code + restart app services</option>
++ <option value="web">Full web + nginx — build &amp; run their own sites (high trust)</option>
++ </select>
++ </div>
++ <div id="ssh-o-webwarn" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid var(--warning);border-radius:8px;background:color-mix(in srgb,var(--warning) 10%,transparent);color:var(--warning);font-size:0.84rem;line-height:1.5">
++ <i class="fa-solid fa-triangle-exclamation"></i> <b>High trust:</b> web + nginx access lets this admin edit any nginx site config and reload the web server — they can serve anything and a bad config can take <em>all</em> the sites down. It still stops short of root (no package installs or arbitrary sudo), but only grant it to admins you fully trust to run the web layer with you.
++ </div>
++ </div>
++ <div class="card">
++ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
++ <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
++ <button class="btn btn-primary" onclick="sshCopy('ssh-o-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>
++ </div>
++ <p class="muted" style="margin:8px 0">Paste into Claude Code running on the server with sudo (or locally with root SSH) to create and lock down the account.</p>
++ <pre id="ssh-o-prompt" class="ssh-prompt"></pre>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-shield-halved"></i> How it's locked down</h3>
++ <ul class="muted" style="line-height:1.7;margin:0;padding-left:20px">
++ <li>Key-only login — password auth is disabled for the account.</li>
++ <li>Member of a shared <code>devs</code> group that owns the project dirs (<code>${esc(sshInfo.projectsRoot)}</code>) with setgid, so they can edit code and new files stay group-shared.</li>
++ <li><b>Not</b> in <code>sudo</code> — no blanket root. TLS / packages / arbitrary system changes stay with you.</li>
++ <li><b>Access levels</b> are additive and each grants only a tightly-scoped sudoers rule (never general root):
++ <ul style="margin:4px 0 0;padding-left:18px">
++ <li><b>Project code only</b> — edit code in the project dirs, nothing privileged.</li>
++ <li><b>+ Restart app services</b> — adds sudo for <code>systemctl restart openvibe-network/openvibe-live</code> only.</li>
++ <li><b>Full web + nginx</b> — adds group ownership of the nginx site configs + a <code>/srv/www</code> web root, plus sudo limited to <code>nginx -t</code> and reload/restart of nginx &amp; php-fpm. Lets them build &amp; run their own sites — high trust, since a bad config affects every site.</li>
++ </ul>
++ </li>
++ <li>Revoke anytime: remove their key from <code>~/.ssh/authorized_keys</code>, or <code>sudo deluser --remove-home &lt;user&gt;</code>.</li>
++ </ul>
++ </div>`;
++ sshOwnerUpdate();
++}
++
++function sshOwnerUpdate() {
++ const user = _sshSanitizeUser(document.getElementById('ssh-o-user')?.value) || '<username>';
++ const pub = (document.getElementById('ssh-o-pubkey')?.value || '').trim();
++ const access = document.getElementById('ssh-o-access')?.value || 'code';
++ const host = sshInfo.host || '<server-host>';
++ const roots = sshInfo.projectsRoot;
++ const firstRoot = roots.split(/\s+/)[0];
++ const pubShown = pub || "<PASTE THE USER'S SSH PUBLIC KEY HERE>";
++ const isRestart = access === 'restart' || access === 'web';
++ const isWeb = access === 'web';
++
++ // Toggle the high-trust warning under the selector.
++ const ww = document.getElementById('ssh-o-webwarn');
++ if (ww) ww.style.display = isWeb ? 'block' : 'none';
++
++ const steps = [
++ `Create the user with no password login:\n sudo adduser --disabled-password --gecos "" ${user}`,
++ `Install their SSH public key:\n sudo install -d -m 700 -o ${user} -g ${user} /home/${user}/.ssh\n echo "${pubShown}" | sudo tee /home/${user}/.ssh/authorized_keys\n sudo chmod 600 /home/${user}/.ssh/authorized_keys\n sudo chown ${user}:${user} /home/${user}/.ssh/authorized_keys`,
++ `Create/ensure a shared dev group and add them:\n sudo groupadd -f devs\n sudo usermod -aG devs ${user}`,
++ `Give the group access to the project dirs (setgid so new files stay shared):\n sudo chgrp -R devs ${roots}\n sudo chmod -R g+rwX ${roots}\n sudo find ${roots} -type d -exec chmod g+s {} +`,
++ `Do NOT add them to the sudo group — they must not have blanket root.`,
++ ];
++ if (isWeb) {
++ steps.push(`Grant broad web + nginx access (edit any site config, run their own sites):\n sudo groupadd -f webdev\n sudo usermod -aG webdev ${user}\n # let the group own & edit nginx site configs (setgid keeps new files group-shared)\n sudo chgrp -R webdev /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo chmod -R g+rwX /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo find /etc/nginx/sites-available /etc/nginx/sites-enabled -type d -exec chmod g+s {} +\n # a shared web root for their own sites (2775 = group-writable + setgid)\n sudo install -d -m 2775 -o root -g webdev /srv/www`);
++ }
++ if (isRestart) {
++ const cmds = ['/usr/bin/systemctl restart openvibe-network', '/usr/bin/systemctl restart openvibe-live'];
++ if (isWeb) cmds.push('/usr/sbin/nginx -t', '/usr/bin/systemctl reload nginx', '/usr/bin/systemctl restart nginx', '/usr/bin/systemctl reload php*-fpm', '/usr/bin/systemctl restart php*-fpm');
++ steps.push(`Allow ONLY these exact root commands via sudo (nothing else):\n echo '${user} ALL=(root) NOPASSWD: ${cmds.join(', ')}' | sudo tee /etc/sudoers.d/${user}\n sudo chmod 440 /etc/sudoers.d/${user}\n sudo visudo -c`);
++ }
++ steps.push(`Verify:\n sudo -u ${user} whoami\n sudo -u ${user} ls -la ${firstRoot}${isWeb ? '\n sudo -u ' + user + ' test -w /etc/nginx/sites-enabled && echo "nginx: writable"' : ''}`);
++ const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n\n');
++
++ const purpose = isWeb
++ ? 'help develop our sites AND build/run their own nginx + PHP sites on the box'
++ : 'help develop the sites';
++ const webNote = isWeb
++ ? '\n\nNote: this tier gives them full control of the web layer (nginx configs + a /srv/www web root + reloading nginx/php-fpm) but still NOT blanket root — installing new system packages (PHP runtime, etc.) stays an owner action. If PHP-FPM is not installed yet, install it once before they start.'
++ : '';
++
++ const prompt =
++`You are provisioning a locked-down SSH account on my Linux server for a trusted OpenVibe admin so they can SSH in and ${purpose} (vibe coding with Claude Code). Be careful and idempotent, and do NOT grant this user root/sudo beyond exactly what is specified below.
++
++Server host: ${host}
++New username: ${user}
++Their SSH public key:
++${pubShown}
++
++Steps:
++${numbered}${webNote}
++
++Finally, tell me the exact command they should run to connect:
++ ssh ${user}@${host}`;
++ _sshSetText('ssh-o-prompt', prompt);
++}
++
++// ── Admin (user) view ───────────────────────────────────────
++function renderSSHUser(el) {
++ const defUser = _sshSanitizeUser(currentUser.username) || 'me';
++ el.innerHTML = `
++ <div class="card">
++ <h3><i class="fa-solid fa-terminal"></i> Connect to the dev server <span class="staff-pill user-pill">You</span></h3>
++ <p class="muted">Generate your key, send the owner (<b>${esc(sshInfo.ownerUsername)}</b>) your <b>public</b> key to authorize, then SSH in and help build the sites with Claude. Your private key never leaves your machine.</p>
++ </div>
++ <div class="card">
++ <div class="form-group">
++ <label>Your SSH username <span class="muted">— confirm the exact name with the owner</span></label>
++ <input type="text" id="ssh-u-user" value="${esc(defUser)}" oninput="sshUserUpdate()" style="max-width:280px">
++ </div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-list-ol"></i> Steps</h3>
++ <ol class="muted" style="line-height:1.9;padding-left:20px;margin:0">
++ <li>Generate a key pair (skip if you already have one you want to use):<br><code id="ssh-u-keygen"></code></li>
++ <li>Print your <b>public</b> key and send it to the owner to authorize:<br><code id="ssh-u-cat"></code></li>
++ <li>Once they confirm it's added, put this in your <code>~/.ssh/config</code>:<pre id="ssh-u-config" class="ssh-prompt" style="margin-top:6px"></pre></li>
++ <li>Connect: <code id="ssh-u-connect"></code></li>
++ </ol>
++ </div>
++ <div class="card">
++ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
++ <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
++ <button class="btn btn-primary" onclick="sshCopy('ssh-u-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>
++ </div>
++ <p class="muted" style="margin:8px 0">Paste into Claude Code on your own machine — it sets up the connection and gives it full context about the server so you can start vibe coding.</p>
++ <pre id="ssh-u-prompt" class="ssh-prompt"></pre>
++ </div>`;
++ sshUserUpdate();
++}
++
++function sshUserUpdate() {
++ const host = sshInfo.host || '<server-host>';
++ const user = _sshSanitizeUser(document.getElementById('ssh-u-user')?.value) || '<username>';
++ const keygen = `ssh-keygen -t ed25519 -C "${user}@openvibe" -f ~/.ssh/openvibe_${user}`;
++ const cat = `cat ~/.ssh/openvibe_${user}.pub`;
++ const config = `Host openvibe\n HostName ${host}\n User ${user}\n IdentityFile ~/.ssh/openvibe_${user}\n ServerAliveInterval 60`;
++ const connect = `ssh openvibe`;
++ _sshSetText('ssh-u-keygen', keygen);
++ _sshSetText('ssh-u-cat', cat);
++ _sshSetText('ssh-u-config', config);
++ _sshSetText('ssh-u-connect', connect);
++
++ const prompt =
++`Help me connect to the OpenVibe dev server over SSH, then help me develop the sites (vibe coding with Claude Code).
++
++My SSH username: ${user}
++Server host: ${host}
++
++Do this:
++1. If I don't already have this key, generate one (leave the passphrase empty, or set one — my call):
++ ${keygen}
++2. Print my PUBLIC key so I can send it to the server owner (${sshInfo.ownerUsername}) to authorize. Nothing works until they add it:
++ ${cat}
++3. Add this block to my ~/.ssh/config (create the file if needed, then chmod 600 ~/.ssh/config):
++${config}
++4. Test the connection:
++ ${connect}
++
++Context about the server so you can help me once I'm connected:
++- Projects live under: ${sshInfo.projectsRoot}
++ (openvibe.network under /opt/openvibe.network, OpenVibe.Live under /opt/openvibe.live)
++- Services are systemd units: ${sshInfo.services.join(', ')}. Deploys are git-based —
++ cd into the project, git pull --ff-only, then restart the service (if I'm permitted).
++- I'm a OpenVibe admin, not root. nginx / TLS / packages / system-level changes go through the owner.
++- Other devs share the project group, so be careful with shared files and always pull before editing.
++Once I'm connected, cd into the relevant project and let's build.`;
++ _sshSetText('ssh-u-prompt', prompt);
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Deploy Panel — TLS / Nginx / Infrastructure Control Plane
++// ═══════════════════════════════════════════════════════════════
++
++function showDeploySubTab(tab, btn) {
++ deployCurrentSubTab = tab;
++ document.querySelectorAll('#deploy-sub-tabs button').forEach(b => b.classList.remove('active'));
++ (btn || _subBtn('deploy-sub-tabs', tab))?.classList.add('active');
++ ['deploy-overview', 'deploy-certs-panel', 'deploy-nginx-panel', 'deploy-domains-panel', 'deploy-config-panel'].forEach(id => {
++ const el = document.getElementById(id);
++ if (el) el.style.display = 'none';
++ });
++ const map = {
++ overview: 'deploy-overview',
++ certs: 'deploy-certs-panel',
++ nginx: 'deploy-nginx-panel',
++ domains: 'deploy-domains-panel',
++ config: 'deploy-config-panel',
++ };
++ const target = document.getElementById(map[tab]);
++ if (target) target.style.display = 'block';
++ if (tab === 'overview') loadDeployOverview();
++ if (tab === 'certs') loadDeployCerts();
++ if (tab === 'domains') loadDeployDomains();
++ if (tab === 'config') loadDeployConfig();
++ _syncAdminUrl();
++}
++
++async function loadDeployOverview() {
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/prerequisites`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ const p = d.prerequisites;
++ const c = d.config;
++ document.getElementById('deploy-stats').innerHTML = `
++ <div class="stat-card"><div class="value" style="color:${p.certbotInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.certbotInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Certbot</div></div>
++ <div class="stat-card"><div class="value" style="color:${p.cloudflarePluginInstalled ? 'var(--success)' : 'var(--warning)'}"><i class="fa-solid fa-${p.cloudflarePluginInstalled ? 'check' : 'minus'}"></i></div><div class="label">CF Plugin</div></div>
++ <div class="stat-card"><div class="value" style="color:${p.nginxInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.nginxInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Nginx</div></div>
++ <div class="stat-card"><div class="value" style="color:${c.hasCloudflareToken ? 'var(--success)' : 'var(--text-muted)'}"><i class="fa-solid fa-${c.hasCloudflareToken ? 'check' : 'minus'}"></i></div><div class="label">CF Token</div></div>
++ <div class="stat-card"><div class="value">${c.domainsConfigured}</div><div class="label">Domains</div></div>
++ <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.certMode)}</div><div class="label">Cert Mode</div></div>
++ <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.nginxMode)}</div><div class="label">Nginx Mode</div></div>
++ `;
++ const prereqsEl = document.getElementById('deploy-prereqs');
++ const items = [];
++ items.push(prereqLine('Certbot installed', p.certbotInstalled));
++ items.push(prereqLine('Cloudflare DNS plugin', p.cloudflarePluginInstalled, 'Optional — needed for automated DNS-01'));
++ items.push(prereqLine('Let\'s Encrypt dir writable', p.letsencryptDirWritable, 'Run as root or with sudo'));
++ items.push(prereqLine('Nginx installed', p.nginxInstalled));
++ items.push(prereqLine('Cloudflare INI exists', p.cloudflareIniExists, 'Auto-created when token is saved'));
++ items.push(prereqLine('ACME email configured', !!c.acmeEmail, c.acmeEmail || 'Not set'));
++ prereqsEl.innerHTML = items.join('');
++ } catch (e) {
++ document.getElementById('deploy-stats').innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++function prereqLine(label, ok, detail) {
++ const icon = ok ? '<i class="fa-solid fa-circle-check" style="color:var(--success)"></i>' : '<i class="fa-solid fa-circle-xmark" style="color:var(--danger)"></i>';
++ const detailHtml = detail ? ` <span class="muted" style="font-size:11px">— ${esc(detail)}</span>` : '';
++ return `<div style="padding:6px 0;display:flex;align-items:center;gap:8px">${icon} ${esc(label)}${detailHtml}</div>`;
++}
++
++async function loadDeployCerts() {
++ const el = document.getElementById('deploy-certs-list');
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok && d.error) throw new Error(d.error);
++ if (!d.certs || d.certs.length === 0) {
++ el.innerHTML = '<div class="muted">No certificates found. Issue your first certificate below.</div>';
++ return;
++ }
++ el.innerHTML = d.certs.map(c => {
++ const isWild = c.domains?.some(d => d.startsWith('*.'));
++ const expColor = (c.daysRemaining || 0) < 14 ? 'var(--danger)' : (c.daysRemaining || 0) < 30 ? 'var(--warning)' : 'var(--success)';
++ return `<div class="card" style="margin-bottom:8px;padding:12px">
++ <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
++ <div>
++ <strong>${esc(c.name)}</strong>
++ ${isWild ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}
++ <div class="muted" style="font-size:11px;margin-top:2px">${(c.domains || []).map(d => esc(d)).join(', ')}</div>
++ </div>
++ <div style="text-align:right">
++ <span style="color:${expColor};font-weight:600">${c.daysRemaining != null ? c.daysRemaining + ' days' : 'Unknown'}</span>
++ <div class="muted" style="font-size:11px">${c.expiry ? 'Expires ' + esc(c.expiry) : ''}</div>
++ </div>
++ </div>
++ </div>`;
++ }).join('');
++ } catch (e) {
++ el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function issueCertCloudflare() {
++ const domain = document.getElementById('cert-issue-domain').value.trim();
++ if (!domain) return alert('Enter a domain');
++ const out = document.getElementById('cert-issue-output');
++ out.innerHTML = '<div class="loading">Issuing wildcard certificate via Cloudflare DNS... This may take 30-60 seconds.</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs/issue-cloudflare`, {
++ method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ domain }),
++ });
++ const d = await r.json();
++ if (d.ok) {
++ out.innerHTML = `<div style="color:var(--success);padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ loadDeployCerts();
++ } else {
++ out.innerHTML = `<div style="color:var(--danger);padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ }
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function showManualDnsInfo() {
++ const domain = document.getElementById('cert-issue-domain').value.trim();
++ if (!domain) return alert('Enter a domain');
++ const out = document.getElementById('cert-issue-output');
++ out.innerHTML = '<div class="loading">Getting manual DNS challenge info...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs/manual-info`, {
++ method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ domain }),
++ });
++ const d = await r.json();
++ const instructions = (d.instructions || []).map(i => esc(i)).join('<br>');
++ out.innerHTML = `<div class="card" style="padding:16px;margin-top:8px">
++ <h4 style="margin-bottom:8px"><i class="fa-solid fa-hand"></i> Manual DNS Challenge</h4>
++ <div style="font-size:13px;line-height:1.6">${instructions}</div>
++ <div style="margin-top:16px">
++ <button class="btn btn-primary" onclick="issueManualCert('${esc(domain)}')">
++ <i class="fa-solid fa-certificate"></i> DNS Records Created — Issue Certificate
++ </button>
++ <div class="muted" style="font-size:11px;margin-top:4px">Only click after creating the required DNS TXT records</div>
++ </div>
++ </div>`;
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function issueManualCert(domain) {
++ const out = document.getElementById('cert-issue-output');
++ out.innerHTML = '<div class="loading">Issuing certificate with manual DNS verification... This may take 30-60 seconds.</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs/issue-manual`, {
++ method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ domain }),
++ });
++ const d = await r.json();
++ if (d.ok) {
++ out.innerHTML = `<div style="color:var(--success);padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ loadDeployCerts();
++ } else {
++ out.innerHTML = `<div style="color:var(--danger);padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ }
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function renewAllCerts() {
++ const out = document.getElementById('cert-issue-output');
++ out.innerHTML = '<div class="loading">Renewing all certificates...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs/renew`, {
++ method: 'POST', headers: authHeaders(),
++ });
++ const d = await r.json();
++ out.innerHTML = `<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'};padding:8px">
++ <i class="fa-solid fa-${d.ok ? 'check-circle' : 'xmark'}"></i> ${d.ok ? 'Renewal complete' : esc(d.error || 'Failed')}
++ </div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ if (d.ok) loadDeployCerts();
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function previewNginxConfigs() {
++ const out = document.getElementById('nginx-preview-output');
++ out.innerHTML = '<div class="loading">Generating Nginx configs...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/nginx/preview`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ out.innerHTML = d.configs.map(c => `
++ <div class="card" style="margin-bottom:12px">
++ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
++ <h4>${esc(c.serviceId)} <span class="muted" style="font-size:11px">${esc(c.filename)}</span></h4>
++ <span style="font-size:11px;color:${c.certFound ? 'var(--success)' : 'var(--warning)'}">${c.certFound ? '✓ cert found' : '⚠ no cert'}</span>
++ </div>
++ <pre style="background:var(--bg-input);padding:12px;border-radius:6px;font-size:11px;max-height:300px;overflow:auto;white-space:pre-wrap">${esc(c.content)}</pre>
++ </div>
++ `).join('');
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function validateNginx() {
++ const out = document.getElementById('nginx-preview-output');
++ out.innerHTML = '<div class="loading">Validating Nginx config...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/nginx/validate`, {
++ method: 'POST', headers: authHeaders(),
++ });
++ const d = await r.json();
++ out.innerHTML = `<div class="card" style="padding:16px">
++ <div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}">
++ <i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i>
++ <strong>${d.ok ? 'Configuration valid' : 'Validation failed'}</strong>
++ </div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;margin-top:8px;max-height:200px;overflow:auto">${esc(d.output || '')}</pre>
++ </div>`;
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function applyNginxConfigs() {
++ if (!confirm('Apply generated Nginx configs? This will backup existing configs, write new ones, validate, and reload Nginx.')) return;
++ const out = document.getElementById('nginx-preview-output');
++ out.innerHTML = '<div class="loading">Applying Nginx configs...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/nginx/apply`, {
++ method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ reload: true }),
++ });
++ const d = await r.json();
++ const lines = [];
++ lines.push(`<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i> <strong>${d.ok ? 'Applied successfully' : 'Apply failed'}</strong></div>`);
++ if (d.dryRun) lines.push('<div class="muted">Dry run — no files were written</div>');
++ if (d.applied?.length) lines.push(`<div>Applied: ${d.applied.map(f => esc(f)).join(', ')}</div>`);
++ if (d.backed_up?.length) lines.push(`<div class="muted">Backed up: ${d.backed_up.length} files</div>`);
++ if (d.validation) lines.push(`<div>Validation: ${d.validation.ok ? '✓ passed' : '✗ failed'}</div>`);
++ if (d.reload) lines.push(`<div>Reload: ${d.reload.ok ? '✓ success' : '✗ failed'}</div>`);
++ if (d.errors?.length) lines.push(`<div style="color:var(--danger)">Errors: ${d.errors.map(e => esc(e)).join('; ')}</div>`);
++ out.innerHTML = `<div class="card" style="padding:16px">${lines.join('')}</div>`;
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++let deployDomainsList = [];
++
++async function loadDeployDomains() {
++ const el = document.getElementById('deploy-domains-list');
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/domains`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ deployDomainsList = d.domains || [];
++ if (deployDomainsList.length === 0) {
++ el.innerHTML = '<div class="muted">No domains configured. Add your first domain below.</div>';
++ return;
++ }
++ el.innerHTML = deployDomainsList.map((d, i) => `
++ <div class="card" style="margin-bottom:6px;padding:10px;display:flex;justify-content:space-between;align-items:center">
++ <div>
++ <strong>${esc(d.domain)}</strong>
++ ${d.wildcard ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}
++ <span class="muted" style="font-size:11px;margin-left:8px">${d.certExists ? '🔒 cert found' : '⚠ no cert'}</span>
++ ${d.certName ? '<span class="muted" style="font-size:11px;margin-left:4px">(' + esc(d.certName) + ')</span>' : ''}
++ </div>
++ <button class="btn btn-danger btn-sm" onclick="removeManagedDomain(${i})" title="Remove">
++ <i class="fa-solid fa-trash"></i>
++ </button>
++ </div>
++ `).join('');
++ } catch (e) {
++ el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function addManagedDomain() {
++ const domain = document.getElementById('domain-add-input').value.trim().toLowerCase();
++ if (!domain) return;
++ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return alert('Invalid domain format');
++ if (deployDomainsList.find(d => d.domain === domain)) return alert('Domain already added');
++ const wildcard = document.getElementById('domain-add-wildcard').checked;
++ deployDomainsList.push({ domain, wildcard, certName: domain, services: [] });
++ await saveDomainsList();
++ document.getElementById('domain-add-input').value = '';
++}
++
++async function removeManagedDomain(idx) {
++ if (!confirm('Remove domain ' + deployDomainsList[idx]?.domain + '?')) return;
++ deployDomainsList.splice(idx, 1);
++ await saveDomainsList();
++}
++
++async function saveDomainsList() {
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/domains`, {
++ method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ domains: deployDomainsList }),
++ });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ loadDeployDomains();
++ } catch (e) {
++ alert('Failed to save: ' + e.message);
++ }
++}
++
++async function loadDeployConfig() {
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/config`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ document.getElementById('deploy-acme-email').value = d.config.acmeEmail || '';
++ document.getElementById('deploy-cert-mode').value = d.config.certMode || 'manual';
++ document.getElementById('deploy-cf-token').value = d.config.cloudflareToken || '';
++ document.getElementById('deploy-nginx-mode').value = d.config.nginxMode || 'preview';
++ document.getElementById('deploy-nginx-path').value = d.config.nginxSitesPath || '/etc/nginx/sites-enabled';
++ } catch (e) {
++ document.getElementById('deploy-config-status').innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
++ }
++}
++
++async function saveDeployConfig() {
++ const status = document.getElementById('deploy-config-status');
++ try {
++ const body = {
++ acmeEmail: document.getElementById('deploy-acme-email').value.trim(),
++ certMode: document.getElementById('deploy-cert-mode').value,
++ cloudflareToken: document.getElementById('deploy-cf-token').value,
++ nginxMode: document.getElementById('deploy-nginx-mode').value,
++ nginxSitesPath: document.getElementById('deploy-nginx-path').value.trim(),
++ };
++ const r = await fetch(`${API}/api/admin/deploy/config`, {
++ method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify(body),
++ });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ status.innerHTML = '<span style="color:var(--success)"><i class="fa-solid fa-check"></i> Saved</span>';
++ setTimeout(() => status.innerHTML = '', 3000);
++ } catch (e) {
++ status.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
++ }
++}
++</script>
++ <script src="https://openvibe.live/live-notify.js" async></script>
++</body>
++</html>
ddiff --git a/public/index.html b/public/index.html
nnew file mode 100644
iindex 00000000..6fb95158
---- /dev/null
++++ b/public/index.html
@@@ -0,0 +1,246 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>OpenVibe — One Account. All of OpenVibe.</title>
++<meta name="description" content="OpenVibe is a free, community-driven network: live streaming, online tools, games, and media — all with one account.">
++<meta name="keywords" content="openvibe, openvibe network, live streaming, free tools, single sign-on">
++<meta name="theme-color" content="#8b5cf6">
++<meta property="og:type" content="website">
++<meta property="og:url" content="https://openvibe.network/">
++<meta property="og:title" content="OpenVibe — One Account. All of OpenVibe.">
++<meta property="og:description" content="Free, community-driven network: live streaming, tools, games, and media — one account for everything.">
++<meta property="og:site_name" content="OpenVibe">
++<link rel="canonical" href="https://openvibe.network/">
++<link rel="icon" type="image/x-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect fill='%238b5cf6' width='32' height='32' rx='8'/%3E%3Ctext x='50%25' y='50%25' font-size='20' font-weight='bold' fill='white' text-anchor='middle' dominant-baseline='central'%3EO%3C/text%3E%3C/svg%3E">
++<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
++<style>
++*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
++:root{
++ --bg-primary:#0d0d0f;--bg-secondary:#16161a;--bg-card:#1a1a20;--bg-hover:#242430;
++ --border:#2a2a32;--border-light:#3a3a44;
++ --accent:#8b5cf6;--accent-light:#a78bfa;--accent-dark:#6d28d9;--secondary:#22d3ee;
++ --text-primary:#e8e6e3;--text-secondary:#9a9a9a;--text-muted:#666;
++ --live-red:#e74c3c;--success:#2ecc71;
++ --shadow-lg:0 8px 32px rgba(0,0,0,0.6);
++}
++html,body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100%}
++a{color:inherit}
++
++.page{position:relative;z-index:1}
++
++/* Ambient background */
++.bg-orbs{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none}
++.bg-orbs .orb{position:absolute;border-radius:50%;filter:blur(110px);opacity:.14;animation:orb-drift 24s ease-in-out infinite alternate}
++.bg-orbs .orb:nth-child(1){width:440px;height:440px;background:var(--accent);top:-120px;left:-120px}
++.bg-orbs .orb:nth-child(2){width:380px;height:380px;background:var(--secondary);top:30%;right:-140px;animation-delay:-8s}
++.bg-orbs .orb:nth-child(3){width:320px;height:320px;background:var(--accent-dark);bottom:-100px;left:35%;animation-delay:-16s}
++@keyframes orb-drift{
++ 0%{transform:translate(0,0) scale(1)}
++ 50%{transform:translate(-30px,40px) scale(1.08)}
++ 100%{transform:translate(40px,-30px) scale(.95)}
++}
++
++/* Hero */
++.hero{max-width:1080px;margin:0 auto;padding:96px 24px 64px;text-align:center}
++.hero-badge{
++ display:inline-flex;align-items:center;gap:8px;padding:6px 14px;border-radius:999px;
++ border:1px solid var(--border-light);background:var(--bg-secondary);
++ font-size:12px;font-weight:600;color:var(--accent-light);letter-spacing:.4px;margin-bottom:28px;
++}
++.hero h1{font-size:clamp(34px,6vw,58px);font-weight:800;letter-spacing:-1.5px;line-height:1.1}
++.hero h1 .grad{background:linear-gradient(120deg,var(--accent-light),var(--accent),var(--secondary));-webkit-background-clip:text;background-clip:text;color:transparent}
++.hero p.sub{margin:20px auto 0;max-width:620px;font-size:17px;line-height:1.6;color:var(--text-secondary)}
++.hero-ctas{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin-top:36px}
++.btn{
++ display:inline-flex;align-items:center;gap:9px;padding:13px 26px;border-radius:12px;
++ font-size:15px;font-weight:700;text-decoration:none;border:1px solid transparent;
++ transition:transform .15s, box-shadow .15s, background .15s, border-color .15s;
++}
++.btn-primary{background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff}
++.btn-primary:hover{transform:translateY(-2px);box-shadow:0 8px 28px rgba(139,92,246,.35)}
++.btn-ghost{background:transparent;color:var(--text-primary);border-color:var(--border-light)}
++.btn-ghost:hover{background:var(--bg-hover);border-color:var(--accent)}
++#hero-account-cta{display:none}
++
++/* Services */
++.services{max-width:1080px;margin:0 auto;padding:24px 24px 80px}
++.services h2{text-align:center;font-size:22px;font-weight:700;letter-spacing:-.4px;margin-bottom:6px}
++.services p.lead{text-align:center;color:var(--text-secondary);font-size:14px;margin-bottom:36px}
++.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px}
++.svc-card{
++ position:relative;display:flex;flex-direction:column;gap:12px;padding:26px;border-radius:16px;
++ background:var(--bg-card);border:1px solid var(--border);text-decoration:none;
++ transition:transform .18s, border-color .18s, box-shadow .18s;overflow:hidden;
++}
++.svc-card::before{
++ content:'';position:absolute;top:0;left:0;right:0;height:3px;
++ background:linear-gradient(90deg,transparent,var(--card-accent,var(--accent)),transparent);
++ opacity:0;transition:opacity .18s;
++}
++.svc-card:hover{transform:translateY(-4px);border-color:var(--card-accent,var(--accent));box-shadow:var(--shadow-lg)}
++.svc-card:hover::before{opacity:1}
++.svc-icon{
++ width:46px;height:46px;border-radius:12px;display:flex;align-items:center;justify-content:center;
++ font-size:20px;color:var(--card-accent,var(--accent));
++ background:color-mix(in srgb, var(--card-accent,var(--accent)) 12%, transparent);
++ border:1px solid color-mix(in srgb, var(--card-accent,var(--accent)) 25%, transparent);
++}
++.svc-name{font-size:17px;font-weight:700;letter-spacing:-.3px}
++.svc-desc{font-size:13px;line-height:1.55;color:var(--text-secondary)}
++.svc-go{margin-top:auto;display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--card-accent,var(--accent-light))}
++.svc-card.soon{opacity:.65;cursor:default}
++.svc-card.soon:hover{transform:none;border-color:var(--border);box-shadow:none}
++.soon-tag{
++ position:absolute;top:14px;right:14px;padding:3px 10px;border-radius:999px;
++ background:var(--bg-hover);border:1px solid var(--border-light);
++ font-size:10.5px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--text-secondary);
++}
++
++/* One account strip */
++.sso-strip{max-width:820px;margin:0 auto;padding:0 24px 80px;text-align:center}
++.sso-inner{
++ border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary);
++ padding:34px 28px;
++}
++.sso-inner i.big{font-size:28px;color:var(--accent-light);margin-bottom:12px}
++.sso-inner h3{font-size:19px;font-weight:700;letter-spacing:-.3px;margin-bottom:8px}
++.sso-inner p{font-size:13.5px;line-height:1.6;color:var(--text-secondary);max-width:560px;margin:0 auto}
++
++/* Footer */
++footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
++.foot-inner{max-width:1080px;margin:0 auto;padding:34px 24px;display:flex;flex-wrap:wrap;gap:20px;align-items:center;justify-content:space-between}
++.foot-brand{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:-.3px}
++.foot-brand i{color:var(--accent)}
++.foot-links{display:flex;flex-wrap:wrap;gap:18px;font-size:13px}
++.foot-links a{color:var(--text-secondary);text-decoration:none;transition:color .15s}
++.foot-links a:hover{color:var(--accent-light)}
++.foot-note{width:100%;font-size:12px;color:var(--text-muted)}
++
++@media (max-width:600px){
++ .hero{padding-top:64px}
++ .hero-ctas .btn{width:100%;justify-content:center}
++}
++</style>
++<script src="/shared/theme-loader.js"></script>
++</head>
++<body>
++
++<div class="bg-orbs"><div class="orb"></div><div class="orb"></div><div class="orb"></div></div>
++
++<div id="navbar-mount"></div>
++
++<div class="page">
++
++ <!-- Hero -->
++ <section class="hero">
++ <div class="hero-badge"><i class="fa-solid fa-circle-nodes"></i> The OpenVibe Network — Community-Driven, No Profit</div>
++ <h1>One Account.<br><span class="grad">All of OpenVibe.</span></h1>
++ <p class="sub">Live streaming, free online tools, games, and media hosting — one free account works across the entire network. No ads, no tracking, no nonsense.</p>
++ <div class="hero-ctas" id="hero-guest-ctas">
++ <a class="btn btn-primary" href="/login"><i class="fa-solid fa-right-to-bracket"></i> Sign In</a>
++ <a class="btn btn-ghost" href="/login?tab=register"><i class="fa-solid fa-user-plus"></i> Create Account</a>
++ </div>
++ <div class="hero-ctas" id="hero-account-cta">
++ <a class="btn btn-primary" href="/my"><i class="fa-solid fa-user"></i> Go to My Account</a>
++ </div>
++ </section>
++
++ <!-- Services -->
++ <section class="services">
++ <h2>The Network</h2>
++ <p class="lead">Every service, one identity. Sign in once, vibe everywhere.</p>
++ <div class="card-grid">
++ <a class="svc-card" href="https://openvibe.live" style="--card-accent:#e74c3c;">
++ <div class="svc-icon"><i class="fa-solid fa-tower-broadcast"></i></div>
++ <div class="svc-name">OpenVibe.Live</div>
++ <div class="svc-desc">Free &amp; open live streaming. Go live with OBS or your browser, chat, clip, and tip with Vibes.</div>
++ <span class="svc-go">openvibe.live <i class="fa-solid fa-arrow-right"></i></span>
++ </a>
++ <a class="svc-card" href="https://openvibe.tools" style="--card-accent:#8b5cf6;">
++ <div class="svc-icon"><i class="fa-solid fa-screwdriver-wrench"></i></div>
++ <div class="svc-name">OpenVibe.Tools</div>
++ <div class="svc-desc">A huge collection of free online tools — network diagnostics, dev utilities, converters, text, images, PDFs, and more.</div>
++ <span class="svc-go">openvibe.tools <i class="fa-solid fa-arrow-right"></i></span>
++ </a>
++ <a class="svc-card" href="https://openvibe.games" style="--card-accent:#2ecc71;">
++ <div class="svc-icon"><i class="fa-solid fa-gamepad"></i></div>
++ <div class="svc-name">OpenVibe.Games</div>
++ <div class="svc-desc">Play Scraplandia and community games, paint the shared canvas, climb the leaderboards, spend your OpenCoins.</div>
++ <span class="svc-go">openvibe.games <i class="fa-solid fa-arrow-right"></i></span>
++ </a>
++ <a class="svc-card" href="https://openvibe.media" style="--card-accent:#22d3ee;">
++ <div class="svc-icon"><i class="fa-solid fa-photo-film"></i></div>
++ <div class="svc-name">OpenVibe.Media</div>
++ <div class="svc-desc">The network's media backbone — VODs, clips, pastes, thumbnails, and file hosting for every OpenVibe app.</div>
++ <span class="svc-go">openvibe.media <i class="fa-solid fa-arrow-right"></i></span>
++ </a>
++ <div class="svc-card soon" style="--card-accent:#f39c12;">
++ <span class="soon-tag">Coming Soon</span>
++ <div class="svc-icon"><i class="fa-solid fa-people-group"></i></div>
++ <div class="svc-name">OpenVibe.Community</div>
++ <div class="svc-desc">Forums, events, and community spaces for the whole network. Under construction — stay tuned.</div>
++ <span class="svc-go">openvibe.community</span>
++ </div>
++ </div>
++ </section>
++
++ <!-- SSO strip -->
++ <section class="sso-strip">
++ <div class="sso-inner">
++ <i class="fa-solid fa-circle-nodes big"></i>
++ <h3>Your identity, everywhere</h3>
++ <p>openvibe.network is the identity service behind it all: single sign-on, profiles, themes that follow you across every app, notifications, and the network-wide OpenCoins wallet.</p>
++ </div>
++ </section>
++
++</div>
++
++<!-- Footer -->
++<footer>
++ <div class="foot-inner">
++ <div class="foot-brand"><i class="fa-solid fa-circle-nodes"></i> OpenVibe</div>
++ <nav class="foot-links">
++ <a href="https://openvibe.live">Live</a>
++ <a href="https://openvibe.tools">Tools</a>
++ <a href="https://openvibe.games">Games</a>
++ <a href="https://openvibe.media">Media</a>
++ <a href="/my">My Account</a>
++ <a href="/login">Sign In</a>
++ </nav>
++ <div class="foot-note">© OpenVibe Network. Community-driven and free — one account for all of OpenVibe.</div>
++ </div>
++</footer>
++
++<script src="/shared/navbar.js"></script>
++<script>
++(function () {
++ function getCookie(name) {
++ const m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
++ return m ? decodeURIComponent(m[1]) : null;
++ }
++
++ const token = localStorage.getItem('ov_token') || getCookie('ov_token');
++
++ function initNavbar(user) {
++ if (typeof OpenVibeNavbar !== 'undefined') {
++ OpenVibeNavbar.init({ service: 'network', token: token || null, user: user || null });
++ }
++ if (user) {
++ document.getElementById('hero-guest-ctas').style.display = 'none';
++ document.getElementById('hero-account-cta').style.display = 'flex';
++ }
++ }
++
++ if (!token) { initNavbar(null); return; }
++
++ fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token }, credentials: 'include' })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (data) { initNavbar(data && (data.user || data)); })
++ .catch(function () { initNavbar(null); });
++})();
++</script>
++</body>
++</html>
ddiff --git a/public/login.html b/public/login.html
nnew file mode 100644
iindex 00000000..279e2027
---- /dev/null
++++ b/public/login.html
@@@ -0,0 +1,1001 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>Sign In — OpenVibe</title>
++<meta name="description" content="Sign in to OpenVibe — one account across OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and OpenVibe.Media.">
++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
++<style>
++*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
++:root{
++ --bg:#0d0d14;--bg-card:#16161f;--bg-input:#1c1c28;
++ --border:#2a2a3a;--border-focus:#8b5cf6;
++ --accent:#8b5cf6;--accent-glow:rgba(139,92,246,0.4);--accent-dark:#6d28d9;
++ --text:#e0e0e0;--text-muted:#707080;--text-dim:#505060;
++ --live-red:#e74c3c;--success:#2ecc71;
++}
++html{min-height:100%;background:var(--bg);color:var(--text);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;overflow-x:hidden;overflow-y:auto}
++body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;overflow-x:hidden;overflow-y:auto}
++
++/* Animated background */
++.bg-canvas{position:fixed;inset:0;z-index:0}
++.bg-canvas .orb{
++ position:absolute;border-radius:50%;filter:blur(100px);opacity:0.15;
++ animation:orb-drift 20s ease-in-out infinite alternate;
++}
++.bg-canvas .orb:nth-child(1){width:400px;height:400px;background:#8b5cf6;top:-100px;left:-100px;animation-delay:0s}
++.bg-canvas .orb:nth-child(2){width:500px;height:500px;background:#e74c3c;bottom:-150px;right:-150px;animation-delay:-5s}
++.bg-canvas .orb:nth-child(3){width:300px;height:300px;background:#9b59b6;top:40%;left:60%;animation-delay:-10s}
++.bg-canvas .orb:nth-child(4){width:250px;height:250px;background:#3498db;bottom:20%;left:10%;animation-delay:-15s}
++@keyframes orb-drift{
++ 0%{transform:translate(0,0) scale(1)}25%{transform:translate(40px,-30px) scale(1.1)}
++ 50%{transform:translate(-20px,50px) scale(0.95)}75%{transform:translate(30px,20px) scale(1.05)}
++ 100%{transform:translate(-40px,-40px) scale(1)}
++}
++
++/* Particle field */
++.particles{position:fixed;inset:0;z-index:1;pointer-events:none;overflow:hidden}
++.particles .p{
++ position:absolute;width:2px;height:2px;background:rgba(139,92,246,0.3);border-radius:50%;
++ animation:particle-float linear infinite;
++}
++@keyframes particle-float{
++ from{transform:translateY(100vh) scale(0);opacity:0}
++ 10%{opacity:1;transform:translateY(90vh) scale(1)}
++ 90%{opacity:.6}
++ to{transform:translateY(-5vh) scale(0.5);opacity:0}
++}
++
++/* Grid lines */
++.grid-overlay{
++ position:fixed;inset:0;z-index:0;
++ background-image:
++ linear-gradient(rgba(139,92,246,0.02) 1px,transparent 1px),
++ linear-gradient(90deg,rgba(139,92,246,0.02) 1px,transparent 1px);
++ background-size:60px 60px;
++ animation:grid-scroll 30s linear infinite;
++}
++@keyframes grid-scroll{from{background-position:0 0}to{background-position:60px 60px}}
++
++/* Container */
++.login-container{
++ position:relative;z-index:10;min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:20px;
++}
++
++/* Card */
++.login-card{
++ width:100%;max-width:420px;background:var(--bg-card);border:1px solid var(--border);
++ border-radius:20px;padding:40px 36px;position:relative;overflow:hidden;
++ box-shadow:0 20px 80px rgba(0,0,0,0.5), 0 0 60px rgba(139,92,246,0.03);
++ animation:card-entrance .6s cubic-bezier(.34,1.56,.64,1);
++}
++@keyframes card-entrance{from{transform:scale(.92) translateY(20px);opacity:0}to{transform:scale(1) translateY(0);opacity:1}}
++
++.login-card::before{
++ content:'';position:absolute;top:0;left:0;right:0;height:3px;
++ background:linear-gradient(90deg,transparent,var(--accent),transparent);
++ animation:shimmer 3s ease-in-out infinite;
++}
++@keyframes shimmer{0%,100%{opacity:0.3}50%{opacity:1}}
++
++/* Logo */
++.logo{text-align:center;margin-bottom:28px}
++.logo .flame{font-size:48px;display:block;margin-bottom:8px;animation:flame-pulse 2s ease-in-out infinite}
++@keyframes flame-pulse{0%,100%{transform:scale(1);filter:brightness(1)}50%{transform:scale(1.08);filter:brightness(1.2)}}
++.logo h1{font-size:22px;font-weight:800;letter-spacing:-.5px;margin-bottom:4px}
++.logo p{font-size:12px;color:var(--text-muted);font-weight:500}
++
++/* Tabs */
++.tab-bar{display:flex;gap:4px;background:var(--bg-input);border-radius:10px;padding:4px;margin-bottom:24px}
++.tab-bar button{
++ flex:1;padding:8px;border:none;border-radius:8px;font-size:13px;font-weight:600;
++ background:transparent;color:var(--text-muted);cursor:pointer;transition:all .2s;
++}
++.tab-bar button.active{background:var(--accent);color:#fff;box-shadow:0 2px 8px rgba(139,92,246,0.3)}
++.tab-bar button:hover:not(.active){color:var(--text)}
++
++/* Forms */
++.form-panel{display:none;animation:panel-in .3s ease}
++.form-panel.active{display:block}
++@keyframes panel-in{from{transform:translateX(10px);opacity:0}to{transform:translateX(0);opacity:1}}
++
++.field{margin-bottom:16px;position:relative}
++.field label{display:block;font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
++.field input{
++ width:100%;padding:12px 14px;background:var(--bg-input);border:1px solid var(--border);
++ border-radius:10px;color:var(--text);font-size:14px;outline:none;
++ transition:border-color .2s, box-shadow .2s;
++}
++.field input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
++.field input::placeholder{color:var(--text-dim)}
++
++.field .toggle-password{
++ position:absolute;right:12px;top:30px;background:none;border:none;
++ color:var(--text-muted);cursor:pointer;font-size:14px;padding:4px;
++}
++
++.submit-btn{
++ width:100%;padding:14px;border:none;border-radius:10px;font-size:14px;font-weight:700;
++ background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;cursor:pointer;
++ transition:all .2s;position:relative;overflow:hidden;
++}
++.submit-btn:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(139,92,246,0.35)}
++.submit-btn:active{transform:translateY(0)}
++.submit-btn.loading{pointer-events:none;opacity:.7}
++.submit-btn .ripple{
++ position:absolute;border-radius:50%;background:rgba(255,255,255,0.3);
++ transform:scale(0);animation:btn-ripple .6s ease-out;
++}
++@keyframes btn-ripple{to{transform:scale(4);opacity:0}}
++
++/* Messages */
++.message{padding:10px 14px;border-radius:8px;font-size:12px;font-weight:500;margin-bottom:16px;display:none;animation:msg-in .3s ease}
++.message.visible{display:block}
++.message.error{background:rgba(231,76,60,0.1);border:1px solid rgba(231,76,60,0.3);color:#e74c3c}
++.message.success{background:rgba(46,204,113,0.1);border:1px solid rgba(46,204,113,0.3);color:#2ecc71}
++@keyframes msg-in{from{transform:translateY(-4px);opacity:0}to{transform:translateY(0);opacity:1}}
++
++/* Extras */
++.divider{text-align:center;position:relative;margin:24px 0}
++.divider::before{content:'';position:absolute;top:50%;left:0;right:0;height:1px;background:var(--border)}
++.divider span{position:relative;background:var(--bg-card);padding:0 12px;font-size:11px;color:var(--text-dim);font-weight:500}
++
++.anon-btn{
++ width:100%;padding:12px;border:1px dashed var(--border);border-radius:10px;
++ background:transparent;color:var(--text-muted);font-size:13px;font-weight:500;
++ cursor:pointer;transition:all .2s;
++}
++.anon-btn:hover{border-color:var(--accent-dark);color:var(--text);background:rgba(139,92,246,0.04)}
++
++.services{text-align:center;margin-top:24px;padding-top:16px;border-top:1px solid var(--border)}
++.services p{font-size:11px;color:var(--text-dim);margin-bottom:10px}
++.services .icons{display:flex;justify-content:center;gap:10px}
++.services .icons span{font-size:16px;opacity:0.4;transition:opacity .2s}
++.services .icons span:hover{opacity:0.8}
++.inline-link{display:inline-flex;align-items:center;gap:6px;margin-top:12px;font-size:12px;color:var(--accent);cursor:pointer;text-decoration:none;font-weight:600}
++.inline-link:hover{color:#a78bfa}
++.panel-note{font-size:12px;color:var(--text-muted);line-height:1.5;margin-bottom:16px}
++
++/* Toast */
++.login-toast{
++ position:fixed;top:20px;right:20px;z-index:100;padding:14px 20px;
++ border-radius:10px;font-size:13px;font-weight:500;
++ transform:translateX(120%);transition:transform .3s cubic-bezier(.34,1.56,.64,1);
++}
++.login-toast.show{transform:translateX(0)}
++.login-toast.error{background:rgba(231,76,60,0.9);color:#fff}
++.login-toast.success{background:rgba(46,204,113,0.9);color:#fff}
++
++/* ── Account Chooser ───────────────────────────────────── */
++.chooser-subtitle{font-size:13px;color:var(--text-muted);text-align:center;margin-bottom:20px;line-height:1.5}
++.chooser-subtitle .app-name{color:var(--accent);font-weight:700}
++
++.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}
++.account-item{
++ display:flex;align-items:center;gap:12px;padding:12px 14px;
++ background:var(--bg-input);border:1px solid var(--border);border-radius:12px;
++ cursor:pointer;transition:all .2s;position:relative;
++}
++.account-item:hover{border-color:var(--accent);background:rgba(139,92,246,0.06);transform:translateX(2px)}
++.account-item.active-account{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-glow)}
++.account-item .avatar{
++ width:40px;height:40px;border-radius:50%;background:var(--border);
++ display:flex;align-items:center;justify-content:center;font-size:18px;
++ flex-shrink:0;overflow:hidden;
++}
++.account-item .avatar img{width:100%;height:100%;object-fit:cover}
++.account-item .info{flex:1;min-width:0}
++.account-item .info .name{font-size:14px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
++.account-item .info .detail{font-size:11px;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
++.account-item .arrow{color:var(--text-dim);font-size:14px;flex-shrink:0;transition:color .2s}
++.account-item:hover .arrow{color:var(--accent)}
++.account-item .remove-btn{
++ position:absolute;top:6px;right:6px;width:20px;height:20px;border:none;
++ background:rgba(231,76,60,0.15);border-radius:50%;color:#e74c3c;font-size:10px;
++ cursor:pointer;display:none;align-items:center;justify-content:center;
++ transition:background .2s;line-height:1;
++}
++.account-item:hover .remove-btn{display:flex}
++.account-item .remove-btn:hover{background:rgba(231,76,60,0.3)}
++
++.chooser-actions{display:flex;flex-direction:column;gap:8px}
++.chooser-actions .action-btn{
++ display:flex;align-items:center;justify-content:center;gap:8px;padding:12px;
++ border:1px dashed var(--border);border-radius:10px;background:transparent;
++ color:var(--text-muted);font-size:13px;font-weight:500;cursor:pointer;transition:all .2s;
++}
++.chooser-actions .action-btn:hover{border-color:var(--accent-dark);color:var(--text);background:rgba(139,92,246,0.04)}
++
++.back-link{
++ display:block;text-align:center;margin-top:16px;font-size:12px;
++ color:var(--text-muted);cursor:pointer;text-decoration:none;transition:color .2s;
++}
++.back-link:hover{color:var(--accent)}
++
++@media(max-width:480px){
++ .login-card{padding:28px 24px;border-radius:16px}
++ .logo .flame{font-size:36px}
++ .logo h1{font-size:18px}
++ .login-container{padding:16px}
++}
++
++@media(max-height:820px){
++ .login-container{align-items:flex-start;padding-top:24px;padding-bottom:24px}
++}
++
++@media(max-width:380px){
++ .login-card{padding:24px 18px;border-radius:14px}
++ .tab-bar button{font-size:12px;padding:8px 6px}
++}
++</style>
++</head>
++<body>
++
++<!-- Animated Background -->
++<div class="bg-canvas">
++ <div class="orb"></div><div class="orb"></div><div class="orb"></div><div class="orb"></div>
++</div>
++<div class="grid-overlay"></div>
++<div class="particles" id="particles"></div>
++
++<div class="login-container">
++ <div class="login-card">
++ <div class="logo">
++ <span class="flame"><i class="fa-solid fa-circle-nodes"></i></span>
++ <h1>OpenVibe</h1>
++ <p>One account. All of OpenVibe.</p>
++ </div>
++
++ <!-- Tab bar (hidden when chooser is shown) -->
++ <div class="tab-bar" id="auth-tabs">
++ <button class="active" onclick="switchTab('login')">Sign In</button>
++ <button onclick="switchTab('register')">Create Account</button>
++ </div>
++
++ <!-- Account Chooser (shown when saved accounts exist) -->
++ <div class="form-panel" id="panel-chooser">
++ <p class="chooser-subtitle" id="chooser-subtitle">Choose an account to continue</p>
++ <div class="account-list" id="account-list"></div>
++ <div class="divider"><span>or</span></div>
++ <div class="chooser-actions">
++ <button class="action-btn" onclick="showAuthForm('login')">
++ <i class="fa-solid fa-key"></i> Use another account
++ </button>
++ <button class="action-btn" onclick="showAuthForm('register')">
++ <i class="fa-solid fa-wand-magic-sparkles"></i> Create a new account
++ </button>
++ <button class="action-btn" onclick="showForgotForm()">
++ <i class="fa-solid fa-lock-open"></i> Forgot password
++ </button>
++ </div>
++ </div>
++
++ <!-- Login Form -->
++ <div class="form-panel" id="panel-login">
++ <div class="message" id="login-msg"></div>
++ <form id="login-form" onsubmit="handleLogin(event)">
++ <div class="field">
++ <label>Username</label>
++ <input type="text" name="username" placeholder="Your username" required autocomplete="username" autofocus>
++ </div>
++ <div class="field">
++ <label>Password</label>
++ <input type="password" name="password" placeholder="Your password" required autocomplete="current-password">
++ <button type="button" class="toggle-password" onclick="togglePw(this)"><i class="fa-solid fa-eye"></i></button>
++ </div>
++ <button type="submit" class="submit-btn" id="login-submit">
++ Sign In →
++ </button>
++ </form>
++ <a class="inline-link" onclick="showForgotForm()">Forgot your password?</a>
++ <a class="back-link" id="login-back" onclick="showChooser()" style="display:none">← Back to saved accounts</a>
++ </div>
++
++ <!-- Register Form -->
++ <div class="form-panel" id="panel-register">
++ <div class="message" id="register-msg"></div>
++ <form id="register-form" onsubmit="handleRegister(event)" novalidate>
++ <div class="field">
++ <label>Username</label>
++ <input type="text" name="username" placeholder="Choose a username" required minlength="3" maxlength="24" pattern="^[a-zA-Z0-9_]+$" title="Use letters, numbers, and underscores only" autocomplete="username">
++ <small class="panel-note">Allowed: letters, numbers, and underscores only; 3–24 characters; cannot start with "anon".</small>
++ </div>
++ <div class="field">
++ <label>Email <span style="color:var(--text-dim)">(optional)</span></label>
++ <input type="email" name="email" placeholder="[email protected]" autocomplete="email">
++ </div>
++ <div class="field">
++ <label>Verification Key <span style="color:var(--text-dim)">(optional)</span></label>
++ <input type="text" name="verification_key" placeholder="OPENVIBE-XXXX-XXXX-XXXX" style="font-family:monospace">
++ </div>
++ <div class="field">
++ <label>Password</label>
++ <input type="password" name="password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">
++ <button type="button" class="toggle-password" onclick="togglePw(this)"><i class="fa-solid fa-eye"></i></button>
++ </div>
++ <div class="field">
++ <label>Confirm Password</label>
++ <input type="password" name="password_confirm" placeholder="Repeat password" required autocomplete="new-password">
++ </div>
++ <button type="submit" class="submit-btn" id="register-submit">
++ Create Account <i class="fa-solid fa-circle-nodes"></i>
++ </button>
++ </form>
++ <a class="back-link" id="register-back" onclick="showChooser()" style="display:none">← Back to saved accounts</a>
++ </div>
++
++ <!-- Forgot Password Form -->
++ <div class="form-panel" id="panel-forgot">
++ <div class="message" id="forgot-msg"></div>
++ <p class="panel-note">Enter your email address. If that email exists, a reset link will be sent.</p>
++ <form id="forgot-form" onsubmit="handleForgotPassword(event)">
++ <div class="field">
++ <label>Email</label>
++ <input type="email" name="email" placeholder="[email protected]" required autocomplete="email">
++ </div>
++ <button type="submit" class="submit-btn" id="forgot-submit">
++ Send Reset Link <i class="fa-solid fa-envelope"></i>
++ </button>
++ </form>
++ <a class="back-link" id="forgot-back" onclick="returnFromRecovery()">← Back</a>
++ </div>
++
++ <!-- Reset Password Form -->
++ <div class="form-panel" id="panel-reset">
++ <div class="message" id="reset-msg"></div>
++ <p class="panel-note" id="reset-note">Choose a new password for your account.</p>
++ <form id="reset-form" onsubmit="handleResetPassword(event)">
++ <input type="hidden" name="token" id="reset-token">
++ <div class="field">
++ <label>New Password</label>
++ <input type="password" name="new_password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">
++ <button type="button" class="toggle-password" onclick="togglePw(this)"><i class="fa-solid fa-eye"></i></button>
++ </div>
++ <div class="field">
++ <label>Confirm New Password</label>
++ <input type="password" name="confirm_password" placeholder="Repeat password" required autocomplete="new-password">
++ </div>
++ <button type="submit" class="submit-btn" id="reset-submit">
++ Reset Password <i class="fa-solid fa-lock"></i>
++ </button>
++ </form>
++ <a class="back-link" id="reset-back" onclick="showAuthForm('login')">← Back to sign in</a>
++ </div>
++
++ <div class="divider"><span>or</span></div>
++ <button class="anon-btn" onclick="continueAnonymous()">
++ <i class="fa-solid fa-user-secret"></i> Continue Anonymously
++ </button>
++
++ <div class="services">
++ <p>Works across the entire network</p>
++ <div class="icons">
++ <span title="OpenVibe.Live"><i class="fa-solid fa-tower-broadcast"></i></span>
++ <span title="OpenVibe.Tools"><i class="fa-solid fa-screwdriver-wrench"></i></span>
++ <span title="OpenVibe.Games"><i class="fa-solid fa-gamepad"></i></span>
++ <span title="OpenVibe.Media"><i class="fa-solid fa-photo-film"></i></span>
++ </div>
++ </div>
++ </div>
++</div>
++
++<div class="login-toast" id="toast"></div>
++
++<script>
++const API_BASE = window.location.origin;
++const params = new URLSearchParams(window.location.search);
++const isOAuth = params.has('client_id') && params.has('redirect_uri');
++const clientName = params.get('client_name') || params.get('client_id') || '';
++let oauthConfirmInFlight = false;
++
++// Generate particles
++(function() {
++ const container = document.getElementById('particles');
++ for (let i = 0; i < 30; i++) {
++ const p = document.createElement('div');
++ p.className = 'p';
++ p.style.left = Math.random() * 100 + '%';
++ p.style.animationDuration = (8 + Math.random() * 12) + 's';
++ p.style.animationDelay = -(Math.random() * 20) + 's';
++ p.style.width = p.style.height = (1 + Math.random() * 2) + 'px';
++ container.appendChild(p);
++ }
++})();
++
++// ── Saved Accounts ──────────────────────────────────────────
++// ov_token is host-only on openvibe.network — NO Domain attribute (CONTRACTS.md).
++function clearAuthCookie() {
++ document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax';
++}
++
++function setAuthCookie(token) {
++ const secure = location.protocol === 'https:' ? ';Secure' : '';
++ document.cookie = `ov_token=${token};path=/;max-age=${60*60*24*30};SameSite=Lax${secure}`;
++}
++
++function clearLocalAuthState() {
++ clearAuthCookie();
++ localStorage.removeItem('ov_token');
++ localStorage.removeItem('openvibe_active_account');
++}
++
++function isLoginLikeRedirect(target) {
++ try {
++ const parsed = new URL(target, window.location.origin);
++ const pathname = (parsed.pathname || '/').toLowerCase();
++ return pathname === '/login' || pathname === '/login.html' || pathname === '/auth/login';
++ } catch {
++ return true;
++ }
++}
++
++function getSavedAccounts() {
++ try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]').filter(a => a && a.token && !a.is_anon); }
++ catch { return []; }
++}
++
++function getAllAccounts() {
++ try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]').filter(a => a && a.token); }
++ catch { return []; }
++}
++
++function saveAccount(user, token) {
++ const accounts = getAllAccounts().filter(a => !a.is_anon);
++ const idx = accounts.findIndex(a => a.id === user.id);
++ const entry = {
++ id: user.id,
++ username: user.username,
++ display_name: user.display_name || user.username,
++ avatar_url: user.avatar_url || null,
++ email: user.email || null,
++ token,
++ is_anon: false,
++ };
++ if (idx !== -1) accounts[idx] = entry;
++ else accounts.push(entry);
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ localStorage.setItem('openvibe_active_account', user.id);
++ return entry;
++}
++
++function saveAnonAccount(user, token) {
++ const accounts = getAllAccounts().filter(a => !a.is_anon);
++ const entry = {
++ id: 'anon',
++ username: user.username,
++ display_name: user.display_name || user.username,
++ avatar_url: null,
++ email: null,
++ token,
++ is_anon: true,
++ anon_number: user.anon_number || null,
++ };
++ accounts.push(entry);
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ localStorage.setItem('openvibe_anon_token', token);
++ localStorage.setItem('openvibe_active_account', 'anon');
++ return entry;
++}
++
++function removeAccount(id) {
++ const accounts = getSavedAccounts().filter(a => a.id !== id);
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ renderChooser();
++ if (accounts.length === 0) showAuthForm('login');
++}
++
++// ── View Management ─────────────────────────────────────────
++let hasAccounts = false;
++
++function setPrimaryExtrasVisible(visible) {
++ const display = visible ? '' : 'none';
++ document.querySelectorAll('.login-card > .divider').forEach(d => d.style.display = display);
++ const anonBtn = document.querySelector('.anon-btn');
++ if (anonBtn) anonBtn.style.display = display;
++}
++
++function setActivePanel(panelId, { showTabs = true, activeTab = null, showExtras = true } = {}) {
++ document.querySelectorAll('.form-panel').forEach(p => p.classList.remove('active'));
++ document.querySelectorAll('.tab-bar button').forEach(b => b.classList.remove('active'));
++ document.getElementById('auth-tabs').style.display = showTabs ? '' : 'none';
++ document.getElementById(panelId).classList.add('active');
++ setPrimaryExtrasVisible(showExtras);
++ if (activeTab) {
++ const buttons = document.querySelectorAll('.tab-bar button');
++ const idx = activeTab === 'register' ? 1 : 0;
++ buttons[idx]?.classList.add('active');
++ }
++}
++
++function showChooser() {
++ setActivePanel('panel-chooser', { showTabs: false, showExtras: true });
++}
++
++function showAuthForm(tab) {
++ setActivePanel('panel-' + tab, { showTabs: true, activeTab: tab, showExtras: true });
++ // Show "back" links if we have saved accounts
++ if (hasAccounts) {
++ document.getElementById('login-back').style.display = '';
++ document.getElementById('register-back').style.display = '';
++ }
++}
++
++function switchTab(tab, evt) {
++ if (evt?.preventDefault) evt.preventDefault();
++ showAuthForm(tab);
++}
++
++function showForgotForm() {
++ const forgotBack = document.getElementById('forgot-back');
++ forgotBack.textContent = hasAccounts ? '← Back to saved accounts' : '← Back to sign in';
++ setActivePanel('panel-forgot', { showTabs: false, showExtras: false });
++}
++
++function returnFromRecovery() {
++ if (hasAccounts) return showChooser();
++ showAuthForm('login');
++}
++
++function showResetForm(token) {
++ document.getElementById('reset-token').value = token || '';
++ setActivePanel('panel-reset', { showTabs: false, showExtras: false });
++}
++
++// ── Render Account Chooser ──────────────────────────────────
++function renderChooser() {
++ const accounts = getSavedAccounts();
++ const list = document.getElementById('account-list');
++ const activeId = localStorage.getItem('openvibe_active_account');
++ list.innerHTML = '';
++
++ // Set subtitle
++ const subtitle = document.getElementById('chooser-subtitle');
++ if (isOAuth && clientName) {
++ subtitle.innerHTML = `Choose an account to continue to <span class="app-name">${escapeHtml(clientName)}</span>`;
++ } else {
++ subtitle.textContent = 'Choose an account to continue';
++ }
++
++ accounts.forEach(account => {
++ const item = document.createElement('div');
++ item.className = 'account-item' + (String(account.id) === String(activeId) ? ' active-account' : '');
++ const avatarContent = account.avatar_url
++ ? `<img src="${escapeHtml(account.avatar_url)}" alt="" onerror="this.parentElement.innerHTML='<i class=\'fa-solid fa-circle-nodes\'></i>'">`
++ : '<i class="fa-solid fa-circle-nodes"></i>';
++ item.innerHTML = `
++ <div class="avatar">${avatarContent}</div>
++ <div class="info">
++ <div class="name">${escapeHtml(account.display_name || account.username)}</div>
++ <div class="detail">${escapeHtml(account.username)}${account.email ? ' · ' + escapeHtml(account.email) : ''}</div>
++ </div>
++ <span class="arrow">→</span>
++ <button class="remove-btn" title="Remove this saved account" onclick="event.stopPropagation(); removeAccount(${account.id})"><i class="fa-solid fa-xmark"></i></button>
++ `;
++ item.addEventListener('click', () => selectAccount(account));
++ list.appendChild(item);
++ });
++}
++
++function escapeHtml(str) {
++ const d = document.createElement('div');
++ d.textContent = str;
++ return d.innerHTML;
++}
++
++// ── Account Selection ───────────────────────────────────────
++async function selectAccount(account) {
++ if (oauthConfirmInFlight) return;
++ // Validate the saved token first — refresh if expired (7-day grace)
++ let validToken = account.token;
++ try {
++ const checkRes = await fetch(API_BASE + '/api/auth/me', {
++ headers: { Authorization: 'Bearer ' + account.token },
++ });
++ if (!checkRes.ok) {
++ // Token expired or invalid — try refresh
++ const refreshRes = await fetch(API_BASE + '/api/auth/refresh', {
++ method: 'POST',
++ headers: { Authorization: 'Bearer ' + account.token, 'Content-Type': 'application/json' },
++ });
++ if (refreshRes.ok) {
++ const refreshData = await refreshRes.json();
++ validToken = refreshData.token;
++ // Update the saved account with the new token
++ account.token = validToken;
++ saveAccount(refreshData.user || account, validToken);
++ } else {
++ // Refresh also failed — session is truly expired
++ toast('Session expired for ' + (account.display_name || account.username) + '. Please sign in again.', 'error');
++ removeAccount(account.id);
++ return;
++ }
++ }
++ } catch {
++ // Network error — proceed with existing token and let the redirect handle it
++ }
++
++ // Set this as the active account
++ localStorage.setItem('ov_token', validToken);
++ localStorage.setItem('openvibe_active_account', account.id);
++ setAuthCookie(validToken);
++
++ if (isOAuth) {
++ // Call /oauth/confirm to get the redirect URL
++ try {
++ oauthConfirmInFlight = true;
++ const res = await fetch(API_BASE + '/oauth/confirm', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ token: validToken,
++ client_id: params.get('client_id'),
++ redirect_uri: params.get('redirect_uri'),
++ scope: params.get('scope') || 'profile theme',
++ state: params.get('state') || '',
++ }),
++ });
++ const data = await res.json();
++ if (!res.ok) {
++ if (res.status === 401) {
++ toast('Session expired for ' + (account.display_name || account.username) + '. Please sign in again.', 'error');
++ removeAccount(account.id);
++ return;
++ }
++ if (res.status === 403) {
++ clearLocalAuthState();
++ toast('Authorization denied for this account. Please sign in again.', 'error');
++ removeAccount(account.id);
++ return;
++ }
++ throw new Error(data.error || 'Authorization failed');
++ }
++ if (!data.redirect || isLoginLikeRedirect(data.redirect)) {
++ throw new Error('Invalid redirect target returned by OAuth server');
++ }
++ toast('Redirecting as ' + (account.display_name || account.username) + '...', 'success');
++ setTimeout(() => window.location.href = data.redirect, 400);
++ } catch (err) {
++ toast(err.message, 'error');
++ } finally {
++ oauthConfirmInFlight = false;
++ }
++ } else {
++ // Not OAuth — just switch the active account and go home
++ toast('Welcome back, ' + (account.display_name || account.username) + '! <i class="fa-solid fa-circle-nodes"></i>', 'success');
++ setTimeout(() => window.location.href = getReturnUrl(), 600);
++ }
++}
++
++// Password toggle
++function togglePw(btn) {
++ const input = btn.previousElementSibling;
++ input.type = input.type === 'password' ? 'text' : 'password';
++ btn.innerHTML = input.type === 'password' ? '<i class="fa-solid fa-eye"></i>' : '<i class="fa-solid fa-eye-slash"></i>';
++}
++
++function getRegisterUsernameError(username) {
++ if (!username) return 'Username is required';
++ if (typeof username !== 'string') return 'Username must be a valid string';
++ if (username.length < 3 || username.length > 24) return 'Username must be 3-24 characters long';
++ if (/^anon/i.test(username)) return 'Username cannot start with "anon" — this prefix is reserved for anonymous identities';
++ if (!/^[a-zA-Z0-9_]+$/.test(username)) return 'Username can only contain letters, numbers, and underscores';
++ return '';
++}
++
++// Show message
++function showMsg(id, text, type) {
++ const el = document.getElementById(id);
++ el.textContent = text;
++ el.className = 'message visible ' + type;
++ setTimeout(() => el.classList.remove('visible'), 5000);
++}
++
++// Toast
++function toast(text, type = 'success') {
++ const el = document.getElementById('toast');
++ el.innerHTML = text;
++ el.className = 'login-toast ' + type + ' show';
++ setTimeout(() => el.classList.remove('show'), 3000);
++}
++
++// Button ripple
++function ripple(btn, e) {
++ const r = document.createElement('span');
++ r.className = 'ripple';
++ const rect = btn.getBoundingClientRect();
++ r.style.left = (e.clientX - rect.left - 10) + 'px';
++ r.style.top = (e.clientY - rect.top - 10) + 'px';
++ r.style.width = r.style.height = '20px';
++ btn.appendChild(r);
++ setTimeout(() => r.remove(), 600);
++}
++
++// Get return URL (non-OAuth)
++function getReturnUrl() {
++ return params.get('return') || '/';
++}
++
++// ── Login Handler ───────────────────────────────────────────
++async function handleLogin(e) {
++ e.preventDefault();
++ const btn = document.getElementById('login-submit');
++ btn.classList.add('loading');
++ btn.textContent = 'Signing in...';
++ ripple(btn, e);
++
++ const form = new FormData(e.target);
++ try {
++ const res = await fetch(API_BASE + '/api/auth/login', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ username: form.get('username'), password: form.get('password') }),
++ });
++ const data = await res.json();
++ if (!res.ok) throw new Error(data.error || 'Login failed');
++
++ // Store token & save account
++ localStorage.setItem('ov_token', data.token);
++ setAuthCookie(data.token);
++ saveAccount(data.user, data.token);
++
++ if (isOAuth) {
++ oauthConfirmInFlight = true;
++ // Confirm OAuth with the new token
++ const confirmRes = await fetch(API_BASE + '/oauth/confirm', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ token: data.token,
++ client_id: params.get('client_id'),
++ redirect_uri: params.get('redirect_uri'),
++ scope: params.get('scope') || 'profile theme',
++ state: params.get('state') || '',
++ }),
++ });
++ const confirmData = await confirmRes.json();
++ if (!confirmRes.ok) {
++ if (confirmRes.status === 401 || confirmRes.status === 403) {
++ clearLocalAuthState();
++ throw new Error('Session invalid during redirect. Please sign in again.');
++ }
++ throw new Error(confirmData.error || 'Authorization failed');
++ }
++ if (!confirmData.redirect || isLoginLikeRedirect(confirmData.redirect)) {
++ throw new Error('Invalid redirect target returned by OAuth server');
++ }
++ toast('Welcome back, ' + (data.user.display_name || data.user.username) + '! <i class="fa-solid fa-circle-nodes"></i>');
++ setTimeout(() => window.location.href = confirmData.redirect, 800);
++ } else {
++ toast('Welcome back, ' + (data.user.display_name || data.user.username) + '! <i class="fa-solid fa-circle-nodes"></i>');
++ setTimeout(() => window.location.href = getReturnUrl(), 800);
++ }
++ } catch (err) {
++ showMsg('login-msg', err.message, 'error');
++ btn.textContent = 'Sign In →';
++ btn.classList.remove('loading');
++ } finally {
++ oauthConfirmInFlight = false;
++ }
++}
++
++// ── Register Handler ────────────────────────────────────────
++async function handleRegister(e) {
++ e.preventDefault();
++ const btn = document.getElementById('register-submit');
++ const form = new FormData(e.target);
++ const username = form.get('username')?.trim();
++
++ const usernameError = getRegisterUsernameError(username);
++ if (usernameError) {
++ showMsg('register-msg', usernameError, 'error');
++ return;
++ }
++
++ if (form.get('password') !== form.get('password_confirm')) {
++ showMsg('register-msg', 'Passwords do not match', 'error');
++ return;
++ }
++
++ btn.classList.add('loading');
++ btn.textContent = 'Creating...';
++ ripple(btn, e);
++
++ try {
++ const res = await fetch(API_BASE + '/api/auth/register', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ username: form.get('username'),
++ email: form.get('email') || undefined,
++ password: form.get('password'),
++ verification_key: form.get('verification_key') || undefined,
++ }),
++ });
++ const data = await res.json();
++ if (!res.ok) throw new Error(data.error || 'Registration failed');
++
++ localStorage.setItem('ov_token', data.token);
++ setAuthCookie(data.token);
++ saveAccount(data.user, data.token);
++
++ if (isOAuth) {
++ oauthConfirmInFlight = true;
++ const confirmRes = await fetch(API_BASE + '/oauth/confirm', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ token: data.token,
++ client_id: params.get('client_id'),
++ redirect_uri: params.get('redirect_uri'),
++ scope: params.get('scope') || 'profile theme',
++ state: params.get('state') || '',
++ }),
++ });
++ const confirmData = await confirmRes.json();
++ if (!confirmRes.ok) {
++ if (confirmRes.status === 401 || confirmRes.status === 403) {
++ clearLocalAuthState();
++ throw new Error('Session invalid during redirect. Please sign in again.');
++ }
++ throw new Error(confirmData.error || 'Authorization failed');
++ }
++ if (!confirmData.redirect || isLoginLikeRedirect(confirmData.redirect)) {
++ throw new Error('Invalid redirect target returned by OAuth server');
++ }
++ toast('Account created! Welcome to the network <i class="fa-solid fa-circle-nodes"></i>');
++ setTimeout(() => window.location.href = confirmData.redirect, 800);
++ } else {
++ toast('Account created! Welcome to the network <i class="fa-solid fa-circle-nodes"></i>');
++ setTimeout(() => window.location.href = getReturnUrl(), 800);
++ }
++ } catch (err) {
++ showMsg('register-msg', err.message, 'error');
++ btn.innerHTML = 'Create Account <i class="fa-solid fa-circle-nodes"></i>';
++ btn.classList.remove('loading');
++ } finally {
++ oauthConfirmInFlight = false;
++ }
++}
++
++async function handleForgotPassword(e) {
++ e.preventDefault();
++ const btn = document.getElementById('forgot-submit');
++ const form = new FormData(e.target);
++ btn.classList.add('loading');
++ btn.textContent = 'Sending...';
++ ripple(btn, e);
++
++ try {
++ const res = await fetch(API_BASE + '/api/auth/forgot-password', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ email: form.get('email') }),
++ });
++ const data = await res.json();
++ if (!res.ok) throw new Error(data.error || 'Unable to send reset link');
++ showMsg('forgot-msg', data.message || 'If that email exists, a reset link has been sent.', 'success');
++ e.target.reset();
++ } catch (err) {
++ showMsg('forgot-msg', err.message, 'error');
++ } finally {
++ btn.innerHTML = 'Send Reset Link <i class="fa-solid fa-envelope"></i>';
++ btn.classList.remove('loading');
++ }
++}
++
++async function validateResetToken(token) {
++ const res = await fetch(API_BASE + '/api/auth/reset-password/validate?token=' + encodeURIComponent(token));
++ const data = await res.json();
++ if (!res.ok || !data.valid) throw new Error(data.error || 'Invalid or expired reset link');
++ return data;
++}
++
++async function handleResetPassword(e) {
++ e.preventDefault();
++ const btn = document.getElementById('reset-submit');
++ const form = new FormData(e.target);
++
++ if (form.get('new_password') !== form.get('confirm_password')) {
++ showMsg('reset-msg', 'Passwords do not match', 'error');
++ return;
++ }
++
++ btn.classList.add('loading');
++ btn.textContent = 'Resetting...';
++ ripple(btn, e);
++
++ try {
++ const res = await fetch(API_BASE + '/api/auth/reset-password', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ token: form.get('token'), new_password: form.get('new_password') }),
++ });
++ const data = await res.json();
++ if (!res.ok) throw new Error(data.error || 'Password reset failed');
++ showMsg('reset-msg', data.message || 'Password reset complete. You can sign in now.', 'success');
++ setTimeout(() => {
++ params.delete('token');
++ const cleanUrl = window.location.pathname === '/reset-password' ? '/login' : window.location.pathname;
++ window.history.replaceState({}, '', cleanUrl);
++ showAuthForm('login');
++ }, 1200);
++ } catch (err) {
++ showMsg('reset-msg', err.message, 'error');
++ } finally {
++ btn.innerHTML = 'Reset Password <i class="fa-solid fa-lock"></i>';
++ btn.classList.remove('loading');
++ }
++}
++
++// Anonymous mode
++async function continueAnonymous() {
++ try {
++ const res = await fetch(API_BASE + '/api/auth/anon-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
++ const data = await res.json();
++ if (!res.ok) throw new Error(data.error || 'Failed');
++ clearAuthCookie();
++ localStorage.removeItem('ov_token');
++ saveAnonAccount(data.user, data.token);
++
++ toast('Browsing as Anonymous #' + data.user.anon_number);
++ setTimeout(() => window.location.href = getReturnUrl(), 600);
++ } catch (err) {
++ toast(err.message, 'error');
++ }
++}
++
++// ── Page Init ───────────────────────────────────────────────
++(function init() {
++ const accounts = getSavedAccounts();
++ hasAccounts = accounts.length > 0;
++ const resetToken = params.get('token');
++
++ if (window.location.pathname === '/forgot-password') {
++ showForgotForm();
++ return;
++ }
++
++ if (window.location.pathname === '/reset-password' || resetToken) {
++ showResetForm(resetToken || '');
++ if (resetToken) {
++ validateResetToken(resetToken)
++ .then((data) => {
++ document.getElementById('reset-note').textContent = `Choose a new password for @${data.username}.`;
++ })
++ .catch((err) => {
++ showMsg('reset-msg', err.message, 'error');
++ document.getElementById('reset-submit').disabled = true;
++ document.getElementById('reset-submit').classList.add('loading');
++ document.getElementById('reset-submit').textContent = 'Reset Link Invalid';
++ });
++ } else {
++ showMsg('reset-msg', 'Missing reset token. Request a new reset link to continue.', 'error');
++ document.getElementById('reset-submit').disabled = true;
++ document.getElementById('reset-submit').classList.add('loading');
++ document.getElementById('reset-submit').textContent = 'Reset Link Required';
++ }
++ return;
++ }
++
++ // Direct link to the create-account tab (e.g. landing page CTA)
++ if (params.get('tab') === 'register') {
++ showAuthForm('register');
++ return;
++ }
++
++ if (hasAccounts) {
++ // Show account chooser
++ renderChooser();
++ showChooser();
++ } else {
++ // No saved accounts — show login form directly
++ document.getElementById('panel-chooser').classList.remove('active');
++ document.getElementById('auth-tabs').style.display = '';
++ document.getElementById('panel-login').classList.add('active');
++ }
++})();
++</script>
++ <script src="https://openvibe.live/live-notify.js" async></script>
++</body>
++</html>
ddiff --git a/public/my.html b/public/my.html
nnew file mode 100644
iindex 00000000..3767b61b
---- /dev/null
++++ b/public/my.html
@@@ -0,0 +1,1197 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<meta http-equiv="X-UA-Compatible" content="IE=edge">
++<title>My Account - OpenVibe Dashboard</title>
++<meta name="description" content="Manage your OpenVibe account, view profile, settings, and access all 13 integrated services from one dashboard.">
++<meta name="keywords" content="account dashboard, openvibe network, user profile, settings">
++<meta name="theme-color" content="#8b5cf6">
++<meta name="robots" content="noindex, follow">
++<meta property="og:type" content="website">
++<meta property="og:url" content="https://openvibe.network/">
++<meta property="og:title" content="My Account - OpenVibe">
++<meta property="og:description" content="Personal dashboard for managing your OpenVibe account and preferences.">
++<meta property="og:site_name" content="OpenVibe">
++<link rel="canonical" href="https://openvibe.network/my">
++<link rel="icon" type="image/x-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect fill='%238b5cf6' width='32' height='32' rx='8'/%3E%3Ctext x='50%25' y='50%25' font-size='20' font-weight='bold' fill='white' text-anchor='middle' dominant-baseline='central'%3EO%3C/text%3E%3C/svg%3E">
++<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
++<style>
++*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
++:root{
++ --bg:#0d0d14;--bg-secondary:#151520;--bg-card:#1a1a26;--bg-input:#1c1c28;
++ --border:#2a2a3a;--border-focus:#8b5cf6;
++ --accent:#8b5cf6;--accent-glow:rgba(139,92,246,0.4);--accent-dark:#6d28d9;--accent-light:#a78bfa;
++ --text:#e0e0e0;--text-secondary:#b0b0b8;--text-muted:#707080;
++ --live-red:#e74c3c;--success:#2ecc71;
++ --shadow:0 4px 20px rgba(0,0,0,0.3);
++}
++html{height:100%;background:var(--bg)}
++body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding-bottom:40px}
++
++/* Layout */
++.page{max-width:900px;margin:0 auto;padding:20px}
++.page-header{display:flex;align-items:center;gap:16px;margin-bottom:32px}
++.page-header .avatar{width:72px;height:72px;border-radius:50%;border:3px solid var(--accent);object-fit:cover}
++.page-header .info{flex:1}
++.page-header .info h1{font-size:22px;font-weight:800;margin-bottom:2px}
++.page-header .info .subtitle{font-size:13px;color:var(--text-muted)}
++.page-header .info .badges{display:flex;gap:6px;margin-top:6px}
++.page-header .info .badges .badge{
++ font-size:10px;padding:3px 10px;border-radius:4px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;
++}
++.badge-admin{background:rgba(231,76,60,0.15);color:#e74c3c}
++.badge-anon{background:rgba(139,92,246,0.15);color:var(--accent-light)}
++.badge-streamer{background:rgba(155,89,182,0.15);color:#9b59b6}
++.badge-user{background:rgba(52,152,219,0.15);color:#3498db}
++
++/* Section tabs */
++.section-tabs{display:flex;gap:4px;margin-bottom:24px;background:var(--bg-secondary);border-radius:10px;padding:4px;overflow-x:auto}
++.section-tabs button{
++ flex-shrink:0;padding:8px 16px;border:none;border-radius:8px;font-size:12px;font-weight:600;
++ background:transparent;color:var(--text-muted);cursor:pointer;transition:all .15s;white-space:nowrap;
++}
++.section-tabs button.active{background:var(--bg-card);color:var(--accent-light)}
++.section-tabs button:hover:not(.active){color:var(--text)}
++
++/* Section */
++.section{display:none;animation:sec-in .2s ease}
++.section.active{display:block}
++@keyframes sec-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
++
++/* Cards */
++.card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}
++.card h3{font-size:14px;font-weight:700;margin-bottom:12px;display:flex;align-items:center;gap:8px}
++.card h3 .icon{font-size:16px}
++.card p{font-size:13px;color:var(--text-secondary);line-height:1.6}
++
++/* Form fields */
++.form-field{margin-bottom:14px}
++.form-field label{display:block;font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:5px}
++.form-field input,.form-field select,.form-field textarea{
++ width:100%;padding:10px 12px;background:var(--bg-input);border:1px solid var(--border);
++ border-radius:8px;color:var(--text);font-size:13px;outline:none;transition:border-color .2s;
++}
++.form-field input:focus,.form-field select:focus,.form-field textarea:focus{border-color:var(--accent)}
++.form-field textarea{resize:vertical;min-height:60px}
++.form-field .hint{font-size:10px;color:var(--text-muted);margin-top:3px}
++
++.btn{
++ padding:8px 20px;border:none;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;transition:all .15s;
++}
++.btn-primary{background:var(--accent);color:#fff}
++.btn-primary:hover{background:var(--accent-dark)}
++.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
++.btn-outline:hover{border-color:var(--accent-dark);color:var(--accent-light)}
++.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red);border:1px solid rgba(231,76,60,0.2)}
++.btn-danger:hover{background:rgba(231,76,60,0.2)}
++.btn-row{display:flex;gap:8px;margin-top:12px}
++
++/* Account list */
++.account-list{display:flex;flex-direction:column;gap:8px}
++.account-item{
++ display:flex;align-items:center;gap:12px;padding:12px;border-radius:10px;
++ background:var(--bg-input);border:1px solid var(--border);
++}
++.account-item.active{border-color:var(--accent)}
++.account-item img{width:36px;height:36px;border-radius:50%}
++.account-item .name{font-size:13px;font-weight:600}
++.account-item .meta{font-size:11px;color:var(--text-muted)}
++.account-item .actions{margin-left:auto;display:flex;gap:6px}
++
++/* Notification pref grid */
++.notif-grid{display:grid;grid-template-columns:1fr auto auto auto auto;gap:8px;align-items:center;font-size:12px}
++.notif-grid .cat{font-weight:600;text-transform:capitalize}
++.notif-grid .toggle{width:36px;height:20px;border-radius:10px;border:none;cursor:pointer;position:relative;transition:background .2s}
++.notif-grid .toggle::after{content:'';position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;top:2px;left:2px;transition:transform .2s}
++.notif-grid .toggle.on{background:var(--accent)}
++.notif-grid .toggle.on::after{transform:translateX(16px)}
++.notif-grid .toggle.off{background:var(--border)}
++
++/* Linked services */
++.linked-item{display:flex;align-items:center;gap:12px;padding:12px;border-radius:8px;background:var(--bg-input);margin-bottom:8px}
++.linked-item .svc-icon{font-size:20px;width:32px;text-align:center}
++.linked-item .svc-name{font-size:13px;font-weight:600}
++.linked-item .svc-user{font-size:11px;color:var(--text-muted)}
++.linked-item .svc-status{margin-left:auto;font-size:11px;font-weight:600;color:var(--success)}
++
++/* Themes */
++.theme-hero{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap;margin-bottom:18px}
++.theme-hero-copy{max-width:560px}
++.theme-hero-copy p{margin-top:8px}
++.theme-current{display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:10px;background:var(--bg-input);border:1px solid var(--border);min-width:220px}
++.theme-current-swatch{display:flex;gap:4px}
++.theme-current-swatch span,.theme-preview-swatch span{width:18px;height:18px;border-radius:50%;border:1px solid rgba(255,255,255,.08);display:inline-block}
++.theme-current .label{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em}
++.theme-current .name{font-size:13px;font-weight:700;margin-top:2px}
++.theme-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
++.theme-custom-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
++.theme-custom-row{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-secondary);background:var(--bg-input);border:1px solid var(--border);border-radius:10px;padding:8px 10px;cursor:pointer}
++.theme-custom-row input[type=color]{width:34px;height:34px;border:none;border-radius:8px;background:none;cursor:pointer;padding:0}
++.theme-custom-row input[type=color]::-webkit-color-swatch{border:1px solid var(--border);border-radius:6px}
++.theme-custom-row input[type=color]::-webkit-color-swatch-wrapper{padding:0}
++.theme-card{background:var(--bg-input);border:1px solid var(--border);border-radius:14px;padding:14px;display:flex;flex-direction:column;gap:12px;transition:border-color .15s,transform .15s,box-shadow .15s}
++.theme-card:hover{transform:translateY(-2px);border-color:var(--accent)}
++.theme-card.active{border-color:var(--accent);box-shadow:0 0 0 1px rgba(139,92,246,.25) inset}
++.theme-preview{border-radius:12px;padding:14px;background:linear-gradient(135deg,var(--theme-bg-1,#202331),var(--theme-bg-2,#181a24));border:1px solid rgba(255,255,255,.06)}
++.theme-preview-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}
++.theme-preview-chip{padding:5px 9px;border-radius:999px;background:rgba(255,255,255,.08);color:#fff;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}
++.theme-preview-bars{display:grid;gap:7px}
++.theme-preview-bars span{display:block;height:8px;border-radius:999px;background:rgba(255,255,255,.16)}
++.theme-preview-bars span:first-child{width:72%}
++.theme-preview-bars span:last-child{width:46%}
++.theme-card h4{font-size:14px;font-weight:700}
++.theme-card p{font-size:12px;color:var(--text-secondary);line-height:1.55}
++.theme-card-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto}
++.theme-badges{display:flex;gap:6px;flex-wrap:wrap}
++.theme-badge{font-size:10px;padding:4px 8px;border-radius:999px;background:rgba(255,255,255,.05);color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;font-weight:700}
++.theme-empty{padding:16px;border-radius:12px;background:var(--bg-input);border:1px dashed var(--border);text-align:center;color:var(--text-muted);font-size:13px}
++
++/* Anon info card */
++.anon-card{text-align:center;padding:32px 20px}
++.anon-card .number{font-size:48px;font-weight:800;color:var(--accent);margin-bottom:8px}
++.anon-card .label{font-size:12px;color:var(--text-muted);margin-bottom:16px}
++.anon-card .stats{display:flex;justify-content:center;gap:24px}
++.anon-card .stat{text-align:center}
++.anon-card .stat .val{font-size:20px;font-weight:700}
++.anon-card .stat .key{font-size:10px;color:var(--text-muted);text-transform:uppercase}
++
++/* Loading */
++.loading-spinner{text-align:center;padding:40px;color:var(--text-muted);font-size:13px}
++.acct-retry-btn{display:inline-block;background:var(--accent,#8b5cf6);color:#0b0d10;border:none;border-radius:6px;padding:3px 12px;font-weight:600;font-size:0.85em;cursor:pointer;vertical-align:baseline}
++.acct-retry-btn:hover{filter:brightness(1.08)}
++
++@media(max-width:600px){
++ .page{padding:12px}
++ .page-header{flex-direction:column;text-align:center}
++ .page-header .info .badges{justify-content:center}
++ .notif-grid{grid-template-columns:1fr auto auto}
++ .notif-grid .col-email,.notif-grid .col-toast{display:none}
++}
++
++/* Dashboard Showcase */
++@keyframes float { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-10px); } }
++@keyframes fadeInScale { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
++.dashboard-showcase { margin: 32px 0; padding: 28px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }
++.showcase-header { text-align: center; margin-bottom: 24px; }
++.showcase-header h2 { font-size: 1.3rem; font-weight: 800; margin-bottom: 6px; }
++.showcase-header p { font-size: 12px; color: var(--text-muted); }
++.showcase-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
++.showcase-card { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; border-radius: 10px; background: var(--bg-input); border: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; gap: 8px; animation: float ease-in-out infinite; transition: all 0.3s ease; }
++.showcase-card:nth-child(1) { animation-delay: 0s; animation-duration: 3s; }
++.showcase-card:nth-child(2) { animation-delay: 0.15s; animation-duration: 3.3s; }
++.showcase-card:nth-child(3) { animation-delay: 0.3s; animation-duration: 3.6s; }
++.showcase-card:nth-child(4) { animation-delay: 0.45s; animation-duration: 3.2s; }
++.showcase-card:hover { transform: translateY(-4px); border-color: var(--accent); background: rgba(139,92,246,0.08); }
++.showcase-card-icon { font-size: 1.4rem; color: var(--accent-light); }
++.showcase-card-label { font-size: 11px; font-weight: 700; text-align: center; }
++.quick-access { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
++.quick-link { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 10px; border-radius: 8px; background: var(--bg); border: 1px solid var(--border); text-decoration: none; color: var(--text-secondary); font-size: 10px; font-weight: 600; text-align: center; transition: all 0.25s ease; animation: fadeInScale 0.5s ease both; }
++.quick-link i { font-size: 1rem; margin-bottom: 4px; }
++.quick-link:nth-child(1) { animation-delay: 0.1s; }
++.quick-link:nth-child(2) { animation-delay: 0.15s; }
++.quick-link:nth-child(3) { animation-delay: 0.2s; }
++.quick-link:nth-child(4) { animation-delay: 0.25s; }
++.quick-link:nth-child(5) { animation-delay: 0.3s; }
++.quick-link:nth-child(6) { animation-delay: 0.35s; }
++.quick-link:hover { border-color: var(--accent); color: var(--accent-light); background: rgba(139,92,246,0.08); }
++@media(max-width:600px){ .showcase-grid { grid-template-columns: repeat(2, 1fr); } .quick-access { grid-template-columns: repeat(3, 1fr); } }
++</style>
++</head>
++<body>
++
++<!-- Navbar mount point -->
++<div id="navbar-mount"></div>
++
++<div class="page">
++ <div class="page-header" id="page-header">
++ <img class="avatar" id="user-avatar" src="" alt="Profile avatar">
++ <div class="info">
++ <h1 id="user-name">Loading...</h1>
++ <div class="subtitle" id="user-subtitle"></div>
++ <div class="badges" id="user-badges"></div>
++ </div>
++ </div>
++
++ <div class="dashboard-showcase">
++ <div class="showcase-header">
++ <h2>Quick Access</h2>
++ <p>Your favorite services at a glance</p>
++ </div>
++ <div class="showcase-grid" id="my-showcase-grid"></div>
++ <div class="quick-access" id="my-quick-access"></div>
++ </div>
++
++ <div class="section-tabs">
++ <button class="active" onclick="showSection('profile',this)"><i class="fa-solid fa-user"></i> Profile</button>
++ <button onclick="showSection('themes',this)"><i class="fa-solid fa-palette"></i> Themes</button>
++ <button onclick="showSection('accounts',this)"><i class="fa-solid fa-arrows-rotate"></i> Accounts</button>
++ <button onclick="showSection('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
++ <button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button>
++ <button onclick="showSection('security',this)"><i class="fa-solid fa-lock"></i> Security</button>
++ <button onclick="showSection('anon',this)"><i class="fa-solid fa-user-secret"></i> Anon Info</button>
++ </div>
++
++ <!-- Profile Section -->
++ <div class="section active" id="sec-profile">
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-user"></i></span> Edit Profile</h3>
++ <form id="profile-form">
++ <div class="form-field">
++ <label>Display Name</label>
++ <input type="text" id="pf-display-name" maxlength="50">
++ <small class="field-hint" id="pf-display-name-hint" style="opacity:.7;font-size:12px">You can only change the capitalization of your username.</small>
++ </div>
++ <div class="form-field">
++ <label>Bio</label>
++ <textarea id="pf-bio" maxlength="500" placeholder="Tell us about yourself..."></textarea>
++ </div>
++ <div class="form-field">
++ <label>Email</label>
++ <input type="email" id="pf-email" placeholder="[email protected]">
++ <div class="hint">Used for password resets and any notification emails you enable below.</div>
++ </div>
++ <div class="form-field">
++ <label>Profile Color</label>
++ <input type="color" id="pf-color" value="#8b5cf6" style="width:60px;height:36px;padding:2px;cursor:pointer">
++ </div>
++ <div class="btn-row">
++ <button type="submit" class="btn btn-primary">Save Changes</button>
++ </div>
++ </form>
++ </div>
++ </div>
++
++ <!-- Themes Section -->
++ <div class="section" id="sec-themes">
++ <div class="card">
++ <div class="theme-hero">
++ <div class="theme-hero-copy">
++ <h3><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</h3>
++ <p>Pick the look that follows you across the OpenVibe. Your theme choice syncs to compatible services and updates this account hub instantly.</p>
++ </div>
++ <div class="theme-current" id="theme-current">
++ <div class="theme-current-swatch" id="theme-current-swatch">
++ <span></span><span></span><span></span>
++ </div>
++ <div>
++ <div class="label">Active theme</div>
++ <div class="name" id="theme-current-name">Loading…</div>
++ </div>
++ </div>
++ </div>
++ <div id="theme-grid" class="theme-grid">
++ <div class="theme-empty">Loading themes…</div>
++ </div>
++ </div>
++
++ <div class="card" style="margin-top:16px">
++ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
++ <div>
++ <h3 style="margin:0"><span class="icon"><i class="fa-solid fa-sliders"></i></span> Customize colors</h3>
++ <p style="margin:6px 0 0;color:var(--text-secondary);font-size:0.9rem">Tweak your active theme. Changes preview live and sync everywhere when you save.</p>
++ </div>
++ <button class="btn btn-outline" id="theme-custom-toggle" onclick="toggleThemeCustomizer()"><i class="fa-solid fa-pen"></i> Customize</button>
++ </div>
++ <div id="theme-customizer" style="display:none;margin-top:16px">
++ <div id="theme-custom-grid" class="theme-custom-grid"></div>
++ <div style="display:flex;gap:10px;margin-top:16px;flex-wrap:wrap">
++ <button class="btn btn-primary" onclick="saveCustomColors(this)"><i class="fa-solid fa-floppy-disk"></i> Save custom theme</button>
++ <button class="btn btn-outline" onclick="resetCustomColors(this)"><i class="fa-solid fa-rotate-left"></i> Reset to preset</button>
++ </div>
++ </div>
++ </div>
++ </div>
++
++ <!-- Accounts Section -->
++ <div class="section" id="sec-accounts">
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-arrows-rotate"></i></span> Your Accounts</h3>
++ <p>Switch between multiple accounts, Google-style. You can have up to 5 accounts logged in at once.</p>
++ <div class="account-list" id="account-list" style="margin-top:12px">
++ <!-- Populated by JS -->
++ </div>
++ <div class="btn-row" style="margin-top:16px">
++ <button class="btn btn-outline" onclick="addAccount()">+ Add Another Account</button>
++ <button class="btn btn-danger" onclick="signOutAll()">Sign Out All</button>
++ </div>
++ </div>
++ </div>
++
++ <!-- Notifications Section -->
++ <div class="section" id="sec-notifications">
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Preferences</h3>
++ <p style="margin-bottom:16px">Control which notifications you receive and how they are delivered, including optional email alerts by category.</p>
++ <div class="notif-grid" id="notif-prefs">
++ <div style="font-weight:700;font-size:11px;color:var(--text-muted)">Category</div>
++ <div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center">Enabled</div>
++ <div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center">Sound</div>
++ <div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-toast">Toast</div>
++ <div style="font-weight:700;font-size:11px;color:var(--text-muted);text-align:center" class="col-email">Email</div>
++ <!-- Rows populated by JS -->
++ </div>
++ </div>
++ </div>
++
++ <!-- Linked Services Section -->
++ <div class="section" id="sec-linked">
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</h3>
++ <p style="margin-bottom:12px">Your OpenVibe account is connected to these services.</p>
++ <div id="linked-list">
++ <!-- Populated by JS -->
++ </div>
++ </div>
++ </div>
++
++ <!-- Security Section -->
++ <div class="section" id="sec-security">
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-lock"></i></span> Change Password</h3>
++ <form id="password-form">
++ <div class="form-field">
++ <label>Current Password</label>
++ <input type="password" id="sec-current-pw" required>
++ </div>
++ <div class="form-field">
++ <label>New Password</label>
++ <input type="password" id="sec-new-pw" required minlength="6">
++ </div>
++ <div class="form-field">
++ <label>Confirm New Password</label>
++ <input type="password" id="sec-confirm-pw" required>
++ </div>
++ <div class="btn-row">
++ <button type="submit" class="btn btn-primary">Change Password</button>
++ </div>
++ </form>
++ </div>
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-mobile-screen"></i></span> Active Sessions</h3>
++ <div id="sessions-list"><div class="loading-spinner">Loading sessions...</div></div>
++ <div class="btn-row" style="margin-top:12px">
++ <button class="btn btn-danger" onclick="revokeAllSessions()">Revoke All Sessions</button>
++ </div>
++ </div>
++ </div>
++
++ <!-- Anon Info Section -->
++ <div class="section" id="sec-anon">
++ <div class="card">
++ <div class="anon-card" id="anon-info">
++ <p style="color:var(--text-muted)">Anonymous user info will appear here once loaded.</p>
++ </div>
++ </div>
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-link"></i></span> Link Anon to Account</h3>
++ <p>If you had an anonymous session before signing up, you can link it to preserve your anon number.</p>
++ <div class="btn-row">
++ <button class="btn btn-outline" onclick="linkAnon()">Link Anonymous Identity</button>
++ </div>
++ </div>
++ </div>
++</div>
++
++<!-- Shared scripts -->
++<script src="/shared/theme-loader.js"></script>
++<script src="/shared/navbar.js"></script>
++<script src="/shared/notification-ui.js"></script>
++<script src="/shared/account-switcher.js"></script>
++
++<script>
++const API = window.location.origin;
++let currentUser = null;
++let availableThemes = [];
++let activeThemeId = 'vibe';
++const DEFAULT_EMAIL_HINT = 'Used for password resets and any notification emails you enable below.';
++
++const LOCAL_THEME_MAP = {
++ '--bg': '--bg-primary',
++ '--bg-secondary': '--bg-secondary',
++ '--bg-card': '--bg-card',
++ '--bg-input': '--bg-input',
++ '--border': '--border',
++ '--accent': '--accent',
++ '--accent-light': '--accent-light',
++ '--accent-dark': '--accent-dark',
++ '--text': '--text-primary',
++ '--text-secondary': '--text-secondary',
++ '--text-muted': '--text-muted',
++};
++
++function getStoredAccounts() {
++ try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); }
++ catch { return []; }
++}
++
++function getActiveAccount() {
++ const activeId = localStorage.getItem('openvibe_active_account');
++ const accounts = getStoredAccounts();
++ if (activeId === 'anon') return accounts.find(a => a?.is_anon) || null;
++ return accounts.find(a => String(a?.id) === String(activeId)) || null;
++}
++
++function isAnonSession() {
++ return !!getActiveAccount()?.is_anon || localStorage.getItem('openvibe_active_account') === 'anon';
++}
++
++/** Read ov_token from the host-only cookie (no Domain attribute — CONTRACTS.md) */
++function _getCookieToken() {
++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
++ return m ? decodeURIComponent(m[1]) : null;
++}
++
++function getAuthToken() {
++ if (isAnonSession()) return null;
++ return localStorage.getItem('ov_token') || getActiveAccount()?.token || _getCookieToken() || null;
++}
++
++function getAnonToken() {
++ return localStorage.getItem('openvibe_anon_token') || (getActiveAccount()?.is_anon ? getActiveAccount()?.token : null) || null;
++}
++
++/**
++ * If localStorage has no token but the cross-domain cookie does,
++ * hydrate localStorage so future getAuthToken() calls are instant.
++ * Also bootstraps the account list from /api/auth/me.
++ * If the token is expired, attempts a silent refresh.
++ */
++async function _bootstrapFromCookie() {
++ if (localStorage.getItem('ov_token')) return; // already have it
++ const cookieToken = _getCookieToken();
++ if (!cookieToken) return;
++ // Store the token in localStorage for this origin
++ localStorage.setItem('ov_token', cookieToken);
++ // Try to populate the account list
++ try {
++ const res = await fetchWithTimeout(API + '/api/auth/me', {
++ headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
++ credentials: 'include',
++ });
++ if (res.ok) {
++ const data = await res.json();
++ const user = data.user || data;
++ // Bootstrap openvibe_accounts + openvibe_active_account
++ const accounts = [{ id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, email: user.email || null, is_anon: false, token: cookieToken, added_at: Date.now() }];
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ localStorage.setItem('openvibe_active_account', String(user.id));
++ } else if (res.status === 401) {
++ // Token expired — try refresh
++ const refreshRes = await fetchWithTimeout(API + '/api/auth/refresh', {
++ method: 'POST',
++ headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
++ credentials: 'include',
++ });
++ if (refreshRes.ok) {
++ const refreshData = await refreshRes.json();
++ if (refreshData.token) {
++ localStorage.setItem('ov_token', refreshData.token);
++ document.cookie = `ov_token=${refreshData.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
++ const user = refreshData.user;
++ if (user) {
++ const accounts = [{ id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, email: user.email || null, is_anon: false, token: refreshData.token, added_at: Date.now() }];
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ localStorage.setItem('openvibe_active_account', String(user.id));
++ }
++ }
++ } else {
++ // Refresh failed — clear stale cookie
++ localStorage.removeItem('ov_token');
++ document.cookie = 'ov_token=;path=/;max-age=0';
++ }
++ }
++ } catch { /* cookie may be expired — loadUser will handle redirect */ }
++}
++
++// fetch with an abort timeout so a weak connection can't hang the page forever.
++async function fetchWithTimeout(url, opts = {}, ms = 15000) {
++ const ctrl = new AbortController();
++ const timer = setTimeout(() => ctrl.abort(), ms);
++ try {
++ return await fetch(url, { ...opts, signal: ctrl.signal });
++ } catch (e) {
++ const err = new Error(e && e.name === 'AbortError'
++ ? 'Request timed out — check your connection'
++ : 'Network error — check your connection');
++ err.network = true;
++ throw err;
++ } finally {
++ clearTimeout(timer);
++ }
++}
++
++async function apiFetch(path, opts = {}) {
++ const headers = { 'Content-Type':'application/json', ...opts.headers };
++ const authToken = getAuthToken();
++ if (authToken) headers.Authorization = 'Bearer ' + authToken;
++ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
++ let data = {};
++ try { data = await res.json(); } catch { /* non-JSON (e.g. proxy error page) */ }
++ if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
++ return data;
++}
++
++async function anonFetch(path, opts = {}) {
++ const headers = { 'Content-Type':'application/json', ...opts.headers };
++ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
++ let data = {};
++ try { data = await res.json(); } catch { /* non-JSON */ }
++ if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
++ return data;
++}
++
++// Build dashboard showcase
++function buildDashboardShowcase() {
++ const grid = document.getElementById('my-showcase-grid');
++ const quickAccess = document.getElementById('my-quick-access');
++ if (!grid || !quickAccess) return;
++ const popular = [
++ { name: 'Live', icon: 'fa-tower-broadcast', url: 'https://openvibe.live' },
++ { name: 'Tools', icon: 'fa-screwdriver-wrench', url: 'https://openvibe.tools' },
++ { name: 'Games', icon: 'fa-gamepad', url: 'https://openvibe.games' },
++ { name: 'Media', icon: 'fa-photo-film', url: 'https://openvibe.media' },
++ ];
++ grid.innerHTML = popular.map(t => `<a href="${t.url}" class="showcase-card" title="${t.name}" target="_blank" rel="noopener"><i class="fa-solid ${t.icon} showcase-card-icon"></i><span class="showcase-card-label">${t.name}</span></a>`).join('');
++ const quickLinks = [
++ { label: 'Themes', icon: 'fa-palette', onclick: "showSection('themes')" },
++ { label: 'Profile', icon: 'fa-user', onclick: "showSection('profile')" },
++ { label: 'Accounts', icon: 'fa-arrows-rotate', onclick: "showSection('accounts')" },
++ { label: 'Linked', icon: 'fa-link', onclick: "showSection('linked')" },
++ { label: 'Security', icon: 'fa-lock', onclick: "showSection('security')" },
++ { label: 'Notifications', icon: 'fa-bell', onclick: "showSection('notifications')" },
++ ];
++ quickAccess.innerHTML = quickLinks.map(l => `<a class="quick-link" onclick="${l.onclick}"><i class="fa-solid ${l.icon}"></i>${l.label}</a>`).join('');
++}
++
++// Section switching
++function showSection(id, btn) {
++ document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
++ document.querySelectorAll('.section-tabs button').forEach(b => b.classList.remove('active'));
++ document.getElementById('sec-' + id).classList.add('active');
++ const targetBtn = btn || [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes(`'${id}'`));
++ targetBtn?.classList.add('active');
++ history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);
++}
++
++function avatarPlaceholder(user, size = 96) {
++ const label = (user?.display_name || user?.username || 'H').trim();
++ const initial = label.charAt(0).toUpperCase() || 'H';
++ 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="#fff">${initial}</text></svg>`;
++ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
++}
++
++function avatarSrc(user, size = 96) {
++ return user?.avatar_url || avatarPlaceholder(user, size);
++}
++
++function themePreviewColors(theme) {
++ const vars = theme?.variables || {};
++ const preview = Array.isArray(theme?.preview_colors) && theme.preview_colors.length
++ ? theme.preview_colors
++ : [vars['--bg-primary'], vars['--accent'], vars['--bg-card']].filter(Boolean);
++ return [preview[0] || '#202331', preview[1] || '#8b5cf6', preview[2] || '#181a24'];
++}
++
++function applyThemeToPage(theme) {
++ const vars = theme?.variables || {};
++ Object.entries(LOCAL_THEME_MAP).forEach(([localVar, themeVar]) => {
++ const value = vars[themeVar];
++ if (value) document.documentElement.style.setProperty(localVar, value);
++ });
++}
++
++function updateThemeSummary(theme) {
++ const currentName = document.getElementById('theme-current-name');
++ const swatches = document.querySelectorAll('#theme-current-swatch span');
++ if (!currentName || swatches.length < 3) return;
++ currentName.textContent = theme?.name || 'Vibe';
++ themePreviewColors(theme).forEach((color, index) => {
++ if (swatches[index]) swatches[index].style.background = color;
++ });
++}
++
++function renderThemes() {
++ const grid = document.getElementById('theme-grid');
++ if (!grid) return;
++ if (!availableThemes.length) {
++ grid.innerHTML = '<div class="theme-empty">No themes available right now.</div>';
++ return;
++ }
++ grid.innerHTML = availableThemes.map((theme) => {
++ const [bg1, accent, bg2] = themePreviewColors(theme);
++ const isActive = String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId);
++ return `
++ <article class="theme-card ${isActive ? 'active' : ''}">
++ <div class="theme-preview" style="--theme-bg-1:${bg1};--theme-bg-2:${bg2}">
++ <div class="theme-preview-top">
++ <div class="theme-preview-swatch"><span style="background:${bg1}"></span><span style="background:${accent}"></span><span style="background:${bg2}"></span></div>
++ <span class="theme-preview-chip">${theme.mode || 'dark'}</span>
++ </div>
++ <div class="theme-preview-bars"><span style="background:${accent}"></span><span></span></div>
++ </div>
++ <div>
++ <h4>${theme.name}</h4>
++ <p>${theme.description || 'A synced network theme.'}</p>
++ </div>
++ <div class="theme-card-footer">
++ <div class="theme-badges">
++ ${theme.is_builtin ? '<span class="theme-badge">Built-in</span>' : '<span class="theme-badge">Community</span>'}
++ </div>
++ <button class="btn ${isActive ? 'btn-outline' : 'btn-primary'}" onclick="setTheme('${theme.id}')">${isActive ? 'Active' : 'Use theme'}</button>
++ </div>
++ </article>
++ `;
++ }).join('');
++ updateThemeSummary(availableThemes.find((theme) => String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId)) || availableThemes[0]);
++}
++
++async function loadThemes() {
++ const grid = document.getElementById('theme-grid');
++ if (!grid) return;
++ try {
++ const [themesData, activeData] = await Promise.all([
++ apiFetch('/api/themes?limit=100'),
++ apiFetch('/api/themes/me/active'),
++ ]);
++ availableThemes = themesData.themes || [];
++ activeThemeId = activeData.theme_id || 'vibe';
++ activeCustomVars = (activeData.custom_variables && typeof activeData.custom_variables === 'object') ? activeData.custom_variables : {};
++ const activeTheme = availableThemes.find((theme) => String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId));
++ activeThemeVars = (activeTheme && activeTheme.variables) || {};
++ if (activeTheme) applyThemeToPage(activeTheme);
++ if (Object.keys(activeCustomVars).length) applyThemeToPage({ variables: Object.assign({}, activeThemeVars, activeCustomVars) });
++ renderThemes();
++ renderThemeCustomizer();
++ } catch (err) {
++ console.error('Failed to load themes:', err);
++ grid.innerHTML = '<div class="theme-empty">Unable to load themes right now.</div>';
++ }
++}
++
++async function setTheme(themeId) {
++ try {
++ await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: themeId }) });
++ activeThemeId = themeId;
++ activeCustomVars = {}; // picking a preset clears custom overrides
++ const activeTheme = availableThemes.find((theme) => String(theme.id) === String(themeId) || String(theme.slug) === String(themeId));
++ if (activeTheme) {
++ activeThemeVars = activeTheme.variables || {};
++ renderThemeCustomizer();
++ applyThemeToPage(activeTheme);
++ localStorage.setItem('ov_theme', JSON.stringify({ id: activeTheme.id, slug: activeTheme.slug, name: activeTheme.name, variables: activeTheme.variables || {} }));
++ // Sync theme choice via cookie + OpenVibeThemeLoader (host-only cookie here)
++ if (typeof OpenVibeThemeLoader !== 'undefined') OpenVibeThemeLoader.save(activeTheme.slug || activeTheme.id, activeTheme.variables);
++ else document.cookie = `ov_theme_id=${encodeURIComponent(activeTheme.slug || activeTheme.id)};path=/;max-age=${365*24*60*60};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
++ }
++ renderThemes();
++ } catch (err) {
++ alert('Error: ' + err.message);
++ }
++}
++
++// ── Custom color editor ──────────────────────────────────────
++let activeCustomVars = {};
++let activeThemeVars = {};
++const THEME_EDITABLE = [
++ ['--bg-primary', 'Background'], ['--bg-secondary', 'Surface'], ['--bg-card', 'Card'],
++ ['--bg-hover', 'Hover'], ['--accent', 'Accent'], ['--accent-light', 'Accent light'],
++ ['--text-primary', 'Text'], ['--text-secondary', 'Muted text'], ['--border', 'Border'],
++];
++function _hexOrDefault(v) {
++ if (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v.trim())) return v.trim();
++ if (typeof v === 'string' && /^#[0-9a-fA-F]{3}$/.test(v.trim())) {
++ const h = v.trim().slice(1); return '#' + h.split('').map(c => c + c).join('');
++ }
++ return '#888888';
++}
++function renderThemeCustomizer() {
++ const grid = document.getElementById('theme-custom-grid');
++ if (!grid) return;
++ grid.innerHTML = THEME_EDITABLE.map(([varName, label]) => {
++ const val = _hexOrDefault(activeCustomVars[varName] || activeThemeVars[varName]);
++ return `<label class="theme-custom-row">
++ <input type="color" value="${val}" data-var="${varName}" oninput="_onCustomColorInput(this)">
++ <span>${label}</span>
++ </label>`;
++ }).join('');
++}
++function toggleThemeCustomizer() {
++ const box = document.getElementById('theme-customizer');
++ const btn = document.getElementById('theme-custom-toggle');
++ if (!box) return;
++ const show = box.style.display === 'none';
++ box.style.display = show ? '' : 'none';
++ if (btn) btn.innerHTML = show ? '<i class="fa-solid fa-chevron-up"></i> Hide' : '<i class="fa-solid fa-pen"></i> Customize';
++ if (show) renderThemeCustomizer();
++}
++function _onCustomColorInput(input) {
++ const v = input.getAttribute('data-var');
++ activeCustomVars[v] = input.value;
++ // Live preview on this page.
++ applyThemeToPage({ variables: Object.assign({}, activeThemeVars, activeCustomVars) });
++}
++async function saveCustomColors(btn) {
++ if (btn) btn.disabled = true;
++ try {
++ await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: activeThemeId, custom_variables: activeCustomVars }) });
++ const merged = Object.assign({}, activeThemeVars, activeCustomVars);
++ try { localStorage.setItem('ov_theme', JSON.stringify({ id: activeThemeId, variables: merged })); } catch {}
++ if (typeof OpenVibeThemeLoader !== 'undefined') OpenVibeThemeLoader.save(activeThemeId, merged);
++ alert('Custom theme saved — it will sync across the network.');
++ } catch (err) { alert('Error: ' + err.message); }
++ finally { if (btn) btn.disabled = false; }
++}
++async function resetCustomColors(btn) {
++ if (btn) btn.disabled = true;
++ try {
++ await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: activeThemeId, custom_variables: null }) });
++ activeCustomVars = {};
++ applyThemeToPage({ variables: activeThemeVars });
++ renderThemeCustomizer();
++ } catch (err) { alert('Error: ' + err.message); }
++ finally { if (btn) btn.disabled = false; }
++}
++
++function openInitialSection() {
++ const routeSection = window.location.pathname === '/themes' ? 'themes' : window.location.hash.replace(/^#/, '') || 'profile';
++ if (isAnonSession() && ['notifications','linked','security'].includes(routeSection)) {
++ return showSection('anon');
++ }
++ if (document.getElementById('sec-' + routeSection)) {
++ showSection(routeSection);
++ }
++}
++
++function setSectionDisabled(sectionId, message) {
++ const section = document.getElementById('sec-' + sectionId);
++ if (!section) return;
++ section.innerHTML = `
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-user-secret"></i></span> Anonymous Mode</h3>
++ <p>${message}</p>
++ </div>
++ `;
++}
++
++function applyAnonModeUI(user) {
++ document.getElementById('pf-bio').disabled = true;
++ document.getElementById('pf-email').disabled = true;
++ document.getElementById('pf-color').disabled = true;
++ document.getElementById('pf-bio').value = '';
++ document.getElementById('pf-email').value = '';
++ document.getElementById('pf-bio').placeholder = 'Anonymous mode does not store bios.';
++ document.getElementById('pf-email').placeholder = 'Anonymous mode does not use email addresses';
++ const emailHint = document.querySelector('#sec-profile .hint');
++ if (emailHint) emailHint.textContent = 'Anonymous mode only stores lightweight preferences like your display name.';
++ const saveButton = document.querySelector('#profile-form .btn-primary');
++ if (saveButton) saveButton.textContent = 'Save Anon Preferences';
++
++ const notifTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'notifications'"));
++ const linkedTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'linked'"));
++ const securityTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'security'"));
++ [notifTab, linkedTab, securityTab].forEach((button) => {
++ if (!button) return;
++ button.style.opacity = '.45';
++ button.title = 'Unavailable in anonymous mode';
++ });
++
++ setSectionDisabled('notifications', 'Notification inbox, email preferences, and alert delivery are only available for full accounts.');
++ setSectionDisabled('linked', 'Linked services are only available once you sign in with a full OpenVibe account.');
++ setSectionDisabled('security', 'Anonymous identities do not have passwords or device sessions to manage.');
++
++ const themeGrid = document.getElementById('theme-grid');
++ if (themeGrid) {
++ themeGrid.innerHTML = '<div class="theme-empty">Theme syncing is unavailable for anonymous sessions right now.</div>';
++ }
++ const currentName = document.getElementById('theme-current-name');
++ if (currentName) currentName.textContent = 'Anon sessions do not sync themes';
++}
++
++// Load user data
++async function loadUser() {
++ // Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)
++ await _bootstrapFromCookie();
++ const authToken = getAuthToken();
++ const anonToken = getAnonToken();
++ if (!authToken && !anonToken) { window.location.href = '/login'; return; }
++ try {
++ if (isAnonSession()) {
++ const data = await anonFetch('/api/auth/anon/' + encodeURIComponent(anonToken));
++ currentUser = {
++ ...data.user,
++ id: 'anon',
++ role: 'anon',
++ email: null,
++ avatar_url: null,
++ profile_color: '#8b5cf6',
++ bio: '',
++ is_anon: true,
++ };
++ renderHeader(currentUser);
++ renderProfile(currentUser);
++ applyAnonModeUI(currentUser);
++ loadAccounts();
++ loadAnonInfo();
++ OpenVibeNavbar.init({ service: 'network', token: null, user: currentUser });
++ } else {
++ const data = await apiFetch('/api/auth/me');
++ currentUser = data.user;
++ renderHeader(data.user);
++ renderProfile(data.user);
++ loadThemes();
++ loadAccounts();
++ loadNotifPrefs();
++ loadLinked();
++ loadSessions();
++ loadAnonInfo();
++
++ // Init navbar
++ OpenVibeNavbar.init({ service: 'network', token: authToken, user: currentUser });
++ const bellMount = OpenVibeNavbar.getBellMount();
++ if (bellMount && window.OpenVibeNotifications && authToken) {
++ OpenVibeNotifications.init({ token: authToken, apiBase: API });
++ const bell = OpenVibeNotifications.createBell();
++ bellMount.replaceChildren(bell);
++ }
++ }
++ } catch (err) {
++ console.error(err);
++ // A genuine auth failure → send them to log in. Everything else (offline, weak signal,
++ // timeout, server hiccup) → show a clear reason + retry instead of a stuck "Loading…".
++ if (err.status === 401 || /401|Authentication|not found|revoked|Unauthorized/i.test(err.message || '')) {
++ window.location.href = '/login?return=' + encodeURIComponent(window.location.pathname);
++ return;
++ }
++ showAccountLoadError(err);
++ }
++}
++
++// Replace the stuck "Loading…" with a readable reason + a Retry button.
++function showAccountLoadError(err) {
++ const nameEl = document.getElementById('user-name');
++ const subEl = document.getElementById('user-subtitle');
++ if (nameEl) nameEl.textContent = "Couldn't load your account";
++ if (subEl) {
++ const offline = (typeof navigator !== 'undefined' && navigator.onLine === false);
++ subEl.textContent = (offline ? "You appear to be offline" : (err && err.message) ? err.message : 'Something went wrong') + ' · ';
++ const btn = document.createElement('button');
++ btn.type = 'button';
++ btn.className = 'acct-retry-btn';
++ btn.textContent = 'Retry';
++ btn.onclick = () => { if (nameEl) nameEl.textContent = 'Loading…'; if (subEl) subEl.textContent = ''; loadUser(); };
++ subEl.appendChild(btn);
++ }
++}
++
++function renderHeader(user) {
++ const avatar = document.getElementById('user-avatar');
++ const fallback = avatarPlaceholder(user, 96);
++ avatar.src = avatarSrc(user, 96);
++ avatar.onerror = () => { avatar.src = fallback; };
++ document.getElementById('user-name').textContent = user.display_name || user.username;
++ document.getElementById('user-subtitle').textContent = user.is_anon
++ ? `Anonymous identity${user.anon_number ? ' · #' + user.anon_number : ''}`
++ : '@' + user.username + (user.email ? ' · ' + user.email : '');
++ const badges = document.getElementById('user-badges');
++ badges.innerHTML = user.is_anon
++ ? `<span class="badge badge-anon">anonymous</span>`
++ : `<span class="badge badge-${user.role}">${user.role}</span>`;
++ if (user.anon_number) badges.innerHTML += `<span class="badge badge-anon">Anon #${user.anon_number}</span>`;
++}
++
++function renderProfile(user) {
++ document.getElementById('pf-display-name').value = user.display_name || '';
++ document.getElementById('pf-bio').value = user.bio || '';
++ document.getElementById('pf-email').value = user.email || '';
++ document.getElementById('pf-color').value = user.profile_color || '#8b5cf6';
++ if (!user.is_anon) {
++ document.getElementById('pf-bio').disabled = false;
++ document.getElementById('pf-email').disabled = false;
++ document.getElementById('pf-color').disabled = false;
++ const emailHint = document.querySelector('#sec-profile .hint');
++ if (emailHint) emailHint.textContent = DEFAULT_EMAIL_HINT;
++ const saveButton = document.querySelector('#profile-form .btn-primary');
++ if (saveButton) saveButton.textContent = 'Save Changes';
++ }
++}
++
++// Profile form
++document.getElementById('profile-form').addEventListener('submit', async (e) => {
++ e.preventDefault();
++ try {
++ if (isAnonSession()) {
++ const anonToken = getAnonToken();
++ const data = await anonFetch('/api/auth/anon/' + encodeURIComponent(anonToken) + '/preferences', {
++ method: 'PUT',
++ body: JSON.stringify({
++ display_name: document.getElementById('pf-display-name').value,
++ }),
++ });
++ currentUser = {
++ ...currentUser,
++ display_name: data.preferences?.display_name || document.getElementById('pf-display-name').value || currentUser.display_name,
++ };
++ const accounts = getStoredAccounts().filter(a => !a.is_anon);
++ accounts.push({
++ id: 'anon',
++ username: currentUser.username,
++ display_name: currentUser.display_name,
++ avatar_url: null,
++ email: null,
++ is_anon: true,
++ anon_number: currentUser.anon_number,
++ token: getAnonToken(),
++ });
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ renderHeader(currentUser);
++ alert('Anonymous preferences updated!');
++ return;
++ }
++
++ // Display name may only re-case the username, not rename it.
++ const _dn = (document.getElementById('pf-display-name').value || '').trim();
++ if (currentUser?.username && _dn.toLowerCase() !== String(currentUser.username).toLowerCase()) {
++ alert('Your display name can only change the capitalization of your username (e.g. "' + currentUser.username + '" → "' + currentUser.username.charAt(0).toUpperCase() + currentUser.username.slice(1) + '").');
++ return;
++ }
++ const data = await apiFetch('/api/auth/profile', { method: 'PUT', body: JSON.stringify({
++ display_name: _dn,
++ bio: document.getElementById('pf-bio').value,
++ email: document.getElementById('pf-email').value || undefined,
++ profile_color: document.getElementById('pf-color').value,
++ })});
++ currentUser = data.user;
++ renderHeader(data.user);
++ alert('Profile updated!');
++ } catch (err) { alert('Error: ' + err.message); }
++});
++
++// Accounts
++function loadAccounts() {
++ const accounts = JSON.parse(localStorage.getItem('openvibe_accounts') || '[]');
++ const activeId = localStorage.getItem('openvibe_active_account');
++ const list = document.getElementById('account-list');
++ list.innerHTML = accounts.map(a => `
++ <div class="account-item ${String(a.id) === String(activeId) ? 'active' : ''}">
++ <img src="${avatarSrc(a, 64)}" alt="${a.display_name || a.username}" onerror="this.onerror=null;this.src='${avatarPlaceholder({ display_name: a.display_name, username: a.username, profile_color: a.profile_color }, 64)}'">
++ <div>
++ <div class="name">${a.display_name || a.username}${a.is_anon ? ' <i class="fa-solid fa-user-secret" style="font-size:12px"></i>' : ''}</div>
++ <div class="meta">${a.is_anon ? 'Anonymous #' + (a.anon_number || '?') : '@' + a.username}</div>
++ </div>
++ <div class="actions">
++ ${String(a.id) !== String(activeId) ? `<button class="btn btn-outline" onclick="switchAccount('${a.id}')">Switch</button>` : '<span style="font-size:11px;color:var(--success);font-weight:600">Active</span>'}
++ ${String(a.id) !== String(activeId) ? `<button class="btn btn-danger" onclick="removeAccount('${a.id}')"><i class="fa-solid fa-xmark"></i></button>` : ''}
++ </div>
++ </div>
++ `).join('');
++}
++
++function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
++function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }
++function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
++function signOutAll() { OpenVibeAccountSwitcher.logoutAll(); }
++
++// Notification preferences
++const CATEGORIES = ['social','chat','game','stream','economy','achievement','moderation','system','service','admin'];
++
++async function loadNotifPrefs() {
++ if (isAnonSession()) return;
++ try {
++ const data = await apiFetch('/api/notifications/preferences');
++ const prefsMap = {};
++ for (const p of data.preferences) prefsMap[p.category] = p;
++ const grid = document.getElementById('notif-prefs');
++ // Clear existing rows
++ grid.querySelectorAll('.pref-row').forEach(r => r.remove());
++ for (const cat of CATEGORIES) {
++ const p = prefsMap[cat] || { enabled: 1, sound: 1, toasts: 1, email: 0 };
++ const html = `
++ <div class="cat pref-row">${cat}</div>
++ <button class="toggle pref-row ${p.enabled ? 'on' : 'off'}" data-cat="${cat}" data-field="enabled" onclick="togglePref(this)"></button>
++ <button class="toggle pref-row ${p.sound ? 'on' : 'off'}" data-cat="${cat}" data-field="sound" onclick="togglePref(this)"></button>
++ <button class="toggle pref-row col-toast ${p.toasts ? 'on' : 'off'}" data-cat="${cat}" data-field="toasts" onclick="togglePref(this)"></button>
++ <button class="toggle pref-row col-email ${p.email ? 'on' : 'off'}" data-cat="${cat}" data-field="email" onclick="togglePref(this)"></button>
++ `;
++ grid.insertAdjacentHTML('beforeend', html);
++ }
++ } catch (err) { console.error('Failed to load notif prefs:', err); }
++}
++
++async function togglePref(btn) {
++ const cat = btn.dataset.cat;
++ const field = btn.dataset.field;
++ const isOn = btn.classList.contains('on');
++ btn.classList.toggle('on');
++ btn.classList.toggle('off');
++ try {
++ await apiFetch('/api/notifications/preferences', { method: 'PUT', body: JSON.stringify({ category: cat, [field]: !isOn }) });
++ } catch (err) { btn.classList.toggle('on'); btn.classList.toggle('off'); }
++}
++
++// Linked services
++async function loadLinked() {
++ if (isAnonSession()) return;
++ try {
++ const data = await apiFetch('/api/auth/me');
++ // We don't have a direct linked endpoint from auth/me, let's check from card
++ const card = await apiFetch('/api/auth/users/' + currentUser.id + '/card');
++ const list = document.getElementById('linked-list');
++ const icons = { openvibelive: '<i class="fa-solid fa-tv"></i>', openvibegames: '<i class="fa-solid fa-shield-halved"></i>', ovn: '<i class="fa-solid fa-screwdriver-wrench"></i>', openvibemaps: '<i class="fa-solid fa-map"></i>' };
++ const linked = card.user?.linked_services || [];
++ if (linked.length === 0) {
++ list.innerHTML = '<p style="color:var(--text-muted);font-size:13px">No services linked yet. Use OpenVibe.Live or Scraplandia with your account to link them automatically.</p>';
++ } else {
++ list.innerHTML = linked.map(l => `
++ <div class="linked-item">
++ <span class="svc-icon">${icons[l.service] || '<i class="fa-solid fa-link"></i>'}</span>
++ <div>
++ <div class="svc-name">${l.service}</div>
++ <div class="svc-user">${l.service_username || l.service_user_id}</div>
++ </div>
++ <span class="svc-status"><i class="fa-solid fa-check"></i> Linked</span>
++ </div>
++ `).join('');
++ }
++ } catch (err) { console.error('Failed to load linked:', err); }
++}
++
++// Sessions
++async function loadSessions() {
++ if (isAnonSession()) {
++ document.getElementById('sessions-list').innerHTML = '<p style="color:var(--text-muted);font-size:13px">Anonymous mode does not create device sessions.</p>';
++ return;
++ }
++ try {
++ const data = await apiFetch('/api/auth/sessions');
++ const list = document.getElementById('sessions-list');
++ if (data.sessions.length === 0) {
++ list.innerHTML = '<p style="color:var(--text-muted);font-size:13px">No active sessions.</p>';
++ } else {
++ list.innerHTML = data.sessions.map(s => `
++ <div class="linked-item">
++ <span class="svc-icon"><i class="fa-solid fa-mobile-screen"></i></span>
++ <div>
++ <div class="svc-name">${s.device_name || 'Unknown Device'}</div>
++ <div class="svc-user">IP: ${s.ip || '?'} · Last used: ${new Date(s.last_used).toLocaleDateString()}</div>
++ </div>
++ <button class="btn btn-danger" onclick="revokeSession(${s.id})">Revoke</button>
++ </div>
++ `).join('');
++ }
++ } catch (err) { document.getElementById('sessions-list').innerHTML = '<p style="color:var(--text-muted)">Unable to load sessions.</p>'; }
++}
++
++async function revokeSession(id) {
++ try { await apiFetch('/api/auth/sessions/' + id, { method: 'DELETE' }); loadSessions(); }
++ catch (err) { alert('Error: ' + err.message); }
++}
++
++async function revokeAllSessions() {
++ if (!confirm('Revoke all sessions? You will need to log in again on other devices.')) return;
++ try { await apiFetch('/api/auth/sessions', { method: 'DELETE' }); loadSessions(); }
++ catch (err) { alert('Error: ' + err.message); }
++}
++
++// Anon info
++async function loadAnonInfo() {
++ const anonToken = localStorage.getItem('openvibe_anon_token');
++ const container = document.getElementById('anon-info');
++ if (!anonToken && !currentUser?.anon_number) {
++ container.innerHTML = `
++ <p style="color:var(--text-muted);font-size:14px"><i class="fa-solid fa-user-secret"></i></p>
++ <p style="color:var(--text-muted);font-size:13px;margin-top:8px">You don't have an anonymous identity yet.</p>
++ <button class="btn btn-outline" onclick="createAnon()" style="margin-top:12px">Create Anonymous Identity</button>
++ `;
++ return;
++ }
++
++ if (currentUser?.is_anon) {
++ container.innerHTML = `
++ <p style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px">Current Anonymous Session</p>
++ <div class="number">#${currentUser.anon_number}</div>
++ <p style="font-size:12px;color:var(--text-muted)">You're currently browsing as an anonymous identity.</p>
++ `;
++ return;
++ }
++
++ if (currentUser?.anon_number) {
++ container.innerHTML = `
++ <p style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px">Your Anonymous Number</p>
++ <div class="number">#${currentUser.anon_number}</div>
++ <p style="font-size:12px;color:var(--text-muted)">This number is permanently linked to your account.</p>
++ `;
++ return;
++ }
++
++ try {
++ const data = await fetch(API + '/api/auth/anon/' + anonToken).then(r => r.json());
++ if (data.user) {
++ container.innerHTML = `
++ <p style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px">Your Anonymous Number</p>
++ <div class="number">#${data.user.anon_number}</div>
++ <div class="label">First seen ${new Date(data.user.first_seen).toLocaleDateString()}</div>
++ <div class="stats">
++ <div class="stat"><div class="val">${data.user.total_messages}</div><div class="key">Messages</div></div>
++ <div class="stat"><div class="val">${data.user.total_commands}</div><div class="key">Commands</div></div>
++ </div>
++ `;
++ }
++ } catch (err) {
++ container.innerHTML = '<p style="color:var(--text-muted)">Unable to load anonymous info.</p>';
++ }
++}
++
++async function createAnon() {
++ try {
++ const res = await fetch(API + '/api/auth/anon-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
++ const data = await res.json();
++ localStorage.setItem('openvibe_anon_token', data.token);
++ const accounts = getStoredAccounts().filter(a => !a.is_anon);
++ accounts.push({ id: 'anon', username: data.user.username, display_name: data.user.display_name, is_anon: true, anon_number: data.user.anon_number, token: data.token, avatar_url: null, email: null });
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ loadAnonInfo();
++ } catch (err) { alert('Error: ' + err.message); }
++}
++
++async function linkAnon() {
++ if (currentUser?.is_anon) { alert('Switch to a real account before linking this anonymous identity.'); return; }
++ const anonToken = localStorage.getItem('openvibe_anon_token');
++ if (!anonToken) { alert('No anonymous session to link.'); return; }
++ try {
++ await apiFetch('/api/auth/anon/' + anonToken + '/link', { method: 'POST' });
++ alert('Anonymous identity linked!');
++ location.reload();
++ } catch (err) { alert('Error: ' + err.message); }
++}
++
++// Password form
++document.getElementById('password-form').addEventListener('submit', async (e) => {
++ e.preventDefault();
++ if (isAnonSession()) { alert('Anonymous identities do not have passwords.'); return; }
++ const newPw = document.getElementById('sec-new-pw').value;
++ if (newPw !== document.getElementById('sec-confirm-pw').value) { alert('Passwords do not match'); return; }
++ try {
++ const data = await apiFetch('/api/auth/change-password', { method: 'POST', body: JSON.stringify({
++ current_password: document.getElementById('sec-current-pw').value,
++ new_password: newPw,
++ })});
++ localStorage.setItem('ov_token', data.token);
++ document.cookie = `ov_token=${data.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
++ alert(data.message || 'Password changed!');
++ document.getElementById('password-form').reset();
++ } catch (err) { alert('Error: ' + err.message); }
++});
++
++// Init
++OpenVibeAccountSwitcher.init({ apiBase: API });
++loadUser();
++buildDashboardShowcase();
++openInitialSection();
++</script>
++ <script src="https://openvibe.live/live-notify.js" async></script>
++</body>
++</html>
ddiff --git a/public/robots.txt b/public/robots.txt
nnew file mode 100644
iindex 00000000..808dd6e4
---- /dev/null
++++ b/public/robots.txt
@@@ -0,0 +1,72 @@
++# General Index Rules
++User-agent: *
++Allow: /
++Allow: /api/public/
++Allow: /api/brand/
++Allow: /api/health/
++
++# Disallow admin and internal paths
++Disallow: /admin/
++Disallow: /api/admin/
++Disallow: /api/internal/
++Disallow: /.well-known/
++Disallow: /node_modules/
++Disallow: /.git/
++Disallow: /build/
++Disallow: /dist/
++
++# Disallow transient/temp paths
++Disallow: /auth/
++Disallow: /oauth/
++Disallow: /logout/
++Disallow: /session/
++Disallow: /tmp/
++Disallow: /temp/
++
++# Aggressive Bot Rate Limiting
++User-agent: AhrefsBot
++Disallow: /
++Crawl-delay: 10
++
++User-agent: SemrushBot
++Disallow: /
++Crawl-delay: 10
++
++User-agent: DotBot
++Disallow: /
++Crawl-delay: 10
++
++User-agent: MJ12bot
++Disallow: /
++Crawl-delay: 30
++
++User-agent: AhrefsBot-Ahrefs
++Disallow: /
++Crawl-delay: 10
++
++User-agent: DotBot
++Disallow: /
++Crawl-delay: 10
++
++User-agent: MJ12bot
++Disallow: /
++Crawl-delay: 30
++
++# Friendly Bots - Normal Rate
++User-agent: Googlebot
++Crawl-delay: 0
++
++User-agent: Bingbot
++Crawl-delay: 1
++
++User-agent: Slurp
++Crawl-delay: 1
++
++User-agent: DuckDuckBot
++Crawl-delay: 1
++
++# Sitemaps
++Sitemap: https://openvibe.network/sitemap.xml
++
++# IndexNow Support (for rapid indexing)
++Host: https://openvibe.network
ddiff --git a/public/sitemap.xml b/public/sitemap.xml
nnew file mode 100644
iindex 00000000..f55664c8
---- /dev/null
++++ b/public/sitemap.xml
@@@ -0,0 +1,15 @@
++<?xml version="1.0" encoding="UTF-8"?>
++<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
++ <url>
++ <loc>https://openvibe.network/</loc>
++ <lastmod>2026-08-17</lastmod>
++ <changefreq>weekly</changefreq>
++ <priority>1.0</priority>
++ </url>
++ <url>
++ <loc>https://openvibe.network/login</loc>
++ <lastmod>2026-08-17</lastmod>
++ <changefreq>monthly</changefreq>
++ <priority>0.6</priority>
++ </url>
++</urlset>
ddiff --git a/server/admin/analytics-routes.js b/server/admin/analytics-routes.js
nnew file mode 100644
iindex 00000000..784320c6
---- /dev/null
++++ b/server/admin/analytics-routes.js
@@@ -0,0 +1,301 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Admin Panel — Analytics Routes
++// Mounted at /api/admin/analytics. Requires admin role.
++// Aggregates analytics from all OpenVibe services:
++// openvibe-network (local), OpenVibe.Live, OpenVibe.Tools,
++// OpenVibe.Games, OpenVibe.Media
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const router = express.Router();
++
++module.exports = function createAnalyticsRoutes(analytics, requireAuth, config) {
++
++ const INTERNAL_SECRET = 'openvibe-internal-2026';
++
++ // All remote services with their internal URLs and fetch strategy
++ const REMOTE_SERVICES = [
++ { name: 'live', label: 'OpenVibe.Live', url: config.services?.live?.internalUrl || 'http://127.0.0.1:3000', path: '/api/admin/analytics', auth: 'bearer' },
++ { name: 'tools', label: 'OpenVibe.Tools', url: config.services?.tools?.internalUrl || 'http://127.0.0.1:4001', path: '/api/internal/analytics', auth: 'internal' },
++ { name: 'games', label: 'OpenVibe.Games', url: config.services?.games?.internalUrl || 'http://127.0.0.1:8000', path: '/api/internal/analytics', auth: 'internal' },
++ { name: 'media', label: 'OpenVibe.Media', url: config.services?.media?.internalUrl || 'http://127.0.0.1:4100', path: '/api/internal/analytics', auth: 'internal' },
++ ];
++
++ function requireAdmin(req, res, next) {
++ if (!req.user || req.user.role !== 'admin') {
++ return res.status(403).json({ ok: false, error: 'Admin access required' });
++ }
++ next();
++ }
++
++ router.use(requireAuth, requireAdmin);
++
++ // ── Helper: fetch analytics from a remote service ────────
++ async function fetchRemoteAnalytics(svc, subPath, token, days, hours) {
++ try {
++ const qs = hours ? `days=${days}&hours=${hours}` : `days=${days}`;
++ const url = `${svc.url}${svc.path}${subPath}?${qs}`;
++ const headers = { 'Content-Type': 'application/json' };
++
++ if (svc.auth === 'internal') {
++ headers['X-Internal-Secret'] = INTERNAL_SECRET;
++ } else {
++ headers['Authorization'] = `Bearer ${token}`;
++ }
++
++ const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
++ if (!res.ok) return null;
++ return await res.json();
++ } catch (err) {
++ console.warn(`[Analytics] Failed to fetch from ${svc.name}:`, err.message);
++ return null;
++ }
++ }
++
++ // ═══════════════════════════════════════════════════════════
++ // Overview — Cross-Service Summary
++ // ═══════════════════════════════════════════════════════════
++
++ router.get('/overview', async (req, res) => {
++ try {
++ const days = Math.min(parseInt(req.query.days) || 30, 365);
++ const hours = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null;
++
++ // Fetch from all services in parallel
++ const promises = [
++ Promise.resolve({ ok: true, analytics: analytics.getStats({ days, hours }), name: 'openvibe-network', label: 'OpenVibe.Network' }),
++ ...REMOTE_SERVICES.map(svc =>
++ fetchRemoteAnalytics(svc, '', req.token, days, hours).then(result => ({
++ ...result, name: svc.name, label: svc.label,
++ }))
++ ),
++ ];
++
++ const results = await Promise.allSettled(promises);
++
++ // Build service list
++ const services = [];
++ const allDataSources = [];
++ for (const r of results) {
++ if (r.status !== 'fulfilled' || !r.value) continue;
++ const v = r.value;
++ const data = v.analytics;
++ if (!data) continue;
++
++ allDataSources.push({ ...data, _name: v.name, _label: v.label });
++ services.push({
++ name: v.name,
++ label: v.label,
++ ...data.summary,
++ realtime: data.realtime,
++ authBreakdown: data.authBreakdown || {},
++ });
++ }
++
++ // Combined totals
++ const totals = {
++ total_pageviews: services.reduce((s, v) => s + (v.total_pageviews || 0), 0),
++ total_api_calls: services.reduce((s, v) => s + (v.total_api_calls || 0), 0),
++ total_unique_visitors: services.reduce((s, v) => s + (v.total_unique_visitors || 0), 0),
++ total_unique_users: services.reduce((s, v) => s + (v.total_unique_users || 0), 0),
++ total_bot_hits: services.reduce((s, v) => s + (v.total_bot_hits || 0), 0),
++ total_errors: services.reduce((s, v) => s + (v.total_errors || 0), 0),
++ avg_response_ms: Math.round(services.reduce((s, v) => s + (v.avg_response_ms || 0), 0) / Math.max(services.length, 1)),
++ };
++
++ // Combine daily trends from all data sources
++ const dailyMap = new Map();
++ for (const src of allDataSources) {
++ if (!src?.daily) continue;
++ for (const d of src.daily) {
++ const existing = dailyMap.get(d.date) || { date: d.date, pageviews: 0, api_calls: 0, unique_visitors: 0, bot_hits: 0 };
++ existing.pageviews += d.pageviews || 0;
++ existing.api_calls += d.api_calls || 0;
++ existing.unique_visitors += d.unique_visitors || 0;
++ existing.bot_hits += d.bot_hits || 0;
++ dailyMap.set(d.date, existing);
++ }
++ }
++ const dailyTrend = Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date));
++
++ // Realtime across services
++ const realtimeTotal = {
++ requests: services.reduce((s, v) => s + (v.realtime?.requests || 0), 0),
++ visitors: services.reduce((s, v) => s + (v.realtime?.visitors || 0), 0),
++ bots: services.reduce((s, v) => s + (v.realtime?.bots || 0), 0),
++ };
++
++ // Aggregate enhanced metrics across services
++ const totalBandwidth = {
++ total_requests: 0, page_requests: 0, api_requests: 0, estimated_bytes: 0,
++ };
++ const totalSessions = allDataSources.reduce((s, d) => s + (d.sessionCount || 0), 0);
++ for (const src of allDataSources) {
++ if (src?.bandwidth) {
++ totalBandwidth.total_requests += src.bandwidth.total_requests || 0;
++ totalBandwidth.page_requests += src.bandwidth.page_requests || 0;
++ totalBandwidth.api_requests += src.bandwidth.api_requests || 0;
++ totalBandwidth.estimated_bytes += src.bandwidth.estimated_bytes || 0;
++ }
++ }
++
++ // Combine time buckets for sub-day views
++ const timeBucketMap = new Map();
++ for (const src of allDataSources) {
++ if (!src?.timeBuckets) continue;
++ for (const b of src.timeBuckets) {
++ const existing = timeBucketMap.get(b.bucket) || { bucket: b.bucket, pageviews: 0, api_calls: 0, unique_visitors: 0, bot_hits: 0, errors: 0 };
++ existing.pageviews += b.pageviews || 0;
++ existing.api_calls += b.api_calls || 0;
++ existing.unique_visitors += b.unique_visitors || 0;
++ existing.bot_hits += b.bot_hits || 0;
++ existing.errors += b.errors || 0;
++ timeBucketMap.set(b.bucket, existing);
++ }
++ }
++ const timeBuckets = Array.from(timeBucketMap.values()).sort((a, b) => a.bucket.localeCompare(b.bucket));
++
++ // Aggregate auth trend across services
++ const authTrendMap = new Map();
++ for (const src of allDataSources) {
++ if (!src?.authTrend) continue;
++ for (const b of src.authTrend) {
++ const existing = authTrendMap.get(b.bucket) || { bucket: b.bucket, auth_visitors: 0, anon_visitors: 0, auth_hits: 0, anon_hits: 0 };
++ existing.auth_visitors += b.auth_visitors || 0;
++ existing.anon_visitors += b.anon_visitors || 0;
++ existing.auth_hits += b.auth_hits || 0;
++ existing.anon_hits += b.anon_hits || 0;
++ authTrendMap.set(b.bucket, existing);
++ }
++ }
++ const authTrend = Array.from(authTrendMap.values()).sort((a, b) => a.bucket.localeCompare(b.bucket));
++
++ // Per-service hourly trend (for stacked charts)
++ const perServiceTrend = [];
++ for (const src of allDataSources) {
++ const trend = (hours && hours < 24 && src.timeBuckets?.length) ? src.timeBuckets : src.daily || [];
++ if (trend.length) {
++ perServiceTrend.push({ name: src._name, label: src._label, data: trend });
++ }
++ }
++
++ res.json({
++ ok: true,
++ overview: {
++ period_days: days,
++ period_hours: hours,
++ totals,
++ services,
++ dailyTrend,
++ timeBuckets,
++ realtime: realtimeTotal,
++ bandwidth: totalBandwidth,
++ sessionCount: totalSessions,
++ authTrend,
++ perServiceTrend,
++ },
++ });
++ } catch (err) {
++ console.error('[Analytics] Overview error:', err);
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════════
++ // Per-Service Detail
++ // ═══════════════════════════════════════════════════════════
++
++ router.get('/service/:name', async (req, res) => {
++ try {
++ const { name } = req.params;
++ const days = Math.min(parseInt(req.query.days) || 30, 365);
++ const hours = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null;
++ let data = null;
++
++ if (name === 'openvibe-network') {
++ data = analytics.getStats({ days, hours });
++ } else {
++ const svc = REMOTE_SERVICES.find(s => s.name === name);
++ if (svc) {
++ const remote = await fetchRemoteAnalytics(svc, '', req.token, days, hours);
++ data = remote?.analytics || null;
++ }
++ }
++
++ if (!data) {
++ return res.status(404).json({ ok: false, error: `No analytics data available for ${name}` });
++ }
++
++ res.json({ ok: true, analytics: data });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════════
++ // Bot Analysis
++ // ═══════════════════════════════════════════════════════════
++
++ router.get('/bots', async (req, res) => {
++ try {
++ const days = Math.min(parseInt(req.query.days) || 30, 365);
++
++ const promises = [
++ Promise.resolve({ ok: true, bots: analytics.getBotAnalysis(days), name: 'openvibe-network' }),
++ ...REMOTE_SERVICES.map(svc =>
++ fetchRemoteAnalytics(svc, '/bots', req.token, days).then(result => ({
++ ...result, name: svc.name,
++ }))
++ ),
++ ];
++
++ const results = await Promise.allSettled(promises);
++ const bots = {};
++ for (const r of results) {
++ if (r.status !== 'fulfilled' || !r.value?.bots) continue;
++ bots[r.value.name] = r.value.bots;
++ }
++
++ res.json({ ok: true, bots });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════════
++ // Real-time snapshot
++ // ═══════════════════════════════════════════════════════════
++
++ router.get('/realtime', (req, res) => {
++ try {
++ const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');
++ const db = analytics.db;
++
++ const realtime = db.prepare(`
++ SELECT
++ COUNT(*) as requests,
++ COUNT(DISTINCT ip) as visitors,
++ COUNT(*) FILTER (WHERE is_bot = 1) as bots,
++ COUNT(*) FILTER (WHERE event_type = 'pageview') as pageviews,
++ COUNT(*) FILTER (WHERE event_type = 'api_call') as api_calls
++ FROM analytics_events
++ WHERE created_at >= ?
++ `).get(fiveMinAgo);
++
++ const byPath = db.prepare(`
++ SELECT path, COUNT(*) as hits
++ FROM analytics_events
++ WHERE created_at >= ? AND is_bot = 0
++ GROUP BY path ORDER BY hits DESC LIMIT 10
++ `).all(fiveMinAgo);
++
++ res.json({ ok: true, realtime: { ...realtime, topPaths: byPath } });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ return router;
++};
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
nnew file mode 100644
iindex 00000000..676ceb4d
---- /dev/null
++++ b/server/admin/routes.js
@@@ -0,0 +1,846 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Admin Panel — API Routes
++// Mounted at /api/admin. Requires admin role.
++// Manages email config, site settings, user management, bulk
++// notifications, and system health.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const router = express.Router();
++const path = require('path');
++const fs = require('fs');
++const childProcess = require('child_process');
++const urlRegistry = require('../url-registry');
++const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
++const { isOwner, requireOwner, isSensitiveSettingKey, maskSecret } = require('../auth/owner-guard');
++const { checkAdminLimit, recordAdminAction } = require('../auth/admin-limits');
++
++const LOCAL_REFRESH_SERVICES = new Set(['network']);
++
++function getServiceRefreshInfo(req, serviceName) {
++ const servicesCfg = req.app.locals.config?.services || {};
++ const serviceCfg = servicesCfg[serviceName];
++
++ if (serviceName === 'network') {
++ return {
++ service: serviceName,
++ configured: true,
++ mode: 'local',
++ target: null,
++ description: 'Local OpenVibe.Network registry refresh',
++ };
++ }
++
++ if (!serviceCfg || !serviceCfg.internalUrl) {
++ return {
++ service: serviceName,
++ configured: false,
++ mode: 'not_configured',
++ target: null,
++ description: 'Service is not configured for refresh',
++ };
++ }
++
++ return {
++ service: serviceName,
++ configured: true,
++ mode: 'remote',
++ target: serviceCfg.internalUrl.replace(/\/?$/, '') + '/internal/url-registry/refresh',
++ description: `Remote refresh target for ${serviceName}`,
++ };
++}
++
++function applyResolvedRegistryToConfig(config, resolved) {
++ if (!resolved || typeof resolved !== 'object') return;
++ if (resolved.OV_NETWORK_URL?.value) {
++ config.networkUrl = resolved.OV_NETWORK_URL.value;
++ config.jwt.issuer = resolved.OV_NETWORK_URL.value;
++ if (!process.env.BASE_URL) config.baseUrl = resolved.OV_NETWORK_URL.value;
++ }
++ if (resolved.OV_NETWORK_LOGIN_URL?.value) {
++ config.loginUrl = resolved.OV_NETWORK_LOGIN_URL.value;
++ }
++ if (resolved.OV_NETWORK_INTERNAL_URL?.value) {
++ config.internalUrl = resolved.OV_NETWORK_INTERNAL_URL.value;
++ }
++ if (resolved.OV_LIVE_INTERNAL_URL?.value && config.services.live) config.services.live.internalUrl = resolved.OV_LIVE_INTERNAL_URL.value;
++ if (resolved.OV_TOOLS_INTERNAL_URL?.value && config.services.tools) config.services.tools.internalUrl = resolved.OV_TOOLS_INTERNAL_URL.value;
++ if (resolved.OV_GAMES_INTERNAL_URL?.value && config.services.games) config.services.games.internalUrl = resolved.OV_GAMES_INTERNAL_URL.value;
++ if (resolved.OV_MEDIA_INTERNAL_URL?.value && config.services.media) config.services.media.internalUrl = resolved.OV_MEDIA_INTERNAL_URL.value;
++}
++
++async function refreshService(req, serviceName) {
++ const info = getServiceRefreshInfo(req, serviceName);
++ if (info.mode === 'local') {
++ try {
++ const db = req.app.locals.db;
++ const config = req.app.locals.config;
++ const resolved = urlRegistry.getResolvedRegistry(db, process.env);
++ req.app.locals.urlRegistry = resolved;
++ applyResolvedRegistryToConfig(config, resolved);
++ return {
++ ok: true,
++ service: serviceName,
++ mode: 'local',
++ status: 200,
++ target: null,
++ message: 'Local registry refresh completed',
++ };
++ } catch (err) {
++ return {
++ ok: false,
++ service: serviceName,
++ mode: 'local',
++ status: 500,
++ target: null,
++ error: err.message,
++ };
++ }
++ }
++
++ if (info.mode === 'not_configured') {
++ return {
++ ok: false,
++ service: serviceName,
++ mode: 'not_configured',
++ status: null,
++ target: null,
++ error: 'Service is not configured for refresh',
++ };
++ }
++
++ const controller = new AbortController();
++ const timer = setTimeout(() => controller.abort(), 3000);
++ try {
++ const r = await fetch(info.target, {
++ method: 'POST',
++ headers: { 'X-Internal-Key': req.app.locals.config?.internalKey || '' },
++ signal: controller.signal,
++ });
++ const baseResult = {
++ ok: r.ok,
++ service: serviceName,
++ mode: 'remote',
++ status: r.status,
++ target: info.target,
++ };
++ if (r.ok) return baseResult;
++ if (r.status === 404) {
++ return {
++ ...baseResult,
++ ok: false,
++ mode: 'unsupported',
++ error: 'Refresh endpoint not supported',
++ };
++ }
++ return {
++ ...baseResult,
++ ok: false,
++ error: `Refresh failed with status ${r.status}`,
++ };
++ } catch (err) {
++ return {
++ ok: false,
++ service: serviceName,
++ mode: 'failed',
++ status: null,
++ target: info.target,
++ error: err.message,
++ };
++ } finally {
++ clearTimeout(timer);
++ }
++}
++
++async function refreshServiceByKey(req, key) {
++ const def = URL_DEFINITIONS[key];
++ const serviceName = def?.service;
++ if (!serviceName) {
++ return { ok: false, service: null, mode: 'unknown', error: `No service defined for key ${key}` };
++ }
++ return refreshService(req, serviceName);
++}
++
++function createAdminRoutes(db, notificationService, emailService, requireAuth) {
++
++ function getEmailMetrics() {
++ const summary = {
++ total: db.prepare('SELECT COUNT(*) AS count FROM email_delivery_log').get().count,
++ sent: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent'").get().count,
++ failed: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed'").get().count,
++ sent_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent' AND created_at >= datetime('now', '-1 day')").get().count,
++ failed_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed' AND created_at >= datetime('now', '-1 day')").get().count,
++ password_resets_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'password_reset' AND created_at >= datetime('now', '-1 day')").get().count,
++ notifications_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type LIKE 'notification:%' AND created_at >= datetime('now', '-1 day')").get().count,
++ tests_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'test' AND created_at >= datetime('now', '-1 day')").get().count,
++ last_sent_at: db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'sent' ORDER BY created_at DESC LIMIT 1").get()?.created_at || null,
++ last_failed_at: db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'failed' ORDER BY created_at DESC LIMIT 1").get()?.created_at || null,
++ };
++
++ const byType = db.prepare(`
++ SELECT email_type, status, COUNT(*) AS count
++ FROM email_delivery_log
++ WHERE created_at >= datetime('now', '-30 day')
++ GROUP BY email_type, status
++ ORDER BY count DESC, email_type ASC
++ LIMIT 20
++ `).all();
++
++ const recent = db.prepare(`
++ SELECT id, email_type, recipient, subject, status, error_message, created_at
++ FROM email_delivery_log
++ ORDER BY created_at DESC
++ LIMIT 20
++ `).all();
++
++ return { summary, by_type: byType, recent };
++ }
++
++ // ─── Admin middleware ──────────────────────────────────
++ function requireAdmin(req, res, next) {
++ if (!req.user || req.user.role !== 'admin') {
++ return res.status(403).json({ ok: false, error: 'Admin access required' });
++ }
++ next();
++ }
++
++ async function notifyServiceRefresh(req, key) {
++ return refreshServiceByKey(req, key);
++ }
++
++ // Push an authoritative role change to OpenVibe.Live so it takes effect
++ // immediately (chat Staff badge, moderation powers) instead of waiting on the
++ // user's next SSO token. Fire-and-forget — never blocks the admin response.
++ function pushRoleToStreamer(user, role) {
++ try {
++ const base = (process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
++ const key = process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY;
++ if (!key || !user) return;
++ fetch(`${base}/internal/user-role`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', 'X-Internal-Key': key },
++ body: JSON.stringify({ openvibenetwork_id: user.id, username: user.username, role }),
++ }).then(r => { if (!r.ok) console.warn('[AdminRole] streamer role push HTTP', r.status); })
++ .catch(e => console.warn('[AdminRole] streamer role push failed:', e.message));
++ } catch (e) {
++ console.warn('[AdminRole] streamer role push error:', e.message);
++ }
++ }
++
++ router.use(requireAuth, requireAdmin);
++
++ // ═══════════════════════════════════════════════════════
++ // Email Configuration (Resend)
++ // ═══════════════════════════════════════════════════════
++
++ // GET /api/admin/email — get current email config (owner-only: exposes key metadata)
++ router.get('/email', requireOwner, (req, res) => {
++ try {
++ const status = emailService.getStatus();
++ res.json({ ok: true, email: status, metrics: getEmailMetrics() });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // PUT /api/admin/email — update email config (owner-only: writes resend_api_key)
++ router.put('/email', requireOwner, (req, res) => {
++ try {
++ const { enabled, api_key, from_email, from_name,
++ from_email_openvibelive, from_email_openvibegames, from_email_openvibenetwork } = req.body;
++ const setSetting = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++
++ const tx = db.transaction(() => {
++ if (enabled !== undefined) setSetting.run('email_enabled', String(enabled), 'boolean');
++ // Only update API key if it's not a masked placeholder
++ if (api_key && !/\u2022/.test(api_key)) setSetting.run('resend_api_key', api_key, 'string');
++ if (from_email) setSetting.run('email_from_address', from_email, 'string');
++ if (from_name !== undefined) setSetting.run('email_from_name', from_name || 'OpenVibe', 'string');
++ // Per-service from addresses
++ if (from_email_openvibelive !== undefined) setSetting.run('email_from_openvibelive', from_email_openvibelive, 'string');
++ if (from_email_openvibegames !== undefined) setSetting.run('email_from_openvibegames', from_email_openvibegames, 'string');
++ if (from_email_openvibenetwork !== undefined) setSetting.run('email_from_openvibenetwork', from_email_openvibenetwork, 'string');
++ });
++ tx();
++
++ emailService.reload();
++
++ // Audit
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'email_config_update', JSON.stringify({ from_email })
++ );
++
++ res.json({ ok: true, email: emailService.getStatus() });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // POST /api/admin/email/test — send test email (owner-only, part of email config)
++ router.post('/email/test', requireOwner, async (req, res) => {
++ try {
++ const { email } = req.body;
++ if (!email) return res.status(400).json({ ok: false, error: 'Email required' });
++ await emailService.sendTestEmail(email);
++ res.json({ ok: true, sent: true });
++ } catch (err) {
++ // sendTestEmail throws with descriptive errors — surface them
++ res.status(400).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Site Settings
++ // ═══════════════════════════════════════════════════════
++
++ // Core site settings are owner-only (off-limits to admins).
++ router.get('/settings', requireOwner, (req, res) => {
++ try {
++ const rows = db.prepare('SELECT * FROM site_settings').all();
++ const settings = {};
++ // Keys managed exclusively by the Email tab — hide from generic settings
++ const EMAIL_MANAGED_KEYS = new Set([
++ 'ses_enabled', 'ses_region', 'ses_access_key_id', 'ses_secret_access_key',
++ 'ses_from_email', 'ses_from_name',
++ 'ses_from_email_openvibelive', 'ses_from_email_openvibegames', 'ses_from_email_openvibenetwork',
++ 'email_enabled', 'resend_api_key', 'email_from_address', 'email_from_name',
++ 'email_from_openvibelive', 'email_from_openvibegames', 'email_from_openvibenetwork',
++ ]);
++ const owner = isOwner(req.user);
++ for (const r of rows) {
++ if (EMAIL_MANAGED_KEYS.has(r.key)) continue;
++ // Secrets (API keys / tokens / credentials) are owner-only — mask for admins.
++ if (!owner && (r.type === 'secret' || isSensitiveSettingKey(r.key))) {
++ settings[r.key] = { value: maskSecret(r.value), type: r.type, redacted: true };
++ } else {
++ settings[r.key] = { value: r.value, type: r.type };
++ }
++ }
++ res.json({ ok: true, settings });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.put('/settings', requireOwner, (req, res) => {
++ try {
++ const { key, value, type } = req.body;
++ if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
++ // Secrets are owner-only — an admin cannot create or overwrite them.
++ if ((type === 'secret' || isSensitiveSettingKey(key)) && !isOwner(req.user)) {
++ return res.status(403).json({ ok: false, error: 'Owner access required to change this setting' });
++ }
++ // Prevent overwriting secrets with masked values
++ if (typeof value === 'string' && /^••••/.test(value)) {
++ return res.json({ ok: true, skipped: true });
++ }
++ db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(key, String(value), type || 'string');
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'setting_update', JSON.stringify({ key })
++ );
++ res.json({ ok: true });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════════════════════
++ // URL Registry
++
++ router.get('/url-registry', (req, res) => {
++ try {
++ let entries = urlRegistry.getAllRegistryEntries(db);
++ // Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.
++ if (!isOwner(req.user) && Array.isArray(entries)) {
++ entries = entries.map((e) => {
++ if (e && (e.type === 'secret' || isSensitiveSettingKey(e.key))) {
++ return { ...e, value: maskSecret(e.value), redacted: true };
++ }
++ return e;
++ });
++ }
++ res.json({ ok: true, entries });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // Reject secret-registry writes/clears from non-owners (Cloudflare token, etc.).
++ function guardRegistryKey(req, res, key) {
++ if (isSensitiveSettingKey(key) && !isOwner(req.user)) {
++ res.status(403).json({ ok: false, error: 'Owner access required to change this key' });
++ return false;
++ }
++ return true;
++ }
++
++ router.put('/url-registry', async (req, res) => {
++ try {
++ const { key, value } = req.body;
++ if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
++ if (value === undefined || value === null) return res.status(400).json({ ok: false, error: 'Value required' });
++ if (!guardRegistryKey(req, res, key)) return;
++ const entry = urlRegistry.setRegistryEntry(db, key, value, req.user.id);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'url_registry_update', JSON.stringify({ key })
++ );
++
++ const refreshResult = await notifyServiceRefresh(req, key);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'url_registry_refresh_request', JSON.stringify(refreshResult)
++ );
++ res.json({ ok: true, entry, refresh: refreshResult });
++ } catch (err) {
++ res.status(400).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.put('/url-registry/:key', async (req, res) => {
++ try {
++ const { key } = req.params;
++ const { value } = req.body;
++ if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
++ if (value === undefined || value === null) return res.status(400).json({ ok: false, error: 'Value required' });
++ if (!guardRegistryKey(req, res, key)) return;
++ const entry = urlRegistry.setRegistryEntry(db, key, value, req.user.id);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'url_registry_update', JSON.stringify({ key })
++ );
++
++ const refreshResult = await notifyServiceRefresh(req, key);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'url_registry_refresh_request', JSON.stringify(refreshResult)
++ );
++ res.json({ ok: true, entry, refresh: refreshResult });
++ } catch (err) {
++ res.status(400).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.delete('/url-registry/:key', (req, res) => {
++ try {
++ const { key } = req.params;
++ if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
++ if (!guardRegistryKey(req, res, key)) return;
++ urlRegistry.resetRegistryEntry(db, key);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'url_registry_reset', JSON.stringify({ key })
++ );
++ res.json({ ok: true, key });
++ } catch (err) {
++ res.status(400).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.post('/url-registry/refresh-all', async (req, res) => {
++ try {
++ const uniqueServices = new Set(Object.values(URL_DEFINITIONS)
++ .filter(def => !!def.service)
++ .map(def => def.service)
++ );
++ const refreshResults = [];
++ for (const serviceName of uniqueServices) {
++ const result = await refreshService(req, serviceName);
++ refreshResults.push({ service: serviceName, results: [result] });
++ }
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'url_registry_refresh_all', JSON.stringify(refreshResults)
++ );
++ res.json({ ok: true, refreshResults });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.post('/url-registry/:key/reset', (req, res) => {
++ try {
++ const { key } = req.params;
++ if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
++ if (!guardRegistryKey(req, res, key)) return;
++ urlRegistry.resetRegistryEntry(db, key);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'url_registry_reset', JSON.stringify({ key })
++ );
++ res.json({ ok: true, key });
++ } catch (err) {
++ res.status(400).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.post('/reset-db', requireOwner, (req, res) => {
++ try {
++ const serviceRoot = path.resolve(__dirname, '..');
++ const scriptPath = path.join(serviceRoot, 'server', 'reset-db.js');
++ if (!fs.existsSync(scriptPath)) {
++ return res.status(404).json({ ok: false, error: 'Reset script not found' });
++ }
++ const result = childProcess.spawnSync(process.execPath, [scriptPath], {
++ cwd: serviceRoot,
++ env: process.env,
++ encoding: 'utf8',
++ timeout: 30000,
++ });
++ if (result.error) throw result.error;
++ if (result.status !== 0) {
++ return res.status(500).json({ ok: false, error: result.stderr || result.stdout || `Exit code ${result.status}` });
++ }
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(req.user.id, 'reset_db', JSON.stringify({ output: result.stdout.trim() }));
++ res.json({ ok: true, message: result.stdout.trim() });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // Only the owner may grant admin — otherwise an admin could self-escalate here,
++ // bypassing the owner gate on PUT /users/:id/role.
++ router.post('/users/grant-admin', requireOwner, (req, res) => {
++ try {
++ const { id, username, email } = req.body;
++ if (!id && !username && !email) {
++ return res.status(400).json({ ok: false, error: 'id, username, or email is required' });
++ }
++
++ let user;
++ if (id) {
++ user = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(id);
++ } else if (username) {
++ user = db.prepare('SELECT id, username, email FROM users WHERE LOWER(username) = LOWER(?)').get(username);
++ } else {
++ user = db.prepare('SELECT id, username, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);
++ }
++ if (!user) {
++ return res.status(404).json({ ok: false, error: 'User not found' });
++ }
++
++ db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', user.id);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id,
++ 'grant_admin',
++ JSON.stringify({ targetId: user.id, targetUsername: user.username, targetEmail: user.email }),
++ );
++ pushRoleToStreamer(user, 'admin');
++ res.json({ ok: true, user: { id: user.id, username: user.username, email: user.email, role: 'admin' } });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // User Management
++ // ═══════════════════════════════════════════════════════
++
++ router.get('/users', (req, res) => {
++ try {
++ const limit = Math.min(parseInt(req.query.limit) || 50, 200);
++ const offset = parseInt(req.query.offset) || 0;
++ const search = req.query.search || '';
++
++ let users;
++ if (search) {
++ users = db.prepare(`
++ SELECT id, username, display_name, email, role, is_banned, created_at, last_seen
++ FROM users WHERE username LIKE ? OR display_name LIKE ? OR email LIKE ?
++ ORDER BY created_at DESC LIMIT ? OFFSET ?
++ `).all(`%${search}%`, `%${search}%`, `%${search}%`, limit, offset);
++ } else {
++ users = db.prepare(`
++ SELECT id, username, display_name, email, role, is_banned, created_at, last_seen
++ FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
++ `).all(limit, offset);
++ }
++
++ const total = db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;
++ res.json({ ok: true, users, total });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.put('/users/:id/role', (req, res) => {
++ try {
++ const { role } = req.body;
++ const validRoles = ['user', 'streamer', 'global_mod', 'admin'];
++ if (!validRoles.includes(role)) return res.status(400).json({ ok: false, error: 'Invalid role' });
++ // Only the network owner may grant/revoke the ADMIN role. Regular admins can
++ // still manage streamers / global mods / users, but not other admins.
++ const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
++ const isOwner = req.user && String(req.user.username || '').toLowerCase() === OWNER;
++ const target = db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id);
++ if (!isOwner && (role === 'admin' || (target && target.role === 'admin'))) {
++ return res.status(403).json({ ok: false, error: 'Only the owner can grant or change the admin role' });
++ }
++ db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'user_role_change', JSON.stringify({ targetId: req.params.id, role })
++ );
++ const changed = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id);
++ if (changed) pushRoleToStreamer(changed, role);
++ res.json({ ok: true });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.put('/users/:id/ban', (req, res) => {
++ try {
++ const { banned, reason } = req.body;
++ db.prepare('UPDATE users SET is_banned = ?, ban_reason = ? WHERE id = ?').run(banned ? 1 : 0, reason || null, req.params.id);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, banned ? 'user_ban' : 'user_unban', JSON.stringify({ targetId: req.params.id, reason })
++ );
++
++ // Notify the user
++ if (banned) {
++ notificationService.create({
++ user_id: parseInt(req.params.id),
++ type: 'BAN',
++ title: 'Account Suspended',
++ message: reason ? `Reason: ${reason}` : 'Your account has been suspended.',
++ priority: 'critical',
++ category: 'moderation',
++ });
++ }
++
++ res.json({ ok: true });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Broadcast Notifications
++ // ═══════════════════════════════════════════════════════
++
++ router.post('/broadcast', (req, res) => {
++ try {
++ const { title, message, icon, url, priority, category } = req.body;
++ if (!title) return res.status(400).json({ ok: false, error: 'Title required' });
++
++ // Abusable at volume — admins get one global notification per 6h; owners are exempt.
++ const limit = checkAdminLimit(db, req.user, 'broadcast');
++ if (!limit.ok) {
++ return res.status(429).json({
++ ok: false,
++ error: `Rate limited — you can send another global notification in ${limit.retryHuman}.`,
++ retryMs: limit.retryMs,
++ });
++ }
++
++ // Get all non-banned user IDs
++ const userIds = db.prepare('SELECT id FROM users WHERE is_banned = 0').all().map(u => u.id);
++ const results = notificationService.createBulk(userIds, {
++ type: 'ADMIN_BROADCAST',
++ title,
++ message,
++ icon: icon || '📢',
++ url,
++ priority: priority || 'normal',
++ category: category || 'admin',
++ sender_id: req.user.id,
++ sender_name: req.user.display_name || req.user.username,
++ service: 'network',
++ });
++
++ recordAdminAction(db, req.user, 'broadcast');
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'broadcast_notification', JSON.stringify({ title, recipients: results.length })
++ );
++
++ res.json({ ok: true, sent: results.length });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // System Health
++ // ═══════════════════════════════════════════════════════
++
++ router.get('/health', (req, res) => {
++ try {
++ const userCount = db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;
++ const notifCount = db.prepare('SELECT COUNT(*) as cnt FROM notifications').get().cnt;
++ const unreadCount = db.prepare('SELECT COUNT(*) as cnt FROM notifications WHERE is_read = 0').get().cnt;
++ const anonCount = db.prepare('SELECT COUNT(*) as cnt FROM anon_users').get().cnt;
++ const sessionCount = db.prepare('SELECT COUNT(*) as cnt FROM user_sessions WHERE is_active = 1').get().cnt;
++ const emailStatus = emailService.getStatus();
++
++ res.json({
++ ok: true,
++ health: {
++ users: userCount,
++ anon_users: anonCount,
++ active_sessions: sessionCount,
++ total_notifications: notifCount,
++ unread_notifications: unreadCount,
++ email_enabled: emailStatus.enabled,
++ uptime: process.uptime(),
++ memory: process.memoryUsage(),
++ },
++ });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Audit Log
++ // ═══════════════════════════════════════════════════════
++
++ router.get('/audit', (req, res) => {
++ try {
++ const limit = Math.min(parseInt(req.query.limit) || 50, 500);
++ const offset = parseInt(req.query.offset) || 0;
++ const rows = db.prepare(`
++ SELECT a.*, u.username FROM audit_log a
++ LEFT JOIN users u ON u.id = a.user_id
++ ORDER BY a.created_at DESC LIMIT ? OFFSET ?
++ `).all(limit, offset);
++ // Historical rows may hold secret values in `details` (older registry/
++ // config writes logged the raw value). Scrub those for non-owners.
++ if (!isOwner(req.user)) {
++ const SECRET_ACTIONS = /(registry|setting|config|email|discord|net_|deploy)/i;
++ for (const r of rows) {
++ if (r.details && SECRET_ACTIONS.test(r.action || '')) r.details = '"[redacted]"';
++ }
++ }
++ res.json({ ok: true, entries: rows });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Verification Keys (reserved username claims)
++ // ═══════════════════════════════════════════════════════
++
++ router.get('/verification-keys', (req, res) => {
++ try {
++ const keys = db.getAllVerificationKeys();
++ res.json({ ok: true, keys });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.post('/verification-keys', (req, res) => {
++ try {
++ const { target_username, note } = req.body;
++ if (!target_username) {
++ return res.status(400).json({ ok: false, error: 'Target username is required' });
++ }
++ if (!/^[a-zA-Z0-9_]+$/.test(target_username) || target_username.length < 3 || target_username.length > 24) {
++ return res.status(400).json({ ok: false, error: 'Invalid username format (3-24 chars, alphanumeric + underscore)' });
++ }
++
++ // Check if username already taken
++ const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(target_username);
++ if (existingUser) {
++ return res.status(409).json({ ok: false, error: `Username "${target_username}" is already registered` });
++ }
++
++ // Check for duplicate active key
++ const existingKey = db.getVerificationKeyByUsername(target_username);
++ if (existingKey) {
++ return res.status(409).json({ ok: false, error: `Active key already exists for "${target_username}"` });
++ }
++
++ // Generate OPENVIBE-XXXX-XXXX-XXXX
++ const crypto = require('crypto');
++ const key = 'OPENVIBE-' + [4, 4, 4].map(() =>
++ crypto.randomBytes(2).toString('hex').toUpperCase()
++ ).join('-');
++
++ db.createVerificationKey({
++ key,
++ target_username,
++ note: note || '',
++ created_by: req.user.id,
++ });
++
++ const created = db.getVerificationKeyByKey(key);
++
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'verification_key_create', JSON.stringify({ key, target_username })
++ );
++
++ res.status(201).json({ ok: true, key: created });
++ } catch (err) {
++ console.error('[Admin] Verification key error:', err.message);
++ res.status(500).json({ ok: false, error: 'Failed to generate key' });
++ }
++ });
++
++ router.delete('/verification-keys/:id', (req, res) => {
++ try {
++ const result = db.revokeVerificationKey(req.params.id);
++ if (result.changes === 0) {
++ return res.status(404).json({ ok: false, error: 'Key not found or already used/revoked' });
++ }
++
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'verification_key_revoke', JSON.stringify({ keyId: req.params.id })
++ );
++
++ res.json({ ok: true, message: 'Verification key revoked' });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to revoke key' });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Net.OpenVibe — Network Tools Config
++ // ═══════════════════════════════════════════════════════
++
++ const NET_SETTING_KEYS = [
++ 'net.ipinfo_token', // ipinfo.io API token (optional, 50k/month free)
++ 'net.globalping_token', // Globalping API token (optional, 100 credits/hr free)
++ ];
++
++ router.get('/net-config', requireOwner, (req, res) => {
++ try {
++ const config = {};
++ for (const key of NET_SETTING_KEYS) {
++ const row = db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key);
++ config[key] = row ? row.value : '';
++ }
++ // Mask tokens for display
++ for (const key of NET_SETTING_KEYS) {
++ if (config[key] && config[key].length > 4) {
++ config[key] = '••••' + config[key].slice(-4);
++ }
++ }
++ res.json({ ok: true, config, keys: NET_SETTING_KEYS });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.put('/net-config', requireOwner, (req, res) => {
++ try {
++ const { key, value } = req.body;
++ if (!key || !NET_SETTING_KEYS.includes(key)) {
++ return res.status(400).json({ ok: false, error: 'Invalid setting key' });
++ }
++ // Skip if masked
++ if (value?.startsWith('••••')) return res.json({ ok: true, skipped: true });
++ db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(key, String(value), 'secret');
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'net_config_update', JSON.stringify({ key })
++ );
++ res.json({ ok: true });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ return router;
++};
++
++module.exports = createAdminRoutes;
++module.exports._getServiceRefreshInfo = getServiceRefreshInfo;
++module.exports._refreshService = refreshService;
++module.exports._refreshServiceByKey = refreshServiceByKey;
ddiff --git a/server/auth/admin-limits.js b/server/auth/admin-limits.js
nnew file mode 100644
iindex 00000000..19cca00a
---- /dev/null
++++ b/server/auth/admin-limits.js
@@@ -0,0 +1,69 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Admin action rate limits.
++//
++// Admins run the network day-to-day but some actions are abusable at volume
++// (mass notifications, etc.). Those get a per-admin cooldown. The OWNER is
++// always exempt. Add a new limit by dropping one entry in ADMIN_LIMITS and
++// calling checkAdminLimit()/recordAdminAction() around the action.
++// ═══════════════════════════════════════════════════════════════
++
++const { isOwner } = require('./owner-guard');
++
++const HOUR = 60 * 60 * 1000;
++
++// action key -> cooldown window (ms). One successful action per window per admin.
++const ADMIN_LIMITS = {
++ broadcast: 6 * HOUR, // global notification to all users
++};
++
++let _ensured = false;
++function ensureTable(db) {
++ if (_ensured) return;
++ db.exec(`CREATE TABLE IF NOT EXISTS admin_rate_limits (
++ user_id INTEGER NOT NULL,
++ action TEXT NOT NULL,
++ last_at INTEGER NOT NULL,
++ PRIMARY KEY (user_id, action)
++ )`);
++ _ensured = true;
++}
++
++function humanizeMs(ms) {
++ const mins = Math.ceil(ms / 60000);
++ if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'}`;
++ const hrs = Math.floor(mins / 60);
++ const rem = mins % 60;
++ return rem ? `${hrs}h ${rem}m` : `${hrs} hour${hrs === 1 ? '' : 's'}`;
++}
++
++// Check (without consuming) whether `user` may perform `action` now.
++// Returns { ok:true } or { ok:false, retryMs, retryHuman }. Owners always ok.
++function checkAdminLimit(db, user, action) {
++ if (isOwner(user)) return { ok: true };
++ const windowMs = ADMIN_LIMITS[action];
++ if (!windowMs) return { ok: true };
++ ensureTable(db);
++ const row = db.prepare('SELECT last_at FROM admin_rate_limits WHERE user_id = ? AND action = ?').get(user.id, action);
++ if (row && row.last_at) {
++ const elapsed = Date.now() - row.last_at;
++ if (elapsed < windowMs) {
++ const retryMs = windowMs - elapsed;
++ return { ok: false, retryMs, retryHuman: humanizeMs(retryMs) };
++ }
++ }
++ return { ok: true };
++}
++
++// Start the cooldown after a successful action. No-op for owners / unlimited actions.
++function recordAdminAction(db, user, action) {
++ if (isOwner(user)) return;
++ if (!ADMIN_LIMITS[action]) return;
++ ensureTable(db);
++ db.prepare(`INSERT INTO admin_rate_limits (user_id, action, last_at) VALUES (?, ?, ?)
++ ON CONFLICT(user_id, action) DO UPDATE SET last_at = excluded.last_at`)
++ .run(user.id, action, Date.now());
++}
++
++module.exports = { ADMIN_LIMITS, checkAdminLimit, recordAdminAction, humanizeMs };
ddiff --git a/server/auth/discord-link.js b/server/auth/discord-link.js
nnew file mode 100644
iindex 00000000..c6fd3b0e
---- /dev/null
++++ b/server/auth/discord-link.js
@@@ -0,0 +1,191 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Discord Account Linking — OAuth2 flow for linking Discord
++// identity to a OpenVibe account.
++// Mounted at /api/auth/discord
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const crypto = require('crypto');
++const router = express.Router();
++
++const DISCORD_API = 'https://discord.com/api/v10';
++
++// Temporary state storage for OAuth CSRF protection (in-memory, short-lived)
++const _pendingStates = new Map();
++const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
++
++// Clean stale states periodically
++setInterval(() => {
++ const now = Date.now();
++ for (const [state, data] of _pendingStates) {
++ if (now - data.created > STATE_TTL_MS) _pendingStates.delete(state);
++ }
++}, 60 * 1000);
++
++/**
++ * GET /api/auth/discord/link — Start Discord OAuth2 flow
++ * Requires authenticated user. Redirects to Discord authorization.
++ */
++router.get('/link', (req, res) => {
++ const db = req.app.locals.db;
++ const clientId = db.getSetting('discord_oauth_client_id');
++ if (!clientId) {
++ return res.status(503).json({ error: 'Discord linking is not configured' });
++ }
++
++ // Verify auth
++ const user = req.user;
++ if (!user) return res.status(401).json({ error: 'Authentication required' });
++
++ const state = crypto.randomBytes(24).toString('hex');
++ _pendingStates.set(state, { userId: user.id, created: Date.now() });
++
++ const redirectUri = `${req.app.locals.config.baseUrl}/api/auth/discord/callback`;
++ const params = new URLSearchParams({
++ client_id: clientId,
++ redirect_uri: redirectUri,
++ response_type: 'code',
++ scope: 'identify',
++ state,
++ });
++
++ res.redirect(`https://discord.com/oauth2/authorize?${params}`);
++});
++
++/**
++ * GET /api/auth/discord/callback — Discord OAuth2 callback
++ * Exchanges code for token, fetches Discord user, links account.
++ */
++router.get('/callback', async (req, res) => {
++ const { code, state, error: oauthError } = req.query;
++
++ if (oauthError) {
++ return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=${encodeURIComponent(oauthError)}`);
++ }
++
++ if (!state || !_pendingStates.has(state)) {
++ return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=invalid_state`);
++ }
++
++ const stateData = _pendingStates.get(state);
++ _pendingStates.delete(state);
++
++ if (Date.now() - stateData.created > STATE_TTL_MS) {
++ return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=expired`);
++ }
++
++ const db = req.app.locals.db;
++ const clientId = db.getSetting('discord_oauth_client_id');
++ const clientSecret = db.getSetting('discord_oauth_client_secret');
++ if (!clientId || !clientSecret) {
++ return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=not_configured`);
++ }
++
++ try {
++ // Exchange code for token
++ const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({
++ client_id: clientId,
++ client_secret: clientSecret,
++ grant_type: 'authorization_code',
++ code,
++ redirect_uri: `${req.app.locals.config.baseUrl}/api/auth/discord/callback`,
++ }),
++ });
++
++ if (!tokenRes.ok) {
++ return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=token_exchange_failed`);
++ }
++
++ const tokenData = await tokenRes.json();
++
++ // Fetch Discord user info
++ const userRes = await fetch(`${DISCORD_API}/users/@me`, {
++ headers: { Authorization: `Bearer ${tokenData.access_token}` },
++ });
++
++ if (!userRes.ok) {
++ return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=user_fetch_failed`);
++ }
++
++ const discordUser = await userRes.json();
++
++ // Check if this Discord account is already linked to a different user
++ const existing = db.prepare(
++ "SELECT user_id FROM linked_accounts WHERE service = 'discord' AND service_user_id = ?"
++ ).get(discordUser.id);
++
++ if (existing && existing.user_id !== stateData.userId) {
++ return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=already_linked_other`);
++ }
++
++ // Link the account
++ db.prepare(`
++ INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at)
++ VALUES (?, 'discord', ?, ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(user_id, service) DO UPDATE SET
++ service_user_id = ?,
++ service_username = ?,
++ linked_at = CURRENT_TIMESTAMP
++ `).run(
++ stateData.userId,
++ discordUser.id,
++ `${discordUser.username}#${discordUser.discriminator || '0'}`,
++ discordUser.id,
++ `${discordUser.username}#${discordUser.discriminator || '0'}`,
++ );
++
++ // Audit log
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ stateData.userId, 'discord_linked', `Discord: ${discordUser.username} (${discordUser.id})`
++ );
++
++ return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=success`);
++ } catch (err) {
++ console.error('[Discord OAuth] Callback error:', err);
++ return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=internal`);
++ }
++});
++
++/**
++ * DELETE /api/auth/discord/link — Unlink Discord account
++ * Requires authenticated user.
++ */
++router.delete('/link', (req, res) => {
++ if (!req.user) return res.status(401).json({ error: 'Authentication required' });
++
++ const db = req.app.locals.db;
++ const result = db.prepare(
++ "DELETE FROM linked_accounts WHERE user_id = ? AND service = 'discord'"
++ ).run(req.user.id);
++
++ if (result.changes > 0) {
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'discord_unlinked', 'Discord account unlinked'
++ );
++ }
++
++ res.json({ ok: true, unlinked: result.changes > 0 });
++});
++
++/**
++ * GET /api/auth/discord/status — Check if Discord is linked
++ */
++router.get('/status', (req, res) => {
++ if (!req.user) return res.status(401).json({ error: 'Authentication required' });
++
++ const db = req.app.locals.db;
++ const linked = db.prepare(
++ "SELECT service_user_id, service_username, linked_at FROM linked_accounts WHERE user_id = ? AND service = 'discord'"
++ ).get(req.user.id);
++
++ const configured = !!(db.getSetting('discord_oauth_client_id') && db.getSetting('discord_oauth_client_secret'));
++
++ res.json({ ok: true, configured, linked: linked || null });
++});
++
++module.exports = router;
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
nnew file mode 100644
iindex 00000000..32f6cc0c
---- /dev/null
++++ b/server/auth/oauth-routes.js
@@@ -0,0 +1,251 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — OAuth2 Authorization Server Routes
++// Implements Authorization Code flow for cross-domain SSO.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const router = express.Router();
++
++function getDb(req) { return req.app.locals.db; }
++function getConfig(req) { return req.app.locals.config; }
++
++// ── GET /authorize ───────────────────────────────────────────
++// OAuth2 authorization endpoint. Always shows the account chooser
++// so the user can pick which account to continue with, add a new
++// one, or create an account.
++router.get('/authorize', (req, res) => {
++ const db = getDb(req);
++ const { client_id, redirect_uri, response_type, scope, state } = req.query;
++
++ if (response_type !== 'code') {
++ return res.status(400).json({ error: 'Only response_type=code is supported' });
++ }
++ if (!client_id || !redirect_uri) {
++ return res.status(400).json({ error: 'client_id and redirect_uri are required' });
++ }
++
++ // Validate client
++ const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
++ if (!client) return res.status(400).json({ error: 'Unknown client_id' });
++
++ const allowedUris = JSON.parse(client.redirect_uris || '[]');
++ const uriLower = redirect_uri.toLowerCase();
++ if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
++ return res.status(400).json({ error: 'Invalid redirect_uri' });
++ }
++
++ // Always redirect to login/chooser — let the user pick which account to use
++ const loginParams = new URLSearchParams({
++ client_id,
++ client_name: client.name || client_id,
++ redirect_uri,
++ response_type,
++ scope: scope || 'profile theme',
++ state: state || '',
++ });
++ res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
++});
++
++// ── POST /oauth/confirm ─────────────────────────────────────
++// Called from the account chooser UI. Accepts an openvibe.network JWT
++// token and OAuth params, validates everything, issues an
++// authorization code, and returns the redirect URL.
++router.post('/confirm', (req, res) => {
++ const db = getDb(req);
++ const config = getConfig(req);
++ const { token, client_id, redirect_uri, scope, state } = req.body;
++
++ if (!token || !client_id || !redirect_uri) {
++ return res.status(400).json({ error: 'token, client_id, and redirect_uri are required' });
++ }
++
++ // Validate client
++ const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
++ if (!client) return res.status(400).json({ error: 'Unknown client_id' });
++
++ const allowedUris = JSON.parse(client.redirect_uris || '[]');
++ const uriLower = redirect_uri.toLowerCase();
++ if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
++ return res.status(400).json({ error: 'Invalid redirect_uri' });
++ }
++
++ // Verify the token
++ const publicKey = req.app.locals.publicKey;
++ const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
++ let decoded;
++ try {
++ decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
++ } catch {
++ return res.status(401).json({ error: 'Invalid or expired token' });
++ }
++
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
++ if (!user || user.is_banned) {
++ return res.status(403).json({ error: 'User not found or banned' });
++ }
++
++ // Issue authorization code
++ const code = crypto.randomBytes(32).toString('hex');
++ const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
++ db.prepare(`
++ INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?)
++ `).run(code, client_id, user.id, redirect_uri, scope || 'profile theme', expiresAt);
++
++ // Also set cookie to this account so openvibe.network itself knows the active session.
++ // Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.
++ res.cookie('ov_token', token, { httpOnly: false, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: true, path: '/' });
++
++ const sep = redirect_uri.includes('?') ? '&' : '?';
++ res.json({ redirect: `${redirect_uri}${sep}code=${code}&state=${state || ''}` });
++});
++
++// ── POST /token ──────────────────────────────────────────────
++// OAuth2 token endpoint. Exchanges authorization codes or
++// refresh tokens for access tokens.
++router.post('/token', (req, res) => {
++ const db = getDb(req);
++ const config = getConfig(req);
++ const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
++
++ // Validate client credentials
++ const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
++ if (!client || client.client_secret !== client_secret) {
++ return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
++ }
++
++ if (grant_type === 'authorization_code') {
++ return handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri);
++ } else if (grant_type === 'refresh_token') {
++ return handleRefreshGrant(db, config, req, res, client, refresh_token);
++ } else {
++ return res.status(400).json({ error: 'unsupported_grant_type' });
++ }
++});
++
++function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri) {
++ if (!code) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing code' });
++
++ const authCode = db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(code);
++ if (!authCode) return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid authorization code' });
++ if (authCode.used) return res.status(400).json({ error: 'invalid_grant', error_description: 'Code already used' });
++ if (authCode.client_id !== client.client_id) return res.status(400).json({ error: 'invalid_grant', error_description: 'Client mismatch' });
++ if (authCode.redirect_uri !== redirectUri) return res.status(400).json({ error: 'invalid_grant', error_description: 'Redirect URI mismatch' });
++
++ const now = new Date();
++ const expiresAt = new Date(authCode.expires_at + (authCode.expires_at.includes('Z') ? '' : 'Z'));
++ if (now > expiresAt) return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code expired' });
++
++ // Mark code as used
++ db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?').run(code);
++
++ // Get user
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);
++ if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
++
++ // Issue tokens
++ const { accessToken, refreshToken } = issueTokenPair(db, config, req, user, client);
++
++ // Get preferences for theme sync
++ const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
++
++ const { password_hash, token_valid_after, ...safeUser } = user;
++ res.json({
++ access_token: accessToken,
++ refresh_token: refreshToken,
++ token_type: 'Bearer',
++ expires_in: 86400, // 24h
++ scope: authCode.scope,
++ user: safeUser,
++ preferences: prefs || { theme_id: 'vibe' },
++ });
++}
++
++function handleRefreshGrant(db, config, req, res, client, refreshToken) {
++ if (!refreshToken) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
++
++ const stored = db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(refreshToken);
++ if (!stored) return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid refresh token' });
++ if (stored.revoked) return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token revoked' });
++ if (stored.client_id !== client.client_id) return res.status(400).json({ error: 'invalid_grant', error_description: 'Client mismatch' });
++
++ const now = new Date();
++ const expiresAt = new Date(stored.expires_at + (stored.expires_at.includes('Z') ? '' : 'Z'));
++ if (now > expiresAt) return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token expired' });
++
++ // Revoke old refresh token (rotation)
++ db.prepare('UPDATE oauth_tokens SET revoked = 1 WHERE id = ?').run(stored.id);
++
++ // Get user
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(stored.user_id);
++ if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
++
++ // Issue new token pair
++ const { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client);
++
++ res.json({
++ access_token: accessToken,
++ refresh_token: newRefresh,
++ token_type: 'Bearer',
++ expires_in: 86400,
++ scope: stored.scope,
++ });
++}
++
++function issueTokenPair(db, config, req, user, client) {
++ const privateKey = req.app.locals.privateKey;
++ const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
++
++ const accessToken = jwt.sign(
++ {
++ sub: user.id,
++ id: user.id,
++ username: user.username,
++ display_name: user.display_name || user.username,
++ role: user.role,
++ avatar_url: user.avatar_url,
++ profile_color: user.profile_color,
++ },
++ privateKey,
++ {
++ algorithm,
++ issuer: config.jwt.issuer,
++ audience: ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'],
++ expiresIn: config.jwt.accessTokenExpiry,
++ }
++ );
++
++ const refreshTokenValue = crypto.randomBytes(48).toString('hex');
++ const refreshExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
++ db.prepare(`
++ INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at)
++ VALUES (?, ?, ?, 'profile theme', ?)
++ `).run(refreshTokenValue, client.client_id, user.id, refreshExpires);
++
++ return { accessToken, refreshToken: refreshTokenValue };
++}
++
++// ── GET /.well-known/openid-configuration ────────────────────
++// Discovery endpoint for OIDC-compatible clients.
++router.get('/.well-known/openid-configuration', (req, res) => {
++ const config = getConfig(req);
++ res.json({
++ issuer: config.jwt.issuer,
++ authorization_endpoint: `${config.baseUrl}/oauth/authorize`,
++ token_endpoint: `${config.baseUrl}/oauth/token`,
++ userinfo_endpoint: `${config.baseUrl}/api/auth/me`,
++ jwks_uri: `${config.baseUrl}/api/.well-known/jwks`,
++ response_types_supported: ['code'],
++ grant_types_supported: ['authorization_code', 'refresh_token'],
++ subject_types_supported: ['public'],
++ id_token_signing_alg_values_supported: ['RS256'],
++ scopes_supported: ['profile', 'theme', 'openid'],
++ token_endpoint_auth_methods_supported: ['client_secret_post'],
++ });
++});
++
++module.exports = router;
ddiff --git a/server/auth/owner-guard.js b/server/auth/owner-guard.js
nnew file mode 100644
iindex 00000000..b334bd5b
---- /dev/null
++++ b/server/auth/owner-guard.js
@@@ -0,0 +1,55 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Owner guard + secret-key detection for openvibe.network.
++//
++// Owner = the configured OpenVibe owner account (OWNER_USERNAME,
++// default 'goosely'). Admins run the network day-to-day but must NOT be
++// able to view or change API keys / payment credentials / other secrets.
++// This module centralises that rule so every secret-touching route uses
++// the same definition (mirrors OpenVibe.Live's server/auth/permissions.js).
++// ═══════════════════════════════════════════════════════════════
++
++function ownerName() {
++ return (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
++}
++
++function isOwner(user) {
++ return !!(user && user.username && String(user.username).toLowerCase() === ownerName());
++}
++
++function requireOwner(req, res, next) {
++ if (!isOwner(req.user)) {
++ return res.status(403).json({ ok: false, error: 'Owner access required' });
++ }
++ next();
++}
++
++// A settings/registry key is "sensitive" if its name looks like a credential.
++const SENSITIVE_RE = /(api[_-]?key|secret|token|password|client_id|client_secret|service_account|private[_-]?key|access[_-]?key|webhook_secret)/i;
++const SENSITIVE_PREFIXES = [
++ 'ai_', 'stripe_', 'paypal_', 'ccbill_', 'crypto_', 'tts_',
++ 'ses_', 'aws_', 's3_', 'b2_', 'backblaze', 'smtp_',
++ 'deploy_cloudflare', 'discord_bot', 'discord_oauth', 'resend_', 'net.',
++];
++const SENSITIVE_EXACT = new Set([
++ 'youtube_api_key', 'resend_api_key',
++ 'discord_bot_token', 'discord_oauth_client_secret',
++]);
++
++function isSensitiveSettingKey(key) {
++ if (!key) return false;
++ const k = String(key).toLowerCase();
++ if (SENSITIVE_EXACT.has(k)) return true;
++ if (SENSITIVE_PREFIXES.some((p) => k.startsWith(p))) return true;
++ return SENSITIVE_RE.test(k);
++}
++
++function maskSecret(v) {
++ if (v == null) return v;
++ const s = String(v);
++ if (!s) return s;
++ return s.length <= 8 ? '••••••••' : '••••' + s.slice(-4);
++}
++
++module.exports = { isOwner, requireOwner, isSensitiveSettingKey, maskSecret, ownerName };
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
nnew file mode 100644
iindex 00000000..40729c07
---- /dev/null
++++ b/server/auth/routes.js
@@@ -0,0 +1,834 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — Auth API Routes
++// Registration, login, profile management, password changes.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const bcrypt = require('bcryptjs');
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const { v4: uuidv4 } = require('uuid');
++const router = express.Router();
++
++const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
++
++// ── Helpers ──────────────────────────────────────────────────
++
++function getDb(req) { return req.app.locals.db; }
++function getConfig(req) { return req.app.locals.config; }
++
++function hashResetToken(token) {
++ return crypto.createHash('sha256').update(String(token)).digest('hex');
++}
++
++function buildToolsBaseUrl(req) {
++ return (process.env.OV_NETWORK_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
++}
++
++function findValidResetToken(db, token) {
++ return db.prepare(`
++ SELECT prt.id, prt.user_id, prt.expires_at, u.username, u.display_name, u.email
++ FROM password_reset_tokens prt
++ JOIN users u ON u.id = prt.user_id
++ WHERE prt.token_hash = ?
++ AND prt.used_at IS NULL
++ AND prt.expires_at > CURRENT_TIMESTAMP
++ LIMIT 1
++ `).get(hashResetToken(token));
++}
++
++// Contract audience (CONTRACTS.md): every first-party service domain.
++const DEFAULT_AUDIENCE = ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'];
++
++function signToken(user, privateKey, config) {
++ const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
++ // Audience always includes the contract service domains, plus the issuer
++ // host and any registry-configured service hosts (white-label installs).
++ const audienceSet = new Set(DEFAULT_AUDIENCE);
++ try {
++ audienceSet.add(new URL(config.jwt.issuer).hostname);
++ } catch { /* malformed issuer — defaults stand */ }
++ const registry = config._registry || {};
++ for (const key of ['OV_LIVE_URL', 'OV_TOOLS_URL', 'OV_GAMES_URL', 'OV_MEDIA_URL', 'OV_NETWORK_URL']) {
++ const url = registry[key]?.value;
++ if (!url) continue;
++ try { audienceSet.add(new URL(url).hostname); } catch { /* ignore */ }
++ }
++ const audience = [...audienceSet];
++
++ return jwt.sign(
++ {
++ sub: user.id,
++ id: user.id,
++ username: user.username,
++ display_name: user.display_name || user.username,
++ role: user.role,
++ avatar_url: user.avatar_url,
++ profile_color: user.profile_color,
++ },
++ privateKey,
++ {
++ algorithm,
++ issuer: config.jwt.issuer,
++ audience,
++ expiresIn: config.jwt.accessTokenExpiry,
++ }
++ );
++}
++
++function sanitizeUser(user) {
++ const { password_hash, token_valid_after, ...safe } = user;
++ // Owner = the configured owner account (OpenVibe owner). Not a DB column —
++ // derived here so the client can gate owner-only areas (Deploy, SSH, secrets).
++ const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
++ safe.is_owner = !!(safe.username && safe.username.toLowerCase() === OWNER);
++ return safe;
++}
++
++function requireAuth(req, res, next) {
++ const authHeader = req.headers.authorization;
++ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
++ if (!token) return res.status(401).json({ error: 'Authentication required' });
++
++ const config = getConfig(req);
++ const publicKey = req.app.locals.publicKey;
++ const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
++
++ try {
++ const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
++ const db = getDb(req);
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
++ if (!user) return res.status(401).json({ error: 'User not found' });
++ if (user.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: user.ban_reason });
++ if (user.token_valid_after) {
++ const tokenIat = decoded.iat * 1000;
++ const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
++ if (tokenIat < validAfter) return res.status(401).json({ error: 'Token revoked' });
++ }
++ req.user = user;
++ req.token = token;
++ next();
++ } catch {
++ return res.status(401).json({ error: 'Invalid or expired token' });
++ }
++}
++
++// ── Register ─────────────────────────────────────────────────
++router.post('/register', (req, res) => {
++ const db = getDb(req);
++ const config = getConfig(req);
++
++ if (!db.getSetting('registration_open')) {
++ return res.status(403).json({ error: 'Registration is currently closed' });
++ }
++
++ const username = String(req.body.username || '').trim();
++ const password = String(req.body.password || '');
++ const email = req.body.email ? String(req.body.email).trim() : undefined;
++ const verification_key = req.body.verification_key ? String(req.body.verification_key).trim() : undefined;
++
++ if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
++ if (username.length < 3 || username.length > 24) {
++ return res.status(400).json({ error: 'Username must be 3-24 characters' });
++ }
++ if (!/^[a-zA-Z0-9_]+$/.test(username)) {
++ return res.status(400).json({ error: 'Username can only contain letters, numbers, and underscores' });
++ }
++ if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
++ if (/^anon/i.test(username)) return res.status(400).json({ error: 'Username cannot start with "anon" — this prefix is reserved for anonymous identities' });
++ // Names the site itself posts under (changelog / system messages) — reserved so a real
++ // user can't impersonate the platform.
++ if (['live', 'system', 'openvibelivebot'].includes(username.toLowerCase())) {
++ return res.status(400).json({ error: 'This username is reserved by the system and cannot be registered.' });
++ }
++
++ const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
++ if (existing) return res.status(409).json({ error: 'Username already taken' });
++
++ // Check if username is reserved (has an active verification key)
++ const reserved = db.isUsernameReserved(username);
++ if (reserved) {
++ if (!verification_key) {
++ return res.status(403).json({
++ error: 'This username is reserved. A verification key is required to claim it.',
++ reserved: true,
++ });
++ }
++ const vk = db.getVerificationKeyByKey(verification_key);
++ if (!vk || vk.status !== 'active') {
++ return res.status(403).json({ error: 'Invalid or expired verification key' });
++ }
++ if (vk.target_username.toLowerCase() !== username.toLowerCase()) {
++ return res.status(403).json({ error: 'This verification key is for a different username' });
++ }
++ }
++
++ if (email) {
++ const emailExists = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
++ if (emailExists) return res.status(409).json({ error: 'Email already in use' });
++ }
++
++ const passwordHash = bcrypt.hashSync(password, 10);
++ const result = db.prepare(`
++ INSERT INTO users (username, email, password_hash, display_name, profile_color)
++ VALUES (?, ?, ?, ?, '#8b5cf6')
++ `).run(username, email || null, passwordHash, username);
++
++ // Create default preferences
++ db.prepare('INSERT OR IGNORE INTO user_preferences (user_id) VALUES (?)').run(result.lastInsertRowid);
++
++ // Redeem verification key if used
++ if (verification_key && reserved) {
++ db.redeemVerificationKey(verification_key, result.lastInsertRowid);
++ }
++
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
++ const token = signToken(user, req.app.locals.privateKey, config);
++
++ console.log(`[Auth] New user registered: ${username} (id: ${user.id})${reserved ? ' [verification key redeemed]' : ''}`);
++
++ // Send welcome notification
++ try {
++ const notifService = req.app.locals.notificationService;
++ if (notifService) {
++ notifService.create({
++ user_id: user.id,
++ type: 'WELCOME',
++ title: 'Welcome to OpenVibe',
++ message: `Hey ${user.display_name || user.username}! Your account is ready. Explore OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and more.`,
++ service: 'network',
++ url: 'https://openvibe.network/my',
++ });
++ }
++ } catch { /* non-critical */ }
++
++ res.status(201).json({ token, user: sanitizeUser(user) });
++});
++
++// ── Login ────────────────────────────────────────────────────
++router.post('/login', (req, res) => {
++ const db = getDb(req);
++ const config = getConfig(req);
++
++ const { username, password } = req.body;
++ if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
++
++ const user = db.prepare('SELECT * FROM users WHERE LOWER(username) = LOWER(?)').get(username);
++ if (!user) return res.status(401).json({ error: 'Invalid username or password' });
++ if (user.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: user.ban_reason });
++ if (!bcrypt.compareSync(password, user.password_hash)) {
++ return res.status(401).json({ error: 'Invalid username or password' });
++ }
++
++ db.prepare('UPDATE users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
++ const token = signToken(user, req.app.locals.privateKey, config);
++
++ console.log(`[Auth] Login: ${user.username}`);
++ res.json({ token, user: sanitizeUser(user) });
++});
++
++// ── Forgot Password ─────────────────────────────────────────
++router.post('/forgot-password', async (req, res) => {
++ const db = getDb(req);
++ const emailService = req.app.locals.emailService;
++ const genericResponse = {
++ ok: true,
++ message: 'If that email exists, a reset link has been sent.',
++ };
++
++ const email = String(req.body?.email || '').trim();
++ if (!email) {
++ return res.status(400).json({ error: 'Email required' });
++ }
++
++ const user = db.prepare('SELECT id, username, display_name, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);
++
++ if (!user || !user.email || !emailService?.isEnabled) {
++ return res.json(genericResponse);
++ }
++
++ const rawToken = crypto.randomBytes(32).toString('hex');
++ const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString();
++ db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(user.id);
++ db.prepare(`
++ INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, requested_ip, requested_user_agent)
++ VALUES (?, ?, ?, ?, ?)
++ `).run(user.id, hashResetToken(rawToken), expiresAt, req.ip || null, req.headers['user-agent'] || null);
++
++ const resetUrl = `${buildToolsBaseUrl(req)}/reset-password?token=${encodeURIComponent(rawToken)}`;
++ await emailService.sendPasswordResetEmail({
++ to: user.email,
++ username: user.display_name || user.username,
++ resetUrl,
++ expiresMinutes: Math.round(RESET_TOKEN_TTL_MS / 60000),
++ });
++
++ res.json(genericResponse);
++});
++
++// ── Validate Reset Token ────────────────────────────────────
++router.get('/reset-password/validate', (req, res) => {
++ const db = getDb(req);
++ const token = String(req.query.token || '').trim();
++ if (!token) return res.status(400).json({ error: 'Reset token required' });
++
++ const reset = findValidResetToken(db, token);
++ if (!reset) return res.status(400).json({ valid: false, error: 'Invalid or expired reset link' });
++
++ res.json({
++ valid: true,
++ username: reset.username,
++ expires_at: reset.expires_at,
++ });
++});
++
++// ── Complete Password Reset ────────────────────────────────
++router.post('/reset-password', (req, res) => {
++ const db = getDb(req);
++ const notifService = req.app.locals.notificationService;
++ const token = String(req.body?.token || '').trim();
++ const newPassword = String(req.body?.new_password || '');
++
++ if (!token || !newPassword) return res.status(400).json({ error: 'Reset token and new password required' });
++ if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
++
++ const reset = findValidResetToken(db, token);
++ if (!reset) return res.status(400).json({ error: 'Invalid or expired reset link' });
++
++ const hash = bcrypt.hashSync(newPassword, 10);
++ const tx = db.transaction(() => {
++ db.prepare(`
++ UPDATE users
++ SET password_hash = ?, token_valid_after = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
++ WHERE id = ?
++ `).run(hash, reset.user_id);
++ db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(reset.user_id);
++ db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(reset.user_id);
++ });
++ tx();
++
++ if (notifService) {
++ notifService.create({
++ user_id: reset.user_id,
++ type: 'PASSWORD_CHANGED',
++ title: 'Password Reset Complete',
++ message: 'Your password was reset successfully. If this was not you, contact support immediately.',
++ priority: 'critical',
++ category: 'system',
++ service: 'network',
++ url: 'https://openvibe.network/security',
++ });
++ }
++
++ res.json({ ok: true, message: 'Password reset complete. You can sign in with your new password now.' });
++});
++
++// ── Refresh Token (first-party sliding window) ──────────────
++// Accepts a still-valid JWT and issues a fresh one with a new 24h expiry.
++// This allows first-party OpenVibe apps to silently renew sessions without
++// requiring a full OAuth2 refresh_token flow.
++// Also accepts tokens expired within the last 7 days (grace period), so
++// users who return after a day away can still get a fresh token.
++router.post('/refresh', (req, res) => {
++ const config = getConfig(req);
++ const publicKey = req.app.locals.publicKey;
++ const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
++
++ const authHeader = req.headers.authorization;
++ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
++ if (!token) return res.status(401).json({ error: 'No token provided' });
++
++ let decoded;
++ try {
++ // First try strict verification
++ decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
++ } catch (err) {
++ // Accept recently expired tokens (up to 7 days grace period)
++ if (err.name === 'TokenExpiredError') {
++ try {
++ decoded = jwt.verify(token, publicKey, {
++ algorithms: [algorithm],
++ issuer: config.jwt.issuer,
++ ignoreExpiration: true,
++ });
++ const expiredAt = decoded.exp * 1000;
++ const gracePeriod = 7 * 24 * 60 * 60 * 1000; // 7 days
++ if (Date.now() - expiredAt > gracePeriod) {
++ return res.status(401).json({ error: 'Token expired beyond grace period' });
++ }
++ } catch {
++ return res.status(401).json({ error: 'Invalid token' });
++ }
++ } else {
++ return res.status(401).json({ error: 'Invalid token' });
++ }
++ }
++
++ // Verify user still exists and is valid
++ const db = getDb(req);
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
++ if (!user) return res.status(401).json({ error: 'User not found' });
++ if (user.is_banned) return res.status(403).json({ error: 'Account banned' });
++
++ // Check token_valid_after (password change invalidation)
++ if (user.token_valid_after) {
++ const tokenIat = decoded.iat * 1000;
++ const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
++ if (tokenIat < validAfter) return res.status(401).json({ error: 'Token revoked' });
++ }
++
++ // Issue fresh token
++ const newToken = signToken(user, req.app.locals.privateKey, config);
++
++ // Update session cookie — host-only on openvibe.network (NO Domain attribute)
++ res.cookie('ov_token', newToken, {
++ httpOnly: false,
++ maxAge: 30 * 24 * 60 * 60 * 1000,
++ sameSite: 'Lax',
++ secure: true,
++ path: '/',
++ });
++
++ res.json({
++ token: newToken,
++ user: sanitizeUser(user),
++ expires_in: 86400,
++ });
++});
++
++// ── Get Current User ─────────────────────────────────────────
++router.get('/me', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);
++ res.json({
++ user: sanitizeUser(req.user),
++ preferences: prefs || { theme_id: 'vibe' },
++ });
++});
++
++// ── Update Profile ───────────────────────────────────────────
++router.put('/profile', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const { display_name, bio, avatar_url, email, profile_color } = req.body;
++ const updates = [];
++ const params = [];
++
++ if (display_name !== undefined) {
++ // Display name may only re-case the username (e.g. "goosely" → "Goosely"), not rename.
++ const dn = String(display_name || '').trim();
++ const uname = String(req.user.username || '');
++ if (dn.toLowerCase() !== uname.toLowerCase()) {
++ return res.status(400).json({ error: 'Your display name can only change the capitalization of your username — not the name itself.' });
++ }
++ updates.push('display_name = ?'); params.push(dn || uname);
++ }
++ if (bio !== undefined) { updates.push('bio = ?'); params.push(bio.slice(0, 500)); }
++ if (avatar_url !== undefined) { updates.push('avatar_url = ?'); params.push(avatar_url); }
++ if (email !== undefined) { updates.push('email = ?'); params.push(email || null); }
++ if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }
++
++ if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
++
++ updates.push('updated_at = CURRENT_TIMESTAMP');
++ params.push(req.user.id);
++ db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
++
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
++ res.json({ user: sanitizeUser(user) });
++});
++
++// ── Change Password ──────────────────────────────────────────
++router.post('/change-password', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const config = getConfig(req);
++ const { current_password, new_password } = req.body;
++
++ if (!current_password || !new_password) return res.status(400).json({ error: 'Both passwords required' });
++ if (new_password.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
++ if (!bcrypt.compareSync(current_password, req.user.password_hash)) {
++ return res.status(401).json({ error: 'Current password is incorrect' });
++ }
++
++ const hash = bcrypt.hashSync(new_password, 10);
++ db.prepare('UPDATE users SET password_hash = ?, token_valid_after = CURRENT_TIMESTAMP WHERE id = ?')
++ .run(hash, req.user.id);
++ db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(req.user.id);
++ db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(req.user.id);
++
++ // Issue fresh token
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
++ const token = signToken(user, req.app.locals.privateKey, config);
++
++ // Notify about password change
++ const notifService = req.app.locals.notificationService;
++ if (notifService) {
++ notifService.create({
++ user_id: user.id,
++ type: 'PASSWORD_CHANGED',
++ title: 'Password Changed',
++ message: 'Your password was changed. If this wasn\'t you, contact support immediately.',
++ priority: 'critical',
++ category: 'system',
++ service: 'network',
++ });
++ }
++
++ res.json({ token, message: 'Password changed. All other sessions have been invalidated.' });
++});
++
++// ═══════════════════════════════════════════════════════════════
++// Anonymous User Management
++// ═══════════════════════════════════════════════════════════════
++
++function getRequestIp(req) {
++ const raw = req?.headers?.['cf-connecting-ip']
++ || req?.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
++ || req?.socket?.remoteAddress || req?.connection?.remoteAddress || 'unknown';
++ return raw.replace(/^::ffff:/, '');
++}
++
++/** Format an anon_users row into the API response shape */
++function formatAnonUser(anon) {
++ return {
++ id: `anon_${anon.id}`,
++ is_anon: true,
++ anon_number: anon.anon_number,
++ display_name: anon.display_name || `Anonymous #${anon.anon_number}`,
++ username: `anon${anon.anon_number}`,
++ preferences: JSON.parse(anon.preferences || '{}'),
++ total_messages: anon.total_messages || 0,
++ total_commands: anon.total_commands || 0,
++ first_seen: anon.first_seen,
++ last_seen: anon.last_seen,
++ };
++}
++
++/** Log an IP association for an anon user (upsert) */
++function logAnonIp(db, anonId, ip) {
++ if (!ip || ip === 'unknown') return;
++ try {
++ const existing = db.prepare('SELECT id FROM anon_ip_log WHERE anon_id = ? AND ip = ?').get(anonId, ip);
++ if (existing) {
++ db.prepare('UPDATE anon_ip_log SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(existing.id);
++ } else {
++ db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(anonId, ip);
++ }
++ } catch { /* non-critical */ }
++}
++
++// ── Create Anonymous Session ─────────────────────────────────
++// POST /api/auth/anon-session
++// Creates a temporary anonymous identity with a unique number.
++// If force_new=true, always creates a new anon identity.
++router.post('/anon-session', (req, res) => {
++ const db = getDb(req);
++ const config = getConfig(req);
++ const ip = getRequestIp(req);
++
++ try {
++ const sessionToken = uuidv4();
++ const fingerprint = req.body.fingerprint || null;
++ const forceNew = !!req.body.force_new;
++
++ // Check if this fingerprint already has an anon user (unless forcing new)
++ if (fingerprint && !forceNew) {
++ const existing = db.prepare('SELECT * FROM anon_users WHERE fingerprint = ?').get(fingerprint);
++ if (existing) {
++ // Return existing anon user with a fresh session token
++ db.prepare('UPDATE anon_users SET session_token = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?')
++ .run(sessionToken, existing.id);
++ logAnonIp(db, existing.id, ip);
++ return res.json({
++ token: sessionToken,
++ user: formatAnonUser({ ...existing, last_seen: new Date().toISOString() }),
++ });
++ }
++ }
++
++ // Check if this IP already has a default anon (unless forcing new)
++ if (!forceNew) {
++ const byIp = db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);
++ if (byIp) {
++ db.prepare('UPDATE anon_users SET session_token = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?')
++ .run(sessionToken, byIp.id);
++ logAnonIp(db, byIp.id, ip);
++ return res.json({
++ token: sessionToken,
++ user: formatAnonUser({ ...byIp, last_seen: new Date().toISOString() }),
++ });
++ }
++ }
++
++ // Generate next anon number
++ const maxNum = db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get().max || 0;
++ const anonNumber = maxNum + 1;
++
++ const result = db.prepare(
++ 'INSERT INTO anon_users (anon_number, fingerprint, session_token, ip) VALUES (?, ?, ?, ?)'
++ ).run(anonNumber, fingerprint, sessionToken, ip);
++
++ logAnonIp(db, result.lastInsertRowid, ip);
++
++ console.log(`[Auth] New anonymous user: #${anonNumber} (IP: ${ip})`);
++ res.json({
++ token: sessionToken,
++ user: formatAnonUser({
++ id: result.lastInsertRowid,
++ anon_number: anonNumber,
++ preferences: '{}',
++ total_messages: 0,
++ total_commands: 0,
++ first_seen: new Date().toISOString(),
++ last_seen: new Date().toISOString(),
++ }),
++ });
++ } catch (err) {
++ console.error('[Auth] Anon session error:', err);
++ res.status(500).json({ error: 'Failed to create anonymous session' });
++ }
++});
++
++// ── Get Anonymous User Info ──────────────────────────────────
++// GET /api/auth/anon/:token
++router.get('/anon/:token', (req, res) => {
++ const db = getDb(req);
++ const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
++ if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
++
++ db.prepare('UPDATE anon_users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(anon.id);
++ const ip = getRequestIp(req);
++ logAnonIp(db, anon.id, ip);
++
++ res.json({ user: formatAnonUser(anon) });
++});
++
++// ── Update Anonymous User Preferences ────────────────────────
++// PUT /api/auth/anon/:token/preferences
++router.put('/anon/:token/preferences', (req, res) => {
++ const db = getDb(req);
++ const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
++ if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
++
++ const current = JSON.parse(anon.preferences || '{}');
++ const updated = { ...current, ...req.body };
++ // Only allow safe preference keys
++ const allowed = ['theme_id', 'language', 'display_name', 'notifications_enabled'];
++ const safe = {};
++ for (const key of allowed) {
++ if (updated[key] !== undefined) safe[key] = updated[key];
++ }
++
++ db.prepare('UPDATE anon_users SET preferences = ?, display_name = ? WHERE id = ?')
++ .run(JSON.stringify(safe), safe.display_name || anon.display_name, anon.id);
++
++ res.json({ ok: true, preferences: safe });
++});
++
++// ── Link Anonymous to Registered Account ─────────────────────
++// POST /api/auth/anon/:token/link
++// Merges anon stats into the authenticated user's account.
++router.post('/anon/:token/link', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
++ if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
++
++ // Store anon number on the user for reference
++ db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL')
++ .run(anon.anon_number, req.user.id);
++
++ console.log(`[Auth] Linked anon #${anon.anon_number} → user ${req.user.username}`);
++ res.json({ ok: true, anon_number: anon.anon_number });
++});
++
++// ── List All Anon Identities for Current IP ──────────────────
++// GET /api/auth/anon-identities
++// Returns all anon identities associated with the caller's IP.
++router.get('/anon-identities', (req, res) => {
++ const db = getDb(req);
++ const ip = getRequestIp(req);
++
++ try {
++ // Find all anon IDs that have been seen from this IP
++ const anons = db.prepare(`
++ SELECT DISTINCT a.* FROM anon_users a
++ INNER JOIN anon_ip_log l ON l.anon_id = a.id
++ WHERE l.ip = ?
++ ORDER BY a.anon_number ASC
++ `).all(ip);
++
++ // Also include any anon whose creating IP matches
++ const byCreatingIp = db.prepare('SELECT * FROM anon_users WHERE ip = ?').all(ip);
++ const seen = new Set(anons.map(a => a.id));
++ for (const a of byCreatingIp) {
++ if (!seen.has(a.id)) anons.push(a);
++ }
++
++ res.json({
++ identities: anons.map(a => ({
++ ...formatAnonUser(a),
++ session_token: a.session_token,
++ })),
++ });
++ } catch (err) {
++ console.error('[Auth] Anon identities error:', err);
++ res.status(500).json({ error: 'Failed to list anon identities' });
++ }
++});
++
++// ═══════════════════════════════════════════════════════════════
++// Multi-Account Session Management
++// ═══════════════════════════════════════════════════════════════
++
++// ── List Active Sessions ─────────────────────────────────────
++// GET /api/auth/sessions
++router.get('/sessions', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const sessions = db.prepare(`
++ SELECT id, device_name, ip, last_used, created_at FROM user_sessions
++ WHERE user_id = ? AND is_active = 1
++ ORDER BY last_used DESC
++ `).all(req.user.id);
++ res.json({ ok: true, sessions });
++});
++
++// ── Create Session (for multi-account) ───────────────────────
++// POST /api/auth/sessions
++// Called when logging in with "add account" — stores session token.
++router.post('/sessions', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const config = getConfig(req);
++
++ const sessionToken = uuidv4();
++ const deviceName = req.body.device_name || req.headers['user-agent']?.slice(0, 100) || 'Unknown';
++ const ip = req.ip;
++ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
++
++ db.prepare(`
++ INSERT INTO user_sessions (user_id, session_token, device_name, ip, user_agent, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?)
++ `).run(req.user.id, sessionToken, deviceName, ip, req.headers['user-agent'] || '', expiresAt);
++
++ // Issue a JWT for this session
++ const token = signToken(req.user, req.app.locals.privateKey, config);
++
++ res.json({ ok: true, session_token: sessionToken, token });
++});
++
++// ── Revoke Session ───────────────────────────────────────────
++// DELETE /api/auth/sessions/:id
++router.delete('/sessions/:id', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const result = db.prepare('UPDATE user_sessions SET is_active = 0 WHERE id = ? AND user_id = ?')
++ .run(req.params.id, req.user.id);
++ res.json({ ok: true, revoked: result.changes > 0 });
++});
++
++// ── Revoke All Sessions ──────────────────────────────────────
++// DELETE /api/auth/sessions
++router.delete('/sessions', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const result = db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(req.user.id);
++ res.json({ ok: true, revoked: result.changes });
++});
++
++// ═══════════════════════════════════════════════════════════════
++// User Card / Public Profile
++// ═══════════════════════════════════════════════════════════════
++
++// GET /api/users/:id/card — public user card data for context menus
++router.get('/users/:id/card', (req, res) => {
++ const db = getDb(req);
++ const userId = req.params.id;
++
++ const user = db.prepare(`
++ SELECT id, username, display_name, avatar_url, bio, role, profile_color,
++ name_effect, particle_effect, is_anon, anon_number, created_at
++ FROM users WHERE id = ?
++ `).get(userId);
++ if (!user) return res.status(404).json({ error: 'User not found' });
++
++ // Linked services
++ const linked = db.prepare('SELECT service, service_username FROM linked_accounts WHERE user_id = ?').all(userId);
++
++ // Follower count
++ const followers = db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE followed_id = ?').get(userId)?.cnt || 0;
++ const following = db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE follower_id = ?').get(userId)?.cnt || 0;
++
++ // Is current user following this user?
++ let isFollowing = false;
++ if (req.headers.authorization) {
++ try {
++ const publicKey = req.app.locals.publicKey;
++ const config = getConfig(req);
++ const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
++ const token = req.headers.authorization.replace('Bearer ', '');
++ const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
++ const follow = db.prepare('SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?').get(decoded.sub || decoded.id, userId);
++ isFollowing = !!follow;
++ } catch { /* not logged in, fine */ }
++ }
++
++ // Active effects
++ const effects = db.prepare(`
++ SELECT effect_type, effect_id FROM user_effects
++ WHERE user_id = ? AND is_active = 1
++ `).all(userId);
++
++ res.json({
++ ok: true,
++ user: {
++ ...user,
++ linked_services: linked,
++ followers,
++ following,
++ is_following: isFollowing,
++ active_effects: effects,
++ },
++ });
++});
++
++// POST /api/users/:id/follow
++router.post('/users/:id/follow', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const targetId = parseInt(req.params.id);
++ if (targetId === req.user.id) return res.status(400).json({ error: 'Cannot follow yourself' });
++
++ try {
++ db.prepare('INSERT OR IGNORE INTO follows (follower_id, followed_id) VALUES (?, ?)').run(req.user.id, targetId);
++
++ // Notify target
++ const notifService = req.app.locals.notificationService;
++ if (notifService) {
++ notifService.create({
++ user_id: targetId,
++ type: 'FOLLOW',
++ title: 'New Follower',
++ message: `${req.user.display_name || req.user.username} started following you`,
++ sender_id: req.user.id,
++ sender_name: req.user.display_name || req.user.username,
++ sender_avatar: req.user.avatar_url,
++ url: `https://openvibe.network/user/${req.user.username}`,
++ service: 'network',
++ });
++ }
++
++ res.json({ ok: true });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to follow' });
++ }
++});
++
++// DELETE /api/users/:id/follow
++router.delete('/users/:id/follow', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const targetId = parseInt(req.params.id);
++ db.prepare('DELETE FROM follows WHERE follower_id = ? AND followed_id = ?').run(req.user.id, targetId);
++ res.json({ ok: true });
++});
++
++module.exports = router;
++// Exported so the admin proxy can re-mint a fresh upstream token that reflects
++// the user's CURRENT role (client tokens can be stale after a role change).
++module.exports.signToken = signToken;
ddiff --git a/server/coins/routes.js b/server/coins/routes.js
nnew file mode 100644
iindex 00000000..f0faccbf
---- /dev/null
++++ b/server/coins/routes.js
@@@ -0,0 +1,38 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — OpenCoins Wallet API (user-facing)
++// Mounted at /api/coins. Auth: Bearer user JWT (requireAuth).
++// Mutations (credit/debit/transfer) are server-to-server only —
++// see /internal/coins/* in server/internal/routes.js.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const wallet = require('./wallet');
++
++module.exports = function createCoinsRoutes(db, requireAuth) {
++ const router = express.Router();
++
++ // ── GET /api/coins/me ────────────────────────────────────
++ // → { balance }
++ router.get('/me', requireAuth, (req, res) => {
++ res.json({ balance: wallet.getBalance(db, req.user.id) });
++ });
++
++ // ── GET /api/coins/me/history?limit=50&offset=0 ──────────
++ // → { transactions: [{ id, app_id, delta, reason, created_at }] }
++ router.get('/me/history', requireAuth, (req, res) => {
++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
++ const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
++ const transactions = db.prepare(`
++ SELECT id, app_id, delta, reason, created_at
++ FROM coin_transactions
++ WHERE user_id = ?
++ ORDER BY id DESC
++ LIMIT ? OFFSET ?
++ `).all(req.user.id, limit, offset);
++ res.json({ transactions });
++ });
++
++ return router;
++};
ddiff --git a/server/coins/wallet.js b/server/coins/wallet.js
nnew file mode 100644
iindex 00000000..7bb78c0e
---- /dev/null
++++ b/server/coins/wallet.js
@@@ -0,0 +1,164 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — OpenCoins Wallet Core
++// Atomic wallet operations backed by better-sqlite3 transactions.
++// user_id here is ALWAYS the Network (SSO) user id.
++//
++// Idempotency: every mutation carries an idempotency_key stored on
++// its coin_transactions row (UNIQUE). Replaying a key returns the
++// original result without re-applying the mutation. Balances only
++// ever change through coin_transactions rows, so the balance right
++// after transaction N equals SUM(delta) of the user's rows with
++// id <= N — which is how replays reconstruct the original result.
++// ═══════════════════════════════════════════════════════════════
++
++class WalletError extends Error {
++ constructor(status, body) {
++ super(body.error || 'wallet_error');
++ this.status = status;
++ this.body = body;
++ }
++}
++
++function isPositiveInt(value) {
++ return Number.isInteger(value) && value > 0;
++}
++
++function assertUser(db, userId, label = 'user_id') {
++ const id = Number(userId);
++ if (!Number.isInteger(id) || id <= 0) {
++ throw new WalletError(400, { error: `invalid_${label}` });
++ }
++ const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
++ if (!user) {
++ throw new WalletError(404, { error: 'user_not_found', [label]: id });
++ }
++ return id;
++}
++
++function getBalance(db, userId) {
++ const row = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(userId);
++ return row ? row.balance : 0;
++}
++
++function balanceAfterTx(db, tx) {
++ const row = db.prepare(
++ 'SELECT COALESCE(SUM(delta), 0) AS bal FROM coin_transactions WHERE user_id = ? AND id <= ?'
++ ).get(tx.user_id, tx.id);
++ return row.bal;
++}
++
++function findByIdempotencyKey(db, key) {
++ if (!key) return null;
++ return db.prepare('SELECT * FROM coin_transactions WHERE idempotency_key = ?').get(key);
++}
++
++/**
++ * Apply a signed delta to a user's wallet inside the caller's transaction.
++ * Inserts the coin_transactions row and updates the wallets row.
++ * Throws WalletError(409, insufficient_funds) on overdraft.
++ * @returns {number} the new balance
++ */
++function applyDelta(db, { user_id, app_id, delta, reason, ref, idempotency_key }) {
++ db.prepare('INSERT OR IGNORE INTO wallets (user_id, balance) VALUES (?, 0)').run(user_id);
++ const balance = getBalance(db, user_id);
++ const next = balance + delta;
++ if (next < 0) {
++ throw new WalletError(409, { error: 'insufficient_funds', balance });
++ }
++ db.prepare(`
++ INSERT INTO coin_transactions (user_id, app_id, delta, reason, ref, idempotency_key)
++ VALUES (?, ?, ?, ?, ?, ?)
++ `).run(user_id, app_id || null, delta, reason || null, ref || null, idempotency_key || null);
++ db.prepare('UPDATE wallets SET balance = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?').run(next, user_id);
++ return next;
++}
++
++/**
++ * Credit (sign=+1) or debit (sign=-1) a wallet. Atomic + idempotent.
++ * @returns {{ balance: number, deduped: boolean }}
++ */
++function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, idempotency_key }) {
++ if (!isPositiveInt(amount)) {
++ throw new WalletError(400, { error: 'invalid_amount', message: 'amount must be a positive integer' });
++ }
++ if (!idempotency_key || typeof idempotency_key !== 'string') {
++ throw new WalletError(400, { error: 'missing_idempotency_key' });
++ }
++ const uid = assertUser(db, user_id);
++
++ const run = db.transaction(() => {
++ const existing = findByIdempotencyKey(db, idempotency_key);
++ if (existing) {
++ return { balance: balanceAfterTx(db, existing), deduped: true };
++ }
++ const balance = applyDelta(db, {
++ user_id: uid,
++ app_id,
++ delta: sign * amount,
++ reason,
++ ref,
++ idempotency_key,
++ });
++ return { balance, deduped: false };
++ });
++ return run();
++}
++
++function credit(db, opts) {
++ return creditOrDebit(db, +1, opts);
++}
++
++function debit(db, opts) {
++ return creditOrDebit(db, -1, opts);
++}
++
++/**
++ * Atomic transfer between two Network users.
++ * Stores two rows: the debit side carries the idempotency_key,
++ * the credit side carries `${idempotency_key}:in`.
++ * @returns {{ from_balance: number, to_balance: number, deduped: boolean }}
++ */
++function transfer(db, { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key }) {
++ if (!isPositiveInt(amount)) {
++ throw new WalletError(400, { error: 'invalid_amount', message: 'amount must be a positive integer' });
++ }
++ if (!idempotency_key || typeof idempotency_key !== 'string') {
++ throw new WalletError(400, { error: 'missing_idempotency_key' });
++ }
++ const fromId = assertUser(db, from_user_id, 'from_user_id');
++ const toId = assertUser(db, to_user_id, 'to_user_id');
++ if (fromId === toId) {
++ throw new WalletError(400, { error: 'invalid_transfer', message: 'from_user_id and to_user_id must differ' });
++ }
++
++ const inKey = `${idempotency_key}:in`;
++ const run = db.transaction(() => {
++ const existingOut = findByIdempotencyKey(db, idempotency_key);
++ if (existingOut) {
++ const existingIn = findByIdempotencyKey(db, inKey);
++ return {
++ from_balance: balanceAfterTx(db, existingOut),
++ to_balance: existingIn ? balanceAfterTx(db, existingIn) : getBalance(db, toId),
++ deduped: true,
++ };
++ }
++ const from_balance = applyDelta(db, {
++ user_id: fromId, app_id, delta: -amount, reason, ref, idempotency_key,
++ });
++ const to_balance = applyDelta(db, {
++ user_id: toId, app_id, delta: amount, reason, ref, idempotency_key: inKey,
++ });
++ return { from_balance, to_balance, deduped: false };
++ });
++ return run();
++}
++
++module.exports = {
++ WalletError,
++ getBalance,
++ credit,
++ debit,
++ transfer,
++};
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 00000000..f18fe229
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,66 @@
++'use strict';
++
++require('dotenv').config();
++
++module.exports = {
++ port: parseInt(process.env.PORT, 10) || 4000,
++ host: process.env.HOST || '0.0.0.0',
++ nodeEnv: process.env.NODE_ENV || 'development',
++ baseUrl: process.env.BASE_URL || 'https://openvibe.network',
++ networkUrl: process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
++ loginUrl: process.env.LOGIN_URL || process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
++ internalUrl: process.env.INTERNAL_URL || process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
++ setupToken: process.env.SETUP_TOKEN || '',
++ bootstrapProfile: process.env.BOOTSTRAP_PROFILE || 'local-dev',
++
++ jwt: {
++ // RS256 keypair — generate with:
++ // openssl genrsa -out data/keys/private.pem 2048
++ // openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
++ privateKeyPath: process.env.JWT_PRIVATE_KEY || 'data/keys/private.pem',
++ publicKeyPath: process.env.JWT_PUBLIC_KEY || 'data/keys/public.pem',
++ accessTokenExpiry: '24h',
++ refreshTokenExpiry: '30d',
++ // issuer is the canonical public network URL — updated at runtime from registry
++ issuer: process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
++ },
++
++ // Internal API key for server-to-server calls (X-Internal-Key)
++ internalKey: process.env.INTERNAL_API_KEY || 'change-me-in-production',
++
++ // Database
++ db: {
++ path: process.env.DB_PATH || './data/network.db',
++ },
++
++ // Admin auto-creation
++ admin: {
++ username: process.env.ADMIN_USERNAME || '',
++ password: process.env.ADMIN_PASSWORD || '',
++ },
++
++ // Upload paths
++ avatars: {
++ path: process.env.AVATAR_PATH || 'data/avatars',
++ maxSize: 512 * 1024, // 512 KB
++ },
++
++ // Connected services (OAuth2 clients are registered in the DB,
++ // but we allow env-based overrides for the internal API URLs)
++ services: {
++ live: {
++ internalUrl: process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000',
++ webhookSecret: process.env.OV_LIVE_WEBHOOK_SECRET || '',
++ },
++ tools: {
++ internalUrl: process.env.OV_TOOLS_INTERNAL_URL || 'http://127.0.0.1:4001',
++ },
++ games: {
++ internalUrl: process.env.OV_GAMES_INTERNAL_URL || 'http://127.0.0.1:8000',
++ webhookSecret: process.env.OV_GAMES_WEBHOOK_SECRET || '',
++ },
++ media: {
++ internalUrl: process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100',
++ },
++ },
++};
ddiff --git a/server/db/database.js b/server/db/database.js
nnew file mode 100644
iindex 00000000..f3044b58
---- /dev/null
++++ b/server/db/database.js
@@@ -0,0 +1,735 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — Central Database
++// Authoritative source for user accounts, OAuth2 clients,
++// themes, the OpenCoins wallet, and cross-platform preferences.
++// ═══════════════════════════════════════════════════════════════
++
++const Database = require('better-sqlite3');
++const path = require('path');
++const fs = require('fs');
++
++const ROLE_PRIORITY = {
++ user: 0,
++ streamer: 1,
++ global_mod: 2,
++ admin: 3,
++};
++
++function previewFromVars(vars) {
++ return JSON.stringify({
++ bg: vars['--bg-primary'] || '#0d0d0f',
++ accent: vars['--accent'] || '#8b5cf6',
++ text: vars['--text-primary'] || '#e8e6e3',
++ });
++}
++
++function resolveLiveDbPath() {
++ const candidates = [
++ process.env.OPENVIBELIVE_DB_PATH,
++ '/opt/openvibelive/data/live.db',
++ path.resolve(process.cwd(), '..', 'live', 'data', 'live.db'),
++ path.resolve(__dirname, '..', '..', '..', 'live', 'data', 'live.db'),
++ ].filter(Boolean);
++
++ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
++}
++
++function syncLinkedLiveRoles(db) {
++ const hsDbPath = resolveLiveDbPath();
++ if (!hsDbPath) return;
++
++ let hsDb;
++ try {
++ hsDb = new Database(hsDbPath, { readonly: true, fileMustExist: true });
++ const rows = db.prepare(`
++ SELECT
++ u.id AS user_id,
++ u.username,
++ u.role AS current_role,
++ u.legacy_id,
++ la.service_user_id,
++ la.service_username
++ FROM users u
++ LEFT JOIN linked_accounts la
++ ON la.user_id = u.id
++ AND la.service = 'live'
++ WHERE u.legacy_source = 'live'
++ OR la.service_user_id IS NOT NULL
++ OR la.service_username IS NOT NULL
++ `).all();
++
++ const selectById = hsDb.prepare('SELECT id, username, role FROM users WHERE id = ?');
++ const selectByUsername = hsDb.prepare('SELECT id, username, role FROM users WHERE LOWER(username) = LOWER(?)');
++ const updateRole = db.prepare('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
++
++ let synced = 0;
++ for (const row of rows) {
++ const lookupId = Number.parseInt(row.service_user_id || row.legacy_id, 10);
++ const lookupUsername = row.service_username || row.username;
++ let sourceUser = null;
++
++ if (Number.isFinite(lookupId)) {
++ sourceUser = selectById.get(lookupId);
++ }
++ if (!sourceUser && lookupUsername) {
++ sourceUser = selectByUsername.get(lookupUsername);
++ }
++ if (!sourceUser || !ROLE_PRIORITY.hasOwnProperty(sourceUser.role)) {
++ continue;
++ }
++
++ const currentRank = ROLE_PRIORITY[row.current_role] ?? 0;
++ const sourceRank = ROLE_PRIORITY[sourceUser.role] ?? 0;
++ if (sourceRank > currentRank) {
++ updateRole.run(sourceUser.role, row.user_id);
++ synced += 1;
++ }
++ }
++
++ if (synced > 0) {
++ console.log(`[DB] Synced ${synced} network role(s) from linked OpenVibe.Live accounts`);
++ }
++ } catch (err) {
++ console.warn('[DB] Role sync from OpenVibe.Live skipped:', err.message);
++ } finally {
++ try { hsDb?.close(); } catch {}
++ }
++}
++
++function initDb(dbPath) {
++ const dir = path.dirname(path.resolve(dbPath));
++ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
++
++ const db = new Database(path.resolve(dbPath));
++ db.pragma('journal_mode = WAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++
++ // ── Schema ───────────────────────────────────────────────
++ db.exec(`
++ -- Users (canonical identity for the OpenVibe)
++ CREATE TABLE IF NOT EXISTS users (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ username TEXT UNIQUE NOT NULL,
++ email TEXT UNIQUE,
++ password_hash TEXT NOT NULL,
++ display_name TEXT,
++ avatar_url TEXT,
++ bio TEXT DEFAULT '',
++ role TEXT DEFAULT 'user' CHECK(role IN ('user','streamer','global_mod','admin')),
++ profile_color TEXT DEFAULT '#8b5cf6',
++ is_banned INTEGER DEFAULT 0,
++ ban_reason TEXT,
++ token_valid_after TEXT DEFAULT NULL,
++ legacy_source TEXT, -- 'live' for migrated accounts
++ legacy_id INTEGER, -- original user ID in source platform
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++
++ -- OAuth2 client registry
++ CREATE TABLE IF NOT EXISTS oauth_clients (
++ client_id TEXT PRIMARY KEY,
++ client_secret TEXT NOT NULL,
++ name TEXT NOT NULL,
++ redirect_uris TEXT NOT NULL, -- JSON array of allowed redirect URIs
++ is_first_party INTEGER DEFAULT 0,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++
++ -- OAuth2 authorization codes (short-lived, single-use)
++ CREATE TABLE IF NOT EXISTS oauth_codes (
++ code TEXT PRIMARY KEY,
++ client_id TEXT NOT NULL,
++ user_id INTEGER NOT NULL,
++ redirect_uri TEXT NOT NULL,
++ scope TEXT DEFAULT 'profile theme',
++ expires_at DATETIME NOT NULL,
++ used INTEGER DEFAULT 0,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
++ FOREIGN KEY (user_id) REFERENCES users(id)
++ );
++
++ -- OAuth2 refresh tokens
++ CREATE TABLE IF NOT EXISTS oauth_tokens (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ token TEXT UNIQUE NOT NULL,
++ client_id TEXT NOT NULL,
++ user_id INTEGER NOT NULL,
++ scope TEXT DEFAULT 'profile theme',
++ expires_at DATETIME NOT NULL,
++ revoked INTEGER DEFAULT 0,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
++ FOREIGN KEY (user_id) REFERENCES users(id)
++ );
++
++ -- User preferences (theme, language, notification settings)
++ CREATE TABLE IF NOT EXISTS user_preferences (
++ user_id INTEGER PRIMARY KEY,
++ theme_id TEXT DEFAULT 'vibe',
++ custom_theme_variables TEXT, -- JSON: custom CSS var overrides
++ language TEXT DEFAULT 'en',
++ notifications_enabled INTEGER DEFAULT 1,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id)
++ );
++
++ -- Theme catalog (built-in + community)
++ CREATE TABLE IF NOT EXISTS themes (
++ id TEXT PRIMARY KEY,
++ name TEXT NOT NULL,
++ slug TEXT UNIQUE NOT NULL,
++ author_id INTEGER,
++ description TEXT DEFAULT '',
++ mode TEXT DEFAULT 'dark' CHECK(mode IN ('dark','light')),
++ variables TEXT NOT NULL, -- JSON: CSS variable map
++ preview_colors TEXT, -- JSON: preview color swatches
++ is_builtin INTEGER DEFAULT 0,
++ is_public INTEGER DEFAULT 1,
++ downloads INTEGER DEFAULT 0,
++ rating_sum INTEGER DEFAULT 0,
++ rating_count INTEGER DEFAULT 0,
++ tags TEXT DEFAULT '[]', -- JSON array
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (author_id) REFERENCES users(id)
++ );
++
++ -- Linked accounts (which OpenVibe user owns which service-specific account)
++ CREATE TABLE IF NOT EXISTS linked_accounts (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ service TEXT NOT NULL, -- 'live', 'games', etc.
++ service_user_id TEXT NOT NULL,
++ linked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id),
++ UNIQUE(service, service_user_id)
++ );
++
++ -- Audit log
++ CREATE TABLE IF NOT EXISTS audit_log (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER,
++ action TEXT NOT NULL,
++ details TEXT, -- JSON context
++ ip TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++
++ -- IP log (cross-platform)
++ CREATE TABLE IF NOT EXISTS ip_log (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER,
++ ip TEXT NOT NULL,
++ action TEXT DEFAULT 'login',
++ country TEXT,
++ region TEXT,
++ city TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id)
++ );
++
++ -- Site settings (key-value)
++ CREATE TABLE IF NOT EXISTS site_settings (
++ key TEXT PRIMARY KEY,
++ value TEXT,
++ type TEXT DEFAULT 'string'
++ );
++
++ -- URL registry for admin-managed first-party and protocol URLs
++ CREATE TABLE IF NOT EXISTS url_registry (
++ key TEXT PRIMARY KEY,
++ label TEXT NOT NULL,
++ category TEXT NOT NULL,
++ service TEXT NOT NULL,
++ scope TEXT NOT NULL,
++ type TEXT NOT NULL,
++ value TEXT,
++ description TEXT,
++ updated_by INTEGER,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++
++ -- ═══════════════════════════════════════════════════════
++ -- Notifications
++ -- ═══════════════════════════════════════════════════════
++
++ -- Central notification store
++ CREATE TABLE IF NOT EXISTS notifications (
++ id TEXT PRIMARY KEY, -- UUID
++ user_id INTEGER NOT NULL,
++ type TEXT NOT NULL, -- from TYPES enum (FOLLOW, MENTION, etc.)
++ category TEXT NOT NULL, -- social, chat, game, stream, economy, etc.
++ priority TEXT NOT NULL DEFAULT 'normal', -- low, normal, high, critical
++ title TEXT NOT NULL,
++ message TEXT,
++ icon TEXT,
++ sender_id INTEGER, -- who triggered this (nullable)
++ sender_name TEXT, -- denormalized for display
++ sender_avatar TEXT,
++ service TEXT, -- originating service (openvibelive, etc.)
++ url TEXT, -- click-through destination
++ rich_content TEXT, -- JSON: images, actions, embeds
++ is_read INTEGER DEFAULT 0,
++ is_dismissed INTEGER DEFAULT 0,
++ is_emailed INTEGER DEFAULT 0,
++ expires_at DATETIME,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++ CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read, created_at DESC);
++ CREATE INDEX IF NOT EXISTS idx_notif_user_cat ON notifications(user_id, category);
++ CREATE INDEX IF NOT EXISTS idx_notif_expires ON notifications(expires_at) WHERE expires_at IS NOT NULL;
++
++ -- Per-user notification preferences (overrides per category)
++ CREATE TABLE IF NOT EXISTS notification_preferences (
++ user_id INTEGER NOT NULL,
++ category TEXT NOT NULL, -- or '*' for global
++ enabled INTEGER DEFAULT 1,
++ sound INTEGER DEFAULT 1,
++ toasts INTEGER DEFAULT 1,
++ email INTEGER DEFAULT 0,
++ PRIMARY KEY (user_id, category),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++
++ -- Email delivery log (email metrics / admin visibility)
++ CREATE TABLE IF NOT EXISTS email_delivery_log (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ email_type TEXT NOT NULL,
++ recipient TEXT NOT NULL,
++ subject TEXT,
++ status TEXT NOT NULL CHECK(status IN ('sent', 'failed')),
++ error_message TEXT,
++ user_id INTEGER,
++ notification_id TEXT,
++ metadata TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
++ FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE SET NULL
++ );
++ CREATE INDEX IF NOT EXISTS idx_email_delivery_created ON email_delivery_log(created_at DESC);
++ CREATE INDEX IF NOT EXISTS idx_email_delivery_status ON email_delivery_log(status, created_at DESC);
++ CREATE INDEX IF NOT EXISTS idx_email_delivery_type ON email_delivery_log(email_type, created_at DESC);
++
++ -- Password reset tokens
++ CREATE TABLE IF NOT EXISTS password_reset_tokens (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ token_hash TEXT UNIQUE NOT NULL,
++ expires_at DATETIME NOT NULL,
++ used_at DATETIME,
++ requested_ip TEXT,
++ requested_user_agent TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++ CREATE INDEX IF NOT EXISTS idx_password_reset_user ON password_reset_tokens(user_id, used_at, expires_at);
++ CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens(expires_at);
++
++ -- ═══════════════════════════════════════════════════════
++ -- Anonymous Users & Multi-Account Sessions
++ -- ═══════════════════════════════════════════════════════
++
++ -- Anonymous user tracking
++ CREATE TABLE IF NOT EXISTS anon_users (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ anon_number INTEGER UNIQUE NOT NULL,
++ fingerprint TEXT, -- browser fingerprint hash (optional)
++ session_token TEXT UNIQUE NOT NULL,
++ display_name TEXT,
++ preferences TEXT DEFAULT '{}', -- JSON
++ total_messages INTEGER DEFAULT 0,
++ total_commands INTEGER DEFAULT 0,
++ first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
++ last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++
++ -- Multi-account session tracking (like Google multi-login)
++ CREATE TABLE IF NOT EXISTS user_sessions (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ session_token TEXT UNIQUE NOT NULL,
++ device_name TEXT,
++ ip TEXT,
++ user_agent TEXT,
++ is_active INTEGER DEFAULT 1,
++ last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ expires_at DATETIME NOT NULL,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++ CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, is_active);
++ CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(session_token);
++
++ -- ═══════════════════════════════════════════════════════
++ -- User Profile Extras
++ -- ═══════════════════════════════════════════════════════
++
++ -- Name & particle effects ownership
++ CREATE TABLE IF NOT EXISTS user_effects (
++ user_id INTEGER NOT NULL,
++ effect_type TEXT NOT NULL, -- 'name' or 'particle'
++ effect_id TEXT NOT NULL, -- e.g. 'rainbow', 'fire', 'neon'
++ is_active INTEGER DEFAULT 0,
++ acquired_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (user_id, effect_type, effect_id),
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++
++ -- User followers
++ CREATE TABLE IF NOT EXISTS follows (
++ follower_id INTEGER NOT NULL,
++ followed_id INTEGER NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (follower_id, followed_id),
++ FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
++ FOREIGN KEY (followed_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++
++ -- ═══════════════════════════════════════════════════════
++ -- OpenCoins Wallet (network-wide currency)
++ -- user_id is ALWAYS the Network (SSO) user id.
++ -- ═══════════════════════════════════════════════════════
++
++ CREATE TABLE IF NOT EXISTS wallets (
++ user_id INTEGER PRIMARY KEY,
++ balance INTEGER NOT NULL DEFAULT 0,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++
++ CREATE TABLE IF NOT EXISTS coin_transactions (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ app_id TEXT,
++ delta INTEGER NOT NULL,
++ reason TEXT,
++ ref TEXT,
++ idempotency_key TEXT UNIQUE,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++ CREATE INDEX IF NOT EXISTS idx_coin_tx_user ON coin_transactions(user_id, created_at DESC);
++ CREATE INDEX IF NOT EXISTS idx_coin_tx_app ON coin_transactions(app_id, created_at DESC);
++
++ -- Verification keys (reserved username claims)
++ CREATE TABLE IF NOT EXISTS verification_keys (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ key TEXT UNIQUE NOT NULL,
++ target_username TEXT NOT NULL,
++ note TEXT DEFAULT '',
++ created_by INTEGER NOT NULL,
++ used_by INTEGER,
++ status TEXT DEFAULT 'active' CHECK(status IN ('active', 'used', 'revoked')),
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ used_at DATETIME,
++ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
++ FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
++ );
++ CREATE INDEX IF NOT EXISTS idx_vkeys_key ON verification_keys(key);
++ CREATE INDEX IF NOT EXISTS idx_vkeys_target ON verification_keys(target_username);
++ CREATE INDEX IF NOT EXISTS idx_vkeys_status ON verification_keys(status);
++ `);
++
++ // ── Anon IP tracking (unified cross-service anon resolution) ──
++ try {
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS anon_ip_log (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ anon_id INTEGER NOT NULL,
++ ip TEXT NOT NULL,
++ first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
++ last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (anon_id) REFERENCES anon_users(id) ON DELETE CASCADE,
++ UNIQUE(anon_id, ip)
++ );
++ CREATE INDEX IF NOT EXISTS idx_anon_ip_log_ip ON anon_ip_log(ip);
++ CREATE INDEX IF NOT EXISTS idx_anon_ip_log_anon ON anon_ip_log(anon_id);
++ `);
++ } catch (e) { /* already exists */ }
++
++ // ── Push Subscriptions (Web Push / VAPID) ──
++ try {
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS push_subscriptions (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ endpoint TEXT NOT NULL UNIQUE,
++ keys_p256dh TEXT NOT NULL,
++ keys_auth TEXT NOT NULL,
++ user_agent TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
++ );
++ CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_id);
++ CREATE INDEX IF NOT EXISTS idx_push_sub_endpoint ON push_subscriptions(endpoint);
++ `);
++ } catch (e) { /* already exists */ }
++
++ // ── Migration: Add columns that may not exist ────────────
++ const migrations = [
++ { table: 'linked_accounts', column: 'service_username', sql: "ALTER TABLE linked_accounts ADD COLUMN service_username TEXT" },
++ { table: 'url_registry', column: 'source', sql: "ALTER TABLE url_registry ADD COLUMN source TEXT NOT NULL DEFAULT 'admin'" },
++ { table: 'users', column: 'is_anon', sql: "ALTER TABLE users ADD COLUMN is_anon INTEGER DEFAULT 0" },
++ { table: 'users', column: 'anon_number', sql: "ALTER TABLE users ADD COLUMN anon_number INTEGER" },
++ { table: 'users', column: 'name_effect', sql: "ALTER TABLE users ADD COLUMN name_effect TEXT" },
++ { table: 'users', column: 'particle_effect', sql: "ALTER TABLE users ADD COLUMN particle_effect TEXT" },
++ { table: 'anon_users', column: 'ip', sql: "ALTER TABLE anon_users ADD COLUMN ip TEXT" },
++ ];
++ for (const m of migrations) {
++ const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();
++ if (!cols.find(c => c.name === m.column)) {
++ try { db.exec(m.sql); console.log(`[DB] Migrated: ${m.table}.${m.column}`); }
++ catch (e) { /* already exists — silently skip */ }
++ }
++ }
++
++ // ── Migration: one linked account per (user, service) ────────
++ // The link-account upsert uses ON CONFLICT(user_id, service), which needs a
++ // matching unique index (the table only declared UNIQUE(service, service_user_id)).
++ try {
++ // Drop any pre-existing duplicate (user_id, service) rows, keeping the newest.
++ db.exec(`
++ DELETE FROM linked_accounts WHERE id NOT IN (
++ SELECT MAX(id) FROM linked_accounts GROUP BY user_id, service
++ );
++ CREATE UNIQUE INDEX IF NOT EXISTS idx_linked_user_service ON linked_accounts(user_id, service);
++ `);
++ } catch (e) { console.warn('[DB] linked_accounts unique index migration:', e.message); }
++
++ // ── Seed OAuth2 Clients (per CONTRACTS) ──────────────────
++ // Each first-party client is created if missing, with a fresh UUID secret
++ // printed exactly once on creation.
++ {
++ const { v4: uuidv4 } = require('uuid');
++ const contractClients = [
++ {
++ client_id: 'live',
++ name: 'OpenVibe.Live',
++ redirect_uris: ['https://openvibe.live/api/auth/callback'],
++ },
++ {
++ client_id: 'tools',
++ name: 'OpenVibe.Tools',
++ redirect_uris: ['https://openvibe.tools/auth/callback'],
++ },
++ {
++ client_id: 'games',
++ name: 'OpenVibe.Games',
++ redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'],
++ },
++ {
++ client_id: 'media',
++ name: 'OpenVibe.Media',
++ redirect_uris: ['https://openvibe.media/auth/callback'],
++ },
++ ];
++ const insert = db.prepare(
++ 'INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)'
++ );
++ let seededAny = false;
++ for (const c of contractClients) {
++ const existing = db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);
++ if (!existing) {
++ const secret = uuidv4();
++ insert.run(c.client_id, secret, c.name, JSON.stringify(c.redirect_uris));
++ console.log(`[DB] Seeded OAuth2 client: ${c.client_id} (secret: ${secret})`);
++ seededAny = true;
++ continue;
++ }
++ // Ensure existing clients include the contract redirect URIs
++ try {
++ const uris = new Set(JSON.parse(existing.redirect_uris || '[]'));
++ let changed = false;
++ for (const uri of c.redirect_uris) {
++ if (!uris.has(uri)) { uris.add(uri); changed = true; }
++ }
++ if (changed) {
++ db.prepare('UPDATE oauth_clients SET redirect_uris = ? WHERE client_id = ?')
++ .run(JSON.stringify([...uris]), c.client_id);
++ console.log(`[DB] Updated ${c.client_id} redirect_uris to include contract URIs`);
++ }
++ } catch { /* malformed redirect_uris — leave untouched */ }
++ }
++ if (seededAny) console.log('[DB] ⚠️ Save these client secrets! They are shown only once.');
++ }
++
++ function ensureLocalOauthRedirects() {
++ if (process.env.NODE_ENV === 'production' && process.env.BOOTSTRAP_PROFILE !== 'local-dev') return;
++
++ const localClients = [
++ {
++ clientId: 'live',
++ extraUris: ['http://localhost:3000/auth/callback', 'http://localhost:3000/api/auth/callback'],
++ },
++ {
++ clientId: 'tools',
++ extraUris: ['http://localhost:4001/auth/callback'],
++ },
++ {
++ clientId: 'games',
++ extraUris: ['http://localhost:8000/auth/callback', 'http://localhost:5173/auth/callback'],
++ },
++ {
++ clientId: 'media',
++ extraUris: ['http://localhost:4100/auth/callback'],
++ },
++ ];
++
++ for (const { clientId, extraUris } of localClients) {
++ try {
++ const client = db.prepare('SELECT redirect_uris FROM oauth_clients WHERE client_id = ?').get(clientId);
++ if (!client) continue;
++ const uris = new Set(JSON.parse(client.redirect_uris || '[]'));
++ let changed = false;
++ for (const uri of extraUris) {
++ if (!uris.has(uri)) {
++ uris.add(uri);
++ changed = true;
++ }
++ }
++ if (changed) {
++ db.prepare('UPDATE oauth_clients SET redirect_uris = ? WHERE client_id = ?')
++ .run(JSON.stringify([...uris]), clientId);
++ console.log(`[DB] Added local redirect_uris for ${clientId}: ${extraUris.join(', ')}`);
++ }
++ } catch (err) {
++ console.warn(`[DB] Failed to add local redirect_uris for ${clientId}:`, err.message);
++ }
++ }
++ }
++ ensureLocalOauthRedirects();
++
++ // ── Seed Default Settings ────────────────────────────────
++ const settingsCount = db.prepare('SELECT COUNT(*) as cnt FROM site_settings').get().cnt;
++ if (settingsCount === 0) {
++ const defaults = [
++ ['registration_open', 'true', 'boolean'],
++ ['platform_name', 'OpenVibe', 'string'],
++ ['default_theme', 'vibe', 'string'],
++ // Email provider defaults
++ ['email_enabled', 'false', 'boolean'],
++ ['resend_api_key', '', 'string'],
++ ['email_from_address', '[email protected]', 'string'],
++ ['email_from_name', 'OpenVibe', 'string'],
++ // Notification defaults
++ ['notifications_enabled', 'true', 'boolean'],
++ ['notification_max_age_days', '90', 'number'],
++ ['notification_email_critical_only', 'true', 'boolean'],
++ ];
++ const insertSetting = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ for (const [k, v, t] of defaults) insertSetting.run(k, v, t);
++ }
++
++ // ── Always-Seed Discord + Integration Settings (idempotent) ──
++ {
++ const alwaysSeed = [
++ ['discord_bot_token', '', 'secret'],
++ ['discord_guild_id', '', 'string'],
++ ['discord_alerts_channel_id', '', 'string'],
++ ['discord_system_channel_id', '', 'string'],
++ ['discord_dedupe_minutes', '15', 'number'],
++ ['discord_alert_message', '', 'string'],
++ ['discord_oauth_client_id', '', 'string'],
++ ['discord_oauth_client_secret', '', 'secret'],
++ ];
++ const insertSeed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ for (const [k, v, t] of alwaysSeed) insertSeed.run(k, v, t);
++ }
++
++ // ── Sync Built-in Themes ─────────────────────────────────
++ {
++ const { BUILTIN_THEMES } = require('openvibe-shared/theme-sync');
++ const upsertTheme = db.prepare(`
++ INSERT INTO themes (id, name, slug, description, mode, variables, preview_colors, is_builtin, is_public, tags, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(id) DO UPDATE SET
++ name = excluded.name,
++ slug = excluded.slug,
++ description = excluded.description,
++ mode = excluded.mode,
++ variables = excluded.variables,
++ preview_colors = excluded.preview_colors,
++ is_builtin = 1,
++ is_public = 1,
++ tags = excluded.tags,
++ updated_at = CURRENT_TIMESTAMP
++ `);
++ for (const t of BUILTIN_THEMES) {
++ upsertTheme.run(
++ t.id,
++ t.name,
++ t.slug,
++ t.description,
++ t.mode,
++ JSON.stringify(t.variables || {}),
++ previewFromVars(t.variables || {}),
++ JSON.stringify(t.tags || [])
++ );
++ }
++ console.log(`[DB] Synced ${BUILTIN_THEMES.length} built-in themes`);
++ }
++
++ // ── Sync Roles From Linked OpenVibe.Live Accounts ────────
++ syncLinkedLiveRoles(db);
++
++ // ── Helper: getSetting ───────────────────────────────────
++ db.getSetting = function (key) {
++ const row = this.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(key);
++ if (!row) return null;
++ if (row.type === 'boolean') return row.value === 'true';
++ if (row.type === 'number') return Number(row.value);
++ return row.value;
++ };
++
++ // ── Verification Key helpers ─────────────────────────────
++ db.createVerificationKey = function ({ key, target_username, note, created_by }) {
++ return this.prepare(
++ 'INSERT INTO verification_keys (key, target_username, note, created_by) VALUES (?, ?, ?, ?)'
++ ).run(key, target_username, note || '', created_by);
++ };
++
++ db.getVerificationKeyByKey = function (key) {
++ return this.prepare('SELECT * FROM verification_keys WHERE key = ?').get(key);
++ };
++
++ db.getVerificationKeyByUsername = function (username) {
++ return this.prepare("SELECT * FROM verification_keys WHERE target_username = ? COLLATE NOCASE AND status = 'active'").get(username);
++ };
++
++ db.getAllVerificationKeys = function () {
++ return this.prepare(`
++ SELECT vk.*, u1.username as created_by_name, u2.username as used_by_name
++ FROM verification_keys vk
++ LEFT JOIN users u1 ON vk.created_by = u1.id
++ LEFT JOIN users u2 ON vk.used_by = u2.id
++ ORDER BY vk.created_at DESC
++ `).all();
++ };
++
++ db.redeemVerificationKey = function (key, userId) {
++ return this.prepare(
++ "UPDATE verification_keys SET status = 'used', used_by = ?, used_at = CURRENT_TIMESTAMP WHERE key = ? AND status = 'active'"
++ ).run(userId, key);
++ };
++
++ db.revokeVerificationKey = function (id) {
++ return this.prepare("UPDATE verification_keys SET status = 'revoked' WHERE id = ? AND status = 'active'").run(id);
++ };
++
++ db.isUsernameReserved = function (username) {
++ const vk = this.prepare("SELECT id FROM verification_keys WHERE target_username = ? COLLATE NOCASE AND status = 'active'").get(username);
++ return !!vk;
++ };
++
++ console.log('[DB] Central database initialized');
++ return db;
++}
++
++module.exports = { initDb };
ddiff --git a/server/deploy/cert-manager.js b/server/deploy/cert-manager.js
nnew file mode 100644
iindex 00000000..9a638469
---- /dev/null
++++ b/server/deploy/cert-manager.js
@@@ -0,0 +1,500 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Certificate Manager — Let's Encrypt wildcard cert automation
++// Wraps certbot for DNS-01 challenge certificate issuance.
++// Supports both Cloudflare automated and manual DNS modes.
++// ═══════════════════════════════════════════════════════════════
++
++const { execFile, spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++
++const CERTBOT_BIN = process.env.CERTBOT_BIN || 'certbot';
++const LETSENCRYPT_DIR = process.env.LETSENCRYPT_DIR || '/etc/letsencrypt';
++const CLOUDFLARE_INI_PATH = path.join(LETSENCRYPT_DIR, 'cloudflare.ini');
++
++// ── Helpers ──────────────────────────────────────────────────
++
++function execPromise(cmd, args, opts = {}) {
++ return new Promise((resolve, reject) => {
++ execFile(cmd, args, { timeout: 120_000, ...opts }, (err, stdout, stderr) => {
++ if (err) {
++ err.stdout = stdout;
++ err.stderr = stderr;
++ return reject(err);
++ }
++ resolve({ stdout, stderr });
++ });
++ });
++}
++
++function certbotAvailable() {
++ try {
++ const { execFileSync } = require('child_process');
++ execFileSync(CERTBOT_BIN, ['--version'], { timeout: 5000, stdio: 'pipe' });
++ return true;
++ } catch {
++ return false;
++ }
++}
++
++function cloudflarePluginAvailable() {
++ try {
++ const { execFileSync } = require('child_process');
++ const out = execFileSync(CERTBOT_BIN, ['plugins', '--text'], { timeout: 10_000, stdio: 'pipe' }).toString();
++ return out.includes('dns-cloudflare');
++ } catch {
++ return false;
++ }
++}
++
++// ── Certificate Status ───────────────────────────────────────
++
++function parseCertbotCertificates(stdout) {
++ const certs = [];
++ const blocks = stdout.split(/^\s*Certificate Name:/m);
++ for (const block of blocks) {
++ if (!block.trim()) continue;
++ const cert = {};
++ const nameMatch = block.match(/^\s*(.+)/);
++ if (nameMatch) cert.name = nameMatch[1].trim();
++ const idMatch = block.match(/Identifiers:\s*(.+)/);
++ if (idMatch) cert.domains = idMatch[1].trim().split(/\s+/);
++ const expiryMatch = block.match(/Expiry Date:\s*(\S+\s+\S+)/);
++ if (expiryMatch) cert.expiry = expiryMatch[1];
++ const validMatch = block.match(/\(VALID:\s*(\d+)\s*days?\)/i);
++ if (validMatch) cert.daysRemaining = parseInt(validMatch[1]);
++ const invalidMatch = block.match(/\(INVALID/i);
++ if (invalidMatch) cert.invalid = true;
++ const certPathMatch = block.match(/Certificate Path:\s*(.+)/);
++ if (certPathMatch) cert.certPath = certPathMatch[1].trim();
++ const keyPathMatch = block.match(/Private Key Path:\s*(.+)/);
++ if (keyPathMatch) cert.keyPath = keyPathMatch[1].trim();
++ if (cert.name) certs.push(cert);
++ }
++ return certs;
++}
++
++async function getCertificateStatus() {
++ if (!certbotAvailable()) {
++ return { available: false, error: 'certbot not found', certs: [] };
++ }
++ try {
++ const { stdout } = await execPromise(CERTBOT_BIN, ['certificates', '--non-interactive']);
++ const certs = parseCertbotCertificates(stdout);
++ return { available: true, certs };
++ } catch (err) {
++ return { available: true, error: err.message, certs: [] };
++ }
++}
++
++function getCertPaths(domain) {
++ const livePath = path.join(LETSENCRYPT_DIR, 'live', domain);
++ const fullchain = path.join(livePath, 'fullchain.pem');
++ const privkey = path.join(livePath, 'privkey.pem');
++ return {
++ livePath,
++ fullchain,
++ privkey,
++ exists: fs.existsSync(fullchain) && fs.existsSync(privkey),
++ };
++}
++
++function findBestCert(domain) {
++ // Check common certbot naming patterns: domain, domain-0001, etc.
++ const candidates = [domain];
++ for (let i = 1; i <= 10; i++) {
++ candidates.push(`${domain}-${String(i).padStart(4, '0')}`);
++ }
++ let best = null;
++ for (const name of candidates) {
++ const paths = getCertPaths(name);
++ if (paths.exists) {
++ best = { certName: name, ...paths };
++ }
++ }
++ return best;
++}
++
++// ── Cloudflare Token Management ──────────────────────────────
++
++function writeCloudflareIni(token) {
++ const content = `# Cloudflare API token for certbot DNS-01 challenge\ndns_cloudflare_api_token = ${token}\n`;
++ fs.mkdirSync(path.dirname(CLOUDFLARE_INI_PATH), { recursive: true });
++ fs.writeFileSync(CLOUDFLARE_INI_PATH, content, { mode: 0o600 });
++}
++
++function cloudflareIniExists() {
++ return fs.existsSync(CLOUDFLARE_INI_PATH);
++}
++
++// ── Certificate Issuance ─────────────────────────────────────
++
++/**
++ * Issue a wildcard certificate using Cloudflare DNS-01 challenge.
++ * Requires certbot + certbot-dns-cloudflare plugin.
++ *
++ * @param {object} opts
++ * @param {string} opts.domain - Base domain (e.g. "openvibe.tools")
++ * @param {string} opts.email - ACME registration email
++ * @param {string} opts.cloudflareToken - Cloudflare API token
++ * @returns {Promise<{ok: boolean, certName: string, output: string}>}
++ */
++async function issueWildcardCloudflare({ domain, email, cloudflareToken }) {
++ if (!certbotAvailable()) throw new Error('certbot is not installed or not in PATH');
++ if (!cloudflarePluginAvailable()) throw new Error('certbot-dns-cloudflare plugin is not installed');
++ if (!domain || !email || !cloudflareToken) throw new Error('domain, email, and cloudflareToken are required');
++
++ // Write cloudflare credentials
++ writeCloudflareIni(cloudflareToken);
++
++ const args = [
++ 'certonly',
++ '--dns-cloudflare',
++ '--dns-cloudflare-credentials', CLOUDFLARE_INI_PATH,
++ '--dns-cloudflare-propagation-seconds', '30',
++ '-d', domain,
++ '-d', `*.${domain}`,
++ '--email', email,
++ '--agree-tos',
++ '--non-interactive',
++ '--keep-until-expiring',
++ ];
++
++ try {
++ const { stdout, stderr } = await execPromise(CERTBOT_BIN, args, { timeout: 180_000 });
++ const output = (stdout + '\n' + stderr).trim();
++ const cert = findBestCert(domain);
++ return {
++ ok: true,
++ certName: cert?.certName || domain,
++ certPath: cert?.fullchain || null,
++ keyPath: cert?.privkey || null,
++ output,
++ };
++ } catch (err) {
++ return {
++ ok: false,
++ error: err.message,
++ output: ((err.stdout || '') + '\n' + (err.stderr || '')).trim(),
++ };
++ }
++}
++
++/**
++ * Start a manual DNS-01 challenge for wildcard certificate.
++ * Returns the challenge records that need to be created.
++ *
++ * @param {object} opts
++ * @param {string} opts.domain - Base domain
++ * @param {string} opts.email - ACME registration email
++ * @returns {Promise<{ok: boolean, challenges: Array, processId: string}>}
++ */
++function startManualDnsChallenge({ domain, email }) {
++ if (!certbotAvailable()) throw new Error('certbot is not installed or not in PATH');
++ if (!domain || !email) throw new Error('domain and email are required');
++
++ return new Promise((resolve, reject) => {
++ const processId = crypto.randomBytes(8).toString('hex');
++ const args = [
++ 'certonly',
++ '--manual',
++ '--preferred-challenges', 'dns-01',
++ '-d', domain,
++ '-d', `*.${domain}`,
++ '--email', email,
++ '--agree-tos',
++ '--non-interactive',
++ '--manual-auth-hook', '/bin/true',
++ '--dry-run',
++ ];
++
++ // For manual mode, we use --manual with a dry-run first to get challenge info,
++ // then the real issuance requires the user to place DNS records.
++ // We use certbot certonly --manual --preferred-challenges dns to get the instructions.
++
++ const challenges = [];
++ let output = '';
++
++ const proc = spawn(CERTBOT_BIN, [
++ 'certonly',
++ '--manual',
++ '--preferred-challenges', 'dns-01',
++ '-d', domain,
++ '-d', `*.${domain}`,
++ '--email', email,
++ '--agree-tos',
++ '--keep-until-expiring',
++ ], { timeout: 300_000 });
++
++ let stdout = '';
++ let stderr = '';
++
++ proc.stdout.on('data', (data) => {
++ stdout += data.toString();
++ });
++
++ proc.stderr.on('data', (data) => {
++ stderr += data.toString();
++ });
++
++ // Wait for initial output, then parse challenge info
++ // certbot manual mode prints challenge instructions to stderr
++ const timeout = setTimeout(() => {
++ // Parse whatever we have
++ const combined = stdout + stderr;
++ const challengeMatches = combined.matchAll(/_acme-challenge\.(\S+)\s+.*?with the following value:\s*\n\s*(\S+)/g);
++ for (const m of challengeMatches) {
++ challenges.push({
++ type: 'DNS TXT',
++ name: `_acme-challenge.${domain}`,
++ value: m[2],
++ });
++ }
++
++ // Store the process for later continuation
++ if (!manualProcesses) {
++ // Module-level store initialized below
++ }
++ manualProcesses.set(processId, { proc, domain, email, challenges, stdout, stderr });
++
++ resolve({
++ ok: true,
++ processId,
++ domain,
++ challenges,
++ instructions: [
++ `Create DNS TXT record(s) for _acme-challenge.${domain}`,
++ 'Wait for DNS propagation (usually 1-5 minutes)',
++ 'Then call the confirm endpoint with the processId',
++ ],
++ output: combined.substring(0, 2000),
++ });
++ }, 15_000);
++
++ proc.on('close', (code) => {
++ clearTimeout(timeout);
++ const combined = stdout + stderr;
++ if (code === 0) {
++ const cert = findBestCert(domain);
++ resolve({
++ ok: true,
++ completed: true,
++ certName: cert?.certName || domain,
++ certPath: cert?.fullchain || null,
++ keyPath: cert?.privkey || null,
++ output: combined.substring(0, 2000),
++ });
++ }
++ // Don't reject here - the timeout handler may have already resolved
++ });
++
++ proc.on('error', (err) => {
++ clearTimeout(timeout);
++ reject(err);
++ });
++ });
++}
++
++// Module-level store for in-progress manual challenges
++const manualProcesses = new Map();
++
++/**
++ * Get the challenge details for a manual DNS issuance that uses certbot's
++ * preferred-challenges dns mode. Since certbot's interactive manual mode
++ * is complex to automate, we provide a simpler workflow:
++ *
++ * 1. Generate challenge info by calling ACME directly (or via certbot dry-run)
++ * 2. User creates DNS records
++ * 3. User confirms → we run the real certbot command
++ */
++async function getManualChallengeInfo({ domain, email }) {
++ if (!certbotAvailable()) throw new Error('certbot is not installed or not in PATH');
++ if (!domain || !email) throw new Error('domain and email are required');
++
++ // Use certbot to get the challenge token via a dry run with manual plugin
++ // This shows what records would be needed without actually issuing
++ try {
++ const { stdout, stderr } = await execPromise(CERTBOT_BIN, [
++ 'certonly',
++ '--manual',
++ '--preferred-challenges', 'dns-01',
++ '-d', domain,
++ '-d', `*.${domain}`,
++ '--email', email,
++ '--agree-tos',
++ '--non-interactive',
++ '--dry-run',
++ '--manual-auth-hook', 'echo',
++ ], { timeout: 60_000 });
++
++ const output = (stdout + '\n' + stderr).trim();
++ return {
++ ok: true,
++ domain,
++ instructions: [
++ `To issue a wildcard certificate for ${domain} and *.${domain}:`,
++ '',
++ '1. Create a DNS TXT record:',
++ ` Name: _acme-challenge.${domain}`,
++ ' Value: (provided by Let\'s Encrypt during issuance)',
++ '',
++ '2. Wait for DNS propagation',
++ '',
++ '3. Click "Issue Certificate" to run the real challenge',
++ '',
++ 'Note: For wildcard certs, you may need TWO TXT records (one per domain).',
++ 'Both should use the same _acme-challenge name but different values.',
++ ],
++ output: output.substring(0, 2000),
++ };
++ } catch (err) {
++ return {
++ ok: true,
++ domain,
++ instructions: [
++ `To issue a wildcard certificate for ${domain} and *.${domain}:`,
++ '',
++ '1. Create DNS TXT record(s):',
++ ` Name: _acme-challenge.${domain}`,
++ ' Value: (will be provided during issuance)',
++ '',
++ '2. For manual mode, run on the server:',
++ ` sudo certbot certonly --manual --preferred-challenges dns -d ${domain} -d *.${domain} --email ${email}`,
++ '',
++ '3. Follow the prompts to create DNS records and verify.',
++ ],
++ error: err.message,
++ };
++ }
++}
++
++/**
++ * Issue a wildcard certificate using manual DNS-01 challenge.
++ * The caller must have already created the required DNS TXT records.
++ * Uses the manual-auth-hook trick: a script that just succeeds,
++ * assuming the DNS records are already in place.
++ *
++ * @param {object} opts
++ * @param {string} opts.domain - Base domain
++ * @param {string} opts.email - ACME registration email
++ * @returns {Promise<{ok: boolean, certName: string, output: string}>}
++ */
++async function issueWildcardManual({ domain, email }) {
++ if (!certbotAvailable()) throw new Error('certbot is not installed or not in PATH');
++ if (!domain || !email) throw new Error('domain and email are required');
++
++ // Create a simple auth hook script that exits 0
++ // This tells certbot that the DNS records are already in place
++ const hookDir = path.join(LETSENCRYPT_DIR, 'hooks');
++ const hookPath = path.join(hookDir, 'manual-auth-noop.sh');
++
++ try {
++ fs.mkdirSync(hookDir, { recursive: true });
++ fs.writeFileSync(hookPath, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
++ } catch (err) {
++ throw new Error(`Cannot create auth hook: ${err.message}`);
++ }
++
++ const args = [
++ 'certonly',
++ '--manual',
++ '--preferred-challenges', 'dns-01',
++ '-d', domain,
++ '-d', `*.${domain}`,
++ '--email', email,
++ '--agree-tos',
++ '--non-interactive',
++ '--keep-until-expiring',
++ '--manual-auth-hook', hookPath,
++ ];
++
++ try {
++ const { stdout, stderr } = await execPromise(CERTBOT_BIN, args, { timeout: 180_000 });
++ const output = (stdout + '\n' + stderr).trim();
++ const cert = findBestCert(domain);
++ return {
++ ok: true,
++ certName: cert?.certName || domain,
++ certPath: cert?.fullchain || null,
++ keyPath: cert?.privkey || null,
++ output,
++ };
++ } catch (err) {
++ return {
++ ok: false,
++ error: err.message,
++ output: ((err.stdout || '') + '\n' + (err.stderr || '')).trim(),
++ };
++ }
++}
++
++/**
++ * Renew all certificates.
++ * @returns {Promise<{ok: boolean, output: string}>}
++ */
++async function renewCertificates() {
++ if (!certbotAvailable()) throw new Error('certbot is not installed or not in PATH');
++ try {
++ const { stdout, stderr } = await execPromise(CERTBOT_BIN, ['renew', '--non-interactive'], { timeout: 300_000 });
++ return { ok: true, output: (stdout + '\n' + stderr).trim() };
++ } catch (err) {
++ return {
++ ok: false,
++ error: err.message,
++ output: ((err.stdout || '') + '\n' + (err.stderr || '')).trim(),
++ };
++ }
++}
++
++/**
++ * Check system prerequisites for certificate management.
++ * @returns {object} Status of certbot, plugins, permissions
++ */
++function checkPrerequisites() {
++ const result = {
++ certbotInstalled: certbotAvailable(),
++ cloudflarePluginInstalled: false,
++ letsencryptDirWritable: false,
++ cloudflareIniExists: cloudflareIniExists(),
++ nginxInstalled: false,
++ };
++
++ if (result.certbotInstalled) {
++ result.cloudflarePluginInstalled = cloudflarePluginAvailable();
++ }
++
++ try {
++ fs.accessSync(LETSENCRYPT_DIR, fs.constants.W_OK);
++ result.letsencryptDirWritable = true;
++ } catch {
++ result.letsencryptDirWritable = false;
++ }
++
++ try {
++ const { execFileSync } = require('child_process');
++ execFileSync('nginx', ['-v'], { timeout: 5000, stdio: 'pipe' });
++ result.nginxInstalled = true;
++ } catch {
++ result.nginxInstalled = false;
++ }
++
++ return result;
++}
++
++module.exports = {
++ getCertificateStatus,
++ getCertPaths,
++ findBestCert,
++ issueWildcardCloudflare,
++ getManualChallengeInfo,
++ issueWildcardManual,
++ renewCertificates,
++ checkPrerequisites,
++ writeCloudflareIni,
++ cloudflareIniExists,
++ CERTBOT_BIN,
++ LETSENCRYPT_DIR,
++};
ddiff --git a/server/deploy/nginx-generator.js b/server/deploy/nginx-generator.js
nnew file mode 100644
iindex 00000000..7410b224
---- /dev/null
++++ b/server/deploy/nginx-generator.js
@@@ -0,0 +1,737 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Nginx Config Generator — Template-driven vhost generation
++// Replaces static hand-maintained nginx configs with generated
++// configs derived from the canonical URL registry.
++// ═══════════════════════════════════════════════════════════════
++
++const { execFile } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++const { findBestCert } = require('./cert-manager');
++
++// ── Default Service Definitions ──────────────────────────────
++// These are the OpenVibe defaults. White-label installs override
++// via DEPLOY_SERVICE_MAP and DEPLOY_DOMAINS in the registry.
++
++const DEFAULT_SERVICE_MAP = {
++ network: {
++ port: 4000,
++ domains: ['openvibe.network', 'my.openvibe.network'],
++ maxBodySize: '5m',
++ rateZones: [
++ { name: 'openvibenetwork_api', rate: '10r/s' },
++ { name: 'openvibenetwork_auth', rate: '5r/m' },
++ ],
++ locations: [
++ {
++ match: '~ ^/(api/auth/(register|login|anon-session)|oauth/(token|authorize))',
++ rateZone: 'openvibenetwork_auth',
++ rateBurst: 5,
++ },
++ {
++ match: '/shared/',
++ cacheTime: '1h',
++ },
++ {
++ match: '/api/',
++ rateZone: 'openvibenetwork_api',
++ rateBurst: 30,
++ },
++ {
++ match: '/internal/',
++ localhostOnly: true,
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ tools: {
++ port: 4001,
++ domains: ['openvibe.tools'],
++ wildcardDomain: 'openvibe.tools',
++ maxBodySize: '5m',
++ rateZones: [
++ { name: 'openvibetools_api', rate: '10r/s' },
++ ],
++ locations: [
++ {
++ match: '/api/',
++ rateZone: 'openvibetools_api',
++ rateBurst: 30,
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ media: {
++ port: 4100,
++ domains: ['openvibe.media'],
++ maxBodySize: '500m',
++ rateZones: [
++ { name: 'openvibemedia_api', rate: '10r/s' },
++ ],
++ locations: [
++ {
++ match: '/api/',
++ rateZone: 'openvibemedia_api',
++ rateBurst: 40,
++ readTimeout: '300s',
++ sendTimeout: '300s',
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ live: {
++ port: 3000,
++ domains: ['openvibe.live', 'www.openvibe.live'],
++ wildcardDomain: 'openvibe.live',
++ maxBodySize: '3m',
++ rateZones: [
++ { name: 'streamer_api', rate: '10r/s' },
++ { name: 'streamer_login', rate: '5r/m' },
++ ],
++ locations: [
++ {
++ match: '= /api/auth/register',
++ rateZone: 'streamer_login',
++ rateBurst: 5,
++ },
++ {
++ match: '= /api/auth/login',
++ rateZone: 'streamer_login',
++ rateBurst: 10,
++ },
++ {
++ match: '^~ /data/avatars/',
++ cacheTime: '7d',
++ },
++ {
++ match: '^~ /api/thumbnails/',
++ cacheTime: '1h',
++ },
++ {
++ match: '~ ^/api/vods/(upload|clips|stream/)',
++ maxBodySize: '500m',
++ readTimeout: '300s',
++ sendTimeout: '300s',
++ },
++ {
++ match: '/api/',
++ rateZone: 'streamer_api',
++ rateBurst: 40,
++ websocket: true,
++ maxBodySize: '10m',
++ },
++ {
++ match: '/ws/',
++ websocket: true,
++ },
++ ],
++ defaultLocationExtras: {
++ websocket: true,
++ headers: {
++ 'Permissions-Policy': 'camera=*, microphone=*, display-capture=*',
++ },
++ },
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ games: {
++ port: 8000,
++ domains: ['openvibe.games', 'www.openvibe.games', 'play.openvibe.games'],
++ wildcardDomain: 'openvibe.games',
++ maxBodySize: '2m',
++ rateZones: [
++ { name: 'openvibegames_api', rate: '10r/s' },
++ { name: 'openvibegames_auth', rate: '5r/m' },
++ ],
++ locations: [
++ {
++ match: '/auth/',
++ rateZone: 'openvibegames_auth',
++ rateBurst: 5,
++ },
++ {
++ match: '/api/',
++ rateZone: 'openvibegames_api',
++ rateBurst: 30,
++ websocket: true,
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ maps: {
++ port: 4010,
++ domains: ['maps.openvibe.tools'],
++ parentDomain: 'openvibe.tools',
++ maxBodySize: '2m',
++ rateZones: [
++ { name: 'openvibemaps_api', rate: '10r/s' },
++ ],
++ locations: [
++ {
++ match: '= /api/search/stream',
++ rateZone: 'openvibemaps_api',
++ rateBurst: 10,
++ sseStreaming: true,
++ },
++ {
++ match: '/api/',
++ rateZone: 'openvibemaps_api',
++ rateBurst: 30,
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ food: {
++ port: 4011,
++ domains: ['food.openvibe.tools'],
++ parentDomain: 'openvibe.tools',
++ maxBodySize: '2m',
++ rateZones: [
++ { name: 'openvibefood_api', rate: '10r/s' },
++ ],
++ locations: [
++ {
++ match: '/api/',
++ rateZone: 'openvibefood_api',
++ rateBurst: 30,
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ img: {
++ port: 4012,
++ domains: ['img.openvibe.tools'],
++ parentDomain: 'openvibe.tools',
++ maxBodySize: '50m',
++ rateZones: [
++ { name: 'openvibeimg_api', rate: '5r/s' },
++ ],
++ locations: [
++ {
++ match: '/api/',
++ rateZone: 'openvibeimg_api',
++ rateBurst: 20,
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ docs: {
++ port: 4016,
++ domains: ['docs.openvibe.tools', 'pdf.openvibe.tools'],
++ parentDomain: 'openvibe.tools',
++ maxBodySize: '50m',
++ rateZones: [
++ { name: 'openvibedocs_api', rate: '5r/s' },
++ ],
++ locations: [
++ {
++ match: '/api/',
++ rateZone: 'openvibedocs_api',
++ rateBurst: 20,
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ yt: {
++ port: 4013,
++ domains: ['yt.openvibe.tools'],
++ parentDomain: 'openvibe.tools',
++ maxBodySize: '2m',
++ rateZones: [
++ { name: 'openvibeyt_api', rate: '2r/s' },
++ ],
++ locations: [
++ {
++ match: '~ ^/api/status/.+/stream$',
++ sseStreaming: true,
++ readTimeout: '600s',
++ },
++ {
++ match: '~ ^/api/downloads/',
++ readTimeout: '600s',
++ },
++ {
++ match: '/api/',
++ rateZone: 'openvibeyt_api',
++ rateBurst: 10,
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ audio: {
++ port: 4014,
++ domains: ['audio.openvibe.tools'],
++ parentDomain: 'openvibe.tools',
++ maxBodySize: '100m',
++ rateZones: [
++ { name: 'openvibeaudio_api', rate: '5r/s' },
++ ],
++ locations: [
++ {
++ match: '/api/',
++ rateZone: 'openvibeaudio_api',
++ rateBurst: 20,
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++ text: {
++ port: 4015,
++ domains: ['text.openvibe.tools'],
++ parentDomain: 'openvibe.tools',
++ maxBodySize: '2m',
++ rateZones: [],
++ locations: [
++ {
++ match: '/api/',
++ },
++ ],
++ headers: {
++ 'X-Content-Type-Options': 'nosniff',
++ 'X-Frame-Options': 'SAMEORIGIN',
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ },
++ },
++};
++
++// ── Nginx Config Generation ──────────────────────────────────
++
++function indent(text, level = 1) {
++ const pad = ' '.repeat(level);
++ return text.split('\n').map(l => l ? pad + l : '').join('\n');
++}
++
++function generateProxyBlock(port, loc = {}) {
++ const lines = [];
++ lines.push(`proxy_pass http://127.0.0.1:${port};`);
++ lines.push('proxy_http_version 1.1;');
++ lines.push('proxy_set_header Host $host;');
++ lines.push('proxy_set_header X-Real-IP $remote_addr;');
++ lines.push('proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;');
++ lines.push('proxy_set_header X-Forwarded-Proto $scheme;');
++
++ if (loc.websocket) {
++ lines.push('proxy_set_header Upgrade $http_upgrade;');
++ lines.push('proxy_set_header Connection "upgrade";');
++ }
++
++ if (loc.sseStreaming) {
++ lines.push("proxy_set_header Connection '';");
++ lines.push('proxy_buffering off;');
++ lines.push('proxy_cache off;');
++ lines.push('chunked_transfer_encoding off;');
++ }
++
++ if (loc.readTimeout) {
++ lines.push(`proxy_read_timeout ${loc.readTimeout};`);
++ }
++ if (loc.sendTimeout) {
++ lines.push(`proxy_send_timeout ${loc.sendTimeout};`);
++ }
++
++ return lines.join('\n');
++}
++
++function generateLocationBlock(port, loc) {
++ const lines = [];
++
++ if (loc.rateZone) {
++ lines.push(`limit_req zone=${loc.rateZone} burst=${loc.rateBurst || 10} nodelay;`);
++ }
++
++ if (loc.localhostOnly) {
++ lines.push('allow 127.0.0.1;');
++ lines.push('allow ::1;');
++ lines.push('deny all;');
++ }
++
++ if (loc.maxBodySize) {
++ lines.push(`client_max_body_size ${loc.maxBodySize};`);
++ }
++
++ lines.push(generateProxyBlock(port, loc));
++
++ if (loc.cacheTime) {
++ lines.push(`expires ${loc.cacheTime};`);
++ const seconds = parseDurationToSeconds(loc.cacheTime);
++ lines.push(`add_header Cache-Control "public, max-age=${seconds}";`);
++ }
++
++ if (loc.headers) {
++ for (const [k, v] of Object.entries(loc.headers)) {
++ lines.push(`add_header ${k} "${v}" always;`);
++ }
++ }
++
++ return `location ${loc.match} {\n${indent(lines.join('\n'))}\n}`;
++}
++
++function parseDurationToSeconds(dur) {
++ const match = dur.match(/^(\d+)([smhd])$/);
++ if (!match) return 3600;
++ const num = parseInt(match[1]);
++ switch (match[2]) {
++ case 's': return num;
++ case 'm': return num * 60;
++ case 'h': return num * 3600;
++ case 'd': return num * 86400;
++ default: return 3600;
++ }
++}
++
++/**
++ * Generate a complete Nginx server block config for a service.
++ *
++ * @param {string} serviceId - Service identifier
++ * @param {object} svc - Service config (from DEFAULT_SERVICE_MAP or registry)
++ * @param {object} opts - Options
++ * @param {string} opts.certPath - Path to SSL certificate
++ * @param {string} opts.keyPath - Path to SSL private key
++ * @param {boolean} opts.sslEnabled - Whether to generate HTTPS block
++ * @returns {string} Nginx config text
++ */
++function generateServiceConfig(serviceId, svc, opts = {}) {
++ const lines = [];
++ const allDomains = [...(svc.domains || [])];
++ if (svc.wildcardDomain && !svc.parentDomain) {
++ // Add wildcard to server_name if this service owns the domain
++ if (!allDomains.includes(`*.${svc.wildcardDomain}`)) {
++ allDomains.push(`*.${svc.wildcardDomain}`);
++ }
++ }
++ const serverNames = allDomains.join(' ');
++
++ // Comment header
++ lines.push(`# ═══════════════════════════════════════════════════════════════`);
++ lines.push(`# ${serviceId} — Auto-generated Nginx Configuration`);
++ lines.push(`# Generated by openvibe.network deploy system`);
++ lines.push(`# DO NOT EDIT MANUALLY — managed via admin panel`);
++ lines.push(`# ═══════════════════════════════════════════════════════════════`);
++ lines.push('');
++
++ // Rate limit zones
++ if (svc.rateZones && svc.rateZones.length > 0) {
++ for (const rz of svc.rateZones) {
++ lines.push(`limit_req_zone $binary_remote_addr zone=${rz.name}:10m rate=${rz.rate};`);
++ }
++ lines.push('');
++ }
++
++ // HTTP → HTTPS redirect
++ lines.push('# HTTP → HTTPS redirect');
++ lines.push('server {');
++ lines.push(' listen 80;');
++ lines.push(' listen [::]:80;');
++ lines.push(` server_name ${serverNames};`);
++ lines.push(' return 301 https://$host$request_uri;');
++ lines.push('}');
++ lines.push('');
++
++ // HTTPS server
++ lines.push('# HTTPS server');
++ lines.push('server {');
++ lines.push(' listen 443 ssl;');
++ lines.push(' listen [::]:443 ssl;');
++ lines.push(' http2 on;');
++ lines.push(` server_name ${serverNames};`);
++ lines.push('');
++
++ // SSL certificate
++ if (opts.sslEnabled !== false && opts.certPath && opts.keyPath) {
++ lines.push(` ssl_certificate ${opts.certPath};`);
++ lines.push(` ssl_certificate_key ${opts.keyPath};`);
++ } else {
++ lines.push(' # SSL certificate not configured — update via admin panel');
++ lines.push(' # ssl_certificate /path/to/fullchain.pem;');
++ lines.push(' # ssl_certificate_key /path/to/privkey.pem;');
++ }
++ lines.push('');
++
++ // Logs
++ const logName = svc.domains?.[0]?.replace(/\./g, '-') || serviceId;
++ lines.push(` access_log /var/log/nginx/${logName}.access.log;`);
++ lines.push(` error_log /var/log/nginx/${logName}.error.log;`);
++ lines.push('');
++
++ // Body size
++ lines.push(` client_max_body_size ${svc.maxBodySize || '5m'};`);
++ lines.push('');
++
++ // Security headers
++ if (svc.headers) {
++ for (const [k, v] of Object.entries(svc.headers)) {
++ lines.push(` add_header ${k} ${v} always;`);
++ }
++ lines.push('');
++ }
++
++ // Location blocks
++ if (svc.locations && svc.locations.length > 0) {
++ for (const loc of svc.locations) {
++ lines.push(indent(generateLocationBlock(svc.port, loc)));
++ lines.push('');
++ }
++ }
++
++ // Default location (catch-all)
++ const defaultLoc = svc.defaultLocationExtras || {};
++ const defaultLines = [];
++ defaultLines.push(generateProxyBlock(svc.port, defaultLoc));
++ if (defaultLoc.headers) {
++ for (const [k, v] of Object.entries(defaultLoc.headers)) {
++ defaultLines.push(`add_header ${k} "${v}" always;`);
++ }
++ }
++ lines.push(indent(`location / {\n${indent(defaultLines.join('\n'))}\n}`));
++ lines.push('');
++
++ // Deny dotfiles (except .well-known)
++ lines.push(indent('location ~ /\\.(?!well-known) {'));
++ lines.push(indent(' deny all;'));
++ lines.push(indent('}'));
++ lines.push('}');
++
++ return lines.join('\n');
++}
++
++/**
++ * Generate all Nginx configs for the entire network.
++ *
++ * @param {object} registry - Resolved URL registry
++ * @param {object} opts - Options
++ * @returns {Array<{serviceId: string, filename: string, content: string}>}
++ */
++function generateAllConfigs(registry = {}) {
++ const serviceMap = registry.DEPLOY_SERVICE_MAP?.value || DEFAULT_SERVICE_MAP;
++ const deployDomains = registry.DEPLOY_DOMAINS?.value || [];
++
++ // Build domain → cert mapping
++ const domainCertMap = {};
++ for (const dd of deployDomains) {
++ if (dd.domain && dd.certName) {
++ domainCertMap[dd.domain] = dd.certName;
++ }
++ }
++
++ const configs = [];
++
++ for (const [serviceId, svc] of Object.entries(serviceMap)) {
++ // Determine which cert to use
++ const certDomain = svc.parentDomain || svc.wildcardDomain || svc.domains?.[0];
++ const certName = domainCertMap[certDomain] || certDomain;
++ const cert = certName ? findBestCert(certName) : null;
++
++ const filename = (svc.domains?.[0] || serviceId).replace(/\./g, '-') + '.conf';
++ const content = generateServiceConfig(serviceId, svc, {
++ sslEnabled: !!cert,
++ certPath: cert?.fullchain || null,
++ keyPath: cert?.privkey || null,
++ });
++
++ configs.push({ serviceId, filename, content, certFound: !!cert });
++ }
++
++ return configs;
++}
++
++// ── Nginx Apply / Reload ─────────────────────────────────────
++
++/**
++ * Test nginx configuration syntax.
++ * @returns {Promise<{ok: boolean, output: string}>}
++ */
++async function testNginxConfig() {
++ return new Promise((resolve) => {
++ execFile('nginx', ['-t'], { timeout: 10_000 }, (err, stdout, stderr) => {
++ const output = ((stdout || '') + '\n' + (stderr || '')).trim();
++ if (err) {
++ resolve({ ok: false, output, error: err.message });
++ } else {
++ resolve({ ok: true, output });
++ }
++ });
++ });
++}
++
++/**
++ * Reload nginx.
++ * @returns {Promise<{ok: boolean, output: string}>}
++ */
++async function reloadNginx() {
++ return new Promise((resolve) => {
++ execFile('systemctl', ['reload', 'nginx'], { timeout: 10_000 }, (err, stdout, stderr) => {
++ const output = ((stdout || '') + '\n' + (stderr || '')).trim();
++ if (err) {
++ resolve({ ok: false, output, error: err.message });
++ } else {
++ resolve({ ok: true, output });
++ }
++ });
++ });
++}
++
++/**
++ * Apply generated configs to the Nginx sites directory.
++ * Creates backups, writes new configs, validates, and optionally reloads.
++ *
++ * @param {Array} configs - Output from generateAllConfigs()
++ * @param {object} opts
++ * @param {string} opts.sitesPath - Target directory (default: /etc/nginx/sites-enabled)
++ * @param {string} opts.backupPath - Backup directory (default: /etc/nginx/sites-backup)
++ * @param {boolean} opts.reload - Whether to reload nginx after apply
++ * @param {boolean} opts.dryRun - Only validate, don't write
++ * @returns {Promise<{ok: boolean, applied: string[], backed_up: string[], validation: object, reload: object}>}
++ */
++async function applyConfigs(configs, opts = {}) {
++ const sitesPath = opts.sitesPath || '/etc/nginx/sites-enabled';
++ const backupPath = opts.backupPath || '/etc/nginx/sites-backup';
++ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
++ const backupDir = path.join(backupPath, timestamp);
++
++ const result = {
++ ok: false,
++ dryRun: !!opts.dryRun,
++ applied: [],
++ backed_up: [],
++ validation: null,
++ reload: null,
++ errors: [],
++ };
++
++ if (opts.dryRun) {
++ // In dry-run mode, validate configs by writing to a temp dir
++ const tmpDir = path.join(require('os').tmpdir(), `nginx-preview-${timestamp}`);
++ try {
++ fs.mkdirSync(tmpDir, { recursive: true });
++ for (const cfg of configs) {
++ fs.writeFileSync(path.join(tmpDir, cfg.filename), cfg.content);
++ result.applied.push(cfg.filename);
++ }
++ result.ok = true;
++ result.validation = { ok: true, output: 'Dry run — configs generated but not applied' };
++ } catch (err) {
++ result.errors.push(err.message);
++ } finally {
++ try { fs.rmSync(tmpDir, { recursive: true }); } catch { /* ignore */ }
++ }
++ return result;
++ }
++
++ // Create backup directory
++ try {
++ fs.mkdirSync(backupDir, { recursive: true });
++ } catch (err) {
++ result.errors.push(`Cannot create backup dir: ${err.message}`);
++ return result;
++ }
++
++ // Backup existing configs
++ try {
++ const existing = fs.readdirSync(sitesPath).filter(f => f.endsWith('.conf') || !f.includes('.'));
++ for (const f of existing) {
++ const src = path.join(sitesPath, f);
++ // Resolve symlinks for backup
++ const realSrc = fs.existsSync(src) ? (fs.lstatSync(src).isSymbolicLink() ? fs.readlinkSync(src) : src) : src;
++ try {
++ const content = fs.readFileSync(realSrc, 'utf8');
++ fs.writeFileSync(path.join(backupDir, f), content);
++ result.backed_up.push(f);
++ } catch { /* skip unreadable files */ }
++ }
++ } catch (err) {
++ result.errors.push(`Backup failed: ${err.message}`);
++ return result;
++ }
++
++ // Write new configs
++ for (const cfg of configs) {
++ try {
++ fs.writeFileSync(path.join(sitesPath, cfg.filename), cfg.content);
++ result.applied.push(cfg.filename);
++ } catch (err) {
++ result.errors.push(`Write ${cfg.filename}: ${err.message}`);
++ }
++ }
++
++ // Validate
++ result.validation = await testNginxConfig();
++ if (!result.validation.ok) {
++ // Rollback — restore from backup
++ result.errors.push('Nginx validation failed — rolling back');
++ for (const f of result.backed_up) {
++ try {
++ const backup = fs.readFileSync(path.join(backupDir, f), 'utf8');
++ fs.writeFileSync(path.join(sitesPath, f), backup);
++ } catch { /* best effort */ }
++ }
++ // Remove newly written files that weren't in backup
++ for (const f of result.applied) {
++ if (!result.backed_up.includes(f)) {
++ try { fs.unlinkSync(path.join(sitesPath, f)); } catch { /* ignore */ }
++ }
++ }
++ return result;
++ }
++
++ // Reload if requested
++ if (opts.reload) {
++ result.reload = await reloadNginx();
++ }
++
++ result.ok = result.validation.ok && (!opts.reload || result.reload?.ok);
++ return result;
++}
++
++module.exports = {
++ DEFAULT_SERVICE_MAP,
++ generateServiceConfig,
++ generateAllConfigs,
++ testNginxConfig,
++ reloadNginx,
++ applyConfigs,
++};
ddiff --git a/server/deploy/routes.js b/server/deploy/routes.js
nnew file mode 100644
iindex 00000000..33aee508
---- /dev/null
++++ b/server/deploy/routes.js
@@@ -0,0 +1,450 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Deploy Routes — Admin API for TLS / Nginx / Infrastructure
++// Mounted at /api/admin/deploy. Requires admin role.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const certManager = require('./cert-manager');
++const nginxGenerator = require('./nginx-generator');
++const urlRegistry = require('../url-registry');
++const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
++
++module.exports = function createDeployRoutes(db, requireAuth) {
++ const router = express.Router();
++
++ // Deploy touches TLS, nginx and live infrastructure — owner-only.
++ const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
++ function requireAdmin(req, res, next) {
++ const isOwner = req.user && req.user.username && req.user.username.toLowerCase() === OWNER;
++ if (!req.user || req.user.role !== 'admin' || !isOwner) {
++ return res.status(403).json({ ok: false, error: 'Owner access required' });
++ }
++ next();
++ }
++
++ function auditLog(req, action, details) {
++ try {
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, action, JSON.stringify(details)
++ );
++ } catch (err) {
++ console.error('[Deploy] Audit log error:', err.message);
++ }
++ }
++
++ function getDeployConfig() {
++ const resolved = urlRegistry.getResolvedRegistry(db, process.env);
++ return {
++ acmeEmail: resolved.DEPLOY_ACME_EMAIL?.value || '',
++ certMode: resolved.DEPLOY_CERT_MODE?.value || 'manual',
++ cloudflareToken: resolved.DEPLOY_CLOUDFLARE_TOKEN?.value || '',
++ domains: resolved.DEPLOY_DOMAINS?.value || [],
++ nginxMode: resolved.DEPLOY_NGINX_MODE?.value || 'preview',
++ nginxSitesPath: resolved.DEPLOY_NGINX_SITES_PATH?.value || '/etc/nginx/sites-enabled',
++ nginxBackupPath: resolved.DEPLOY_NGINX_BACKUP_PATH?.value || '/etc/nginx/sites-backup',
++ serviceMap: resolved.DEPLOY_SERVICE_MAP?.value || null,
++ };
++ }
++
++ router.use(requireAuth, requireAdmin);
++
++ // ═══════════════════════════════════════════════════════
++ // System Prerequisites
++ // ═══════════════════════════════════════════════════════
++
++ router.get('/prerequisites', (req, res) => {
++ try {
++ const prereqs = certManager.checkPrerequisites();
++ const config = getDeployConfig();
++ res.json({
++ ok: true,
++ prerequisites: prereqs,
++ config: {
++ acmeEmail: config.acmeEmail,
++ certMode: config.certMode,
++ hasCloudflareToken: !!config.cloudflareToken,
++ nginxMode: config.nginxMode,
++ domainsConfigured: config.domains.length,
++ },
++ });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Deploy Configuration
++ // ═══════════════════════════════════════════════════════
++
++ router.get('/config', (req, res) => {
++ try {
++ const config = getDeployConfig();
++ // Mask the Cloudflare token
++ const masked = { ...config };
++ if (masked.cloudflareToken && masked.cloudflareToken.length > 4) {
++ masked.cloudflareToken = '••••' + masked.cloudflareToken.slice(-4);
++ }
++ res.json({ ok: true, config: masked });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.put('/config', (req, res) => {
++ try {
++ const allowedKeys = {
++ acmeEmail: 'DEPLOY_ACME_EMAIL',
++ certMode: 'DEPLOY_CERT_MODE',
++ cloudflareToken: 'DEPLOY_CLOUDFLARE_TOKEN',
++ domains: 'DEPLOY_DOMAINS',
++ nginxMode: 'DEPLOY_NGINX_MODE',
++ nginxSitesPath: 'DEPLOY_NGINX_SITES_PATH',
++ nginxBackupPath: 'DEPLOY_NGINX_BACKUP_PATH',
++ serviceMap: 'DEPLOY_SERVICE_MAP',
++ };
++
++ const updated = {};
++ for (const [bodyKey, registryKey] of Object.entries(allowedKeys)) {
++ if (!(bodyKey in req.body)) continue;
++ const value = req.body[bodyKey];
++ // Skip masked secrets
++ if (registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' && typeof value === 'string' && value.startsWith('••••')) {
++ continue;
++ }
++ // Validate cert mode
++ if (registryKey === 'DEPLOY_CERT_MODE' && !['cloudflare', 'manual', 'none'].includes(value)) {
++ return res.status(400).json({ ok: false, error: 'certMode must be "cloudflare", "manual", or "none"' });
++ }
++ // Validate nginx mode
++ if (registryKey === 'DEPLOY_NGINX_MODE' && !['preview', 'apply', 'disabled'].includes(value)) {
++ return res.status(400).json({ ok: false, error: 'nginxMode must be "preview", "apply", or "disabled"' });
++ }
++ const entry = urlRegistry.setRegistryEntry(db, registryKey, value, req.user.id);
++ updated[bodyKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;
++ }
++
++ auditLog(req, 'deploy_config_update', { keys: Object.keys(updated) });
++ res.json({ ok: true, updated });
++ } catch (err) {
++ res.status(400).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Certificate Status
++ // ═══════════════════════════════════════════════════════
++
++ router.get('/certs', async (req, res) => {
++ try {
++ const status = await certManager.getCertificateStatus();
++ const config = getDeployConfig();
++
++ // Annotate certs with domain config
++ for (const cert of status.certs) {
++ const matchingDomain = config.domains.find(d =>
++ cert.domains?.includes(d.domain) || cert.domains?.includes(`*.${d.domain}`)
++ );
++ if (matchingDomain) {
++ cert.managedDomain = matchingDomain;
++ }
++ }
++
++ res.json({ ok: true, ...status });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Certificate Issuance — Cloudflare Mode
++ // ═══════════════════════════════════════════════════════
++
++ router.post('/certs/issue-cloudflare', async (req, res) => {
++ try {
++ const { domain } = req.body;
++ if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
++
++ const config = getDeployConfig();
++ if (!config.acmeEmail) {
++ return res.status(400).json({ ok: false, error: 'ACME email not configured. Set it in Deploy Config.' });
++ }
++ if (!config.cloudflareToken) {
++ return res.status(400).json({ ok: false, error: 'Cloudflare token not configured. Set it in Deploy Config.' });
++ }
++
++ auditLog(req, 'cert_issue_cloudflare_start', { domain });
++
++ const result = await certManager.issueWildcardCloudflare({
++ domain,
++ email: config.acmeEmail,
++ cloudflareToken: config.cloudflareToken,
++ });
++
++ auditLog(req, 'cert_issue_cloudflare_result', {
++ domain,
++ ok: result.ok,
++ certName: result.certName,
++ error: result.error,
++ });
++
++ if (result.ok) {
++ // Update domain config with cert name
++ const domains = config.domains.map(d => {
++ if (d.domain === domain) return { ...d, certName: result.certName };
++ return d;
++ });
++ // If domain not in list, add it
++ if (!domains.find(d => d.domain === domain)) {
++ domains.push({ domain, wildcard: true, certName: result.certName, services: [] });
++ }
++ urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
++ }
++
++ res.json({ ok: result.ok, ...result });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Certificate Issuance — Manual DNS Mode
++ // ═══════════════════════════════════════════════════════
++
++ router.post('/certs/manual-info', async (req, res) => {
++ try {
++ const { domain } = req.body;
++ if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
++
++ const config = getDeployConfig();
++ if (!config.acmeEmail) {
++ return res.status(400).json({ ok: false, error: 'ACME email not configured' });
++ }
++
++ const result = await certManager.getManualChallengeInfo({
++ domain,
++ email: config.acmeEmail,
++ });
++
++ res.json({ ok: true, ...result });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.post('/certs/issue-manual', async (req, res) => {
++ try {
++ const { domain } = req.body;
++ if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
++
++ const config = getDeployConfig();
++ if (!config.acmeEmail) {
++ return res.status(400).json({ ok: false, error: 'ACME email not configured' });
++ }
++
++ auditLog(req, 'cert_issue_manual_start', { domain });
++
++ const result = await certManager.issueWildcardManual({
++ domain,
++ email: config.acmeEmail,
++ });
++
++ auditLog(req, 'cert_issue_manual_result', {
++ domain,
++ ok: result.ok,
++ certName: result.certName,
++ error: result.error,
++ });
++
++ if (result.ok) {
++ const domains = config.domains.map(d => {
++ if (d.domain === domain) return { ...d, certName: result.certName };
++ return d;
++ });
++ if (!domains.find(d => d.domain === domain)) {
++ domains.push({ domain, wildcard: true, certName: result.certName, services: [] });
++ }
++ urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
++ }
++
++ res.json({ ok: result.ok, ...result });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Certificate Renewal
++ // ═══════════════════════════════════════════════════════
++
++ router.post('/certs/renew', async (req, res) => {
++ try {
++ auditLog(req, 'cert_renew_start', {});
++ const result = await certManager.renewCertificates();
++ auditLog(req, 'cert_renew_result', { ok: result.ok, error: result.error });
++ res.json({ ok: result.ok, ...result });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Nginx Config Preview
++ // ═══════════════════════════════════════════════════════
++
++ router.get('/nginx/preview', (req, res) => {
++ try {
++ const resolved = urlRegistry.getResolvedRegistry(db, process.env);
++ const configs = nginxGenerator.generateAllConfigs(resolved);
++ res.json({
++ ok: true,
++ configs: configs.map(c => ({
++ serviceId: c.serviceId,
++ filename: c.filename,
++ content: c.content,
++ certFound: c.certFound,
++ })),
++ });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.get('/nginx/preview/:serviceId', (req, res) => {
++ try {
++ const resolved = urlRegistry.getResolvedRegistry(db, process.env);
++ const configs = nginxGenerator.generateAllConfigs(resolved);
++ const cfg = configs.find(c => c.serviceId === req.params.serviceId);
++ if (!cfg) return res.status(404).json({ ok: false, error: 'Service not found' });
++ res.json({ ok: true, ...cfg });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Nginx Validate
++ // ═══════════════════════════════════════════════════════
++
++ router.post('/nginx/validate', async (req, res) => {
++ try {
++ const result = await nginxGenerator.testNginxConfig();
++ res.json({ ok: result.ok, ...result });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Nginx Apply (write + validate + optional reload)
++ // ═══════════════════════════════════════════════════════
++
++ router.post('/nginx/apply', async (req, res) => {
++ try {
++ const config = getDeployConfig();
++ if (config.nginxMode === 'disabled') {
++ return res.status(400).json({ ok: false, error: 'Nginx management is disabled' });
++ }
++
++ const dryRun = config.nginxMode === 'preview' || req.body.dryRun === true;
++ const reload = req.body.reload !== false && !dryRun;
++
++ auditLog(req, 'nginx_apply_start', { dryRun, reload });
++
++ const resolved = urlRegistry.getResolvedRegistry(db, process.env);
++ const configs = nginxGenerator.generateAllConfigs(resolved);
++
++ const result = await nginxGenerator.applyConfigs(configs, {
++ sitesPath: config.nginxSitesPath,
++ backupPath: config.nginxBackupPath,
++ reload,
++ dryRun,
++ });
++
++ auditLog(req, 'nginx_apply_result', {
++ ok: result.ok,
++ dryRun: result.dryRun,
++ applied: result.applied,
++ errors: result.errors,
++ });
++
++ res.json({ ok: result.ok, ...result });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Nginx Reload (standalone)
++ // ═══════════════════════════════════════════════════════
++
++ router.post('/nginx/reload', async (req, res) => {
++ try {
++ // Validate first
++ const validation = await nginxGenerator.testNginxConfig();
++ if (!validation.ok) {
++ return res.status(400).json({
++ ok: false,
++ error: 'Nginx config validation failed — will not reload',
++ validation,
++ });
++ }
++
++ auditLog(req, 'nginx_reload', {});
++ const result = await nginxGenerator.reloadNginx();
++ res.json({ ok: result.ok, ...result, validation });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ═══════════════════════════════════════════════════════
++ // Managed Domains CRUD
++ // ═══════════════════════════════════════════════════════
++
++ router.get('/domains', (req, res) => {
++ try {
++ const config = getDeployConfig();
++ // Annotate each domain with cert status
++ const domains = (config.domains || []).map(d => {
++ const cert = certManager.findBestCert(d.certName || d.domain);
++ return {
++ ...d,
++ certExists: !!cert,
++ certPath: cert?.fullchain || null,
++ keyPath: cert?.privkey || null,
++ certName: cert?.certName || d.certName || d.domain,
++ };
++ });
++ res.json({ ok: true, domains });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.put('/domains', (req, res) => {
++ try {
++ const { domains } = req.body;
++ if (!Array.isArray(domains)) {
++ return res.status(400).json({ ok: false, error: 'domains must be an array' });
++ }
++ // Validate each domain entry
++ for (const d of domains) {
++ if (!d.domain || typeof d.domain !== 'string') {
++ return res.status(400).json({ ok: false, error: 'Each domain entry must have a "domain" string' });
++ }
++ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(d.domain)) {
++ return res.status(400).json({ ok: false, error: `Invalid domain: ${d.domain}` });
++ }
++ }
++ const entry = urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
++ auditLog(req, 'deploy_domains_update', { count: domains.length });
++ res.json({ ok: true, domains: entry.value });
++ } catch (err) {
++ res.status(400).json({ ok: false, error: err.message });
++ }
++ });
++
++ return router;
++};
ddiff --git a/server/discord/discord-service.js b/server/discord/discord-service.js
nnew file mode 100644
iindex 00000000..003138a1
---- /dev/null
++++ b/server/discord/discord-service.js
@@@ -0,0 +1,222 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Discord Service — Modular Discord bot for the OpenVibe
++// Handles: live stream alerts, system notifications, admin alerts
++// Future: role sync, moderation hooks, linked-account verification
++// ═══════════════════════════════════════════════════════════════
++
++let Discord;
++try {
++ Discord = require('discord.js');
++} catch {
++ console.warn('[Discord] discord.js not installed — Discord integration disabled');
++}
++
++class DiscordService {
++ /**
++ * @param {object} db — better-sqlite3 Database instance (with getSetting helper)
++ */
++ constructor(db) {
++ this._db = db;
++ this._client = null;
++ this._ready = false;
++ this._destroyed = false;
++
++ // Per-streamer dedupe: username → lastAlertTimestamp
++ this._liveAlertCooldowns = new Map();
++ }
++
++ // ─── Lifecycle ─────────────────────────────────────────
++
++ /**
++ * Initialize and login the Discord bot.
++ * Reads bot token from site_settings. No-ops if token is missing.
++ */
++ async init() {
++ if (!Discord) return;
++
++ const token = this._getSetting('discord_bot_token');
++ if (!token) {
++ console.log('[Discord] No bot token configured — bot disabled');
++ return;
++ }
++
++ try {
++ this._client = new Discord.Client({
++ intents: [Discord.GatewayIntentBits.Guilds],
++ });
++
++ this._client.once('ready', () => {
++ this._ready = true;
++ console.log(`[Discord] Bot logged in as ${this._client.user.tag}`);
++ });
++
++ this._client.on('error', (err) => {
++ console.error('[Discord] Client error:', err.message);
++ });
++
++ await this._client.login(token);
++ } catch (err) {
++ console.error('[Discord] Failed to login:', err.message);
++ this._client = null;
++ this._ready = false;
++ }
++ }
++
++ /**
++ * Gracefully disconnect the bot.
++ */
++ async destroy() {
++ this._destroyed = true;
++ if (this._client) {
++ try { this._client.destroy(); } catch {}
++ this._client = null;
++ this._ready = false;
++ }
++ }
++
++ /**
++ * Reinitialize with new credentials (e.g. after admin updates the token).
++ */
++ async reinit() {
++ await this.destroy();
++ this._destroyed = false;
++ await this.init();
++ }
++
++ // ─── Status ────────────────────────────────────────────
++
++ getStatus() {
++ return {
++ available: !!Discord,
++ connected: this._ready,
++ botTag: this._client?.user?.tag || null,
++ guildId: this._getSetting('discord_guild_id') || null,
++ alertsChannelId: this._getSetting('discord_alerts_channel_id') || null,
++ };
++ }
++
++ isReady() {
++ return this._ready && !!this._client;
++ }
++
++ // ─── Live Stream Alerts ────────────────────────────────
++
++ /**
++ * Send a live-stream alert to the configured alerts channel.
++ * Includes per-streamer dedupe to prevent spam from flapping streams.
++ *
++ * @param {{ username: string, display_name?: string, avatar_url?: string }} streamer
++ * @param {{ id: number, title?: string, protocol?: string }} stream
++ * @returns {{ sent: boolean, reason?: string }}
++ */
++ async sendLiveAlert(streamer, stream) {
++ if (!this.isReady()) return { sent: false, reason: 'bot_not_connected' };
++
++ const channelId = this._getSetting('discord_alerts_channel_id');
++ if (!channelId) return { sent: false, reason: 'no_alerts_channel' };
++
++ // Dedupe check — one alert per streamer per cooldown window
++ const cooldownMin = parseInt(this._getSetting('discord_dedupe_minutes') || '15', 10);
++ const cooldownMs = Math.max(cooldownMin, 1) * 60 * 1000;
++ const key = streamer.username.toLowerCase();
++ const lastAlert = this._liveAlertCooldowns.get(key);
++ if (lastAlert && (Date.now() - lastAlert) < cooldownMs) {
++ return { sent: false, reason: 'cooldown', remaining_ms: cooldownMs - (Date.now() - lastAlert) };
++ }
++
++ try {
++ const channel = await this._client.channels.fetch(channelId);
++ if (!channel || !channel.isTextBased()) {
++ return { sent: false, reason: 'channel_not_found' };
++ }
++
++ const displayName = streamer.display_name || streamer.username;
++ const streamUrl = `https://openvibe.live/${streamer.username}`;
++ const title = stream.title || 'Started streaming';
++
++ const embed = new Discord.EmbedBuilder()
++ .setTitle(`🔴 ${displayName} is live!`)
++ .setDescription(title)
++ .setURL(streamUrl)
++ .setColor(0xef4444)
++ .setTimestamp()
++ .setFooter({ text: 'OpenVibe.Live.com' });
++
++ if (streamer.avatar_url) {
++ embed.setThumbnail(streamer.avatar_url);
++ }
++
++ if (stream.protocol) {
++ embed.addFields({ name: 'Protocol', value: stream.protocol.toUpperCase(), inline: true });
++ }
++
++ // Custom message template support
++ const customTemplate = this._getSetting('discord_alert_message');
++ let content = null;
++ if (customTemplate) {
++ content = customTemplate
++ .replace(/{username}/g, streamer.username)
++ .replace(/{display_name}/g, displayName)
++ .replace(/{title}/g, title)
++ .replace(/{url}/g, streamUrl);
++ }
++
++ await channel.send({ content, embeds: [embed] });
++
++ this._liveAlertCooldowns.set(key, Date.now());
++ return { sent: true };
++ } catch (err) {
++ console.error('[Discord] Failed to send live alert:', err.message);
++ return { sent: false, reason: 'send_error', error: err.message };
++ }
++ }
++
++ // ─── System Alerts ─────────────────────────────────────
++
++ /**
++ * Send a system/admin alert to a configured system channel.
++ * @param {{ title: string, message: string, level?: 'info'|'warning'|'error' }} alert
++ */
++ async sendSystemAlert(alert) {
++ if (!this.isReady()) return { sent: false, reason: 'bot_not_connected' };
++
++ const channelId = this._getSetting('discord_system_channel_id');
++ if (!channelId) return { sent: false, reason: 'no_system_channel' };
++
++ const colors = { info: 0x3b82f6, warning: 0xf59e0b, error: 0xef4444 };
++
++ try {
++ const channel = await this._client.channels.fetch(channelId);
++ if (!channel || !channel.isTextBased()) {
++ return { sent: false, reason: 'channel_not_found' };
++ }
++
++ const embed = new Discord.EmbedBuilder()
++ .setTitle(alert.title)
++ .setDescription(alert.message)
++ .setColor(colors[alert.level] || colors.info)
++ .setTimestamp()
++ .setFooter({ text: 'OpenVibe System' });
++
++ await channel.send({ embeds: [embed] });
++ return { sent: true };
++ } catch (err) {
++ console.error('[Discord] Failed to send system alert:', err.message);
++ return { sent: false, reason: 'send_error', error: err.message };
++ }
++ }
++
++ // ─── Helpers ───────────────────────────────────────────
++
++ _getSetting(key) {
++ try {
++ return this._db.getSetting(key) || null;
++ } catch {
++ return null;
++ }
++ }
++}
++
++module.exports = { DiscordService };
ddiff --git a/server/discord/routes.js b/server/discord/routes.js
nnew file mode 100644
iindex 00000000..b29f8c45
---- /dev/null
++++ b/server/discord/routes.js
@@@ -0,0 +1,106 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Discord Admin Routes — Config + management for the Discord bot
++// Mounted at /api/admin/discord
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const { isOwner, isSensitiveSettingKey } = require('../auth/owner-guard');
++
++module.exports = function createDiscordRoutes(db, discordService, requireAuth, requireAdmin) {
++ const router = express.Router();
++
++ router.use(requireAuth, requireAdmin);
++
++ /** GET /api/admin/discord — bot status + current config */
++ router.get('/', (req, res) => {
++ const status = discordService.getStatus();
++ const settings = {
++ discord_bot_token: db.getSetting('discord_bot_token') ? '••••••••' : '',
++ discord_guild_id: db.getSetting('discord_guild_id') || '',
++ discord_alerts_channel_id: db.getSetting('discord_alerts_channel_id') || '',
++ discord_system_channel_id: db.getSetting('discord_system_channel_id') || '',
++ discord_dedupe_minutes: db.getSetting('discord_dedupe_minutes') || '15',
++ discord_alert_message: db.getSetting('discord_alert_message') || '',
++ discord_oauth_client_id: db.getSetting('discord_oauth_client_id') || '',
++ discord_oauth_client_secret: db.getSetting('discord_oauth_client_secret') ? '••••••••' : '',
++ };
++ res.json({ ok: true, status, settings });
++ });
++
++ /** PUT /api/admin/discord — update Discord configuration */
++ router.put('/', async (req, res) => {
++ const { settings } = req.body;
++ if (!settings || typeof settings !== 'object') {
++ return res.status(400).json({ ok: false, error: 'settings object required' });
++ }
++
++ const allowedKeys = [
++ 'discord_bot_token', 'discord_guild_id', 'discord_alerts_channel_id',
++ 'discord_system_channel_id', 'discord_dedupe_minutes', 'discord_alert_message',
++ 'discord_oauth_client_id', 'discord_oauth_client_secret',
++ ];
++
++ const upsert = db.prepare(
++ 'INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)'
++ );
++ let tokenChanged = false;
++
++ const owner = isOwner(req.user);
++ for (const [key, value] of Object.entries(settings)) {
++ if (!allowedKeys.includes(key)) continue;
++ // Bot token / OAuth secret are owner-only — silently skip for admins.
++ if (isSensitiveSettingKey(key) && !owner) continue;
++ const strVal = String(value ?? '').trim();
++ // Don't overwrite token/secret with masked values
++ if (key === 'discord_bot_token' && (strVal === '••••••••' || strVal === '')) continue;
++ if (key === 'discord_oauth_client_secret' && (strVal === '••••••••' || strVal === '')) continue;
++ if (key === 'discord_bot_token') tokenChanged = true;
++ const type = key === 'discord_dedupe_minutes' ? 'number' : 'string';
++ upsert.run(key, strVal, type);
++ }
++
++ // Reinit bot if token changed
++ if (tokenChanged) {
++ try {
++ await discordService.reinit();
++ } catch (err) {
++ return res.json({ ok: true, warning: `Settings saved but bot reinit failed: ${err.message}` });
++ }
++ }
++
++ res.json({ ok: true, status: discordService.getStatus() });
++ });
++
++ /** POST /api/admin/discord/test — send a test alert to the configured channel */
++ router.post('/test', async (req, res) => {
++ const result = await discordService.sendSystemAlert({
++ title: '🧪 Test Alert',
++ message: `Test from openvibe.network admin at ${new Date().toISOString()}`,
++ level: 'info',
++ });
++ res.json({ ok: result.sent, ...result });
++ });
++
++ /** POST /api/admin/discord/test-live — send a test live alert */
++ router.post('/test-live', async (req, res) => {
++ const result = await discordService.sendLiveAlert(
++ { username: 'test', display_name: 'Test Streamer', avatar_url: null },
++ { id: 0, title: 'This is a test live alert', protocol: 'webrtc' },
++ );
++ res.json({ ok: result.sent, ...result });
++ });
++
++ /** POST /api/admin/discord/reinit — force reconnect the bot */
++ router.post('/reinit', async (req, res) => {
++ try {
++ await discordService.reinit();
++ res.json({ ok: true, status: discordService.getStatus() });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ return router;
++};
ddiff --git a/server/grant-admin.js b/server/grant-admin.js
nnew file mode 100644
iindex 00000000..5718ee07
---- /dev/null
++++ b/server/grant-admin.js
@@@ -0,0 +1,89 @@
++'use strict';
++
++const path = require('path');
++const fs = require('fs');
++const Database = require('better-sqlite3');
++const dotenv = require('dotenv');
++
++function loadEnv() {
++ const envPath = path.resolve(process.cwd(), '.env');
++ if (fs.existsSync(envPath)) {
++ const parsed = dotenv.config({ path: envPath });
++ if (parsed.error) {
++ throw parsed.error;
++ }
++ return parsed.parsed || {};
++ }
++ return {};
++}
++
++function resolveDatabasePath(env) {
++ if (env.DB_PATH) {
++ return path.resolve(process.cwd(), env.DB_PATH);
++ }
++ return path.resolve(__dirname, '..', 'data', 'network.db');
++}
++
++function parseArgs() {
++ const args = process.argv.slice(2);
++ const parsed = {};
++ for (let i = 0; i < args.length; i += 1) {
++ const arg = args[i];
++ if (arg === '--username' && args[i + 1]) {
++ parsed.username = args[++i];
++ } else if (arg === '--email' && args[i + 1]) {
++ parsed.email = args[++i];
++ } else if (arg === '--id' && args[i + 1]) {
++ parsed.id = args[++i];
++ } else if (arg === '--help') {
++ parsed.help = true;
++ }
++ }
++ return parsed;
++}
++
++function showUsage() {
++ console.log('Usage: node server/grant-admin.js --username <username> | --email <email> | --id <userId>');
++ process.exit(1);
++}
++
++function main() {
++ const args = parseArgs();
++ if (args.help || (!args.username && !args.email && !args.id)) {
++ showUsage();
++ }
++
++ const env = loadEnv();
++ const dbPath = resolveDatabasePath(env);
++ if (!fs.existsSync(dbPath)) {
++ console.error(`Database file not found at ${dbPath}`);
++ process.exit(2);
++ }
++
++ const db = new Database(dbPath);
++ const query = [];
++ let row;
++
++ if (args.id) {
++ row = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(args.id);
++ } else if (args.username) {
++ row = db.prepare('SELECT id, username, email, role FROM users WHERE LOWER(username) = LOWER(?)').get(args.username);
++ } else if (args.email) {
++ row = db.prepare('SELECT id, username, email, role FROM users WHERE LOWER(email) = LOWER(?)').get(args.email);
++ }
++
++ if (!row) {
++ console.error('User not found. Please verify username, email, or id.');
++ process.exit(3);
++ }
++
++ db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', row.id);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(null, 'grant_admin', JSON.stringify({ targetId: row.id, targetUsername: row.username, method: args.id ? 'id' : args.email ? 'email' : 'username' }));
++
++ console.log(`User ${row.username} (id=${row.id}) has been granted admin privileges.`);
++ db.close();
++ process.exit(0);
++}
++
++main();
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 00000000..a71b9aca
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,593 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — Main Server Entry Point
++// Pure identity/account service for the OpenVibe network:
++// SSO provider (OAuth2/OIDC), accounts, themes, notifications,
++// admin, url-registry, OpenCoins wallet, and /shared assets.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const cors = require('cors');
++const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++const path = require('path');
++const fs = require('fs');
++const bcrypt = require('bcryptjs');
++
++const config = require('./config');
++const { initDb } = require('./db/database');
++const urlRegistry = require('./url-registry');
++const { BRAND } = require('openvibe-shared/brand');
++const { NotificationService } = require('./notifications/notification-service');
++const { EmailService } = require('./notifications/email-service');
++const createNotificationRoutes = require('./notifications/routes');
++const createAdminRoutes = require('./admin/routes');
++const createSetupRoutes = require('./setup/routes');
++const { AnalyticsTracker } = require('openvibe-shared/analytics');
++const { DiscordService } = require('./discord/discord-service');
++const createDiscordRoutes = require('./discord/routes');
++const createDeployRoutes = require('./deploy/routes');
++const createCoinsRoutes = require('./coins/routes');
++const { signToken } = require('./auth/routes');
++
++const app = express();
++
++function getRequestHost(req) {
++ return String(req.headers.host || '').split(':')[0].toLowerCase();
++}
++
++function ensureAdminUser(db, config) {
++ const adminExists = db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();
++ if (adminExists) return;
++ const username = config.admin.username || (config.nodeEnv !== 'production' ? 'admin' : null);
++ const password = config.admin.password || (config.nodeEnv !== 'production' ? 'admin' : null);
++ if (!username || !password) {
++ console.warn('[Setup] No admin user exists and ADMIN_USERNAME/PASSWORD are not configured. Setup routes remain available to complete bootstrap.');
++ return;
++ }
++ const passwordHash = bcrypt.hashSync(password, 10);
++ db.prepare(`
++ INSERT INTO users (username, email, password_hash, display_name, role, profile_color)
++ VALUES (?, ?, ?, ?, 'admin', '#8b5cf6')
++ ON CONFLICT(username) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash
++ `).run(username, null, passwordHash, username);
++ console.log(`[Setup] Admin user created or elevated: ${username}`);
++}
++
++function redirectWithoutHtml(req, res, targetPath) {
++ const query = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
++ return res.redirect(302, `${targetPath}${query}`);
++}
++
++function sendMyAccountApp(res) {
++ return res.sendFile(path.join(__dirname, '..', 'public', 'my.html'));
++}
++
++function sendLandingPage(res) {
++ return res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
++}
++
++async function proxyJsonRequest(req, res, targetUrl, errorLabel) {
++ try {
++ // Re-mint a fresh token from the user's CURRENT server-side record so that
++ // a just-granted role (e.g. admin) propagates to upstream services
++ // immediately, instead of relying on the client's possibly-stale token.
++ let upstreamToken = req.token;
++ try {
++ if (req.user && req.app.locals.privateKey) {
++ upstreamToken = signToken(req.user, req.app.locals.privateKey, req.app.locals.config);
++ }
++ } catch (e) {
++ console.error('[AdminProxy] token re-mint failed, forwarding original:', e.message);
++ }
++ const fetchOpts = {
++ method: req.method,
++ headers: {
++ 'Content-Type': 'application/json',
++ 'Authorization': `Bearer ${upstreamToken}`,
++ },
++ };
++ if (req.method !== 'GET' && req.method !== 'HEAD' && req.body) {
++ fetchOpts.body = JSON.stringify(req.body);
++ }
++
++ const upstream = await fetch(targetUrl, fetchOpts);
++ const contentType = upstream.headers.get('content-type') || '';
++ const raw = await upstream.text();
++
++ res.status(upstream.status);
++ if (contentType.includes('application/json')) {
++ return res.json(raw ? JSON.parse(raw) : {});
++ }
++ return res.send(raw);
++ } catch (err) {
++ console.error(`[AdminProxy] ${errorLabel}:`, err.message);
++ return res.status(502).json({ error: 'Could not reach OpenVibe.Live service' });
++ }
++}
++
++// ── Security ─────────────────────────────────────────────────
++app.set('trust proxy', 2); // Cloudflare → Nginx → Node
++
++app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ scriptSrc: ["'self'", "'unsafe-inline'", "https://openvibe.network", "https://openvibe.live", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "fonts.googleapis.com"],
++ styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com", "fonts.gstatic.com"],
++ fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],
++ imgSrc: ["'self'", "data:", "blob:"],
++ connectSrc: [
++ "'self'",
++ "https://openvibe.network",
++ "https://openvibe.live",
++ "https://openvibe.tools", "https://*.openvibe.tools",
++ "https://openvibe.games", "https://play.openvibe.games",
++ "https://openvibe.media",
++ "https://openvibe.community",
++ ],
++ frameSrc: ["'none'"],
++ scriptSrcAttr: ["'unsafe-inline'"],
++ },
++ },
++ crossOriginEmbedderPolicy: false,
++}));
++app.use(cookieParser());
++app.use(express.json({ limit: '1mb' }));
++app.use(express.urlencoded({ extended: true }));
++
++// ── CORS ─────────────────────────────────────────────────────
++// Origins are derived dynamically from the URL registry at request time.
++// OpenVibe defaults are seeded values — white-label installs override via admin.
++//
++// A request origin is allowed when ANY of the following is true:
++// 1. It exactly matches a configured first-party URL (network, live, tools, games, media)
++// 2. It matches a wildcard subdomain of TOOLS_SUBDOMAIN_BASE (e.g. *.openvibe.tools)
++// 3. It is listed in ALLOWED_EXTRA_ORIGINS (admin-configurable JSON array)
++// 4. It is a localhost/127.0.0.1 origin in non-production environments
++
++function normalizeOriginForCors(origin) {
++ if (!origin || typeof origin !== 'string') return null;
++ try {
++ const url = new URL(origin.trim());
++ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
++ return `${url.protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`;
++ } catch {
++ return null;
++ }
++}
++
++function buildAllowedOriginsSet() {
++ const registry = app.locals.urlRegistry || {};
++ const origins = new Set();
++
++ // All configured public first-party URLs are always allowed
++ const publicUrlKeys = [
++ 'OV_NETWORK_URL', 'OV_NETWORK_LOGIN_URL',
++ 'OV_LIVE_URL', 'OV_TOOLS_URL', 'OV_GAMES_URL', 'OV_MEDIA_URL',
++ 'WEBRTC_PUBLIC_URL', 'JSMPEG_PUBLIC_URL', 'WHIP_PUBLIC_URL',
++ ];
++ for (const key of publicUrlKeys) {
++ const v = registry[key]?.value;
++ if (!v) continue;
++ const norm = normalizeOriginForCors(v);
++ if (!norm) continue;
++ origins.add(norm);
++ // Auto-add www/non-www variant
++ try {
++ const u = new URL(norm);
++ if (u.hostname.startsWith('www.')) {
++ origins.add(`${u.protocol}//${u.hostname.slice(4)}${u.port ? ':' + u.port : ''}`);
++ } else {
++ origins.add(`${u.protocol}//www.${u.hostname}${u.port ? ':' + u.port : ''}`);
++ }
++ } catch { /* ignore */ }
++ }
++
++ // Extra origins from admin-configurable list
++ const extra = registry.ALLOWED_EXTRA_ORIGINS?.value;
++ if (Array.isArray(extra)) {
++ for (const o of extra) {
++ const norm = normalizeOriginForCors(o);
++ if (norm) origins.add(norm);
++ }
++ }
++
++ // Hard-coded OpenVibe defaults as baseline (survive registry reset/failure)
++ for (const o of [
++ 'https://openvibe.network',
++ 'https://openvibe.live', 'https://www.openvibe.live',
++ 'https://openvibe.tools',
++ 'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games',
++ 'https://openvibe.media',
++ 'https://openvibe.community',
++ ]) {
++ origins.add(o);
++ }
++
++ if (process.env.NODE_ENV !== 'production') {
++ for (const o of [
++ 'http://localhost:3000', // live
++ 'http://localhost:4000', 'http://127.0.0.1:4000', // network
++ 'http://localhost:4001', // tools gateway
++ 'http://localhost:4100', // media
++ 'http://localhost:8000', // games
++ 'http://localhost:5173', // games vite dev
++ ]) {
++ origins.add(o);
++ }
++ }
++
++ return origins;
++}
++
++function getToolsSubdomainBase() {
++ const registry = app.locals.urlRegistry || {};
++ // Use registry value if explicitly set by admin; fall back to OpenVibe default
++ return registry.TOOLS_SUBDOMAIN_BASE?.value || 'openvibe.tools';
++}
++
++function isAllowedOrigin(origin) {
++ if (!origin) return true; // non-browser requests (curl, server-to-server)
++ const norm = normalizeOriginForCors(origin);
++ if (!norm) return false;
++ if (buildAllowedOriginsSet().has(norm)) return true;
++
++ // Wildcard subdomain match for TOOLS_SUBDOMAIN_BASE (e.g. *.openvibe.tools)
++ const subdomainBase = getToolsSubdomainBase();
++ if (subdomainBase) {
++ try {
++ const u = new URL(norm);
++ // Only trust https:// subdomains in production
++ if (u.protocol === 'https:' && u.hostname.endsWith('.' + subdomainBase)) return true;
++ // In dev, allow http as well
++ if (process.env.NODE_ENV !== 'production' && u.hostname.endsWith('.' + subdomainBase)) return true;
++ } catch { /* ignore */ }
++ }
++
++ return false;
++}
++
++app.use(cors({
++ origin(origin, callback) {
++ if (!origin) return callback(null, true); // non-browser / server-to-server
++ if (isAllowedOrigin(origin)) return callback(null, true);
++ console.warn(`[CORS] Rejected origin: "${origin}" | allowed set: ${[...buildAllowedOriginsSet()].join(', ')} | subdomainBase: ${getToolsSubdomainBase()}`);
++ return callback(new Error('Origin not allowed by CORS'));
++ },
++ credentials: true,
++}));
++
++// ── Rate Limiting ────────────────────────────────────────────
++app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));
++app.use('/api/auth/', rateLimit({ windowMs: 15 * 60_000, max: 30, skipSuccessfulRequests: true }));
++
++// ── Database ─────────────────────────────────────────────────
++const db = initDb(config.db.path);
++urlRegistry.initializeUrlRegistry(db);
++urlRegistry.seedBootstrapRegistry(db, process.env, config.bootstrapProfile);
++ensureAdminUser(db, config);
++const resolvedRegistry = urlRegistry.getResolvedRegistry(db, process.env);
++
++// Apply resolved network registry values to runtime config
++if (resolvedRegistry.OV_NETWORK_URL?.value) {
++ config.networkUrl = resolvedRegistry.OV_NETWORK_URL.value;
++ config.jwt.issuer = resolvedRegistry.OV_NETWORK_URL.value;
++ if (!process.env.BASE_URL) config.baseUrl = resolvedRegistry.OV_NETWORK_URL.value;
++}
++if (resolvedRegistry.OV_NETWORK_LOGIN_URL?.value) {
++ config.loginUrl = resolvedRegistry.OV_NETWORK_LOGIN_URL.value;
++} else {
++ config.loginUrl = config.loginUrl || config.networkUrl;
++}
++if (resolvedRegistry.OV_NETWORK_INTERNAL_URL?.value) config.internalUrl = resolvedRegistry.OV_NETWORK_INTERNAL_URL.value;
++if (resolvedRegistry.OV_LIVE_INTERNAL_URL?.value) config.services.live.internalUrl = resolvedRegistry.OV_LIVE_INTERNAL_URL.value;
++if (resolvedRegistry.OV_TOOLS_INTERNAL_URL?.value) config.services.tools.internalUrl = resolvedRegistry.OV_TOOLS_INTERNAL_URL.value;
++if (resolvedRegistry.OV_GAMES_INTERNAL_URL?.value) config.services.games.internalUrl = resolvedRegistry.OV_GAMES_INTERNAL_URL.value;
++if (resolvedRegistry.OV_MEDIA_INTERNAL_URL?.value) config.services.media.internalUrl = resolvedRegistry.OV_MEDIA_INTERNAL_URL.value;
++
++// Expose a canonical registry payload for admin/internal consumers
++app.locals.urlRegistry = resolvedRegistry;
++// Make registry accessible to signToken via config._registry
++config._registry = resolvedRegistry;
++
++// ── Analytics Tracking ────────────────────────────────────────
++const analytics = new AnalyticsTracker(db, 'openvibe-network');
++app.locals.analytics = analytics;
++app.use(analytics.middleware());
++
++// ── Extract bearer token (available as req.token for optional-auth proxies) ─
++app.use((req, _res, next) => {
++ const ah = req.headers.authorization;
++ req.token = ah?.startsWith('Bearer ') ? ah.slice(7) : req.cookies?.ov_token || null;
++ next();
++});
++
++// ── Load RSA Keys ────────────────────────────────────────────
++let privateKey, publicKey;
++try {
++ privateKey = fs.readFileSync(path.resolve(config.jwt.privateKeyPath), 'utf8');
++ publicKey = fs.readFileSync(path.resolve(config.jwt.publicKeyPath), 'utf8');
++ console.log('[Auth] RS256 keypair loaded');
++} catch (err) {
++ console.warn('[Auth] RSA keypair not found — generating ephemeral keys for development');
++ console.warn('[Auth] Run: openssl genrsa -out data/keys/private.pem 2048');
++ console.warn('[Auth] openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem');
++ // Fall back to HS256 with a random secret for development
++ const crypto = require('crypto');
++ privateKey = crypto.randomBytes(64).toString('hex');
++ publicKey = privateKey;
++ console.warn('[Auth] Using ephemeral HS256 key — DO NOT use in production');
++}
++
++// Make keys available to route modules
++app.locals.db = db;
++app.locals.privateKey = privateKey;
++app.locals.publicKey = publicKey;
++app.locals.config = config;
++
++// ── Initialize Services ──────────────────────────────────────
++const notificationService = new NotificationService(db);
++const emailService = new EmailService(db);
++app.locals.notificationService = notificationService;
++app.locals.emailService = emailService;
++
++// Discord bot service
++const discordService = new DiscordService(db);
++app.locals.discordService = discordService;
++discordService.init().catch(err => console.error('[Discord] Init error:', err.message));
++
++// requireAuth helper (needed by route factories)
++const authRoutes = require('./auth/routes');
++const jwt = require('jsonwebtoken');
++function requireAuth(req, res, next) {
++ const authHeader = req.headers.authorization;
++ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
++ if (!token) return res.status(401).json({ error: 'Authentication required' });
++ const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
++ try {
++ const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
++ if (!user) return res.status(401).json({ error: 'User not found' });
++ if (user.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: user.ban_reason });
++ if (user.token_valid_after) {
++ const tokenIat = decoded.iat * 1000;
++ const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
++ if (tokenIat < validAfter) return res.status(401).json({ error: 'Token revoked' });
++ }
++ req.user = user;
++ req.token = token;
++ next();
++ } catch {
++ return res.status(401).json({ error: 'Invalid or expired token' });
++ }
++}
++
++// ── Routes ───────────────────────────────────────────────────
++// Public key endpoint (services fetch this to verify JWTs)
++app.get('/api/.well-known/jwks', (_req, res) => {
++ res.json({ public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' });
++});
++
++// Health check
++app.get('/api/health', (_req, res) => {
++ res.json({ status: 'ok', service: 'openvibe-network', version: '1.0.0' });
++});
++
++// Brand info (used by all frontends for consistent URLs/names)
++app.get('/api/brand', (_req, res) => res.json(BRAND));
++
++// Auth routes (SSO provider)
++app.use('/api/auth', authRoutes);
++
++// Discord account linking (OAuth2 flow)
++app.use('/api/auth/discord', requireAuth, require('./auth/discord-link'));
++
++// OAuth2 authorization endpoints
++app.use('/oauth', require('./auth/oauth-routes'));
++
++// Theme API
++app.use('/api/themes', require('./themes/routes'));
++
++// OpenCoins wallet API (user-facing, Bearer JWT)
++app.use('/api/coins', createCoinsRoutes(db, requireAuth));
++
++// Notification API (authenticated users)
++app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
++
++// Push Notifications API
++const pushService = require('./push/push-service');
++pushService.initVapid(db);
++app.use('/api/push', requireAuth, require('./push/routes'));
++
++// Discord bot admin API
++function requireAdmin(req, res, next) {
++ if (!req.user || req.user.role !== 'admin') {
++ return res.status(403).json({ error: 'Admin access required' });
++ }
++ next();
++}
++app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
++
++// Deploy (TLS / Nginx / Infrastructure) admin API
++app.use('/api/admin/deploy', createDeployRoutes(db, requireAuth));
++
++// SSH access provisioning info — powers the admin "SSH" tab.
++// Returns non-secret connection context (host, project roots, services). The
++// actual host lives in env (SSH_SERVER_HOST) so no infra detail is committed.
++app.get('/api/admin/ssh-info', requireAuth, requireAdmin, (req, res) => {
++ const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
++ res.json({
++ ok: true,
++ host: process.env.SSH_SERVER_HOST || '',
++ ownerUsername: OWNER,
++ projectsRoot: process.env.SSH_PROJECTS_ROOT || '/opt/openvibe.network /opt/openvibe.live',
++ services: ['openvibe-network', 'openvibe-live'],
++ is_owner: !!(req.user.username && req.user.username.toLowerCase() === OWNER),
++ });
++});
++
++// Setup API for first-run bootstrapping and status checks
++app.use('/api/setup', createSetupRoutes(db, config));
++
++// Admin panel API
++app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, requireAuth));
++
++// Analytics admin API
++const createAnalyticsRoutes = require('./admin/analytics-routes');
++app.use('/api/admin/analytics', createAnalyticsRoutes(analytics, requireAuth, config));
++
++// ── Admin Proxy to OpenVibe.Live ──────────────────────────────
++// Proxies /api/admin/streamer/* → openvibe.live /api/admin/*
++// This lets the unified admin panel on openvibe.network manage OpenVibe.Live features.
++const OPENVIBELIVE_INTERNAL = config.services.live.internalUrl;
++app.use('/api/admin/streamer', requireAuth, (req, res, next) => {
++ if (!req.user || req.user.role !== 'admin') {
++ return res.status(403).json({ error: 'Admin access required' });
++ }
++ next();
++}, async (req, res) => {
++ return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/admin${req.url}`, 'Streamer proxy error');
++});
++
++// Proxy /api/mod/* for moderator routes
++app.use('/api/admin/streamer-mod', requireAuth, (req, res, next) => {
++ if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'global_mod')) {
++ return res.status(403).json({ error: 'Staff access required' });
++ }
++ next();
++}, async (req, res) => {
++ return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/mod${req.url}`, 'Mod proxy error');
++});
++
++app.use('/api/admin/streamer-tts', requireAuth, (req, res, next) => {
++ if (!req.user || req.user.role !== 'admin') {
++ return res.status(403).json({ error: 'Admin access required' });
++ }
++ next();
++}, async (req, res) => {
++ return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/tts${req.url}`, 'TTS proxy error');
++});
++
++app.use('/api/admin/streamer-funds', requireAuth, (req, res, next) => {
++ if (!req.user || req.user.role !== 'admin') {
++ return res.status(403).json({ error: 'Admin access required' });
++ }
++ next();
++}, async (req, res) => {
++ return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/funds${req.url}`, 'Funds proxy error');
++});
++
++app.use('/api/admin/streamer-pastes', requireAuth, (req, res, next) => {
++ if (!req.user || req.user.role !== 'admin') {
++ return res.status(403).json({ error: 'Admin access required' });
++ }
++ next();
++}, async (req, res) => {
++ return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/pastes${req.url}`, 'Pastes proxy error');
++});
++
++// Internal API (server-to-server, X-Internal-Key)
++app.use('/internal', require('./internal/routes'));
++
++// ── Host Canonicalization ────────────────────────────────────
++// my.openvibe.network is legacy — 301 to the apex (nginx will also do this).
++app.use((req, res, next) => {
++ if (getRequestHost(req) === 'my.openvibe.network') {
++ const targetPath = (req.path === '/' || req.path === '/my' || req.path === '/my.html') ? '/my' : req.path;
++ const query = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
++ return res.redirect(301, `https://openvibe.network${targetPath}${query}`);
++ }
++ next();
++});
++
++// ── Static Files ─────────────────────────────────────────────
++app.get(['/login.html', '/admin.html'], (req, res) => {
++ if (req.path === '/login.html') return redirectWithoutHtml(req, res, '/login');
++ if (req.path === '/admin.html') return redirectWithoutHtml(req, res, '/admin');
++ return res.status(404).end();
++});
++
++// Landing page at the apex root
++app.get(['/', '/index.html'], (req, res) => {
++ if (req.path === '/index.html') return redirectWithoutHtml(req, res, '/');
++ return sendLandingPage(res);
++});
++
++// Account hub (my.html) — the apex hosts the account hub under /my
++// plus its client-routed sections.
++app.get(['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences'], (req, res) => {
++ if (req.path === '/my.html') return redirectWithoutHtml(req, res, '/my');
++ return sendMyAccountApp(res);
++});
++
++app.use(express.static(path.join(__dirname, '..', 'public'), {
++ setHeaders(res, filePath) {
++ if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
++ res.setHeader('Cache-Control', 'no-cache');
++ }
++ },
++}));
++
++// Serve openvibe-shared client-side libs (notification-ui.js, navbar.js, etc.)
++// Accessible at https://openvibe.network/shared/notification-ui.js etc.
++const sharedPath = path.resolve(__dirname, '..', 'packages', 'openvibe-shared');
++app.use('/shared', express.static(sharedPath, {
++ setHeaders(res, filePath) {
++ res.setHeader('Access-Control-Allow-Origin', '*');
++ // Override helmet's same-origin policies so other domains can load these scripts
++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
++ res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
++ res.setHeader('Cache-Control', 'public, max-age=300');
++ },
++}));
++
++// Avatar serving
++const avatarDir = path.resolve(config.avatars.path);
++if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
++app.use('/data/avatars', express.static(avatarDir, { maxAge: '7d' }));
++
++// Serve clean auth + recovery routes
++app.get(['/login', '/forgot-password', '/reset-password'], (req, res) => {
++ res.sendFile(path.join(__dirname, '..', 'public', 'login.html'));
++});
++
++// Admin panel — serve the SPA for /admin and any client-routed sub-path
++// (e.g. /admin/settings, /admin/analytics/overview). Auth is checked client-side.
++app.get(['/admin', '/admin/*'], (req, res) => {
++ res.sendFile(path.join(__dirname, '..', 'public', 'admin.html'));
++});
++
++app.get('*', (req, res) => {
++ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) {
++ return res.status(404).json({ error: 'Not found' });
++ }
++ // Unknown paths fall through to the account hub SPA (apex is the account-hub host)
++ return sendMyAccountApp(res);
++});
++
++// ── Start ────────────────────────────────────────────────────
++app.listen(config.port, config.host, () => {
++ console.log(`\n╔═══════════════════════════════════════╗`);
++ console.log(`║ OpenVibe.Network — Identity Service ║`);
++ console.log(`╠═══════════════════════════════════════╣`);
++ console.log(`║ Port: ${String(config.port).padEnd(30)}║`);
++ console.log(`║ URL: ${config.baseUrl.padEnd(30)}║`);
++ console.log(`║ Auth: ${config.loginUrl.padEnd(30)}║`);
++ console.log(`╚═══════════════════════════════════════╝\n`);
++
++ // ── Periodic Maintenance ─────────────────────────────────
++ // Clean expired notifications every hour
++ setInterval(() => notificationService.maintenance(), 60 * 60 * 1000);
++
++ // Process email queue every 2 minutes
++ setInterval(() => emailService.processQueue(notificationService), 2 * 60 * 1000);
++
++ // Clean expired sessions daily
++ setInterval(() => {
++ const cleaned = db.prepare("DELETE FROM user_sessions WHERE expires_at < datetime('now') OR is_active = 0").run().changes;
++ if (cleaned > 0) console.log(`[Sessions] Cleaned ${cleaned} expired sessions`);
++ }, 24 * 60 * 60 * 1000);
++});
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
nnew file mode 100644
iindex 00000000..a244af3a
---- /dev/null
++++ b/server/internal/routes.js
@@@ -0,0 +1,586 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — Internal Server-to-Server API
++// Used by OpenVibe.Live (3000), OpenVibe.Tools (4001),
++// OpenVibe.Games (8000), and OpenVibe.Media (4100) to verify
++// tokens, sync users, move OpenCoins, and fetch shared data.
++// Protected by X-Internal-Key header.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const router = express.Router();
++const urlRegistry = require('../url-registry');
++const wallet = require('../coins/wallet');
++
++function getDb(req) { return req.app.locals.db; }
++function getConfig(req) { return req.app.locals.config; }
++
++// ── Internal Key Middleware ──────────────────────────────────
++function requireInternalKey(req, res, next) {
++ const key = req.headers['x-internal-key'];
++ const config = getConfig(req);
++ if (!key || key !== config.internalKey) {
++ return res.status(403).json({ error: 'Invalid or missing internal key' });
++ }
++ next();
++}
++
++router.use(requireInternalKey);
++
++// ── Verify Token ─────────────────────────────────────────────
++// Other services call this to validate an access token and get user data.
++// Avoids each service needing the public key locally (though they can — this is a convenience).
++const jwt = require('jsonwebtoken');
++
++router.post('/verify-token', (req, res) => {
++ const { token } = req.body;
++ if (!token) return res.status(400).json({ error: 'token required' });
++
++ const publicKey = req.app.locals.publicKey;
++ const config = getConfig(req);
++ const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
++
++ try {
++ const decoded = jwt.verify(token, publicKey, {
++ algorithms: [algorithm],
++ issuer: config.jwt.issuer
++ });
++ const db = getDb(req);
++ const user = db.prepare('SELECT id, username, display_name, role, avatar_url, color FROM users WHERE id = ?').get(decoded.sub || decoded.id);
++ res.json({ valid: true, decoded, user: user || null });
++ } catch (err) {
++ res.json({ valid: false, error: err.message });
++ }
++});
++
++// ── Get User by ID ───────────────────────────────────────────
++router.get('/users/:id', (req, res) => {
++ const db = getDb(req);
++ const user = db.prepare(`
++ SELECT id, username, display_name, role, avatar_url, color, bio, created_at
++ FROM users WHERE id = ?
++ `).get(req.params.id);
++ if (!user) return res.status(404).json({ error: 'User not found' });
++ res.json({ user });
++});
++
++// ── Lookup User by Username ──────────────────────────────────
++router.get('/users/by-username/:username', (req, res) => {
++ const db = getDb(req);
++ const user = db.prepare(`
++ SELECT id, username, display_name, role, avatar_url, color, bio, created_at
++ FROM users WHERE username = ?
++ `).get(req.params.username.toLowerCase());
++ if (!user) return res.status(404).json({ error: 'User not found' });
++ res.json({ user });
++});
++
++// ── Bulk User Lookup ─────────────────────────────────────────
++router.post('/users/bulk', (req, res) => {
++ const { ids } = req.body;
++ if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' });
++ if (ids.length > 200) return res.status(400).json({ error: 'Max 200 ids per request' });
++
++ const db = getDb(req);
++ const placeholders = ids.map(() => '?').join(',');
++ const users = db.prepare(`
++ SELECT id, username, display_name, role, avatar_url, color
++ FROM users WHERE id IN (${placeholders})
++ `).all(...ids);
++ res.json({ users });
++});
++
++// ── Get User Theme Preference ────────────────────────────────
++router.get('/users/:id/theme', (req, res) => {
++ const db = getDb(req);
++ const prefs = db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(req.params.id);
++ if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null });
++ let custom = null;
++ try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
++ res.json({ theme_id: prefs.theme_id, custom_variables: custom });
++});
++
++// ── Sync Linked Account ──────────────────────────────────────
++// When a user connects their OpenVibe.Live or OpenVibe.Games account,
++// the service reports the link here.
++router.post('/link-account', (req, res) => {
++ const { user_id, service, service_user_id, service_username } = req.body;
++ if (!user_id || !service || !service_user_id) {
++ return res.status(400).json({ error: 'user_id, service, and service_user_id required' });
++ }
++
++ const db = getDb(req);
++ db.prepare(`
++ INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at)
++ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(user_id, service) DO UPDATE SET
++ service_user_id = ?,
++ service_username = ?,
++ linked_at = CURRENT_TIMESTAMP
++ `).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);
++
++ res.json({ success: true });
++});
++
++// ── Get Linked Accounts ──────────────────────────────────────
++router.get('/users/:id/linked-accounts', (req, res) => {
++ const db = getDb(req);
++ const accounts = db.prepare('SELECT service, service_user_id, service_username, linked_at FROM linked_accounts WHERE user_id = ?').all(req.params.id);
++ res.json({ accounts });
++});
++
++// ── Audit Log ────────────────────────────────────────────────
++router.post('/audit', (req, res) => {
++ const { user_id, action, details, ip } = req.body;
++ if (!action) return res.status(400).json({ error: 'action required' });
++ const db = getDb(req);
++ db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(user_id || null, action, details || null, ip || null);
++ res.json({ success: true });
++});
++
++// ── Health / Stats ───────────────────────────────────────────
++router.get('/stats', (req, res) => {
++ const db = getDb(req);
++ const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
++ const themeCount = db.prepare('SELECT COUNT(*) as count FROM themes').get().count;
++ const linkedCount = db.prepare('SELECT COUNT(*) as count FROM linked_accounts').get().count;
++ const notifCount = db.prepare('SELECT COUNT(*) as count FROM notifications').get().count;
++ res.json({ users: userCount, themes: themeCount, linked_accounts: linkedCount, notifications: notifCount });
++});
++
++router.get('/url-registry/resolved', (req, res) => {
++ try {
++ const db = getDb(req);
++ const resolved = urlRegistry.getResolvedRegistry(db, process.env);
++ res.json({ ok: true, registry: resolved });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++});
++
++// ═══════════════════════════════════════════════════════════════
++// OpenCoins Wallet (server-to-server)
++// Atomic better-sqlite3 transactions with idempotency_key dedupe.
++// Repeating a key returns the original result (no double-apply).
++// user_id is ALWAYS the Network (SSO) user id.
++// ═══════════════════════════════════════════════════════════════
++
++function handleWalletError(res, err) {
++ if (err instanceof wallet.WalletError) {
++ return res.status(err.status).json(err.body);
++ }
++ console.error('[Internal] Wallet error:', err);
++ return res.status(500).json({ error: 'wallet_internal_error' });
++}
++
++// ── POST /internal/coins/credit ──────────────────────────────
++// Body: { user_id, app_id, amount (positive int), reason, ref?, idempotency_key }
++// → { balance }
++router.post('/coins/credit', (req, res) => {
++ try {
++ const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
++ const result = wallet.credit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
++ res.json({ balance: result.balance });
++ } catch (err) {
++ handleWalletError(res, err);
++ }
++});
++
++// ── POST /internal/coins/debit ───────────────────────────────
++// Same body → { balance }; insufficient funds → 409 { error: 'insufficient_funds', balance }
++router.post('/coins/debit', (req, res) => {
++ try {
++ const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
++ const result = wallet.debit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
++ res.json({ balance: result.balance });
++ } catch (err) {
++ handleWalletError(res, err);
++ }
++});
++
++// ── POST /internal/coins/transfer ────────────────────────────
++// Body: { from_user_id, to_user_id, app_id, amount, reason, ref?, idempotency_key }
++// → { from_balance, to_balance } (atomic)
++router.post('/coins/transfer', (req, res) => {
++ try {
++ const { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
++ const result = wallet.transfer(getDb(req), { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key });
++ res.json({ from_balance: result.from_balance, to_balance: result.to_balance });
++ } catch (err) {
++ handleWalletError(res, err);
++ }
++});
++
++// ═══════════════════════════════════════════════════════
++// Stream-Live Event Handler
++// OpenVibe.Live calls this when a stream goes live.
++// Handles: Discord alerts (via bot), push notifications to
++// followers + "all streamer" subscribers.
++// ═══════════════════════════════════════════════════════════════
++
++router.post('/events/stream-live', async (req, res) => {
++ const { streamer, stream } = req.body;
++ if (!streamer?.username || !stream?.id) {
++ return res.status(400).json({ error: 'streamer and stream objects required' });
++ }
++
++ const results = { discord: null, notifications: null };
++
++ // ── Discord Alert ────────────────────────────────────────
++ const discordService = req.app.locals.discordService;
++ if (discordService) {
++ try {
++ results.discord = await discordService.sendLiveAlert(streamer, stream);
++ } catch (err) {
++ results.discord = { sent: false, reason: 'error', error: err.message };
++ }
++ }
++
++ // ── Push Notifications to Followers ──────────────────────
++ const notifService = req.app.locals.notificationService;
++ if (notifService) {
++ const db = getDb(req);
++ const displayName = streamer.display_name || streamer.username;
++ const notifData = {
++ type: 'STREAM_LIVE',
++ title: `${displayName} is live!`,
++ message: stream.title || 'Started streaming',
++ icon: '🔴',
++ sender_id: streamer.id || null,
++ sender_name: displayName,
++ sender_avatar: streamer.avatar_url || null,
++ service: 'live',
++ url: `https://openvibe.live/${streamer.username}`,
++ rich_content: {
++ thumbnail: streamer.avatar_url || null,
++ context: {
++ stream_id: stream.id,
++ username: streamer.username,
++ title: stream.title || 'Started streaming',
++ protocol: stream.protocol || null,
++ },
++ },
++ };
++
++ // Find followers of this streamer in openvibe.network
++ const followerRows = db.prepare(
++ 'SELECT follower_id FROM follows WHERE followed_id = ?'
++ ).all(streamer.id);
++ const followerIds = followerRows.map(r => r.follower_id);
++
++ // Find users who opted into "all live" notifications
++ const allLiveRows = db.prepare(
++ "SELECT user_id FROM notification_preferences WHERE category = 'stream_live_all' AND enabled = 1"
++ ).all();
++ const allLiveUserIds = allLiveRows.map(r => r.user_id);
++
++ // Merge and deduplicate
++ const targetIds = [...new Set([...followerIds, ...allLiveUserIds])];
++
++ if (targetIds.length > 0) {
++ try {
++ const created = notifService.createBulk(targetIds, notifData);
++ results.notifications = { sent: created.length, total: targetIds.length };
++ } catch (err) {
++ results.notifications = { error: err.message };
++ }
++ } else {
++ results.notifications = { sent: 0, total: 0 };
++ }
++ }
++
++ res.json({ ok: true, ...results });
++});
++
++// ═══════════════════════════════════════════════════════════════
++// Cross-Service Notification Push
++// Services call these to create notifications for users without
++// needing direct DB access.
++// ═══════════════════════════════════════════════════════════════
++
++// ── Push Single Notification ─────────────────────────────────
++// POST /internal/notifications/push
++// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }
++router.post('/notifications/push', (req, res) => {
++ const notifService = req.app.locals.notificationService;
++ if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
++
++ const { user_id, ...data } = req.body;
++ if (!user_id) return res.status(400).json({ error: 'user_id required' });
++
++ try {
++ const notification = notifService.create({ user_id, ...data });
++ if (!notification) return res.json({ ok: true, skipped: true, reason: 'User has category disabled' });
++ res.json({ ok: true, notification });
++ } catch (err) {
++ console.error('[Internal] Notification push error:', err);
++ res.status(500).json({ error: err.message });
++ }
++});
++
++// ── Push Bulk Notifications ──────────────────────────────────
++// POST /internal/notifications/push-bulk
++// Body: { user_ids: [], type, title, message, ... }
++router.post('/notifications/push-bulk', (req, res) => {
++ const notifService = req.app.locals.notificationService;
++ if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
++
++ const { user_ids, ...data } = req.body;
++ if (!Array.isArray(user_ids) || user_ids.length === 0) {
++ return res.status(400).json({ error: 'user_ids array required' });
++ }
++ if (user_ids.length > 1000) {
++ return res.status(400).json({ error: 'Max 1000 user_ids per request' });
++ }
++
++ try {
++ const results = notifService.createBulk(user_ids, data);
++ res.json({ ok: true, sent: results.length, total: user_ids.length });
++ } catch (err) {
++ console.error('[Internal] Bulk notification push error:', err);
++ res.status(500).json({ error: err.message });
++ }
++});
++
++// ── Get Unread Count for User ────────────────────────────────
++// GET /internal/notifications/unread/:userId
++router.get('/notifications/unread/:userId', (req, res) => {
++ const notifService = req.app.locals.notificationService;
++ if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
++
++ try {
++ const count = notifService.getUnreadCount(parseInt(req.params.userId));
++ res.json({ ok: true, count });
++ } catch (err) {
++ res.status(500).json({ error: err.message });
++ }
++});
++
++// ── Mark Notifications Read by Type ──────────────────────────
++// POST /internal/notifications/mark-read
++// Body: { user_id, type, url_pattern? }
++router.post('/notifications/mark-read', (req, res) => {
++ const notifService = req.app.locals.notificationService;
++ if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
++
++ const { user_id, type, url_pattern } = req.body;
++ if (!user_id || !type) return res.status(400).json({ error: 'user_id and type required' });
++
++ try {
++ const changes = notifService.markReadByType(parseInt(user_id), type, url_pattern || null);
++ res.json({ ok: true, marked: changes });
++ } catch (err) {
++ console.error('[Internal] Mark read by type error:', err);
++ res.status(500).json({ error: err.message });
++ }
++});
++
++// ── Resolve User for Notification Context ────────────────────
++// POST /internal/notifications/resolve-users
++// Body: { usernames: [] } → returns user IDs + display info
++router.post('/notifications/resolve-users', (req, res) => {
++ const { usernames } = req.body;
++ if (!Array.isArray(usernames) || usernames.length === 0) {
++ return res.status(400).json({ error: 'usernames array required' });
++ }
++ const db = getDb(req);
++ const placeholders = usernames.map(() => '?').join(',');
++ const users = db.prepare(`
++ SELECT id, username, display_name, avatar_url, name_effect, particle_effect
++ FROM users WHERE LOWER(username) IN (${placeholders})
++ `).all(...usernames.map(u => u.toLowerCase()));
++ res.json({ ok: true, users });
++});
++
++// ── Issue Token for Linked User ──────────────────────────────
++// POST /internal/issue-token
++// Body: { user_id }
++// Used by first-party services to get an openvibe.network JWT for
++// users who logged in via password but have a linked account.
++// This enables cross-service features (notifications, themes).
++router.post('/issue-token', (req, res) => {
++ const { user_id } = req.body;
++ if (!user_id) return res.status(400).json({ error: 'user_id required' });
++
++ const db = getDb(req);
++ const config = getConfig(req);
++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(user_id);
++ if (!user) return res.status(404).json({ error: 'User not found' });
++ if (user.is_banned) return res.status(403).json({ error: 'User is banned' });
++
++ const privateKey = req.app.locals.privateKey;
++ const algorithm = privateKey === req.app.locals.publicKey ? 'HS256' : 'RS256';
++
++ const token = jwt.sign(
++ { sub: user.id, username: user.username, role: user.role },
++ privateKey,
++ { algorithm, expiresIn: config.jwt.accessTokenExpiry, issuer: config.jwt.issuer }
++ );
++
++ res.json({ token, user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role, avatar_url: user.avatar_url } });
++});
++
++// ═══════════════════════════════════════════════════════════════
++// Unified Anon Identity Resolution (cross-service)
++// ═══════════════════════════════════════════════════════════════
++
++// ── Resolve Anon by IP ───────────────────────────────────────
++// POST /internal/resolve-anon
++// Body: { ip }
++// Called by first-party services to get or create a unified
++// anon identity for a given IP address. Single source of truth.
++router.post('/resolve-anon', (req, res) => {
++ const { ip } = req.body;
++ if (!ip) return res.status(400).json({ error: 'ip required' });
++
++ const db = getDb(req);
++ const { v4: uuidv4 } = require('uuid');
++
++ try {
++ // Check if this IP already has an anon
++ const byIpLog = db.prepare(`
++ SELECT a.* FROM anon_users a
++ INNER JOIN anon_ip_log l ON l.anon_id = a.id
++ WHERE l.ip = ?
++ ORDER BY a.id ASC LIMIT 1
++ `).get(ip);
++ if (byIpLog) {
++ db.prepare('UPDATE anon_users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(byIpLog.id);
++ db.prepare(`
++ UPDATE anon_ip_log SET last_seen = CURRENT_TIMESTAMP
++ WHERE anon_id = ? AND ip = ?
++ `).run(byIpLog.id, ip);
++ return res.json({
++ anon_number: byIpLog.anon_number,
++ anon_id: `anon_${byIpLog.id}`,
++ display_name: byIpLog.display_name || `Anonymous #${byIpLog.anon_number}`,
++ username: `anon${byIpLog.anon_number}`,
++ is_new: false,
++ });
++ }
++
++ // Check by creating IP
++ const byCreatingIp = db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);
++ if (byCreatingIp) {
++ db.prepare('UPDATE anon_users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(byCreatingIp.id);
++ // Ensure IP log entry exists
++ try {
++ db.prepare('INSERT OR IGNORE INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(byCreatingIp.id, ip);
++ } catch { /* ok */ }
++ return res.json({
++ anon_number: byCreatingIp.anon_number,
++ anon_id: `anon_${byCreatingIp.id}`,
++ display_name: byCreatingIp.display_name || `Anonymous #${byCreatingIp.anon_number}`,
++ username: `anon${byCreatingIp.anon_number}`,
++ is_new: false,
++ });
++ }
++
++ // Create new anon
++ const maxNum = db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get().max || 0;
++ const anonNumber = maxNum + 1;
++ const sessionToken = uuidv4();
++
++ const result = db.prepare(
++ 'INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)'
++ ).run(anonNumber, sessionToken, ip);
++
++ // Log IP
++ try {
++ db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(result.lastInsertRowid, ip);
++ } catch { /* ok */ }
++
++ console.log(`[Internal] New unified anon #${anonNumber} for IP ${ip}`);
++ res.json({
++ anon_number: anonNumber,
++ anon_id: `anon_${result.lastInsertRowid}`,
++ display_name: `Anonymous #${anonNumber}`,
++ username: `anon${anonNumber}`,
++ is_new: true,
++ });
++ } catch (err) {
++ console.error('[Internal] resolve-anon error:', err);
++ res.status(500).json({ error: 'Failed to resolve anon identity' });
++ }
++});
++
++// ── Admin: IP → Anon/Account Lookup ──────────────────────────
++// GET /internal/anon-admin?ip=X
++// Returns all anonymous identities AND registered accounts
++// associated with a given IP address.
++router.get('/anon-admin', (req, res) => {
++ const { ip } = req.query;
++ if (!ip) return res.status(400).json({ error: 'ip query param required' });
++
++ const db = getDb(req);
++
++ try {
++ // Get all anons seen from this IP
++ const anons = db.prepare(`
++ SELECT a.id, a.anon_number, a.display_name, a.ip AS creating_ip,
++ a.total_messages, a.total_commands, a.first_seen, a.last_seen,
++ l.first_seen AS ip_first_seen, l.last_seen AS ip_last_seen
++ FROM anon_users a
++ INNER JOIN anon_ip_log l ON l.anon_id = a.id
++ WHERE l.ip = ?
++ ORDER BY a.anon_number ASC
++ `).all(ip);
++
++ // Get all registered users who have logged in from this IP
++ const users = db.prepare(`
++ SELECT DISTINCT u.id, u.username, u.display_name, u.role, u.is_banned,
++ u.anon_number, u.created_at
++ FROM users u
++ INNER JOIN ip_log il ON il.user_id = u.id
++ WHERE il.ip = ?
++ ORDER BY u.created_at ASC
++ `).all(ip);
++
++ // Get all IPs for each anon (cross-reference)
++ const anonIps = {};
++ for (const a of anons) {
++ const ips = db.prepare('SELECT ip, first_seen, last_seen FROM anon_ip_log WHERE anon_id = ?').all(a.id);
++ anonIps[a.anon_number] = ips;
++ }
++
++ res.json({
++ ip,
++ anonymous_identities: anons,
++ registered_accounts: users,
++ anon_ip_map: anonIps,
++ });
++ } catch (err) {
++ console.error('[Internal] anon-admin error:', err);
++ res.status(500).json({ error: 'Failed to lookup IP data' });
++ }
++});
++
++// ── Admin: List All Anon Identities ──────────────────────────
++// GET /internal/anon-list?limit=100&offset=0
++router.get('/anon-list', (req, res) => {
++ const db = getDb(req);
++ const limit = Math.min(parseInt(req.query.limit) || 100, 500);
++ const offset = parseInt(req.query.offset) || 0;
++
++ try {
++ const total = db.prepare('SELECT COUNT(*) as cnt FROM anon_users').get().cnt;
++ const anons = db.prepare(`
++ SELECT a.id, a.anon_number, a.display_name, a.ip AS creating_ip,
++ a.total_messages, a.total_commands, a.first_seen, a.last_seen
++ FROM anon_users a ORDER BY a.anon_number DESC LIMIT ? OFFSET ?
++ `).all(limit, offset);
++
++ // Attach IP list to each anon
++ for (const a of anons) {
++ a.ips = db.prepare('SELECT ip, last_seen FROM anon_ip_log WHERE anon_id = ?').all(a.id);
++ }
++
++ res.json({ total, anons, limit, offset });
++ } catch (err) {
++ console.error('[Internal] anon-list error:', err);
++ res.status(500).json({ error: 'Failed to list anons' });
++ }
++});
++
++module.exports = router;
ddiff --git a/server/notifications/email-service.js b/server/notifications/email-service.js
nnew file mode 100644
iindex 00000000..1019302a
---- /dev/null
++++ b/server/notifications/email-service.js
@@@ -0,0 +1,462 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Email Service (Resend)
++// Sends transactional emails via Resend API.
++// Only sends for CRITICAL priority + eligible categories.
++// Configuration stored in site_settings and managed via admin UI.
++// ═══════════════════════════════════════════════════════════════
++
++const https = require('https');
++
++/**
++ * Send an email via the Resend REST API.
++ * @param {string} apiKey - Resend API key (re_xxxx)
++ * @param {Object} opts - { from, to, subject, html, text }
++ * @returns {Promise<{id: string}>}
++ */
++function resendSend(apiKey, { from, to, subject, html, text }) {
++ return new Promise((resolve, reject) => {
++ const payload = JSON.stringify({ from, to: Array.isArray(to) ? to : [to], subject, html, text });
++ const req = https.request({
++ hostname: 'api.resend.com',
++ path: '/emails',
++ method: 'POST',
++ headers: {
++ 'Authorization': `Bearer ${apiKey}`,
++ 'Content-Type': 'application/json',
++ 'Content-Length': Buffer.byteLength(payload),
++ },
++ }, (res) => {
++ let body = '';
++ res.on('data', chunk => body += chunk);
++ res.on('end', () => {
++ try {
++ const parsed = JSON.parse(body);
++ if (res.statusCode >= 200 && res.statusCode < 300) {
++ resolve(parsed);
++ } else {
++ const msg = parsed.message || parsed.error?.message || body;
++ reject(new Error(`Resend API ${res.statusCode}: ${msg}`));
++ }
++ } catch {
++ reject(new Error(`Resend API ${res.statusCode}: ${body}`));
++ }
++ });
++ });
++ req.on('error', reject);
++ req.write(payload);
++ req.end();
++ });
++}
++
++class EmailService {
++ constructor(db) {
++ this.db = db;
++ this._enabled = false;
++ this._apiKey = null;
++ this._fromEmail = '[email protected]';
++ this._fromName = 'OpenVibe';
++ this._logDelivery = db.prepare(`
++ INSERT INTO email_delivery_log (email_type, recipient, subject, status, error_message, user_id, notification_id, metadata)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
++ `);
++ this._migrateFromSES();
++ this._loadConfig();
++ }
++
++ /**
++ * One-time migration: copy legacy email settings to the new email_* keys if they exist
++ * and the new keys haven't been set yet.
++ */
++ _migrateFromSES() {
++ const migrations = [
++ ['ses_enabled', 'email_enabled', 'boolean'],
++ ['ses_from_email', 'email_from_address', 'string'],
++ ['ses_from_name', 'email_from_name', 'string'],
++ ['ses_from_email_openvibelive', 'email_from_openvibelive', 'string'],
++ ['ses_from_email_openvibegames', 'email_from_openvibegames', 'string'],
++ ['ses_from_email_openvibenetwork', 'email_from_openvibenetwork', 'string'],
++ ];
++ const setSetting = this.db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ for (const [oldKey, newKey, type] of migrations) {
++ const oldVal = this.db.getSetting(oldKey);
++ if (oldVal != null && oldVal !== '') {
++ setSetting.run(newKey, String(oldVal), type);
++ }
++ }
++ }
++
++ _loadConfig() {
++ this._enabled = this.db.getSetting('email_enabled') === true;
++ this._apiKey = this.db.getSetting('resend_api_key') || null;
++ this._fromEmail = this.db.getSetting('email_from_address') || '[email protected]';
++ this._fromName = this.db.getSetting('email_from_name') || 'OpenVibe';
++
++ if (this._enabled && this._apiKey) {
++ console.log(`[Email] Resend initialized (from: ${this._fromName} <${this._fromEmail}>)`);
++ } else if (this._enabled) {
++ console.warn('[Email] Enabled but missing Resend API key — disabled');
++ this._enabled = false;
++ }
++ }
++
++ /** Reload config (call after admin changes settings). */
++ reload() {
++ this._apiKey = null;
++ this._loadConfig();
++ }
++
++ get isEnabled() { return this._enabled && !!this._apiKey; }
++
++ /**
++ * Resolve the from-address for a given service.
++ * Falls back to the global from-email if no per-service override.
++ */
++ getFromEmail(service) {
++ if (service) {
++ const key = `email_from_${service.replace(/[^a-z0-9]/gi, '').toLowerCase()}`;
++ const override = this.db.getSetting(key);
++ if (override) return override;
++ }
++ return this._fromEmail;
++ }
++
++ /**
++ * Diagnose why the email service isn't ready.
++ * Returns a user-friendly error string, or null if ready.
++ */
++ diagnose() {
++ if (!this._enabled) {
++ const dbEnabled = this.db.getSetting('email_enabled');
++ if (dbEnabled !== true) return 'Email is disabled — enable the toggle in admin panel and save';
++ return 'Email service failed to initialize after being enabled — check server logs';
++ }
++ if (!this._apiKey) return 'Resend API key is missing — enter your API key from resend.com/api-keys';
++ return null;
++ }
++
++ async _sendEmail({ to, subject, htmlBody, textBody, emailType = 'generic', userId = null, notificationId = null, metadata = null, fromEmail = null }) {
++ if (!to) return false;
++
++ if (!this.isEnabled) {
++ const reason = this.diagnose() || 'Email service is disabled or not configured';
++ this._recordDelivery({
++ emailType,
++ recipient: to,
++ subject,
++ status: 'failed',
++ errorMessage: reason,
++ userId,
++ notificationId,
++ metadata,
++ });
++ return false;
++ }
++
++ try {
++ const source = fromEmail || this._fromEmail;
++ const from = `${this._fromName} <${source}>`;
++ await resendSend(this._apiKey, {
++ from,
++ to,
++ subject,
++ html: htmlBody,
++ text: textBody,
++ });
++ this._recordDelivery({
++ emailType,
++ recipient: to,
++ subject,
++ status: 'sent',
++ userId,
++ notificationId,
++ metadata,
++ });
++ return true;
++ } catch (err) {
++ this._recordDelivery({
++ emailType,
++ recipient: to,
++ subject,
++ status: 'failed',
++ errorMessage: err.message,
++ userId,
++ notificationId,
++ metadata,
++ });
++ console.error(`[Email] Send failed (to: ${to}):`, err.message);
++ return false;
++ }
++ }
++
++ /**
++ * Send a notification email.
++ * @param {Object} opts - { to, username, subject, notification }
++ * @returns {Promise<boolean>} true on success
++ */
++ async sendNotificationEmail({ to, username, subject, notification }) {
++ const htmlBody = this._buildEmailHtml({ username, notification });
++ const textBody = this._buildEmailText({ username, notification });
++ const fromEmail = this.getFromEmail(notification.service);
++ const sent = await this._sendEmail({
++ to,
++ subject: subject || notification.title || 'Notification',
++ htmlBody,
++ textBody,
++ emailType: `notification:${notification.type || 'GENERIC'}`,
++ userId: notification.user_id || null,
++ notificationId: notification.id || null,
++ fromEmail,
++ metadata: {
++ category: notification.category || null,
++ priority: notification.priority || null,
++ service: notification.service || null,
++ },
++ });
++ if (sent) {
++ console.log(`[Email] Sent to ${to}: ${subject || notification.title || 'Notification'}`);
++ }
++ return sent;
++ }
++
++ async sendPasswordResetEmail({ to, username, resetUrl, expiresMinutes = 60 }) {
++ const htmlBody = this._buildPasswordResetHtml({ username, resetUrl, expiresMinutes });
++ const textBody = this._buildPasswordResetText({ username, resetUrl, expiresMinutes });
++ const subject = '🔥 OpenVibe — Reset Your Password';
++ const sent = await this._sendEmail({
++ to,
++ subject,
++ htmlBody,
++ textBody,
++ emailType: 'password_reset',
++ metadata: { expiresMinutes },
++ });
++ if (sent) {
++ console.log(`[Email] Password reset email sent to ${to}`);
++ }
++ return sent;
++ }
++
++ /**
++ * Process the email queue — finds critical notifications not yet emailed.
++ * @param {import('./notification-service').NotificationService} notifService
++ */
++ async processQueue(notifService) {
++ if (!this.isEnabled) return;
++
++ const pending = notifService.getPendingEmails();
++ if (pending.length === 0) return;
++
++ console.log(`[Email] Processing ${pending.length} pending email(s)...`);
++
++ for (const notif of pending) {
++ if (!notifService.shouldEmail(notif)) {
++ notifService.markEmailed(notif.id);
++ continue;
++ }
++ const sent = await this.sendNotificationEmail({
++ to: notif.email,
++ username: notif.display_name || notif.username,
++ subject: `🔥 OpenVibe — ${notif.title}`,
++ notification: notif,
++ });
++ if (sent) notifService.markEmailed(notif.id);
++ }
++ }
++
++ // ─── Email Templates ───────────────────────────────────────
++
++ _buildEmailHtml({ username, notification }) {
++ const priorityColor = { low: '#888', normal: '#8b5cf6', high: '#f39c12', critical: '#e74c3c' };
++ const color = priorityColor[notification.priority] || '#8b5cf6';
++
++ return `<!DOCTYPE html>
++<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++<style>
++body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
++.container { max-width: 560px; margin: 0 auto; padding: 24px; }
++.card { background: #22222c; border-radius: 12px; border: 1px solid #333340; overflow: hidden; }
++.header { background: linear-gradient(135deg, #2a2a38, #1a1a24); padding: 24px; text-align: center; border-bottom: 2px solid ${color}; }
++.header .flame { font-size: 32px; margin-bottom: 8px; }
++.header h1 { margin: 0; color: #e0e0e0; font-size: 18px; font-weight: 700; }
++.body { padding: 24px; color: #b0b0b8; font-size: 14px; line-height: 1.6; }
++.body .greeting { color: #e0e0e0; font-weight: 600; margin-bottom: 12px; }
++.notif-box { background: #1a1a24; border-radius: 8px; padding: 16px; margin: 16px 0; border-left: 3px solid ${color}; }
++.notif-box .icon { font-size: 20px; margin-bottom: 6px; }
++.notif-box .title { color: #e0e0e0; font-weight: 600; font-size: 15px; }
++.notif-box .message { color: #b0b0b8; margin-top: 6px; }
++.notif-box .meta { color: #707080; font-size: 11px; margin-top: 8px; }
++.cta { display: inline-block; background: ${color}; color: #fff; text-decoration: none; padding: 10px 24px; border-radius: 8px; font-weight: 600; margin: 16px 0; }
++.footer { padding: 16px 24px; text-align: center; color: #505060; font-size: 11px; border-top: 1px solid #333340; }
++.footer a { color: #8b5cf6; text-decoration: none; }
++</style></head>
++<body><div class="container"><div class="card">
++<div class="header"><div class="flame">🔥</div><h1>OpenVibe</h1></div>
++<div class="body">
++<div class="greeting">Hey ${username || 'there'},</div>
++<p>You have an important notification:</p>
++<div class="notif-box">
++<div class="icon">${notification.icon || '🔔'}</div>
++<div class="title">${notification.title}</div>
++${notification.message ? `<div class="message">${notification.message}</div>` : ''}
++<div class="meta">${notification.service ? `From ${notification.service} · ` : ''}${notification.priority?.toUpperCase()} priority</div>
++</div>
++${notification.url ? `<a class="cta" href="${notification.url}">View Details →</a>` : ''}
++</div>
++<div class="footer">
++<p>You're receiving this because it's a critical notification.</p>
++<p><a href="https://openvibe.network/notifications">Manage notification preferences</a> · <a href="https://openvibe.network">openvibe.network</a></p>
++</div>
++</div></div></body></html>`;
++ }
++
++ _buildEmailText({ username, notification }) {
++ return [
++ `Hey ${username || 'there'},`,
++ '',
++ 'You have an important notification from OpenVibe:',
++ '',
++ `${notification.icon || '🔔'} ${notification.title}`,
++ notification.message || '',
++ '',
++ notification.url ? `View details: ${notification.url}` : '',
++ '',
++ '---',
++ 'Manage preferences: https://openvibe.network/notifications',
++ 'OpenVibe · https://openvibe.network',
++ ].join('\n');
++ }
++
++ _buildPasswordResetHtml({ username, resetUrl, expiresMinutes }) {
++ return `<!DOCTYPE html>
++<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++<style>
++body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
++.container { max-width: 560px; margin: 0 auto; padding: 24px; }
++.card { background: #22222c; border-radius: 12px; border: 1px solid #333340; overflow: hidden; }
++.header { background: linear-gradient(135deg, #2a2a38, #1a1a24); padding: 24px; text-align: center; border-bottom: 2px solid #8b5cf6; }
++.header .flame { font-size: 32px; margin-bottom: 8px; }
++.header h1 { margin: 0; color: #e0e0e0; font-size: 18px; font-weight: 700; }
++.body { padding: 24px; color: #b0b0b8; font-size: 14px; line-height: 1.6; }
++.body .greeting { color: #e0e0e0; font-weight: 600; margin-bottom: 12px; }
++.notice { background: #1a1a24; border-radius: 8px; padding: 16px; margin: 16px 0; border-left: 3px solid #8b5cf6; }
++.cta { display: inline-block; background: #8b5cf6; color: #fff !important; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 700; margin: 16px 0; }
++.muted { color: #707080; font-size: 12px; }
++.footer { padding: 16px 24px; text-align: center; color: #505060; font-size: 11px; border-top: 1px solid #333340; }
++.footer a { color: #8b5cf6; text-decoration: none; }
++</style></head>
++<body><div class="container"><div class="card">
++<div class="header"><div class="flame">🔥</div><h1>Reset Your Password</h1></div>
++<div class="body">
++<div class="greeting">Hey ${this._escapeHtml(username || 'there')},</div>
++<p>We received a request to reset your OpenVibe password.</p>
++<div class="notice">
++<strong style="color:#e0e0e0">This link expires in ${expiresMinutes} minutes.</strong>
++<div class="muted" style="margin-top:8px">If you did not request this, you can safely ignore this email.</div>
++</div>
++<a class="cta" href="${resetUrl}">Reset Password →</a>
++<p class="muted">If the button does not work, paste this URL into your browser:</p>
++<p class="muted" style="word-break:break-all">${resetUrl}</p>
++</div>
++<div class="footer">
++<p>For your security, this link can only be used once.</p>
++<p><a href="https://openvibe.network">openvibe.network</a></p>
++</div>
++</div></div></body></html>`;
++ }
++
++ _buildPasswordResetText({ username, resetUrl, expiresMinutes }) {
++ return [
++ `Hey ${username || 'there'},`,
++ '',
++ 'We received a request to reset your OpenVibe password.',
++ `This link expires in ${expiresMinutes} minutes.`,
++ '',
++ `Reset your password: ${resetUrl}`,
++ '',
++ 'If you did not request this, you can safely ignore this email.',
++ '',
++ 'OpenVibe · https://openvibe.network',
++ ].join('\n');
++ }
++
++ _escapeHtml(value) {
++ return String(value ?? '')
++ .replace(/&/g, '&amp;')
++ .replace(/</g, '&lt;')
++ .replace(/>/g, '&gt;')
++ .replace(/"/g, '&quot;')
++ .replace(/'/g, '&#39;');
++ }
++
++ /**
++ * Get current email service configuration status (for admin panel).
++ */
++ getStatus() {
++ const apiKey = this.db.getSetting('resend_api_key') || '';
++ const issue = this.diagnose();
++ return {
++ enabled: this._enabled,
++ ready: this.isEnabled,
++ issue: issue || null,
++ provider: 'resend',
++ from_email: this._fromEmail,
++ from_name: this._fromName,
++ from_email_openvibelive: this.db.getSetting('email_from_openvibelive') || '',
++ from_email_openvibegames: this.db.getSetting('email_from_openvibegames') || '',
++ from_email_openvibenetwork: this.db.getSetting('email_from_openvibenetwork') || '',
++ hasApiKey: !!apiKey,
++ // Masked API key for display (show first 6 chars + last 4)
++ api_key: apiKey ? apiKey.slice(0, 6) + '••••' + apiKey.slice(-4) : '',
++ };
++ }
++
++ /**
++ * Send a test email (admin only).
++ * Throws with a descriptive error if email service is not ready.
++ */
++ async sendTestEmail(to) {
++ const issue = this.diagnose();
++ if (issue) throw new Error(issue);
++
++ const subject = '🔥 OpenVibe — Test Email';
++ const notification = {
++ icon: '🧪',
++ title: 'Test Email',
++ message: 'If you received this, Resend email is configured correctly!',
++ priority: 'normal',
++ service: 'network',
++ url: 'https://openvibe.network/admin',
++ };
++ const sent = await this._sendEmail({
++ to,
++ subject,
++ htmlBody: this._buildEmailHtml({ username: 'Admin', notification }),
++ textBody: this._buildEmailText({ username: 'Admin', notification }),
++ emailType: 'test',
++ metadata: { source: 'admin', provider: 'resend' },
++ });
++ if (!sent) throw new Error('Email send failed — check the delivery log below for details');
++ return true;
++ }
++
++ _recordDelivery({ emailType, recipient, subject, status, errorMessage = null, userId = null, notificationId = null, metadata = null }) {
++ try {
++ this._logDelivery.run(
++ emailType,
++ recipient,
++ subject || null,
++ status,
++ errorMessage,
++ userId,
++ notificationId,
++ metadata ? JSON.stringify(metadata) : null,
++ );
++ } catch (err) {
++ console.warn('[Email] Failed to record email delivery log:', err.message);
++ }
++ }
++}
++
++module.exports = { EmailService };
ddiff --git a/server/notifications/notification-service.js b/server/notifications/notification-service.js
nnew file mode 100644
iindex 00000000..1271f353
---- /dev/null
++++ b/server/notifications/notification-service.js
@@@ -0,0 +1,311 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Notification Service — Server-side notification management
++// Creates, stores, queries, and cleans up notifications.
++// Also handles cross-service push via internal API.
++// ═══════════════════════════════════════════════════════════════
++
++const { v4: uuidv4 } = require('uuid');
++const { TYPES, PRIORITY, EMAIL_ELIGIBLE_CATEGORIES } = require('openvibe-shared/notifications');
++
++class NotificationService {
++ constructor(db) {
++ this.db = db;
++ this._prepareStatements();
++ }
++
++ _prepareStatements() {
++ const db = this.db;
++
++ this._insertNotif = db.prepare(`
++ INSERT INTO notifications (id, user_id, type, category, priority, title, message, icon,
++ sender_id, sender_name, sender_avatar, service, url, rich_content, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ `);
++
++ this._getById = db.prepare('SELECT * FROM notifications WHERE id = ?');
++
++ this._getForUser = db.prepare(`
++ SELECT * FROM notifications
++ WHERE user_id = ? AND is_dismissed = 0
++ ORDER BY created_at DESC
++ LIMIT ? OFFSET ?
++ `);
++
++ this._getUnreadForUser = db.prepare(`
++ SELECT * FROM notifications
++ WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0
++ ORDER BY created_at DESC
++ LIMIT ? OFFSET ?
++ `);
++
++ this._getByCategory = db.prepare(`
++ SELECT * FROM notifications
++ WHERE user_id = ? AND category = ? AND is_dismissed = 0
++ ORDER BY created_at DESC
++ LIMIT ? OFFSET ?
++ `);
++
++ this._unreadCount = db.prepare(`
++ SELECT COUNT(*) as count FROM notifications
++ WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0
++ `);
++
++ this._unreadCountByCategory = db.prepare(`
++ SELECT category, COUNT(*) as count FROM notifications
++ WHERE user_id = ? AND is_read = 0 AND is_dismissed = 0
++ GROUP BY category
++ `);
++
++ this._markRead = db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?');
++ this._markAllRead = db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0');
++ this._markReadByCategory = db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id = ? AND category = ? AND is_read = 0');
++ this._markReadByType = db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id = ? AND type = ? AND is_read = 0');
++ this._markReadByTypeAndUrl = db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id = ? AND type = ? AND url LIKE ? AND is_read = 0');
++ this._dismiss = db.prepare('UPDATE notifications SET is_dismissed = 1 WHERE id = ? AND user_id = ?');
++ this._dismissAll = db.prepare('UPDATE notifications SET is_dismissed = 1 WHERE user_id = ?');
++ this._markEmailed = db.prepare('UPDATE notifications SET is_emailed = 1 WHERE id = ?');
++
++ this._deleteExpired = db.prepare("DELETE FROM notifications WHERE expires_at IS NOT NULL AND expires_at < datetime('now')");
++ this._deleteOld = db.prepare("DELETE FROM notifications WHERE created_at < datetime('now', ?)");
++
++ this._getPrefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?');
++ this._getPrefByCategory = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ? AND category = ?');
++ this._upsertPref = db.prepare(`
++ INSERT INTO notification_preferences (user_id, category, enabled, sound, toasts, email)
++ VALUES (?, ?, ?, ?, ?, ?)
++ ON CONFLICT(user_id, category) DO UPDATE SET enabled = ?, sound = ?, toasts = ?, email = ?
++ `);
++ this._deletePrefs = db.prepare('DELETE FROM notification_preferences WHERE user_id = ? AND category = ?');
++
++ this._getPendingEmails = db.prepare(`
++ SELECT n.*, u.email, u.username, u.display_name FROM notifications n
++ JOIN users u ON u.id = n.user_id
++ WHERE n.is_emailed = 0 AND n.is_dismissed = 0
++ AND u.email IS NOT NULL AND u.email != ''
++ ORDER BY n.created_at ASC
++ LIMIT 100
++ `);
++
++ this._newestForUser = db.prepare(`
++ SELECT * FROM notifications
++ WHERE user_id = ? AND is_dismissed = 0
++ ORDER BY created_at DESC
++ LIMIT 1
++ `);
++ }
++
++ // ─── Create ────────────────────────────────────────────────
++
++ /**
++ * Create a notification. Returns the created notification object.
++ * @param {Object} data - { user_id, type, title, message, sender_id, sender_name, sender_avatar, service, url, rich_content, expires_at }
++ */
++ create(data) {
++ const typeDef = TYPES[data.type] || {};
++ const id = uuidv4();
++ const category = data.category || typeDef.category || 'system';
++ const priority = data.priority || typeDef.priority || PRIORITY.NORMAL;
++ const icon = data.icon || typeDef.icon || '🔔';
++ const title = data.title || typeDef.title || 'Notification';
++
++ // Check user's preferences — skip if disabled
++ const pref = this._getPrefByCategory.get(data.user_id, category);
++ if (pref && !pref.enabled) return null;
++
++ const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;
++
++ this._insertNotif.run(
++ id, data.user_id, data.type || 'GENERIC', category, priority,
++ title, data.message || null, icon,
++ data.sender_id || null, data.sender_name || null, data.sender_avatar || null,
++ data.service || null, data.url || null, richContent,
++ data.expires_at || null,
++ );
++
++ // Fire browser push notification (async, non-blocking)
++ try {
++ const pushService = require('../push/push-service');
++ pushService.sendPush(data.user_id, {
++ title,
++ message: data.message,
++ icon,
++ url: data.url,
++ type: data.type,
++ }).catch(() => {});
++ } catch (_) { /* push module not available */ }
++
++ return { id, user_id: data.user_id, type: data.type, category, priority, title, message: data.message, icon, service: data.service, url: data.url, rich_content: data.rich_content, is_read: 0, created_at: new Date().toISOString() };
++ }
++
++ /**
++ * Bulk-create notifications for multiple users (e.g., broadcast).
++ */
++ createBulk(userIds, data) {
++ const results = [];
++ const tx = this.db.transaction(() => {
++ for (const uid of userIds) {
++ const notif = this.create({ ...data, user_id: uid });
++ if (notif) results.push(notif);
++ }
++ });
++ tx();
++ return results;
++ }
++
++ // ─── Read ──────────────────────────────────────────────────
++
++ getById(id) {
++ const n = this._getById.get(id);
++ if (n && n.rich_content) n.rich_content = JSON.parse(n.rich_content);
++ return n;
++ }
++
++ getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false } = {}) {
++ let rows;
++ if (category) {
++ rows = this._getByCategory.all(userId, category, limit, offset);
++ } else if (unreadOnly) {
++ rows = this._getUnreadForUser.all(userId, limit, offset);
++ } else {
++ rows = this._getForUser.all(userId, limit, offset);
++ }
++ return rows.map(n => {
++ // Defensive: a single corrupt row must never 500 the whole dropdown.
++ if (n.rich_content) {
++ try { n.rich_content = JSON.parse(n.rich_content); }
++ catch { n.rich_content = null; }
++ }
++ return n;
++ });
++ }
++
++ getUnreadCount(userId) {
++ return this._unreadCount.get(userId)?.count || 0;
++ }
++
++ getUnreadByCategory(userId) {
++ return this._unreadCountByCategory.all(userId);
++ }
++
++ getNewest(userId) {
++ const n = this._newestForUser.get(userId);
++ if (n && n.rich_content) n.rich_content = JSON.parse(n.rich_content);
++ return n;
++ }
++
++ // ─── Update ────────────────────────────────────────────────
++
++ markRead(id, userId) {
++ return this._markRead.run(id, userId).changes > 0;
++ }
++
++ markAllRead(userId, category = null) {
++ if (category) return this._markReadByCategory.run(userId, category).changes;
++ return this._markAllRead.run(userId).changes;
++ }
++
++ dismiss(id, userId) {
++ return this._dismiss.run(id, userId).changes > 0;
++ }
++
++ dismissAll(userId) {
++ return this._dismissAll.run(userId).changes;
++ }
++
++ markReadByType(userId, type, urlPattern = null) {
++ if (urlPattern) return this._markReadByTypeAndUrl.run(userId, type, urlPattern).changes;
++ return this._markReadByType.run(userId, type).changes;
++ }
++
++ markEmailed(id) {
++ return this._markEmailed.run(id).changes > 0;
++ }
++
++ // ─── Preferences ──────────────────────────────────────────
++
++ getPreferences(userId) {
++ return this._getPrefs.all(userId);
++ }
++
++ setPreference(userId, category, updates = {}) {
++ const current = this._getPrefByCategory.get(userId, category) || {
++ enabled: 1,
++ sound: 1,
++ toasts: 1,
++ email: 0,
++ };
++ const next = {
++ enabled: updates.enabled !== undefined ? (updates.enabled ? 1 : 0) : current.enabled,
++ sound: updates.sound !== undefined ? (updates.sound ? 1 : 0) : current.sound,
++ toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,
++ email: updates.email !== undefined ? (updates.email ? 1 : 0) : current.email,
++ };
++ this._upsertPref.run(
++ userId,
++ category,
++ next.enabled,
++ next.sound,
++ next.toasts,
++ next.email,
++ next.enabled,
++ next.sound,
++ next.toasts,
++ next.email,
++ );
++ }
++
++ resetPreference(userId, category) {
++ this._deletePrefs.run(userId, category);
++ }
++
++ // ─── Email Queue ──────────────────────────────────────────
++
++ /**\n * Get notifications that should be emailed.\n * Now checks ALL un-emailed notifications (not just critical),\n * because shouldEmail() will filter by user preference.\n */
++ getPendingEmails() {
++ return this._getPendingEmails.all();
++ }
++
++ /**
++ * Check if a notification should trigger an email.
++ * Priority: user's per-category email preference > built-in rules.
++ * Built-in: CRITICAL + system/moderation/admin always email.
++ * User opt-in: any category the user explicitly enabled email for.
++ */
++ shouldEmail(notification) {
++ const pref = this._getPrefByCategory.get(notification.user_id, notification.category);
++ // If user disabled the whole category, no email
++ if (pref && !pref.enabled) return false;
++ // LOW priority never emails
++ if (notification.priority === PRIORITY.LOW) return false;
++ // User explicitly opted into email for this category
++ if (pref && pref.email) return true;
++ // Built-in: CRITICAL + eligible categories always email
++ return notification.priority === PRIORITY.CRITICAL && EMAIL_ELIGIBLE_CATEGORIES.has(notification.category);
++ }
++
++ // ─── Cleanup ──────────────────────────────────────────────
++
++ cleanExpired() {
++ return this._deleteExpired.run().changes;
++ }
++
++ cleanOld(days = 90) {
++ return this._deleteOld.run(`-${days} days`).changes;
++ }
++
++ /**
++ * Run periodic maintenance (call from setInterval in main server).
++ */
++ maintenance() {
++ const expired = this.cleanExpired();
++ const maxAge = this.db.getSetting?.('notification_max_age_days') || 90;
++ const old = this.cleanOld(maxAge);
++ if (expired + old > 0) {
++ console.log(`[Notifications] Cleaned ${expired} expired + ${old} old notifications`);
++ }
++ }
++}
++
++module.exports = { NotificationService };
ddiff --git a/server/notifications/routes.js b/server/notifications/routes.js
nnew file mode 100644
iindex 00000000..ec7ea273
---- /dev/null
++++ b/server/notifications/routes.js
@@@ -0,0 +1,143 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Notification API Routes
++// REST endpoints for the notification UI client to interact with.
++// Mounted at /api/notifications
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const router = express.Router();
++
++module.exports = function createNotificationRoutes(db, notificationService, requireAuth) {
++
++ // ─── GET /api/notifications ────────────────────────────
++ // Query params: limit, offset, category, unread_only
++ router.get('/', requireAuth, (req, res) => {
++ try {
++ const userId = req.user.id;
++ const limit = Math.min(parseInt(req.query.limit) || 50, 200);
++ const offset = parseInt(req.query.offset) || 0;
++ const category = req.query.category || null;
++ const unreadOnly = req.query.unread_only === '1' || req.query.unread_only === 'true';
++
++ const notifications = notificationService.getForUser(userId, { limit, offset, category, unreadOnly });
++ res.json({ ok: true, notifications });
++ } catch (err) {
++ console.error('[Notifications] GET / error:', err);
++ res.status(500).json({ ok: false, error: 'Failed to fetch notifications' });
++ }
++ });
++
++ // ─── GET /api/notifications/unread-count ───────────────
++ router.get('/unread-count', requireAuth, (req, res) => {
++ try {
++ const count = notificationService.getUnreadCount(req.user.id);
++ res.json({ ok: true, count });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to get count' });
++ }
++ });
++
++ // ─── GET /api/notifications/unread-by-category ─────────
++ router.get('/unread-by-category', requireAuth, (req, res) => {
++ try {
++ const categories = notificationService.getUnreadByCategory(req.user.id);
++ res.json({ ok: true, categories });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to get counts' });
++ }
++ });
++
++ // ─── GET /api/notifications/newest ─────────────────────
++ router.get('/newest', requireAuth, (req, res) => {
++ try {
++ const newest = notificationService.getNewest(req.user.id);
++ res.json({ ok: true, notification: newest || null });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to get newest' });
++ }
++ });
++
++ // ─── POST /api/notifications/:id/read ──────────────────
++ router.post('/:id/read', requireAuth, (req, res) => {
++ try {
++ const ok = notificationService.markRead(req.params.id, req.user.id);
++ res.json({ ok });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to mark read' });
++ }
++ });
++
++ // ─── POST /api/notifications/read-all ──────────────────
++ router.post('/read-all', requireAuth, (req, res) => {
++ try {
++ const { category } = req.body || {};
++ const changed = notificationService.markAllRead(req.user.id, category || null);
++ res.json({ ok: true, changed });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to mark all read' });
++ }
++ });
++
++ // ─── POST /api/notifications/:id/dismiss ───────────────
++ router.post('/:id/dismiss', requireAuth, (req, res) => {
++ try {
++ const ok = notificationService.dismiss(req.params.id, req.user.id);
++ res.json({ ok });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to dismiss' });
++ }
++ });
++
++ // ─── DELETE /api/notifications ─────────────────────────
++ // Dismiss all
++ router.delete('/', requireAuth, (req, res) => {
++ try {
++ const changed = notificationService.dismissAll(req.user.id);
++ res.json({ ok: true, changed });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to dismiss all' });
++ }
++ });
++
++ // ─── GET /api/notifications/preferences ────────────────
++ router.get('/preferences', requireAuth, (req, res) => {
++ try {
++ const prefs = notificationService.getPreferences(req.user.id);
++ res.json({ ok: true, preferences: prefs });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to get preferences' });
++ }
++ });
++
++ // ─── PUT /api/notifications/preferences ────────────────
++ // Body: { category, enabled, sound, toasts, email }
++ router.put('/preferences', requireAuth, (req, res) => {
++ try {
++ const { category, enabled, sound, toasts, email } = req.body;
++ if (!category) return res.status(400).json({ ok: false, error: 'Category required' });
++ notificationService.setPreference(req.user.id, category, {
++ enabled,
++ sound,
++ toasts,
++ email,
++ });
++ res.json({ ok: true });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to update preferences' });
++ }
++ });
++
++ // ─── DELETE /api/notifications/preferences/:category ───
++ router.delete('/preferences/:category', requireAuth, (req, res) => {
++ try {
++ notificationService.resetPreference(req.user.id, req.params.category);
++ res.json({ ok: true });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: 'Failed to reset preference' });
++ }
++ });
++
++ return router;
++};
ddiff --git a/server/push/push-service.js b/server/push/push-service.js
nnew file mode 100644
iindex 00000000..21e7b598
---- /dev/null
++++ b/server/push/push-service.js
@@@ -0,0 +1,124 @@
++'use strict';
++
++let _db = null;
++
++let webpush;
++try {
++ webpush = require('web-push');
++} catch {
++ console.warn('[push] web-push not installed — browser push notifications disabled');
++}
++
++/**
++ * Initialize VAPID keys. Generates a new keypair on first run and stores in site_settings.
++ * @param {object} db - better-sqlite3 Database instance
++ */
++function initVapid(db) {
++ _db = db;
++ if (!webpush) return;
++ const upsert = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ let publicKey = db.getSetting('vapid_public_key');
++ let privateKey = db.getSetting('vapid_private_key');
++
++ if (!publicKey || !privateKey) {
++ const keys = webpush.generateVAPIDKeys();
++ publicKey = keys.publicKey;
++ privateKey = keys.privateKey;
++ upsert.run('vapid_public_key', publicKey, 'secret');
++ upsert.run('vapid_private_key', privateKey, 'secret');
++ console.log('[push] Generated new VAPID keypair');
++ }
++
++ const contactEmail = db.getSetting('vapid_contact_email') || 'mailto:[email protected]';
++ webpush.setVapidDetails(contactEmail, publicKey, privateKey);
++ console.log('[push] VAPID initialized');
++}
++
++/**
++ * Get the public VAPID key for client subscription.
++ */
++function getPublicKey() {
++ return _db?.getSetting('vapid_public_key') || null;
++}
++
++/**
++ * Save a push subscription for a user.
++ */
++function subscribe(userId, subscription) {
++ if (!_db) throw new Error('Push service not initialized');
++ if (!subscription?.endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {
++ throw new Error('Invalid push subscription');
++ }
++ const stmt = _db.prepare(`
++ INSERT OR REPLACE INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth, user_agent, created_at)
++ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
++ `);
++ stmt.run(userId, subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, subscription.userAgent || null);
++}
++
++/**
++ * Remove a push subscription.
++ */
++function unsubscribe(userId, endpoint) {
++ if (!_db) return;
++ _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(userId, endpoint);
++}
++
++/**
++ * Remove all subscriptions for a user.
++ */
++function unsubscribeAll(userId) {
++ if (!_db) return;
++ _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
++}
++
++/**
++ * Send a push notification to all subscriptions for a user.
++ * @param {number} userId
++ * @param {{ title: string, message: string, icon?: string, url?: string, tag?: string }} payload
++ */
++async function sendPush(userId, payload) {
++ if (!webpush || !_db) return;
++
++ const subs = _db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
++ if (!subs.length) return;
++
++ const pushPayload = JSON.stringify({
++ title: payload.title || 'OpenVibe.Live',
++ body: payload.message || '',
++ icon: payload.icon || '/assets/img/logo-192.png',
++ url: payload.url || 'https://openvibe.live',
++ tag: payload.tag || payload.type || 'notification',
++ });
++
++ const stale = [];
++ await Promise.allSettled(subs.map(async (sub) => {
++ try {
++ await webpush.sendNotification({
++ endpoint: sub.endpoint,
++ keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth },
++ }, pushPayload);
++ } catch (err) {
++ if (err.statusCode === 404 || err.statusCode === 410) {
++ stale.push(sub.id);
++ }
++ }
++ }));
++
++ // Cleanup stale subscriptions
++ if (stale.length) {
++ const placeholders = stale.map(() => '?').join(',');
++ _db.prepare(`DELETE FROM push_subscriptions WHERE id IN (${placeholders})`).run(...stale);
++ }
++}
++
++/**
++ * Send push to multiple users (bulk).
++ */
++async function sendPushBulk(userIds, payload) {
++ if (!webpush || !userIds.length) return;
++ // Fire all in parallel, don't block
++ await Promise.allSettled(userIds.map(uid => sendPush(uid, payload)));
++}
++
++module.exports = { initVapid, getPublicKey, subscribe, unsubscribe, unsubscribeAll, sendPush, sendPushBulk };
ddiff --git a/server/push/routes.js b/server/push/routes.js
nnew file mode 100644
iindex 00000000..45328b88
---- /dev/null
++++ b/server/push/routes.js
@@@ -0,0 +1,76 @@
++'use strict';
++
++const express = require('express');
++const router = express.Router();
++const pushService = require('./push-service');
++
++// All routes require auth (middleware applied by parent)
++
++/** GET /api/push/vapid-key — return the public VAPID key for client subscription */
++router.get('/vapid-key', (req, res) => {
++ const key = pushService.getPublicKey();
++ if (!key) return res.status(503).json({ error: 'Push notifications not configured' });
++ res.json({ publicKey: key });
++});
++
++/** POST /api/push/subscribe — save a PushSubscription */
++router.post('/subscribe', (req, res) => {
++ try {
++ const { subscription } = req.body;
++ if (!subscription) return res.status(400).json({ error: 'Missing subscription' });
++ pushService.subscribe(req.user.id, subscription);
++ res.json({ success: true });
++ } catch (err) {
++ res.status(400).json({ error: err.message });
++ }
++});
++
++/** POST /api/push/unsubscribe — remove a PushSubscription */
++router.post('/unsubscribe', (req, res) => {
++ const { endpoint } = req.body;
++ if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
++ pushService.unsubscribe(req.user.id, endpoint);
++ res.json({ success: true });
++});
++
++/** GET /api/push/status — check subscription + live notification preferences */
++router.get('/status', (req, res) => {
++ const db = req.app.locals.db;
++ const subs = db.prepare('SELECT id, endpoint, created_at FROM push_subscriptions WHERE user_id = ?').all(req.user.id);
++ const notifService = req.app.locals.notificationService;
++ const streamPref = notifService
++ ? notifService._getPrefByCategory.get(req.user.id, 'stream') || { enabled: 1, sound: 1, toasts: 1 }
++ : { enabled: 1, sound: 1, toasts: 1 };
++ const allLivePref = notifService
++ ? notifService._getPrefByCategory.get(req.user.id, 'stream_live_all') || { enabled: 0 }
++ : { enabled: 0 };
++
++ res.json({
++ ok: true,
++ subscribed: subs.length > 0,
++ subscription_count: subs.length,
++ preferences: {
++ stream_enabled: !!streamPref.enabled,
++ stream_live_all: !!allLivePref.enabled,
++ },
++ });
++});
++
++/** PUT /api/push/live-preferences — toggle live notification mode */
++router.put('/live-preferences', (req, res) => {
++ const { stream_live_all } = req.body;
++ const notifService = req.app.locals.notificationService;
++ if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
++
++ if (stream_live_all !== undefined) {
++ notifService.setPreference(req.user.id, 'stream_live_all', {
++ enabled: !!stream_live_all,
++ sound: 1,
++ toasts: 1,
++ });
++ }
++
++ res.json({ ok: true });
++});
++
++module.exports = router;
ddiff --git a/server/reset-db.js b/server/reset-db.js
nnew file mode 100644
iindex 00000000..e587003c
---- /dev/null
++++ b/server/reset-db.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++const path = require('path');
++const fs = require('fs');
++const dotenv = require('dotenv');
++
++function loadEnv() {
++ const envPath = path.resolve(process.cwd(), '.env');
++ if (fs.existsSync(envPath)) {
++ const parsed = dotenv.config({ path: envPath });
++ if (parsed.error) {
++ throw parsed.error;
++ }
++ return parsed.parsed || {};
++ }
++ return {};
++}
++
++function resolveDatabasePath(env) {
++ if (env.DB_PATH) {
++ return path.resolve(process.cwd(), env.DB_PATH);
++ }
++ return path.resolve(__dirname, '..', 'data', 'network.db');
++}
++
++function main() {
++ const env = loadEnv();
++ const dbPath = resolveDatabasePath(env);
++
++ if (!fs.existsSync(dbPath)) {
++ console.log(`No database file found at ${dbPath}. Nothing to reset.`);
++ process.exit(0);
++ }
++
++ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
++ const backupPath = `${dbPath}.backup.${timestamp}`;
++
++ try {
++ fs.copyFileSync(dbPath, backupPath);
++ fs.unlinkSync(dbPath);
++ for (const suffix of ['-wal', '-shm']) {
++ const journal = `${dbPath}${suffix}`;
++ if (fs.existsSync(journal)) {
++ fs.unlinkSync(journal);
++ }
++ }
++ console.log(`Backed up existing database to ${backupPath}`);
++ console.log(`Database reset complete. Restart the openvibe-network service to recreate the schema.`);
++ process.exit(0);
++ } catch (err) {
++ console.error(`Failed to reset database: ${err.message}`);
++ process.exit(1);
++ }
++}
++
++main();
ddiff --git a/server/setup/routes.js b/server/setup/routes.js
nnew file mode 100644
iindex 00000000..4dc42312
---- /dev/null
++++ b/server/setup/routes.js
@@@ -0,0 +1,333 @@
++'use strict';
++
++const express = require('express');
++const bcrypt = require('bcryptjs');
++const urlRegistry = require('../url-registry');
++const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
++const certManager = require('../deploy/cert-manager');
++const { isSensitiveSettingKey, maskSecret } = require('../auth/owner-guard');
++
++// The setup status is served UNAUTHENTICATED — never leak secret registry
++// values (e.g. DEPLOY_CLOUDFLARE_TOKEN) in it.
++function redactResolvedRegistry(resolved) {
++ if (!resolved || typeof resolved !== 'object') return resolved;
++ const out = {};
++ for (const [key, entry] of Object.entries(resolved)) {
++ if (entry && typeof entry === 'object' && 'value' in entry &&
++ (entry.type === 'secret' || isSensitiveSettingKey(key))) {
++ out[key] = { ...entry, value: entry.value ? maskSecret(entry.value) : entry.value, redacted: true };
++ } else {
++ out[key] = entry;
++ }
++ }
++ return out;
++}
++
++function createSetupRoutes(db, config) {
++ const router = express.Router();
++
++ function hasAdminUser() {
++ return !!db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();
++ }
++
++ function isValidSetupToken(req) {
++ const token = req.headers['x-setup-token'] || req.body?.setup_token || req.query?.setup_token;
++ return config.setupToken && token && token === config.setupToken;
++ }
++
++ function requireSetupMode(req, res, next) {
++ if (!hasAdminUser()) return next();
++ if (isValidSetupToken(req)) return next();
++ return res.status(403).json({ ok: false, error: 'Setup locked. Provide a valid setup token or log in as an admin.' });
++ }
++
++ function getSetupWarnings(resolvedRegistry) {
++ const warnings = [];
++ const firstPartyUrl = resolvedRegistry.OV_NETWORK_URL?.value;
++ if (config.nodeEnv === 'production') {
++ if (!firstPartyUrl) {
++ warnings.push('OV_NETWORK_URL is not configured. Public production hosts are required.');
++ } else if (firstPartyUrl.startsWith('http://')) {
++ warnings.push('OV_NETWORK_URL is using HTTP in production. Use HTTPS for public OpenVibe.Tools access.');
++ }
++ if (!resolvedRegistry.OV_NETWORK_INTERNAL_URL?.value) {
++ warnings.push('OV_NETWORK_INTERNAL_URL is not configured. Services cannot reach the internal API.');
++ }
++ if (!resolvedRegistry.OV_LIVE_INTERNAL_URL?.value) {
++ warnings.push('OV_LIVE_INTERNAL_URL is not configured. OpenVibe.Tools cannot notify OpenVibe.Live of config refreshes.');
++ }
++ if (resolvedRegistry.MEDIASOUP_ANNOUNCED_IP?.value && ['127.0.0.1', 'localhost'].includes(resolvedRegistry.MEDIASOUP_ANNOUNCED_IP.value)) {
++ warnings.push('MEDIASOUP_ANNOUNCED_IP is set to a local address in production. External WebRTC clients may fail to connect.');
++ }
++ if (!config.internalKey || config.internalKey === 'change-me-in-production') {
++ warnings.push('INTERNAL_API_KEY is not configured or using an insecure default. Internal service communication must be protected.');
++ }
++ }
++ return warnings;
++ }
++
++ function getSetupIssues(resolvedRegistry) {
++ const required = [
++ 'OV_NETWORK_URL',
++ 'OV_NETWORK_INTERNAL_URL',
++ 'OV_LIVE_INTERNAL_URL',
++ ];
++ return required.filter(key => !resolvedRegistry[key]?.value).map(key => `${key} is missing or invalid.`);
++ }
++
++ function buildSetupStatus() {
++ const resolvedRegistry = urlRegistry.getResolvedRegistry(db, process.env);
++ return {
++ adminExists: hasAdminUser(),
++ setupTokenConfigured: Boolean(config.setupToken),
++ registrySeeded: urlRegistry.isRegistrySeeded(db),
++ bootstrapProfile: config.bootstrapProfile,
++ warnings: getSetupWarnings(resolvedRegistry),
++ issues: getSetupIssues(resolvedRegistry),
++ resolvedRegistry: redactResolvedRegistry(resolvedRegistry),
++ };
++ }
++
++ router.get('/status', (req, res) => {
++ try {
++ res.json({ ok: true, status: buildSetupStatus() });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.post('/bootstrap', requireSetupMode, (req, res) => {
++ try {
++ const profile = String(req.body.profile || config.bootstrapProfile || 'local-dev');
++ const validProfiles = ['local-dev', 'single-node-prod'];
++ if (!validProfiles.includes(profile)) {
++ return res.status(400).json({ ok: false, error: 'Invalid bootstrap profile' });
++ }
++ urlRegistry.seedBootstrapRegistry(db, process.env, profile);
++ const status = buildSetupStatus();
++ return res.json({ ok: true, message: 'Bootstrap completed', status });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.post('/admin', requireSetupMode, (req, res) => {
++ try {
++ const { username, password } = req.body;
++ if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password are required' });
++ if (hasAdminUser() && !isValidSetupToken(req)) {
++ return res.status(403).json({ ok: false, error: 'Admin account already exists' });
++ }
++ const normalizedUsername = String(username).trim().toLowerCase();
++ const passwordHash = bcrypt.hashSync(String(password), 10);
++ db.prepare(`
++ INSERT INTO users (username, email, password_hash, display_name, role, profile_color)
++ VALUES (?, ?, ?, ?, 'admin', '#8b5cf6')
++ ON CONFLICT(username) DO UPDATE SET
++ password_hash = excluded.password_hash,
++ role = 'admin'
++ `).run(normalizedUsername, null, passwordHash, normalizedUsername);
++ return res.json({ ok: true, message: 'Admin account created' });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ── Brand identity setup ─────────────────────────────────
++ // POST /api/setup/identity — configure network/service names and subdomain base.
++ // These drive white-label installs: CORS wildcard patterns, brand display names, etc.
++ // Accepted fields (all optional, only provided fields are written):
++ // network_name → NETWORK_NAME (e.g. "OpenVibe")
++ // tools_service_name → TOOLS_SERVICE_NAME (e.g. "OpenVibe.Tools")
++ // streamer_service_name → STREAMER_SERVICE_NAME (e.g. "OpenVibe.Live")
++ // tools_subdomain_base → TOOLS_SUBDOMAIN_BASE (e.g. "openvibe.tools")
++ // extra_origins → ALLOWED_EXTRA_ORIGINS (array of additional CORS origins)
++ const IDENTITY_FIELD_MAP = {
++ network_name: 'NETWORK_NAME',
++ tools_service_name: 'TOOLS_SERVICE_NAME',
++ streamer_service_name: 'STREAMER_SERVICE_NAME',
++ tools_subdomain_base: 'TOOLS_SUBDOMAIN_BASE',
++ extra_origins: 'ALLOWED_EXTRA_ORIGINS',
++ };
++ router.post('/identity', requireSetupMode, (req, res) => {
++ try {
++ const updated = {};
++ for (const [bodyKey, registryKey] of Object.entries(IDENTITY_FIELD_MAP)) {
++ if (!(bodyKey in req.body)) continue;
++ const raw = req.body[bodyKey];
++ let value = raw;
++ if (registryKey === 'ALLOWED_EXTRA_ORIGINS') {
++ const origins = Array.isArray(raw)
++ ? raw
++ : String(raw).split(',').map(s => s.trim()).filter(Boolean);
++ for (const o of origins) {
++ try { new URL(o); } catch {
++ return res.status(400).json({ ok: false, error: `Invalid origin URL: ${o}` });
++ }
++ }
++ value = origins;
++ }
++ if (registryKey === 'TOOLS_SUBDOMAIN_BASE' && value != null) {
++ const stringValue = String(value).trim();
++ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(stringValue)) {
++ return res.status(400).json({ ok: false, error: `Invalid subdomain base: ${stringValue}. Expected a bare hostname like "openvibe.tools"` });
++ }
++ value = stringValue;
++ }
++ if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
++ continue;
++ }
++ const entry = urlRegistry.setRegistryEntry(db, registryKey, value, null);
++ updated[registryKey] = entry.value;
++ }
++ return res.json({ ok: true, updated, message: 'Identity config saved' });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ── URL config setup ─────────────────────────────────────
++ // POST /api/setup/urls — configure network URLs during first-time setup.
++ // Writes the provided values into the url_registry with source='admin'.
++ // Only keys that exist in URL_DEFINITIONS are accepted.
++ router.post('/urls', requireSetupMode, (req, res) => {
++ try {
++ const allowedKeys = new Set(Object.keys(URL_DEFINITIONS));
++ const updated = {};
++ const rejected = {};
++ for (const [key, value] of Object.entries(req.body)) {
++ if (!allowedKeys.has(key)) {
++ rejected[key] = 'unknown registry key';
++ continue;
++ }
++ try {
++ const entry = urlRegistry.setRegistryEntry(db, key, value, null);
++ updated[key] = entry.value;
++ } catch (err) {
++ rejected[key] = err.message;
++ }
++ }
++ const status = buildSetupStatus();
++ return res.json({ ok: true, updated, rejected, status });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ── Deploy / Infrastructure Setup ────────────────────────
++ // POST /api/setup/deploy — configure TLS, domain, and reverse-proxy settings.
++ // Captures the deploy-level infrastructure state needed to provision
++ // certificates and generate Nginx configs without manual SSH.
++ //
++ // Accepted fields:
++ // acme_email → DEPLOY_ACME_EMAIL
++ // cert_mode → DEPLOY_CERT_MODE ("cloudflare" | "manual" | "none")
++ // cloudflare_token → DEPLOY_CLOUDFLARE_TOKEN
++ // domains → DEPLOY_DOMAINS (array of domain objects)
++ // nginx_mode → DEPLOY_NGINX_MODE ("preview" | "apply" | "disabled")
++ // service_map → DEPLOY_SERVICE_MAP (override port/domain mappings)
++
++ const DEPLOY_FIELD_MAP = {
++ acme_email: 'DEPLOY_ACME_EMAIL',
++ cert_mode: 'DEPLOY_CERT_MODE',
++ cloudflare_token: 'DEPLOY_CLOUDFLARE_TOKEN',
++ domains: 'DEPLOY_DOMAINS',
++ nginx_mode: 'DEPLOY_NGINX_MODE',
++ service_map: 'DEPLOY_SERVICE_MAP',
++ };
++
++ router.post('/deploy', requireSetupMode, (req, res) => {
++ try {
++ const updated = {};
++ const errors = {};
++
++ for (const [bodyKey, registryKey] of Object.entries(DEPLOY_FIELD_MAP)) {
++ if (!(bodyKey in req.body)) continue;
++ const value = req.body[bodyKey];
++
++ // Validate cert_mode
++ if (registryKey === 'DEPLOY_CERT_MODE' && !['cloudflare', 'manual', 'none'].includes(value)) {
++ errors[bodyKey] = 'Must be "cloudflare", "manual", or "none"';
++ continue;
++ }
++
++ // Validate nginx_mode
++ if (registryKey === 'DEPLOY_NGINX_MODE' && !['preview', 'apply', 'disabled'].includes(value)) {
++ errors[bodyKey] = 'Must be "preview", "apply", or "disabled"';
++ continue;
++ }
++
++ // Validate domains array
++ if (registryKey === 'DEPLOY_DOMAINS') {
++ if (!Array.isArray(value)) {
++ errors[bodyKey] = 'Must be an array of domain objects';
++ continue;
++ }
++ for (const d of value) {
++ if (!d.domain || !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(d.domain)) {
++ errors[bodyKey] = `Invalid domain: ${d.domain}`;
++ break;
++ }
++ }
++ if (errors[bodyKey]) continue;
++ }
++
++ // Validate ACME email
++ if (registryKey === 'DEPLOY_ACME_EMAIL' && value) {
++ if (typeof value !== 'string' || !value.includes('@')) {
++ errors[bodyKey] = 'Must be a valid email address';
++ continue;
++ }
++ }
++
++ if (value === undefined || value === null) continue;
++
++ try {
++ const entry = urlRegistry.setRegistryEntry(db, registryKey, value, null);
++ updated[registryKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;
++ } catch (err) {
++ errors[bodyKey] = err.message;
++ }
++ }
++
++ // Return prerequisites check alongside the config save
++ const prereqs = certManager.checkPrerequisites();
++
++ return res.json({
++ ok: Object.keys(errors).length === 0,
++ updated,
++ errors: Object.keys(errors).length > 0 ? errors : undefined,
++ prerequisites: prereqs,
++ message: 'Deploy config saved',
++ });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // GET /api/setup/deploy-status — check deploy prerequisites and current config
++ router.get('/deploy-status', (req, res) => {
++ try {
++ const resolved = urlRegistry.getResolvedRegistry(db, process.env);
++ const prereqs = certManager.checkPrerequisites();
++
++ res.json({
++ ok: true,
++ prerequisites: prereqs,
++ config: {
++ acmeEmail: resolved.DEPLOY_ACME_EMAIL?.value || '',
++ certMode: resolved.DEPLOY_CERT_MODE?.value || 'manual',
++ hasCloudflareToken: !!(resolved.DEPLOY_CLOUDFLARE_TOKEN?.value),
++ domains: resolved.DEPLOY_DOMAINS?.value || [],
++ nginxMode: resolved.DEPLOY_NGINX_MODE?.value || 'preview',
++ },
++ });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ return router;
++}
++
++module.exports = createSetupRoutes;
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
nnew file mode 100644
iindex 00000000..d4496050
---- /dev/null
++++ b/server/themes/routes.js
@@@ -0,0 +1,144 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// openvibe.network — Theme API Routes
++// Central theme catalog accessible by all OpenVibe services.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const router = express.Router();
++
++function getDb(req) { return req.app.locals.db; }
++function getConfig(req) { return req.app.locals.config; }
++
++function optionalAuth(req, _res, next) {
++ const token = req.headers.authorization?.startsWith('Bearer ')
++ ? req.headers.authorization.slice(7)
++ : req.cookies?.ov_token;
++ if (token) {
++ const config = getConfig(req);
++ const publicKey = req.app.locals.publicKey;
++ const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
++ try {
++ const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
++ req.user = decoded;
++ } catch { /* not authenticated, continue */ }
++ }
++ next();
++}
++
++function requireAuth(req, res, next) {
++ optionalAuth(req, res, () => {
++ if (!req.user) return res.status(401).json({ error: 'Authentication required' });
++ next();
++ });
++}
++
++// ── List Themes ──────────────────────────────────────────────
++router.get('/', (req, res) => {
++ const db = getDb(req);
++ const { mode, search, sort, limit } = req.query;
++
++ let sql = 'SELECT id, name, slug, description, mode, variables, preview_colors, is_builtin, downloads, rating_sum, rating_count, tags FROM themes WHERE is_public = 1';
++ const params = [];
++
++ if (mode && (mode === 'dark' || mode === 'light')) {
++ sql += ' AND mode = ?';
++ params.push(mode);
++ }
++ if (search) {
++ sql += ' AND (name LIKE ? OR description LIKE ?)';
++ params.push(`%${search}%`, `%${search}%`);
++ }
++
++ const sortBy = sort === 'downloads' ? 'downloads DESC' : sort === 'rating' ? '(CAST(rating_sum AS REAL) / MAX(rating_count, 1)) DESC' : 'is_builtin DESC, name ASC';
++ sql += ` ORDER BY ${sortBy}`;
++ sql += ` LIMIT ?`;
++ params.push(Math.min(parseInt(limit, 10) || 100, 500));
++
++ const themes = db.prepare(sql).all(...params);
++ for (const t of themes) {
++ try { t.variables = JSON.parse(t.variables); } catch { t.variables = {}; }
++ try { t.preview_colors = JSON.parse(t.preview_colors); } catch { t.preview_colors = null; }
++ try { t.tags = JSON.parse(t.tags); } catch { t.tags = []; }
++ }
++ res.json({ themes });
++});
++
++// ── Get User's Active Theme ──────────────────────────────────
++router.get('/me/active', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const userId = req.user.sub || req.user.id;
++ const prefs = db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
++ if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null });
++
++ let custom = null;
++ try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
++ res.json({ theme_id: prefs.theme_id, custom_variables: custom });
++});
++
++// ── Get Theme by ID or Slug ──────────────────────────────────
++router.get('/:idOrSlug', (req, res) => {
++ const db = getDb(req);
++ const theme = db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);
++ if (!theme) return res.status(404).json({ error: 'Theme not found' });
++ try { theme.variables = JSON.parse(theme.variables); } catch { theme.variables = {}; }
++ try { theme.tags = JSON.parse(theme.tags); } catch { theme.tags = []; }
++ res.json({ theme });
++});
++
++// ── Set User's Active Theme ──────────────────────────────────
++router.put('/me', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const userId = req.user.sub || req.user.id;
++ const { theme_id, custom_variables } = req.body;
++
++ if (theme_id) {
++ const theme = db.prepare('SELECT id FROM themes WHERE id = ? OR slug = ?').get(theme_id, theme_id);
++ if (!theme) return res.status(404).json({ error: 'Theme not found' });
++ }
++
++ db.prepare(`
++ INSERT INTO user_preferences (user_id, theme_id, custom_theme_variables, updated_at)
++ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(user_id) DO UPDATE SET
++ theme_id = COALESCE(?, theme_id),
++ custom_theme_variables = ?,
++ updated_at = CURRENT_TIMESTAMP
++ `).run(
++ userId,
++ theme_id || 'vibe',
++ custom_variables ? JSON.stringify(custom_variables) : null,
++ theme_id || null,
++ custom_variables ? JSON.stringify(custom_variables) : null
++ );
++
++ res.json({ success: true, theme_id, custom_variables });
++});
++
++// ── Submit Community Theme ───────────────────────────────────
++router.post('/', requireAuth, (req, res) => {
++ const db = getDb(req);
++ const userId = req.user.sub || req.user.id;
++ const { name, slug, description, mode, variables, tags } = req.body;
++
++ if (!name || !slug || !variables) return res.status(400).json({ error: 'name, slug, and variables required' });
++ if (typeof variables !== 'object') return res.status(400).json({ error: 'variables must be an object' });
++ if (!/^[a-z0-9-]+$/.test(slug)) return res.status(400).json({ error: 'slug must be lowercase alphanumeric with hyphens' });
++
++ const existing = db.prepare('SELECT id FROM themes WHERE slug = ?').get(slug);
++ if (existing) return res.status(409).json({ error: 'A theme with that slug already exists' });
++
++ const id = `community-${slug}`;
++ db.prepare(`
++ INSERT INTO themes (id, name, slug, author_id, description, mode, variables, is_builtin, is_public, tags)
++ VALUES (?, ?, ?, ?, ?, ?, ?, 0, 1, ?)
++ `).run(id, name, slug, userId, description || '', mode || 'dark', JSON.stringify(variables), JSON.stringify(tags || []));
++
++ const theme = db.prepare('SELECT * FROM themes WHERE id = ?').get(id);
++ try { theme.variables = JSON.parse(theme.variables); } catch {}
++ res.status(201).json({ theme });
++});
++
++module.exports = router;
ddiff --git a/server/url-registry.js b/server/url-registry.js
nnew file mode 100644
iindex 00000000..15ca7f9b
---- /dev/null
++++ b/server/url-registry.js
@@@ -0,0 +1,232 @@
++'use strict';
++
++const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('openvibe-shared/url-resolver');
++
++function initializeUrlRegistry(db) {
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS url_registry (
++ key TEXT PRIMARY KEY,
++ label TEXT NOT NULL,
++ category TEXT NOT NULL,
++ service TEXT NOT NULL,
++ scope TEXT NOT NULL,
++ type TEXT NOT NULL,
++ value TEXT,
++ description TEXT,
++ source TEXT NOT NULL DEFAULT 'admin',
++ updated_by INTEGER,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ );
++ `);
++}
++
++function getRegistryRows(db) {
++ return db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry ORDER BY category, service, key').all();
++}
++
++function getRegistryRow(db, key) {
++ return db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry WHERE key = ?').get(key);
++}
++
++function encodeRegistryValue(value, type) {
++ const normalized = normalizeValue(value, type);
++ if (normalized == null) return null;
++ switch (type) {
++ case 'json_array':
++ case 'json_map':
++ return JSON.stringify(normalized);
++ default:
++ return String(normalized);
++ }
++}
++
++function decodeRegistryValue(value, type) {
++ if (value == null) return null;
++ switch (type) {
++ case 'json_array':
++ case 'json_map':
++ if (typeof value === 'string') {
++ try {
++ return normalizeValue(JSON.parse(value), type);
++ } catch {
++ return null;
++ }
++ }
++ return normalizeValue(value, type);
++ default:
++ return normalizeValue(value, type);
++ }
++}
++
++function getAllRegistryEntries(db) {
++ const rows = db.prepare('SELECT key, value, source, updated_by, updated_at FROM url_registry').all();
++ const rowMap = rows.reduce((map, row) => {
++ map[row.key] = row;
++ return map;
++ }, {});
++
++ const warnings = getRegistryWarnings(db);
++
++ return Object.values(URL_DEFINITIONS).map(def => {
++ const row = rowMap[def.key];
++ const rawValue = row?.value ?? null;
++ return {
++ key: def.key,
++ label: def.label,
++ category: def.category,
++ service: def.service,
++ scope: def.scope,
++ type: def.type,
++ description: def.description || '',
++ value: rawValue != null ? decodeRegistryValue(rawValue, def.type) : null,
++ updated_by: row?.updated_by || null,
++ updated_at: row?.updated_at || null,
++ source: row?.source || 'default',
++ placeholder: def.default || '',
++ warning: warnings[def.key] || null,
++ };
++ });
++}
++
++function getRegistryWarnings(db, env = process.env) {
++ const warnings = {};
++ const resolved = getResolvedRegistry(db, env);
++ try {
++ const liveHost = resolved.OV_LIVE_URL.value ? new URL(resolved.OV_LIVE_URL.value).hostname : null;
++ const whipHost = resolved.WHIP_PUBLIC_URL.value ? new URL(resolved.WHIP_PUBLIC_URL.value).hostname : null;
++ if (liveHost && whipHost && liveHost !== whipHost) {
++ warnings.WHIP_PUBLIC_URL = 'Dedicated WHIP hostname differs from OV_LIVE_URL host. Ensure DNS, vhost, and TLS are configured for this host.';
++ warnings.MEDIASOUP_ANNOUNCED_IP = 'Mediasoup announced host is currently derived from OV_LIVE_URL by default. Set MEDIASOUP_ANNOUNCED_IP explicitly for a different ICE hostname.';
++ }
++ } catch {
++ // Ignore parse failures from incomplete values.
++ }
++ return warnings;
++}
++
++function loadRegistryValuesBySource(db, source) {
++ const rows = db.prepare("SELECT key, value FROM url_registry WHERE source = ? AND value IS NOT NULL AND value != ''").all(source);
++ return rows.reduce((map, row) => {
++ const def = URL_DEFINITIONS[row.key];
++ map[row.key] = def ? decodeRegistryValue(row.value, def.type) : row.value;
++ return map;
++ }, {});
++}
++
++function loadOverrides(db) {
++ return loadRegistryValuesBySource(db, 'admin');
++}
++
++function loadBootstrapValues(db) {
++ return loadRegistryValuesBySource(db, 'bootstrap');
++}
++
++function formatEntry(row) {
++ if (!row) return null;
++ const value = row.value != null ? decodeRegistryValue(row.value, row.type) : null;
++ return {
++ key: row.key,
++ label: row.label,
++ category: row.category,
++ service: row.service,
++ scope: row.scope,
++ type: row.type,
++ description: row.description || '',
++ value,
++ source: row.source || 'admin',
++ updated_by: row.updated_by || null,
++ updated_at: row.updated_at || null,
++ };
++}
++
++function getResolvedRegistry(db, env = process.env) {
++ const overrides = loadOverrides(db);
++ const bootstrap = loadBootstrapValues(db);
++ return resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);
++}
++
++function setRegistryEntry(db, key, value, updatedBy = null) {
++ if (!URL_DEFINITIONS[key]) {
++ throw new Error(`Unknown URL registry key: ${key}`);
++ }
++ const entry = URL_DEFINITIONS[key];
++ const encoded = encodeRegistryValue(value, entry.type);
++ if (encoded == null) {
++ throw new Error(`Invalid value for ${key}`);
++ }
++ db.prepare(`
++ INSERT INTO url_registry (key, label, category, service, scope, type, value, description, source, updated_by, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'admin', ?, CURRENT_TIMESTAMP)
++ ON CONFLICT(key) DO UPDATE SET
++ value = excluded.value,
++ source = 'admin',
++ updated_by = excluded.updated_by,
++ updated_at = excluded.updated_at
++ `).run(entry.key, entry.label, entry.category, entry.service, entry.scope, entry.type, encoded, entry.description, updatedBy);
++ return formatEntry(getRegistryRow(db, key));
++}
++
++function resetRegistryEntry(db, key) {
++ if (!URL_DEFINITIONS[key]) {
++ throw new Error(`Unknown URL registry key: ${key}`);
++ }
++ db.prepare('DELETE FROM url_registry WHERE key = ?').run(key);
++ return URL_DEFINITIONS[key];
++}
++
++function isRegistrySeeded(db) {
++ const rowCount = db.prepare('SELECT COUNT(*) AS cnt FROM url_registry').get().cnt;
++ return rowCount > 0;
++}
++
++function buildBootstrapOverrides(env = {}, profile = 'local-dev') {
++ const bootstrap = {};
++ for (const [key, def] of Object.entries(URL_DEFINITIONS)) {
++ if (env[key]) {
++ bootstrap[key] = env[key];
++ continue;
++ }
++ if (profile === 'local-dev') {
++ bootstrap[key] = def.default;
++ continue;
++ }
++ bootstrap[key] = def.default;
++ }
++ return bootstrap;
++}
++
++function seedBootstrapRegistry(db, env = process.env, profile = 'local-dev') {
++ const bootstrap = buildBootstrapOverrides(env, profile);
++ const insert = db.prepare(`
++ INSERT INTO url_registry (key, label, category, service, scope, type, value, description, source, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bootstrap', CURRENT_TIMESTAMP)
++ ON CONFLICT(key) DO NOTHING
++ `);
++ const exists = db.prepare('SELECT 1 FROM url_registry WHERE key = ? LIMIT 1');
++
++ const tx = db.transaction(() => {
++ for (const [key, def] of Object.entries(URL_DEFINITIONS)) {
++ if (exists.get(def.key)) continue;
++ const encoded = encodeRegistryValue(bootstrap[key], def.type);
++ if (encoded == null) continue;
++ insert.run(def.key, def.label, def.category, def.service, def.scope, def.type, encoded, def.description || '');
++ }
++ });
++ tx();
++}
++
++module.exports = {
++ initializeUrlRegistry,
++ getRegistryRows,
++ getRegistryRow,
++ getAllRegistryEntries,
++ setRegistryEntry,
++ resetRegistryEntry,
++ loadOverrides,
++ loadBootstrapValues,
++ getResolvedRegistry,
++ formatEntry,
++ isRegistrySeeded,
++ seedBootstrapRegistry,
++ buildBootstrapOverrides,
++};
ddiff --git a/test/admin-refresh.test.js b/test/admin-refresh.test.js
nnew file mode 100644
iindex 00000000..f45fb685
---- /dev/null
++++ b/test/admin-refresh.test.js
@@@ -0,0 +1,36 @@
++const assert = require('assert');
++const adminRoutes = require('../server/admin/routes');
++
++const getServiceRefreshInfo = adminRoutes._getServiceRefreshInfo;
++
++assert.ok(typeof getServiceRefreshInfo === 'function', 'Expected getServiceRefreshInfo helper to exist');
++
++const mockReq = {
++ app: {
++ locals: {
++ config: {
++ services: {
++ live: { internalUrl: 'http://127.0.0.1:3000' },
++ },
++ },
++ },
++ },
++};
++
++const networkInfo = getServiceRefreshInfo(mockReq, 'network');
++assert.strictEqual(networkInfo.mode, 'local');
++assert.strictEqual(networkInfo.service, 'network');
++assert.strictEqual(networkInfo.configured, true);
++assert.strictEqual(networkInfo.target, null);
++
++const docsInfo = getServiceRefreshInfo(mockReq, 'docs');
++assert.strictEqual(docsInfo.mode, 'not_configured');
++assert.strictEqual(docsInfo.configured, false);
++assert.strictEqual(docsInfo.target, null);
++
++const streamerInfo = getServiceRefreshInfo(mockReq, 'live');
++assert.strictEqual(streamerInfo.mode, 'remote');
++assert.strictEqual(streamerInfo.configured, true);
++assert.strictEqual(streamerInfo.target, 'http://127.0.0.1:3000/internal/url-registry/refresh');
++
++console.log('✅ Admin service refresh helper tests passed');
ddiff --git a/test/registry-bootstrap.test.js b/test/registry-bootstrap.test.js
nnew file mode 100644
iindex 00000000..e616847c
---- /dev/null
++++ b/test/registry-bootstrap.test.js
@@@ -0,0 +1,37 @@
++const assert = require('assert');
++const Database = require('better-sqlite3');
++const urlRegistry = require('../server/url-registry');
++
++const db = new Database(':memory:');
++urlRegistry.initializeUrlRegistry(db);
++urlRegistry.seedBootstrapRegistry(db, { OV_LIVE_URL: 'https://bootstrap.example.com', OV_NETWORK_URL: 'https://openvibe.network' }, 'local-dev');
++const resolved = urlRegistry.getResolvedRegistry(db, {});
++assert.strictEqual(resolved.OV_LIVE_URL.value, 'https://bootstrap.example.com');
++assert.strictEqual(resolved.OV_LIVE_URL.source, 'bootstrap');
++assert.strictEqual(resolved.OV_NETWORK_URL.value, 'https://openvibe.network');
++assert.strictEqual(resolved.OV_NETWORK_URL.source, 'bootstrap');
++assert.strictEqual(resolved.WHIP_PUBLIC_URL.value, 'http://localhost:3000');
++assert.strictEqual(resolved.WHIP_PUBLIC_URL.source, 'bootstrap');
++assert.strictEqual(resolved.OV_NETWORK_INTERNAL_URL.value, 'http://127.0.0.1:4000');
++assert.strictEqual(resolved.OV_MEDIA_INTERNAL_URL.value, 'http://127.0.0.1:4100');
++assert.strictEqual(resolved.OV_LIVE_INTERNAL_URL.value, 'http://127.0.0.1:3000');
++assert.strictEqual(resolved.OV_LIVE_INTERNAL_URL.source, 'bootstrap');
++
++const storedExtra = db.prepare('SELECT key, value, type FROM url_registry WHERE key = ?').get('ALLOWED_EXTRA_ORIGINS');
++assert.strictEqual(storedExtra.type, 'json_array');
++assert.strictEqual(storedExtra.value, '[]');
++assert.deepStrictEqual(urlRegistry.getAllRegistryEntries(db).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, []);
++
++const updated = urlRegistry.setRegistryEntry(db, 'ALLOWED_EXTRA_ORIGINS', ['https://cdn.example.com'], null);
++assert.deepStrictEqual(updated.value, ['https://cdn.example.com']);
++assert.deepStrictEqual(urlRegistry.getAllRegistryEntries(db).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, ['https://cdn.example.com']);
++
++const scalarUpdated = urlRegistry.setRegistryEntry(db, 'NETWORK_NAME', 'CoolTools', null);
++assert.strictEqual(scalarUpdated.value, 'CoolTools');
++assert.strictEqual(urlRegistry.getAllRegistryEntries(db).find(e => e.key === 'NETWORK_NAME').value, 'CoolTools');
++
++const overrides = urlRegistry.loadOverrides(db);
++assert.deepStrictEqual(overrides.ALLOWED_EXTRA_ORIGINS, ['https://cdn.example.com']);
++assert.strictEqual(overrides.NETWORK_NAME, 'CoolTools');
++
++console.log('✅ openvibe-network registry bootstrap test passed');