# AGENTS.md — OpenVibe.MediaHub ## Project Overview OpenVibe.MediaHub is the network's file service. One process serves three hosts, chosen by Host header: - **openvibe.download** — a private drive: upload, folders, share links, resumable uploads, sync keys, agent grants. - **openvibe.video** — OpenVibe.Video: uploads and browser recordings, AI captions/chapters/cuts, watch pages, playlists, and OpenVibe.Live's stream recordings and clips. - **openvibe.pics** — OpenVibe.Pics: picture hosting with resize/convert, alt text, albums, the public gallery and a takedown blocklist. - **Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` - **Runtime:** Node 22, Express 4, PostgreSQL 18 through `openvibe-sdk/db` (PGlite in development/tests), Valkey 9. - **Read first:** [README.md](README.md), [STATUS.json](STATUS.json), [.env.example](.env.example). **Network context:** subjects, usernames and JWKS are OpenVibe.Network (OAuth client `media-hub`). The **bytes** are OpenVibe.Media objects (`med_…`); MediaHub stores ids and metadata and asks Media to create/read/delete. Live's public JSON API supplies recordings/clips (`MEDIAHUB_LIVE_URL`). Comments on videos and pictures are OpenVibe.Community threads through `server/comments/`. Sharing is `openvibe-sdk/share`. Events go through `event_outbox` to OpenVibe.Events; account export/deletion arrives at `/internal/events`; public ready videos go to OpenVibe.Search. Nothing here is identity, media bytes, money or event delivery. ## Commands ```bash npm ci # install (contracts/sdk/shared pinned by tag) cp .env.example .env # development names/values npm run dev # http://127.0.0.1:4990, PGlite under data/ npm start # node server/index.js npm test # every test/*.test.js on PGlite with stand-ins for Network/Media npm run test:pg # the same through PostgreSQL + PgBouncer npm test -- video- # only test files whose name contains the word node --check # syntax check (no linter configured) ``` No build step: pages are server-rendered; browser code is plain JS under [public/js/](public/js/) plus the vendored HLS player [public/vendor/hls.light.min.js](public/vendor/hls.light.min.js). ## Architecture at a Glance - **Entry:** [server/index.js](server/index.js) — listener, telemetry flush/retention, Events subscriptions; graceful stop in `createLifecycle()`. `MEDIAHUB_DRILL` keeps boot-time work off. - **App factory:** [server/app.js](server/app.js) — helmet/CSP, SSO, **host routing** (`videoHost`, `picsHost`; every other host is the drive), `/auth`, `/internal/events`, `/shared` + static, `/api/v1`, the agent layer, pages, 404 and error handler. Tests build `createApp({ store, fetchImpl, ... })`. - **Config:** [server/config.js](server/config.js) — `load(env)` is pure; production `/etc/openvibe/media-hub.env`. - **Drive:** [server/drive/](server/drive/) — `service.js`, `store.js`, `upload.js` (chunked/resumable), `syncKeys.js`, `drops.js`, `ui.js`; API [server/http/api.js](server/http/api.js). Bytes: [server/media/](server/media/). - **Video:** [server/video/](server/video/) — `pages.js`, `uploads.js`, `playlists.js`, `ai-edit.js` (captions, chapters, cut/trim), `cut.js`, `downloads.js`, `watch-ui.js`, `studio.js`, `search-index.js`, `comments-client.js`; watch routes and `/u/` owner pages. - **Pics:** [server/pics/](server/pics/) — `service.js`, `pages.js`, `api.js`, `store.js`, `describe.js` (AI alt text), `gallery.js`, `policy.js`, `variants.js`, `images.js`, `resumable.js`; [server/comments/](server/comments/) (`form.js`, `inline.js`) for Community threads. - **Radio:** [server/radio/routes.js](server/radio/routes.js) — the shared radio tracks/listener. - **Auth:** [server/auth/sso.js](server/auth/sso.js), [server/auth/keys.js](server/auth/keys.js), [server/http/principal.js](server/http/principal.js) (person, sync key, app/agent/service capability). - **Frontend:** server-rendered through [server/render/](server/render/) and `openvibe-shared/shell|frame`, with assets [public/css/](public/css/) (`app`, `drive`, `video`, `pics`, `comments`, `labels`, `radio`) and [public/js/](public/js/) (`upload`, `video`, `video-upload`, `video-record`, `pics`, `pics-gallery`, `drop`, `comments`, `my-videos`, `video-home`). Pages work without JavaScript; the one drive script is a progress meter. - **Database:** [server/db.js](server/db.js) and [migrations/](migrations/) — drive (`mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage`, `mh_uploads`, `mh_sync_keys`, `mh_drops`, `mh_agent_grants`, `mh_share_defaults`, `mh_drop_reports`, `mh_drop_blocklist`), video (`video_uploads`, `video_playlists`, `video_playlist_items`, `video_cuts`, `video_likes`, `video_search_state`, `video_upload_ai`, `video_upload_reports`, `video_usage`), pics (`pics_images`, `pics_albums`, `pics_album_images`, `pics_reports`, `pics_blocklist`, `pics_usage`, `pics_views`, `pics_likes`, `pics_uploads`, `pics_upload_chunks`), radio, sharing (`shares`, `share_*`), `event_outbox`, `account_data_events`, `telemetry_samples`. - **Deploy:** [deploy/systemd/openvibe-media-hub.service](deploy/systemd/openvibe-media-hub.service) (`/etc/openvibe/media-hub.env`, `/opt/openvibe.download`) and [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf) — one vhost for openvibe.download and openvibe.video (both proxy to 127.0.0.1:4990), openvibe.pics parked with a 302 to `https://openvibe.network/`; uploads and downloads stream unbuffered. `sudo ovhost deploy media-hub`; database `ov_media_hub`. ## Ownership and Boundaries MediaHub owns the drive's metadata, share links, sync keys, agent grants, upload sessions, reports and usage; the video upload/playlist/cut metadata and search state; the pics metadata, albums, gallery, labels and blocklist; the radio tracks; its share rows and outbox. It does **not** own the bytes (Media), identity (Network), Live's channels or recordings (Live), comments (Community), event delivery (Events), or the search index (Search). CSAM hash matching against NCMEC/PhotoDNA is not implemented: Pics matches only its own takedown blocklist, and child-safety reports hide at once. Never claim otherwise in UI or docs. ## Auth and Capabilities The Contracts manifest lists ten capabilities: `video.vod.read`, `video.playlist.read`, `video.playlist.manage`, `pics.image.read`, `pics.image.upload`, `pics.album.read`, `pics.album.manage`, `download.file.read`, `download.file.upload`, `download.share.create`. The two playlist capabilities are registered in [server/http/principal.js](server/http/principal.js). `download.file.read` is agent-only and requires that agent's active per-file grant — a capability never lists the drive or replaces the grant. Every other drive write is a signed-in person or the owner's scoped sync key. - Cookie-authenticated writes must be same-origin. Uploads and downloads take the same per-caller budgets as the API route they stand for, so a plain form is never a way round a limit. - Outbound: `identity.subject.resolve` (Network), Media `media.object.upload|read` on namespace `media-hub`, `events.subscription.manage` + `events.event.publish` (`mediahub.video.published`, `mediahub.picture.uploaded`), `search.document.write`. - Errors are RFC 9457 problem+json with stable codes; never leak Media's internals or a person's email/subject. ## API (drive, `/api/v1`) `GET /files`, `POST /files`, `GET|DELETE /files/:id`, `GET /folders`, `POST /folders`, `POST /shares`, `DELETE /shares/:slug`, `GET|POST /agent-grants`, `DELETE /agent-grants/:id`, `GET /agent/files/:id/content` (agent + grant, byte ranges), `GET|PUT|POST|DELETE /uploads/:id[/parts/:n|/complete]`, `GET /usage`. Video and Pics have their own APIs on their hosts (`/api/v1/videos`, playlists, pics/images, albums, gallery, comments), plus public reads, `/llms.txt`, sitemaps and IndexNow for openvibe.video. Machine endpoints `/api/health`, `/api/ready`, `/release.json`, `/metrics` are loopback only. ## Conventions - CommonJS, 4-space indent, single quotes, semicolons; no linter/formatter. Async handlers through the router so a rejection reaches the error handler. - New schema = a new `migrations/NNNN_name.sql`; never edit one that has run. Prefix tables by product (`mh_`, `pics_`, `video_`) and keep the 40+ existing numbers' order. - Never render a file's bytes into a page; escape every owner-supplied name with the `html` template. - CSP: add every external origin to the `helmet` directives in [server/app.js](server/app.js) (Media images, Live HLS, Community, Events, Search). Do not widen `frameAncestors`/`objectSrc`. The HLS player is vendored on purpose. - **No Font Awesome** — use `openvibe-shared/glyphs` and `openvibe-shared/brands`; the no-FA guard checks every page and stylesheet, and a new `fa-` name must be one the glyphs/brands draw. - **No looks filter** — `openvibe-sdk/no-looks` is gone; AI text may describe people. Only sex acts/porn stay gated ([test/no-looks-guard.test.js](test/no-looks-guard.test.js)). - Phone first: watch, drive, pics and gallery pages must fit 390 px; the video mini-player and the watch page are first-class phone surfaces. - "Calm reduces, only Off removes": audience/share preferences de-emphasize, they do not silently delete. Only the explicit Off/delete path removes. - Empty states use the shared kit (`ovk-empty`), not bespoke markup ([test/empty-states.test.js](test/empty-states.test.js)). ## Key Pitfalls - **Host header is the product switch.** openvibe.video and openvibe.pics must never serve drive pages/API and vice versa; add new routes to the right host router in [server/app.js](server/app.js). - **Bytes are Media's.** Never write a media file to local disk as the source of truth; Media controls retention, and a soft-deleted drive row stays until its object is gone. - **Streaming:** uploads and downloads must not be buffered by nginx or by the app; the vhost is deliberately `proxy_request_buffering off`/`proxy_buffering off`. Range requests must keep working for the player and downloads. - **Share links** have expiry, allow-lists and a report high-water mark; revocation is immediate. Agent grants expire after 1–720 h and cover one file — do not turn one into a listing right. - **Pics policy:** signed-out uploads exist only with `MEDIAHUB_PICS_ANONYMOUS`; the anonymous delete key is stored as its sha256; the blocklist matches sha256 and difference hashes, not perceptual guesses. Reports hide first, review later. - **Video imports** from Live are public-API reads (signed out); a recording in progress is handled by Media, and a stale/private upload is tombstoned from Search. Never call Media's private routes with a person's token. - **Drill mode** (`MEDIAHUB_DRILL`) keeps the backfills, search sync, indexnow and timers off; add new background work there. - **`event_outbox`** needs its rejected partial index ([migrations/0025_outbox_rejected_index.sql](migrations/0025_outbox_rejected_index.sql)). - Nginx parks openvibe.pics with a 302 today; do not "fix" that without the Pics deployment decision. ## Testing `npm test` runs each file in its own process on PGlite with Network/Media stand-ins. The suite covers drive upload/share/download/range, sync keys, agent grants, drops, video upload/record/playlists/AI/cut/trim/search/comments, pics upload/gallery/album/labels/policy/sharing/rotate, radio, sharing, account data, host routing, no Font Awesome, no-looks, empty states, phone/contrast, secret-leak/redirect and size budgets. Run the narrowest file first, then the suite; `npm run test:pg` for the PostgreSQL path. Always `node --check` changed files. ## Documentation - [README.md](README.md) — three products in one process, owns/does-not-own, capabilities, drive/video/pics pages, sync keys, API, playlists, account data, configuration, acceptance, deploy, security notes. - [docs/screenshots/](docs/screenshots/) — recorded UI states. - [STATUS.json](STATUS.json) — stage, deployed flag, features and not-yet list. - [.env.example](.env.example) — variable names only; never commit values. - [CHANGELOG.md](CHANGELOG.md), [SECURITY.md](SECURITY.md).