openvibers/openvibe.chat Commits
Cutover kit: Live patch (CHAT_AUTHORITY=chat), runbook, capability proposals, ops files
Changes
nnew file mode 100644@@@ -0,0 +1,58 @@++# ═══════════════════════════════════════════════════════════════++# openvibe.chat — Environment Configuration++# Copy to .env and fill in values.++# In production this lives at /etc/openvibe/chat.env (0600).++# ═══════════════════════════════════════════════════════════════++++# Server (loopback only: nginx sends /ws/chat and the chat REST prefixes here)++PORT=4400++HOST=127.0.0.1++NODE_ENV=development++# The public origin browsers use — chat is served on Live's origin.++BASE_URL=http://localhost:3000++# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2), as Live++TRUST_PROXY=2++# Extra origins for WebSocket upgrades / credentialed CORS (Live's are built in)++#ALLOWED_ORIGINS=++++# Chat's own database (production: systemd StateDirectory=openvibe-chat)++CHAT_DB_PATH=./data/chat.db++# Chat's caches (101soundboards clips, TTS preview clips). Default: <db dir>/cache++#CHAT_CACHE_DIR=./data/cache++++# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# PEM with the Network public key; unset = fetched from ${OV_NETWORK_INTERNAL_URL}/api/.well-known/jwks++#OV_NETWORK_PUBLIC_KEY=/opt/openvibe.live/data/keys/openvibe-tools-public.pem++++# Chat's service principal (client `chat`; create it with Network's++# server/setup/service-principal.js create chat --env-file /etc/openvibe/chat.env)++OV_OAUTH_CLIENT_ID=chat++OV_OAUTH_CLIENT_SECRET=written-by-service-principal-js++++# OpenVibe.Live — every Live-owned read and side effect (server/live-context.js)++OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000++#LIVE_TIMEOUT_MS=4000++# Copy Chat's changes into Live's chat tables (read mirror + rollback path). ON from the cutover;++# OFF for rehearsals (and Live refuses mirror writes unless it runs with CHAT_AUTHORITY=chat).++LIVE_MIRROR=0++#LIVE_MIRROR_INTERVAL_MS=1000++++# OpenVibe.Events — the outbox relays only when set (otherwise events wait in events_outbox)++#EVENTS_URL=http://127.0.0.1:4300++#EVENTS_RELAY_INTERVAL_MS=5000++++# Public origin used in /paste links (Live's SITE_URL)++SITE_URL=https://openvibe.live++++# Channel sounds: the SAME directory as Live's (stored rows hold absolute paths into it and Live's++# donation alerts read alert sounds from it). Production: /opt/openvibe.live/data/sounds++SOUNDS_PATH=./data/sounds++#MAX_SOUND_SIZE_KB=2048++#SOUND_DEFAULT_MAX_SECONDS=10++#MAX_SOUNDS_PER_CHANNEL=150++#MAX_SOUNDS_PER_UPLOADER_PER_CHANNEL=10++++# Optional diagnostics++#DEBUG_DM_DELIVERY=1nnew file mode 100644@@@ -0,0 +1,30 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # production runs Node 22++ cache: npm++ - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg # sound uploads are probed/converted++ - run: npm ci --no-audit --no-fund++ - run: npm test++ contracts:++ # Blocking once docs/capabilities-proposal/ is registered in openvibe-contracts and the chat++ # service manifest lists them (docs/cutover.md, prerequisites). Until then it reports the gap.++ runs-on: ubuntu-latest++ continue-on-error: true++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1++ cache: npm++ - run: npm ci --no-audit --no-fund++ - run: npx openvibe-contracts-check --service chat --src server@@@ -2,14 +2,112 @@> Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence — one identity, every conversation.--**Status:** placeholder — planning only, no runnable code yet.--**Domain:** `openvibe.chat`++**Status:** alpha — Wave 6 code, not deployed. Live's chat runs here behind a service boundary;++the cutover (`docs/cutover.md`) has not happened, and `openvibe.chat` keeps its placeholder.++**Domain:** `openvibe.chat` (placeholder); until launch Chat is served on Live's origin:++`https://openvibe.live/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/`, `/api/sounds` via nginx.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §9 and §9.5.**License:** AGPL-3.0 (same as every OpenVibe service).## Purpose--The communication authority extracted from OpenVibe.Live. Live keeps a compatibility adapter while browsers move to Chat directly with Network-issued tokens and authorised topic subscriptions.++The communication authority extracted from OpenVibe.Live. Live keeps a compatibility adapter while++browsers move to Chat directly with Network-issued tokens and authorised topic subscriptions.++++## What Wave 6 did++++The roadmap's method for this wave: **move the existing implementation behind a service boundary++before changing behaviour.** Browser JavaScript does not change; nginx routes the chat paths here.++++- **Moved as they were** (from Live `server/chat/`): the WebSocket chat server (`/ws/chat`, every++ message type and command: `join`/`join_stream`/`leave_stream`/`get-users`/`chat`/`self-delete-history`,++ `/help /tts /color /viewers /uptime /w /me /ban /unban /timeout /clear /slow /paste /ai`, `!sr !queue++ !np !skip !sb !gotti`, channel `!sounds`, arena `!hype !beef !arena`, hardware `!forward … !say`),++ DMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`,++ `/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the word++ filter, the deploy-notice card. Changes are confined to where they touched Live's data.++- **One adapter to Live — `server/live-context.js`.** Accounts and roles, streams, slots and channels,++ channel moderation settings and moderators, bans and IP rules, follows, cosmetics and tags, site++ settings and anon numbers are read through it; coins, AI viewers, arena, media queue, hardware,++ pastes, translation, PowerChat, notifications, viewer counts and the IP log are effects it asks++ Live for. Reads are cached projections (SQLite `ctx_*` tables kept complete by paged syncs; TTL++ caches that serve stale while refreshing) warmed when a socket joins, so a chat message never++ waits on a network read. Live pushes invalidations when it changes cached data.++- **Live's side** is `docs/live-patch.diff` (applies to Live `main` with `git apply`):++ `/internal/chat-context/*` and `/internal/chat-effects/*`, and `CHAT_AUTHORITY=chat`, which stops++ Live's chat server and routes and turns `require('./chat/chat-server')` into a proxy that forwards++ Live's own chat calls here (`POST /internal/live/calls`).++- **Own database** (`CHAT_DB_PATH`, systemd `StateDirectory=openvibe-chat`) with Live's tables and++ ids, the Network subject on new rows, a transactional events outbox and a read mirror back into Live.++- **Import:** `scripts/import-from-live.js --live-db <snapshot> [--dry-run]` — idempotent, never drops++ a row (`import_hold`), reports counts per table.++++## How it fits the network++++| Concern | Where it lives | How Chat reaches it |++| --- | --- | --- |++| Messages, DMs, channel sounds, TTS voice overrides, relay users, first chats, IP-approval queue, moderation log | **Chat** (authority from the cutover) | its own SQLite; Live keeps a read mirror (`POST /internal/chat-effects/mirror`) |++| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |++| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |++| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |++| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |++| Events | **OpenVibe.Events** | `events_outbox` → `POST /api/v1/events` when `EVENTS_URL` is set (`events.event.publish`) |++++### Data++++Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`,++`dm_blocks`, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`,++`pending_ip_messages`, `stream_first_chats`, `moderation_actions`. Same columns and ids as Live, plus++`subject_id` / `sender_subject_id` / `blocker_subject_id` / `actor_subject_id` /++`created_by_subject_id` (the Network `usr_…` subject, filled on new rows and by the importer).++++Staged here, still written by Live in this wave (their routes have not moved): `channel_moderators`,++`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` —++imported (and refreshed by later import runs) so their move is a switch; Chat reads the live values++through `live-context`. `table_authority` records which is which.++++Stays in Live (decided with evidence, `docs/cutover.md`): `media_requests`, `media_request_settings`.++++### Events++++| Event | Visibility | When |++| --- | --- | --- |++| `chat.message.created` | public | every stored message in a public room (global, channel, stream) |++| `chat.dm.created` | subject | every DM; the payload names the participants, never the text |++| `chat.moderation.action` | internal | every moderation log row (chat commands and Live's `/api/mod`) |++++## Running it++++```bash++npm install++cp .env.example .env # OV_LIVE_INTERNAL_URL, Network URLs, OV_OAUTH_CLIENT_SECRET, SOUNDS_PATH++npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,sounds}, /health, /ready++npm test # Node 22; stub Live and Network in-process++node scripts/import-from-live.js --live-db /tmp/live-snapshot.db --dry-run++node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"++node scripts/mirror-flush.js # rollback helper: push queued mirror rows to Live++```++++`/ready` reports the last Live sync, the mirror queue and whether events are relayed.++Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-chat.locations.conf`,++`/etc/openvibe/chat.env`. The whole switch-over — rehearsal, import, parity checks, nginx, the flag,++rollback — is `docs/cutover.md`.++++### Layout++++```++server/index.js boot: DB, first Live sync, WS server, HTTP app, relays, graceful stop++server/app.js Live's guards for these routes: CORS, /api rate limit, IP bans, ban cookie, WS origin + IP checks++server/live-context.js the only module that talks to Live (interface in its header)++server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice++server/auth/ token resolution through Live; the chat subset of Live's permissions++server/bridge/ Live → Chat calls + presence (live-bridge.js); Chat → Live read mirror (live-mirror.js)++server/events/outbox.js events.event-envelope@1 outbox and relay++server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)++server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)++scripts/ import-from-live, parity-check, mirror-flush++docs/ cutover.md, live-patch.diff, capabilities-proposal/++```## Owns@@@ -19,42 +117,38 @@ The communication authority extracted from OpenVibe.Live. Live keeps a compatibi- TTS/audio/soundboard/media-request queues with skip/clear/failure lifecycle- call signalling metadata and the `pending/ringing/active/ended/missed/declined/failed` lifecycle++(Wave 6 moved messages, DMs, TTS, sounds and the chat side of moderation; bans, channel moderation++settings, the media-request queue and calls are still Live's and reached through `live-context`.)++## Does not own- media bytes (attachments are Media references)- billing of paid messages (Tips/Billing)--## Planned surfaces----- `api/` rooms, history, DMs, moderation, settings; `realtime/` WS/SSE delivery; `calls/`; `speech/`; `soundboard/`; `moderation/`; `adapters/live/`----## Data (authority tables / families)----- see above--## Capabilities and events--- `chat.room.*`, `chat.message.*`, `chat.dm.*`, `chat.moderation.*`, `chat.tts.*`, `chat.call.*`++Introduced here (proposals in `docs/capabilities-proposal/`, not yet in `openvibe-contracts`):++`chat.live_bridge.write`, `chat.presence.read` (owner chat) and `live.chat_context.read`,++`live.chat_effects.write`, `live.chat_mirror.write` (owner live). Planned families:++`chat.room.*`, `chat.message.*`, `chat.dm.*`, `chat.moderation.*`, `chat.tts.*`, `chat.call.*`.--Events: ``chat.message.created|deleted``, ``chat.room.updated``, ``chat.call.*``, ``chat.tts.queued|played|failed``++Events: `chat.message.created`, `chat.dm.created`, `chat.moderation.action` (produced);++planned `chat.message.deleted`, `chat.room.updated`, `chat.call.*`, `chat.tts.queued|played|failed`.## Depends on- OpenVibe.Network++- OpenVibe.Live (until the rest of chat's data moves)- OpenVibe.Events- OpenVibe.Media- OpenVibe.Contracts## Acceptance (must be true before "done")--- stream/global/DM histories preserved on import; old Live URLs and WS messages keep working through the adapter--- restart Live without losing Chat; restart Chat's delivery plane and resume persisted messages--- a call row without a working signalling/media path is not parity--- a paid TTS request is never duplicated by a retry----## Bootstrap / extraction source----Live's chat, DM, TTS, soundboard and WebRTC call modules, imported with stable legacy IDs.++- stream/global/DM histories preserved on import; old Live URLs and WS messages keep working through the adapter — *implemented (import, same paths, protocol parity tests); not yet exercised on production data*++- restart Live without losing Chat; restart Chat's delivery plane and resume persisted messages — *implemented (Live's forwarded writes wait in its outbox; Chat's mirror and events wait in theirs); not yet exercised in production*++- a call row without a working signalling/media path is not parity — *calls are still Live's (`/ws/call`)*++- a paid TTS request is never duplicated by a retry — *forwarded writes are applied once per idempotency key; paid TTS does not exist yet*## Launch rule@@@ -1,7 +1,18 @@{"repository": "OpenVibers/OpenVibe.Chat",-- "stage": "placeholder",-- "code": false,++ "stage": "alpha",++ "code": true,++ "wave": "W6","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-21"++ "runtime": "node 22, express, ws, better-sqlite3",++ "port": 4400,++ "domain": "openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via nginx); openvibe.chat keeps its placeholder",++ "contracts": "openvibe-contracts v0.5.1",++ "capabilities": ["chat.live_bridge.write", "chat.presence.read"],++ "capabilitiesUsed": ["live.chat_context.read", "live.chat_effects.write", "live.chat_mirror.write", "events.event.publish"],++ "capabilitiesRegistered": false,++ "events": ["chat.message.created", "chat.dm.created", "chat.moderation.action"],++ "liveAuthorityFlag": "CHAT_AUTHORITY=chat (docs/live-patch.diff)",++ "deployed": false,++ "updated": "2026-09-22"}nnew file mode 100644@@@ -0,0 +1,79 @@++# OpenVibe.Chat locations — include INSIDE the `server { listen 443 … server_name openvibe.live … }`++# block of /etc/nginx/sites-enabled/openvibe.live.conf (Live's deploy/nginx/openvibe.live.conf),++# above `location /api/`:++#++# include /opt/openvibe.chat/deploy/nginx/openvibe.live-chat.locations.conf;++#++# Adding the include (+ nginx -t + reload) is the traffic flip; removing it is the rollback++# (docs/cutover.md). Headers and limits are Live's for the same paths: the /api/ zone and burst,++# the body size, X-Forwarded-For for trust proxy 2, CF-Connecting-IP + upgrade for the socket.++# `^~` keeps the static-asset regex location from ever taking these (e.g. /api/sounds/file/x.mp3).++# Trailing slashes matter: /api/chat-ai (Live's AI routes) must NOT match /api/chat/.++++location ^~ /ws/chat {++ proxy_pass http://127.0.0.1:4400;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection "upgrade";++}++++location ^~ /api/chat/ {++ limit_req zone=api_limit burst=40 nodelay;++ client_max_body_size 10m;++ proxy_pass http://127.0.0.1:4400;++ 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 ^~ /api/dm/ {++ limit_req zone=api_limit burst=40 nodelay;++ client_max_body_size 10m;++ proxy_pass http://127.0.0.1:4400;++ 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 ^~ /api/tts/ {++ limit_req zone=api_limit burst=40 nodelay;++ client_max_body_size 10m;++ proxy_pass http://127.0.0.1:4400;++ 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;++}++++# POST /api/sounds (upload) has no trailing path.++location = /api/sounds {++ limit_req zone=api_limit burst=40 nodelay;++ client_max_body_size 10m;++ proxy_pass http://127.0.0.1:4400;++ 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 ^~ /api/sounds/ {++ limit_req zone=api_limit burst=40 nodelay;++ client_max_body_size 10m;++ proxy_pass http://127.0.0.1:4400;++ 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;++}nnew file mode 100644@@@ -0,0 +1,47 @@++[Unit]++Description=OpenVibe.Chat — rooms, messages, DMs, TTS and sounds, moderation (/ws/chat and the chat API of openvibe.live)++# Live and Network should be up first; Chat serves from its caches if they are slow, and queues++# its writes to Live (mirror, effects) until Live answers.++After=network.target++Wants=openvibe-live.service openvibe-network.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.chat++EnvironmentFile=/etc/openvibe/chat.env++Environment=NODE_ENV=production++Environment=PORT=4400++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.chat/server/index.js++# Tells clients the chat restarts, flushes the Live mirror, closes sockets.++KillSignal=SIGTERM++TimeoutStopSec=15++Restart=always++RestartSec=3++++# Hardening (as openvibe-community.service)++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++ProtectKernelTunables=true++ProtectKernelModules=true++ProtectControlGroups=true++RestrictSUIDSGID=true++LockPersonality=true++RestrictRealtime=true++++# Chat's own database and caches. The code tree stays read-only.++StateDirectory=openvibe-chat++Environment=CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db++Environment=CHAT_CACHE_DIR=/var/lib/openvibe-chat/cache++# Channel sounds stay in Live's sounds directory (rows hold absolute paths into it; Live's++# donation alerts read alert sounds there). Legacy layout and release layout (shared/data).++Environment=SOUNDS_PATH=/opt/openvibe.live/data/sounds++ReadWritePaths=-/opt/openvibe.live/data/sounds -/opt/openvibe.live/shared/data/sounds++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,22 @@++{++ "id": "chat.live_bridge.write",++ "version": "1.0",++ "owner": "chat",++ "status": "planned",++ "visibility": "internal",++ "description": "Live's remaining modules (AI viewers, chat relays, donations and alerts, /api/mod, recaps, news, calls, emotes, deploy notices) push to chat clients and write chat rows through OpenVibe.Chat: ChatServer calls in order (broadcasts, DMs with the participant check, TTS, channel sounds, disconnects, user updates, cache invalidations) and allow-listed chat-table writes, applied once per idempotency key, with placeholder ids mapped per Live boot. Transitional: shrinks as those modules move out of Live.",++ "permissions": [++ "chat:write"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "internal-write",++ "events": [++ "chat.message.created",++ "chat.moderation.action"++ ],++ "implementedBy": [++ "POST /internal/live/calls"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "chat.presence.read",++ "version": "1.0",++ "owner": "chat",++ "status": "planned",++ "visibility": "internal",++ "description": "Who is connected to chat where: connection and per-stream viewer counts, slow modes, and signed-in/anonymous sockets with their addresses — for Live's synchronous reads (health, AI viewer pacing, moderation by address). Internal: carries IP addresses.",++ "permissions": [++ "chat:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "internal-read",++ "events": [],++ "implementedBy": [++ "GET /internal/live/presence"++ ]++}nnew file mode 100644@@@ -0,0 +1,37 @@++{++ "id": "live.chat_context.read",++ "version": "1.0",++ "owner": "live",++ "status": "planned",++ "visibility": "internal",++ "description": "OpenVibe.Chat reads the Live data chat decisions depend on and caches it (Wave 6): user projections and token resolution (Live's own rules, with the Network subject), streams, slots and channels, channel moderation settings and moderators, active bans, follows, IP approvals, cosmetics and tags, the chat site settings (tts_*, GIF and soundboard keys only), anon first-seen, arena-cached TTS clips and the chat profile card. Read-only; no secrets beyond the chat settings.",++ "permissions": [++ "live:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "internal-read",++ "events": [],++ "implementedBy": [++ "POST /internal/chat-context/auth",++ "GET /internal/chat-context/users",++ "POST /internal/chat-context/users/lookup",++ "GET /internal/chat-context/users/:id/follows",++ "GET /internal/chat-context/users/profile",++ "GET /internal/chat-context/streams",++ "GET /internal/chat-context/streams/active",++ "GET /internal/chat-context/streams/:id",++ "GET /internal/chat-context/managed-streams",++ "GET /internal/chat-context/channels",++ "GET /internal/chat-context/channels/by-user/:userId",++ "GET /internal/chat-context/channels/:id/policy",++ "GET /internal/chat-context/channels/:id/approved-ip",++ "GET /internal/chat-context/bans",++ "POST /internal/chat-context/decor",++ "GET /internal/chat-context/settings",++ "GET /internal/chat-context/anon/:num",++ "GET /internal/chat-context/anon-first-seen",++ "GET /internal/chat-context/tts-audio/:file"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "live.chat_effects.write",++ "version": "1.0",++ "owner": "live",++ "status": "planned",++ "visibility": "internal",++ "description": "OpenVibe.Chat asks Live for the side effects chat triggers in data Live still owns (Wave 6): anon numbers, IP log, viewer counts and snapshots, /color, /ban /timeout /unban rows, IP approvals, /slow persistence, alert sounds, channel creation, TTS site settings, the per-message reactions (OpenCoins chat bonus, AI chat viewers, PowerChat relay), AI mod commands, arena commands, the media queue, hardware commands, /paste, translation, DM notifications and Media asset sync. Live re-checks the acting person (moderator, owner, admin) on every effect that acts for one, and refuses all of them unless CHAT_AUTHORITY=chat.",++ "permissions": [++ "live:write"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "internal-write",++ "events": [],++ "implementedBy": [++ "POST /internal/chat-effects/{anon,ip-log,viewer-counts,viewer-snapshots,user-color,ban,approve-ip,channel-settings,alert-sound,ensure-channel,site-settings,chat-message,ai/mod-command,arena-command,media-queue,hardware,paste,translate,notify/dm,notify/dm-read,asset-sync}"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "live.chat_mirror.write",++ "version": "1.0",++ "owner": "live",++ "status": "planned",++ "visibility": "internal",++ "description": "OpenVibe.Chat copies every change to the tables it owns into Live's tables of the same name and ids (a read mirror for Live's remaining readers and the rollback path). Only the twelve chat tables; only columns Live has; Live-only columns are kept; refused unless CHAT_AUTHORITY=chat. Retired when Live stops reading chat tables.",++ "permissions": [++ "live:write"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "internal-write",++ "events": [],++ "implementedBy": [++ "POST /internal/chat-effects/mirror"++ ]++}nnew file mode 100644@@@ -0,0 +1,227 @@++# Chat cutover — Live → OpenVibe.Chat (roadmap Wave 6)++++What changes at the cutover: browsers keep calling `https://openvibe.live`, but nginx sends the chat++socket and the chat REST prefixes to OpenVibe.Chat (127.0.0.1:4400), and Live runs with++`CHAT_AUTHORITY=chat`. Nothing in Live's browser code changes. Everything below is reversible++until the Live-side read mirror is retired (a later wave).++++- [What is served where](#what-is-served-where)++- [Data authority](#data-authority)++- [Prerequisites](#prerequisites)++- [Rehearsal on a live.db snapshot](#rehearsal-on-a-livedb-snapshot)++- [Cutover](#cutover)++- [Rollback](#rollback)++- [Behaviour that is not byte-for-byte identical](#behaviour-that-is-not-byte-for-byte-identical)++- [Live quirks moved as they are](#live-quirks-moved-as-they-are)++++## What is served where++++nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in Live's++`server { … server_name openvibe.live … }` block above `location /api/`):++++| nginx location | Chat serves |++| --- | --- |++| `^~ /ws/chat` | the chat WebSocket (`?stream=`, `?token=`; protocol unchanged) |++| `^~ /api/chat/` | `GET gif/providers`, `GET gif/trending`, `GET gif/search`, `POST send`, `GET search`, `GET user/:userId/history`, `GET user/:username/profile`, `GET relay-user/:platform/:username`, `GET relay-user/:platform/:username/logs`, `GET anon/:anonId`, `GET anon/:anonId/logs`, `GET filters/friendly`, `GET global/history`, `GET :streamId/replay`, `GET :streamId/history`, `GET channel/:userId/history`, `GET :streamId/users`, `POST admin/purge/preview`, `DELETE admin/purge`, `GET admin/logs`, `GET admin/logs/export` |++| `^~ /api/dm/` | `GET/POST conversations`, `GET/PATCH conversations/:id`, `GET/POST conversations/:id/messages`, `DELETE conversations/:id/messages/:msgId`, `POST conversations/:id/read`, `POST conversations/:id/participants`, `DELETE conversations/:id/participants/:userId`, `GET unread`, `GET users/search`, `GET blocks`, `POST/DELETE blocks/:userId`, `GET blocks/check/:userId` |++| `^~ /api/tts/` | `GET voices`, `GET settings`, `GET/PUT admin/settings`, `POST admin/test`, `GET audio/:file` (Chat's clips; Live's arena/mod-preview clips are fetched from Live under the same URL) |++| `= /api/sounds` and `^~ /api/sounds/` | `POST /api/sounds` (upload), `GET channel/:userId`, `GET all/:streamId`, `DELETE :id`, `PATCH command`, `GET file/:filename`, `GET alert/mine`, `POST/DELETE alert/:kind` |++++Stay on Live (do not route): `/api/chat-ai/*` (the trailing slash in `/api/chat/` keeps it out),++`/api/emotes/*`, `/api/channels/*` (channel moderation dashboard), `/api/mod/*`, `/api/media/*`,++`/ws/call`, `/ws/broadcast`, `/ws/control`, and everything else.++++Loopback only, never routed: Chat `/internal/live/*`, `/health`, `/ready`; Live++`/internal/chat-context/*`, `/internal/chat-effects/*` (both refuse anything that came through++nginx).++++## Data authority++++| Table (Live baseline, target OpenVibe.Chat) | W6 authority | Notes |++| --- | --- | --- |++| `chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions` | **Chat** | Imported with their ids. Live keeps a read mirror (Chat → `POST /internal/chat-effects/mirror`); Live's remaining writers (AI viewers, relays, donations, `/api/mod`, `/api/channels` deletes, emote renames) forward to Chat. |++| `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` | **Live** (staged in Chat) | Their writers (`/api/channels`, dashboard, `/api/emotes` + Media asset-sync, game tags, chat AI) have not moved. Imported into Chat (a later import run refreshes them); Chat reads the live values through `live-context` and Live pushes invalidations. `/slow` and alert sounds are written by Live on Chat's behalf. |++| `media_requests`, `media_request_settings` | **Live** (not moved) | Decision with evidence: every writer is `server/media/media-queue.js` / `server/media/routes.js` (`/api/media`: gold payment, yt-dlp download, playback state, the streamer's overlay and dashboard). Chat only has the `!sr/!queue/!np/!skip` entry points, which call Live (`POST /internal/chat-effects/media-queue`). Not chat-owned in practice; it moves with the queue lifecycle the charter describes, not before. The importer reports them as `not_moved`. |++| `chat_messages_new`, `emotes_new`, `channel_sounds_new` | — | Transient tables of Live's table rebuilds; empty in a consistent snapshot. Rows found there go to `import_hold`. |++++Chat also keeps `ctx_users`, `ctx_streams`, `ctx_managed_streams`, `ctx_channels` — projections of++Live data maintained by `live-context` (never authority), `events_outbox`, `live_mirror_outbox`,++`bridge_applied`, `import_hold`, `import_runs`, `table_authority`.++++## Prerequisites++++1. **Contracts.** Register `docs/capabilities-proposal/*.json` in OpenVibe.Contracts (a new++ `openvibe-contracts` release): `live.chat_context.read`, `live.chat_effects.write`,++ `live.chat_mirror.write` (owner live), `chat.live_bridge.write`, `chat.presence.read` (owner++ chat). Add the live ones to `manifests/services/live.json`; set `manifests/services/chat.json` to++ alpha with `capabilities: ["chat.live_bridge.write", "chat.presence.read"]`,++ `eventsProduced: ["chat.message.created", "chat.dm.created", "chat.moderation.action"]` and a++ `contractRanges` that includes the release. Until then `openvibe-contracts-check` reports these++ capabilities as undefined (Live's CI with the patch, Chat's non-blocking contracts job).++2. **Network.** Create the principal and grants:++ ```bash++ sudo node server/setup/service-principal.js create chat --env-file /etc/openvibe/chat.env++ ```++ `principal_grants` (client, capability, audience): `chat live.chat_context.read openvibe.live`,++ `chat live.chat_effects.write openvibe.live`, `chat live.chat_mirror.write openvibe.live`,++ `live chat.live_bridge.write openvibe.chat`, `live chat.presence.read openvibe.chat`++ (`chat events.event.publish openvibe.events` is already a default). Add them to++ `DEFAULT_GRANTS` in `server/identity/principals.js` so every boot keeps them.++3. **Live.** Apply `docs/live-patch.diff` to Live `main` (`git apply docs/live-patch.diff`), run its++ tests (`node test/chat-context.test.js`, `npm test`), deploy it **without** `CHAT_AUTHORITY` —++ nothing changes for users; the new internal routes answer only service tokens and every effect++ answers 409 while the flag is off. Add `OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400` to++ `/etc/openvibe/live.env`.++4. **Chat.** `git clone … /opt/openvibe.chat && cd /opt/openvibe.chat && npm ci --omit=dev`;++ `/etc/openvibe/chat.env` from `.env.example` (0600): `OV_LIVE_INTERNAL_URL`, Network URLs,++ `SITE_URL=https://openvibe.live`, `BASE_URL=https://openvibe.live`, `LIVE_MIRROR=1`, and++ `EVENTS_URL` if OpenVibe.Events is deployed. The unit sets `CHAT_DB_PATH`, `CHAT_CACHE_DIR`,++ `SOUNDS_PATH=/opt/openvibe.live/data/sounds` and allows writes there. ffmpeg/ffprobe and++ espeak-ng are the host's (Live uses the same).++ ```bash++ sudo cp deploy/systemd/openvibe-chat.service /etc/systemd/system/ && sudo systemctl daemon-reload++ ```++ Do not start it before the rehearsal is signed off.++++## Rehearsal on a live.db snapshot++++Nothing here touches production data. A rehearsal Chat may read production Live++(`/internal/chat-context/*` is read-only), but every effect is refused while Live's flag is off —++no coins, AI replies or pastes happen; anon numbers come out temporary (`anon9xxxxxxxx`).++++```bash++# 1. snapshot (online, consistent)++sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-rehearsal.db"++SNAP_AT="$(date -u '+%Y-%m-%d %H:%M:%S')"++++# 2. import into a scratch database++cd /opt/openvibe.chat++node scripts/import-from-live.js --live-db /tmp/live-rehearsal.db --chat-db /tmp/chat-rehearsal.db --dry-run++node scripts/import-from-live.js --live-db /tmp/live-rehearsal.db --chat-db /tmp/chat-rehearsal.db++```++++Check the report: per table `live` = `inserted` (first run), `held_total` 0 (anything held: look at++`import_hold.reason`), `media_requests.not_moved`, `chat_messages.next_id` = Live's max + headroom.++Cross-check: `sqlite3 /tmp/live-rehearsal.db "select count(*) from chat_messages"` against the++report. Run the import again: every `inserted` must be 0 (idempotent).++++```bash++# 3. a rehearsal Chat on 4401 against production Live, mirror OFF++sudo -u ubuntu env $(sudo cat /etc/openvibe/chat.env | xargs) PORT=4401 CHAT_DB_PATH=/tmp/chat-rehearsal.db \++ CHAT_CACHE_DIR=/tmp/chat-rehearsal-cache LIVE_MIRROR=0 EVENTS_URL= node server/index.js &++curl -s http://127.0.0.1:4401/ready | jq # ready: true after the first Live sync++++# 4. parity: the same reads answer the same (history pinned before the snapshot)++node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "$SNAP_AT" \++ --stream <a live stream id> --stream <an ended stream id> --channel <streamer user id> --channel <another> \++ --token <a test account's Network JWT>++```++++Expected: `all paths answer the same`. Then by hand against 4401 (`wscat -H 'Origin: https://openvibe.live'++-c ws://127.0.0.1:4401/ws/chat?stream=<id>`): `{"type":"join","streamId":<id>}` → `auth`; a join++with a token → `authenticated: true`; `/help`; a message → stored in `/tmp/chat-rehearsal.db` and++returned by `/api/chat/<id>/history` on 4401. Stop the rehearsal Chat and delete `/tmp/chat-rehearsal*`.++++## Cutover++++Pick a quiet moment (`curl -s https://openvibe.live/api/streams`: restarting Live drops RTMP++streamers; `deploy.sh --wait-idle` waits for none).++++1. **Start Chat on an empty database, import.**++ ```bash++ sudo systemctl start openvibe-chat && curl -s http://127.0.0.1:4400/ready | jq++ sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-cutover-1.db"++ sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-cutover-1.db --chat-db /var/lib/openvibe-chat/chat.db++ ```++ Chat now holds everything up to snapshot 1 and its id sequences start 10000 above Live's++ (`--headroom`), so rows Live writes until the flip still fit below.++2. **Flip.** Set `CHAT_AUTHORITY=chat` in `/etc/openvibe/live.env`, restart Live (it tells chat++ clients it restarts), and as soon as `https://openvibe.live/api/ready` answers, add the include++ to nginx and reload:++ ```bash++ sudo systemctl restart openvibe-live # or deploy.sh --wait-idle++ # add: include /opt/openvibe.chat/deploy/nginx/openvibe.live-chat.locations.conf;++ sudo nginx -t && sudo systemctl reload nginx++ ```++ Between the two steps `/ws/chat` on Live answers 503 and clients retry with backoff; afterwards++ they land on Chat.++3. **Second import pass** — rows Live wrote between snapshot 1 and its restart:++ ```bash++ sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-cutover-2.db"++ sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-cutover-2.db --chat-db /var/lib/openvibe-chat/chat.db++ ```++ Chat's own new rows are already in Live (mirror) and count as `identical`; `chat_kept` counts rows++ Chat edited since pass 1 (Chat wins); `held` must be 0.++4. **Checks.**++ - `curl -s 127.0.0.1:4400/ready | jq` — `live.last_sync_at` recent, `mirror.pending` near 0,++ `mirror.last_error` null.++ - Live: `sqlite3 …/live.db "select count(*) from chat_bridge_outbox"` stays near 0 (its forwarded++ writes are acknowledged); `journalctl -u openvibe-live | grep ChatRemote` shows no refusals.++ - Browser: join a stream chat, send a message (it appears for others and in history after a++ reload), `/help`, a DM between two test accounts (live delivery + unread badge), a moderator++ `/timeout` on a test account, a channel `!sound`, the global feed on the home page.++ - Live features that read chat: home page chat stats, a recap, AI viewers answering in a test++ stream, `/api/mod` chat logs and deletes (the deleted line disappears in Chat).++ - `node scripts/parity-check.js --live http://127.0.0.1:3000 --chat http://127.0.0.1:4400 …` is no++ longer meaningful (Live's chat routes answer 503 now); compare against the mirror instead with++ `sqlite3` counts: Live `chat_messages` ≈ Chat `chat_messages`.++++## Rollback++++Live becomes the chat authority again on its own tables, which the mirror kept complete.++++1. Remove the include from nginx; `sudo nginx -t && sudo systemctl reload nginx` (chat traffic goes++ to Live, which answers 503 until step 3 — clients retry).++2. Make sure everything Chat wrote is in Live: `curl -s 127.0.0.1:4400/ready | jq .mirror.pending`++ → 0. If the service cannot run: `cd /opt/openvibe.chat && sudo -u ubuntu env $(sudo cat++ /etc/openvibe/chat.env | xargs) CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db node scripts/mirror-flush.js`++ (exit 0 = drained). Live accepts mirror writes only while it still has the flag, so do this++ before step 3.++3. Remove `CHAT_AUTHORITY` from `/etc/openvibe/live.env`, restart Live. At boot Live applies any++ chat writes it had queued for Chat and never got acknowledged (`chat_bridge_outbox`).++4. `sudo systemctl stop openvibe-chat`. Keep `/var/lib/openvibe-chat/chat.db` (a later cutover starts++ from a fresh database and a fresh import; `subject_id` values are recomputed from Live's++ `linked_accounts`).++++## Behaviour that is not byte-for-byte identical++++- **Ordering of Live's reactions.** The OpenCoins chat bonus (`coin_earned`), AI-viewer reactions,++ translations and the PowerChat relay happen in Live after the message is broadcast (one call per++ message); before, the coin reply was sent before the broadcast. Arena and media-queue replies++ arrive a round trip later.++- **Freshness of Live data.** Chat answers from caches: changes made in Live reach Chat at once++ through invalidations (channel moderators/settings and alert sounds via Live's db writes, IP++ approvals, role/avatar pushes, admin user edits, bans followed by a disconnect) and otherwise++ within the TTL — bans 10 s, channel policy 15 s, settings 30 s, follows/cosmetics/tags 60 s,++ new users 30 s (all users every 15 min), live/ended streams 10 s. Live's synchronous reads of++ chat presence (viewer counts, AI-viewer pacing, `getConnectedUserIp` in `/api/mod` bans) are up++ to 3 s old.++- **Placeholder ids.** A chat row Live's own modules create (AI viewers, relays, donations) gets its++ id from Chat; the Live caller sees a placeholder (≤ -2^40), mapped to the real id for everything it++ sends to chat afterwards. What such a caller stores itself keeps the placeholder:++ `ai_viewer_log.chat_message_id` and the PowerChat message id of AI-viewer lines.++- **Separate rate-limit budget.** The chat REST routes count against Chat's own `/api` limiter++ (same numbers) instead of sharing Live's; Live's request analytics no longer see them.++- **Live unreachable.** Chat keeps serving rooms from its caches; new sign-ins resolve as anonymous++ and new anonymous visitors get a temporary number (`anon9xxxxxxxx`) until Live answers; effects are++ skipped (coins, AI viewers) or answer an error (`/color`, media commands).++- **Deploy notices** are still Live's (its commits, announced in chat through the bridge); Chat's own++ deploys are not announced.++- **Responses** carry the same fields as before; Chat's `*subject_id` columns are stripped from API++ answers.++++## Live quirks moved as they are++++Found while moving; deliberately not fixed in this wave (fix after the cutover, in one place):++++- `GET /api/chat/search` always reports `total: 0` (its count regex does not cross the SELECT's line++ break).++- `POST /api/chat/send` never attaches cosmetics (Live required a module that does not exist).++- A `/timeout` stores an ISO `expires_at` that Live compares as TEXT with SQLite's++ `'YYYY-MM-DD HH:MM:SS'`, so a timeout lasts at least until the end of that UTC day.++- `/api/tts/admin/settings` masks credentials in the TTS engine's cached settings object, so for up++ to 30 s after an admin views them the owner's view — and Google/AWS synthesis — see the mask.++- `/paste` announces "Anonymous shared a paste" and titles it "Chat paste by anon" (it reads++ fields the chat client object does not have).++- The AI-viewer / PowerChat "is moderator" flag passes the channel owner's user id where a channel++ id belongs (`canModerateChannel(user, channelUserId)`).nnew file mode 100644@@@ -0,0 +1,1596 @@++diff --git a/.env.example b/.env.example++index 31f53b7..19810f1 100644++--- a/.env.example++@@ -107,3 +107,9 @@ OPS_ALERT_WEBHOOK_URL=++ # Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)++ PASTES_ON_COMMUNITY=0++ OV_COMMUNITY_URL=https://openvibe.community++++++# Chat runs in OpenVibe.Chat when this is "chat" (roadmap W6; server/chat/chat-authority.js): Live+++# stops serving /ws/chat and /api/chat|dm|tts|sounds (nginx sends them to Chat), forwards its own+++# chat calls to Chat and keeps its chat tables as Chat's read mirror. Unset = Live runs chat.+++#CHAT_AUTHORITY=chat+++OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400++diff --git a/docs/chat-system.md b/docs/chat-system.md++index 65d1647..fd58f07 100644++--- a/docs/chat-system.md++@@ -8,6 +8,29 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte++ - **REST API**: `server/chat/routes.js` — moderation endpoints, message search, admin tools++ - **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync+++++### OpenVibe.Chat (roadmap Wave 6)++++++Chat is moving to [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400)+++with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches it on+++(`server/chat/chat-authority.js`); without it nothing here changes.++++++- nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live answers+++ them with 503 if one still arrives, and `/ws/chat` upgrades are refused.+++- `require('./chat/chat-server')` returns `chat-remote.js`: the same methods, forwarded in order to+++ Chat's `POST /internal/live/calls` (broadcasts, DMs, TTS, channel sounds, disconnects, user updates).+++ Chat-table writes Live's other modules make (AI viewers, relays, donations, `/api/mod`) are forwarded+++ too; inserts return a placeholder id (≤ -2^40) Chat maps to the real one. Unacknowledged writes wait+++ in `chat_bridge_outbox` (and are applied here if Live boots without the flag again — rollback).+++- Synchronous reads (`getTotalConnections`, viewer counts, slow modes, a connected user's IP) come from+++ Chat's presence snapshot, polled every 3 s.+++- Chat reads Live data and asks for side effects on `/internal/chat-context/*` and+++ `/internal/chat-effects/*` (`server/chat/live-context-routes.js`), with Network service tokens+++ (`live.chat_context.read`, `live.chat_effects.write`, `live.chat_mirror.write`,+++ `server/net/service-guard.js`). Effects re-check the acting moderator/owner/admin and are refused+++ unless `CHAT_AUTHORITY=chat`.+++- Live's chat tables become Chat's read mirror (`POST /internal/chat-effects/mirror`): same ids, so home+++ stats, recaps, AI context, VOD chat replay and the `/api/mod` queues keep reading them in place.+++++ ## Features++++ ### Real-time Chat++diff --git a/server/auth/auth.js b/server/auth/auth.js++index e2dd198..4a7e908 100644++--- a/server/auth/auth.js++@@ -413,6 +413,10 @@ function reloadNetworkKey() {++ module.exports = {++ apiTokenAllows,++ verifyToken,+++ verifyTokenWithReason,+++ getNetworkIssuer,+++ // The Network's RS256 key also signs service tokens (server/net/service-guard.js).+++ getNetworkPublicKey: () => openvibeToolsPublicKey,++ requireAuth,++ optionalAuth,++ requireAdmin,++diff --git a/server/chat/chat-authority.js b/server/chat/chat-authority.js++new file mode 100644++index 0000000..4867b0b++--- /dev/null++@@ -0,0 +1,23 @@+++'use strict';+++/**+++ * Who runs chat (roadmap Wave 6).+++ *+++ * CHAT_AUTHORITY=chat: OpenVibe.Chat (127.0.0.1:4400) serves /ws/chat and the chat REST routes+++ * (nginx routes them there) and owns the chat tables. Live then+++ * - does not start its chat WebSocket server or mount /api/chat, /api/dm, /api/tts, /api/sounds;+++ * - hands every chat call its other modules make to Chat (server/chat/chat-remote.js — what+++ * require('./chat/chat-server') returns in this mode);+++ * - keeps its chat tables as a read mirror that Chat writes (POST /internal/chat-effects/mirror)+++ * so its own readers (home stats, recaps, AI context, /api/mod queues) keep working and a+++ * rollback loses nothing;+++ * - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.+++ * Anything else (default): Live runs chat itself, exactly as before.+++ */++++++const CHAT_URL = (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/+$/, '');++++++function isRemote() {+++ return process.env.CHAT_AUTHORITY === 'chat';+++}++++++module.exports = { isRemote, CHAT_URL };++diff --git a/server/chat/chat-remote.js b/server/chat/chat-remote.js++new file mode 100644++index 0000000..7809725++--- /dev/null++@@ -0,0 +1,374 @@+++'use strict';+++/**+++ * The chat server, when OpenVibe.Chat runs it (CHAT_AUTHORITY=chat, see chat-authority.js).+++ *+++ * require('./chat/chat-server') returns this object instead of a ChatServer, so every module that+++ * talks to chat keeps working unchanged:+++ * - calls that push to browsers (broadcastToStream, forwardToGlobal, sendDm, TTS, channel sounds,+++ * disconnectUser, sendUserUpdate …) are queued, in order, and sent to Chat's+++ * POST /internal/live/calls in small batches (service token for audience openvibe.chat,+++ * capability chat.live_bridge.write);+++ * - the chat-table writes Live's other modules make (db.saveChatMessage from AI viewers, relays,+++ * donations; /api/mod deletes and queues; moderation log) are forwarded the same way, because+++ * Chat owns those tables. Updates and deletes also run on Live's mirror at once so their+++ * return values (deleted ids) are right; inserts run only in Chat and return a placeholder id+++ * (≤ -2^40) that Chat maps to the real one for every later call of this boot. Forwarded writes+++ * are kept in chat_bridge_outbox until Chat acknowledges them, so a restart or a Chat outage+++ * loses none;+++ * - synchronous reads (connection counts, viewer counts, slow modes, a connected user's address)+++ * come from a presence snapshot polled from Chat's GET /internal/live/presence every few seconds.+++ */+++const crypto = require('crypto');+++const db = require('../db/database');+++const principal = require('../net/network-principal');+++const { CHAT_URL } = require('./chat-authority');++++++const AUDIENCE = 'openvibe.chat';+++const BOOT = crypto.randomUUID();+++const REF_BASE = 2 ** 40;+++const BATCH = 200;+++const PRESENCE_MS = 3000;+++const EPHEMERAL_TTL_MS = 30000; // a broadcast older than this is dropped, not delivered late+++const MAX_QUEUE = 20000;++++++// Chat-table writes Live still makes, and whether they also run on Live's mirror right away.+++// Inserts never run here (their ids are Chat's): the caller gets a placeholder.+++const FORWARDED_DB = {+++ saveChatMessage: 'insert',+++ logModerationAction: 'insert',+++ holdMessageForApproval: 'insert',+++ hideRelayUser: 'insert',+++ createChannelSound: 'insert',+++ mergeChatMessageMetadata: 'local',+++ deleteChatMessage: 'local',+++ deleteUserChatMessages: 'local',+++ deleteAnonChatMessages: 'local',+++ deleteRelayUserMessages: 'local',+++ deleteChatMessagesByTimeRange: 'local',+++ reviewPendingIpMessage: 'local',+++ approveAllFromIp: 'local',+++ denyAllFromIp: 'local',+++ recordRelayUser: 'local',+++ unhideRelayUser: 'local',+++ unhideRelayUserByIdentity: 'local',+++ recordFirstChat: 'local',+++ setTtsVoiceOverride: 'local',+++ deleteTtsVoiceOverride: 'local',+++ setChannelSoundEmote: 'local',+++ deleteChannelSound: 'local',+++ renameChannelSoundCommand: 'local',+++ updateChannelSoundEmoteRefs: 'local',+++};++++++// Live-owned data Chat caches, written by Live's own routes (dashboard, /api/channels, /api/mod):+++// after the write, Chat is told to reload it instead of waiting for its cache to expire.+++const OBSERVED_DB = {+++ addChannelModerator: (a) => ['channel', a[0]],+++ removeChannelModerator: (a) => ['channel', a[0]],+++ upsertChannelModerationSettings: (a) => ['channel', a[0]],+++ setChannelAlertSound: (a) => ['channel', a[0]],+++ approveIp: (a) => ['approvals', a[0]],+++ revokeIpApproval: (a) => ['approvals', a[0]],+++ forgiveBan: () => ['bans', null],+++};++++++function create(ChatServer) {+++ // A local ChatServer that never listens: its address and anon-number helpers are Live's own+++ // (call-server uses them for voice calls, and Chat asks Live for anon numbers).+++ const local = new ChatServer();+++ const queue = []; // { seq, op, args, ref, durableId, at }+++ let seq = 0;+++ let refCounter = 0;+++ let busy = false;+++ let inflight = 0; // ops at the head of the queue that are being sent right now+++ let batchLimit = BATCH; // 1 after Chat refused a batch as a whole (too large / malformed)+++ let backoffUntil = 0;+++ let flushTimer = null;+++ let presenceTimer = null;+++ let started = false;+++ let presence = { total: 0, streams: {}, slow_mode: {}, users: [], anons: [], at: null };+++ const slowModeByStream = new Map();+++ const stats = { sent: 0, failed: 0, dropped: 0, lastError: null };++++++ function ensureOutbox() {+++ db.getDb().exec(`CREATE TABLE IF NOT EXISTS chat_bridge_outbox (+++ id INTEGER PRIMARY KEY AUTOINCREMENT,+++ boot TEXT NOT NULL,+++ ref INTEGER,+++ op TEXT NOT NULL,+++ args TEXT NOT NULL,+++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP+++ )`);+++ }++++++ function schedule(ms = 0) {+++ if (flushTimer) return;+++ flushTimer = setTimeout(() => { flushTimer = null; flush().catch(() => {}); }, ms);+++ if (flushTimer.unref) flushTimer.unref();+++ }++++++ function enqueue(op, args, { ref = null, durable = false, boot = BOOT, durableId = null } = {}) {+++ let id = durableId;+++ if (durable && !id) {+++ try {+++ ensureOutbox();+++ id = db.getDb().prepare('INSERT INTO chat_bridge_outbox (boot, ref, op, args) VALUES (?, ?, ?, ?)').run(boot, ref, op, JSON.stringify(args)).lastInsertRowid;+++ } catch (err) { console.warn('[ChatRemote] outbox write failed:', err.message); }+++ }+++ if (queue.length >= MAX_QUEUE) {+++ // Drop the oldest broadcast, never a write.+++ const i = queue.findIndex((q, idx) => idx >= inflight && !q.durableId);+++ if (i >= 0) { queue.splice(i, 1); stats.dropped++; }+++ }+++ queue.push({ seq: ++seq, op, args, ref, durableId: id, boot, at: Date.now() });+++ schedule();+++ }++++++ async function post(path, body, retried = false) {+++ const res = await fetch(`${CHAT_URL}${path}`, {+++ method: body ? 'POST' : 'GET',+++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await principal.serviceHeaders(AUDIENCE)) },+++ body: body ? JSON.stringify(body) : undefined,+++ signal: AbortSignal.timeout(10000),+++ });+++ if (res.status === 401 && !retried) { principal.invalidate(AUDIENCE); return post(path, body, true); }+++ if (!res.ok) { const err = new Error(`Chat ${res.status}`); err.status = res.status; throw err; }+++ return res.json();+++ }++++++ async function flush() {+++ if (busy || !queue.length) return;+++ if (Date.now() < backoffUntil) { schedule(backoffUntil - Date.now()); return; }+++ busy = true;+++ try {+++ while (queue.length) {+++ // Stale broadcasts are dropped; forwarded writes always go.+++ const now = Date.now();+++ for (let i = queue.length - 1; i >= 0; i--) {+++ if (!queue[i].durableId && now - queue[i].at > EPHEMERAL_TTL_MS) { queue.splice(i, 1); stats.dropped++; }+++ }+++ if (!queue.length) break;+++ // One boot per request (replayed writes of an earlier boot keep their own id space).+++ const boot = queue[0].boot;+++ const batch = [];+++ for (const q of queue) { if (q.boot !== boot || batch.length >= batchLimit) break; batch.push(q); }+++ let out;+++ inflight = batch.length;+++ try {+++ // A forwarded write carries its outbox id as an idempotency key: Chat applies it once+++ // even when a lost response makes Live send it again.+++ out = await post('/internal/live/calls', { boot, ops: batch.map((q) => ({ seq: q.seq, op: q.op, args: q.args, ref: q.ref, key: q.durableId ? `live:${q.durableId}` : undefined })) });+++ } catch (err) {+++ inflight = 0;+++ stats.failed++;+++ if (err.status === 400 || err.status === 413) {+++ // Refused as a request, not per op: retry one by one; a single op still refused+++ // can never be delivered, so it is dropped (logged) instead of blocking the queue.+++ if (batch.length > 1) { batchLimit = 1; continue; }+++ const q = queue.shift();+++ stats.dropped++;+++ console.error(`[ChatRemote] Chat refused ${q.op}${q.op === 'db' ? ` ${q.args[0]}` : ''} (${err.status}); dropped`);+++ if (q.durableId) { try { db.getDb().prepare('DELETE FROM chat_bridge_outbox WHERE id = ?').run(q.durableId); } catch { /* */ } }+++ continue;+++ }+++ if (stats.lastError !== err.message) console.warn(`[ChatRemote] Chat unreachable (${queue.length} queued): ${err.message}`);+++ stats.lastError = err.message;+++ backoffUntil = Date.now() + 2000;+++ schedule(2000);+++ return;+++ }+++ stats.lastError = null;+++ inflight = 0;+++ batchLimit = BATCH;+++ queue.splice(0, batch.length);+++ stats.sent += batch.length;+++ const done = batch.filter((q) => q.durableId).map((q) => q.durableId);+++ if (done.length) {+++ try { db.getDb().prepare(`DELETE FROM chat_bridge_outbox WHERE id IN (${done.map(() => '?').join(',')})`).run(...done); } catch { /* */ }+++ }+++ for (const r of (out && out.results) || []) {+++ if (!r.ok) console.warn(`[ChatRemote] Chat refused op #${r.seq}: ${r.error}`);+++ }+++ }+++ } finally { busy = false; }+++ }++++++ async function pollPresence() {+++ try {+++ const p = await post('/internal/live/presence');+++ presence = p;+++ slowModeByStream.clear();+++ for (const [k, v] of Object.entries(p.slow_mode || {})) slowModeByStream.set(Number(k), Number(v) || 0);+++ } catch { /* keep the last snapshot */ }+++ }++++++ // ── Forwarded database writes ───────────────────────────────+++ const originals = {};+++ function installDbForwarding() {+++ for (const [fn, mode] of Object.entries(FORWARDED_DB)) {+++ if (typeof db[fn] !== 'function' || originals[fn]) continue;+++ originals[fn] = db[fn];+++ db[fn] = function forwardedChatWrite(...args) {+++ if (mode === 'insert') {+++ const ref = -(REF_BASE + (++refCounter));+++ enqueue('db', [fn, ...args], { ref, durable: true });+++ return { changes: 1, lastInsertRowid: ref };+++ }+++ let result;+++ try { result = originals[fn](...args); } catch (err) { result = undefined; console.warn(`[ChatRemote] mirror ${fn}: ${err.message}`); }+++ enqueue('db', [fn, ...args], { durable: true });+++ return result;+++ };+++ }+++ }++++++ function installInvalidations() {+++ for (const [fn, target] of Object.entries(OBSERVED_DB)) {+++ if (typeof db[fn] !== 'function' || originals[fn]) continue;+++ originals[fn] = db[fn];+++ db[fn] = function observedLiveWrite(...args) {+++ const result = originals[fn](...args);+++ try { const [kind, id] = target(args); enqueue('invalidate', [kind, id]); } catch { /* */ }+++ return result;+++ };+++ }+++ }++++++ function replayOutbox() {+++ try {+++ ensureOutbox();+++ const rows = db.getDb().prepare('SELECT id, boot, ref, op, args FROM chat_bridge_outbox ORDER BY id').all();+++ for (const r of rows) {+++ let args;+++ try { args = JSON.parse(r.args); } catch { continue; }+++ queue.push({ seq: ++seq, op: r.op, args, ref: r.ref, durableId: r.id, boot: r.boot, at: Date.now() });+++ }+++ if (rows.length) console.log(`[ChatRemote] replaying ${rows.length} chat write(s) queued before the restart`);+++ } catch (err) { console.warn('[ChatRemote] outbox replay:', err.message); }+++ }++++++ // A stream row carries Live's stream keys: Chat only ever needs the owner.+++ const slimStream = (s) => (s ? { id: s.id, user_id: s.user_id } : s);+++ const send = (op) => (...args) => { enqueue(op, args); };++++++ // Live code that iterated chatServer.clients and wrote to each socket (a global delete in+++ // /api/mod) gets one pseudo-socket whose send() reaches every chat client through Chat.+++ const everyone = { readyState: 1, bufferedAmount: 0, send: (raw) => enqueue('broadcastAllRaw', [String(raw)]), close() {} };+++ const clients = new Map([[everyone, { pseudo: true }]]);++++++ const remote = {+++ remote: true,+++ boot: BOOT,+++ stats,+++ clients,+++ slowModeByStream,++++++ init() {+++ if (started) return null;+++ started = true;+++ ensureOutbox();+++ replayOutbox();+++ schedule();+++ pollPresence();+++ presenceTimer = setInterval(pollPresence, PRESENCE_MS);+++ if (presenceTimer.unref) presenceTimer.unref();+++ console.log(`[Chat] CHAT_AUTHORITY=chat — chat runs in OpenVibe.Chat (${CHAT_URL}); Live forwards to it`);+++ return null;+++ },+++ handleUpgrade(req, socket) {+++ // nginx sends /ws/chat to Chat; a socket that still lands here is told to retry.+++ try { socket.write('HTTP/1.1 503 Service Unavailable\r\nRetry-After: 2\r\nConnection: close\r\n\r\n'); } catch { /* */ }+++ try { socket.destroy(); } catch { /* */ }+++ return true;+++ },+++ close() {+++ if (presenceTimer) clearInterval(presenceTimer);+++ presenceTimer = null;+++ return flush().catch(() => {});+++ },+++ flush,++++++ // ── Pushes to browsers (Chat delivers) ──+++ broadcastToStream: send('broadcastToStream'),+++ broadcastToChannelRoom: send('broadcastToChannelRoom'),+++ broadcastGlobal: send('broadcastGlobal'),+++ broadcastAll: send('broadcastAll'),+++ forwardToGlobal: send('forwardToGlobal'),+++ forwardToGlobalByChannel: send('forwardToGlobalByChannel'),+++ forwardToStreamerRooms: send('forwardToStreamerRooms'),+++ broadcastToOwnerStreams: send('broadcastToOwnerStreams'),+++ sendDm: send('sendDm'),+++ sendUserUpdate: send('sendUserUpdate'),+++ disconnectUser: send('disconnectUser'),+++ sendToConn: send('sendToConn'),+++ synthesizeAndBroadcastTTS(...args) { enqueue('synthesizeAndBroadcastTTS', args); return Promise.resolve(); },+++ triggerChannelSound(ws, client, stream, command, args = [], relay = null) {+++ const c = client ? { streamId: client.streamId || null, user: client.user ? { id: client.user.id, username: client.user.username, display_name: client.user.display_name, role: client.user.role, avatar_url: client.user.avatar_url, profile_color: client.user.profile_color } : null, anonId: client.anonId || null, ip: client.ip || null } : {};+++ enqueue('triggerChannelSound', [null, c, slimStream(stream), command, args, relay]);+++ },+++ /** Commits this boot shipped (deploy-notice.js decides which; Chat stores and shows them). */+++ deployNotice(commits) { enqueue('deployNotice', [commits], { durable: true }); },+++ /** Live changed a user (role push, avatar): Chat drops what it cached about them. */+++ userChanged(userId) { if (userId) enqueue('userChanged', [userId]); },++++++ // ── Synchronous reads (presence snapshot) ──+++ getTotalConnections() { return Number(presence.total) || 0; },+++ getStreamViewerCount(streamId) { return Number((presence.streams || {})[streamId]) || 0; },+++ getConnectedUserIp(userId) {+++ const hit = (presence.users || []).find((u) => u.user_id === userId);+++ return hit ? hit.ip : null;+++ },+++ findClientByAnonId(anonId, streamId) {+++ // Same match as ChatServer.findClientByAnonId: the socket's stream must equal streamId.+++ const hit = (presence.anons || []).find((a) => a.anon_id === anonId && (a.stream_id ?? null) === streamId);+++ return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;+++ },+++ getUserList() { return { logged: [], anonCount: 0 }; },++++++ // ── Live's own address / anon helpers ──+++ normalizeIp: (ip) => local.normalizeIp(ip),+++ getClientIp: (req) => local.getClientIp(req),+++ getAnonIdForIp: (ip) => local.getAnonIdForIp(ip),+++ getAnonIdForConnection(ip, streamId = null) {+++ const key = local.normalizeIp(ip);+++ const hit = (presence.anons || []).find((a) => a.ip === key && (streamId == null || (a.stream_id || null) === streamId));+++ return hit ? hit.anon_id : local.getAnonIdForIp(key);+++ },+++ /** The anon number for an address (Network's unified resolve, else Live's table) — for Chat. */+++ async resolveAnon(ip) {+++ const key = local.normalizeIp(ip);+++ const num = await local._resolveUnifiedAnonNum(key);+++ return { anon_number: num, first_seen: db.getAnonFirstSeen(key) };+++ },+++ };++++++ installDbForwarding();+++ installInvalidations();+++ remote._restoreDb = () => { for (const [fn, orig] of Object.entries(originals)) db[fn] = orig; };+++ return remote;+++}++++++/**+++ * Rollback: Live runs chat itself again (CHAT_AUTHORITY unset) but chat writes it forwarded were+++ * never acknowledged by Chat (Chat down). Apply them to Live's own tables so nothing is lost.+++ * A write Chat did apply whose answer was lost is applied twice — rare, and only for that window.+++ */+++function drainToLocal(log = console) {+++ try {+++ const d = db.getDb();+++ if (!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_bridge_outbox'").get()) return 0;+++ const rows = d.prepare("SELECT id, args FROM chat_bridge_outbox WHERE op = 'db' ORDER BY id").all();+++ let applied = 0;+++ for (const r of rows) {+++ try {+++ const [fn, ...args] = JSON.parse(r.args);+++ if (FORWARDED_DB[fn] && typeof db[fn] === 'function') { db[fn](...args); applied++; }+++ } catch (err) { log.warn(`[ChatRemote] could not apply queued chat write #${r.id}: ${err.message}`); }+++ }+++ d.prepare('DELETE FROM chat_bridge_outbox').run();+++ if (rows.length) log.log(`[ChatRemote] applied ${applied} of ${rows.length} chat write(s) OpenVibe.Chat never acknowledged`);+++ return applied;+++ } catch (err) { log.warn('[ChatRemote] drain:', err.message); return 0; }+++}++++++module.exports = { create, drainToLocal, FORWARDED_DB, OBSERVED_DB, REF_BASE, AUDIENCE };++diff --git a/server/chat/chat-server.js b/server/chat/chat-server.js++index cfce589..ae77d68 100644++--- a/server/chat/chat-server.js++@@ -2491,4 +2491,6 @@ class ChatServer {++ }++ }++++-module.exports = new ChatServer();+++// CHAT_AUTHORITY=chat: OpenVibe.Chat runs the chat server; Live's modules get a proxy with the same+++// methods that hands every call to it (chat-remote.js). Default: Live runs chat itself.+++module.exports = require('./chat-authority').isRemote() ? require('./chat-remote').create(ChatServer) : new ChatServer();++diff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js++index 4b0f91a..33f8811 100644++--- a/server/chat/deploy-notice.js++@@ -82,6 +82,14 @@ async function announce({ db, chatServer, log = console }) {++ const { head, commits } = await newCommits(db);++ if (!head || !commits.length) return { announced: 0 };+++++ // CHAT_AUTHORITY=chat: Live still decides what shipped; OpenVibe.Chat stores the rolling+++ // message and shows it (its own copy of this module), so hand it the commits.+++ if (chatServer && chatServer.remote) {+++ chatServer.deployNotice(commits);+++ try { db.setSetting(SETTING, head); } catch (err) { log.warn('[Deploy notice] not recorded:', err.message); }+++ return { announced: commits.length };+++ }+++++ let saved;++ try { saved = persist(db, commits); db.setSetting(SETTING, head); }++ catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }++diff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js++new file mode 100644++index 0000000..53a72ce++--- /dev/null++@@ -0,0 +1,656 @@+++'use strict';+++/**+++ * What OpenVibe.Chat asks of Live (roadmap Wave 6). Chat owns chat; Live still owns accounts,+++ * streams, channels, bans, cosmetics, coins, AI viewers, the arena, the media queue and hardware.+++ * Chat's single adapter (OpenVibe.Chat server/live-context.js) calls these with a Network service+++ * token for audience openvibe.live (server/net/service-guard.js); nothing here is routed by nginx.+++ *+++ * /internal/chat-context/* capability live.chat_context.read — projections Chat caches+++ * POST /auth {token} → { user, expires_at, reason } (Live's own token rules)+++ * GET /users?after_id&limit → { rows } users projection (paged by id)+++ * POST /users/lookup {ids?, usernames?} → { users }+++ * GET /users/:id/follows → { streamer_ids }+++ * GET /users/profile?username&viewer_id → the /api/chat/user/:username/profile card+++ * GET /streams?after_id&limit, /streams/active, /streams/:id+++ * GET /managed-streams?after_id&limit, /channels?after_id&limit, /channels/by-user/:userId+++ * GET /channels/:id/policy → { channel, settings, moderator_ids, language }+++ * GET /channels/:id/approved-ip?ip → { approved }+++ * GET /bans?version → { version, bans } | { version, unchanged: true }+++ * POST /decor {user_ids} → { decor: { id: { cosmetic, tag } } }+++ * GET /settings → { settings } (tts_*, GIF and soundboard keys only)+++ * GET /anon/:num, /anon-first-seen?ip → { first_seen }+++ * GET /tts-audio/:file → a clip from the arena voice cache+++ *+++ * /internal/chat-effects/* capability live.chat_effects.write — side effects (CHAT_AUTHORITY=chat only)+++ * anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,+++ * alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,+++ * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, asset-sync+++ * mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy+++ *+++ * Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat+++ * checked first; Live does not take its word for it.+++ */+++const express = require('express');+++const fs = require('fs');+++const path = require('path');+++const db = require('../db/database');+++const config = require('../config');+++const permissions = require('../auth/permissions');+++const { guard } = require('../net/service-guard');+++const { isRemote } = require('./chat-authority');++++++const contextRouter = express.Router();+++const effectsRouter = express.Router();++++++const int = (v, d = 0) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };+++const pageArgs = (req) => ({ after: Math.max(0, int(req.query.after_id)), limit: Math.min(5000, Math.max(1, int(req.query.limit, 1000))) });+++const fail = (res, status, error) => res.status(status).json({ error });++++++// ── Projections ─────────────────────────────────────────────────++++++const SUBJECT_SQL = "(SELECT la.subject_id FROM linked_accounts la WHERE la.user_id = u.id AND la.service = 'network' AND la.subject_id IS NOT NULL ORDER BY la.id DESC LIMIT 1)";+++const USER_SQL = `SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, u.role, u.is_banned, u.ban_reason,+++ COALESCE(u.is_owner, 0) AS is_owner, u.created_at, ${SUBJECT_SQL} AS subject_id FROM users u`;+++const STREAM_SQL = 'SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams';+++const MS_SQL = 'SELECT id, user_id, slug, title, sort_order, created_at FROM managed_streams';+++const CHANNEL_SQL = 'SELECT id, user_id, title FROM channels';++++++/** The chat projection of a user row (no secrets: no email, password, stream key, balances). */+++function userProjection(user, subjectId) {+++ if (!user) return null;+++ let subject = subjectId || user.subject_id || null;+++ if (!subject) {+++ try { subject = db.get("SELECT subject_id FROM linked_accounts WHERE user_id = ? AND service = 'network' AND subject_id IS NOT NULL ORDER BY id DESC LIMIT 1", [user.id])?.subject_id || null; } catch { subject = null; }+++ }+++ return {+++ id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url || null,+++ profile_color: user.profile_color || null, role: user.role, is_banned: user.is_banned ? 1 : 0, ban_reason: user.ban_reason || null,+++ is_owner: user.is_owner ? 1 : 0, created_at: user.created_at, subject_id: subject,+++ };+++}++++++contextRouter.use(guard('live.chat_context.read'));++++++// Resolve a browser/bot token exactly as Live's requireAuth / authenticateWs do.+++contextRouter.post('/auth', (req, res) => {+++ const token = String((req.body && req.body.token) || '');+++ if (!token) return res.json({ user: null, reason: 'invalid' });+++ const auth = require('../auth/auth');+++ const apiUser = auth.authenticateApiToken(token);+++ if (apiUser) {+++ const user = { ...userProjection(apiUser), auth_source: 'api_token', scopes: apiUser.scopes || [] };+++ return res.json({ user, expires_at: null, reason: null });+++ }+++ const r = auth.verifyTokenWithReason(token);+++ if (!r.ok) return res.json({ user: null, reason: 'invalid' });+++ const user = auth.resolveNetworkUser(r.decoded);+++ if (!user) return res.json({ user: null, reason: 'unresolved' });+++ res.json({+++ user: { ...userProjection(user, r.decoded.subject_id), auth_source: 'network' },+++ expires_at: typeof r.decoded.exp === 'number' ? new Date(r.decoded.exp * 1000).toISOString() : null,+++ reason: null,+++ });+++});++++++contextRouter.get('/users', (req, res) => {+++ const { after, limit } = pageArgs(req);+++ res.json({ rows: db.all(`${USER_SQL} WHERE u.id > ? ORDER BY u.id LIMIT ?`, [after, limit]) });+++});++++++contextRouter.post('/users/lookup', (req, res) => {+++ const ids = (Array.isArray(req.body?.ids) ? req.body.ids : []).map(Number).filter(Number.isInteger).slice(0, 500);+++ const names = (Array.isArray(req.body?.usernames) ? req.body.usernames : []).map(String).slice(0, 100);+++ const rows = [];+++ if (ids.length) rows.push(...db.all(`${USER_SQL} WHERE u.id IN (${ids.map(() => '?').join(',')})`, ids));+++ for (const n of names) { const r = db.get(`${USER_SQL} WHERE u.username = ? COLLATE NOCASE`, [n]); if (r) rows.push(r); }+++ res.json({ users: rows });+++});++++++contextRouter.get('/users/profile', (req, res) => {+++ // The same card /api/chat/user/:username/profile built in Live.+++ try {+++ const name = String(req.query.username || '');+++ let user = db.getUserByUsername(name);+++ if (!user) user = db.get('SELECT * FROM users WHERE display_name = ? COLLATE NOCASE', [name]);+++ if (!user) return fail(res, 404, 'User not found');+++ const profile = db.getUserProfile(user.id);+++ if (!profile) return fail(res, 404, 'Profile not found');+++ // OpenCoins are part of the game; Vibes (real money) and presence stay with the user.+++ const viewerId = int(req.query.viewer_id, 0);+++ if (!viewerId || viewerId !== user.id) {+++ delete profile.openvibe_bucks_balance;+++ delete profile.last_seen;+++ }+++ try {+++ const game = require('../game/game-engine');+++ const player = game.getPlayer(user.id);+++ if (player) {+++ profile.game = {+++ total_level: player.total_level,+++ mining_level: player.mining_level,+++ fishing_level: player.fishing_level,+++ woodcut_level: player.woodcut_level,+++ farming_level: player.farming_level,+++ combat_level: player.combat_level,+++ crafting_level: player.crafting_level,+++ mining_xp: player.mining_xp,+++ fishing_xp: player.fishing_xp,+++ woodcut_xp: player.woodcut_xp,+++ farming_xp: player.farming_xp,+++ combat_xp: player.combat_xp,+++ crafting_xp: player.crafting_xp,+++ total_coins_earned: player.total_coins_earned || 0,+++ };+++ }+++ } catch { /* game not initialized or player doesn't exist */ }+++ res.json(profile);+++ } catch (err) {+++ fail(res, 500, 'Failed to get profile');+++ }+++});++++++contextRouter.get('/users/:id/follows', (req, res) => {+++ res.json({ streamer_ids: db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [int(req.params.id)]).map((r) => r.streamer_id) });+++});++++++contextRouter.get('/streams', (req, res) => {+++ const { after, limit } = pageArgs(req);+++ res.json({ rows: db.all(`${STREAM_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });+++});+++// Live now, plus anything that ended in the last quarter hour (so Chat sees it go offline).+++contextRouter.get('/streams/active', (req, res) => {+++ res.json({ rows: db.all(`${STREAM_SQL} WHERE is_live = 1 OR (ended_at IS NOT NULL AND ended_at >= datetime('now', '-15 minutes'))`) });+++});+++contextRouter.get('/streams/:id', (req, res) => {+++ const stream = db.get(`${STREAM_SQL} WHERE id = ?`, [int(req.params.id)]);+++ if (!stream) return res.json({ stream: null });+++ res.json({+++ stream,+++ owner: db.get(`${USER_SQL} WHERE u.id = ?`, [stream.user_id]) || null,+++ managed_stream: stream.managed_stream_id ? (db.get(`${MS_SQL} WHERE id = ?`, [stream.managed_stream_id]) || null) : null,+++ channel: stream.channel_id ? (db.get(`${CHANNEL_SQL} WHERE id = ?`, [stream.channel_id]) || null) : (db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [stream.user_id]) || null),+++ });+++});++++++contextRouter.get('/managed-streams', (req, res) => {+++ const { after, limit } = pageArgs(req);+++ res.json({ rows: db.all(`${MS_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });+++});++++++contextRouter.get('/channels', (req, res) => {+++ const { after, limit } = pageArgs(req);+++ res.json({ rows: db.all(`${CHANNEL_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });+++});+++contextRouter.get('/channels/by-user/:userId', (req, res) => {+++ res.json({ channel: db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [int(req.params.userId)]) || null });+++});+++contextRouter.get('/channels/:id/policy', (req, res) => {+++ const channelId = int(req.params.id);+++ const channel = db.get(`${CHANNEL_SQL} WHERE id = ?`, [channelId]) || null;+++ let language = 'en';+++ try { if (channel) language = require('../i18n/translate').channelLanguage(channel.user_id) || 'en'; } catch { language = 'en'; }+++ res.json({+++ channel,+++ settings: db.getChannelModerationSettings(channelId),+++ moderator_ids: db.all('SELECT user_id FROM channel_moderators WHERE channel_id = ?', [channelId]).map((r) => r.user_id),+++ language,+++ });+++});+++contextRouter.get('/channels/:id/approved-ip', (req, res) => {+++ res.json({ approved: db.isIpApproved(int(req.params.id), String(req.query.ip || '')) });+++});++++++// Active bans (user, IP and CIDR rows). `version` changes whenever the table does, so an+++// unchanged table is one small answer.+++contextRouter.get('/bans', (req, res) => {+++ const v = db.get('SELECT COUNT(*) AS c, COALESCE(MAX(id), 0) AS m, COALESCE(SUM(id * 7 + COALESCE(stream_id, 0) + LENGTH(COALESCE(expires_at, \'\'))), 0) AS s FROM bans');+++ const version = `${v.c}:${v.m}:${v.s}`;+++ if (req.query.version && String(req.query.version) === version) return res.json({ version, unchanged: true });+++ res.json({+++ version,+++ bans: db.all('SELECT id, stream_id, user_id, ip_address, anon_id, expires_at FROM bans WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP'),+++ });+++});++++++contextRouter.post('/decor', (req, res) => {+++ const ids = (Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger).slice(0, 500);+++ let cosmetics = null, tags = null;+++ try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }+++ try { tags = require('../game/tags'); } catch { /* */ }+++ const decor = {};+++ for (const id of ids) {+++ let cosmetic = {}, tag = null;+++ try { if (cosmetics) cosmetic = cosmetics.getCosmeticProfile(id) || {}; } catch { cosmetic = {}; }+++ try { if (tags) tag = tags.getTagProfile(id) || null; } catch { tag = null; }+++ decor[id] = { cosmetic, tag };+++ }+++ res.json({ decor });+++});++++++// Site settings chat reads (TTS config, GIF provider keys, the 101soundboards key) — nothing else.+++const CHAT_SETTING_KEYS = new Set(['gif_tenor_api_key', 'gif_giphy_api_key', 'soundboard_101_api_key']);+++const isChatSetting = (k) => CHAT_SETTING_KEYS.has(k) || /^tts_[a-z0-9_]+$/.test(k);+++contextRouter.get('/settings', (req, res) => {+++ const settings = {};+++ for (const r of db.all("SELECT key FROM site_settings WHERE key LIKE 'tts\\_%' ESCAPE '\\' OR key IN ('gif_tenor_api_key', 'gif_giphy_api_key', 'soundboard_101_api_key')")) {+++ if (isChatSetting(r.key)) settings[r.key] = db.getSetting(r.key);+++ }+++ res.json({ settings });+++});++++++contextRouter.get('/anon/:num', (req, res) => {+++ res.json({ first_seen: db.get('SELECT created_at FROM anon_ip_mappings WHERE anon_num = ?', [int(req.params.num)])?.created_at || null });+++});+++contextRouter.get('/anon-first-seen', (req, res) => {+++ res.json({ first_seen: db.getAnonFirstSeen(String(req.query.ip || '')) });+++});++++++contextRouter.get('/tts-audio/:file', (req, res) => {+++ try {+++ const hit = require('../arena/voice').cachedByName(req.params.file);+++ if (!hit) return fail(res, 404, 'No such clip');+++ res.set({ 'Content-Type': hit.mimeType });+++ require('fs').createReadStream(hit.path).pipe(res);+++ } catch (err) { fail(res, 500, err.message); }+++});++++++// ── Effects ────────────────────────────────────────────────────++++++effectsRouter.post('/mirror', guard('live.chat_mirror.write'), (req, res, next) => next());+++effectsRouter.use((req, res, next) => {+++ if (req.path === '/mirror') return next();+++ return guard('live.chat_effects.write')(req, res, next);+++});+++// Live only takes Chat's writes while Chat is the authority — a rehearsal Chat pointed at+++// production Live can never award coins, post to AI viewers or overwrite chat tables.+++effectsRouter.use((req, res, next) => {+++ if (!isRemote()) return fail(res, 409, 'Live runs chat itself (CHAT_AUTHORITY is not "chat")');+++ next();+++});++++++const chatServer = () => require('./chat-server');+++const actorOf = (id) => (id ? db.getUserById(id) : null);++++++effectsRouter.post('/anon', async (req, res) => {+++ const ip = String(req.body?.ip || '');+++ if (!ip) return fail(res, 400, 'ip required');+++ try { res.json(await chatServer().resolveAnon(ip)); } catch (err) { fail(res, 500, err.message); }+++});++++++effectsRouter.post('/ip-log', (req, res) => {+++ const entries = Array.isArray(req.body?.entries) ? req.body.entries.slice(0, 1000) : [];+++ let ipUtils = null;+++ try { ipUtils = require('../admin/ip-utils'); } catch { /* */ }+++ for (const e of entries) {+++ try {+++ const geo = ipUtils ? ipUtils.enrichIp(e.ip) : null;+++ db.logIp({ userId: e.userId || null, anonId: e.anonId || null, ip: e.ip, action: e.action || 'chat', geo });+++ } catch { /* non-critical */ }+++ }+++ res.json({ ok: true, logged: entries.length });+++});++++++effectsRouter.post('/viewer-counts', (req, res) => {+++ for (const [sid, count] of Object.entries(req.body?.counts || {})) {+++ try { db.updateViewerCount(int(sid), Math.max(0, int(count))); } catch { /* */ }+++ }+++ res.json({ ok: true });+++});++++++effectsRouter.post('/viewer-snapshots', (req, res) => {+++ for (const s of (Array.isArray(req.body?.snapshots) ? req.body.snapshots : []).slice(0, 1000)) {+++ try { db.insertViewerSnapshot(int(s.stream_id), Math.max(0, int(s.viewer_count)), Math.max(0, int(s.chat_messages_5m))); } catch { /* */ }+++ }+++ res.json({ ok: true });+++});++++++// /color: the user changes their own color.+++effectsRouter.post('/user-color', (req, res) => {+++ const userId = int(req.body?.user_id);+++ const color = String(req.body?.color || '');+++ if (!userId || !/^#[0-9a-fA-F]{6}$/.test(color)) return fail(res, 400, 'user_id and a #rrggbb color required');+++ db.run('UPDATE users SET profile_color = ? WHERE id = ?', [color, userId]);+++ res.json({ ok: true });+++});++++++// /ban, /timeout, /unban from chat. The moderator is checked again: global staff, or a+++// moderator of the stream chat decided on (for an offline channel room, its latest stream).+++effectsRouter.post('/ban', (req, res) => {+++ const b = req.body || {};+++ const actor = actorOf(int(b.actor_user_id));+++ const modStream = int(b.moderation_stream_id) || null;+++ const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));+++ if (!allowed) return fail(res, 403, 'You do not have permission.');+++ const streamId = b.stream_id != null ? int(b.stream_id) || null : null;+++ if (b.action === 'unban') {+++ const userId = int(b.user_id);+++ if (!userId) return fail(res, 400, 'user_id required');+++ db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)', [userId, streamId]);+++ return res.json({ ok: true });+++ }+++ if (b.action !== 'ban') return fail(res, 400, 'action must be ban or unban');+++ if (b.user_id) {+++ const target = db.getUserById(int(b.user_id));+++ if (!target) return fail(res, 404, 'User not found');+++ // Prevent non-admins from banning admins+++ if (permissions.isGlobalModOrAbove(target) && target.role === 'admin' && actor.role !== 'admin') return fail(res, 403, 'You cannot ban an admin.');+++ db.run('INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)',+++ [streamId, target.id, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id, b.expires_at || null]);+++ } else if (b.ip_address) {+++ db.run('INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)',+++ [streamId, String(b.ip_address), b.anon_id ? String(b.anon_id) : null, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id]);+++ } else {+++ return fail(res, 400, 'user_id or ip_address required');+++ }+++ res.json({ ok: true });+++});++++++// IP approval mode: an address that chatted here before is approved automatically ('auto_existing').+++effectsRouter.post('/approve-ip', (req, res) => {+++ const channelId = int(req.body?.channel_id);+++ const ip = String(req.body?.ip || '');+++ if (!channelId || !ip) return fail(res, 400, 'channel_id and ip required');+++ db.approveIp(channelId, ip, req.body.approved_by ? int(req.body.approved_by) : null, String(req.body.source || 'auto').slice(0, 32));+++ res.json({ ok: true });+++});++++++// /slow persists the channel's slow mode.+++effectsRouter.post('/channel-settings', (req, res) => {+++ const channelId = int(req.body?.channel_id);+++ const actor = actorOf(int(req.body?.actor_user_id));+++ if (!channelId || !actor || !permissions.canModerateChannel(actor, channelId)) return fail(res, 403, 'You do not have permission.');+++ const fields = {};+++ if (req.body.fields && req.body.fields.slow_mode_seconds !== undefined) fields.slow_mode_seconds = Math.max(0, int(req.body.fields.slow_mode_seconds));+++ if (!Object.keys(fields).length) return fail(res, 400, 'no chat-settable fields');+++ db.upsertChannelModerationSettings(channelId, fields);+++ res.json({ ok: true });+++});++++++// Donation / goal alert sounds: the streamer's own channel only, files in the shared sounds dir.+++effectsRouter.post('/alert-sound', (req, res) => {+++ const channelId = int(req.body?.channel_id);+++ const channel = channelId ? db.getChannelById(channelId) : null;+++ if (!channel || channel.user_id !== int(req.body?.actor_user_id)) return fail(res, 403, 'Not your channel');+++ const url = req.body.url ? path.resolve(String(req.body.url)) : null;+++ if (url) {+++ // Compared through realpath: Chat and Live may reach the shared sounds dir via different symlinks.+++ let inside = false;+++ try { inside = path.dirname(fs.realpathSync(url)) === fs.realpathSync(path.resolve(config.sounds.path)); } catch { inside = false; }+++ if (!inside) return fail(res, 400, 'alert sounds live in the sounds directory');+++ }+++ db.setChannelAlertSound(channelId, req.body.kind === 'goal' ? 'goal' : 'donation', url, url ? String(req.body.mime || 'audio/mpeg') : null);+++ res.json({ ok: true });+++});++++++effectsRouter.post('/ensure-channel', (req, res) => {+++ const userId = int(req.body?.user_id);+++ if (!userId || !db.getUserById(userId)) return fail(res, 404, 'User not found');+++ const ch = db.ensureChannel(userId);+++ res.json({ channel: ch ? { id: ch.id, user_id: ch.user_id, title: ch.title } : null });+++});++++++// TTS admin settings: admins; credentials only the owner (Live's /api/tts/admin/settings rules).+++effectsRouter.post('/site-settings', (req, res) => {+++ const actor = actorOf(int(req.body?.actor_user_id));+++ if (!actor || actor.role !== 'admin') return fail(res, 403, 'Admin access required');+++ const allowed = ['tts_enabled', 'tts_provider', 'tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region', 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global', 'tts_default_voice'];+++ const secret = new Set(['tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key']);+++ const owner = permissions.isOwner(actor);+++ let count = 0;+++ for (const [key, value] of Object.entries(req.body.settings || {})) {+++ if (!allowed.includes(key)) continue;+++ if (secret.has(key) && !owner) continue;+++ if (secret.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;+++ db.setSetting(key, value);+++ count++;+++ }+++ try { require('./tts-engine').invalidateSettingsCache(); } catch { /* */ }+++ res.json({ ok: true, updated: count });+++});++++++// One real chat line: OpenCoins chat bonus, AI chat viewers, the PowerChat overlay relay.+++effectsRouter.post('/chat-message', (req, res) => {+++ const b = req.body || {};+++ let coin = null;+++ if (b.award && b.user_id && b.stream_id) {+++ try { coin = require('../monetization/opencoins').awardChat(int(b.user_id), int(b.stream_id)); } catch { coin = null; }+++ }+++ if (b.ai && b.stream_id) {+++ try {+++ require('../integrations/ai-chatbot-service').onRealChatMessage(int(b.stream_id), {+++ username: b.username,+++ message: b.message,+++ userId: b.user_id || null,+++ anonId: b.anon_id || null,+++ isStreamer: !!b.is_streamer,+++ isMod: !!b.is_mod,+++ msgId: b.msg_id || null,+++ channelUserId: b.channel_user_id || null,+++ });+++ } catch { /* non-critical */ }+++ }+++ if (b.powerchat && b.channel_user_id && b.powerchat_chat) {+++ try {+++ const pc = require('../integrations/powerchat-platform');+++ const channelUserId = int(b.channel_user_id);+++ if (pc.channelRelayEnabled(channelUserId, b.stream_id || null)) {+++ let isSub = false;+++ try { isSub = !!(b.user_id && db.isActiveSubscriber(int(b.user_id), channelUserId)); } catch { /* */ }+++ const c = b.powerchat_chat;+++ pc.forwardChat(channelUserId, {+++ chatterName: c.chatterName,+++ externalChatterId: c.externalChatterId,+++ message: c.message,+++ avatarUrl: c.avatarUrl || undefined,+++ isModerator: !!c.isModerator,+++ isSubscriber: isSub,+++ });+++ }+++ } catch { /* non-critical */ }+++ }+++ res.json({ coin });+++});++++++effectsRouter.post('/ai/mod-command', (req, res) => {+++ try {+++ const engine = require('../integrations/ai-chatbot-service');+++ const reply = engine.onModCommand+++ ? engine.onModCommand(req.body.channel_user_id || null, req.body.stream_id || null, Array.isArray(req.body.args) ? req.body.args : [], { by: req.body.by })+++ : 'AI viewers: command not supported by this engine.';+++ res.json({ reply: reply || null });+++ } catch (err) { fail(res, 400, err.message); }+++});++++++// !hype / !beef / !arena: arena-chat answers the sender through Chat (sendToConn).+++effectsRouter.post('/arena-command', (req, res) => {+++ const client = req.body?.client || {};+++ const cs = chatServer();+++ const shim = {+++ sendTo: (_ws, payload) => cs.sendToConn(client.conn_id, payload),+++ broadcastToStream: (streamId, payload) => cs.broadcastToStream(streamId, payload),+++ };+++ let handled = false;+++ try { handled = require('../arena/arena-chat').handle(shim, null, client, String(req.body.cmd || ''), Array.isArray(req.body.parts) ? req.body.parts : []); } catch (e) { console.warn('[Arena] chat command:', e.message); }+++ res.json({ handled: !!handled });+++});++++++// !sr / !queue / !np / !skip — the media queue is Live's.+++effectsRouter.post('/media-queue', async (req, res) => {+++ const b = req.body || {};+++ const mediaQueue = require('../media/media-queue');+++ const streamerId = int(b.streamerId);+++ if (!streamerId) return fail(res, 400, 'streamerId required');+++ try {+++ if (b.op === 'add') {+++ const request = await mediaQueue.addRequest({ streamerId, streamId: int(b.streamId) || null, userId: int(b.userId), username: String(b.username || ''), input: String(b.input || '') });+++ return res.json({ request });+++ }+++ if (b.op === 'state') return res.json({ state: mediaQueue.getState(streamerId) });+++ if (b.op === 'skip') {+++ const actor = actorOf(int(b.actorUserId));+++ const streamId = int(b.streamId) || null;+++ const allowed = actor && (actor.id === streamerId || permissions.isGlobalModOrAbove(actor) || (streamId && permissions.canModerateStream(actor, streamId)));+++ if (!allowed) return fail(res, 403, 'Only the streamer or a moderator can skip media.');+++ const ended = mediaQueue.finishCurrent(streamerId, 'skipped');+++ const next = mediaQueue.startNext(streamerId);+++ return res.json({ ended: ended || null, next: next || null });+++ }+++ fail(res, 400, 'unknown op');+++ } catch (err) {+++ fail(res, 400, err.message || 'Media command failed.');+++ }+++});++++++// Cozmo / !say: the robot's control socket is in Live, keyed by the streamer's stream key.+++const HARDWARE_COMMANDS = new Set(['forward', 'backward', 'turn_left', 'turn_right', 'lift_up', 'lift_down', 'head_up', 'head_down']);+++effectsRouter.post('/hardware', (req, res) => {+++ const command = String(req.body?.command || '');+++ if (!HARDWARE_COMMANDS.has(command) && !/^say:[\s\S]{1,200}$/.test(command)) return fail(res, 400, 'unknown command');+++ const user = db.getUserById(int(req.body.streamer_user_id));+++ if (!user) return res.json({ ok: false, reason: 'no_user' });+++ const controlServer = require('../controls/control-server');+++ const hwWs = controlServer.hardwareClients.get(user.stream_key);+++ if (!hwWs || hwWs.readyState !== 1) return res.json({ ok: false, reason: 'no_hardware' });+++ hwWs.send(JSON.stringify({+++ type: 'command',+++ command,+++ from_user: String(req.body.from_user || ''),+++ timestamp: new Date().toISOString(),+++ }));+++ res.json({ ok: true });+++});++++++// /paste from chat, through Live's pastes client (Community or Media per PASTES_AUTHORITY).+++effectsRouter.post('/paste', async (req, res) => {+++ const b = req.body || {};+++ try {+++ const paste = await require('../pastes-client').createPaste({+++ title: String(b.title || '').slice(0, 200),+++ content: String(b.content || ''),+++ language: b.language || 'auto',+++ visibility: b.visibility || 'public',+++ user_id: b.user_id || undefined,+++ stream_id: b.stream_id || undefined,+++ });+++ res.json({ paste: { slug: paste.slug } });+++ } catch (err) { fail(res, 502, err.message); }+++});++++++effectsRouter.post('/translate', async (req, res) => {+++ let i18n;+++ try { i18n = require('../i18n/translate'); } catch { return res.json({ translation: null }); }+++ if (!i18n.available()) return res.json({ translation: null });+++ try {+++ const tr = await i18n.translateChatMessage(String(req.body?.text || ''), req.body?.channel_user_id || null);+++ res.json({ translation: tr && tr.text ? tr : null });+++ } catch { res.json({ translation: null }); }+++});++++++effectsRouter.post('/notify/dm', (req, res) => {+++ try {+++ const { pushBulkNotification, actorInfo } = require('../utils/notify');+++ const sender = db.getUserById(int(req.body?.sender_id));+++ const otherIds = (Array.isArray(req.body?.recipient_ids) ? req.body.recipient_ids : []).map(Number).filter(Boolean).slice(0, 50);+++ const convId = int(req.body?.conversation_id);+++ if (otherIds.length && convId) {+++ const senderName = sender?.display_name || sender?.username || 'Someone';+++ // Generic preview — the message itself only travels over the live socket.+++ pushBulkNotification(otherIds, {+++ type: 'DIRECT_MESSAGE',+++ title: `Message from ${senderName}`,+++ message: 'Sent you a message',+++ url: `https://openvibe.live/dm/${convId}`,+++ ...actorInfo(sender),+++ });+++ }+++ } catch { /* non-critical */ }+++ res.json({ ok: true });+++});++++++effectsRouter.post('/notify/dm-read', (req, res) => {+++ try {+++ const { markNotificationsRead } = require('../utils/notify');+++ markNotificationsRead(int(req.body?.user_id), 'DIRECT_MESSAGE', `%/dm/${int(req.body?.conversation_id)}`);+++ } catch { /* non-critical */ }+++ res.json({ ok: true });+++});++++++// Channel sound files are mirrored to OpenVibe.Media from Live's copy of channel_sounds.+++effectsRouter.post('/asset-sync', (req, res) => {+++ try {+++ const sync = require('../media-proxy/asset-sync');+++ if (req.body?.op === 'syncSoon') sync.syncSoon();+++ else if (req.body?.op === 'remove-sound') {+++ const row = db.get('SELECT media_asset_id FROM channel_sounds WHERE id = ?', [int(req.body.asset_id)]);+++ if (row && row.media_asset_id) sync.removeAsset(row.media_asset_id);+++ }+++ } catch { /* best-effort */ }+++ res.json({ ok: true });+++});++++++// ── The read mirror of Chat's tables ──────────────────────────────+++// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id+++// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).+++const MIRROR_TABLES = {+++ chat_messages: ['id'],+++ dm_conversations: ['id'],+++ dm_participants: ['id'],+++ dm_messages: ['id'],+++ dm_blocks: ['id'],+++ tts_voice_overrides: ['identity_key'],+++ channel_sounds: ['id'],+++ relay_users: ['platform', 'username'],+++ hidden_relay_users: ['id'],+++ pending_ip_messages: ['id'],+++ stream_first_chats: ['chatter_key', 'channel_user_id'],+++ moderation_actions: ['id'],+++};+++const _cols = new Map();+++function liveColumns(table) {+++ if (!_cols.has(table)) _cols.set(table, new Set(db.getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));+++ return _cols.get(table);+++}++++++function applyMirror(changes) {+++ const d = db.getDb();+++ let applied = 0;+++ const skipped = [];+++ // A copy of the authority's rows: foreign keys are the authority's business. Synchronous, so+++ // nothing else runs while they are off.+++ d.pragma('foreign_keys = OFF');+++ try {+++ d.transaction(() => {+++ for (const c of changes) {+++ const pk = MIRROR_TABLES[c && c.table];+++ if (!pk) { skipped.push({ table: c && c.table, reason: 'not a mirrored table' }); continue; }+++ const have = liveColumns(c.table);+++ try {+++ if (c.op === 'delete') {+++ d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));+++ } else if (c.op === 'upsert' && c.row) {+++ const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));+++ const upd = cols.filter((k) => !pk.includes(k));+++ d.prepare(`INSERT INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})+++ ON CONFLICT(${pk.join(', ')}) DO ${upd.length ? `UPDATE SET ${upd.map((k) => `${k} = excluded.${k}`).join(', ')}` : 'NOTHING'}`)+++ .run(...cols.map((k) => c.row[k]));+++ } else { skipped.push({ table: c.table, reason: 'bad change' }); continue; }+++ applied++;+++ } catch (err) {+++ skipped.push({ table: c.table, pk: c.pk || (c.row && pk.map((k) => c.row[k])), reason: err.message });+++ }+++ }+++ })();+++ } finally {+++ d.pragma('foreign_keys = ON');+++ }+++ return { applied, skipped };+++}++++++effectsRouter.post('/mirror', (req, res) => {+++ const changes = Array.isArray(req.body?.changes) ? req.body.changes.slice(0, 1000) : [];+++ const out = applyMirror(changes);+++ if (out.skipped.length) console.warn(`[ChatMirror] ${out.skipped.length} change(s) not applied:`, JSON.stringify(out.skipped.slice(0, 3)));+++ res.json({ ok: true, ...out });+++});++++++module.exports = { contextRouter, effectsRouter, applyMirror, userProjection, MIRROR_TABLES };++diff --git a/server/index.js b/server/index.js++index 894e159..2763945 100644++--- a/server/index.js++@@ -585,6 +585,11 @@ app.get('/internal/analytics-summary', (req, res) => {++ } catch (err) { res.status(500).json({ ok: false, error: err.message }); }++ });+++++// OpenVibe.Chat reads Live data and asks for side effects here, with Network service tokens+++// (server/chat/live-context-routes.js); mounted before the X-Internal-Key routes below.+++const chatLiveContext = require('./chat/live-context-routes');+++app.use('/internal/chat-context', chatLiveContext.contextRouter);+++app.use('/internal/chat-effects', chatLiveContext.effectsRouter);++ app.use('/internal', require('./internal/routes'));++++++@@ -653,7 +658,11 @@ app.use('/api/streams', streamRoutes);++ // Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).++ const liveEvents = require('./streaming/live-events');++ app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));++-app.use('/api/chat', chatRoutes);+++// CHAT_AUTHORITY=chat: OpenVibe.Chat serves the chat REST routes (nginx sends /api/chat/, /api/dm/,+++// /api/tts/, /api/sounds there). One that still reaches Live must not write to the mirror.+++const chatRemote = require('./chat/chat-authority').isRemote();+++const chatMoved = (req, res) => res.status(503).set('Retry-After', '2').json({ error: 'Chat is served by OpenVibe.Chat' });+++app.use('/api/chat', chatRemote ? chatMoved : chatRoutes);++ app.use('/api/funds', monetizationRoutes);++ app.use('/api/coins', coinsRoutes);++ app.use('/api/payments', require('./monetization/payments-routes'));++@@ -675,7 +684,7 @@ app.use('/api/restream', restreamRoutes);++ app.use('/api/thumbnails', thumbnailRoutes);++ app.use('/api/themes', themeRoutes);++ app.use('/api/emotes', emoteRoutes);++-app.use('/api/sounds', require('./chat/sounds-routes'));+++app.use('/api/sounds', chatRemote ? chatMoved : require('./chat/sounds-routes'));++ app.use('/api/ai-viewers', require('./ai/viewers/routes'));++ app.use('/api/powerchat', require('./integrations/powerchat-routes'));++ // Game & Canvas — migrated to openvibe.games++@@ -695,9 +704,9 @@ app.use('/api/media', require('./media/routes'));++ app.use('/api/img-proxy', require('./media/external-image-proxy'));++ app.use('/api/vibe-coding', vibeCodingRoutes);++ const ttsRoutes = require('./chat/tts-routes');++-app.use('/api/tts', ttsRoutes);+++app.use('/api/tts', chatRemote ? chatMoved : ttsRoutes);++ const dmRoutes = require('./chat/dm-routes');++-app.use('/api/dm', dmRoutes);+++app.use('/api/dm', chatRemote ? chatMoved : dmRoutes);++ const analyticsRoutes = require('./streaming/analytics-routes');++ app.use('/api/analytics', analyticsRoutes);++ const newsRoutes = require('./news/news-routes');++@@ -1081,6 +1090,8 @@ async function start() {++ // Initialize DM tables++ const dm = require('./chat/dm');++ dm.ensureTables();+++ // Back from CHAT_AUTHORITY=chat (rollback): chat writes OpenVibe.Chat never acknowledged land here.+++ if (!require('./chat/chat-authority').isRemote()) require('./chat/chat-remote').drainToLocal();++ // Migrate: add last_heartbeat column if missing++ try { db.run("ALTER TABLE streams ADD COLUMN last_heartbeat DATETIME"); console.log('[DB] Added last_heartbeat column'); } catch { /* already exists */ }++ // Migrate: add theme_id to users table if missing++diff --git a/server/internal/routes.js b/server/internal/routes.js++index 540fcb2..d962f81 100644++--- a/server/internal/routes.js++@@ -17,6 +17,11 @@ function requireInternalKey(req, res, next) {++++ router.use(requireInternalKey);+++++// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.+++function notifyChat(userId) {+++ try { const cs = require('../chat/chat-server'); if (cs.remote) cs.userChanged(userId); } catch { /* non-critical */ }+++}+++++ // ── Site copy for the shared footer (OpenVibe.Network → here, once a day) ─────────────++ // The Network sends facts about each site plus an allow-list of link ids; the model writes a++ // short blurb per site and PICKS ids from that list. It never returns URLs or markup, so nothing++@@ -81,6 +86,7 @@ router.post('/user-avatar', (req, res) => {++ if (!user && username) user = db.getUserByUsername(username);++ if (!user) return res.status(404).json({ ok: false, error: 'user not found' });++ if ((user.avatar_url || null) !== url) db.updateUserAvatar(user.id, url, null);+++ notifyChat(user.id);++ return res.json({ ok: true, id: user.id, changed: (user.avatar_url || null) !== url });++ } catch (err) {++ console.error('[Internal] user-avatar error:', err.message);++@@ -107,6 +113,7 @@ router.post('/user-role', (req, res) => {++ // Never strip the owner's admin role via a role push (is_owner is local).++ const finalRole = (user.is_owner && role !== 'admin') ? 'admin' : role;++ db.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run(finalRole, user.id);+++ notifyChat(user.id);++ console.log(`[Internal] role push: ${user.username} -> ${finalRole}`);++ return res.json({ ok: true, id: user.id, username: user.username, role: finalRole });++ } catch (err) {++diff --git a/server/net/service-guard.js b/server/net/service-guard.js++new file mode 100644++index 0000000..e4b13d4++--- /dev/null++@@ -0,0 +1,45 @@+++'use strict';+++/**+++ * Live as a receiver of service tokens (roadmap Wave 6).+++ *+++ * guard(capability) checks a Network client-credentials token (RS256, contract+++ * identity.service-token-claims@1) for audience openvibe.live on Live's own internal routes —+++ * today the ones OpenVibe.Chat calls (/internal/chat-context/*, /internal/chat-effects/*). The+++ * token is verified with the same Network key Live verifies user JWTs with.+++ *+++ * Chat's capabilities (live.chat_context.read, live.chat_effects.write, live.chat_mirror.write)+++ * are proposals not yet in the openvibe-contracts registry, whose requireCapability() refuses+++ * unknown ids; until they are registered the grant is checked with capabilities.grants() and the+++ * guard switches to capabilities.check() by itself once the registry knows the id. Like+++ * X-Internal-Key routes, these are loopback-only: anything that came through nginx is refused.+++ */+++const { serviceAuth, capabilities, http } = require('openvibe-contracts');+++const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');++++++const AUDIENCE = 'openvibe.live';++++++function viaProxy(req) {+++ return !!(req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']);+++}++++++function guard(capability) {+++ const registered = !!capabilities.get(capability);+++ return function liveServiceGuard(req, res, next) {+++ const ctx = http.requestContext(req.headers);+++ if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });+++ const auth = String(req.headers.authorization || '');+++ if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });+++ const publicKey = getNetworkPublicKey();+++ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });+++ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: getNetworkIssuer(), audience: AUDIENCE });+++ if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });+++ const c = registered+++ ? capabilities.check(r.claims, capability)+++ : (capabilities.grants(r.claims.cap, capability) ? { allowed: true } : { allowed: false, code: 'capability.denied', reason: `${capability} not granted` });+++ if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });+++ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };+++ next();+++ };+++}++++++module.exports = { guard, AUDIENCE };++diff --git a/test/chat-context.test.js b/test/chat-context.test.js++new file mode 100644++index 0000000..1d935cc++--- /dev/null++@@ -0,0 +1,283 @@+++'use strict';++++++// Chat moved to OpenVibe.Chat (roadmap Wave 6). What Live answers it on /internal/chat-context/*+++// and /internal/chat-effects/* (service tokens, capabilities, re-checked moderators, the read+++// mirror), and the chat-server proxy Live's own modules get with CHAT_AUTHORITY=chat (ordered+++// bridge calls, placeholder ids for forwarded inserts, presence reads). Against stub Network and+++// Chat servers.++++++const assert = require('assert');+++const fs = require('fs');+++const os = require('os');+++const path = require('path');+++const http = require('http');+++const crypto = require('crypto');+++const jwt = require('jsonwebtoken');+++const { serviceAuth } = require('openvibe-contracts');++++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chatctx-'));+++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });+++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);+++fs.mkdirSync(path.join(tmp, 'sounds'));+++process.env.DB_PATH = path.join(tmp, 'live.db');+++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');+++process.env.OV_NETWORK_URL = 'https://openvibe.network';+++process.env.OV_OAUTH_CLIENT_ID = 'live';+++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';+++process.env.SOUNDS_PATH = path.join(tmp, 'sounds');+++process.env.CHAT_AUTHORITY = 'chat';+++const quiet = console.log;+++console.log = () => {};+++console.warn = () => {};++++++const ISS = 'https://openvibe.network';+++const now = () => Math.floor(Date.now() / 1000);+++let jti = 0;+++function serviceToken(cap, { aud = 'openvibe.live', sub = 'svc:chat' } = {}) {+++ return serviceAuth.signServiceToken({ iss: ISS, sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey);+++}+++const READ = serviceToken(['live.chat_context.read']);+++const WRITE = serviceToken(['live.chat_effects.write']);+++const MIRROR = serviceToken(['live.chat_mirror.write']);++++++// Stub Network (Live's own service token for audience openvibe.chat) and stub Chat (the bridge).+++const bridgeCalls = [];+++const network = http.createServer((req, res) => {+++ let raw = '';+++ req.on('data', (c) => { raw += c; });+++ req.on('end', () => {+++ res.setHeader('Content-Type', 'application/json');+++ if (req.url === '/oauth/token') {+++ const f = new URLSearchParams(raw);+++ return res.end(JSON.stringify({ access_token: serviceToken(['chat.live_bridge.write', 'chat.presence.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));+++ }+++ res.statusCode = 404; res.end('{}');+++ });+++});+++const chat = http.createServer((req, res) => {+++ let raw = '';+++ req.on('data', (c) => { raw += c; });+++ req.on('end', () => {+++ res.setHeader('Content-Type', 'application/json');+++ assert.ok(String(req.headers.authorization || '').startsWith('Bearer '), 'Live calls Chat with a service token');+++ if (req.url === '/internal/live/calls') {+++ const body = JSON.parse(raw);+++ bridgeCalls.push(body);+++ return res.end(JSON.stringify({ ok: true, results: body.ops.map((o) => ({ seq: o.seq, ok: true })) }));+++ }+++ if (req.url === '/internal/live/presence') {+++ return res.end(JSON.stringify({ total: 7, streams: { 1: 3 }, slow_mode: { 1: 5000 }, users: [{ user_id: 3, ip: '198.51.100.3', stream_id: 1 }], anons: [{ anon_id: 'anon9', ip: '203.0.113.9', stream_id: 1 }] }));+++ }+++ res.statusCode = 404; res.end('{}');+++ });+++});++++++const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));+++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++++(async () => {+++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;+++ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${await listen(chat)}`;++++++ const db = require('../server/db/database');+++ db.initDb();+++ require('../server/chat/dm').ensureTables();+++ const d = db.getDb();+++ const mkUser = (username, role = 'user', extra = {}) => {+++ const id = db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid;+++ d.prepare('UPDATE users SET role = ?, is_owner = ? WHERE id = ?').run(role, extra.owner ? 1 : 0, id);+++ return Number(id);+++ };+++ const owner = mkUser('owner', 'admin', { owner: true });+++ const admin = mkUser('admin2', 'admin');+++ const streamer = mkUser('streamer', 'streamer');+++ const mod = mkUser('moddy');+++ const viewer = mkUser('viewer');+++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '501', 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ')").run(viewer);+++ db.createChannel({ user_id: streamer, title: 'Streamer TV' });+++ const channel = db.getChannelByUserId(streamer);+++ const streamId = Number(db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' }).lastInsertRowid);+++ db.addChannelModerator ? db.addChannelModerator(channel.id, mod, streamer) : d.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, mod, streamer);+++ d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(viewer, streamer);+++ db.setSetting('tts_enabled', 'true');+++ db.setSetting('stripe_secret_key', 'sk_live_never_shared');++++++ const express = require('express');+++ const routes = require('../server/chat/live-context-routes');+++ const app = express();+++ app.use(express.json());+++ app.use('/internal/chat-context', routes.contextRouter);+++ app.use('/internal/chat-effects', routes.effectsRouter);+++ const port = await listen(http.createServer(app));+++ const call = async (method, p, { token, body, headers = {} } = {}) => {+++ const res = await fetch(`http://127.0.0.1:${port}${p}`, {+++ method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers },+++ body: body ? JSON.stringify(body) : undefined,+++ });+++ const text = await res.text();+++ let json = null; try { json = JSON.parse(text); } catch { /* */ }+++ return { status: res.status, body: json, text };+++ };++++++ let exit = 0;+++ try {+++ // 1. Service tokens only: none, wrong audience, missing capability, through nginx.+++ assert.strictEqual((await call('GET', '/internal/chat-context/users')).status, 401);+++ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: serviceToken(['live.chat_context.read'], { aud: 'openvibe.media' }) })).status, 401);+++ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: WRITE })).status, 403);+++ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: READ, headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');+++ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: READ, body: { user_id: viewer, color: '#ff00ff' } })).status, 403, 'reads cannot write');++++++ // 2. Users projection: paged, with the Network subject, never secrets.+++ const users = (await call('GET', '/internal/chat-context/users?after_id=0&limit=2', { token: READ })).body.rows;+++ assert.strictEqual(users.length, 2);+++ const all = (await call('GET', `/internal/chat-context/users?after_id=${users[1].id}`, { token: READ })).body.rows;+++ const v = all.find((u) => u.id === viewer);+++ assert.strictEqual(v.subject_id, 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ');+++ for (const u of users.concat(all)) { assert.ok(!('email' in u) && !('password_hash' in u) && !('stream_key' in u), 'no secrets in the projection'); }++++++ // 3. Token resolution follows Live's rules and carries the subject.+++ const userJwt = jwt.sign({ sub: '501', username: 'viewer', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });+++ const auth = (await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: userJwt } })).body;+++ assert.strictEqual(auth.user.id, viewer);+++ assert.strictEqual(auth.user.subject_id, 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ');+++ assert.strictEqual(auth.user.auth_source, 'network');+++ assert.ok(auth.expires_at);+++ assert.deepStrictEqual((await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: 'garbage' } })).body, { user: null, reason: 'invalid' });++++++ // 4. Streams, channel policy (settings + moderators + language), follows, bans with version.+++ const s = (await call('GET', `/internal/chat-context/streams/${streamId}`, { token: READ })).body;+++ assert.strictEqual(s.stream.user_id, streamer);+++ assert.strictEqual(s.owner.username, 'streamer');+++ assert.strictEqual(s.channel.id, channel.id);+++ assert.ok(!('stream_key' in s.stream));+++ const active = (await call('GET', '/internal/chat-context/streams/active', { token: READ })).body.rows;+++ assert.ok(active.some((r) => r.id === streamId && r.is_live === 1));+++ const policy = (await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body;+++ assert.deepStrictEqual(policy.moderator_ids, [mod]);+++ assert.strictEqual(policy.settings.allow_anonymous, 1, 'defaults when the channel has no row');+++ assert.deepStrictEqual((await call('GET', `/internal/chat-context/users/${viewer}/follows`, { token: READ })).body.streamer_ids, [streamer]);+++ const b1 = (await call('GET', '/internal/chat-context/bans', { token: READ })).body;+++ assert.deepStrictEqual(b1.bans, []);+++ assert.strictEqual((await call('GET', `/internal/chat-context/bans?version=${encodeURIComponent(b1.version)}`, { token: READ })).body.unchanged, true);+++ const settings = (await call('GET', '/internal/chat-context/settings', { token: READ })).body.settings;+++ assert.strictEqual(settings.tts_enabled, db.getSetting('tts_enabled'), 'typed like Live\'s getSetting');+++ assert.ok(!('stripe_secret_key' in settings), 'only chat settings leave Live');++++++ // 5. Effects need CHAT_AUTHORITY=chat.+++ process.env.CHAT_AUTHORITY = '';+++ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 409);+++ process.env.CHAT_AUTHORITY = 'chat';+++ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 200);+++ assert.strictEqual(db.getUserById(viewer).profile_color, '#ff00ff');++++++ // 6. Bans: Live re-checks the moderator; the rows are exactly chat-server's.+++ const ban = (body) => call('POST', '/internal/chat-effects/ban', { token: WRITE, body });+++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: viewer, moderation_stream_id: streamId, stream_id: streamId, user_id: mod })).status, 403, 'a viewer cannot ban');+++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: owner })).status, 403, 'a channel mod cannot ban an admin');+++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer, reason: 'Banned by moderator', banned_by: mod })).status, 200);+++ assert.ok(db.isUserBanned(viewer, streamId));+++ const b2 = (await call('GET', `/internal/chat-context/bans?version=${encodeURIComponent(b1.version)}`, { token: READ })).body;+++ assert.strictEqual(b2.bans.length, 1, 'the version moved with the table');+++ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);+++ assert.ok(!db.isUserBanned(viewer, streamId));++++++ // 7. TTS settings: admins; credentials only the owner.+++ const put = (actor, settingsBody) => call('POST', '/internal/chat-effects/site-settings', { token: WRITE, body: { actor_user_id: actor, settings: settingsBody } });+++ assert.strictEqual((await put(viewer, { tts_enabled: 'false' })).status, 403);+++ assert.strictEqual((await put(admin, { tts_max_length: 300, tts_google_api_key: 'admin-cannot' })).body.updated, 1);+++ assert.notStrictEqual(db.getSetting('tts_google_api_key'), 'admin-cannot', 'an admin cannot set a credential');+++ assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);+++ assert.strictEqual(db.getSetting('tts_google_api_key'), 'owner-can');++++++ // 8. Slow mode persistence and alert sounds: only for people who may.+++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { slow_mode_seconds: 9 } } })).status, 403);+++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { slow_mode_seconds: 9 } } })).status, 200);+++ assert.strictEqual(db.getChannelModerationSettings(channel.id).slow_mode_seconds, 9);+++ const snd = path.join(tmp, 'sounds', 'alert.mp3'); fs.writeFileSync(snd, 'x');+++ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, kind: 'donation', url: snd } })).status, 403, 'only the channel owner');+++ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');+++ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);++++++ // 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept.+++ const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });+++ assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');+++ let m = await mirror([{ table: 'chat_messages', op: 'upsert', row: { id: 900001, stream_id: streamId, channel_user_id: streamer, user_id: viewer, username: 'VIEWER', message: 'hi from chat', message_type: 'chat', is_global: 0, is_deleted: 0, timestamp: '2026-09-22 10:00:00', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' } }]);+++ assert.strictEqual(m.body.applied, 1);+++ assert.strictEqual(db.getChatMessageById(900001).message, 'hi from chat');+++ // Columns only Live's copy has (media-proxy/asset-sync adds them at start).+++ for (const c of ['media_url TEXT', 'media_asset_id INTEGER']) { try { d.exec(`ALTER TABLE channel_sounds ADD COLUMN ${c}`); } catch { /* present */ } }+++ d.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, media_asset_id) VALUES (77, ?, 'honk', '/x.mp3', 555)").run(streamer);+++ m = await mirror([{ table: 'channel_sounds', op: 'upsert', row: { id: 77, channel_owner_id: streamer, command: 'honk2', url: '/x.mp3', created_by_subject_id: null } }, { table: 'chat_messages', op: 'delete', pk: { id: 900001 } }, { table: 'users', op: 'delete', pk: { id: viewer } }]);+++ assert.strictEqual(m.body.applied, 2);+++ assert.strictEqual(m.body.skipped.length, 1, 'only chat tables are mirrored');+++ const s77 = d.prepare('SELECT command, media_asset_id FROM channel_sounds WHERE id = 77').get();+++ assert.deepStrictEqual(s77, { command: 'honk2', media_asset_id: 555 });+++ assert.strictEqual(db.getChatMessageById(900001), undefined);+++ assert.ok(db.getUserById(viewer), 'users are never touched');++++++ // 10. The proxy Live's modules get: ordered bridge calls, placeholders, presence.+++ const chatServer = require('../server/chat/chat-server');+++ assert.strictEqual(chatServer.remote, true);+++ chatServer.init();+++ const before = d.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n;+++ const saved = db.saveChatMessage({ stream_id: streamId, user_id: null, username: 'Bot', message: 'beep', message_type: 'chat', source_platform: 'ai' });+++ assert.ok(saved.lastInsertRowid <= -(2 ** 40), 'a placeholder id');+++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, before, 'inserts happen in Chat, not here');+++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 1, 'kept until Chat acknowledges');+++ chatServer.broadcastToStream(streamId, { type: 'chat', id: saved.lastInsertRowid, message: 'beep' });+++ chatServer.synthesizeAndBroadcastTTS(streamId, 'Bot', 'beep', null, null, 'aibot:bot', null, `m${saved.lastInsertRowid}`);+++ await chatServer.flush();+++ await sleep(100);+++ const ops = bridgeCalls.flatMap((c) => c.ops);+++ assert.deepStrictEqual(ops.map((o) => o.op), ['db', 'broadcastToStream', 'synthesizeAndBroadcastTTS']);+++ assert.strictEqual(ops[0].args[0], 'saveChatMessage');+++ assert.strictEqual(ops[0].ref, saved.lastInsertRowid);+++ assert.ok(/^live:\d+$/.test(ops[0].key), 'forwarded writes carry an idempotency key');+++ assert.strictEqual(ops[1].args[1].id, saved.lastInsertRowid);+++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 0, 'acknowledged writes leave the outbox');+++ // Deletes run on the mirror at once (their ids are returned) and are forwarded too.+++ d.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (900002, ?, 'MODDY', 'x')").run(mod);+++ assert.deepStrictEqual(db.deleteUserChatMessages(mod, {}), [900002]);+++ await chatServer.flush();+++ assert.deepStrictEqual(bridgeCalls.at(-1).ops[0].args.slice(0, 2), ['deleteUserChatMessages', mod]);+++ // Live's own writes to data Chat caches (dashboard mods, IP approvals) tell Chat to reload it.+++ db.addChannelModerator(channel.id, viewer, streamer);+++ await chatServer.flush();+++ assert.deepStrictEqual(bridgeCalls.at(-1).ops.map((o) => [o.op, ...o.args]), [['invalidate', 'channel', channel.id]]);+++ assert.ok(d.prepare('SELECT 1 FROM channel_moderators WHERE channel_id = ? AND user_id = ?').get(channel.id, viewer), 'the write itself still happens in Live');+++ // The /api/mod global delete loops over chatServer.clients: one pseudo-socket reaches everyone.+++ for (const [ws] of chatServer.clients) { if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'delete-messages', ids: [1] })); }+++ await chatServer.flush();+++ assert.strictEqual(bridgeCalls.at(-1).ops[0].op, 'broadcastAllRaw');+++ // Synchronous reads come from Chat's presence snapshot.+++ await sleep(200);+++ assert.strictEqual(chatServer.getTotalConnections(), 7);+++ assert.strictEqual(chatServer.getStreamViewerCount(1), 3);+++ assert.strictEqual(chatServer.slowModeByStream.get(1), 5000);+++ assert.strictEqual(chatServer.getConnectedUserIp(3), '198.51.100.3');+++ assert.strictEqual(chatServer.findClientByAnonId('anon9', 1).ip, '203.0.113.9');+++ assert.strictEqual(chatServer.findClientByAnonId('anon9', 2), null);+++ // A /ws/chat upgrade that still lands on Live is refused, not served from the mirror.+++ let written = '';+++ chatServer.handleUpgrade({}, { write: (x) => { written += x; }, destroy() {} });+++ assert.match(written, /^HTTP\/1\.1 503/);+++ chatServer.close();++++++ // Rollback: writes Chat never acknowledged are applied to Live's own tables.+++ d.prepare("INSERT INTO chat_bridge_outbox (boot, ref, op, args) VALUES ('old', -1, 'db', ?)").run(JSON.stringify(['recordFirstChat', 'user:77', streamer]));+++ chatServer._restoreDb();+++ assert.strictEqual(require('../server/chat/chat-remote').drainToLocal({ log() {}, warn() {} }), 1);+++ assert.ok(d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = 'user:77'").get());+++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 0);++++++ quiet('chat context (Live side of OpenVibe.Chat): all checks passed');+++ } catch (err) {+++ console.error(err);+++ exit = 1;+++ } finally {+++ try { db.close(); } catch { /* */ }+++ fs.rmSync(tmp, { recursive: true, force: true });+++ process.exit(exit);+++ }+++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+# ═══════════════════════════════════════════════════════════════
+# openvibe.chat — Environment Configuration
+# Copy to .env and fill in values.
+# In production this lives at /etc/openvibe/chat.env (0600).
+# ═══════════════════════════════════════════════════════════════
+
+# Server (loopback only: nginx sends /ws/chat and the chat REST prefixes here)
+PORT=4400
+HOST=127.0.0.1
+NODE_ENV=development
+# The public origin browsers use — chat is served on Live's origin.
+BASE_URL=http://localhost:3000
+# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2), as Live
+TRUST_PROXY=2
+# Extra origins for WebSocket upgrades / credentialed CORS (Live's are built in)
+#ALLOWED_ORIGINS=
+
+# Chat's own database (production: systemd StateDirectory=openvibe-chat)
+CHAT_DB_PATH=./data/chat.db
+# Chat's caches (101soundboards clips, TTS preview clips). Default: <db dir>/cache
+#CHAT_CACHE_DIR=./data/cache
+
+# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# PEM with the Network public key; unset = fetched from ${OV_NETWORK_INTERNAL_URL}/api/.well-known/jwks
+#OV_NETWORK_PUBLIC_KEY=/opt/openvibe.live/data/keys/openvibe-tools-public.pem
+
+# Chat's service principal (client `chat`; create it with Network's
+# server/setup/service-principal.js create chat --env-file /etc/openvibe/chat.env)
+OV_OAUTH_CLIENT_ID=chat
+OV_OAUTH_CLIENT_SECRET=written-by-service-principal-js
+
+# OpenVibe.Live — every Live-owned read and side effect (server/live-context.js)
+OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
+#LIVE_TIMEOUT_MS=4000
+# Copy Chat's changes into Live's chat tables (read mirror + rollback path). ON from the cutover;
+# OFF for rehearsals (and Live refuses mirror writes unless it runs with CHAT_AUTHORITY=chat).
+LIVE_MIRROR=0
+#LIVE_MIRROR_INTERVAL_MS=1000
+
+# OpenVibe.Events — the outbox relays only when set (otherwise events wait in events_outbox)
+#EVENTS_URL=http://127.0.0.1:4300
+#EVENTS_RELAY_INTERVAL_MS=5000
+
+# Public origin used in /paste links (Live's SITE_URL)
+SITE_URL=https://openvibe.live
+
+# Channel sounds: the SAME directory as Live's (stored rows hold absolute paths into it and Live's
+# donation alerts read alert sounds from it). Production: /opt/openvibe.live/data/sounds
+SOUNDS_PATH=./data/sounds
+#MAX_SOUND_SIZE_KB=2048
+#SOUND_DEFAULT_MAX_SECONDS=10
+#MAX_SOUNDS_PER_CHANNEL=150
+#MAX_SOUNDS_PER_UPLOADER_PER_CHANNEL=10
+
+# Optional diagnostics
+#DEBUG_DM_DELIVERY=1
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # production runs Node 22
+ cache: npm
+ - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg # sound uploads are probed/converted
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ contracts:
+ # Blocking once docs/capabilities-proposal/ is registered in openvibe-contracts and the chat
+ # service manifest lists them (docs/cutover.md, prerequisites). Until then it reports the gap.
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - run: npx openvibe-contracts-check --service chat --src server
@@ -2,14 +2,112 @@
@@ -2,14 +2,112 @@
> Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence — one identity, every conversation.
> Rooms, messages, DMs, calls, TTS and audio queues, moderation and presence — one identity, every conversation.
-**Status:** placeholder — planning only, no runnable code yet.
-**Domain:** `openvibe.chat`
+**Status:** alpha — Wave 6 code, not deployed. Live's chat runs here behind a service boundary;
+the cutover (`docs/cutover.md`) has not happened, and `openvibe.chat` keeps its placeholder.
+**Domain:** `openvibe.chat` (placeholder); until launch Chat is served on Live's origin:
+`https://openvibe.live/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/`, `/api/sounds` via nginx.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §9 and §9.5.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §9 and §9.5.
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
## Purpose
## Purpose
-The communication authority extracted from OpenVibe.Live. Live keeps a compatibility adapter while browsers move to Chat directly with Network-issued tokens and authorised topic subscriptions.
+The communication authority extracted from OpenVibe.Live. Live keeps a compatibility adapter while
+browsers move to Chat directly with Network-issued tokens and authorised topic subscriptions.
+
+## What Wave 6 did
+
+The roadmap's method for this wave: **move the existing implementation behind a service boundary
+before changing behaviour.** Browser JavaScript does not change; nginx routes the chat paths here.
+
+- **Moved as they were** (from Live `server/chat/`): the WebSocket chat server (`/ws/chat`, every
+ message type and command: `join`/`join_stream`/`leave_stream`/`get-users`/`chat`/`self-delete-history`,
+ `/help /tts /color /viewers /uptime /w /me /ban /unban /timeout /clear /slow /paste /ai`, `!sr !queue
+ !np !skip !sb !gotti`, channel `!sounds`, arena `!hype !beef !arena`, hardware `!forward … !say`),
+ DMs (`dm.js`, `/api/dm/*`), chat history (`history-store.js`, `/api/chat/*`), TTS (`tts-engine.js`,
+ `/api/tts/*`), 101soundboards and channel sounds (`/api/sounds*`), moderation utils and the word
+ filter, the deploy-notice card. Changes are confined to where they touched Live's data.
+- **One adapter to Live — `server/live-context.js`.** Accounts and roles, streams, slots and channels,
+ channel moderation settings and moderators, bans and IP rules, follows, cosmetics and tags, site
+ settings and anon numbers are read through it; coins, AI viewers, arena, media queue, hardware,
+ pastes, translation, PowerChat, notifications, viewer counts and the IP log are effects it asks
+ Live for. Reads are cached projections (SQLite `ctx_*` tables kept complete by paged syncs; TTL
+ caches that serve stale while refreshing) warmed when a socket joins, so a chat message never
+ waits on a network read. Live pushes invalidations when it changes cached data.
+- **Live's side** is `docs/live-patch.diff` (applies to Live `main` with `git apply`):
+ `/internal/chat-context/*` and `/internal/chat-effects/*`, and `CHAT_AUTHORITY=chat`, which stops
+ Live's chat server and routes and turns `require('./chat/chat-server')` into a proxy that forwards
+ Live's own chat calls here (`POST /internal/live/calls`).
+- **Own database** (`CHAT_DB_PATH`, systemd `StateDirectory=openvibe-chat`) with Live's tables and
+ ids, the Network subject on new rows, a transactional events outbox and a read mirror back into Live.
+- **Import:** `scripts/import-from-live.js --live-db <snapshot> [--dry-run]` — idempotent, never drops
+ a row (`import_hold`), reports counts per table.
+
+## How it fits the network
+
+| Concern | Where it lives | How Chat reaches it |
+| --- | --- | --- |
+| Messages, DMs, channel sounds, TTS voice overrides, relay users, first chats, IP-approval queue, moderation log | **Chat** (authority from the cutover) | its own SQLite; Live keeps a read mirror (`POST /internal/chat-effects/mirror`) |
+| Accounts, roles, streams, channels, bans, IP approvals, follows, cosmetics, tags, site settings | **Live** (Network for identity) | `server/live-context.js` → `GET/POST /internal/chat-context/*` (service token, `live.chat_context.read`) |
+| Coins, AI viewers, arena, media queue, hardware, pastes, translation, PowerChat, notifications | **Live** | `server/live-context.js` → `POST /internal/chat-effects/*` (`live.chat_effects.write`) |
+| Live's own chat pushes and writes (AI viewers, relays, donations, `/api/mod`, recaps, calls) | **Live → Chat** | `POST /internal/live/calls` (`chat.live_bridge.write`), presence `GET /internal/live/presence` (`chat.presence.read`) |
+| Identity | **OpenVibe.Network** | user tokens are resolved by Live (its account links); service tokens from `/oauth/token` |
+| Events | **OpenVibe.Events** | `events_outbox` → `POST /api/v1/events` when `EVENTS_URL` is set (`events.event.publish`) |
+
+### Data
+
+Chat owns (from the cutover): `chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`,
+`dm_blocks`, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`,
+`pending_ip_messages`, `stream_first_chats`, `moderation_actions`. Same columns and ids as Live, plus
+`subject_id` / `sender_subject_id` / `blocker_subject_id` / `actor_subject_id` /
+`created_by_subject_id` (the Network `usr_…` subject, filled on new rows and by the importer).
+
+Staged here, still written by Live in this wave (their routes have not moved): `channel_moderators`,
+`channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` —
+imported (and refreshed by later import runs) so their move is a switch; Chat reads the live values
+through `live-context`. `table_authority` records which is which.
+
+Stays in Live (decided with evidence, `docs/cutover.md`): `media_requests`, `media_request_settings`.
+
+### Events
+
+| Event | Visibility | When |
+| --- | --- | --- |
+| `chat.message.created` | public | every stored message in a public room (global, channel, stream) |
+| `chat.dm.created` | subject | every DM; the payload names the participants, never the text |
+| `chat.moderation.action` | internal | every moderation log row (chat commands and Live's `/api/mod`) |
+
+## Running it
+
+```bash
+npm install
+cp .env.example .env # OV_LIVE_INTERNAL_URL, Network URLs, OV_OAUTH_CLIENT_SECRET, SOUNDS_PATH
+npm start # 127.0.0.1:4400 — /ws/chat, /api/{chat,dm,tts,sounds}, /health, /ready
+npm test # Node 22; stub Live and Network in-process
+node scripts/import-from-live.js --live-db /tmp/live-snapshot.db --dry-run
+node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "…"
+node scripts/mirror-flush.js # rollback helper: push queued mirror rows to Live
+```
+
+`/ready` reports the last Live sync, the mirror queue and whether events are relayed.
+Production: `deploy/systemd/openvibe-chat.service`, `deploy/nginx/openvibe.live-chat.locations.conf`,
+`/etc/openvibe/chat.env`. The whole switch-over — rehearsal, import, parity checks, nginx, the flag,
+rollback — is `docs/cutover.md`.
+
+### Layout
+
+```
+server/index.js boot: DB, first Live sync, WS server, HTTP app, relays, graceful stop
+server/app.js Live's guards for these routes: CORS, /api rate limit, IP bans, ban cookie, WS origin + IP checks
+server/live-context.js the only module that talks to Live (interface in its header)
+server/chat/ moved from Live: chat-server, dm, dm-routes, routes, history-store, tts-*, sounds-*, soundboard, moderation-utils, word-filter, deploy-notice
+server/auth/ token resolution through Live; the chat subset of Live's permissions
+server/bridge/ Live → Chat calls + presence (live-bridge.js); Chat → Live read mirror (live-mirror.js)
+server/events/outbox.js events.event-envelope@1 outbox and relay
+server/net/service-auth.js service tokens: client (Chat → others) and guard (others → Chat)
+server/db/ schema.sql, database.js (Live's chat functions, same names and arguments)
+scripts/ import-from-live, parity-check, mirror-flush
+docs/ cutover.md, live-patch.diff, capabilities-proposal/
+```
## Owns
## Owns
@@ -19,42 +117,38 @@ The communication authority extracted from OpenVibe.Live. Live keeps a compatibi
@@ -19,42 +117,38 @@ The communication authority extracted from OpenVibe.Live. Live keeps a compatibi
- TTS/audio/soundboard/media-request queues with skip/clear/failure lifecycle
- TTS/audio/soundboard/media-request queues with skip/clear/failure lifecycle
- call signalling metadata and the `pending/ringing/active/ended/missed/declined/failed` lifecycle
- call signalling metadata and the `pending/ringing/active/ended/missed/declined/failed` lifecycle
+(Wave 6 moved messages, DMs, TTS, sounds and the chat side of moderation; bans, channel moderation
+settings, the media-request queue and calls are still Live's and reached through `live-context`.)
+
## Does not own
## Does not own
- media bytes (attachments are Media references)
- media bytes (attachments are Media references)
- billing of paid messages (Tips/Billing)
- billing of paid messages (Tips/Billing)
-## Planned surfaces
-
-- `api/` rooms, history, DMs, moderation, settings; `realtime/` WS/SSE delivery; `calls/`; `speech/`; `soundboard/`; `moderation/`; `adapters/live/`
-
-## Data (authority tables / families)
-
-- see above
-
## Capabilities and events
## Capabilities and events
-- `chat.room.*`, `chat.message.*`, `chat.dm.*`, `chat.moderation.*`, `chat.tts.*`, `chat.call.*`
+Introduced here (proposals in `docs/capabilities-proposal/`, not yet in `openvibe-contracts`):
+`chat.live_bridge.write`, `chat.presence.read` (owner chat) and `live.chat_context.read`,
+`live.chat_effects.write`, `live.chat_mirror.write` (owner live). Planned families:
+`chat.room.*`, `chat.message.*`, `chat.dm.*`, `chat.moderation.*`, `chat.tts.*`, `chat.call.*`.
-Events: ``chat.message.created|deleted``, ``chat.room.updated``, ``chat.call.*``, ``chat.tts.queued|played|failed``
+Events: `chat.message.created`, `chat.dm.created`, `chat.moderation.action` (produced);
+planned `chat.message.deleted`, `chat.room.updated`, `chat.call.*`, `chat.tts.queued|played|failed`.
## Depends on
## Depends on
- OpenVibe.Network
- OpenVibe.Network
+- OpenVibe.Live (until the rest of chat's data moves)
- OpenVibe.Events
- OpenVibe.Events
- OpenVibe.Media
- OpenVibe.Media
- OpenVibe.Contracts
- OpenVibe.Contracts
## Acceptance (must be true before "done")
## Acceptance (must be true before "done")
-- stream/global/DM histories preserved on import; old Live URLs and WS messages keep working through the adapter
-- restart Live without losing Chat; restart Chat's delivery plane and resume persisted messages
-- a call row without a working signalling/media path is not parity
-- a paid TTS request is never duplicated by a retry
-
-## Bootstrap / extraction source
-
-Live's chat, DM, TTS, soundboard and WebRTC call modules, imported with stable legacy IDs.
+- stream/global/DM histories preserved on import; old Live URLs and WS messages keep working through the adapter — *implemented (import, same paths, protocol parity tests); not yet exercised on production data*
+- restart Live without losing Chat; restart Chat's delivery plane and resume persisted messages — *implemented (Live's forwarded writes wait in its outbox; Chat's mirror and events wait in theirs); not yet exercised in production*
+- a call row without a working signalling/media path is not parity — *calls are still Live's (`/ws/call`)*
+- a paid TTS request is never duplicated by a retry — *forwarded writes are applied once per idempotency key; paid TTS does not exist yet*
## Launch rule
## Launch rule
@@ -1,7 +1,18 @@
@@ -1,7 +1,18 @@
{
{
"repository": "OpenVibers/OpenVibe.Chat",
"repository": "OpenVibers/OpenVibe.Chat",
- "stage": "placeholder",
- "code": false,
+ "stage": "alpha",
+ "code": true,
+ "wave": "W6",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-21"
+ "runtime": "node 22, express, ws, better-sqlite3",
+ "port": 4400,
+ "domain": "openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via nginx); openvibe.chat keeps its placeholder",
+ "contracts": "openvibe-contracts v0.5.1",
+ "capabilities": ["chat.live_bridge.write", "chat.presence.read"],
+ "capabilitiesUsed": ["live.chat_context.read", "live.chat_effects.write", "live.chat_mirror.write", "events.event.publish"],
+ "capabilitiesRegistered": false,
+ "events": ["chat.message.created", "chat.dm.created", "chat.moderation.action"],
+ "liveAuthorityFlag": "CHAT_AUTHORITY=chat (docs/live-patch.diff)",
+ "deployed": false,
+ "updated": "2026-09-22"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+# OpenVibe.Chat locations — include INSIDE the `server { listen 443 … server_name openvibe.live … }`
+# block of /etc/nginx/sites-enabled/openvibe.live.conf (Live's deploy/nginx/openvibe.live.conf),
+# above `location /api/`:
+#
+# include /opt/openvibe.chat/deploy/nginx/openvibe.live-chat.locations.conf;
+#
+# Adding the include (+ nginx -t + reload) is the traffic flip; removing it is the rollback
+# (docs/cutover.md). Headers and limits are Live's for the same paths: the /api/ zone and burst,
+# the body size, X-Forwarded-For for trust proxy 2, CF-Connecting-IP + upgrade for the socket.
+# `^~` keeps the static-asset regex location from ever taking these (e.g. /api/sounds/file/x.mp3).
+# Trailing slashes matter: /api/chat-ai (Live's AI routes) must NOT match /api/chat/.
+
+location ^~ /ws/chat {
+ proxy_pass http://127.0.0.1:4400;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+}
+
+location ^~ /api/chat/ {
+ limit_req zone=api_limit burst=40 nodelay;
+ client_max_body_size 10m;
+ proxy_pass http://127.0.0.1:4400;
+ 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 ^~ /api/dm/ {
+ limit_req zone=api_limit burst=40 nodelay;
+ client_max_body_size 10m;
+ proxy_pass http://127.0.0.1:4400;
+ 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 ^~ /api/tts/ {
+ limit_req zone=api_limit burst=40 nodelay;
+ client_max_body_size 10m;
+ proxy_pass http://127.0.0.1:4400;
+ 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;
+}
+
+# POST /api/sounds (upload) has no trailing path.
+location = /api/sounds {
+ limit_req zone=api_limit burst=40 nodelay;
+ client_max_body_size 10m;
+ proxy_pass http://127.0.0.1:4400;
+ 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 ^~ /api/sounds/ {
+ limit_req zone=api_limit burst=40 nodelay;
+ client_max_body_size 10m;
+ proxy_pass http://127.0.0.1:4400;
+ 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;
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+[Unit]
+Description=OpenVibe.Chat — rooms, messages, DMs, TTS and sounds, moderation (/ws/chat and the chat API of openvibe.live)
+# Live and Network should be up first; Chat serves from its caches if they are slow, and queues
+# its writes to Live (mirror, effects) until Live answers.
+After=network.target
+Wants=openvibe-live.service openvibe-network.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.chat
+EnvironmentFile=/etc/openvibe/chat.env
+Environment=NODE_ENV=production
+Environment=PORT=4400
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.chat/server/index.js
+# Tells clients the chat restarts, flushes the Live mirror, closes sockets.
+KillSignal=SIGTERM
+TimeoutStopSec=15
+Restart=always
+RestartSec=3
+
+# Hardening (as openvibe-community.service)
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectControlGroups=true
+RestrictSUIDSGID=true
+LockPersonality=true
+RestrictRealtime=true
+
+# Chat's own database and caches. The code tree stays read-only.
+StateDirectory=openvibe-chat
+Environment=CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db
+Environment=CHAT_CACHE_DIR=/var/lib/openvibe-chat/cache
+# Channel sounds stay in Live's sounds directory (rows hold absolute paths into it; Live's
+# donation alerts read alert sounds there). Legacy layout and release layout (shared/data).
+Environment=SOUNDS_PATH=/opt/openvibe.live/data/sounds
+ReadWritePaths=-/opt/openvibe.live/data/sounds -/opt/openvibe.live/shared/data/sounds
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "id": "chat.live_bridge.write",
+ "version": "1.0",
+ "owner": "chat",
+ "status": "planned",
+ "visibility": "internal",
+ "description": "Live's remaining modules (AI viewers, chat relays, donations and alerts, /api/mod, recaps, news, calls, emotes, deploy notices) push to chat clients and write chat rows through OpenVibe.Chat: ChatServer calls in order (broadcasts, DMs with the participant check, TTS, channel sounds, disconnects, user updates, cache invalidations) and allow-listed chat-table writes, applied once per idempotency key, with placeholder ids mapped per Live boot. Transitional: shrinks as those modules move out of Live.",
+ "permissions": [
+ "chat:write"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "internal-write",
+ "events": [
+ "chat.message.created",
+ "chat.moderation.action"
+ ],
+ "implementedBy": [
+ "POST /internal/live/calls"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "chat.presence.read",
+ "version": "1.0",
+ "owner": "chat",
+ "status": "planned",
+ "visibility": "internal",
+ "description": "Who is connected to chat where: connection and per-stream viewer counts, slow modes, and signed-in/anonymous sockets with their addresses — for Live's synchronous reads (health, AI viewer pacing, moderation by address). Internal: carries IP addresses.",
+ "permissions": [
+ "chat:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "internal-read",
+ "events": [],
+ "implementedBy": [
+ "GET /internal/live/presence"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+{
+ "id": "live.chat_context.read",
+ "version": "1.0",
+ "owner": "live",
+ "status": "planned",
+ "visibility": "internal",
+ "description": "OpenVibe.Chat reads the Live data chat decisions depend on and caches it (Wave 6): user projections and token resolution (Live's own rules, with the Network subject), streams, slots and channels, channel moderation settings and moderators, active bans, follows, IP approvals, cosmetics and tags, the chat site settings (tts_*, GIF and soundboard keys only), anon first-seen, arena-cached TTS clips and the chat profile card. Read-only; no secrets beyond the chat settings.",
+ "permissions": [
+ "live:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "internal-read",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/chat-context/auth",
+ "GET /internal/chat-context/users",
+ "POST /internal/chat-context/users/lookup",
+ "GET /internal/chat-context/users/:id/follows",
+ "GET /internal/chat-context/users/profile",
+ "GET /internal/chat-context/streams",
+ "GET /internal/chat-context/streams/active",
+ "GET /internal/chat-context/streams/:id",
+ "GET /internal/chat-context/managed-streams",
+ "GET /internal/chat-context/channels",
+ "GET /internal/chat-context/channels/by-user/:userId",
+ "GET /internal/chat-context/channels/:id/policy",
+ "GET /internal/chat-context/channels/:id/approved-ip",
+ "GET /internal/chat-context/bans",
+ "POST /internal/chat-context/decor",
+ "GET /internal/chat-context/settings",
+ "GET /internal/chat-context/anon/:num",
+ "GET /internal/chat-context/anon-first-seen",
+ "GET /internal/chat-context/tts-audio/:file"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "live.chat_effects.write",
+ "version": "1.0",
+ "owner": "live",
+ "status": "planned",
+ "visibility": "internal",
+ "description": "OpenVibe.Chat asks Live for the side effects chat triggers in data Live still owns (Wave 6): anon numbers, IP log, viewer counts and snapshots, /color, /ban /timeout /unban rows, IP approvals, /slow persistence, alert sounds, channel creation, TTS site settings, the per-message reactions (OpenCoins chat bonus, AI chat viewers, PowerChat relay), AI mod commands, arena commands, the media queue, hardware commands, /paste, translation, DM notifications and Media asset sync. Live re-checks the acting person (moderator, owner, admin) on every effect that acts for one, and refuses all of them unless CHAT_AUTHORITY=chat.",
+ "permissions": [
+ "live:write"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "internal-write",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/chat-effects/{anon,ip-log,viewer-counts,viewer-snapshots,user-color,ban,approve-ip,channel-settings,alert-sound,ensure-channel,site-settings,chat-message,ai/mod-command,arena-command,media-queue,hardware,paste,translate,notify/dm,notify/dm-read,asset-sync}"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "live.chat_mirror.write",
+ "version": "1.0",
+ "owner": "live",
+ "status": "planned",
+ "visibility": "internal",
+ "description": "OpenVibe.Chat copies every change to the tables it owns into Live's tables of the same name and ids (a read mirror for Live's remaining readers and the rollback path). Only the twelve chat tables; only columns Live has; Live-only columns are kept; refused unless CHAT_AUTHORITY=chat. Retired when Live stops reading chat tables.",
+ "permissions": [
+ "live:write"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "internal-write",
+ "events": [],
+ "implementedBy": [
+ "POST /internal/chat-effects/mirror"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
+# Chat cutover — Live → OpenVibe.Chat (roadmap Wave 6)
+
+What changes at the cutover: browsers keep calling `https://openvibe.live`, but nginx sends the chat
+socket and the chat REST prefixes to OpenVibe.Chat (127.0.0.1:4400), and Live runs with
+`CHAT_AUTHORITY=chat`. Nothing in Live's browser code changes. Everything below is reversible
+until the Live-side read mirror is retired (a later wave).
+
+- [What is served where](#what-is-served-where)
+- [Data authority](#data-authority)
+- [Prerequisites](#prerequisites)
+- [Rehearsal on a live.db snapshot](#rehearsal-on-a-livedb-snapshot)
+- [Cutover](#cutover)
+- [Rollback](#rollback)
+- [Behaviour that is not byte-for-byte identical](#behaviour-that-is-not-byte-for-byte-identical)
+- [Live quirks moved as they are](#live-quirks-moved-as-they-are)
+
+## What is served where
+
+nginx locations (`deploy/nginx/openvibe.live-chat.locations.conf`, included in Live's
+`server { … server_name openvibe.live … }` block above `location /api/`):
+
+| nginx location | Chat serves |
+| --- | --- |
+| `^~ /ws/chat` | the chat WebSocket (`?stream=`, `?token=`; protocol unchanged) |
+| `^~ /api/chat/` | `GET gif/providers`, `GET gif/trending`, `GET gif/search`, `POST send`, `GET search`, `GET user/:userId/history`, `GET user/:username/profile`, `GET relay-user/:platform/:username`, `GET relay-user/:platform/:username/logs`, `GET anon/:anonId`, `GET anon/:anonId/logs`, `GET filters/friendly`, `GET global/history`, `GET :streamId/replay`, `GET :streamId/history`, `GET channel/:userId/history`, `GET :streamId/users`, `POST admin/purge/preview`, `DELETE admin/purge`, `GET admin/logs`, `GET admin/logs/export` |
+| `^~ /api/dm/` | `GET/POST conversations`, `GET/PATCH conversations/:id`, `GET/POST conversations/:id/messages`, `DELETE conversations/:id/messages/:msgId`, `POST conversations/:id/read`, `POST conversations/:id/participants`, `DELETE conversations/:id/participants/:userId`, `GET unread`, `GET users/search`, `GET blocks`, `POST/DELETE blocks/:userId`, `GET blocks/check/:userId` |
+| `^~ /api/tts/` | `GET voices`, `GET settings`, `GET/PUT admin/settings`, `POST admin/test`, `GET audio/:file` (Chat's clips; Live's arena/mod-preview clips are fetched from Live under the same URL) |
+| `= /api/sounds` and `^~ /api/sounds/` | `POST /api/sounds` (upload), `GET channel/:userId`, `GET all/:streamId`, `DELETE :id`, `PATCH command`, `GET file/:filename`, `GET alert/mine`, `POST/DELETE alert/:kind` |
+
+Stay on Live (do not route): `/api/chat-ai/*` (the trailing slash in `/api/chat/` keeps it out),
+`/api/emotes/*`, `/api/channels/*` (channel moderation dashboard), `/api/mod/*`, `/api/media/*`,
+`/ws/call`, `/ws/broadcast`, `/ws/control`, and everything else.
+
+Loopback only, never routed: Chat `/internal/live/*`, `/health`, `/ready`; Live
+`/internal/chat-context/*`, `/internal/chat-effects/*` (both refuse anything that came through
+nginx).
+
+## Data authority
+
+| Table (Live baseline, target OpenVibe.Chat) | W6 authority | Notes |
+| --- | --- | --- |
+| `chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions` | **Chat** | Imported with their ids. Live keeps a read mirror (Chat → `POST /internal/chat-effects/mirror`); Live's remaining writers (AI viewers, relays, donations, `/api/mod`, `/api/channels` deletes, emote renames) forward to Chat. |
+| `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events` | **Live** (staged in Chat) | Their writers (`/api/channels`, dashboard, `/api/emotes` + Media asset-sync, game tags, chat AI) have not moved. Imported into Chat (a later import run refreshes them); Chat reads the live values through `live-context` and Live pushes invalidations. `/slow` and alert sounds are written by Live on Chat's behalf. |
+| `media_requests`, `media_request_settings` | **Live** (not moved) | Decision with evidence: every writer is `server/media/media-queue.js` / `server/media/routes.js` (`/api/media`: gold payment, yt-dlp download, playback state, the streamer's overlay and dashboard). Chat only has the `!sr/!queue/!np/!skip` entry points, which call Live (`POST /internal/chat-effects/media-queue`). Not chat-owned in practice; it moves with the queue lifecycle the charter describes, not before. The importer reports them as `not_moved`. |
+| `chat_messages_new`, `emotes_new`, `channel_sounds_new` | — | Transient tables of Live's table rebuilds; empty in a consistent snapshot. Rows found there go to `import_hold`. |
+
+Chat also keeps `ctx_users`, `ctx_streams`, `ctx_managed_streams`, `ctx_channels` — projections of
+Live data maintained by `live-context` (never authority), `events_outbox`, `live_mirror_outbox`,
+`bridge_applied`, `import_hold`, `import_runs`, `table_authority`.
+
+## Prerequisites
+
+1. **Contracts.** Register `docs/capabilities-proposal/*.json` in OpenVibe.Contracts (a new
+ `openvibe-contracts` release): `live.chat_context.read`, `live.chat_effects.write`,
+ `live.chat_mirror.write` (owner live), `chat.live_bridge.write`, `chat.presence.read` (owner
+ chat). Add the live ones to `manifests/services/live.json`; set `manifests/services/chat.json` to
+ alpha with `capabilities: ["chat.live_bridge.write", "chat.presence.read"]`,
+ `eventsProduced: ["chat.message.created", "chat.dm.created", "chat.moderation.action"]` and a
+ `contractRanges` that includes the release. Until then `openvibe-contracts-check` reports these
+ capabilities as undefined (Live's CI with the patch, Chat's non-blocking contracts job).
+2. **Network.** Create the principal and grants:
+ ```bash
+ sudo node server/setup/service-principal.js create chat --env-file /etc/openvibe/chat.env
+ ```
+ `principal_grants` (client, capability, audience): `chat live.chat_context.read openvibe.live`,
+ `chat live.chat_effects.write openvibe.live`, `chat live.chat_mirror.write openvibe.live`,
+ `live chat.live_bridge.write openvibe.chat`, `live chat.presence.read openvibe.chat`
+ (`chat events.event.publish openvibe.events` is already a default). Add them to
+ `DEFAULT_GRANTS` in `server/identity/principals.js` so every boot keeps them.
+3. **Live.** Apply `docs/live-patch.diff` to Live `main` (`git apply docs/live-patch.diff`), run its
+ tests (`node test/chat-context.test.js`, `npm test`), deploy it **without** `CHAT_AUTHORITY` —
+ nothing changes for users; the new internal routes answer only service tokens and every effect
+ answers 409 while the flag is off. Add `OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400` to
+ `/etc/openvibe/live.env`.
+4. **Chat.** `git clone … /opt/openvibe.chat && cd /opt/openvibe.chat && npm ci --omit=dev`;
+ `/etc/openvibe/chat.env` from `.env.example` (0600): `OV_LIVE_INTERNAL_URL`, Network URLs,
+ `SITE_URL=https://openvibe.live`, `BASE_URL=https://openvibe.live`, `LIVE_MIRROR=1`, and
+ `EVENTS_URL` if OpenVibe.Events is deployed. The unit sets `CHAT_DB_PATH`, `CHAT_CACHE_DIR`,
+ `SOUNDS_PATH=/opt/openvibe.live/data/sounds` and allows writes there. ffmpeg/ffprobe and
+ espeak-ng are the host's (Live uses the same).
+ ```bash
+ sudo cp deploy/systemd/openvibe-chat.service /etc/systemd/system/ && sudo systemctl daemon-reload
+ ```
+ Do not start it before the rehearsal is signed off.
+
+## Rehearsal on a live.db snapshot
+
+Nothing here touches production data. A rehearsal Chat may read production Live
+(`/internal/chat-context/*` is read-only), but every effect is refused while Live's flag is off —
+no coins, AI replies or pastes happen; anon numbers come out temporary (`anon9xxxxxxxx`).
+
+```bash
+# 1. snapshot (online, consistent)
+sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-rehearsal.db"
+SNAP_AT="$(date -u '+%Y-%m-%d %H:%M:%S')"
+
+# 2. import into a scratch database
+cd /opt/openvibe.chat
+node scripts/import-from-live.js --live-db /tmp/live-rehearsal.db --chat-db /tmp/chat-rehearsal.db --dry-run
+node scripts/import-from-live.js --live-db /tmp/live-rehearsal.db --chat-db /tmp/chat-rehearsal.db
+```
+
+Check the report: per table `live` = `inserted` (first run), `held_total` 0 (anything held: look at
+`import_hold.reason`), `media_requests.not_moved`, `chat_messages.next_id` = Live's max + headroom.
+Cross-check: `sqlite3 /tmp/live-rehearsal.db "select count(*) from chat_messages"` against the
+report. Run the import again: every `inserted` must be 0 (idempotent).
+
+```bash
+# 3. a rehearsal Chat on 4401 against production Live, mirror OFF
+sudo -u ubuntu env $(sudo cat /etc/openvibe/chat.env | xargs) PORT=4401 CHAT_DB_PATH=/tmp/chat-rehearsal.db \
+ CHAT_CACHE_DIR=/tmp/chat-rehearsal-cache LIVE_MIRROR=0 EVENTS_URL= node server/index.js &
+curl -s http://127.0.0.1:4401/ready | jq # ready: true after the first Live sync
+
+# 4. parity: the same reads answer the same (history pinned before the snapshot)
+node scripts/parity-check.js --live https://openvibe.live --chat http://127.0.0.1:4401 --before "$SNAP_AT" \
+ --stream <a live stream id> --stream <an ended stream id> --channel <streamer user id> --channel <another> \
+ --token <a test account's Network JWT>
+```
+
+Expected: `all paths answer the same`. Then by hand against 4401 (`wscat -H 'Origin: https://openvibe.live'
+-c ws://127.0.0.1:4401/ws/chat?stream=<id>`): `{"type":"join","streamId":<id>}` → `auth`; a join
+with a token → `authenticated: true`; `/help`; a message → stored in `/tmp/chat-rehearsal.db` and
+returned by `/api/chat/<id>/history` on 4401. Stop the rehearsal Chat and delete `/tmp/chat-rehearsal*`.
+
+## Cutover
+
+Pick a quiet moment (`curl -s https://openvibe.live/api/streams`: restarting Live drops RTMP
+streamers; `deploy.sh --wait-idle` waits for none).
+
+1. **Start Chat on an empty database, import.**
+ ```bash
+ sudo systemctl start openvibe-chat && curl -s http://127.0.0.1:4400/ready | jq
+ sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-cutover-1.db"
+ sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-cutover-1.db --chat-db /var/lib/openvibe-chat/chat.db
+ ```
+ Chat now holds everything up to snapshot 1 and its id sequences start 10000 above Live's
+ (`--headroom`), so rows Live writes until the flip still fit below.
+2. **Flip.** Set `CHAT_AUTHORITY=chat` in `/etc/openvibe/live.env`, restart Live (it tells chat
+ clients it restarts), and as soon as `https://openvibe.live/api/ready` answers, add the include
+ to nginx and reload:
+ ```bash
+ sudo systemctl restart openvibe-live # or deploy.sh --wait-idle
+ # add: include /opt/openvibe.chat/deploy/nginx/openvibe.live-chat.locations.conf;
+ sudo nginx -t && sudo systemctl reload nginx
+ ```
+ Between the two steps `/ws/chat` on Live answers 503 and clients retry with backoff; afterwards
+ they land on Chat.
+3. **Second import pass** — rows Live wrote between snapshot 1 and its restart:
+ ```bash
+ sqlite3 /opt/openvibe.live/data/live.db ".backup /tmp/live-cutover-2.db"
+ sudo -u ubuntu node scripts/import-from-live.js --live-db /tmp/live-cutover-2.db --chat-db /var/lib/openvibe-chat/chat.db
+ ```
+ Chat's own new rows are already in Live (mirror) and count as `identical`; `chat_kept` counts rows
+ Chat edited since pass 1 (Chat wins); `held` must be 0.
+4. **Checks.**
+ - `curl -s 127.0.0.1:4400/ready | jq` — `live.last_sync_at` recent, `mirror.pending` near 0,
+ `mirror.last_error` null.
+ - Live: `sqlite3 …/live.db "select count(*) from chat_bridge_outbox"` stays near 0 (its forwarded
+ writes are acknowledged); `journalctl -u openvibe-live | grep ChatRemote` shows no refusals.
+ - Browser: join a stream chat, send a message (it appears for others and in history after a
+ reload), `/help`, a DM between two test accounts (live delivery + unread badge), a moderator
+ `/timeout` on a test account, a channel `!sound`, the global feed on the home page.
+ - Live features that read chat: home page chat stats, a recap, AI viewers answering in a test
+ stream, `/api/mod` chat logs and deletes (the deleted line disappears in Chat).
+ - `node scripts/parity-check.js --live http://127.0.0.1:3000 --chat http://127.0.0.1:4400 …` is no
+ longer meaningful (Live's chat routes answer 503 now); compare against the mirror instead with
+ `sqlite3` counts: Live `chat_messages` ≈ Chat `chat_messages`.
+
+## Rollback
+
+Live becomes the chat authority again on its own tables, which the mirror kept complete.
+
+1. Remove the include from nginx; `sudo nginx -t && sudo systemctl reload nginx` (chat traffic goes
+ to Live, which answers 503 until step 3 — clients retry).
+2. Make sure everything Chat wrote is in Live: `curl -s 127.0.0.1:4400/ready | jq .mirror.pending`
+ → 0. If the service cannot run: `cd /opt/openvibe.chat && sudo -u ubuntu env $(sudo cat
+ /etc/openvibe/chat.env | xargs) CHAT_DB_PATH=/var/lib/openvibe-chat/chat.db node scripts/mirror-flush.js`
+ (exit 0 = drained). Live accepts mirror writes only while it still has the flag, so do this
+ before step 3.
+3. Remove `CHAT_AUTHORITY` from `/etc/openvibe/live.env`, restart Live. At boot Live applies any
+ chat writes it had queued for Chat and never got acknowledged (`chat_bridge_outbox`).
+4. `sudo systemctl stop openvibe-chat`. Keep `/var/lib/openvibe-chat/chat.db` (a later cutover starts
+ from a fresh database and a fresh import; `subject_id` values are recomputed from Live's
+ `linked_accounts`).
+
+## Behaviour that is not byte-for-byte identical
+
+- **Ordering of Live's reactions.** The OpenCoins chat bonus (`coin_earned`), AI-viewer reactions,
+ translations and the PowerChat relay happen in Live after the message is broadcast (one call per
+ message); before, the coin reply was sent before the broadcast. Arena and media-queue replies
+ arrive a round trip later.
+- **Freshness of Live data.** Chat answers from caches: changes made in Live reach Chat at once
+ through invalidations (channel moderators/settings and alert sounds via Live's db writes, IP
+ approvals, role/avatar pushes, admin user edits, bans followed by a disconnect) and otherwise
+ within the TTL — bans 10 s, channel policy 15 s, settings 30 s, follows/cosmetics/tags 60 s,
+ new users 30 s (all users every 15 min), live/ended streams 10 s. Live's synchronous reads of
+ chat presence (viewer counts, AI-viewer pacing, `getConnectedUserIp` in `/api/mod` bans) are up
+ to 3 s old.
+- **Placeholder ids.** A chat row Live's own modules create (AI viewers, relays, donations) gets its
+ id from Chat; the Live caller sees a placeholder (≤ -2^40), mapped to the real id for everything it
+ sends to chat afterwards. What such a caller stores itself keeps the placeholder:
+ `ai_viewer_log.chat_message_id` and the PowerChat message id of AI-viewer lines.
+- **Separate rate-limit budget.** The chat REST routes count against Chat's own `/api` limiter
+ (same numbers) instead of sharing Live's; Live's request analytics no longer see them.
+- **Live unreachable.** Chat keeps serving rooms from its caches; new sign-ins resolve as anonymous
+ and new anonymous visitors get a temporary number (`anon9xxxxxxxx`) until Live answers; effects are
+ skipped (coins, AI viewers) or answer an error (`/color`, media commands).
+- **Deploy notices** are still Live's (its commits, announced in chat through the bridge); Chat's own
+ deploys are not announced.
+- **Responses** carry the same fields as before; Chat's `*subject_id` columns are stripped from API
+ answers.
+
+## Live quirks moved as they are
+
+Found while moving; deliberately not fixed in this wave (fix after the cutover, in one place):
+
+- `GET /api/chat/search` always reports `total: 0` (its count regex does not cross the SELECT's line
+ break).
+- `POST /api/chat/send` never attaches cosmetics (Live required a module that does not exist).
+- A `/timeout` stores an ISO `expires_at` that Live compares as TEXT with SQLite's
+ `'YYYY-MM-DD HH:MM:SS'`, so a timeout lasts at least until the end of that UTC day.
+- `/api/tts/admin/settings` masks credentials in the TTS engine's cached settings object, so for up
+ to 30 s after an admin views them the owner's view — and Google/AWS synthesis — see the mask.
+- `/paste` announces "Anonymous shared a paste" and titles it "Chat paste by anon" (it reads
+ fields the chat client object does not have).
+- The AI-viewer / PowerChat "is moderator" flag passes the channel owner's user id where a channel
+ id belongs (`canModerateChannel(user, channelUserId)`).
new file mode 100644
new file mode 100644
@@ -0,0 +1,1596 @@
@@ -0,0 +1,1596 @@
+diff --git a/.env.example b/.env.example
+index 31f53b7..19810f1 100644
+--- a/.env.example
+@@ -107,3 +107,9 @@ OPS_ALERT_WEBHOOK_URL=
+ # Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)
+ PASTES_ON_COMMUNITY=0
+ OV_COMMUNITY_URL=https://openvibe.community
++
++# Chat runs in OpenVibe.Chat when this is "chat" (roadmap W6; server/chat/chat-authority.js): Live
++# stops serving /ws/chat and /api/chat|dm|tts|sounds (nginx sends them to Chat), forwards its own
++# chat calls to Chat and keeps its chat tables as Chat's read mirror. Unset = Live runs chat.
++#CHAT_AUTHORITY=chat
++OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
+diff --git a/docs/chat-system.md b/docs/chat-system.md
+index 65d1647..fd58f07 100644
+--- a/docs/chat-system.md
+@@ -8,6 +8,29 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte
+ - **REST API**: `server/chat/routes.js` — moderation endpoints, message search, admin tools
+ - **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync
+
++### OpenVibe.Chat (roadmap Wave 6)
++
++Chat is moving to [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400)
++with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches it on
++(`server/chat/chat-authority.js`); without it nothing here changes.
++
++- nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live answers
++ them with 503 if one still arrives, and `/ws/chat` upgrades are refused.
++- `require('./chat/chat-server')` returns `chat-remote.js`: the same methods, forwarded in order to
++ Chat's `POST /internal/live/calls` (broadcasts, DMs, TTS, channel sounds, disconnects, user updates).
++ Chat-table writes Live's other modules make (AI viewers, relays, donations, `/api/mod`) are forwarded
++ too; inserts return a placeholder id (≤ -2^40) Chat maps to the real one. Unacknowledged writes wait
++ in `chat_bridge_outbox` (and are applied here if Live boots without the flag again — rollback).
++- Synchronous reads (`getTotalConnections`, viewer counts, slow modes, a connected user's IP) come from
++ Chat's presence snapshot, polled every 3 s.
++- Chat reads Live data and asks for side effects on `/internal/chat-context/*` and
++ `/internal/chat-effects/*` (`server/chat/live-context-routes.js`), with Network service tokens
++ (`live.chat_context.read`, `live.chat_effects.write`, `live.chat_mirror.write`,
++ `server/net/service-guard.js`). Effects re-check the acting moderator/owner/admin and are refused
++ unless `CHAT_AUTHORITY=chat`.
++- Live's chat tables become Chat's read mirror (`POST /internal/chat-effects/mirror`): same ids, so home
++ stats, recaps, AI context, VOD chat replay and the `/api/mod` queues keep reading them in place.
++
+ ## Features
+
+ ### Real-time Chat
+diff --git a/server/auth/auth.js b/server/auth/auth.js
+index e2dd198..4a7e908 100644
+--- a/server/auth/auth.js
+@@ -413,6 +413,10 @@ function reloadNetworkKey() {
+ module.exports = {
+ apiTokenAllows,
+ verifyToken,
++ verifyTokenWithReason,
++ getNetworkIssuer,
++ // The Network's RS256 key also signs service tokens (server/net/service-guard.js).
++ getNetworkPublicKey: () => openvibeToolsPublicKey,
+ requireAuth,
+ optionalAuth,
+ requireAdmin,
+diff --git a/server/chat/chat-authority.js b/server/chat/chat-authority.js
+new file mode 100644
+index 0000000..4867b0b
+--- /dev/null
+@@ -0,0 +1,23 @@
++'use strict';
++/**
++ * Who runs chat (roadmap Wave 6).
++ *
++ * CHAT_AUTHORITY=chat: OpenVibe.Chat (127.0.0.1:4400) serves /ws/chat and the chat REST routes
++ * (nginx routes them there) and owns the chat tables. Live then
++ * - does not start its chat WebSocket server or mount /api/chat, /api/dm, /api/tts, /api/sounds;
++ * - hands every chat call its other modules make to Chat (server/chat/chat-remote.js — what
++ * require('./chat/chat-server') returns in this mode);
++ * - keeps its chat tables as a read mirror that Chat writes (POST /internal/chat-effects/mirror)
++ * so its own readers (home stats, recaps, AI context, /api/mod queues) keep working and a
++ * rollback loses nothing;
++ * - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
++ * Anything else (default): Live runs chat itself, exactly as before.
++ */
++
++const CHAT_URL = (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/+$/, '');
++
++function isRemote() {
++ return process.env.CHAT_AUTHORITY === 'chat';
++}
++
++module.exports = { isRemote, CHAT_URL };
+diff --git a/server/chat/chat-remote.js b/server/chat/chat-remote.js
+new file mode 100644
+index 0000000..7809725
+--- /dev/null
+@@ -0,0 +1,374 @@
++'use strict';
++/**
++ * The chat server, when OpenVibe.Chat runs it (CHAT_AUTHORITY=chat, see chat-authority.js).
++ *
++ * require('./chat/chat-server') returns this object instead of a ChatServer, so every module that
++ * talks to chat keeps working unchanged:
++ * - calls that push to browsers (broadcastToStream, forwardToGlobal, sendDm, TTS, channel sounds,
++ * disconnectUser, sendUserUpdate …) are queued, in order, and sent to Chat's
++ * POST /internal/live/calls in small batches (service token for audience openvibe.chat,
++ * capability chat.live_bridge.write);
++ * - the chat-table writes Live's other modules make (db.saveChatMessage from AI viewers, relays,
++ * donations; /api/mod deletes and queues; moderation log) are forwarded the same way, because
++ * Chat owns those tables. Updates and deletes also run on Live's mirror at once so their
++ * return values (deleted ids) are right; inserts run only in Chat and return a placeholder id
++ * (≤ -2^40) that Chat maps to the real one for every later call of this boot. Forwarded writes
++ * are kept in chat_bridge_outbox until Chat acknowledges them, so a restart or a Chat outage
++ * loses none;
++ * - synchronous reads (connection counts, viewer counts, slow modes, a connected user's address)
++ * come from a presence snapshot polled from Chat's GET /internal/live/presence every few seconds.
++ */
++const crypto = require('crypto');
++const db = require('../db/database');
++const principal = require('../net/network-principal');
++const { CHAT_URL } = require('./chat-authority');
++
++const AUDIENCE = 'openvibe.chat';
++const BOOT = crypto.randomUUID();
++const REF_BASE = 2 ** 40;
++const BATCH = 200;
++const PRESENCE_MS = 3000;
++const EPHEMERAL_TTL_MS = 30000; // a broadcast older than this is dropped, not delivered late
++const MAX_QUEUE = 20000;
++
++// Chat-table writes Live still makes, and whether they also run on Live's mirror right away.
++// Inserts never run here (their ids are Chat's): the caller gets a placeholder.
++const FORWARDED_DB = {
++ saveChatMessage: 'insert',
++ logModerationAction: 'insert',
++ holdMessageForApproval: 'insert',
++ hideRelayUser: 'insert',
++ createChannelSound: 'insert',
++ mergeChatMessageMetadata: 'local',
++ deleteChatMessage: 'local',
++ deleteUserChatMessages: 'local',
++ deleteAnonChatMessages: 'local',
++ deleteRelayUserMessages: 'local',
++ deleteChatMessagesByTimeRange: 'local',
++ reviewPendingIpMessage: 'local',
++ approveAllFromIp: 'local',
++ denyAllFromIp: 'local',
++ recordRelayUser: 'local',
++ unhideRelayUser: 'local',
++ unhideRelayUserByIdentity: 'local',
++ recordFirstChat: 'local',
++ setTtsVoiceOverride: 'local',
++ deleteTtsVoiceOverride: 'local',
++ setChannelSoundEmote: 'local',
++ deleteChannelSound: 'local',
++ renameChannelSoundCommand: 'local',
++ updateChannelSoundEmoteRefs: 'local',
++};
++
++// Live-owned data Chat caches, written by Live's own routes (dashboard, /api/channels, /api/mod):
++// after the write, Chat is told to reload it instead of waiting for its cache to expire.
++const OBSERVED_DB = {
++ addChannelModerator: (a) => ['channel', a[0]],
++ removeChannelModerator: (a) => ['channel', a[0]],
++ upsertChannelModerationSettings: (a) => ['channel', a[0]],
++ setChannelAlertSound: (a) => ['channel', a[0]],
++ approveIp: (a) => ['approvals', a[0]],
++ revokeIpApproval: (a) => ['approvals', a[0]],
++ forgiveBan: () => ['bans', null],
++};
++
++function create(ChatServer) {
++ // A local ChatServer that never listens: its address and anon-number helpers are Live's own
++ // (call-server uses them for voice calls, and Chat asks Live for anon numbers).
++ const local = new ChatServer();
++ const queue = []; // { seq, op, args, ref, durableId, at }
++ let seq = 0;
++ let refCounter = 0;
++ let busy = false;
++ let inflight = 0; // ops at the head of the queue that are being sent right now
++ let batchLimit = BATCH; // 1 after Chat refused a batch as a whole (too large / malformed)
++ let backoffUntil = 0;
++ let flushTimer = null;
++ let presenceTimer = null;
++ let started = false;
++ let presence = { total: 0, streams: {}, slow_mode: {}, users: [], anons: [], at: null };
++ const slowModeByStream = new Map();
++ const stats = { sent: 0, failed: 0, dropped: 0, lastError: null };
++
++ function ensureOutbox() {
++ db.getDb().exec(`CREATE TABLE IF NOT EXISTS chat_bridge_outbox (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ boot TEXT NOT NULL,
++ ref INTEGER,
++ op TEXT NOT NULL,
++ args TEXT NOT NULL,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ }
++
++ function schedule(ms = 0) {
++ if (flushTimer) return;
++ flushTimer = setTimeout(() => { flushTimer = null; flush().catch(() => {}); }, ms);
++ if (flushTimer.unref) flushTimer.unref();
++ }
++
++ function enqueue(op, args, { ref = null, durable = false, boot = BOOT, durableId = null } = {}) {
++ let id = durableId;
++ if (durable && !id) {
++ try {
++ ensureOutbox();
++ id = db.getDb().prepare('INSERT INTO chat_bridge_outbox (boot, ref, op, args) VALUES (?, ?, ?, ?)').run(boot, ref, op, JSON.stringify(args)).lastInsertRowid;
++ } catch (err) { console.warn('[ChatRemote] outbox write failed:', err.message); }
++ }
++ if (queue.length >= MAX_QUEUE) {
++ // Drop the oldest broadcast, never a write.
++ const i = queue.findIndex((q, idx) => idx >= inflight && !q.durableId);
++ if (i >= 0) { queue.splice(i, 1); stats.dropped++; }
++ }
++ queue.push({ seq: ++seq, op, args, ref, durableId: id, boot, at: Date.now() });
++ schedule();
++ }
++
++ async function post(path, body, retried = false) {
++ const res = await fetch(`${CHAT_URL}${path}`, {
++ method: body ? 'POST' : 'GET',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await principal.serviceHeaders(AUDIENCE)) },
++ body: body ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(10000),
++ });
++ if (res.status === 401 && !retried) { principal.invalidate(AUDIENCE); return post(path, body, true); }
++ if (!res.ok) { const err = new Error(`Chat ${res.status}`); err.status = res.status; throw err; }
++ return res.json();
++ }
++
++ async function flush() {
++ if (busy || !queue.length) return;
++ if (Date.now() < backoffUntil) { schedule(backoffUntil - Date.now()); return; }
++ busy = true;
++ try {
++ while (queue.length) {
++ // Stale broadcasts are dropped; forwarded writes always go.
++ const now = Date.now();
++ for (let i = queue.length - 1; i >= 0; i--) {
++ if (!queue[i].durableId && now - queue[i].at > EPHEMERAL_TTL_MS) { queue.splice(i, 1); stats.dropped++; }
++ }
++ if (!queue.length) break;
++ // One boot per request (replayed writes of an earlier boot keep their own id space).
++ const boot = queue[0].boot;
++ const batch = [];
++ for (const q of queue) { if (q.boot !== boot || batch.length >= batchLimit) break; batch.push(q); }
++ let out;
++ inflight = batch.length;
++ try {
++ // A forwarded write carries its outbox id as an idempotency key: Chat applies it once
++ // even when a lost response makes Live send it again.
++ out = await post('/internal/live/calls', { boot, ops: batch.map((q) => ({ seq: q.seq, op: q.op, args: q.args, ref: q.ref, key: q.durableId ? `live:${q.durableId}` : undefined })) });
++ } catch (err) {
++ inflight = 0;
++ stats.failed++;
++ if (err.status === 400 || err.status === 413) {
++ // Refused as a request, not per op: retry one by one; a single op still refused
++ // can never be delivered, so it is dropped (logged) instead of blocking the queue.
++ if (batch.length > 1) { batchLimit = 1; continue; }
++ const q = queue.shift();
++ stats.dropped++;
++ console.error(`[ChatRemote] Chat refused ${q.op}${q.op === 'db' ? ` ${q.args[0]}` : ''} (${err.status}); dropped`);
++ if (q.durableId) { try { db.getDb().prepare('DELETE FROM chat_bridge_outbox WHERE id = ?').run(q.durableId); } catch { /* */ } }
++ continue;
++ }
++ if (stats.lastError !== err.message) console.warn(`[ChatRemote] Chat unreachable (${queue.length} queued): ${err.message}`);
++ stats.lastError = err.message;
++ backoffUntil = Date.now() + 2000;
++ schedule(2000);
++ return;
++ }
++ stats.lastError = null;
++ inflight = 0;
++ batchLimit = BATCH;
++ queue.splice(0, batch.length);
++ stats.sent += batch.length;
++ const done = batch.filter((q) => q.durableId).map((q) => q.durableId);
++ if (done.length) {
++ try { db.getDb().prepare(`DELETE FROM chat_bridge_outbox WHERE id IN (${done.map(() => '?').join(',')})`).run(...done); } catch { /* */ }
++ }
++ for (const r of (out && out.results) || []) {
++ if (!r.ok) console.warn(`[ChatRemote] Chat refused op #${r.seq}: ${r.error}`);
++ }
++ }
++ } finally { busy = false; }
++ }
++
++ async function pollPresence() {
++ try {
++ const p = await post('/internal/live/presence');
++ presence = p;
++ slowModeByStream.clear();
++ for (const [k, v] of Object.entries(p.slow_mode || {})) slowModeByStream.set(Number(k), Number(v) || 0);
++ } catch { /* keep the last snapshot */ }
++ }
++
++ // ── Forwarded database writes ───────────────────────────────
++ const originals = {};
++ function installDbForwarding() {
++ for (const [fn, mode] of Object.entries(FORWARDED_DB)) {
++ if (typeof db[fn] !== 'function' || originals[fn]) continue;
++ originals[fn] = db[fn];
++ db[fn] = function forwardedChatWrite(...args) {
++ if (mode === 'insert') {
++ const ref = -(REF_BASE + (++refCounter));
++ enqueue('db', [fn, ...args], { ref, durable: true });
++ return { changes: 1, lastInsertRowid: ref };
++ }
++ let result;
++ try { result = originals[fn](...args); } catch (err) { result = undefined; console.warn(`[ChatRemote] mirror ${fn}: ${err.message}`); }
++ enqueue('db', [fn, ...args], { durable: true });
++ return result;
++ };
++ }
++ }
++
++ function installInvalidations() {
++ for (const [fn, target] of Object.entries(OBSERVED_DB)) {
++ if (typeof db[fn] !== 'function' || originals[fn]) continue;
++ originals[fn] = db[fn];
++ db[fn] = function observedLiveWrite(...args) {
++ const result = originals[fn](...args);
++ try { const [kind, id] = target(args); enqueue('invalidate', [kind, id]); } catch { /* */ }
++ return result;
++ };
++ }
++ }
++
++ function replayOutbox() {
++ try {
++ ensureOutbox();
++ const rows = db.getDb().prepare('SELECT id, boot, ref, op, args FROM chat_bridge_outbox ORDER BY id').all();
++ for (const r of rows) {
++ let args;
++ try { args = JSON.parse(r.args); } catch { continue; }
++ queue.push({ seq: ++seq, op: r.op, args, ref: r.ref, durableId: r.id, boot: r.boot, at: Date.now() });
++ }
++ if (rows.length) console.log(`[ChatRemote] replaying ${rows.length} chat write(s) queued before the restart`);
++ } catch (err) { console.warn('[ChatRemote] outbox replay:', err.message); }
++ }
++
++ // A stream row carries Live's stream keys: Chat only ever needs the owner.
++ const slimStream = (s) => (s ? { id: s.id, user_id: s.user_id } : s);
++ const send = (op) => (...args) => { enqueue(op, args); };
++
++ // Live code that iterated chatServer.clients and wrote to each socket (a global delete in
++ // /api/mod) gets one pseudo-socket whose send() reaches every chat client through Chat.
++ const everyone = { readyState: 1, bufferedAmount: 0, send: (raw) => enqueue('broadcastAllRaw', [String(raw)]), close() {} };
++ const clients = new Map([[everyone, { pseudo: true }]]);
++
++ const remote = {
++ remote: true,
++ boot: BOOT,
++ stats,
++ clients,
++ slowModeByStream,
++
++ init() {
++ if (started) return null;
++ started = true;
++ ensureOutbox();
++ replayOutbox();
++ schedule();
++ pollPresence();
++ presenceTimer = setInterval(pollPresence, PRESENCE_MS);
++ if (presenceTimer.unref) presenceTimer.unref();
++ console.log(`[Chat] CHAT_AUTHORITY=chat — chat runs in OpenVibe.Chat (${CHAT_URL}); Live forwards to it`);
++ return null;
++ },
++ handleUpgrade(req, socket) {
++ // nginx sends /ws/chat to Chat; a socket that still lands here is told to retry.
++ try { socket.write('HTTP/1.1 503 Service Unavailable\r\nRetry-After: 2\r\nConnection: close\r\n\r\n'); } catch { /* */ }
++ try { socket.destroy(); } catch { /* */ }
++ return true;
++ },
++ close() {
++ if (presenceTimer) clearInterval(presenceTimer);
++ presenceTimer = null;
++ return flush().catch(() => {});
++ },
++ flush,
++
++ // ── Pushes to browsers (Chat delivers) ──
++ broadcastToStream: send('broadcastToStream'),
++ broadcastToChannelRoom: send('broadcastToChannelRoom'),
++ broadcastGlobal: send('broadcastGlobal'),
++ broadcastAll: send('broadcastAll'),
++ forwardToGlobal: send('forwardToGlobal'),
++ forwardToGlobalByChannel: send('forwardToGlobalByChannel'),
++ forwardToStreamerRooms: send('forwardToStreamerRooms'),
++ broadcastToOwnerStreams: send('broadcastToOwnerStreams'),
++ sendDm: send('sendDm'),
++ sendUserUpdate: send('sendUserUpdate'),
++ disconnectUser: send('disconnectUser'),
++ sendToConn: send('sendToConn'),
++ synthesizeAndBroadcastTTS(...args) { enqueue('synthesizeAndBroadcastTTS', args); return Promise.resolve(); },
++ triggerChannelSound(ws, client, stream, command, args = [], relay = null) {
++ const c = client ? { streamId: client.streamId || null, user: client.user ? { id: client.user.id, username: client.user.username, display_name: client.user.display_name, role: client.user.role, avatar_url: client.user.avatar_url, profile_color: client.user.profile_color } : null, anonId: client.anonId || null, ip: client.ip || null } : {};
++ enqueue('triggerChannelSound', [null, c, slimStream(stream), command, args, relay]);
++ },
++ /** Commits this boot shipped (deploy-notice.js decides which; Chat stores and shows them). */
++ deployNotice(commits) { enqueue('deployNotice', [commits], { durable: true }); },
++ /** Live changed a user (role push, avatar): Chat drops what it cached about them. */
++ userChanged(userId) { if (userId) enqueue('userChanged', [userId]); },
++
++ // ── Synchronous reads (presence snapshot) ──
++ getTotalConnections() { return Number(presence.total) || 0; },
++ getStreamViewerCount(streamId) { return Number((presence.streams || {})[streamId]) || 0; },
++ getConnectedUserIp(userId) {
++ const hit = (presence.users || []).find((u) => u.user_id === userId);
++ return hit ? hit.ip : null;
++ },
++ findClientByAnonId(anonId, streamId) {
++ // Same match as ChatServer.findClientByAnonId: the socket's stream must equal streamId.
++ const hit = (presence.anons || []).find((a) => a.anon_id === anonId && (a.stream_id ?? null) === streamId);
++ return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;
++ },
++ getUserList() { return { logged: [], anonCount: 0 }; },
++
++ // ── Live's own address / anon helpers ──
++ normalizeIp: (ip) => local.normalizeIp(ip),
++ getClientIp: (req) => local.getClientIp(req),
++ getAnonIdForIp: (ip) => local.getAnonIdForIp(ip),
++ getAnonIdForConnection(ip, streamId = null) {
++ const key = local.normalizeIp(ip);
++ const hit = (presence.anons || []).find((a) => a.ip === key && (streamId == null || (a.stream_id || null) === streamId));
++ return hit ? hit.anon_id : local.getAnonIdForIp(key);
++ },
++ /** The anon number for an address (Network's unified resolve, else Live's table) — for Chat. */
++ async resolveAnon(ip) {
++ const key = local.normalizeIp(ip);
++ const num = await local._resolveUnifiedAnonNum(key);
++ return { anon_number: num, first_seen: db.getAnonFirstSeen(key) };
++ },
++ };
++
++ installDbForwarding();
++ installInvalidations();
++ remote._restoreDb = () => { for (const [fn, orig] of Object.entries(originals)) db[fn] = orig; };
++ return remote;
++}
++
++/**
++ * Rollback: Live runs chat itself again (CHAT_AUTHORITY unset) but chat writes it forwarded were
++ * never acknowledged by Chat (Chat down). Apply them to Live's own tables so nothing is lost.
++ * A write Chat did apply whose answer was lost is applied twice — rare, and only for that window.
++ */
++function drainToLocal(log = console) {
++ try {
++ const d = db.getDb();
++ if (!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_bridge_outbox'").get()) return 0;
++ const rows = d.prepare("SELECT id, args FROM chat_bridge_outbox WHERE op = 'db' ORDER BY id").all();
++ let applied = 0;
++ for (const r of rows) {
++ try {
++ const [fn, ...args] = JSON.parse(r.args);
++ if (FORWARDED_DB[fn] && typeof db[fn] === 'function') { db[fn](...args); applied++; }
++ } catch (err) { log.warn(`[ChatRemote] could not apply queued chat write #${r.id}: ${err.message}`); }
++ }
++ d.prepare('DELETE FROM chat_bridge_outbox').run();
++ if (rows.length) log.log(`[ChatRemote] applied ${applied} of ${rows.length} chat write(s) OpenVibe.Chat never acknowledged`);
++ return applied;
++ } catch (err) { log.warn('[ChatRemote] drain:', err.message); return 0; }
++}
++
++module.exports = { create, drainToLocal, FORWARDED_DB, OBSERVED_DB, REF_BASE, AUDIENCE };
+diff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
+index cfce589..ae77d68 100644
+--- a/server/chat/chat-server.js
+@@ -2491,4 +2491,6 @@ class ChatServer {
+ }
+ }
+
+-module.exports = new ChatServer();
++// CHAT_AUTHORITY=chat: OpenVibe.Chat runs the chat server; Live's modules get a proxy with the same
++// methods that hands every call to it (chat-remote.js). Default: Live runs chat itself.
++module.exports = require('./chat-authority').isRemote() ? require('./chat-remote').create(ChatServer) : new ChatServer();
+diff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js
+index 4b0f91a..33f8811 100644
+--- a/server/chat/deploy-notice.js
+@@ -82,6 +82,14 @@ async function announce({ db, chatServer, log = console }) {
+ const { head, commits } = await newCommits(db);
+ if (!head || !commits.length) return { announced: 0 };
+
++ // CHAT_AUTHORITY=chat: Live still decides what shipped; OpenVibe.Chat stores the rolling
++ // message and shows it (its own copy of this module), so hand it the commits.
++ if (chatServer && chatServer.remote) {
++ chatServer.deployNotice(commits);
++ try { db.setSetting(SETTING, head); } catch (err) { log.warn('[Deploy notice] not recorded:', err.message); }
++ return { announced: commits.length };
++ }
++
+ let saved;
+ try { saved = persist(db, commits); db.setSetting(SETTING, head); }
+ catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
+diff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
+new file mode 100644
+index 0000000..53a72ce
+--- /dev/null
+@@ -0,0 +1,656 @@
++'use strict';
++/**
++ * What OpenVibe.Chat asks of Live (roadmap Wave 6). Chat owns chat; Live still owns accounts,
++ * streams, channels, bans, cosmetics, coins, AI viewers, the arena, the media queue and hardware.
++ * Chat's single adapter (OpenVibe.Chat server/live-context.js) calls these with a Network service
++ * token for audience openvibe.live (server/net/service-guard.js); nothing here is routed by nginx.
++ *
++ * /internal/chat-context/* capability live.chat_context.read — projections Chat caches
++ * POST /auth {token} → { user, expires_at, reason } (Live's own token rules)
++ * GET /users?after_id&limit → { rows } users projection (paged by id)
++ * POST /users/lookup {ids?, usernames?} → { users }
++ * GET /users/:id/follows → { streamer_ids }
++ * GET /users/profile?username&viewer_id → the /api/chat/user/:username/profile card
++ * GET /streams?after_id&limit, /streams/active, /streams/:id
++ * GET /managed-streams?after_id&limit, /channels?after_id&limit, /channels/by-user/:userId
++ * GET /channels/:id/policy → { channel, settings, moderator_ids, language }
++ * GET /channels/:id/approved-ip?ip → { approved }
++ * GET /bans?version → { version, bans } | { version, unchanged: true }
++ * POST /decor {user_ids} → { decor: { id: { cosmetic, tag } } }
++ * GET /settings → { settings } (tts_*, GIF and soundboard keys only)
++ * GET /anon/:num, /anon-first-seen?ip → { first_seen }
++ * GET /tts-audio/:file → a clip from the arena voice cache
++ *
++ * /internal/chat-effects/* capability live.chat_effects.write — side effects (CHAT_AUTHORITY=chat only)
++ * anon, ip-log, viewer-counts, viewer-snapshots, user-color, ban, approve-ip, channel-settings,
++ * alert-sound, ensure-channel, site-settings, chat-message, ai/mod-command, arena-command,
++ * media-queue, hardware, paste, translate, notify/dm, notify/dm-read, asset-sync
++ * mirror (capability live.chat_mirror.write) — Chat's changes to its tables, applied to Live's copy
++ *
++ * Every effect that acts for a person re-checks that person here (moderator, owner, admin) — Chat
++ * checked first; Live does not take its word for it.
++ */
++const express = require('express');
++const fs = require('fs');
++const path = require('path');
++const db = require('../db/database');
++const config = require('../config');
++const permissions = require('../auth/permissions');
++const { guard } = require('../net/service-guard');
++const { isRemote } = require('./chat-authority');
++
++const contextRouter = express.Router();
++const effectsRouter = express.Router();
++
++const int = (v, d = 0) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
++const pageArgs = (req) => ({ after: Math.max(0, int(req.query.after_id)), limit: Math.min(5000, Math.max(1, int(req.query.limit, 1000))) });
++const fail = (res, status, error) => res.status(status).json({ error });
++
++// ── Projections ─────────────────────────────────────────────────
++
++const SUBJECT_SQL = "(SELECT la.subject_id FROM linked_accounts la WHERE la.user_id = u.id AND la.service = 'network' AND la.subject_id IS NOT NULL ORDER BY la.id DESC LIMIT 1)";
++const USER_SQL = `SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, u.role, u.is_banned, u.ban_reason,
++ COALESCE(u.is_owner, 0) AS is_owner, u.created_at, ${SUBJECT_SQL} AS subject_id FROM users u`;
++const STREAM_SQL = 'SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams';
++const MS_SQL = 'SELECT id, user_id, slug, title, sort_order, created_at FROM managed_streams';
++const CHANNEL_SQL = 'SELECT id, user_id, title FROM channels';
++
++/** The chat projection of a user row (no secrets: no email, password, stream key, balances). */
++function userProjection(user, subjectId) {
++ if (!user) return null;
++ let subject = subjectId || user.subject_id || null;
++ if (!subject) {
++ try { subject = db.get("SELECT subject_id FROM linked_accounts WHERE user_id = ? AND service = 'network' AND subject_id IS NOT NULL ORDER BY id DESC LIMIT 1", [user.id])?.subject_id || null; } catch { subject = null; }
++ }
++ return {
++ id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url || null,
++ profile_color: user.profile_color || null, role: user.role, is_banned: user.is_banned ? 1 : 0, ban_reason: user.ban_reason || null,
++ is_owner: user.is_owner ? 1 : 0, created_at: user.created_at, subject_id: subject,
++ };
++}
++
++contextRouter.use(guard('live.chat_context.read'));
++
++// Resolve a browser/bot token exactly as Live's requireAuth / authenticateWs do.
++contextRouter.post('/auth', (req, res) => {
++ const token = String((req.body && req.body.token) || '');
++ if (!token) return res.json({ user: null, reason: 'invalid' });
++ const auth = require('../auth/auth');
++ const apiUser = auth.authenticateApiToken(token);
++ if (apiUser) {
++ const user = { ...userProjection(apiUser), auth_source: 'api_token', scopes: apiUser.scopes || [] };
++ return res.json({ user, expires_at: null, reason: null });
++ }
++ const r = auth.verifyTokenWithReason(token);
++ if (!r.ok) return res.json({ user: null, reason: 'invalid' });
++ const user = auth.resolveNetworkUser(r.decoded);
++ if (!user) return res.json({ user: null, reason: 'unresolved' });
++ res.json({
++ user: { ...userProjection(user, r.decoded.subject_id), auth_source: 'network' },
++ expires_at: typeof r.decoded.exp === 'number' ? new Date(r.decoded.exp * 1000).toISOString() : null,
++ reason: null,
++ });
++});
++
++contextRouter.get('/users', (req, res) => {
++ const { after, limit } = pageArgs(req);
++ res.json({ rows: db.all(`${USER_SQL} WHERE u.id > ? ORDER BY u.id LIMIT ?`, [after, limit]) });
++});
++
++contextRouter.post('/users/lookup', (req, res) => {
++ const ids = (Array.isArray(req.body?.ids) ? req.body.ids : []).map(Number).filter(Number.isInteger).slice(0, 500);
++ const names = (Array.isArray(req.body?.usernames) ? req.body.usernames : []).map(String).slice(0, 100);
++ const rows = [];
++ if (ids.length) rows.push(...db.all(`${USER_SQL} WHERE u.id IN (${ids.map(() => '?').join(',')})`, ids));
++ for (const n of names) { const r = db.get(`${USER_SQL} WHERE u.username = ? COLLATE NOCASE`, [n]); if (r) rows.push(r); }
++ res.json({ users: rows });
++});
++
++contextRouter.get('/users/profile', (req, res) => {
++ // The same card /api/chat/user/:username/profile built in Live.
++ try {
++ const name = String(req.query.username || '');
++ let user = db.getUserByUsername(name);
++ if (!user) user = db.get('SELECT * FROM users WHERE display_name = ? COLLATE NOCASE', [name]);
++ if (!user) return fail(res, 404, 'User not found');
++ const profile = db.getUserProfile(user.id);
++ if (!profile) return fail(res, 404, 'Profile not found');
++ // OpenCoins are part of the game; Vibes (real money) and presence stay with the user.
++ const viewerId = int(req.query.viewer_id, 0);
++ if (!viewerId || viewerId !== user.id) {
++ delete profile.openvibe_bucks_balance;
++ delete profile.last_seen;
++ }
++ try {
++ const game = require('../game/game-engine');
++ const player = game.getPlayer(user.id);
++ if (player) {
++ profile.game = {
++ total_level: player.total_level,
++ mining_level: player.mining_level,
++ fishing_level: player.fishing_level,
++ woodcut_level: player.woodcut_level,
++ farming_level: player.farming_level,
++ combat_level: player.combat_level,
++ crafting_level: player.crafting_level,
++ mining_xp: player.mining_xp,
++ fishing_xp: player.fishing_xp,
++ woodcut_xp: player.woodcut_xp,
++ farming_xp: player.farming_xp,
++ combat_xp: player.combat_xp,
++ crafting_xp: player.crafting_xp,
++ total_coins_earned: player.total_coins_earned || 0,
++ };
++ }
++ } catch { /* game not initialized or player doesn't exist */ }
++ res.json(profile);
++ } catch (err) {
++ fail(res, 500, 'Failed to get profile');
++ }
++});
++
++contextRouter.get('/users/:id/follows', (req, res) => {
++ res.json({ streamer_ids: db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [int(req.params.id)]).map((r) => r.streamer_id) });
++});
++
++contextRouter.get('/streams', (req, res) => {
++ const { after, limit } = pageArgs(req);
++ res.json({ rows: db.all(`${STREAM_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });
++});
++// Live now, plus anything that ended in the last quarter hour (so Chat sees it go offline).
++contextRouter.get('/streams/active', (req, res) => {
++ res.json({ rows: db.all(`${STREAM_SQL} WHERE is_live = 1 OR (ended_at IS NOT NULL AND ended_at >= datetime('now', '-15 minutes'))`) });
++});
++contextRouter.get('/streams/:id', (req, res) => {
++ const stream = db.get(`${STREAM_SQL} WHERE id = ?`, [int(req.params.id)]);
++ if (!stream) return res.json({ stream: null });
++ res.json({
++ stream,
++ owner: db.get(`${USER_SQL} WHERE u.id = ?`, [stream.user_id]) || null,
++ managed_stream: stream.managed_stream_id ? (db.get(`${MS_SQL} WHERE id = ?`, [stream.managed_stream_id]) || null) : null,
++ channel: stream.channel_id ? (db.get(`${CHANNEL_SQL} WHERE id = ?`, [stream.channel_id]) || null) : (db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [stream.user_id]) || null),
++ });
++});
++
++contextRouter.get('/managed-streams', (req, res) => {
++ const { after, limit } = pageArgs(req);
++ res.json({ rows: db.all(`${MS_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });
++});
++
++contextRouter.get('/channels', (req, res) => {
++ const { after, limit } = pageArgs(req);
++ res.json({ rows: db.all(`${CHANNEL_SQL} WHERE id > ? ORDER BY id LIMIT ?`, [after, limit]) });
++});
++contextRouter.get('/channels/by-user/:userId', (req, res) => {
++ res.json({ channel: db.get(`${CHANNEL_SQL} WHERE user_id = ?`, [int(req.params.userId)]) || null });
++});
++contextRouter.get('/channels/:id/policy', (req, res) => {
++ const channelId = int(req.params.id);
++ const channel = db.get(`${CHANNEL_SQL} WHERE id = ?`, [channelId]) || null;
++ let language = 'en';
++ try { if (channel) language = require('../i18n/translate').channelLanguage(channel.user_id) || 'en'; } catch { language = 'en'; }
++ res.json({
++ channel,
++ settings: db.getChannelModerationSettings(channelId),
++ moderator_ids: db.all('SELECT user_id FROM channel_moderators WHERE channel_id = ?', [channelId]).map((r) => r.user_id),
++ language,
++ });
++});
++contextRouter.get('/channels/:id/approved-ip', (req, res) => {
++ res.json({ approved: db.isIpApproved(int(req.params.id), String(req.query.ip || '')) });
++});
++
++// Active bans (user, IP and CIDR rows). `version` changes whenever the table does, so an
++// unchanged table is one small answer.
++contextRouter.get('/bans', (req, res) => {
++ const v = db.get('SELECT COUNT(*) AS c, COALESCE(MAX(id), 0) AS m, COALESCE(SUM(id * 7 + COALESCE(stream_id, 0) + LENGTH(COALESCE(expires_at, \'\'))), 0) AS s FROM bans');
++ const version = `${v.c}:${v.m}:${v.s}`;
++ if (req.query.version && String(req.query.version) === version) return res.json({ version, unchanged: true });
++ res.json({
++ version,
++ bans: db.all('SELECT id, stream_id, user_id, ip_address, anon_id, expires_at FROM bans WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP'),
++ });
++});
++
++contextRouter.post('/decor', (req, res) => {
++ const ids = (Array.isArray(req.body?.user_ids) ? req.body.user_ids : []).map(Number).filter(Number.isInteger).slice(0, 500);
++ let cosmetics = null, tags = null;
++ try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }
++ try { tags = require('../game/tags'); } catch { /* */ }
++ const decor = {};
++ for (const id of ids) {
++ let cosmetic = {}, tag = null;
++ try { if (cosmetics) cosmetic = cosmetics.getCosmeticProfile(id) || {}; } catch { cosmetic = {}; }
++ try { if (tags) tag = tags.getTagProfile(id) || null; } catch { tag = null; }
++ decor[id] = { cosmetic, tag };
++ }
++ res.json({ decor });
++});
++
++// Site settings chat reads (TTS config, GIF provider keys, the 101soundboards key) — nothing else.
++const CHAT_SETTING_KEYS = new Set(['gif_tenor_api_key', 'gif_giphy_api_key', 'soundboard_101_api_key']);
++const isChatSetting = (k) => CHAT_SETTING_KEYS.has(k) || /^tts_[a-z0-9_]+$/.test(k);
++contextRouter.get('/settings', (req, res) => {
++ const settings = {};
++ for (const r of db.all("SELECT key FROM site_settings WHERE key LIKE 'tts\\_%' ESCAPE '\\' OR key IN ('gif_tenor_api_key', 'gif_giphy_api_key', 'soundboard_101_api_key')")) {
++ if (isChatSetting(r.key)) settings[r.key] = db.getSetting(r.key);
++ }
++ res.json({ settings });
++});
++
++contextRouter.get('/anon/:num', (req, res) => {
++ res.json({ first_seen: db.get('SELECT created_at FROM anon_ip_mappings WHERE anon_num = ?', [int(req.params.num)])?.created_at || null });
++});
++contextRouter.get('/anon-first-seen', (req, res) => {
++ res.json({ first_seen: db.getAnonFirstSeen(String(req.query.ip || '')) });
++});
++
++contextRouter.get('/tts-audio/:file', (req, res) => {
++ try {
++ const hit = require('../arena/voice').cachedByName(req.params.file);
++ if (!hit) return fail(res, 404, 'No such clip');
++ res.set({ 'Content-Type': hit.mimeType });
++ require('fs').createReadStream(hit.path).pipe(res);
++ } catch (err) { fail(res, 500, err.message); }
++});
++
++// ── Effects ────────────────────────────────────────────────────
++
++effectsRouter.post('/mirror', guard('live.chat_mirror.write'), (req, res, next) => next());
++effectsRouter.use((req, res, next) => {
++ if (req.path === '/mirror') return next();
++ return guard('live.chat_effects.write')(req, res, next);
++});
++// Live only takes Chat's writes while Chat is the authority — a rehearsal Chat pointed at
++// production Live can never award coins, post to AI viewers or overwrite chat tables.
++effectsRouter.use((req, res, next) => {
++ if (!isRemote()) return fail(res, 409, 'Live runs chat itself (CHAT_AUTHORITY is not "chat")');
++ next();
++});
++
++const chatServer = () => require('./chat-server');
++const actorOf = (id) => (id ? db.getUserById(id) : null);
++
++effectsRouter.post('/anon', async (req, res) => {
++ const ip = String(req.body?.ip || '');
++ if (!ip) return fail(res, 400, 'ip required');
++ try { res.json(await chatServer().resolveAnon(ip)); } catch (err) { fail(res, 500, err.message); }
++});
++
++effectsRouter.post('/ip-log', (req, res) => {
++ const entries = Array.isArray(req.body?.entries) ? req.body.entries.slice(0, 1000) : [];
++ let ipUtils = null;
++ try { ipUtils = require('../admin/ip-utils'); } catch { /* */ }
++ for (const e of entries) {
++ try {
++ const geo = ipUtils ? ipUtils.enrichIp(e.ip) : null;
++ db.logIp({ userId: e.userId || null, anonId: e.anonId || null, ip: e.ip, action: e.action || 'chat', geo });
++ } catch { /* non-critical */ }
++ }
++ res.json({ ok: true, logged: entries.length });
++});
++
++effectsRouter.post('/viewer-counts', (req, res) => {
++ for (const [sid, count] of Object.entries(req.body?.counts || {})) {
++ try { db.updateViewerCount(int(sid), Math.max(0, int(count))); } catch { /* */ }
++ }
++ res.json({ ok: true });
++});
++
++effectsRouter.post('/viewer-snapshots', (req, res) => {
++ for (const s of (Array.isArray(req.body?.snapshots) ? req.body.snapshots : []).slice(0, 1000)) {
++ try { db.insertViewerSnapshot(int(s.stream_id), Math.max(0, int(s.viewer_count)), Math.max(0, int(s.chat_messages_5m))); } catch { /* */ }
++ }
++ res.json({ ok: true });
++});
++
++// /color: the user changes their own color.
++effectsRouter.post('/user-color', (req, res) => {
++ const userId = int(req.body?.user_id);
++ const color = String(req.body?.color || '');
++ if (!userId || !/^#[0-9a-fA-F]{6}$/.test(color)) return fail(res, 400, 'user_id and a #rrggbb color required');
++ db.run('UPDATE users SET profile_color = ? WHERE id = ?', [color, userId]);
++ res.json({ ok: true });
++});
++
++// /ban, /timeout, /unban from chat. The moderator is checked again: global staff, or a
++// moderator of the stream chat decided on (for an offline channel room, its latest stream).
++effectsRouter.post('/ban', (req, res) => {
++ const b = req.body || {};
++ const actor = actorOf(int(b.actor_user_id));
++ const modStream = int(b.moderation_stream_id) || null;
++ const allowed = actor && !actor.is_banned && (permissions.isGlobalModOrAbove(actor) || (modStream && permissions.canModerateStream(actor, modStream)));
++ if (!allowed) return fail(res, 403, 'You do not have permission.');
++ const streamId = b.stream_id != null ? int(b.stream_id) || null : null;
++ if (b.action === 'unban') {
++ const userId = int(b.user_id);
++ if (!userId) return fail(res, 400, 'user_id required');
++ db.run('DELETE FROM bans WHERE user_id = ? AND (stream_id = ? OR stream_id IS NULL)', [userId, streamId]);
++ return res.json({ ok: true });
++ }
++ if (b.action !== 'ban') return fail(res, 400, 'action must be ban or unban');
++ if (b.user_id) {
++ const target = db.getUserById(int(b.user_id));
++ if (!target) return fail(res, 404, 'User not found');
++ // Prevent non-admins from banning admins
++ if (permissions.isGlobalModOrAbove(target) && target.role === 'admin' && actor.role !== 'admin') return fail(res, 403, 'You cannot ban an admin.');
++ db.run('INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)',
++ [streamId, target.id, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id, b.expires_at || null]);
++ } else if (b.ip_address) {
++ db.run('INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)',
++ [streamId, String(b.ip_address), b.anon_id ? String(b.anon_id) : null, String(b.reason || 'Banned by moderator').slice(0, 200), actor.id]);
++ } else {
++ return fail(res, 400, 'user_id or ip_address required');
++ }
++ res.json({ ok: true });
++});
++
++// IP approval mode: an address that chatted here before is approved automatically ('auto_existing').
++effectsRouter.post('/approve-ip', (req, res) => {
++ const channelId = int(req.body?.channel_id);
++ const ip = String(req.body?.ip || '');
++ if (!channelId || !ip) return fail(res, 400, 'channel_id and ip required');
++ db.approveIp(channelId, ip, req.body.approved_by ? int(req.body.approved_by) : null, String(req.body.source || 'auto').slice(0, 32));
++ res.json({ ok: true });
++});
++
++// /slow persists the channel's slow mode.
++effectsRouter.post('/channel-settings', (req, res) => {
++ const channelId = int(req.body?.channel_id);
++ const actor = actorOf(int(req.body?.actor_user_id));
++ if (!channelId || !actor || !permissions.canModerateChannel(actor, channelId)) return fail(res, 403, 'You do not have permission.');
++ const fields = {};
++ if (req.body.fields && req.body.fields.slow_mode_seconds !== undefined) fields.slow_mode_seconds = Math.max(0, int(req.body.fields.slow_mode_seconds));
++ if (!Object.keys(fields).length) return fail(res, 400, 'no chat-settable fields');
++ db.upsertChannelModerationSettings(channelId, fields);
++ res.json({ ok: true });
++});
++
++// Donation / goal alert sounds: the streamer's own channel only, files in the shared sounds dir.
++effectsRouter.post('/alert-sound', (req, res) => {
++ const channelId = int(req.body?.channel_id);
++ const channel = channelId ? db.getChannelById(channelId) : null;
++ if (!channel || channel.user_id !== int(req.body?.actor_user_id)) return fail(res, 403, 'Not your channel');
++ const url = req.body.url ? path.resolve(String(req.body.url)) : null;
++ if (url) {
++ // Compared through realpath: Chat and Live may reach the shared sounds dir via different symlinks.
++ let inside = false;
++ try { inside = path.dirname(fs.realpathSync(url)) === fs.realpathSync(path.resolve(config.sounds.path)); } catch { inside = false; }
++ if (!inside) return fail(res, 400, 'alert sounds live in the sounds directory');
++ }
++ db.setChannelAlertSound(channelId, req.body.kind === 'goal' ? 'goal' : 'donation', url, url ? String(req.body.mime || 'audio/mpeg') : null);
++ res.json({ ok: true });
++});
++
++effectsRouter.post('/ensure-channel', (req, res) => {
++ const userId = int(req.body?.user_id);
++ if (!userId || !db.getUserById(userId)) return fail(res, 404, 'User not found');
++ const ch = db.ensureChannel(userId);
++ res.json({ channel: ch ? { id: ch.id, user_id: ch.user_id, title: ch.title } : null });
++});
++
++// TTS admin settings: admins; credentials only the owner (Live's /api/tts/admin/settings rules).
++effectsRouter.post('/site-settings', (req, res) => {
++ const actor = actorOf(int(req.body?.actor_user_id));
++ if (!actor || actor.role !== 'admin') return fail(res, 403, 'Admin access required');
++ const allowed = ['tts_enabled', 'tts_provider', 'tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region', 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global', 'tts_default_voice'];
++ const secret = new Set(['tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key']);
++ const owner = permissions.isOwner(actor);
++ let count = 0;
++ for (const [key, value] of Object.entries(req.body.settings || {})) {
++ if (!allowed.includes(key)) continue;
++ if (secret.has(key) && !owner) continue;
++ if (secret.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;
++ db.setSetting(key, value);
++ count++;
++ }
++ try { require('./tts-engine').invalidateSettingsCache(); } catch { /* */ }
++ res.json({ ok: true, updated: count });
++});
++
++// One real chat line: OpenCoins chat bonus, AI chat viewers, the PowerChat overlay relay.
++effectsRouter.post('/chat-message', (req, res) => {
++ const b = req.body || {};
++ let coin = null;
++ if (b.award && b.user_id && b.stream_id) {
++ try { coin = require('../monetization/opencoins').awardChat(int(b.user_id), int(b.stream_id)); } catch { coin = null; }
++ }
++ if (b.ai && b.stream_id) {
++ try {
++ require('../integrations/ai-chatbot-service').onRealChatMessage(int(b.stream_id), {
++ username: b.username,
++ message: b.message,
++ userId: b.user_id || null,
++ anonId: b.anon_id || null,
++ isStreamer: !!b.is_streamer,
++ isMod: !!b.is_mod,
++ msgId: b.msg_id || null,
++ channelUserId: b.channel_user_id || null,
++ });
++ } catch { /* non-critical */ }
++ }
++ if (b.powerchat && b.channel_user_id && b.powerchat_chat) {
++ try {
++ const pc = require('../integrations/powerchat-platform');
++ const channelUserId = int(b.channel_user_id);
++ if (pc.channelRelayEnabled(channelUserId, b.stream_id || null)) {
++ let isSub = false;
++ try { isSub = !!(b.user_id && db.isActiveSubscriber(int(b.user_id), channelUserId)); } catch { /* */ }
++ const c = b.powerchat_chat;
++ pc.forwardChat(channelUserId, {
++ chatterName: c.chatterName,
++ externalChatterId: c.externalChatterId,
++ message: c.message,
++ avatarUrl: c.avatarUrl || undefined,
++ isModerator: !!c.isModerator,
++ isSubscriber: isSub,
++ });
++ }
++ } catch { /* non-critical */ }
++ }
++ res.json({ coin });
++});
++
++effectsRouter.post('/ai/mod-command', (req, res) => {
++ try {
++ const engine = require('../integrations/ai-chatbot-service');
++ const reply = engine.onModCommand
++ ? engine.onModCommand(req.body.channel_user_id || null, req.body.stream_id || null, Array.isArray(req.body.args) ? req.body.args : [], { by: req.body.by })
++ : 'AI viewers: command not supported by this engine.';
++ res.json({ reply: reply || null });
++ } catch (err) { fail(res, 400, err.message); }
++});
++
++// !hype / !beef / !arena: arena-chat answers the sender through Chat (sendToConn).
++effectsRouter.post('/arena-command', (req, res) => {
++ const client = req.body?.client || {};
++ const cs = chatServer();
++ const shim = {
++ sendTo: (_ws, payload) => cs.sendToConn(client.conn_id, payload),
++ broadcastToStream: (streamId, payload) => cs.broadcastToStream(streamId, payload),
++ };
++ let handled = false;
++ try { handled = require('../arena/arena-chat').handle(shim, null, client, String(req.body.cmd || ''), Array.isArray(req.body.parts) ? req.body.parts : []); } catch (e) { console.warn('[Arena] chat command:', e.message); }
++ res.json({ handled: !!handled });
++});
++
++// !sr / !queue / !np / !skip — the media queue is Live's.
++effectsRouter.post('/media-queue', async (req, res) => {
++ const b = req.body || {};
++ const mediaQueue = require('../media/media-queue');
++ const streamerId = int(b.streamerId);
++ if (!streamerId) return fail(res, 400, 'streamerId required');
++ try {
++ if (b.op === 'add') {
++ const request = await mediaQueue.addRequest({ streamerId, streamId: int(b.streamId) || null, userId: int(b.userId), username: String(b.username || ''), input: String(b.input || '') });
++ return res.json({ request });
++ }
++ if (b.op === 'state') return res.json({ state: mediaQueue.getState(streamerId) });
++ if (b.op === 'skip') {
++ const actor = actorOf(int(b.actorUserId));
++ const streamId = int(b.streamId) || null;
++ const allowed = actor && (actor.id === streamerId || permissions.isGlobalModOrAbove(actor) || (streamId && permissions.canModerateStream(actor, streamId)));
++ if (!allowed) return fail(res, 403, 'Only the streamer or a moderator can skip media.');
++ const ended = mediaQueue.finishCurrent(streamerId, 'skipped');
++ const next = mediaQueue.startNext(streamerId);
++ return res.json({ ended: ended || null, next: next || null });
++ }
++ fail(res, 400, 'unknown op');
++ } catch (err) {
++ fail(res, 400, err.message || 'Media command failed.');
++ }
++});
++
++// Cozmo / !say: the robot's control socket is in Live, keyed by the streamer's stream key.
++const HARDWARE_COMMANDS = new Set(['forward', 'backward', 'turn_left', 'turn_right', 'lift_up', 'lift_down', 'head_up', 'head_down']);
++effectsRouter.post('/hardware', (req, res) => {
++ const command = String(req.body?.command || '');
++ if (!HARDWARE_COMMANDS.has(command) && !/^say:[\s\S]{1,200}$/.test(command)) return fail(res, 400, 'unknown command');
++ const user = db.getUserById(int(req.body.streamer_user_id));
++ if (!user) return res.json({ ok: false, reason: 'no_user' });
++ const controlServer = require('../controls/control-server');
++ const hwWs = controlServer.hardwareClients.get(user.stream_key);
++ if (!hwWs || hwWs.readyState !== 1) return res.json({ ok: false, reason: 'no_hardware' });
++ hwWs.send(JSON.stringify({
++ type: 'command',
++ command,
++ from_user: String(req.body.from_user || ''),
++ timestamp: new Date().toISOString(),
++ }));
++ res.json({ ok: true });
++});
++
++// /paste from chat, through Live's pastes client (Community or Media per PASTES_AUTHORITY).
++effectsRouter.post('/paste', async (req, res) => {
++ const b = req.body || {};
++ try {
++ const paste = await require('../pastes-client').createPaste({
++ title: String(b.title || '').slice(0, 200),
++ content: String(b.content || ''),
++ language: b.language || 'auto',
++ visibility: b.visibility || 'public',
++ user_id: b.user_id || undefined,
++ stream_id: b.stream_id || undefined,
++ });
++ res.json({ paste: { slug: paste.slug } });
++ } catch (err) { fail(res, 502, err.message); }
++});
++
++effectsRouter.post('/translate', async (req, res) => {
++ let i18n;
++ try { i18n = require('../i18n/translate'); } catch { return res.json({ translation: null }); }
++ if (!i18n.available()) return res.json({ translation: null });
++ try {
++ const tr = await i18n.translateChatMessage(String(req.body?.text || ''), req.body?.channel_user_id || null);
++ res.json({ translation: tr && tr.text ? tr : null });
++ } catch { res.json({ translation: null }); }
++});
++
++effectsRouter.post('/notify/dm', (req, res) => {
++ try {
++ const { pushBulkNotification, actorInfo } = require('../utils/notify');
++ const sender = db.getUserById(int(req.body?.sender_id));
++ const otherIds = (Array.isArray(req.body?.recipient_ids) ? req.body.recipient_ids : []).map(Number).filter(Boolean).slice(0, 50);
++ const convId = int(req.body?.conversation_id);
++ if (otherIds.length && convId) {
++ const senderName = sender?.display_name || sender?.username || 'Someone';
++ // Generic preview — the message itself only travels over the live socket.
++ pushBulkNotification(otherIds, {
++ type: 'DIRECT_MESSAGE',
++ title: `Message from ${senderName}`,
++ message: 'Sent you a message',
++ url: `https://openvibe.live/dm/${convId}`,
++ ...actorInfo(sender),
++ });
++ }
++ } catch { /* non-critical */ }
++ res.json({ ok: true });
++});
++
++effectsRouter.post('/notify/dm-read', (req, res) => {
++ try {
++ const { markNotificationsRead } = require('../utils/notify');
++ markNotificationsRead(int(req.body?.user_id), 'DIRECT_MESSAGE', `%/dm/${int(req.body?.conversation_id)}`);
++ } catch { /* non-critical */ }
++ res.json({ ok: true });
++});
++
++// Channel sound files are mirrored to OpenVibe.Media from Live's copy of channel_sounds.
++effectsRouter.post('/asset-sync', (req, res) => {
++ try {
++ const sync = require('../media-proxy/asset-sync');
++ if (req.body?.op === 'syncSoon') sync.syncSoon();
++ else if (req.body?.op === 'remove-sound') {
++ const row = db.get('SELECT media_asset_id FROM channel_sounds WHERE id = ?', [int(req.body.asset_id)]);
++ if (row && row.media_asset_id) sync.removeAsset(row.media_asset_id);
++ }
++ } catch { /* best-effort */ }
++ res.json({ ok: true });
++});
++
++// ── The read mirror of Chat's tables ──────────────────────────────
++// Same tables, same ids. Upserts set only the columns Live's table has (Chat's *subject_id
++// columns are skipped; Live-only columns such as channel_sounds.media_asset_id are kept).
++const MIRROR_TABLES = {
++ chat_messages: ['id'],
++ dm_conversations: ['id'],
++ dm_participants: ['id'],
++ dm_messages: ['id'],
++ dm_blocks: ['id'],
++ tts_voice_overrides: ['identity_key'],
++ channel_sounds: ['id'],
++ relay_users: ['platform', 'username'],
++ hidden_relay_users: ['id'],
++ pending_ip_messages: ['id'],
++ stream_first_chats: ['chatter_key', 'channel_user_id'],
++ moderation_actions: ['id'],
++};
++const _cols = new Map();
++function liveColumns(table) {
++ if (!_cols.has(table)) _cols.set(table, new Set(db.getDb().prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));
++ return _cols.get(table);
++}
++
++function applyMirror(changes) {
++ const d = db.getDb();
++ let applied = 0;
++ const skipped = [];
++ // A copy of the authority's rows: foreign keys are the authority's business. Synchronous, so
++ // nothing else runs while they are off.
++ d.pragma('foreign_keys = OFF');
++ try {
++ d.transaction(() => {
++ for (const c of changes) {
++ const pk = MIRROR_TABLES[c && c.table];
++ if (!pk) { skipped.push({ table: c && c.table, reason: 'not a mirrored table' }); continue; }
++ const have = liveColumns(c.table);
++ try {
++ if (c.op === 'delete') {
++ d.prepare(`DELETE FROM ${c.table} WHERE ${pk.map((k) => `${k} = ?`).join(' AND ')}`).run(...pk.map((k) => c.pk[k]));
++ } else if (c.op === 'upsert' && c.row) {
++ const cols = Object.keys(c.row).filter((k) => have.has(k) && /^[a-z_]+$/.test(k));
++ const upd = cols.filter((k) => !pk.includes(k));
++ d.prepare(`INSERT INTO ${c.table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})
++ ON CONFLICT(${pk.join(', ')}) DO ${upd.length ? `UPDATE SET ${upd.map((k) => `${k} = excluded.${k}`).join(', ')}` : 'NOTHING'}`)
++ .run(...cols.map((k) => c.row[k]));
++ } else { skipped.push({ table: c.table, reason: 'bad change' }); continue; }
++ applied++;
++ } catch (err) {
++ skipped.push({ table: c.table, pk: c.pk || (c.row && pk.map((k) => c.row[k])), reason: err.message });
++ }
++ }
++ })();
++ } finally {
++ d.pragma('foreign_keys = ON');
++ }
++ return { applied, skipped };
++}
++
++effectsRouter.post('/mirror', (req, res) => {
++ const changes = Array.isArray(req.body?.changes) ? req.body.changes.slice(0, 1000) : [];
++ const out = applyMirror(changes);
++ if (out.skipped.length) console.warn(`[ChatMirror] ${out.skipped.length} change(s) not applied:`, JSON.stringify(out.skipped.slice(0, 3)));
++ res.json({ ok: true, ...out });
++});
++
++module.exports = { contextRouter, effectsRouter, applyMirror, userProjection, MIRROR_TABLES };
+diff --git a/server/index.js b/server/index.js
+index 894e159..2763945 100644
+--- a/server/index.js
+@@ -585,6 +585,11 @@ app.get('/internal/analytics-summary', (req, res) => {
+ } catch (err) { res.status(500).json({ ok: false, error: err.message }); }
+ });
+
++// OpenVibe.Chat reads Live data and asks for side effects here, with Network service tokens
++// (server/chat/live-context-routes.js); mounted before the X-Internal-Key routes below.
++const chatLiveContext = require('./chat/live-context-routes');
++app.use('/internal/chat-context', chatLiveContext.contextRouter);
++app.use('/internal/chat-effects', chatLiveContext.effectsRouter);
+ app.use('/internal', require('./internal/routes'));
+
+
+@@ -653,7 +658,11 @@ app.use('/api/streams', streamRoutes);
+ // Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).
+ const liveEvents = require('./streaming/live-events');
+ app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));
+-app.use('/api/chat', chatRoutes);
++// CHAT_AUTHORITY=chat: OpenVibe.Chat serves the chat REST routes (nginx sends /api/chat/, /api/dm/,
++// /api/tts/, /api/sounds there). One that still reaches Live must not write to the mirror.
++const chatRemote = require('./chat/chat-authority').isRemote();
++const chatMoved = (req, res) => res.status(503).set('Retry-After', '2').json({ error: 'Chat is served by OpenVibe.Chat' });
++app.use('/api/chat', chatRemote ? chatMoved : chatRoutes);
+ app.use('/api/funds', monetizationRoutes);
+ app.use('/api/coins', coinsRoutes);
+ app.use('/api/payments', require('./monetization/payments-routes'));
+@@ -675,7 +684,7 @@ app.use('/api/restream', restreamRoutes);
+ app.use('/api/thumbnails', thumbnailRoutes);
+ app.use('/api/themes', themeRoutes);
+ app.use('/api/emotes', emoteRoutes);
+-app.use('/api/sounds', require('./chat/sounds-routes'));
++app.use('/api/sounds', chatRemote ? chatMoved : require('./chat/sounds-routes'));
+ app.use('/api/ai-viewers', require('./ai/viewers/routes'));
+ app.use('/api/powerchat', require('./integrations/powerchat-routes'));
+ // Game & Canvas — migrated to openvibe.games
+@@ -695,9 +704,9 @@ app.use('/api/media', require('./media/routes'));
+ app.use('/api/img-proxy', require('./media/external-image-proxy'));
+ app.use('/api/vibe-coding', vibeCodingRoutes);
+ const ttsRoutes = require('./chat/tts-routes');
+-app.use('/api/tts', ttsRoutes);
++app.use('/api/tts', chatRemote ? chatMoved : ttsRoutes);
+ const dmRoutes = require('./chat/dm-routes');
+-app.use('/api/dm', dmRoutes);
++app.use('/api/dm', chatRemote ? chatMoved : dmRoutes);
+ const analyticsRoutes = require('./streaming/analytics-routes');
+ app.use('/api/analytics', analyticsRoutes);
+ const newsRoutes = require('./news/news-routes');
+@@ -1081,6 +1090,8 @@ async function start() {
+ // Initialize DM tables
+ const dm = require('./chat/dm');
+ dm.ensureTables();
++ // Back from CHAT_AUTHORITY=chat (rollback): chat writes OpenVibe.Chat never acknowledged land here.
++ if (!require('./chat/chat-authority').isRemote()) require('./chat/chat-remote').drainToLocal();
+ // Migrate: add last_heartbeat column if missing
+ try { db.run("ALTER TABLE streams ADD COLUMN last_heartbeat DATETIME"); console.log('[DB] Added last_heartbeat column'); } catch { /* already exists */ }
+ // Migrate: add theme_id to users table if missing
+diff --git a/server/internal/routes.js b/server/internal/routes.js
+index 540fcb2..d962f81 100644
+--- a/server/internal/routes.js
+@@ -17,6 +17,11 @@ function requireInternalKey(req, res, next) {
+
+ router.use(requireInternalKey);
+
++// CHAT_AUTHORITY=chat: OpenVibe.Chat caches users; a role or avatar pushed here reaches it at once.
++function notifyChat(userId) {
++ try { const cs = require('../chat/chat-server'); if (cs.remote) cs.userChanged(userId); } catch { /* non-critical */ }
++}
++
+ // ── Site copy for the shared footer (OpenVibe.Network → here, once a day) ─────────────
+ // The Network sends facts about each site plus an allow-list of link ids; the model writes a
+ // short blurb per site and PICKS ids from that list. It never returns URLs or markup, so nothing
+@@ -81,6 +86,7 @@ router.post('/user-avatar', (req, res) => {
+ if (!user && username) user = db.getUserByUsername(username);
+ if (!user) return res.status(404).json({ ok: false, error: 'user not found' });
+ if ((user.avatar_url || null) !== url) db.updateUserAvatar(user.id, url, null);
++ notifyChat(user.id);
+ return res.json({ ok: true, id: user.id, changed: (user.avatar_url || null) !== url });
+ } catch (err) {
+ console.error('[Internal] user-avatar error:', err.message);
+@@ -107,6 +113,7 @@ router.post('/user-role', (req, res) => {
+ // Never strip the owner's admin role via a role push (is_owner is local).
+ const finalRole = (user.is_owner && role !== 'admin') ? 'admin' : role;
+ db.getDb().prepare('UPDATE users SET role = ? WHERE id = ?').run(finalRole, user.id);
++ notifyChat(user.id);
+ console.log(`[Internal] role push: ${user.username} -> ${finalRole}`);
+ return res.json({ ok: true, id: user.id, username: user.username, role: finalRole });
+ } catch (err) {
+diff --git a/server/net/service-guard.js b/server/net/service-guard.js
+new file mode 100644
+index 0000000..e4b13d4
+--- /dev/null
+@@ -0,0 +1,45 @@
++'use strict';
++/**
++ * Live as a receiver of service tokens (roadmap Wave 6).
++ *
++ * guard(capability) checks a Network client-credentials token (RS256, contract
++ * identity.service-token-claims@1) for audience openvibe.live on Live's own internal routes —
++ * today the ones OpenVibe.Chat calls (/internal/chat-context/*, /internal/chat-effects/*). The
++ * token is verified with the same Network key Live verifies user JWTs with.
++ *
++ * Chat's capabilities (live.chat_context.read, live.chat_effects.write, live.chat_mirror.write)
++ * are proposals not yet in the openvibe-contracts registry, whose requireCapability() refuses
++ * unknown ids; until they are registered the grant is checked with capabilities.grants() and the
++ * guard switches to capabilities.check() by itself once the registry knows the id. Like
++ * X-Internal-Key routes, these are loopback-only: anything that came through nginx is refused.
++ */
++const { serviceAuth, capabilities, http } = require('openvibe-contracts');
++const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');
++
++const AUDIENCE = 'openvibe.live';
++
++function viaProxy(req) {
++ return !!(req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']);
++}
++
++function guard(capability) {
++ const registered = !!capabilities.get(capability);
++ return function liveServiceGuard(req, res, next) {
++ const ctx = http.requestContext(req.headers);
++ if (viaProxy(req)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'internal routes are loopback-only', ctx });
++ const auth = String(req.headers.authorization || '');
++ if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token', ctx });
++ const publicKey = getNetworkPublicKey();
++ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded', ctx });
++ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), { publicKey, issuer: getNetworkIssuer(), audience: AUDIENCE });
++ if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx });
++ const c = registered
++ ? capabilities.check(r.claims, capability)
++ : (capabilities.grants(r.claims.cap, capability) ? { allowed: true } : { allowed: false, code: 'capability.denied', reason: `${capability} not granted` });
++ if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason, ctx });
++ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
++ next();
++ };
++}
++
++module.exports = { guard, AUDIENCE };
+diff --git a/test/chat-context.test.js b/test/chat-context.test.js
+new file mode 100644
+index 0000000..1d935cc
+--- /dev/null
+@@ -0,0 +1,283 @@
++'use strict';
++
++// Chat moved to OpenVibe.Chat (roadmap Wave 6). What Live answers it on /internal/chat-context/*
++// and /internal/chat-effects/* (service tokens, capabilities, re-checked moderators, the read
++// mirror), and the chat-server proxy Live's own modules get with CHAT_AUTHORITY=chat (ordered
++// bridge calls, placeholder ids for forwarded inserts, presence reads). Against stub Network and
++// Chat servers.
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const { serviceAuth } = require('openvibe-contracts');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-chatctx-'));
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++fs.writeFileSync(path.join(tmp, 'network.pem'), keys.publicKey);
++fs.mkdirSync(path.join(tmp, 'sounds'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.OV_NETWORK_PUBLIC_KEY = path.join(tmp, 'network.pem');
++process.env.OV_NETWORK_URL = 'https://openvibe.network';
++process.env.OV_OAUTH_CLIENT_ID = 'live';
++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
++process.env.SOUNDS_PATH = path.join(tmp, 'sounds');
++process.env.CHAT_AUTHORITY = 'chat';
++const quiet = console.log;
++console.log = () => {};
++console.warn = () => {};
++
++const ISS = 'https://openvibe.network';
++const now = () => Math.floor(Date.now() / 1000);
++let jti = 0;
++function serviceToken(cap, { aud = 'openvibe.live', sub = 'svc:chat' } = {}) {
++ return serviceAuth.signServiceToken({ iss: ISS, sub, actor_type: 'service', aud: [aud], cap, iat: now(), exp: now() + 300, jti: `tok_test_${++jti}` }, keys.privateKey);
++}
++const READ = serviceToken(['live.chat_context.read']);
++const WRITE = serviceToken(['live.chat_effects.write']);
++const MIRROR = serviceToken(['live.chat_mirror.write']);
++
++// Stub Network (Live's own service token for audience openvibe.chat) and stub Chat (the bridge).
++const bridgeCalls = [];
++const network = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ res.setHeader('Content-Type', 'application/json');
++ if (req.url === '/oauth/token') {
++ const f = new URLSearchParams(raw);
++ return res.end(JSON.stringify({ access_token: serviceToken(['chat.live_bridge.write', 'chat.presence.read'], { aud: f.get('audience'), sub: 'svc:live' }), expires_in: 300 }));
++ }
++ res.statusCode = 404; res.end('{}');
++ });
++});
++const chat = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ res.setHeader('Content-Type', 'application/json');
++ assert.ok(String(req.headers.authorization || '').startsWith('Bearer '), 'Live calls Chat with a service token');
++ if (req.url === '/internal/live/calls') {
++ const body = JSON.parse(raw);
++ bridgeCalls.push(body);
++ return res.end(JSON.stringify({ ok: true, results: body.ops.map((o) => ({ seq: o.seq, ok: true })) }));
++ }
++ if (req.url === '/internal/live/presence') {
++ return res.end(JSON.stringify({ total: 7, streams: { 1: 3 }, slow_mode: { 1: 5000 }, users: [{ user_id: 3, ip: '198.51.100.3', stream_id: 1 }], anons: [{ anon_id: 'anon9', ip: '203.0.113.9', stream_id: 1 }] }));
++ }
++ res.statusCode = 404; res.end('{}');
++ });
++});
++
++const listen = (s) => new Promise((r) => s.listen(0, '127.0.0.1', () => r(s.address().port)));
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++
++(async () => {
++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${await listen(network)}`;
++ process.env.OV_CHAT_INTERNAL_URL = `http://127.0.0.1:${await listen(chat)}`;
++
++ const db = require('../server/db/database');
++ db.initDb();
++ require('../server/chat/dm').ensureTables();
++ const d = db.getDb();
++ const mkUser = (username, role = 'user', extra = {}) => {
++ const id = db.createUser({ username, email: `${username}@example.test`, password_hash: '!x', display_name: username.toUpperCase(), stream_key: `key-${username}` }).lastInsertRowid;
++ d.prepare('UPDATE users SET role = ?, is_owner = ? WHERE id = ?').run(role, extra.owner ? 1 : 0, id);
++ return Number(id);
++ };
++ const owner = mkUser('owner', 'admin', { owner: true });
++ const admin = mkUser('admin2', 'admin');
++ const streamer = mkUser('streamer', 'streamer');
++ const mod = mkUser('moddy');
++ const viewer = mkUser('viewer');
++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '501', 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ')").run(viewer);
++ db.createChannel({ user_id: streamer, title: 'Streamer TV' });
++ const channel = db.getChannelByUserId(streamer);
++ const streamId = Number(db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' }).lastInsertRowid);
++ db.addChannelModerator ? db.addChannelModerator(channel.id, mod, streamer) : d.prepare('INSERT INTO channel_moderators (channel_id, user_id, added_by) VALUES (?, ?, ?)').run(channel.id, mod, streamer);
++ d.prepare('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)').run(viewer, streamer);
++ db.setSetting('tts_enabled', 'true');
++ db.setSetting('stripe_secret_key', 'sk_live_never_shared');
++
++ const express = require('express');
++ const routes = require('../server/chat/live-context-routes');
++ const app = express();
++ app.use(express.json());
++ app.use('/internal/chat-context', routes.contextRouter);
++ app.use('/internal/chat-effects', routes.effectsRouter);
++ const port = await listen(http.createServer(app));
++ const call = async (method, p, { token, body, headers = {} } = {}) => {
++ const res = await fetch(`http://127.0.0.1:${port}${p}`, {
++ method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers },
++ body: body ? JSON.stringify(body) : undefined,
++ });
++ const text = await res.text();
++ let json = null; try { json = JSON.parse(text); } catch { /* */ }
++ return { status: res.status, body: json, text };
++ };
++
++ let exit = 0;
++ try {
++ // 1. Service tokens only: none, wrong audience, missing capability, through nginx.
++ assert.strictEqual((await call('GET', '/internal/chat-context/users')).status, 401);
++ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: serviceToken(['live.chat_context.read'], { aud: 'openvibe.media' }) })).status, 401);
++ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: WRITE })).status, 403);
++ assert.strictEqual((await call('GET', '/internal/chat-context/users', { token: READ, headers: { 'X-Forwarded-For': '1.2.3.4' } })).status, 403, 'loopback only');
++ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: READ, body: { user_id: viewer, color: '#ff00ff' } })).status, 403, 'reads cannot write');
++
++ // 2. Users projection: paged, with the Network subject, never secrets.
++ const users = (await call('GET', '/internal/chat-context/users?after_id=0&limit=2', { token: READ })).body.rows;
++ assert.strictEqual(users.length, 2);
++ const all = (await call('GET', `/internal/chat-context/users?after_id=${users[1].id}`, { token: READ })).body.rows;
++ const v = all.find((u) => u.id === viewer);
++ assert.strictEqual(v.subject_id, 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ');
++ for (const u of users.concat(all)) { assert.ok(!('email' in u) && !('password_hash' in u) && !('stream_key' in u), 'no secrets in the projection'); }
++
++ // 3. Token resolution follows Live's rules and carries the subject.
++ const userJwt = jwt.sign({ sub: '501', username: 'viewer', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' }, keys.privateKey, { algorithm: 'RS256', issuer: ISS, expiresIn: 600 });
++ const auth = (await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: userJwt } })).body;
++ assert.strictEqual(auth.user.id, viewer);
++ assert.strictEqual(auth.user.subject_id, 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ');
++ assert.strictEqual(auth.user.auth_source, 'network');
++ assert.ok(auth.expires_at);
++ assert.deepStrictEqual((await call('POST', '/internal/chat-context/auth', { token: READ, body: { token: 'garbage' } })).body, { user: null, reason: 'invalid' });
++
++ // 4. Streams, channel policy (settings + moderators + language), follows, bans with version.
++ const s = (await call('GET', `/internal/chat-context/streams/${streamId}`, { token: READ })).body;
++ assert.strictEqual(s.stream.user_id, streamer);
++ assert.strictEqual(s.owner.username, 'streamer');
++ assert.strictEqual(s.channel.id, channel.id);
++ assert.ok(!('stream_key' in s.stream));
++ const active = (await call('GET', '/internal/chat-context/streams/active', { token: READ })).body.rows;
++ assert.ok(active.some((r) => r.id === streamId && r.is_live === 1));
++ const policy = (await call('GET', `/internal/chat-context/channels/${channel.id}/policy`, { token: READ })).body;
++ assert.deepStrictEqual(policy.moderator_ids, [mod]);
++ assert.strictEqual(policy.settings.allow_anonymous, 1, 'defaults when the channel has no row');
++ assert.deepStrictEqual((await call('GET', `/internal/chat-context/users/${viewer}/follows`, { token: READ })).body.streamer_ids, [streamer]);
++ const b1 = (await call('GET', '/internal/chat-context/bans', { token: READ })).body;
++ assert.deepStrictEqual(b1.bans, []);
++ assert.strictEqual((await call('GET', `/internal/chat-context/bans?version=${encodeURIComponent(b1.version)}`, { token: READ })).body.unchanged, true);
++ const settings = (await call('GET', '/internal/chat-context/settings', { token: READ })).body.settings;
++ assert.strictEqual(settings.tts_enabled, db.getSetting('tts_enabled'), 'typed like Live\'s getSetting');
++ assert.ok(!('stripe_secret_key' in settings), 'only chat settings leave Live');
++
++ // 5. Effects need CHAT_AUTHORITY=chat.
++ process.env.CHAT_AUTHORITY = '';
++ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 409);
++ process.env.CHAT_AUTHORITY = 'chat';
++ assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 200);
++ assert.strictEqual(db.getUserById(viewer).profile_color, '#ff00ff');
++
++ // 6. Bans: Live re-checks the moderator; the rows are exactly chat-server's.
++ const ban = (body) => call('POST', '/internal/chat-effects/ban', { token: WRITE, body });
++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: viewer, moderation_stream_id: streamId, stream_id: streamId, user_id: mod })).status, 403, 'a viewer cannot ban');
++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: owner })).status, 403, 'a channel mod cannot ban an admin');
++ assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer, reason: 'Banned by moderator', banned_by: mod })).status, 200);
++ assert.ok(db.isUserBanned(viewer, streamId));
++ const b2 = (await call('GET', `/internal/chat-context/bans?version=${encodeURIComponent(b1.version)}`, { token: READ })).body;
++ assert.strictEqual(b2.bans.length, 1, 'the version moved with the table');
++ assert.strictEqual((await ban({ action: 'unban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: viewer })).status, 200);
++ assert.ok(!db.isUserBanned(viewer, streamId));
++
++ // 7. TTS settings: admins; credentials only the owner.
++ const put = (actor, settingsBody) => call('POST', '/internal/chat-effects/site-settings', { token: WRITE, body: { actor_user_id: actor, settings: settingsBody } });
++ assert.strictEqual((await put(viewer, { tts_enabled: 'false' })).status, 403);
++ assert.strictEqual((await put(admin, { tts_max_length: 300, tts_google_api_key: 'admin-cannot' })).body.updated, 1);
++ assert.notStrictEqual(db.getSetting('tts_google_api_key'), 'admin-cannot', 'an admin cannot set a credential');
++ assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);
++ assert.strictEqual(db.getSetting('tts_google_api_key'), 'owner-can');
++
++ // 8. Slow mode persistence and alert sounds: only for people who may.
++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: viewer, fields: { slow_mode_seconds: 9 } } })).status, 403);
++ assert.strictEqual((await call('POST', '/internal/chat-effects/channel-settings', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, fields: { slow_mode_seconds: 9 } } })).status, 200);
++ assert.strictEqual(db.getChannelModerationSettings(channel.id).slow_mode_seconds, 9);
++ const snd = path.join(tmp, 'sounds', 'alert.mp3'); fs.writeFileSync(snd, 'x');
++ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: mod, kind: 'donation', url: snd } })).status, 403, 'only the channel owner');
++ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: '/etc/passwd' } })).status, 400, 'files stay in the sounds dir');
++ assert.strictEqual((await call('POST', '/internal/chat-effects/alert-sound', { token: WRITE, body: { channel_id: channel.id, actor_user_id: streamer, kind: 'donation', url: snd } })).status, 200);
++
++ // 9. The read mirror: same ids, Chat-only columns ignored, Live-only columns kept.
++ const mirror = (changes) => call('POST', '/internal/chat-effects/mirror', { token: MIRROR, body: { changes } });
++ assert.strictEqual((await call('POST', '/internal/chat-effects/mirror', { token: WRITE, body: { changes: [] } })).status, 403, 'mirror has its own capability');
++ let m = await mirror([{ table: 'chat_messages', op: 'upsert', row: { id: 900001, stream_id: streamId, channel_user_id: streamer, user_id: viewer, username: 'VIEWER', message: 'hi from chat', message_type: 'chat', is_global: 0, is_deleted: 0, timestamp: '2026-09-22 10:00:00', subject_id: 'usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ' } }]);
++ assert.strictEqual(m.body.applied, 1);
++ assert.strictEqual(db.getChatMessageById(900001).message, 'hi from chat');
++ // Columns only Live's copy has (media-proxy/asset-sync adds them at start).
++ for (const c of ['media_url TEXT', 'media_asset_id INTEGER']) { try { d.exec(`ALTER TABLE channel_sounds ADD COLUMN ${c}`); } catch { /* present */ } }
++ d.prepare("INSERT INTO channel_sounds (id, channel_owner_id, command, url, media_asset_id) VALUES (77, ?, 'honk', '/x.mp3', 555)").run(streamer);
++ m = await mirror([{ table: 'channel_sounds', op: 'upsert', row: { id: 77, channel_owner_id: streamer, command: 'honk2', url: '/x.mp3', created_by_subject_id: null } }, { table: 'chat_messages', op: 'delete', pk: { id: 900001 } }, { table: 'users', op: 'delete', pk: { id: viewer } }]);
++ assert.strictEqual(m.body.applied, 2);
++ assert.strictEqual(m.body.skipped.length, 1, 'only chat tables are mirrored');
++ const s77 = d.prepare('SELECT command, media_asset_id FROM channel_sounds WHERE id = 77').get();
++ assert.deepStrictEqual(s77, { command: 'honk2', media_asset_id: 555 });
++ assert.strictEqual(db.getChatMessageById(900001), undefined);
++ assert.ok(db.getUserById(viewer), 'users are never touched');
++
++ // 10. The proxy Live's modules get: ordered bridge calls, placeholders, presence.
++ const chatServer = require('../server/chat/chat-server');
++ assert.strictEqual(chatServer.remote, true);
++ chatServer.init();
++ const before = d.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n;
++ const saved = db.saveChatMessage({ stream_id: streamId, user_id: null, username: 'Bot', message: 'beep', message_type: 'chat', source_platform: 'ai' });
++ assert.ok(saved.lastInsertRowid <= -(2 ** 40), 'a placeholder id');
++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_messages').get().n, before, 'inserts happen in Chat, not here');
++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 1, 'kept until Chat acknowledges');
++ chatServer.broadcastToStream(streamId, { type: 'chat', id: saved.lastInsertRowid, message: 'beep' });
++ chatServer.synthesizeAndBroadcastTTS(streamId, 'Bot', 'beep', null, null, 'aibot:bot', null, `m${saved.lastInsertRowid}`);
++ await chatServer.flush();
++ await sleep(100);
++ const ops = bridgeCalls.flatMap((c) => c.ops);
++ assert.deepStrictEqual(ops.map((o) => o.op), ['db', 'broadcastToStream', 'synthesizeAndBroadcastTTS']);
++ assert.strictEqual(ops[0].args[0], 'saveChatMessage');
++ assert.strictEqual(ops[0].ref, saved.lastInsertRowid);
++ assert.ok(/^live:\d+$/.test(ops[0].key), 'forwarded writes carry an idempotency key');
++ assert.strictEqual(ops[1].args[1].id, saved.lastInsertRowid);
++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 0, 'acknowledged writes leave the outbox');
++ // Deletes run on the mirror at once (their ids are returned) and are forwarded too.
++ d.prepare("INSERT INTO chat_messages (id, user_id, username, message) VALUES (900002, ?, 'MODDY', 'x')").run(mod);
++ assert.deepStrictEqual(db.deleteUserChatMessages(mod, {}), [900002]);
++ await chatServer.flush();
++ assert.deepStrictEqual(bridgeCalls.at(-1).ops[0].args.slice(0, 2), ['deleteUserChatMessages', mod]);
++ // Live's own writes to data Chat caches (dashboard mods, IP approvals) tell Chat to reload it.
++ db.addChannelModerator(channel.id, viewer, streamer);
++ await chatServer.flush();
++ assert.deepStrictEqual(bridgeCalls.at(-1).ops.map((o) => [o.op, ...o.args]), [['invalidate', 'channel', channel.id]]);
++ assert.ok(d.prepare('SELECT 1 FROM channel_moderators WHERE channel_id = ? AND user_id = ?').get(channel.id, viewer), 'the write itself still happens in Live');
++ // The /api/mod global delete loops over chatServer.clients: one pseudo-socket reaches everyone.
++ for (const [ws] of chatServer.clients) { if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'delete-messages', ids: [1] })); }
++ await chatServer.flush();
++ assert.strictEqual(bridgeCalls.at(-1).ops[0].op, 'broadcastAllRaw');
++ // Synchronous reads come from Chat's presence snapshot.
++ await sleep(200);
++ assert.strictEqual(chatServer.getTotalConnections(), 7);
++ assert.strictEqual(chatServer.getStreamViewerCount(1), 3);
++ assert.strictEqual(chatServer.slowModeByStream.get(1), 5000);
++ assert.strictEqual(chatServer.getConnectedUserIp(3), '198.51.100.3');
++ assert.strictEqual(chatServer.findClientByAnonId('anon9', 1).ip, '203.0.113.9');
++ assert.strictEqual(chatServer.findClientByAnonId('anon9', 2), null);
++ // A /ws/chat upgrade that still lands on Live is refused, not served from the mirror.
++ let written = '';
++ chatServer.handleUpgrade({}, { write: (x) => { written += x; }, destroy() {} });
++ assert.match(written, /^HTTP\/1\.1 503/);
++ chatServer.close();
++
++ // Rollback: writes Chat never acknowledged are applied to Live's own tables.
++ d.prepare("INSERT INTO chat_bridge_outbox (boot, ref, op, args) VALUES ('old', -1, 'db', ?)").run(JSON.stringify(['recordFirstChat', 'user:77', streamer]));
++ chatServer._restoreDb();
++ assert.strictEqual(require('../server/chat/chat-remote').drainToLocal({ log() {}, warn() {} }), 1);
++ assert.ok(d.prepare("SELECT 1 FROM stream_first_chats WHERE chatter_key = 'user:77'").get());
++ assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM chat_bridge_outbox').get().n, 0);
++
++ quiet('chat context (Live side of OpenVibe.Chat): all checks passed');
++ } catch (err) {
++ console.error(err);
++ exit = 1;
++ } finally {
++ try { db.close(); } catch { /* */ }
++ fs.rmSync(tmp, { recursive: true, force: true });
++ process.exit(exit);
++ }
++})();