openvibers/openvibe.live AGENTS.md
AGENTS.md
AGENTS.md — OpenVibe.Live
Project Overview
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, PostgreSQL (openvibe-sdk/db; embedded PGlite in development and tests; on PostgreSQL since 2026-10-08). Part of the OpenVibe network with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
Network context: SSO/OAuth2 + the OpenCoins wallet come from OpenVibe.Network (server/monetization/wallet-client.js). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in OpenVibe.Media — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at POST /internal/media-events (the only path; the direct webhook was retired 2026-10-10). The legacy local vods/clips/pastes/paste_likes/paste_comments tables are FROZEN: never read or write them (test/frozen-tables.test.js enforces it; ask Media or Community through server/media-proxy/lookups.js, media-client.js, pastes-client.js); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the old local comments table is not in the PostgreSQL database either; Chat owns every chat table — the staged set (channel_moderators, channel_moderation_settings, emotes, user_tags, chat_ai_summaries, chat_timeline_events; Live reads those only through server/chat/moderation-client.js, capability chat.moderation.read, cached 30 s, and the summaries through server/chat/insight-client.js, cached 60 s) and the twelve the chat cutover imports covered (chat_messages, the DM tables, tts_voice_overrides, channel_sounds, relay_users, hidden_relay_users, pending_ip_messages, stream_first_chats, moderation_actions); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through server/chat/chat-reads.js, and the twelve tables were never created on PostgreSQL (migrations/0002_live.sql leaves them out; the SQLite-era boot migration 007 dropped them before the move) — test/chat-tables-dropped.test.js guards that nothing under server/ names them and no migration creates one; Live-owned AI/transcript state for Media-hosted content lives in vod_ai_state/clip_ai_state. See ../CONTRACTS.md for the binding inter-service contracts. Money: OpenVibe.Billing is the only money ledger since the cutover of 2026-10-17 ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)): every checkout, donation, cashout, recycle, subscription, balance and entitlement is a Billing call, provider and PowerChat webhooks go to Billing (Live's answer 410), and Live's old openvibe_bucks_*/openvibe_coins_balance columns and transactions/payment_orders/subscriptions tables are gone (contract migration 0016_drop_old_money_ledger; test/money-tables-gone.test.js). The owner-only money_writes_frozen flag ([server/monetization/money-authority.js](server/monetization/money-authority.js), /api/admin/money) refuses money actions. Cosmetics: OpenVibe.Inventory owns them since 2026-10-09 (ADR-054: live.* items issued by service:live, Live's item ids as aliases); [server/monetization/cosmetics.js](server/monetization/cosmetics.js) reads and writes through [inventory-client.js](server/monetization/inventory-client.js) (Chat's decor lookups batch 100 people per read) and Live keeps only the catalog's renderers. Live's user_cosmetics/user_equipped/user_equipped_tag are read by nothing (test/cosmetics-tables-gone.test.js) and are dropped by a contract migration.
Commands
npm run dev # Start dev server (NODE_ENV=development)
npm start # Start production server (node server/index.js)
# The schema is migrations/NNNN_*.sql, applied at boot (DATABASE_DIRECT_URL, owner); without DATABASE_URL, development uses PGlite under DATA_DIR
node --check <file.js> # Syntax check (no linter configured)
npm test # All unit/security/migration/deploy tests + size budgets (test/run.js), on PGlite
npm run test:pg # The same on the PostgreSQL + PgBouncer containers (openvibe-sdk scripts/test-services.sh up)
node test/<file>.test.js # One test
BASE=http://127.0.0.1:3000 npm run test:browser # Browser smoke (running server + Chrome); add -- --a11y for axe (WCAG 2.1 AA)
No build step. Frontend is plain JS served directly — no bundler, no transpiler. Asset URLs are content-hashed at serve time (server/web/assets.js) — never add ?v= by hand.
Deploy: production runs the release layout (/opt/openvibe.live/current → releases/<time>-<sha>; deploy with cd /opt/openvibe.live/current && sudo deploy/scripts/deploy.sh), env /etc/openvibe/live.env, unit openvibe-live.service. Static-only changes deploy without a restart. See [docs/deploy.md](docs/deploy.md).
Architecture at a Glance
- Entry: [server/index.js](server/index.js) — Express app, middleware, route mounting, WS upgrade handler, sub-service init
- Config: [server/config.js](server/config.js) reads
.env([.env.example](.env.example)) - Database: [server/db/database.js](server/db/database.js) — all queries (async,
openvibe-sdk/db), schema in [migrations/](migrations/) (0001_analytics.sql,0002_live.sql) - Auth: [server/auth/auth.js](server/auth/auth.js) — RS256 JWT from openvibe.tools SSO +
hbt_API tokens - Permissions: [server/auth/permissions.js](server/auth/permissions.js) — role hierarchy:
user < streamer < global_mod < admin - Frontend shell: [public/index.html](public/index.html) — navbar and
<section id="page-*">shells (page markup, the home page's included, is inpublic/fragments/, inlined on its own route); routing viahistory.pushStatein [public/js/app.js](public/js/app.js) - Route loading: [public/features.json](public/features.json) maps routes → features (fragment in
public/fragments/, CSS inpublic/css/features/, scripts, deps, stubs); [public/js/ov-loader.js](public/js/ov-loader.js) loads them. New page code goes in a feature, not inindex.html/coreapp.js. See [docs/architecture.md](docs/architecture.md#frontend-loading).
Each feature lives in its own server/<feature>/ directory with routes.js + service files. Frontend: one JS file per feature in public/js/, registered in public/features.json.
Conventions
- CommonJS (
require/module.exports) everywhere. No ES modules except dynamicimport()for mediasoup-client. - Style: 4-space indent, single quotes, semicolons. No linter/formatter configured.
- Naming:
camelCasefor JS,snake_casefor PostgreSQL columns/tables. - DB access:
database.jshelpers, every one async (await db.getUserById(),await db.run(),await db.get(),await db.all()); an INSERT that needs its id saysRETURNING id. Never leave a write un-awaited when anything after it depends on it: the pool runs un-awaited work out of order (PGlite does not, so onlynpm run test:pgshows it). - Auth middleware:
requireAuthfromauth.js. Permission checks viapermissions.js. - DB migrations: a new
migrations/NNNN_name.sqlwith its-- phase: expand|migrate|contractheader (a contract names its expand with-- after: NNNNand waits out the N-1 window); never edit one that has run. No table is created at runtime. - Public responses: serialize through [server/web/serializers.js](server/web/serializers.js) — never return raw
managed_streams/usersrows. - TURN: ICE lists come from [server/net/turn.js](server/net/turn.js); set
TURN_AUTH_SECRET(coturnuse-auth-secret) for short-lived credentials. - Outbound fetches of user-chosen URLs: [server/net/egress.js](server/net/egress.js) only. Background loops:
server/utils/jobs.js. - Files and restore drills: every file location comes from [server/paths.js](server/paths.js) (
DATA_DIR), never a literal./data; the database isDATABASE_URL(drill mode refuses production'sov_live). Anything started at boot or at module load (timers, listeners, sockets, jobs, outbound calls) must stay off underLIVE_DRILL([server/drill.js](server/drill.js));test/drill-mode.test.jsfails on any new one. - WebSocket servers: Each has
init(server)andhandleUpgrade(req, socket, head)methods. - Frontend globals:
currentUser,api(),navigate(),handleLinkClick(). Cross-component sync viaCustomEvent(e.g.,openvibe-auth-changed).
Key Pitfalls
- No build step: Changes to
public/take effect on deploy without a restart; caching follows content hashes.npm testfails if the home page's size budgets grow (scripts/perf/check-budgets.js). - First-paint CSS: public routes block only on
/css/style.core.css, a subset ofstyle.cssgenerated frompublic/css/critical-selectors.json(server/web/critical-css.js); the fullstyle.cssfollows without blocking. After changing how a public page looks, re-runnode scripts/perf/critical-css.js(docs/performance-audit.md). - Inline handlers on lazy features: a function called from
onclick=in markup that exists before its feature loads must be listed in that feature'sstubs. - New page routes: the SPA fallback answers 404 for any path [server/web/page-status.js](server/web/page-status.js) does not know. A new top-level route goes in both
routeFromURL()and that file's page list. - innerHTML usage: Frontend has heavy
innerHTML— prefer DOM node creation for new code to avoid XSS. - Public AI text may describe people, and nudity is fine (owner, 2026-10-31: the old no-looks rule is gone,
openvibe-sdk/no-looksis deprecated; "dick is art… titties is art… stop being against art on my platform"). What stays: the AI never picks, posts or auto-clips a moment whose text says it shows sex acts or porn (server/ai/explicit.js; porn would need an adults-only section with age verification), and the home tagline never pairs a child with bare skin (server/ai/hero-copy.js). Anything sexual involving a minor is illegal and always blocked. - WebSocket auth lifecycle: WS connections can start anonymous and upgrade via
joinmessage. On account switch, the socket must be rebuilt (not just re-joined) — seeopenvibe-auth-changedhandling inchat.js. - openvibe-shared: Pinned release of OpenVibers/OpenVibe.Shared (
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/vX.Y.Z"), served at/shared/*fromnode_modules. Change it there and bump the tag; never editnode_modules. - DM delivery: Server verifies
dm.isParticipant()before delivering — always maintain this check. - Schema: every table is in
migrations/;ensureTables()is gone. The frozen and Chat-owned tables are not in the PostgreSQL schema at all.
Robots: OpenVibe.Bot owns them (plan T15). Live has no control socket, hardware API or ONVIF code: a channel bound to a robot (channels.bot_robot_id, PUT /api/streams/channel/:username/bot) embeds the robot's panel (server/bot/embed.js), and chat's !forward/!say … reach Live at POST /internal/chat-effects/hardware, which forwards them to Bot's POST /api/v1/robots/:id/commands as the viewer (server/bot/bot-client.js, Network grant bot.robot.control). Live's old controls tables (control_configs, its buttons and whitelist, stream_controls, the ONVIF camera tables, api_keys) and the columns that pointed at them are dropped by migration 0011_drop_live_controls (test/controls-tables-gone.test.js).
WebSocket Endpoints
/ws/broadcast, /ws/call, /ws/robotstreamer-publish — upgraded via the handler in server/index.js with origin checks and IP bans. /ws/chat is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live runs no chat WebSocket server. Any other path gets a plain HTTP answer, not a dropped socket (nginx logs a drop as a 502): 404, or 410 naming OpenVibe.Bot for the retired robot control socket /ws/control ([server/net/ws-paths.js](server/net/ws-paths.js)); a new WebSocket server adds its path there.
Testing
Standalone Node scripts in test/ using assert, run together by npm test. They run on one migrated PGlite database per run (test/helpers/pg-preload.mjs; npm run test:pg uses the containers). Browser smoke: test/browser/smoke.js (routes × widths, console errors, overflow, duplicate scripts, resource growth). Always node --check modified files before committing.
Documentation
Every file below is also served on the site at /docs/<name> (rendered by server/docs/routes.js, GitHub-compatible heading anchors) — link to https://openvibe.live/docs/whip#… rather than to GitHub when pointing users at a doc.
- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/go-live-in-your-browser.md](docs/go-live-in-your-browser.md) — Going live from a browser with no OBS and no follower minimum (the headline feature: keep it on the home page, in /llms.txt and in search metadata)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
- [docs/agent-streams.md](docs/agent-streams.md) — Agent streams: the
ags_resource, publish and view tickets, the curtain, the owner page and the embed - [docs/streams-api.md](docs/streams-api.md) — Stream slots API: everything the Go live screen does, as calls (slots, settings, key, title ideas, history, going live and ending)
- [docs/rundown.md](docs/rundown.md) — The Rundown (
/rundown, was the Arena): what streamers talked about, written up from their speech — topics, takes, episodes, crossovers - [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system
- [docs/vods-and-clips.md](docs/vods-and-clips.md) — VOD/clip pipeline (pre-split; storage/cutting now in OpenVibe.Media)
- [docs/dashboard.md](docs/dashboard.md) — Streamer dashboard
- [docs/onboarding.md](docs/onboarding.md) — New user flow
- [docs/deploy.md](docs/deploy.md) — Deploy kinds, release layout, caching, nginx
- [docs/performance-audit.md](docs/performance-audit.md) — Measurements and tools
- [SETUP.md](SETUP.md) — Full deployment guide
- [SECURITY_AUDIT.md](SECURITY_AUDIT.md) — Security audit findings
- [hardware/README.md](hardware/README.md) — Raspberry Pi integration (moved to OpenVibe.Extensions
hardware/; this is a redirect)