openvibers/openvibe.mediahub Commits
MediaHub 0.1.0: README, STATUS and CHANGELOG for the drive; openvibe-contracts v0.118.0, openvibe-sdk v0.36.0, openvibe-shared v2.15.0
Changes
@@@ -4,4 +4,11 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa## 0.1.0 — 2026-10-08--- **First release:** the service starts from the OpenVibe skeleton — sign-in with OpenVibe.Network (OAuth 2 + PKCE), server-rendered pages through the OpenVibe Frame, the `/api/v1` mount with per-caller limits, crawl artifacts, PostgreSQL migrations, the deploy files and the test suite.++- openvibe.download v1, a private drive. Account-only uploads go into OpenVibe.Media: a plain form, or resumable++ parts. Files and folders are private, with per-person limits.++- Share links that expire, need a signed-in downloader and can name accounts (matched by subject; unknown names refused).++- Reports, suspension and a staff queue.++- Sandboxed attachment downloads.++- openvibe.pics and openvibe.video answer with honest "coming" pages.++- Account export and deletion (ADR-033) with openvibe-sdk/account-data.++- Pins: openvibe-contracts v0.118.0, openvibe-sdk v0.36.0, openvibe-shared v2.15.0.@@@ -2,85 +2,117 @@> Your files, shared on your terms.--**Status:** skeleton. It runs, it is tested, and its pages and API are live — but the product itself is not written yet.--**Domain:** `openvibe.download` · **Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB`--**License:** AGPL-3.0 (same as every OpenVibe service).++**Status:** alpha. One service behind three domains: **openvibe.download** (a private drive, live in v1),++**openvibe.pics** and **openvibe.video** (honest "coming" pages: no uploads until the safety tooling exists).++**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.++++## Why v1 is narrow++++Public file hosting is the riskiest surface on the network: OpenVibe.Media has no malware or CSAM scanning yet. So++OpenVibe.Download v1 is a **private drive**, not a public host:++++- Every upload and every file page needs a signed-in OpenVibe person. Apps, agents and anonymous callers cannot upload.++- Files are private to their owner: another person's file id answers 404.++- A share link (`/s/<slug>`, 24 random url-safe characters) expires in 1 hour to 7 days (7 by default), can be revoked,++ counts downloads, and **needs the downloader to be signed in**. It may be limited to named OpenVibe accounts. The names++ are resolved through OpenVibe.Network to account subjects, and only subjects are matched, because a username can++ change hands. A name no account has is refused.++- Every share page has a Report form (malware, illegal content, copyright, something else). Three distinct reports,++ or any report of illegal content, suspend the share at once. Staff (Network role `admin` or `global_mod`) then++ restore it, or remove the file, which also deletes the object in Media.++- Downloads are always attachments, with `X-Content-Type-Options: nosniff`,++ `Content-Security-Policy: default-src 'none'; sandbox` and a real `Content-Type` only for a short safe list (images++ except SVG, audio, video, PDF, plain text, zip). HTML, SVG and anything executable come back as++ `application/octet-stream`. The name survives in any script (`filename*=UTF-8''…`) and can never break the header.++- Per-person limits, all from the environment: 2 GB stored, 512 MB per file, 50 uploads a day, 20 live share links.++++## How it works++++Bytes live in **OpenVibe.Media** (`MEDIAHUB_MEDIA_URL`, app `media-hub`, app key `MEDIAHUB_MEDIA_APP_KEY`). MediaHub++calls Media server-side only, naming the person with `X-OV-Subject`, and never hands a browser a Media URL:++++- **Small files:** the plain form (`POST /files/upload`, no JavaScript needed) streams the file to a temp file with++ its sha256, then into Media. The ceiling is `MEDIAHUB_SINGLE_UPLOAD_BYTES`, 256 MB.++- **Big files:** the page's small script uploads in parts (`/api/v1/uploads/:id/parts/:n`, 8 MB each) that can++ resume after a dropped connection.++- **Downloads:** MediaHub checks access, then streams from Media with its own headers. Range requests pass through,++ so downloads resume.++++MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).++++## Pages++++| Path | Who | What |++|---|---|---|++| `/` | anyone | openvibe.download's home: what it is, the safety rules, sign in. On openvibe.pics and openvibe.video, their "coming" pages |++| `/files`, `/files/:id` | the owner | your drive: folders, files, the usage bar, upload, share, delete |++| `/s/:slug` | a signed-in person | a share page: the name, the size, who shared it (their username), the expiry, Download, Report |++| `/staff` | staff | the reports queue: restore or remove |++| `/safety` | anyone | what is not allowed, how reports work, [email protected] |++| `/updates` | anyone | what shipped |--## What is already here++## API (`/api/v1`, a signed-in person only)--| Piece | Where | What it does |--|---|---|---|--| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |--| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |--| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `media-hub_at` / `media-hub_rt` cookies; `/auth/me` for the shared navbar |--| Signing key | [server/auth/keys.js](server/auth/keys.js) | The Network JWKS through `openvibe-sdk/auth`, kept fresh, verified offline |--| Who is calling | [server/http/principal.js](server/http/principal.js) | `req.principal`: a person, an app/agent/service with a capability, or anonymous |--| API | [server/http/api.js](server/http/api.js) | `GET /api/v1/ping`; the router, the problem+json errors and the guards are wired for the product's routes |--| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | The home page and `/updates`, server-rendered through `openvibe-shared/shell`, readable without JavaScript |--| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml`, `llms.txt`, `llms-full.txt` and the home page's JSON-LD |--| Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API and per-address limits at nginx |--| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |--| Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |--| Process | [server/index.js](server/index.js) | Listens on `PORT`, and stops gracefully through `openvibe-sdk/service` |--| Deploy | [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf), [deploy/systemd/openvibe-media-hub.service](deploy/systemd/openvibe-media-hub.service) | nginx vhost and systemd unit (port 4990, `/opt/openvibe.download`, `/etc/openvibe/media-hub.env`) |--| Tests | [test/](test/) | `npm test`: every `test/*.test.js` in its own process, on a temp PGlite database with an in-process mock of OpenVibe.Network |----## API----| Route | Who | |--|---|---|---|--| `GET /api/v1/ping` | anyone | `{ ok: true, service: "media-hub" }` |++| Route | |++|---|---|++| `GET /files`, `POST /files`, `GET /files/:id`, `DELETE /files/:id` | your files |++| `GET /folders`, `POST /folders` | your folders |++| `POST /shares`, `DELETE /shares/:slug` | make (`{ file_id \| folder_id, hours, usernames? }`) and revoke share links |++| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |++| `GET /usage` | your usage against the limits |--Everything else the site serves is a page. The product adds its routes in [server/http/api.js](server/http/api.js), naming--each route's capability in [server/http/principal.js](server/http/principal.js) (`CAPABILITIES`) and its numbers in--[server/http/caller-limits.js](server/http/caller-limits.js) (`BUDGETS`).++Session-cookie writes must be same-origin. Errors are RFC 9457 problem+json with a stable `code`.--**Who can call it:** a person, with their Network token as a Bearer or this site's session; or an app, agent or service--with a Network token for audience `openvibe.download` that holds the route's capability. A write made with the session cookie--must come from `openvibe.download` itself. **Errors** are RFC 9457 `application/problem+json` with a stable `code`.++## Account export and deletion (ADR-033)++++`POST /internal/events` (v2 signature under `MEDIAHUB_EVENTS_SECRET`, loopback only; nginx answers `/internal/` with 404)++takes `network.account.export_requested` and `network.account.deleted`, and the subscriptions are created at boot++(`MEDIAHUB_EVENTS_URL`).++++- **Export:** the person's files, folders, shares, the reports they made and their usage.++- **Deletion:** all of it is erased, and each object is deleted in Media too.## Configuration--See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `media-hub` OAuth client on the--Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the--Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).++Every name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internal++URL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`,++`MEDIAHUB_MEDIA_APP_KEY`), Events, and the limits (`MEDIAHUB_QUOTA_BYTES`, `MEDIAHUB_MAX_FILE_BYTES`,++`MEDIAHUB_UPLOADS_PER_DAY`, `MEDIAHUB_MAX_SHARES`, `MEDIAHUB_SINGLE_UPLOAD_BYTES`, `MEDIAHUB_PART_BYTES`).## Development```bashnpm install--fnm exec --using=22 npm test # every test/*.test.js, on temp PGlite databases with a mock Network--fnm exec --using=22 npm run dev # http://localhost:4990++cp .env.example .env++npm run dev # http://127.0.0.1:4990, PGlite under data/++npm test # every test/*.test.js on PGlite, with stand-ins for Network and Media```--Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run--test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).----## Deploy (for the lead)++## Deploy--- **Deploy:** `sudo ovhost deploy media-hub` on the host (git checkout at `/opt/openvibe.download`, unit-- `openvibe-media-hub.service` on 127.0.0.1:4990, env `/etc/openvibe/media-hub.env`, database `ov_media-hub` on the data role).--- **nginx:** [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf), installed with `ov-vhost-install`.--- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.--- Register the service and its capabilities in **OpenVibe.Contracts** (`contracts-service: media-hub` in CI) and with-- **OpenVibe.Services** before the first deploy.++`sudo ovhost deploy media-hub` on the host (unit [deploy/systemd/openvibe-media-hub.service](deploy/systemd/openvibe-media-hub.service),++env `/etc/openvibe/media-hub.env`). The vhost [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf)++serves all three domains, each with its own certificate, and is installed with `ov-vhost-install`. Uploads stream++through nginx unbuffered; downloads are not buffered either.## Security (threat notes)Reporting a vulnerability: [SECURITY.md](SECURITY.md).--- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.--- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.--- Request bodies are never logged.--- Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard-- decides what may be fetched.++- Uploads are account-only, files are private, and share links need a signed-in downloader. Every download is++ attributable, and none renders as a page.++- The form reader streams files to disk. A text field over 64 KB, a second file, or a body cut off before its last++ boundary is refused.++- Session tokens are httpOnly cookies. Secrets live only in the env file, and no secret or request body is logged.++- Next, before Pics or Video take uploads: scanning (hash lists, malware), a DMCA agent, and abuse intake shared++ with OpenVibe.Media's holds.---Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.115.0--- openvibe-sdk: v0.35.2--- openvibe-shared: v2.14.0++- openvibe-contracts: v0.118.0++- openvibe-sdk: v0.36.0++- openvibe-shared: v2.15.0<!-- versions:end -->@@@ -1,38 +1,35 @@{-- "stage": "skeleton",++ "stage": "alpha","code": true,"wave": "W20",-- "plan": "OpenVibe.MediaHub starts from the OpenVibe service skeleton; the product's own plan is written here when it is",++ "plan": "plan T10/T19: one media-hub service, three brands; v1 = openvibe.download as a private drive, pics and video wait for scanning and abuse intake","runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": "4990",-- "domain": "openvibe.download",-- "contracts": "openvibe-contracts v0.115.0",-- "sdk": "openvibe-sdk v0.35.2",-- "shared": "openvibe-shared v2.14.0",++ "domain": "openvibe.download (openvibe.pics and openvibe.video answer with coming pages)","packages": [-- "openvibe-contracts v0.115.0",-- "openvibe-sdk v0.35.2",-- "openvibe-shared v2.14.0"++ "openvibe-contracts v0.118.0",++ "openvibe-sdk v0.36.0",++ "openvibe-shared v2.15.0"],-- "summary": "OpenVibe.MediaHub — Your files, shared on your terms. A new OpenVibe service on the shared skeleton, not deployed yet: sign-in with OpenVibe.Network (OAuth 2 + PKCE, httpOnly session cookies), server-rendered pages through the OpenVibe Frame, the /api/v1 mount with per-caller limits, crawl artifacts, PostgreSQL migrations applied at boot, the nginx and systemd deploy files and a test suite on PGlite with a mock Network.",++ "summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.pics and openvibe.video are coming pages.","works": [-- "Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)",-- "Server-rendered pages: the home page and /updates, through the OpenVibe Frame, complete without JavaScript",-- "Crawl artifacts: robots.txt, sitemap.xml, llms.txt, llms-full.txt and the home page's JSON-LD",-- "The API mount at /api/v1 with the problem+json error shape, the per-address limits and the per-caller limits",-- "Capability verification for app, agent and service tokens (audience openvibe.download) and for a person's own token",-- "Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",-- "PostgreSQL with migrations/NNNN_*.sql (openvibe-sdk/db); PGlite in development",-- "Deploy files: deploy/nginx/openvibe.download.conf and deploy/systemd/openvibe-media-hub.service",-- "Tests on a temp PGlite database with an in-process mock of OpenVibe.Network"++ "Sign-in with OpenVibe.Network (PKCE S256, httpOnly session cookies); pages server-rendered, complete without JavaScript",++ "Uploads: a plain form streamed to disk with its sha256 (up to 256 MB), and resumable chunked uploads (8 MB parts) through Media multipart",++ "Private files and folders, per-person limits (2 GB stored, 512 MB per file, 50 uploads a day, 20 live shares)",++ "Share links: 1 hour to 7 days, revocable, counted, sign-in required, optional named accounts (Network subjects only)",++ "Reports: 3 distinct people or one report of illegal content suspend a share; staff restore or remove (Media object deleted)",++ "Downloads: attachment, nosniff, sandbox CSP, real type only for a safe list, filename* for any script, Range resume",++ "Host-header brands: openvibe.pics and openvibe.video coming pages, no upload there",++ "Account export and deletion (ADR-033) through openvibe-sdk/account-data; subscriptions created at boot",++ "Per-caller and per-address limits; readiness (database required); metrics on loopback"],"notYet": [-- "The product itself: its tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js",-- "Its service and capability manifests in openvibe-contracts (contracts-service is media-hub)",-- "A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host"++ "Uploads on openvibe.pics (images, albums, embeds) and openvibe.video (watch pages, captions, playlists): they wait for scanning (hash lists, malware) and abuse intake shared with Media holds",++ "Public (signed-out) share links: deliberately not offered while there is no scanning",++ "Previews and thumbnails of files"],"capabilitiesRegistered": false,-- "serviceManifestRegistered": false,++ "serviceManifestRegistered": true,"deployed": false,"placeholderInSites": false,"features": [@@@ -42,5 +39,8 @@"limits and readiness","deploy files"],-- "updated": "2026-10-08"++ "updated": "2026-10-08",++ "contracts": "openvibe-contracts v0.118.0",++ "sdk": "openvibe-sdk v0.36.0",++ "shared": "openvibe-shared v2.15.0"}@@@ -15,9 +15,9 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.14.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},"devDependencies": {@@@ -871,9 +871,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.115.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",-- "integrity": "sha512-2si6od4+RLgZEIAuWMmTrLZL65TzvCwB/sKoe24vSzC9+sNPt2RXWGXDCwE2fB90vmaRvX6pI+DQyu18QN2f0Q==",++ "version": "0.118.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",++ "integrity": "sha512-vd5OzSIGhGB9ezy/SDHolji+jCJIxp1SmaKkE01KbiManwOnMdM6FOX3LIEVldXCBTjWH77iMkqB5sCxeY+qiQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -887,9 +887,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.35.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",-- "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",++ "version": "0.36.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",++ "integrity": "sha512-mb+VvCuFH3KnMw4LWvqMDEoF93rNEb+eQi8HfT6mj+RPN5eQFWMcc/DCqJ3L0Yzjr99tQ08+DLwaYdZDIlfD0A==","license": "MIT","engines": {"node": ">=20"@@@ -916,9 +916,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.14.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.14.0",-- "integrity": "sha512-XraQxTERs14Jc65sJ23Hv0B5BJLC3Fbwsg3uEp8rMaEShFiuhMofr7pKGYX11sgreMMF64tymO6l6kdjYTBJfw==",++ "version": "2.15.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",++ "integrity": "sha512-xApTyiUBVDQLTdnKEjjr/9uUU4+T5gofz9DqXv0MrLVINmW88uwbfoEht3kAlG9mp1792U6kIFEblJDHn2wyMQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -22,9 +22,9 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.14.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},"engines": {
@@ -4,4 +4,11 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,4 +4,11 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## 0.1.0 — 2026-10-08
## 0.1.0 — 2026-10-08
-- **First release:** the service starts from the OpenVibe skeleton — sign-in with OpenVibe.Network (OAuth 2 + PKCE), server-rendered pages through the OpenVibe Frame, the `/api/v1` mount with per-caller limits, crawl artifacts, PostgreSQL migrations, the deploy files and the test suite.
+- openvibe.download v1, a private drive. Account-only uploads go into OpenVibe.Media: a plain form, or resumable
+ parts. Files and folders are private, with per-person limits.
+- Share links that expire, need a signed-in downloader and can name accounts (matched by subject; unknown names refused).
+- Reports, suspension and a staff queue.
+- Sandboxed attachment downloads.
+- openvibe.pics and openvibe.video answer with honest "coming" pages.
+- Account export and deletion (ADR-033) with openvibe-sdk/account-data.
+- Pins: openvibe-contracts v0.118.0, openvibe-sdk v0.36.0, openvibe-shared v2.15.0.
@@ -2,85 +2,117 @@
@@ -2,85 +2,117 @@
> Your files, shared on your terms.
> Your files, shared on your terms.
-**Status:** skeleton. It runs, it is tested, and its pages and API are live — but the product itself is not written yet.
-**Domain:** `openvibe.download` · **Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB`
-**License:** AGPL-3.0 (same as every OpenVibe service).
+**Status:** alpha. One service behind three domains: **openvibe.download** (a private drive, live in v1),
+**openvibe.pics** and **openvibe.video** (honest "coming" pages: no uploads until the safety tooling exists).
+**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.
+
+## Why v1 is narrow
+
+Public file hosting is the riskiest surface on the network: OpenVibe.Media has no malware or CSAM scanning yet. So
+OpenVibe.Download v1 is a **private drive**, not a public host:
+
+- Every upload and every file page needs a signed-in OpenVibe person. Apps, agents and anonymous callers cannot upload.
+- Files are private to their owner: another person's file id answers 404.
+- A share link (`/s/<slug>`, 24 random url-safe characters) expires in 1 hour to 7 days (7 by default), can be revoked,
+ counts downloads, and **needs the downloader to be signed in**. It may be limited to named OpenVibe accounts. The names
+ are resolved through OpenVibe.Network to account subjects, and only subjects are matched, because a username can
+ change hands. A name no account has is refused.
+- Every share page has a Report form (malware, illegal content, copyright, something else). Three distinct reports,
+ or any report of illegal content, suspend the share at once. Staff (Network role `admin` or `global_mod`) then
+ restore it, or remove the file, which also deletes the object in Media.
+- Downloads are always attachments, with `X-Content-Type-Options: nosniff`,
+ `Content-Security-Policy: default-src 'none'; sandbox` and a real `Content-Type` only for a short safe list (images
+ except SVG, audio, video, PDF, plain text, zip). HTML, SVG and anything executable come back as
+ `application/octet-stream`. The name survives in any script (`filename*=UTF-8''…`) and can never break the header.
+- Per-person limits, all from the environment: 2 GB stored, 512 MB per file, 50 uploads a day, 20 live share links.
+
+## How it works
+
+Bytes live in **OpenVibe.Media** (`MEDIAHUB_MEDIA_URL`, app `media-hub`, app key `MEDIAHUB_MEDIA_APP_KEY`). MediaHub
+calls Media server-side only, naming the person with `X-OV-Subject`, and never hands a browser a Media URL:
+
+- **Small files:** the plain form (`POST /files/upload`, no JavaScript needed) streams the file to a temp file with
+ its sha256, then into Media. The ceiling is `MEDIAHUB_SINGLE_UPLOAD_BYTES`, 256 MB.
+- **Big files:** the page's small script uploads in parts (`/api/v1/uploads/:id/parts/:n`, 8 MB each) that can
+ resume after a dropped connection.
+- **Downloads:** MediaHub checks access, then streams from Media with its own headers. Range requests pass through,
+ so downloads resume.
+
+MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).
+
+## Pages
+
+| Path | Who | What |
+|---|---|---|
+| `/` | anyone | openvibe.download's home: what it is, the safety rules, sign in. On openvibe.pics and openvibe.video, their "coming" pages |
+| `/files`, `/files/:id` | the owner | your drive: folders, files, the usage bar, upload, share, delete |
+| `/s/:slug` | a signed-in person | a share page: the name, the size, who shared it (their username), the expiry, Download, Report |
+| `/staff` | staff | the reports queue: restore or remove |
+| `/safety` | anyone | what is not allowed, how reports work, [email protected] |
+| `/updates` | anyone | what shipped |
-## What is already here
+## API (`/api/v1`, a signed-in person only)
-| Piece | Where | What it does |
-|---|---|---|
-| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |
-| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |
-| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `media-hub_at` / `media-hub_rt` cookies; `/auth/me` for the shared navbar |
-| Signing key | [server/auth/keys.js](server/auth/keys.js) | The Network JWKS through `openvibe-sdk/auth`, kept fresh, verified offline |
-| Who is calling | [server/http/principal.js](server/http/principal.js) | `req.principal`: a person, an app/agent/service with a capability, or anonymous |
-| API | [server/http/api.js](server/http/api.js) | `GET /api/v1/ping`; the router, the problem+json errors and the guards are wired for the product's routes |
-| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | The home page and `/updates`, server-rendered through `openvibe-shared/shell`, readable without JavaScript |
-| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml`, `llms.txt`, `llms-full.txt` and the home page's JSON-LD |
-| Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API and per-address limits at nginx |
-| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |
-| Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |
-| Process | [server/index.js](server/index.js) | Listens on `PORT`, and stops gracefully through `openvibe-sdk/service` |
-| Deploy | [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf), [deploy/systemd/openvibe-media-hub.service](deploy/systemd/openvibe-media-hub.service) | nginx vhost and systemd unit (port 4990, `/opt/openvibe.download`, `/etc/openvibe/media-hub.env`) |
-| Tests | [test/](test/) | `npm test`: every `test/*.test.js` in its own process, on a temp PGlite database with an in-process mock of OpenVibe.Network |
-
-## API
-
-| Route | Who | |
-|---|---|---|
-| `GET /api/v1/ping` | anyone | `{ ok: true, service: "media-hub" }` |
+| Route | |
+|---|---|
+| `GET /files`, `POST /files`, `GET /files/:id`, `DELETE /files/:id` | your files |
+| `GET /folders`, `POST /folders` | your folders |
+| `POST /shares`, `DELETE /shares/:slug` | make (`{ file_id \| folder_id, hours, usernames? }`) and revoke share links |
+| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |
+| `GET /usage` | your usage against the limits |
-Everything else the site serves is a page. The product adds its routes in [server/http/api.js](server/http/api.js), naming
-each route's capability in [server/http/principal.js](server/http/principal.js) (`CAPABILITIES`) and its numbers in
-[server/http/caller-limits.js](server/http/caller-limits.js) (`BUDGETS`).
+Session-cookie writes must be same-origin. Errors are RFC 9457 problem+json with a stable `code`.
-**Who can call it:** a person, with their Network token as a Bearer or this site's session; or an app, agent or service
-with a Network token for audience `openvibe.download` that holds the route's capability. A write made with the session cookie
-must come from `openvibe.download` itself. **Errors** are RFC 9457 `application/problem+json` with a stable `code`.
+## Account export and deletion (ADR-033)
+
+`POST /internal/events` (v2 signature under `MEDIAHUB_EVENTS_SECRET`, loopback only; nginx answers `/internal/` with 404)
+takes `network.account.export_requested` and `network.account.deleted`, and the subscriptions are created at boot
+(`MEDIAHUB_EVENTS_URL`).
+
+- **Export:** the person's files, folders, shares, the reports they made and their usage.
+- **Deletion:** all of it is erased, and each object is deleted in Media too.
## Configuration
## Configuration
-See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `media-hub` OAuth client on the
-Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the
-Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).
+Every name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internal
+URL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`,
+`MEDIAHUB_MEDIA_APP_KEY`), Events, and the limits (`MEDIAHUB_QUOTA_BYTES`, `MEDIAHUB_MAX_FILE_BYTES`,
+`MEDIAHUB_UPLOADS_PER_DAY`, `MEDIAHUB_MAX_SHARES`, `MEDIAHUB_SINGLE_UPLOAD_BYTES`, `MEDIAHUB_PART_BYTES`).
## Development
## Development
```bash
```bash
npm install
npm install
-fnm exec --using=22 npm test # every test/*.test.js, on temp PGlite databases with a mock Network
-fnm exec --using=22 npm run dev # http://localhost:4990
+cp .env.example .env
+npm run dev # http://127.0.0.1:4990, PGlite under data/
+npm test # every test/*.test.js on PGlite, with stand-ins for Network and Media
```
```
-Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run
-test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
-
-## Deploy (for the lead)
+## Deploy
-- **Deploy:** `sudo ovhost deploy media-hub` on the host (git checkout at `/opt/openvibe.download`, unit
- `openvibe-media-hub.service` on 127.0.0.1:4990, env `/etc/openvibe/media-hub.env`, database `ov_media-hub` on the data role).
-- **nginx:** [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf), installed with `ov-vhost-install`.
-- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.
-- Register the service and its capabilities in **OpenVibe.Contracts** (`contracts-service: media-hub` in CI) and with
- **OpenVibe.Services** before the first deploy.
+`sudo ovhost deploy media-hub` on the host (unit [deploy/systemd/openvibe-media-hub.service](deploy/systemd/openvibe-media-hub.service),
+env `/etc/openvibe/media-hub.env`). The vhost [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf)
+serves all three domains, each with its own certificate, and is installed with `ov-vhost-install`. Uploads stream
+through nginx unbuffered; downloads are not buffered either.
## Security (threat notes)
## Security (threat notes)
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
-- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.
-- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.
-- Request bodies are never logged.
-- Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard
- decides what may be fetched.
+- Uploads are account-only, files are private, and share links need a signed-in downloader. Every download is
+ attributable, and none renders as a page.
+- The form reader streams files to disk. A text field over 64 KB, a second file, or a body cut off before its last
+ boundary is refused.
+- Session tokens are httpOnly cookies. Secrets live only in the env file, and no secret or request body is logged.
+- Next, before Pics or Video take uploads: scanning (hash lists, malware), a DMCA agent, and abuse intake shared
+ with OpenVibe.Media's holds.
---
---
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.115.0
-- openvibe-sdk: v0.35.2
-- openvibe-shared: v2.14.0
+- openvibe-contracts: v0.118.0
+- openvibe-sdk: v0.36.0
+- openvibe-shared: v2.15.0
<!-- versions:end -->
<!-- versions:end -->
@@ -1,38 +1,35 @@
@@ -1,38 +1,35 @@
{
{
- "stage": "skeleton",
+ "stage": "alpha",
"code": true,
"code": true,
"wave": "W20",
"wave": "W20",
- "plan": "OpenVibe.MediaHub starts from the OpenVibe service skeleton; the product's own plan is written here when it is",
+ "plan": "plan T10/T19: one media-hub service, three brands; v1 = openvibe.download as a private drive, pics and video wait for scanning and abuse intake",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4990",
"port": "4990",
- "domain": "openvibe.download",
- "contracts": "openvibe-contracts v0.115.0",
- "sdk": "openvibe-sdk v0.35.2",
- "shared": "openvibe-shared v2.14.0",
+ "domain": "openvibe.download (openvibe.pics and openvibe.video answer with coming pages)",
"packages": [
"packages": [
- "openvibe-contracts v0.115.0",
- "openvibe-sdk v0.35.2",
- "openvibe-shared v2.14.0"
+ "openvibe-contracts v0.118.0",
+ "openvibe-sdk v0.36.0",
+ "openvibe-shared v2.15.0"
],
],
- "summary": "OpenVibe.MediaHub — Your files, shared on your terms. A new OpenVibe service on the shared skeleton, not deployed yet: sign-in with OpenVibe.Network (OAuth 2 + PKCE, httpOnly session cookies), server-rendered pages through the OpenVibe Frame, the /api/v1 mount with per-caller limits, crawl artifacts, PostgreSQL migrations applied at boot, the nginx and systemd deploy files and a test suite on PGlite with a mock Network.",
+ "summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.pics and openvibe.video are coming pages.",
"works": [
"works": [
- "Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)",
- "Server-rendered pages: the home page and /updates, through the OpenVibe Frame, complete without JavaScript",
- "Crawl artifacts: robots.txt, sitemap.xml, llms.txt, llms-full.txt and the home page's JSON-LD",
- "The API mount at /api/v1 with the problem+json error shape, the per-address limits and the per-caller limits",
- "Capability verification for app, agent and service tokens (audience openvibe.download) and for a person's own token",
- "Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",
- "PostgreSQL with migrations/NNNN_*.sql (openvibe-sdk/db); PGlite in development",
- "Deploy files: deploy/nginx/openvibe.download.conf and deploy/systemd/openvibe-media-hub.service",
- "Tests on a temp PGlite database with an in-process mock of OpenVibe.Network"
+ "Sign-in with OpenVibe.Network (PKCE S256, httpOnly session cookies); pages server-rendered, complete without JavaScript",
+ "Uploads: a plain form streamed to disk with its sha256 (up to 256 MB), and resumable chunked uploads (8 MB parts) through Media multipart",
+ "Private files and folders, per-person limits (2 GB stored, 512 MB per file, 50 uploads a day, 20 live shares)",
+ "Share links: 1 hour to 7 days, revocable, counted, sign-in required, optional named accounts (Network subjects only)",
+ "Reports: 3 distinct people or one report of illegal content suspend a share; staff restore or remove (Media object deleted)",
+ "Downloads: attachment, nosniff, sandbox CSP, real type only for a safe list, filename* for any script, Range resume",
+ "Host-header brands: openvibe.pics and openvibe.video coming pages, no upload there",
+ "Account export and deletion (ADR-033) through openvibe-sdk/account-data; subscriptions created at boot",
+ "Per-caller and per-address limits; readiness (database required); metrics on loopback"
],
],
"notYet": [
"notYet": [
- "The product itself: its tables and migrations, its capabilities, its routes in server/http/api.js and its pages in server/http/pages.js",
- "Its service and capability manifests in openvibe-contracts (contracts-service is media-hub)",
- "A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host"
+ "Uploads on openvibe.pics (images, albums, embeds) and openvibe.video (watch pages, captions, playlists): they wait for scanning (hash lists, malware) and abuse intake shared with Media holds",
+ "Public (signed-out) share links: deliberately not offered while there is no scanning",
+ "Previews and thumbnails of files"
],
],
"capabilitiesRegistered": false,
"capabilitiesRegistered": false,
- "serviceManifestRegistered": false,
+ "serviceManifestRegistered": true,
"deployed": false,
"deployed": false,
"placeholderInSites": false,
"placeholderInSites": false,
"features": [
"features": [
@@ -42,5 +39,8 @@
@@ -42,5 +39,8 @@
"limits and readiness",
"limits and readiness",
"deploy files"
"deploy files"
],
],
- "updated": "2026-10-08"
+ "updated": "2026-10-08",
+ "contracts": "openvibe-contracts v0.118.0",
+ "sdk": "openvibe-sdk v0.36.0",
+ "shared": "openvibe-shared v2.15.0"
}
}
@@ -15,9 +15,9 @@
@@ -15,9 +15,9 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.14.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -871,9 +871,9 @@
@@ -871,9 +871,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.115.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
- "integrity": "sha512-2si6od4+RLgZEIAuWMmTrLZL65TzvCwB/sKoe24vSzC9+sNPt2RXWGXDCwE2fB90vmaRvX6pI+DQyu18QN2f0Q==",
+ "version": "0.118.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",
+ "integrity": "sha512-vd5OzSIGhGB9ezy/SDHolji+jCJIxp1SmaKkE01KbiManwOnMdM6FOX3LIEVldXCBTjWH77iMkqB5sCxeY+qiQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -887,9 +887,9 @@
@@ -887,9 +887,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.35.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
- "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",
+ "version": "0.36.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
+ "integrity": "sha512-mb+VvCuFH3KnMw4LWvqMDEoF93rNEb+eQi8HfT6mj+RPN5eQFWMcc/DCqJ3L0Yzjr99tQ08+DLwaYdZDIlfD0A==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -916,9 +916,9 @@
@@ -916,9 +916,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.14.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.14.0",
- "integrity": "sha512-XraQxTERs14Jc65sJ23Hv0B5BJLC3Fbwsg3uEp8rMaEShFiuhMofr7pKGYX11sgreMMF64tymO6l6kdjYTBJfw==",
+ "version": "2.15.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
+ "integrity": "sha512-xApTyiUBVDQLTdnKEjjr/9uUU4+T5gofz9DqXv0MrLVINmW88uwbfoEht3kAlG9mp1792U6kIFEblJDHn2wyMQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -22,9 +22,9 @@
@@ -22,9 +22,9 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.14.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.118.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {