openvibers/openvibe.mediahub AGENTS.md
AGENTS.md
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
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 <file.js> # 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 increateLifecycle().MEDIAHUB_DRILLkeeps 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 buildcreateApp({ 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 tohttps://openvibe.network/;
uploads and downloads stream unbuffered.sudo ovhost deploy media-hub; databaseov_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), Mediamedia.object.upload|readon namespacemedia-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
htmltemplate. - CSP: add every external origin to the
helmetdirectives in [server/app.js](server/app.js) (Media images, Live HLS,
Community, Events, Search). Do not widenframeAncestors/objectSrc. The HLS player is vendored on purpose. - No Font Awesome — use
openvibe-shared/glyphsandopenvibe-shared/brands; the no-FA guard
checks every page and stylesheet, and a newfa-name must be one the glyphs/brands draw. - No looks filter —
openvibe-sdk/no-looksis 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_outboxneeds 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).