openvibers/openvibe.media Commits
Media enforces the unsafe-image review: pending, hidden and quarantined (#321)
Changes
@@@ -13,6 +13,16 @@(`media.object.delete`, principal `svc:community`) and soft-deletes exactly a `legacy:live:paste:<slug>`screenshot: 410 at `/o/:id`, the ordinary retention window and `media.object.deleted` event, and a repeatis a no-op. VODs, clips, files, thumbnails, avatars and native objects are refused, and so is any other principal.++- Review follow-up: a category-3 result places an `evidence` hold in the quarantine transaction, so owner delete and++ retention purge answer 409 held; an operator's overruling category releases it with who and when. Adds Vision's++ logged 60-second `operator-view` and service-only `review-decision` routes, a separate operator signature purpose,++ `media_review_views`, migration `0019_review_evidence.sql`, and Contracts v0.294.0.++- Unsafe-image review enforcement (VIS-6): a visible upload is pending until OpenVibe.Vision (`VISION_URL` plus++ Media's service principal) sorts it into `fine`, `sex_acts` or `minor_sexual`. Sex acts become private and leave++ every public surface; sexual content involving a minor is quarantined even from a signed read and keeps Vision's++ operator report id. A reviewer outage leaves the object pending, never public. Migration++ `0018_object_reviews.sql`, `server/objects/review.js`, `POST /api/v2/:app/objects/:id/review`, tests++ `test/object-review.test.js`.- A tenant for the screenshot pass (plan L9, devblog §3.3): OpenVibe.Host stores the PNGs it captures (ovhost shots) in its owntenant, `host`, through the Object API v2. The tenant is created at boot like the other first-party ones, and reached only@@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pgexport and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay andthe account and revocation subscriptions)- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host--- `openvibe-contracts` v0.289.1, `openvibe-sdk` v0.82.0 (tokens, events outbox, usage readings, per-actor limits),++- `openvibe-contracts` v0.294.0, `openvibe-sdk` v0.82.0 (tokens, events outbox, usage readings, per-actor limits),`openvibe-shared` v4.115.1, pinned by release tarball## Capabilities@@@ -129,7 +129,7 @@ server/storage.orphans.scan (monthly storage orphan report, service-wide, report only),storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)client-ip.js trust proxy = loopback; req.ip is the only client address--(openvibe-shared v4.115.1, openvibe-contracts v0.289.1, openvibe-sdk v0.82.0: pinned release tarballs, installed by npm)++(openvibe-shared v4.115.1, openvibe-contracts v0.294.0, openvibe-sdk v0.82.0: pinned release tarballs, installed by npm)scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator toolsscripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals@@@ -593,6 +593,29 @@ without a signature is a 404 like any other private one). A native object in theR2 popularity cache redirects to its verified R2 copy; a GET of a native objectcounts its viewer for the day, identifying no one (see Storage tiering).++## Unsafe-image review (VIS-6)++++A visible upload is marked `review_status = 'pending'` and is not served until OpenVibe.Vision (configured by++`VISION_URL` or `OV_VISION_URL`, plus Media's Network service principal) answers. The reviewer sorts it into exactly++three categories:++++| Category | Media's action |++|---|---|++| `fine` | `clear`: the object serves as before |++| `sex_acts` | `hidden`: `visibility = private`; `/o/:id`, its HLS playlists, `public_url` and every public surface close; a signed download still reaches it for its owner |++| `minor_sexual` | `quarantined`: private and 404 even with a signature; Vision's operator report id is kept in `review_report_id`, and an `evidence` hold is placed in the same transaction so the owner's delete or retention purge cannot destroy it |++++If Vision is unreachable, the object stays `pending` (closed), never public. `POST /api/v2/:app/objects/:id/review`++re-runs the review. A category is an operator decision:++`POST /api/v2/:app/objects/:id/review-decision` needs the service capability `media.object.review.decide` (Vision's),++takes `fine | sex_acts | minor_sexual`, and releases the evidence hold only for `fine` or `sex_acts` with++`released_by`/`released_at` on the hold row. `POST /api/v2/:app/objects/:id/operator-view` needs++`media.object.review.view`, refuses a fine or pending object, and answers a 60-second signed URL under a separate++`operator` signature purpose; every view is written to `media_review_views` and a `media.moderation.action` audit++event. `GET /:id` includes `review: { category, status, report_id, hold_id, reviewed_at }`, and `objectPublic`'s++`public_url` is null for a hidden, quarantined or pending object. Rows uploaded before this migration have NULL review++fields and keep serving as they did. Tests: `test/object-review.test.js`.++## S3 surface and zonesMedia answers a subset of S3 at `https://s3.openvibe.media` (ADR-031; DNS-only since 2026-10-21, so an object may be larger than Cloudflare's 100 MB; `https://s3.openvibe.network` is the same surface behind Cloudflare). Each bucket is a zone of a project environment, and zones are managed by OpenVibe.Zone. Media issues and verifies the `ovk_` access keys, and every object stays an ordinary Media object.@@@ -614,7 +637,7 @@ model current. Full reference: **[docs/object-model.md](docs/object-model.md)**.(302 for public, HMAC-signed short-lived URL for private —`MEDIA_SIGNING_SECRET`). App key, or a Network service token with`media.object.upload` / `media.object.read` for namespace `:app`;-- `X-OV-Subject` sets the owner.++ `X-OV-Subject` sets the owner. `POST /:id/review` applies a review (see Unsafe-image review).- **Retention holds** — `media_holds` (`moderation`, `dmca`, `creator_pin`,`admin`, `evidence`; reason, placed by/at, released by/at, note; `/:id/holds`and the staff routes `/api/v1/:app/admin/storage/holds`, app key only): a held@@@ -755,7 +778,7 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does notrestart; afterwards `sudo ovhost rollback media --to <sha>`.<!-- versions:start -->--- openvibe-contracts: v0.289.1++- openvibe-contracts: v0.294.0- openvibe-sdk: v0.82.0- openvibe-shared: v4.115.1<!-- versions:end -->@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, ffmpeg","port": 4100,"domain": "openvibe.media",-- "contracts": "openvibe-contracts v0.289.1",++ "contracts": "openvibe-contracts v0.294.0","sdk": "openvibe-sdk v0.82.0","shared": "openvibe-shared v4.115.1","capabilities": [@@@ -18,7 +18,9 @@"media.derivative.create","media.derivative.read","media.lifecycle.read",-- "media.lifecycle.transition"++ "media.lifecycle.transition",++ "media.object.review.view",++ "media.object.review.decide"],"capabilitiesRegistered": true,"pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO, default https://openvibe.community); Media's read-only paste API is deleted (T10 step 2: /api/v1/:app/pastes is unmounted) and /p/:slug and /p/:slug/raw are unconditional 301s there (no row read), so Media serves only the frozen rows' screenshot bytes",@@@ -36,7 +38,7 @@"media.* events through the SDK outbox as the only outcome announcement","restore-drill mode and N-1 compatibility tests","per-actor limits (openvibe-sdk/limits)",-- "placement demand rollups on Valkey (F2.4): reads per object × region × 5-minute bucket and, for a timeline segment, per segment too (object total is the sum over its segments; hotness over the hour, media.object.hot once per object per hour)",++ "placement demand rollups on Valkey (F2.4): reads per object \u00d7 region \u00d7 5-minute bucket and, for a timeline segment, per segment too (object total is the sum over its segments; hotness over the hour, media.object.hot once per object per hour)","tiering on demand (F2.5): the object sweep promotes and demotes on the hour's Valkey reads against per-class hysteresis bands, one MGET per page, falling back to the daily PostgreSQL counts for a whole sweep when Valkey fails","placement constraints (F2.6): sweep moves refused while their provider fails its class (breaker or probe), and a per-class monthly R2 storage ceiling (maxHotUsdPerMonth) applied in value order, each explained in the decision's inputs","every storage class in the native-object sweep: kind and mime type classify video/image/download, an app may declare game-asset/attachment/backup (or any of the six) in the object's metadata (metadata.class, or media_class/placement_class), and each class spends its own per-sweep budget, hysteresis band and R2 storage ceiling",@@@ -44,9 +46,9 @@"local timeline cache (F3): 10 GiB default budget and 8 GiB free-disk floor, least-recently-served eviction of verified durable copies in the hourly sweep, durable segment fallback, optional timeline_cache readiness and local bytes/evictions metrics; lazy CMAF backfill capped at 20 per hour while recording finalize is exempt","packing after finalize (F3.3, behind MEDIA_HLS_ENABLED): the object.pack job concatenates durable segments into ~60 s chunk objects verified by sha256, each media_timeline row naming its byte range; the segment route serves a packed segment as a ranged read of its chunk; the per-segment keys are deleted once the chunk is durable; segment providers come from the placement router; a finished object.cmaf queues object.pack by itself (dedupeActive: a rerun joins an active pack, a later cut queues a new one)","on-demand renditions (F4 slice 1, behind MEDIA_HLS_ENABLED + MEDIA_RENDITIONS): the rendition.create job transcodes one 720p H.264/AAC rung of a ready media object into media_timeline through the same content-addressed rows, placement-router durable provider and two-phase commit as object.cmaf, forcing the source timeline's segment boundaries as keyframes; a source already 720p or smaller is skipped and a held object is left alone. The master playlist lists a rendition only once its rows exist and queues the missing ones lazily from /o/:id/master.m3u8 and download?format=json (dedupeActive + an object/rendition idempotency key), and /o/:id/<rendition>/index.m3u8 plus /o/:id/<rendition>/<name> serve it",-- "timeline sprites (F3.1, behind MEDIA_HLS_ENABLED): object.sprite cuts its sheet from the object's media_timeline rows — each sample time picks the row covering it and one frame is decoded from that row's bytes (this node's segment, or a packed row's byte range in its chunk), checked against the row's sha256 — with the same layout and metadata as the seek path, so a packed long VOD is never decoded end to end; a source without a timeline keeps one fast seek per frame",-- "HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic — get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own",-- "materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows — the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) — inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input"++ "timeline sprites (F3.1, behind MEDIA_HLS_ENABLED): object.sprite cuts its sheet from the object's media_timeline rows \u2014 each sample time picks the row covering it and one frame is decoded from that row's bytes (this node's segment, or a packed row's byte range in its chunk), checked against the row's sha256 \u2014 with the same layout and metadata as the seek path, so a packed long VOD is never decoded end to end; a source without a timeline keeps one fast seek per frame",++ "HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic \u2014 get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own",++ "materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows \u2014 the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) \u2014 inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input"],"updated": "2026-11-06"}nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- VIS-6's last item: an unsafe-image review sorts an object into one of exactly three categories.++-- fine allowed, including nudity++-- sex_acts never public: the object is made private (kept for its owner)++-- minor_sexual always blocked: quarantined even from a signed read, with the operator's report id++-- A visible upload is 'pending' until Vision answers, so an unreviewed image is never served publicly.++-- Rows uploaded before this migration have NULL and keep serving as they did.++ALTER TABLE media_objects ADD COLUMN review_category text COLLATE "C";++ALTER TABLE media_objects ADD COLUMN review_status text COLLATE "C";++ALTER TABLE media_objects ADD COLUMN review_report_id text COLLATE "C";++ALTER TABLE media_objects ADD COLUMN reviewed_at timestamptz;++ALTER TABLE media_objects ADD CONSTRAINT media_objects_review_category++ CHECK (review_category IS NULL OR review_category IN ('fine', 'sex_acts', 'minor_sexual'));++ALTER TABLE media_objects ADD CONSTRAINT media_objects_review_status++ CHECK (review_status IS NULL OR review_status IN ('pending', 'clear', 'hidden', 'quarantined'));++ALTER TABLE media_objects ADD CONSTRAINT media_objects_review_report++ CHECK (review_report_id IS NULL OR review_report_id ~ '^vor_[0-9A-HJKMNP-TV-Z]{26}$');++CREATE INDEX media_objects_review_pending ON media_objects (review_status, id) WHERE review_status = 'pending';++CREATE INDEX media_objects_review_category ON media_objects (review_category, id) WHERE review_category IS NOT NULL;nnew file mode 100644@@@ -0,0 +1,15 @@++-- phase: expand++-- VIS-6 review follow-up: a category-3 result places an evidence hold in the same transaction as the++-- quarantine, so neither the owner's delete nor retention purge can destroy the object. The hold is++-- released only by an operator's overruling category (fine or sex_acts), with who and when on the hold row.++ALTER TABLE media_objects ADD COLUMN review_hold_id bigint;++-- One row per operator view of a hidden/quarantined object: who, when, and when the 60-second URL expires.++CREATE TABLE media_review_views (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ object_id text COLLATE "C" NOT NULL,++ app_id text COLLATE "C" NOT NULL,++ viewer_sub text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now()++);++CREATE INDEX media_review_views_object ON media_review_views (object_id, created_at DESC);@@@ -16,7 +16,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1","pg": "^8.23.0",@@@ -2122,9 +2122,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.289.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",++ "version": "0.294.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",++ "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -20,7 +20,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1","pg": "^8.23.0",@@@ -724,8 +724,17 @@ async function objectPublic(obj, { locations = true } = {}) {held: await isHeld(obj.id),// metadata / bytes_verified / playable, from this row and its copies' recorded checks (objects/readiness.js).readiness: await require('./readiness').compute(obj, locs),++ // A review that hides or quarantines an object closes its public URL too; a pending review is not public yet.++ review: {++ category: obj.review_category || null,++ status: obj.review_status || null,++ report_id: obj.review_report_id || null,++ hold_id: obj.review_hold_id || null,++ reviewed_at: obj.reviewed_at || null,++ },// Developer-project sandbox objects have no public URL: /download hands out signed ones.public_url: obj.visibility !== 'private' && obj.lifecycle_status === 'ready' && !await db.isSandboxTenant(obj.app_id)++ && obj.review_status !== 'pending' && !['sex_acts', 'minor_sexual'].includes(obj.review_category)? (legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`) : null,created_at: obj.created_at,updated_at: obj.updated_at,nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++++/**++ * OpenVibe.Media's side of the unsafe-image review (VIS-6).++ *++ * Vision is the reviewer: it sorts an image into exactly three categories and, for the hard line, makes the++ * operator's report row. Media is the enforcer:++ *++ * fine clear: the object may be served as before.++ * sex_acts hidden: visibility becomes private, so every public surface (the open /o/:id route, HLS,++ * public_url, a listing's public link) is closed; the owner can still get a signed URL.++ * minor_sexual quarantined: private and blocked even with a signature; the report id is kept on the row.++ *++ * A visible upload is marked `pending` at init and stays unserved until Vision answers. If Vision cannot be++ * reached, it stays pending rather than becoming public. `reviewObject` is also exposed through++ * POST /api/v2/:app/objects/:id/review for an operator re-run and for tests.++ */++const { serviceAuth } = require('openvibe-contracts');++const db = require('../db/database');++const model = require('./model');++++const ACTIONS = Object.freeze({ fine: 'allow', sex_acts: 'hide', minor_sexual: 'quarantine_and_report' });++++/** The Vision client, built from the environment; null when Vision or Media's service principal is unset. */++function visionClient({ fetchImpl = globalThis.fetch } = {}) {++ const base = String(process.env.VISION_URL || process.env.OV_VISION_URL || '').replace(/\/+$/, '');++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';++ if (!base || !clientSecret) return null;++ const network = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${network}/oauth/token`,++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'media',++ clientSecret,++ audience: 'openvibe.vision',++ scope: 'vision.image.review',++ fetchImpl,++ });++ async function review(obj, retried = false) {++ const res = await fetchImpl(`${base}/api/v1/review`, {++ method: 'POST',++ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify({++ object_type: 'media.object',++ object_id: obj.id,++ object_app: obj.app_id,++ media_object_id: obj.id,++ media_app: obj.app_id,++ owner_subject: obj.owner_subject || null,++ image_url: model.legacyPublicUrl(obj) || `${require('../config').publicUrl}/o/${obj.id}`,++ }),++ signal: AbortSignal.timeout(20000),++ });++ if (res.status === 401 && !retried) { try { tokens.invalidate(); } catch { /* older client */ } return await review(obj, true); }++ const body = await res.json().catch(() => null);++ if (!res.ok) throw new Error(`Vision review ${res.status}: ${(body && (body.detail || body.code)) || 'failed'}`);++ if (!body || !['fine', 'sex_acts', 'minor_sexual'].includes(body.category)) throw new Error('Vision review returned no known category');++ return body;++ }++ return { review, enabled: true };++}++++let injected = null;++/** Tests: run with a stand-in Vision client, or null to restore the environment-built one. */++function setReviewClient(client) { injected = client; }++function client() { return injected !== null ? injected : visionClient(); }++/** Is a reviewer configured (or injected for a test)? */++function enabled() { return Boolean(client()); }++++/** Should this object be reviewed? Only an image on a public or unlisted object, and only when Vision is configured. */++function reviewRequired(obj) {++ if (!obj || obj.visibility === 'private') return false;++ if (!/^image\//i.test(String(obj.mime_type || ''))) return false;++ return enabled();++}++++/** The columns a category writes. Exported for the direct operator route and its tests. */++function columns(category, reportId = null) {++ if (!['fine', 'sex_acts', 'minor_sexual'].includes(category)) throw new Error(`unknown review category: ${category}`);++ if (category === 'fine') return { category, status: 'clear', visibility: null, reportId: null };++ if (category === 'sex_acts') return { category, status: 'hidden', visibility: 'private', reportId: null };++ return { category, status: 'quarantined', visibility: 'private', reportId };++}++++/**++ * Write one category. A category-3 result places an `evidence` hold in the same transaction: the owner's++ * delete and retention purge both see the hold and the owner gets the usual neutral 409 held. An operator's++ * overruling category (fine or sex_acts, with `by`) releases that hold, with who and when on the hold row.++ */++async function applyCategory(obj, category, reportId = null, { by = null } = {}) {++ const c = columns(category, reportId);++ await db.getDb().tx(async () => {++ const sets = ['review_category = ?', 'review_status = ?', 'review_report_id = ?', 'reviewed_at = now()'];++ const args = [c.category, c.status, c.reportId];++ if (c.visibility) { sets.push('visibility = ?'); args.push(c.visibility); }++ args.push(obj.id);++ await db.run(`UPDATE media_objects SET ${sets.join(', ')} WHERE id = ?`, args);++ if (category === 'minor_sexual') {++ let hold = await db.get("SELECT id FROM media_holds WHERE object_id = ? AND kind = 'evidence' AND released_at IS NULL ORDER BY id DESC LIMIT 1", [obj.id]);++ if (!hold) {++ const inserted = await db.run("INSERT INTO media_holds (object_id, kind, reason, created_by, note) VALUES (?, 'evidence', ?, ?, ?) RETURNING id", [++ obj.id, 'unsafe-image review: sexual content involving a minor', by || 'svc:vision',++ reportId ? `operator report ${reportId}` : null,++ ]);++ hold = { id: inserted.lastInsertRowid };++ await require('../events').recordModeration(obj.app_id, {++ action: 'review.hold_placed',++ target: { type: obj.kind, id: obj.id, ...(obj.owner_subject ? { owner_subject: obj.owner_subject } : {}) },++ actor_subject: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(by)) ? by : null,++ reason: 'unsafe-image review: sexual content involving a minor',++ details: { hold_id: hold.id, review_category: category, app: obj.app_id, ...(by ? { by: String(by).slice(0, 200) } : {}) },++ });++ }++ await db.run('UPDATE media_objects SET review_hold_id = ? WHERE id = ?', [hold.id, obj.id]);++ } else if (by) {++ const hold = await db.get("SELECT id FROM media_holds WHERE object_id = ? AND kind = 'evidence' AND released_at IS NULL ORDER BY id DESC LIMIT 1", [obj.id]);++ if (hold) {++ await db.run('UPDATE media_holds SET released_at = ov_now(), released_by = ? WHERE id = ? AND released_at IS NULL', [by, hold.id]);++ await db.run('UPDATE media_objects SET review_hold_id = NULL WHERE id = ?', [obj.id]);++ await require('../events').recordModeration(obj.app_id, {++ action: 'review.hold_released',++ target: { type: obj.kind, id: obj.id, ...(obj.owner_subject ? { owner_subject: obj.owner_subject } : {}) },++ actor_subject: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(by)) ? by : null,++ reason: `operator overruled the review to ${category}`,++ details: { hold_id: hold.id, review_category: category, app: obj.app_id, ...(/^usr_/.test(String(by)) ? {} : { by: String(by).slice(0, 200) }) },++ });++ }++ }++ });++ return await model.getObject(obj.id);++}++++/**++ * Ask Vision and apply its answer. A failure leaves the object pending (closed), never public; it does not++ * throw, so a Vision outage does not fail the uploader's already-stored upload.++ */++async function reviewObject(obj, { log = console } = {}) {++ if (!obj) return null;++ if (!reviewRequired(obj)) {++ if (obj.review_status === 'pending') await db.run("UPDATE media_objects SET review_status = 'clear', reviewed_at = now() WHERE id = ?", [obj.id]);++ return obj.review_status === 'pending' ? await model.getObject(obj.id) : obj;++ }++ const c = client();++ try {++ const out = await c.review(obj);++ const reportId = out.report && out.report.id ? out.report.id : null;++ const updated = await applyCategory(obj, out.category, reportId);++ if (out.category === 'sex_acts') log.warn(`[Objects] ${obj.id} hidden: the review found sex acts or porn`);++ if (out.category === 'minor_sexual') log.error(`[Objects] ${obj.id} quarantined: the review found sexual content involving a minor; operator report ${reportId || '(missing)'}`);++ return updated;++ } catch (err) {++ await db.run("UPDATE media_objects SET review_status = 'pending' WHERE id = ?", [obj.id]);++ log.warn(`[Objects] ${obj.id}: review failed, the object stays pending and unserved (${String((err && err.message) || err).slice(0, 200)})`);++ return await model.getObject(obj.id);++ }++}++++/** True when a public reader must not be served this object (a signature does not open a quarantine). */++function publicBlocked(obj, signed = false, operator = false) {++ if (!obj) return true;++ if (operator) return false; // a logged 60-second operator evidence view++ if (obj.review_status === 'pending') return true;++ if (obj.review_category === 'minor_sexual') return true;++ if (obj.review_category === 'sex_acts' && !signed) return true;++ return false;++}++++/** Log one operator evidence view; the event goes through the same moderation audit as a hold. */++async function logOperatorView({ obj, viewerSub, expiresAt }) {++ await db.getDb().tx(async () => {++ await db.run('INSERT INTO media_review_views (object_id, app_id, viewer_sub, expires_at) VALUES (?, ?, ?, ?)', [obj.id, obj.app_id, viewerSub, expiresAt || null]);++ await require('../events').recordModeration(obj.app_id, {++ action: 'review.viewed',++ target: { type: obj.kind, id: obj.id, ...(obj.owner_subject ? { owner_subject: obj.owner_subject } : {}) },++ actor_subject: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(viewerSub)) ? viewerSub : null,++ reason: 'operator evidence view',++ details: { review_category: obj.review_category, app: obj.app_id, expires_at: expiresAt || null, ...(/^usr_/.test(String(viewerSub)) ? {} : { by: String(viewerSub).slice(0, 200) }) },++ });++ });++}++++module.exports = { ACTIONS, visionClient, setReviewClient, enabled, reviewRequired, columns, applyCategory, reviewObject, publicBlocked, logOperatorView };@@@ -61,6 +61,7 @@ const { contentDisposition } = require('./disposition');const { tenantAuth, tenantCors, tenantPath, namespaceGrant } = require('../auth');const { announce } = require('../announce');const { limits } = require('../actor-limits');++const review = require('./review');const MB = 1024 * 1024;// Each route checks its verb for the namespace it works in (the object's, or the one init names).@@@ -69,6 +70,8 @@ const read = tenantAuth({ verb: 'read', namespaced: true, project: true });const list = tenantAuth({ verb: 'list', namespaced: true, project: true });const remove = tenantAuth({ verb: 'delete', namespaced: true, project: true });const appOnly = tenantAuth();++const reviewView = tenantAuth({ capability: 'media.object.review.view', namespaced: true, project: true });++const reviewDecide = tenantAuth({ capability: 'media.object.review.decide', namespaced: true, project: true });// Per-actor limits (server/actor-limits.js), after the credential and before the work. Reads take the// defaults (MEDIA_LIMITS_MINUTE / _HOUR). An init reserves quota and makes a row, and each upload step// writes or hashes bytes, so the upload routes allow a steady app a new object every two seconds at most.@@@ -387,6 +390,12 @@ router.post('/', upload, limits('media.object.upload', UPLOAD), async (req, res)if (made.problem) return problem(res, made.problem.status, made.problem.code, made.problem.detail, made.problem.extra);const id = made.id;const obj = await model.getObject(id);++ // A visible upload is pending until Vision reviews it (objects/review.js). Private objects are not++ // reviewed and keep serving to their owner as before.++ if (visibility !== 'private' && review.enabled()) {++ await db.run("UPDATE media_objects SET review_status = 'pending' WHERE id = ?", [id]);++ obj.review_status = 'pending';++ }let session = null;if (wantParts) {session = await multipart.initiate(obj, { partSize: b.part_size });@@@ -442,6 +451,11 @@ async function completeUpload(req, res, obj, { expectedHash = null } = {}) {payload: async () => await model.objectPublic(await model.getObject(obj.id)),});await namespaces.reconcileChain(req.appRow, obj.namespace);++ const current = await model.getObject(obj.id);++ if (current.review_status === 'pending') {++ const reviewed = await review.reviewObject(current);++ return res.json(await model.objectPublic(reviewed));++ }return res.json(body);}@@@ -596,6 +610,66 @@ router.delete('/:id/multipart/:uploadId', mpWrite, limits('media.object.multiparres.json(await multipart.sessionPublic(await multipart.getSession(session.id), { parts: false }));});++// An operator re-run, and the direct category a caller already decided (the same three Vision stores). This is++// also how a consumer can enforce a label it got from Vision: the action is Media's, not the caller's.++// Re-run Vision on an object (no category body). A category decision is the operator route below, never++// an owner's write: releasing an evidence hold must not be something the object's owner can do.++router.post('/:id/review', upload, limits('media.object.review', { minute: 60, hour: 1200 }), async (req, res) => {++ try {++ const obj = await load(req, res, 'write');++ if (!obj) return;++ if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');++ if (req.body && req.body.category != null) return problem(res, 403, 'capability.denied', 'a category is an operator decision; use POST /review-decision');++ const out = await review.reviewObject(obj);++ return res.json(await model.objectPublic(out));++ } catch (err) {++ console.error('[Objects] review error:', err.message);++ return problem(res, 500, 'media.object.review_failed', 'The review could not be applied');++ }++});++++// An operator's overruling category (fine or sex_acts releases the evidence hold; minor_sexual places it).++// Service principal only: the capability is Vision's, and an app key never sets req.principal.++router.post('/:id/review-decision', reviewDecide, async (req, res) => {++ try {++ if (!req.principal || !req.principal.sub) return problem(res, 403, 'capability.denied', 'an operator service token is required');++ const obj = await model.getObject(String(req.params.id || ''));++ if (!obj || obj.app_id !== req.appId) return problem(res, 404, 'media.object.not_found', 'No such object in this namespace');++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const category = String(b.category || '');++ if (!['fine', 'sex_acts', 'minor_sexual'].includes(category)) return problem(res, 400, 'media.object.invalid', 'category must be fine, sex_acts or minor_sexual');++ const reportId = b.report_id == null ? null : String(b.report_id);++ if (reportId && !/^vor_[0-9A-HJKMNP-TV-Z]{26}$/.test(reportId)) return problem(res, 400, 'media.object.invalid', 'report_id must be an operator report id');++ const decidedBy = b.decided_by == null ? req.principal.sub : String(b.decided_by).slice(0, 200);++ const out = await review.applyCategory(obj, category, reportId, { by: decidedBy });++ if (category === 'minor_sexual') console.error(`[Objects] ${obj.id} quarantined by ${decidedBy}; report ${reportId || '(none)'}`);++ return res.json(await model.objectPublic(out));++ } catch (err) {++ console.error('[Objects] review decision error:', err.message);++ return problem(res, 500, 'media.object.review_failed', 'The decision could not be applied');++ }++});++++// A 60-second operator evidence view of a hidden or quarantined object. Only Vision's service token holds++// media.object.review.view; the view is logged (media_review_views plus a moderation event) before the URL++// is answered, and the URL's purpose is separate from every normal download signature.++router.post('/:id/operator-view', reviewView, async (req, res) => {++ try {++ if (!req.principal || !req.principal.sub) return problem(res, 403, 'capability.denied', 'an operator service token is required');++ const obj = await model.getObject(String(req.params.id || ''));++ if (!obj || obj.app_id !== req.appId) return problem(res, 404, 'media.object.not_found', 'No such object in this namespace');++ if (!['sex_acts', 'minor_sexual'].includes(obj.review_category) && !['hidden', 'quarantined'].includes(obj.review_status)) {++ return problem(res, 409, 'media.object.not_reviewed', 'only a hidden or quarantined object has an operator view');++ }++ const signed = signing.signedOperatorUrl(obj.id, 60);++ await review.logOperatorView({ obj, viewerSub: req.principal.sub, expiresAt: signed.expires_at });++ return res.json({ object_id: obj.id, review_category: obj.review_category, review_status: obj.review_status, expires_at: signed.expires_at, url: signed.url });++ } catch (err) {++ console.error('[Objects] operator view error:', err.message);++ return problem(res, 500, 'media.object.review_view_failed', 'The operator view could not be made');++ }++});++router.get('/', list, limits('media.object.list'), async (req, res) => {try {const q = req.query;@@@ -690,6 +764,7 @@ router.get('/:id/download', read, limits('media.object.download'), async (req, rif (!obj) return;if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Object was deleted');if (obj.lifecycle_status !== 'ready') return problem(res, 409, 'media.object.not_ready', `Object is ${obj.lifecycle_status}`);++ if (obj.review_status === 'pending' || obj.review_category === 'minor_sexual') return problem(res, 404, 'media.object.not_found', 'No such object');res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (req.query.format === 'mp4') return await downloadMp4(req, res, obj);const json = req.query.format === 'json';@@@ -700,7 +775,7 @@ router.get('/:id/download', read, limits('media.object.download'), async (req, r// A clip without its own timeline plays its window of the source's (F3.5) and is never cut a CMAF timeline here.// The same answer shapes the v1 VOD and clip (objects/hls.js discovery; vodPublic/clipPublic).const hlsInfo = json ? await hls.discoveryForObject(obj) : null;-- if (obj.visibility !== 'private' && !await db.isSandboxTenant(obj.app_id)) {++ if (obj.review_category !== 'sex_acts' && obj.visibility !== 'private' && !await db.isSandboxTenant(obj.app_id)) {const url = model.legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`;return json ? res.json({ url, expires_at: null, public: true, ...(hlsInfo || {}) }) : res.redirect(302, url);}@@@ -839,7 +914,9 @@ async function hlsObject(req, res, next) {if (!obj) { res.status(404).json({ error: 'Not found' }); return null; }// A playlist token (purpose 'hls', from /download's hls_url) or, as before, the object's download signature.const signed = !!req.query.sig && (signing.verifyPlaylist(obj.id, req.query.exp, req.query.sig) || signing.verifyDownload(obj.id, req.query.exp, req.query.sig));-- const closed = obj.visibility === 'private' || await db.isSandboxTenant(obj.app_id);++ if (review.publicBlocked(obj, signed)) { res.status(404).json({ error: 'Not found' }); return null; }++ const closed = obj.visibility === 'private' || obj.review_category === 'sex_acts' || obj.review_category === 'minor_sexual'++ || obj.review_status === 'pending' || await db.isSandboxTenant(obj.app_id);if (closed && !signed) { res.status(404).json({ error: 'Not found' }); return null; }if (obj.lifecycle_status === 'deleted') { res.status(410).json({ error: 'Gone' }); return null; }if (obj.lifecycle_status !== 'ready') { res.status(404).json({ error: 'Not found' }); return null; }@@@ -1002,11 +1079,16 @@ publicRouter.get('/:id', async (req, res) => {const obj = await model.getObject(String(req.params.id || ''));if (!obj) return res.status(404).json({ error: 'Not found' });const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);++ const operator = !!req.query.sig && signing.verifyOperator(obj.id, req.query.exp, req.query.sig);++ // A pending review, a quarantine and a hidden sex-act object are indistinguishable from missing,++ // exactly like a private object. A signed read opens a sex-act object for its owner; a separate,++ // 60-second operator signature opens either hidden category for the review page (and is logged).++ if (review.publicBlocked(obj, signed, operator)) return res.status(404).json({ error: 'Not found' });// Private objects (and every developer-project sandbox object) are indistinguishable from// missing ones without a valid signature — checked before the lifecycle answers, or a 410// for a deleted private object would still say it existed.const sandbox = await db.isSandboxTenant(obj.app_id);-- if ((obj.visibility === 'private' || sandbox) && !signed) return res.status(404).json({ error: 'Not found' });++ if ((obj.visibility === 'private' || sandbox) && !signed && !operator) return res.status(404).json({ error: 'Not found' });if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });@@@ -57,6 +57,25 @@ function verifyDownload(objectId, exp, sig) {return safeEqual(sig, mac('get', objectId, e));}++/**++ * A 60-second operator evidence view of a hidden/quarantined object. Its own purpose ('operator') so a++ * normal download signature never opens a quarantine and this one expires quickly; the route that mints it++ * logs who asked. Only the public object route accepts it.++ */++function signedOperatorUrl(objectId, ttlS = 60) {++ const exp = nowS() + Math.min(300, Math.max(30, Number(ttlS) || 60));++ return {++ url: `${config.publicUrl}/o/${encodeURIComponent(objectId)}?exp=${exp}&sig=${mac('operator', objectId, exp)}`,++ expires_at: new Date(exp * 1000).toISOString(),++ };++}++++function verifyOperator(objectId, exp, sig) {++ const e = Number(exp);++ if (!Number.isInteger(e) || e < nowS() || e > nowS() + 300) return false;++ return safeEqual(sig, mac('operator', objectId, e));++}++/*** Signed HLS master playlist of an object (/o/:id/master.m3u8), carried onto every playlist and segment URI under it.* Its own purpose ('hls'): a viewer plays a long VOD on one page load, so it lives longer than a download URL@@@ -180,6 +199,6 @@ function verifyMultipartToken(token, { tenant, objectId, uploadId, totalSize })}module.exports = {-- signedDownloadUrl, verifyDownload, signedPlaylistUrl, verifyPlaylist, signedFileUrl, verifyFile, signedMediaUrl, verifyMedia,++ signedDownloadUrl, verifyDownload, signedOperatorUrl, verifyOperator, signedPlaylistUrl, verifyPlaylist, signedFileUrl, verifyFile, signedMediaUrl, verifyMedia,uploadToken, uploadTtl, checkUploadToken, verifyUploadToken, multipartToken, verifyMultipartToken,};nnew file mode 100644@@@ -0,0 +1,224 @@++'use strict';++// VIS-6 in Media: an unsafe-image review's three categories are enforcement, not advice.++// fine the object serves as before++// sex_acts hidden from every public surface; a signed read still keeps it for its owner++// minor_sexual quarantined even from a signed read, with the operator's report id on the row++// A visible upload is pending until Vision answers, so an outage cannot publish an unreviewed image.++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 { ids, serviceAuth } = require('openvibe-contracts');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-review-'));++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++ process.env.VOD_PATH = dir('vods');++ process.env.FILES_PATH = dir('files');++ process.env.OBJECTS_PATH = dir('objects');++ process.env.THUMBNAILS_PATH = dir('thumbnails');++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++ process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';++ process.env.MEDIA_OBJECT_MAX_MB = '2';++ process.env.MEDIA_PUBLIC_OBJECT_MAX_MB = '10';++ process.env.OV_NETWORK_INTERNAL_URL = 'http://127.0.0.1:1'; // replaced below with the mock's address++ process.env.OV_OAUTH_CLIENT_ID = 'media';++ process.env.OV_OAUTH_CLIENT_SECRET = 'media-secret';++++ // ── Vision, as Media sees it: a token, then one review per image ──++ const state = { category: 'fine', fail: false, requests: [] };++ const vision = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const body = Buffer.concat(chunks);++ const send = (status, obj) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return send(200, { access_token: 'svc-vision-review', token_type: 'Bearer', expires_in: 300 });++ if (req.url === '/api/v1/review') {++ state.requests.push(JSON.parse(body.toString('utf8')));++ if (state.fail) return send(503, { code: 'vision.review_unavailable', detail: 'the reviewer is down' });++ const out = { category: state.category, action: state.category === 'fine' ? 'allow' : state.category === 'sex_acts' ? 'hide' : 'quarantine_and_report', public: state.category === 'fine', report: state.category === 'minor_sexual' ? { id: 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ', status: 'open' } : null };++ return send(201, out);++ }++ send(404, {});++ });++ });++ await new Promise((r) => vision.listen(0, '127.0.0.1', r));++ const visionBase = `http://127.0.0.1:${vision.address().port}`;++ process.env.VISION_URL = visionBase;++ process.env.OV_NETWORK_INTERNAL_URL = visionBase;++++ const db = require('../server/db/database');++ const auth = require('../server/auth');++ const objectRoutes = require('../server/objects/routes');++ const express = require('express');++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ auth._setNetworkPublicKeyForTests(keys.publicKey);++ const nowS = Math.floor(Date.now() / 1000);++ const svc = (cap) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:vision', actor_type: 'service', aud: ['openvibe.media'], cap, ns: ['live'], iat: nowS, exp: nowS + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);++++ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });++ const app = express();++ app.put('/api/v2/:app/objects/:id/content', ...objectRoutes.contentHandlers);++ app.use(express.json());++ app.use('/api/v2/:app/objects', objectRoutes);++ app.use('/o', objectRoutes.publicRouter);++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const local = (u) => base + String(u).replace('https://media.test', '');++ const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');++ const png = await require('sharp')({ create: { width: 80, height: 60, channels: 3, background: '#3355aa' } }).png().toBuffer();++ const O = '/api/v2/live/objects';++ const call = async (method, p, { body, raw, headers = {}, bearer = 'live-key' } = {}) => {++ const h = { authorization: `Bearer ${bearer}`, ...headers };++ let payload;++ if (raw !== undefined) payload = raw;++ else if (body !== undefined) { h['content-type'] = 'application/json'; payload = JSON.stringify(body); }++ const res = await fetch(p.startsWith('http') ? p : base + p, { method, headers: h, body: payload, redirect: 'manual' });++ const text = await res.text();++ let json = null; try { json = JSON.parse(text); } catch { /* bytes */ }++ return { status: res.status, body: json, text, headers: res.headers };++ };++ async function upload({ kind = 'screenshot', visibility = 'public', mime = 'image/png', bytes = png } = {}) {++ let r = await call('POST', O, { body: { kind, visibility, mime_type: mime, size_bytes: bytes.length, content_hash: sha(bytes) } });++ assert.strictEqual(r.status, 201, r.text);++ const id = r.body.id;++ r = await call('PUT', `${O}/${id}/content`, { raw: bytes, headers: { 'content-type': mime } });++ assert.strictEqual(r.status, 200, r.text);++ r = await call('POST', `${O}/${id}/complete`);++ assert.strictEqual(r.status, 200, r.text);++ return { id, complete: r.body };++ }++++ let passed = 0;++ const check = async (name, fn) => { await fn(); passed++; console.log(` ✓ ${name}`); };++++ await check('fine: the image serves publicly, and the row says clear', async () => {++ state.category = 'fine';++ const { id, complete } = await upload();++ globalThis.__fineId = id;++ assert.strictEqual(complete.review.category, 'fine');++ assert.strictEqual(complete.review.status, 'clear');++ assert.strictEqual(complete.public_url, `https://media.test/o/${id}`);++ const got = await fetch(`${base}/o/${id}`);++ assert.strictEqual(got.status, 200);++ assert.strictEqual((await call('GET', `${O}/${id}`)).body.visibility, 'public');++ });++++ await check('sex_acts: hidden from /o, private in the row, and a signed read still reaches it for its owner', async () => {++ state.category = 'sex_acts';++ const { id, complete } = await upload();++ assert.deepStrictEqual([complete.review.category, complete.review.status, complete.visibility, complete.public_url], ['sex_acts', 'hidden', 'private', null]);++ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 404);++ const signed = await call('GET', `${O}/${id}/download`);++ assert.strictEqual(signed.status, 200, JSON.stringify(signed.body));++ assert.strictEqual(signed.body.public, false);++ const bytes = await fetch(local(signed.body.url));++ assert.strictEqual(bytes.status, 200, 'the owner keeps the bytes through a signed link');++ assert.strictEqual(Buffer.from(await bytes.arrayBuffer()).length, png.length);++ });++++ await check('minor_sexual: quarantined even with a signature, report id kept on the row', async () => {++ state.category = 'minor_sexual';++ const { id, complete } = await upload();++ assert.deepStrictEqual([complete.review.category, complete.review.status, complete.visibility, complete.public_url], ['minor_sexual', 'quarantined', 'private', null]);++ assert.match(complete.review.report_id, /^vor_/);++ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 404);++ assert.strictEqual((await call('GET', `${O}/${id}/download`)).status, 404, 'a signed download is closed too');++ const row = await db.get('SELECT review_category, review_status, review_report_id FROM media_objects WHERE id = ?', [id]);++ assert.deepStrictEqual([row.review_category, row.review_status], ['minor_sexual', 'quarantined']);++ assert.match(row.review_report_id, /^vor_/);++ });++++ await check('a Vision outage leaves the object pending and unserved; a later re-review clears it', async () => {++ state.category = 'fine';++ state.fail = true;++ const { id, complete } = await upload();++ assert.deepStrictEqual([complete.review.status, complete.public_url], ['pending', null]);++ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 404, 'pending is not public');++ state.fail = false;++ const again = await call('POST', `${O}/${id}/review`);++ assert.strictEqual(again.status, 200, again.text);++ assert.deepStrictEqual([again.body.review.category, again.body.review.status], ['fine', 'clear']);++ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 200);++ });++++ await check('a non-image is never sent to Vision', async () => {++ const before = state.requests.length;++ const text = Buffer.from('hello world');++ const { complete } = await upload({ kind: 'file', mime: 'text/plain', bytes: text });++ assert.strictEqual(state.requests.length, before, 'text files are not reviewed');++ assert.deepStrictEqual([complete.review.status, complete.public_url !== null], ['clear', true]);++ });++++ await check('an operator decision needs the service capability; the same enforcement follows', async () => {++ state.category = 'fine';++ const { id } = await upload();++ const no = await call('POST', `${O}/${id}/review-decision`, { body: { category: 'sex_acts' } });++ assert.deepStrictEqual([no.status, no.body.code], [403, 'capability.denied']);++ const r = await call('POST', `${O}/${id}/review-decision`, { body: { category: 'sex_acts' }, bearer: svc(['media.object.review.decide']) });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.body.review.category, r.body.review.status, r.body.visibility], ['sex_acts', 'hidden', 'private']);++ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 404);++ const bad = await call('POST', `${O}/${id}/review-decision`, { body: { category: 'nudity' }, bearer: svc(['media.object.review.decide']) });++ assert.deepStrictEqual([bad.status, bad.body.code], [400, 'media.object.invalid']);++ });++++ await check('minor_sexual places an evidence hold; the owner delete is the usual neutral 409', async () => {++ state.category = 'minor_sexual';++ const { id } = await upload();++ const hold = await db.get("SELECT id, kind, released_at, released_by FROM media_holds WHERE object_id = ? AND kind = 'evidence' AND released_at IS NULL", [id]);++ assert.ok(hold && hold.id, 'an open evidence hold exists');++ const del = await call('DELETE', `${O}/${id}`);++ assert.deepStrictEqual([del.status, del.body.code], [409, 'media.object.held']);++ // A second category-3 decision does not duplicate the hold.++ await call('POST', `${O}/${id}/review-decision`, { body: { category: 'minor_sexual', report_id: 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, bearer: svc(['media.object.review.decide']) });++ const count = await db.get("SELECT COUNT(*)::int AS n FROM media_holds WHERE object_id = ? AND kind = 'evidence'", [id]);++ assert.strictEqual(Number(count.n), 1);++ });++++ await check('operator view: only the review.view capability, only hidden/quarantined, and it opens a 60-second URL', async () => {++ state.category = 'minor_sexual';++ const { id } = await upload();++ const no = await call('POST', `${O}/${id}/operator-view`, { body: {} });++ assert.deepStrictEqual([no.status, no.body.code], [403, 'capability.denied'], 'an app key is not an operator');++ const wrong = await call('POST', `${O}/${id}/operator-view`, { body: {}, bearer: svc(['media.object.review.decide']) });++ assert.strictEqual(wrong.status, 403, 'another capability is not view');++ const r = await call('POST', `${O}/${id}/operator-view`, { body: {}, bearer: svc(['media.object.review.view']) });++ assert.strictEqual(r.status, 200, r.text);++ assert.match(r.body.url, /\?exp=\d+&sig=/);++ const ttl = Math.round((Date.parse(r.body.expires_at) - Date.now()) / 1000);++ assert.ok(ttl >= 30 && ttl <= 60, `a 60-second view, got ${ttl}s`);++ const bytes = await fetch(local(r.body.url));++ assert.strictEqual(bytes.status, 200, 'the operator URL opens a quarantined object');++ const fine = await call('POST', `${O}/${globalThis.__fineId}/operator-view`, { body: {}, bearer: svc(['media.object.review.view']) });++ assert.deepStrictEqual([fine.status, fine.body.code], [409, 'media.object.not_reviewed']);++ // A past operator signature does not open it.++ const past = nowS - 10;++ const sig = crypto.createHmac('sha256', 'test-signing-secret').update(`operator\n${id}\n${past}`).digest('base64url');++ assert.strictEqual((await fetch(`${base}/o/${id}?exp=${past}&sig=${sig}`)).status, 404);++ const viewed = await db.get('SELECT viewer_sub, expires_at FROM media_review_views WHERE object_id = ? ORDER BY id DESC LIMIT 1', [id]);++ assert.strictEqual(viewed.viewer_sub, 'svc:vision');++ });++++ await check('an operator overruling to fine releases the evidence hold with who and when', async () => {++ state.category = 'minor_sexual';++ const { id } = await upload();++ const open = await db.get("SELECT id FROM media_holds WHERE object_id = ? AND kind = 'evidence' AND released_at IS NULL", [id]);++ assert.ok(open);++ const r = await call('POST', `${O}/${id}/review-decision`, { body: { category: 'fine', report_id: 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ', decided_by: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, bearer: svc(['media.object.review.decide']) });++ assert.strictEqual(r.status, 200, r.text);++ const released = await db.get('SELECT released_at, released_by FROM media_holds WHERE id = ?', [open.id]);++ assert.ok(released.released_at);++ assert.strictEqual(released.released_by, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ');++ const del = await call('DELETE', `${O}/${id}`);++ assert.strictEqual(del.status, 200, del.text);++ });++++ console.log(`object review: ${passed} checks passed`);++ await new Promise((r) => server.close(r));++ await new Promise((r) => vision.close(r));++})().catch((e) => { console.error(e); process.exit(1); });
@@ -13,6 +13,16 @@
@@ -13,6 +13,16 @@
(`media.object.delete`, principal `svc:community`) and soft-deletes exactly a `legacy:live:paste:<slug>`
(`media.object.delete`, principal `svc:community`) and soft-deletes exactly a `legacy:live:paste:<slug>`
screenshot: 410 at `/o/:id`, the ordinary retention window and `media.object.deleted` event, and a repeat
screenshot: 410 at `/o/:id`, the ordinary retention window and `media.object.deleted` event, and a repeat
is a no-op. VODs, clips, files, thumbnails, avatars and native objects are refused, and so is any other principal.
is a no-op. VODs, clips, files, thumbnails, avatars and native objects are refused, and so is any other principal.
+- Review follow-up: a category-3 result places an `evidence` hold in the quarantine transaction, so owner delete and
+ retention purge answer 409 held; an operator's overruling category releases it with who and when. Adds Vision's
+ logged 60-second `operator-view` and service-only `review-decision` routes, a separate operator signature purpose,
+ `media_review_views`, migration `0019_review_evidence.sql`, and Contracts v0.294.0.
+- Unsafe-image review enforcement (VIS-6): a visible upload is pending until OpenVibe.Vision (`VISION_URL` plus
+ Media's service principal) sorts it into `fine`, `sex_acts` or `minor_sexual`. Sex acts become private and leave
+ every public surface; sexual content involving a minor is quarantined even from a signed read and keeps Vision's
+ operator report id. A reviewer outage leaves the object pending, never public. Migration
+ `0018_object_reviews.sql`, `server/objects/review.js`, `POST /api/v2/:app/objects/:id/review`, tests
+ `test/object-review.test.js`.
- A tenant for the screenshot pass (plan L9, devblog §3.3): OpenVibe.Host stores the PNGs it captures (ovhost shots) in its own
- A tenant for the screenshot pass (plan L9, devblog §3.3): OpenVibe.Host stores the PNGs it captures (ovhost shots) in its own
tenant, `host`, through the Object API v2. The tenant is created at boot like the other first-party ones, and reached only
tenant, `host`, through the Object API v2. The tenant is created at boot like the other first-party ones, and reached only
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
the account and revocation subscriptions)
the account and revocation subscriptions)
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
-- `openvibe-contracts` v0.289.1, `openvibe-sdk` v0.82.0 (tokens, events outbox, usage readings, per-actor limits),
+- `openvibe-contracts` v0.294.0, `openvibe-sdk` v0.82.0 (tokens, events outbox, usage readings, per-actor limits),
`openvibe-shared` v4.115.1, pinned by release tarball
`openvibe-shared` v4.115.1, pinned by release tarball
## Capabilities
## Capabilities
@@ -129,7 +129,7 @@ server/
@@ -129,7 +129,7 @@ server/
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
client-ip.js trust proxy = loopback; req.ip is the only client address
client-ip.js trust proxy = loopback; req.ip is the only client address
-(openvibe-shared v4.115.1, openvibe-contracts v0.289.1, openvibe-sdk v0.82.0: pinned release tarballs, installed by npm)
+(openvibe-shared v4.115.1, openvibe-contracts v0.294.0, openvibe-sdk v0.82.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
@@ -593,6 +593,29 @@ without a signature is a 404 like any other private one). A native object in the
@@ -593,6 +593,29 @@ without a signature is a 404 like any other private one). A native object in the
R2 popularity cache redirects to its verified R2 copy; a GET of a native object
R2 popularity cache redirects to its verified R2 copy; a GET of a native object
counts its viewer for the day, identifying no one (see Storage tiering).
counts its viewer for the day, identifying no one (see Storage tiering).
+## Unsafe-image review (VIS-6)
+
+A visible upload is marked `review_status = 'pending'` and is not served until OpenVibe.Vision (configured by
+`VISION_URL` or `OV_VISION_URL`, plus Media's Network service principal) answers. The reviewer sorts it into exactly
+three categories:
+
+| Category | Media's action |
+|---|---|
+| `fine` | `clear`: the object serves as before |
+| `sex_acts` | `hidden`: `visibility = private`; `/o/:id`, its HLS playlists, `public_url` and every public surface close; a signed download still reaches it for its owner |
+| `minor_sexual` | `quarantined`: private and 404 even with a signature; Vision's operator report id is kept in `review_report_id`, and an `evidence` hold is placed in the same transaction so the owner's delete or retention purge cannot destroy it |
+
+If Vision is unreachable, the object stays `pending` (closed), never public. `POST /api/v2/:app/objects/:id/review`
+re-runs the review. A category is an operator decision:
+`POST /api/v2/:app/objects/:id/review-decision` needs the service capability `media.object.review.decide` (Vision's),
+takes `fine | sex_acts | minor_sexual`, and releases the evidence hold only for `fine` or `sex_acts` with
+`released_by`/`released_at` on the hold row. `POST /api/v2/:app/objects/:id/operator-view` needs
+`media.object.review.view`, refuses a fine or pending object, and answers a 60-second signed URL under a separate
+`operator` signature purpose; every view is written to `media_review_views` and a `media.moderation.action` audit
+event. `GET /:id` includes `review: { category, status, report_id, hold_id, reviewed_at }`, and `objectPublic`'s
+`public_url` is null for a hidden, quarantined or pending object. Rows uploaded before this migration have NULL review
+fields and keep serving as they did. Tests: `test/object-review.test.js`.
+
## S3 surface and zones
## S3 surface and zones
Media answers a subset of S3 at `https://s3.openvibe.media` (ADR-031; DNS-only since 2026-10-21, so an object may be larger than Cloudflare's 100 MB; `https://s3.openvibe.network` is the same surface behind Cloudflare). Each bucket is a zone of a project environment, and zones are managed by OpenVibe.Zone. Media issues and verifies the `ovk_` access keys, and every object stays an ordinary Media object.
Media answers a subset of S3 at `https://s3.openvibe.media` (ADR-031; DNS-only since 2026-10-21, so an object may be larger than Cloudflare's 100 MB; `https://s3.openvibe.network` is the same surface behind Cloudflare). Each bucket is a zone of a project environment, and zones are managed by OpenVibe.Zone. Media issues and verifies the `ovk_` access keys, and every object stays an ordinary Media object.
@@ -614,7 +637,7 @@ model current. Full reference: **[docs/object-model.md](docs/object-model.md)**.
@@ -614,7 +637,7 @@ model current. Full reference: **[docs/object-model.md](docs/object-model.md)**.
(302 for public, HMAC-signed short-lived URL for private —
(302 for public, HMAC-signed short-lived URL for private —
`MEDIA_SIGNING_SECRET`). App key, or a Network service token with
`MEDIA_SIGNING_SECRET`). App key, or a Network service token with
`media.object.upload` / `media.object.read` for namespace `:app`;
`media.object.upload` / `media.object.read` for namespace `:app`;
- `X-OV-Subject` sets the owner.
+ `X-OV-Subject` sets the owner. `POST /:id/review` applies a review (see Unsafe-image review).
- **Retention holds** — `media_holds` (`moderation`, `dmca`, `creator_pin`,
- **Retention holds** — `media_holds` (`moderation`, `dmca`, `creator_pin`,
`admin`, `evidence`; reason, placed by/at, released by/at, note; `/:id/holds`
`admin`, `evidence`; reason, placed by/at, released by/at, note; `/:id/holds`
and the staff routes `/api/v1/:app/admin/storage/holds`, app key only): a held
and the staff routes `/api/v1/:app/admin/storage/holds`, app key only): a held
@@ -755,7 +778,7 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not
@@ -755,7 +778,7 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not
restart; afterwards `sudo ovhost rollback media --to <sha>`.
restart; afterwards `sudo ovhost rollback media --to <sha>`.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.289.1
+- openvibe-contracts: v0.294.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
- openvibe-shared: v4.115.1
- openvibe-shared: v4.115.1
<!-- versions:end -->
<!-- versions:end -->
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, ffmpeg",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, ffmpeg",
"port": 4100,
"port": 4100,
"domain": "openvibe.media",
"domain": "openvibe.media",
- "contracts": "openvibe-contracts v0.289.1",
+ "contracts": "openvibe-contracts v0.294.0",
"sdk": "openvibe-sdk v0.82.0",
"sdk": "openvibe-sdk v0.82.0",
"shared": "openvibe-shared v4.115.1",
"shared": "openvibe-shared v4.115.1",
"capabilities": [
"capabilities": [
@@ -18,7 +18,9 @@
@@ -18,7 +18,9 @@
"media.derivative.create",
"media.derivative.create",
"media.derivative.read",
"media.derivative.read",
"media.lifecycle.read",
"media.lifecycle.read",
- "media.lifecycle.transition"
+ "media.lifecycle.transition",
+ "media.object.review.view",
+ "media.object.review.decide"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO, default https://openvibe.community); Media's read-only paste API is deleted (T10 step 2: /api/v1/:app/pastes is unmounted) and /p/:slug and /p/:slug/raw are unconditional 301s there (no row read), so Media serves only the frozen rows' screenshot bytes",
"pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO, default https://openvibe.community); Media's read-only paste API is deleted (T10 step 2: /api/v1/:app/pastes is unmounted) and /p/:slug and /p/:slug/raw are unconditional 301s there (no row read), so Media serves only the frozen rows' screenshot bytes",
@@ -36,7 +38,7 @@
@@ -36,7 +38,7 @@
"media.* events through the SDK outbox as the only outcome announcement",
"media.* events through the SDK outbox as the only outcome announcement",
"restore-drill mode and N-1 compatibility tests",
"restore-drill mode and N-1 compatibility tests",
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
- "placement demand rollups on Valkey (F2.4): reads per object × region × 5-minute bucket and, for a timeline segment, per segment too (object total is the sum over its segments; hotness over the hour, media.object.hot once per object per hour)",
+ "placement demand rollups on Valkey (F2.4): reads per object \u00d7 region \u00d7 5-minute bucket and, for a timeline segment, per segment too (object total is the sum over its segments; hotness over the hour, media.object.hot once per object per hour)",
"tiering on demand (F2.5): the object sweep promotes and demotes on the hour's Valkey reads against per-class hysteresis bands, one MGET per page, falling back to the daily PostgreSQL counts for a whole sweep when Valkey fails",
"tiering on demand (F2.5): the object sweep promotes and demotes on the hour's Valkey reads against per-class hysteresis bands, one MGET per page, falling back to the daily PostgreSQL counts for a whole sweep when Valkey fails",
"placement constraints (F2.6): sweep moves refused while their provider fails its class (breaker or probe), and a per-class monthly R2 storage ceiling (maxHotUsdPerMonth) applied in value order, each explained in the decision's inputs",
"placement constraints (F2.6): sweep moves refused while their provider fails its class (breaker or probe), and a per-class monthly R2 storage ceiling (maxHotUsdPerMonth) applied in value order, each explained in the decision's inputs",
"every storage class in the native-object sweep: kind and mime type classify video/image/download, an app may declare game-asset/attachment/backup (or any of the six) in the object's metadata (metadata.class, or media_class/placement_class), and each class spends its own per-sweep budget, hysteresis band and R2 storage ceiling",
"every storage class in the native-object sweep: kind and mime type classify video/image/download, an app may declare game-asset/attachment/backup (or any of the six) in the object's metadata (metadata.class, or media_class/placement_class), and each class spends its own per-sweep budget, hysteresis band and R2 storage ceiling",
@@ -44,9 +46,9 @@
@@ -44,9 +46,9 @@
"local timeline cache (F3): 10 GiB default budget and 8 GiB free-disk floor, least-recently-served eviction of verified durable copies in the hourly sweep, durable segment fallback, optional timeline_cache readiness and local bytes/evictions metrics; lazy CMAF backfill capped at 20 per hour while recording finalize is exempt",
"local timeline cache (F3): 10 GiB default budget and 8 GiB free-disk floor, least-recently-served eviction of verified durable copies in the hourly sweep, durable segment fallback, optional timeline_cache readiness and local bytes/evictions metrics; lazy CMAF backfill capped at 20 per hour while recording finalize is exempt",
"packing after finalize (F3.3, behind MEDIA_HLS_ENABLED): the object.pack job concatenates durable segments into ~60 s chunk objects verified by sha256, each media_timeline row naming its byte range; the segment route serves a packed segment as a ranged read of its chunk; the per-segment keys are deleted once the chunk is durable; segment providers come from the placement router; a finished object.cmaf queues object.pack by itself (dedupeActive: a rerun joins an active pack, a later cut queues a new one)",
"packing after finalize (F3.3, behind MEDIA_HLS_ENABLED): the object.pack job concatenates durable segments into ~60 s chunk objects verified by sha256, each media_timeline row naming its byte range; the segment route serves a packed segment as a ranged read of its chunk; the per-segment keys are deleted once the chunk is durable; segment providers come from the placement router; a finished object.cmaf queues object.pack by itself (dedupeActive: a rerun joins an active pack, a later cut queues a new one)",
"on-demand renditions (F4 slice 1, behind MEDIA_HLS_ENABLED + MEDIA_RENDITIONS): the rendition.create job transcodes one 720p H.264/AAC rung of a ready media object into media_timeline through the same content-addressed rows, placement-router durable provider and two-phase commit as object.cmaf, forcing the source timeline's segment boundaries as keyframes; a source already 720p or smaller is skipped and a held object is left alone. The master playlist lists a rendition only once its rows exist and queues the missing ones lazily from /o/:id/master.m3u8 and download?format=json (dedupeActive + an object/rendition idempotency key), and /o/:id/<rendition>/index.m3u8 plus /o/:id/<rendition>/<name> serve it",
"on-demand renditions (F4 slice 1, behind MEDIA_HLS_ENABLED + MEDIA_RENDITIONS): the rendition.create job transcodes one 720p H.264/AAC rung of a ready media object into media_timeline through the same content-addressed rows, placement-router durable provider and two-phase commit as object.cmaf, forcing the source timeline's segment boundaries as keyframes; a source already 720p or smaller is skipped and a held object is left alone. The master playlist lists a rendition only once its rows exist and queues the missing ones lazily from /o/:id/master.m3u8 and download?format=json (dedupeActive + an object/rendition idempotency key), and /o/:id/<rendition>/index.m3u8 plus /o/:id/<rendition>/<name> serve it",
- "timeline sprites (F3.1, behind MEDIA_HLS_ENABLED): object.sprite cuts its sheet from the object's media_timeline rows — each sample time picks the row covering it and one frame is decoded from that row's bytes (this node's segment, or a packed row's byte range in its chunk), checked against the row's sha256 — with the same layout and metadata as the seek path, so a packed long VOD is never decoded end to end; a source without a timeline keeps one fast seek per frame",
- "HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic — get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own",
- "materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows — the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) — inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input"
+ "timeline sprites (F3.1, behind MEDIA_HLS_ENABLED): object.sprite cuts its sheet from the object's media_timeline rows \u2014 each sample time picks the row covering it and one frame is decoded from that row's bytes (this node's segment, or a packed row's byte range in its chunk), checked against the row's sha256 \u2014 with the same layout and metadata as the seek path, so a packed long VOD is never decoded end to end; a source without a timeline keeps one fast seek per frame",
+ "HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic \u2014 get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own",
+ "materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows \u2014 the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) \u2014 inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input"
],
],
"updated": "2026-11-06"
"updated": "2026-11-06"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- VIS-6's last item: an unsafe-image review sorts an object into one of exactly three categories.
+-- fine allowed, including nudity
+-- sex_acts never public: the object is made private (kept for its owner)
+-- minor_sexual always blocked: quarantined even from a signed read, with the operator's report id
+-- A visible upload is 'pending' until Vision answers, so an unreviewed image is never served publicly.
+-- Rows uploaded before this migration have NULL and keep serving as they did.
+ALTER TABLE media_objects ADD COLUMN review_category text COLLATE "C";
+ALTER TABLE media_objects ADD COLUMN review_status text COLLATE "C";
+ALTER TABLE media_objects ADD COLUMN review_report_id text COLLATE "C";
+ALTER TABLE media_objects ADD COLUMN reviewed_at timestamptz;
+ALTER TABLE media_objects ADD CONSTRAINT media_objects_review_category
+ CHECK (review_category IS NULL OR review_category IN ('fine', 'sex_acts', 'minor_sexual'));
+ALTER TABLE media_objects ADD CONSTRAINT media_objects_review_status
+ CHECK (review_status IS NULL OR review_status IN ('pending', 'clear', 'hidden', 'quarantined'));
+ALTER TABLE media_objects ADD CONSTRAINT media_objects_review_report
+ CHECK (review_report_id IS NULL OR review_report_id ~ '^vor_[0-9A-HJKMNP-TV-Z]{26}$');
+CREATE INDEX media_objects_review_pending ON media_objects (review_status, id) WHERE review_status = 'pending';
+CREATE INDEX media_objects_review_category ON media_objects (review_category, id) WHERE review_category IS NOT NULL;
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+-- phase: expand
+-- VIS-6 review follow-up: a category-3 result places an evidence hold in the same transaction as the
+-- quarantine, so neither the owner's delete nor retention purge can destroy the object. The hold is
+-- released only by an operator's overruling category (fine or sex_acts), with who and when on the hold row.
+ALTER TABLE media_objects ADD COLUMN review_hold_id bigint;
+-- One row per operator view of a hidden/quarantined object: who, when, and when the 60-second URL expires.
+CREATE TABLE media_review_views (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ object_id text COLLATE "C" NOT NULL,
+ app_id text COLLATE "C" NOT NULL,
+ viewer_sub text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now()
+);
+CREATE INDEX media_review_views_object ON media_review_views (object_id, created_at DESC);
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2122,9 +2122,9 @@
@@ -2122,9 +2122,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.289.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
+ "version": "0.294.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
+ "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -724,8 +724,17 @@ async function objectPublic(obj, { locations = true } = {}) {
@@ -724,8 +724,17 @@ async function objectPublic(obj, { locations = true } = {}) {
held: await isHeld(obj.id),
held: await isHeld(obj.id),
// metadata / bytes_verified / playable, from this row and its copies' recorded checks (objects/readiness.js).
// metadata / bytes_verified / playable, from this row and its copies' recorded checks (objects/readiness.js).
readiness: await require('./readiness').compute(obj, locs),
readiness: await require('./readiness').compute(obj, locs),
+ // A review that hides or quarantines an object closes its public URL too; a pending review is not public yet.
+ review: {
+ category: obj.review_category || null,
+ status: obj.review_status || null,
+ report_id: obj.review_report_id || null,
+ hold_id: obj.review_hold_id || null,
+ reviewed_at: obj.reviewed_at || null,
+ },
// Developer-project sandbox objects have no public URL: /download hands out signed ones.
// Developer-project sandbox objects have no public URL: /download hands out signed ones.
public_url: obj.visibility !== 'private' && obj.lifecycle_status === 'ready' && !await db.isSandboxTenant(obj.app_id)
public_url: obj.visibility !== 'private' && obj.lifecycle_status === 'ready' && !await db.isSandboxTenant(obj.app_id)
+ && obj.review_status !== 'pending' && !['sex_acts', 'minor_sexual'].includes(obj.review_category)
? (legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`) : null,
? (legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`) : null,
created_at: obj.created_at,
created_at: obj.created_at,
updated_at: obj.updated_at,
updated_at: obj.updated_at,
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+
+/**
+ * OpenVibe.Media's side of the unsafe-image review (VIS-6).
+ *
+ * Vision is the reviewer: it sorts an image into exactly three categories and, for the hard line, makes the
+ * operator's report row. Media is the enforcer:
+ *
+ * fine clear: the object may be served as before.
+ * sex_acts hidden: visibility becomes private, so every public surface (the open /o/:id route, HLS,
+ * public_url, a listing's public link) is closed; the owner can still get a signed URL.
+ * minor_sexual quarantined: private and blocked even with a signature; the report id is kept on the row.
+ *
+ * A visible upload is marked `pending` at init and stays unserved until Vision answers. If Vision cannot be
+ * reached, it stays pending rather than becoming public. `reviewObject` is also exposed through
+ * POST /api/v2/:app/objects/:id/review for an operator re-run and for tests.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const db = require('../db/database');
+const model = require('./model');
+
+const ACTIONS = Object.freeze({ fine: 'allow', sex_acts: 'hide', minor_sexual: 'quarantine_and_report' });
+
+/** The Vision client, built from the environment; null when Vision or Media's service principal is unset. */
+function visionClient({ fetchImpl = globalThis.fetch } = {}) {
+ const base = String(process.env.VISION_URL || process.env.OV_VISION_URL || '').replace(/\/+$/, '');
+ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
+ if (!base || !clientSecret) return null;
+ const network = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${network}/oauth/token`,
+ clientId: process.env.OV_OAUTH_CLIENT_ID || 'media',
+ clientSecret,
+ audience: 'openvibe.vision',
+ scope: 'vision.image.review',
+ fetchImpl,
+ });
+ async function review(obj, retried = false) {
+ const res = await fetchImpl(`${base}/api/v1/review`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify({
+ object_type: 'media.object',
+ object_id: obj.id,
+ object_app: obj.app_id,
+ media_object_id: obj.id,
+ media_app: obj.app_id,
+ owner_subject: obj.owner_subject || null,
+ image_url: model.legacyPublicUrl(obj) || `${require('../config').publicUrl}/o/${obj.id}`,
+ }),
+ signal: AbortSignal.timeout(20000),
+ });
+ if (res.status === 401 && !retried) { try { tokens.invalidate(); } catch { /* older client */ } return await review(obj, true); }
+ const body = await res.json().catch(() => null);
+ if (!res.ok) throw new Error(`Vision review ${res.status}: ${(body && (body.detail || body.code)) || 'failed'}`);
+ if (!body || !['fine', 'sex_acts', 'minor_sexual'].includes(body.category)) throw new Error('Vision review returned no known category');
+ return body;
+ }
+ return { review, enabled: true };
+}
+
+let injected = null;
+/** Tests: run with a stand-in Vision client, or null to restore the environment-built one. */
+function setReviewClient(client) { injected = client; }
+function client() { return injected !== null ? injected : visionClient(); }
+/** Is a reviewer configured (or injected for a test)? */
+function enabled() { return Boolean(client()); }
+
+/** Should this object be reviewed? Only an image on a public or unlisted object, and only when Vision is configured. */
+function reviewRequired(obj) {
+ if (!obj || obj.visibility === 'private') return false;
+ if (!/^image\//i.test(String(obj.mime_type || ''))) return false;
+ return enabled();
+}
+
+/** The columns a category writes. Exported for the direct operator route and its tests. */
+function columns(category, reportId = null) {
+ if (!['fine', 'sex_acts', 'minor_sexual'].includes(category)) throw new Error(`unknown review category: ${category}`);
+ if (category === 'fine') return { category, status: 'clear', visibility: null, reportId: null };
+ if (category === 'sex_acts') return { category, status: 'hidden', visibility: 'private', reportId: null };
+ return { category, status: 'quarantined', visibility: 'private', reportId };
+}
+
+/**
+ * Write one category. A category-3 result places an `evidence` hold in the same transaction: the owner's
+ * delete and retention purge both see the hold and the owner gets the usual neutral 409 held. An operator's
+ * overruling category (fine or sex_acts, with `by`) releases that hold, with who and when on the hold row.
+ */
+async function applyCategory(obj, category, reportId = null, { by = null } = {}) {
+ const c = columns(category, reportId);
+ await db.getDb().tx(async () => {
+ const sets = ['review_category = ?', 'review_status = ?', 'review_report_id = ?', 'reviewed_at = now()'];
+ const args = [c.category, c.status, c.reportId];
+ if (c.visibility) { sets.push('visibility = ?'); args.push(c.visibility); }
+ args.push(obj.id);
+ await db.run(`UPDATE media_objects SET ${sets.join(', ')} WHERE id = ?`, args);
+ if (category === 'minor_sexual') {
+ let hold = await db.get("SELECT id FROM media_holds WHERE object_id = ? AND kind = 'evidence' AND released_at IS NULL ORDER BY id DESC LIMIT 1", [obj.id]);
+ if (!hold) {
+ const inserted = await db.run("INSERT INTO media_holds (object_id, kind, reason, created_by, note) VALUES (?, 'evidence', ?, ?, ?) RETURNING id", [
+ obj.id, 'unsafe-image review: sexual content involving a minor', by || 'svc:vision',
+ reportId ? `operator report ${reportId}` : null,
+ ]);
+ hold = { id: inserted.lastInsertRowid };
+ await require('../events').recordModeration(obj.app_id, {
+ action: 'review.hold_placed',
+ target: { type: obj.kind, id: obj.id, ...(obj.owner_subject ? { owner_subject: obj.owner_subject } : {}) },
+ actor_subject: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(by)) ? by : null,
+ reason: 'unsafe-image review: sexual content involving a minor',
+ details: { hold_id: hold.id, review_category: category, app: obj.app_id, ...(by ? { by: String(by).slice(0, 200) } : {}) },
+ });
+ }
+ await db.run('UPDATE media_objects SET review_hold_id = ? WHERE id = ?', [hold.id, obj.id]);
+ } else if (by) {
+ const hold = await db.get("SELECT id FROM media_holds WHERE object_id = ? AND kind = 'evidence' AND released_at IS NULL ORDER BY id DESC LIMIT 1", [obj.id]);
+ if (hold) {
+ await db.run('UPDATE media_holds SET released_at = ov_now(), released_by = ? WHERE id = ? AND released_at IS NULL', [by, hold.id]);
+ await db.run('UPDATE media_objects SET review_hold_id = NULL WHERE id = ?', [obj.id]);
+ await require('../events').recordModeration(obj.app_id, {
+ action: 'review.hold_released',
+ target: { type: obj.kind, id: obj.id, ...(obj.owner_subject ? { owner_subject: obj.owner_subject } : {}) },
+ actor_subject: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(by)) ? by : null,
+ reason: `operator overruled the review to ${category}`,
+ details: { hold_id: hold.id, review_category: category, app: obj.app_id, ...(/^usr_/.test(String(by)) ? {} : { by: String(by).slice(0, 200) }) },
+ });
+ }
+ }
+ });
+ return await model.getObject(obj.id);
+}
+
+/**
+ * Ask Vision and apply its answer. A failure leaves the object pending (closed), never public; it does not
+ * throw, so a Vision outage does not fail the uploader's already-stored upload.
+ */
+async function reviewObject(obj, { log = console } = {}) {
+ if (!obj) return null;
+ if (!reviewRequired(obj)) {
+ if (obj.review_status === 'pending') await db.run("UPDATE media_objects SET review_status = 'clear', reviewed_at = now() WHERE id = ?", [obj.id]);
+ return obj.review_status === 'pending' ? await model.getObject(obj.id) : obj;
+ }
+ const c = client();
+ try {
+ const out = await c.review(obj);
+ const reportId = out.report && out.report.id ? out.report.id : null;
+ const updated = await applyCategory(obj, out.category, reportId);
+ if (out.category === 'sex_acts') log.warn(`[Objects] ${obj.id} hidden: the review found sex acts or porn`);
+ if (out.category === 'minor_sexual') log.error(`[Objects] ${obj.id} quarantined: the review found sexual content involving a minor; operator report ${reportId || '(missing)'}`);
+ return updated;
+ } catch (err) {
+ await db.run("UPDATE media_objects SET review_status = 'pending' WHERE id = ?", [obj.id]);
+ log.warn(`[Objects] ${obj.id}: review failed, the object stays pending and unserved (${String((err && err.message) || err).slice(0, 200)})`);
+ return await model.getObject(obj.id);
+ }
+}
+
+/** True when a public reader must not be served this object (a signature does not open a quarantine). */
+function publicBlocked(obj, signed = false, operator = false) {
+ if (!obj) return true;
+ if (operator) return false; // a logged 60-second operator evidence view
+ if (obj.review_status === 'pending') return true;
+ if (obj.review_category === 'minor_sexual') return true;
+ if (obj.review_category === 'sex_acts' && !signed) return true;
+ return false;
+}
+
+/** Log one operator evidence view; the event goes through the same moderation audit as a hold. */
+async function logOperatorView({ obj, viewerSub, expiresAt }) {
+ await db.getDb().tx(async () => {
+ await db.run('INSERT INTO media_review_views (object_id, app_id, viewer_sub, expires_at) VALUES (?, ?, ?, ?)', [obj.id, obj.app_id, viewerSub, expiresAt || null]);
+ await require('../events').recordModeration(obj.app_id, {
+ action: 'review.viewed',
+ target: { type: obj.kind, id: obj.id, ...(obj.owner_subject ? { owner_subject: obj.owner_subject } : {}) },
+ actor_subject: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(viewerSub)) ? viewerSub : null,
+ reason: 'operator evidence view',
+ details: { review_category: obj.review_category, app: obj.app_id, expires_at: expiresAt || null, ...(/^usr_/.test(String(viewerSub)) ? {} : { by: String(viewerSub).slice(0, 200) }) },
+ });
+ });
+}
+
+module.exports = { ACTIONS, visionClient, setReviewClient, enabled, reviewRequired, columns, applyCategory, reviewObject, publicBlocked, logOperatorView };
@@ -61,6 +61,7 @@ const { contentDisposition } = require('./disposition');
@@ -61,6 +61,7 @@ const { contentDisposition } = require('./disposition');
const { tenantAuth, tenantCors, tenantPath, namespaceGrant } = require('../auth');
const { tenantAuth, tenantCors, tenantPath, namespaceGrant } = require('../auth');
const { announce } = require('../announce');
const { announce } = require('../announce');
const { limits } = require('../actor-limits');
const { limits } = require('../actor-limits');
+const review = require('./review');
const MB = 1024 * 1024;
const MB = 1024 * 1024;
// Each route checks its verb for the namespace it works in (the object's, or the one init names).
// Each route checks its verb for the namespace it works in (the object's, or the one init names).
@@ -69,6 +70,8 @@ const read = tenantAuth({ verb: 'read', namespaced: true, project: true });
@@ -69,6 +70,8 @@ const read = tenantAuth({ verb: 'read', namespaced: true, project: true });
const list = tenantAuth({ verb: 'list', namespaced: true, project: true });
const list = tenantAuth({ verb: 'list', namespaced: true, project: true });
const remove = tenantAuth({ verb: 'delete', namespaced: true, project: true });
const remove = tenantAuth({ verb: 'delete', namespaced: true, project: true });
const appOnly = tenantAuth();
const appOnly = tenantAuth();
+const reviewView = tenantAuth({ capability: 'media.object.review.view', namespaced: true, project: true });
+const reviewDecide = tenantAuth({ capability: 'media.object.review.decide', namespaced: true, project: true });
// Per-actor limits (server/actor-limits.js), after the credential and before the work. Reads take the
// Per-actor limits (server/actor-limits.js), after the credential and before the work. Reads take the
// defaults (MEDIA_LIMITS_MINUTE / _HOUR). An init reserves quota and makes a row, and each upload step
// defaults (MEDIA_LIMITS_MINUTE / _HOUR). An init reserves quota and makes a row, and each upload step
// writes or hashes bytes, so the upload routes allow a steady app a new object every two seconds at most.
// writes or hashes bytes, so the upload routes allow a steady app a new object every two seconds at most.
@@ -387,6 +390,12 @@ router.post('/', upload, limits('media.object.upload', UPLOAD), async (req, res)
@@ -387,6 +390,12 @@ router.post('/', upload, limits('media.object.upload', UPLOAD), async (req, res)
if (made.problem) return problem(res, made.problem.status, made.problem.code, made.problem.detail, made.problem.extra);
if (made.problem) return problem(res, made.problem.status, made.problem.code, made.problem.detail, made.problem.extra);
const id = made.id;
const id = made.id;
const obj = await model.getObject(id);
const obj = await model.getObject(id);
+ // A visible upload is pending until Vision reviews it (objects/review.js). Private objects are not
+ // reviewed and keep serving to their owner as before.
+ if (visibility !== 'private' && review.enabled()) {
+ await db.run("UPDATE media_objects SET review_status = 'pending' WHERE id = ?", [id]);
+ obj.review_status = 'pending';
+ }
let session = null;
let session = null;
if (wantParts) {
if (wantParts) {
session = await multipart.initiate(obj, { partSize: b.part_size });
session = await multipart.initiate(obj, { partSize: b.part_size });
@@ -442,6 +451,11 @@ async function completeUpload(req, res, obj, { expectedHash = null } = {}) {
@@ -442,6 +451,11 @@ async function completeUpload(req, res, obj, { expectedHash = null } = {}) {
payload: async () => await model.objectPublic(await model.getObject(obj.id)),
payload: async () => await model.objectPublic(await model.getObject(obj.id)),
});
});
await namespaces.reconcileChain(req.appRow, obj.namespace);
await namespaces.reconcileChain(req.appRow, obj.namespace);
+ const current = await model.getObject(obj.id);
+ if (current.review_status === 'pending') {
+ const reviewed = await review.reviewObject(current);
+ return res.json(await model.objectPublic(reviewed));
+ }
return res.json(body);
return res.json(body);
}
}
@@ -596,6 +610,66 @@ router.delete('/:id/multipart/:uploadId', mpWrite, limits('media.object.multipar
@@ -596,6 +610,66 @@ router.delete('/:id/multipart/:uploadId', mpWrite, limits('media.object.multipar
res.json(await multipart.sessionPublic(await multipart.getSession(session.id), { parts: false }));
res.json(await multipart.sessionPublic(await multipart.getSession(session.id), { parts: false }));
});
});
+// An operator re-run, and the direct category a caller already decided (the same three Vision stores). This is
+// also how a consumer can enforce a label it got from Vision: the action is Media's, not the caller's.
+// Re-run Vision on an object (no category body). A category decision is the operator route below, never
+// an owner's write: releasing an evidence hold must not be something the object's owner can do.
+router.post('/:id/review', upload, limits('media.object.review', { minute: 60, hour: 1200 }), async (req, res) => {
+ try {
+ const obj = await load(req, res, 'write');
+ if (!obj) return;
+ if (!canWrite(req, obj)) return problem(res, 403, 'media.object.forbidden', 'Not your object');
+ if (req.body && req.body.category != null) return problem(res, 403, 'capability.denied', 'a category is an operator decision; use POST /review-decision');
+ const out = await review.reviewObject(obj);
+ return res.json(await model.objectPublic(out));
+ } catch (err) {
+ console.error('[Objects] review error:', err.message);
+ return problem(res, 500, 'media.object.review_failed', 'The review could not be applied');
+ }
+});
+
+// An operator's overruling category (fine or sex_acts releases the evidence hold; minor_sexual places it).
+// Service principal only: the capability is Vision's, and an app key never sets req.principal.
+router.post('/:id/review-decision', reviewDecide, async (req, res) => {
+ try {
+ if (!req.principal || !req.principal.sub) return problem(res, 403, 'capability.denied', 'an operator service token is required');
+ const obj = await model.getObject(String(req.params.id || ''));
+ if (!obj || obj.app_id !== req.appId) return problem(res, 404, 'media.object.not_found', 'No such object in this namespace');
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const category = String(b.category || '');
+ if (!['fine', 'sex_acts', 'minor_sexual'].includes(category)) return problem(res, 400, 'media.object.invalid', 'category must be fine, sex_acts or minor_sexual');
+ const reportId = b.report_id == null ? null : String(b.report_id);
+ if (reportId && !/^vor_[0-9A-HJKMNP-TV-Z]{26}$/.test(reportId)) return problem(res, 400, 'media.object.invalid', 'report_id must be an operator report id');
+ const decidedBy = b.decided_by == null ? req.principal.sub : String(b.decided_by).slice(0, 200);
+ const out = await review.applyCategory(obj, category, reportId, { by: decidedBy });
+ if (category === 'minor_sexual') console.error(`[Objects] ${obj.id} quarantined by ${decidedBy}; report ${reportId || '(none)'}`);
+ return res.json(await model.objectPublic(out));
+ } catch (err) {
+ console.error('[Objects] review decision error:', err.message);
+ return problem(res, 500, 'media.object.review_failed', 'The decision could not be applied');
+ }
+});
+
+// A 60-second operator evidence view of a hidden or quarantined object. Only Vision's service token holds
+// media.object.review.view; the view is logged (media_review_views plus a moderation event) before the URL
+// is answered, and the URL's purpose is separate from every normal download signature.
+router.post('/:id/operator-view', reviewView, async (req, res) => {
+ try {
+ if (!req.principal || !req.principal.sub) return problem(res, 403, 'capability.denied', 'an operator service token is required');
+ const obj = await model.getObject(String(req.params.id || ''));
+ if (!obj || obj.app_id !== req.appId) return problem(res, 404, 'media.object.not_found', 'No such object in this namespace');
+ if (!['sex_acts', 'minor_sexual'].includes(obj.review_category) && !['hidden', 'quarantined'].includes(obj.review_status)) {
+ return problem(res, 409, 'media.object.not_reviewed', 'only a hidden or quarantined object has an operator view');
+ }
+ const signed = signing.signedOperatorUrl(obj.id, 60);
+ await review.logOperatorView({ obj, viewerSub: req.principal.sub, expiresAt: signed.expires_at });
+ return res.json({ object_id: obj.id, review_category: obj.review_category, review_status: obj.review_status, expires_at: signed.expires_at, url: signed.url });
+ } catch (err) {
+ console.error('[Objects] operator view error:', err.message);
+ return problem(res, 500, 'media.object.review_view_failed', 'The operator view could not be made');
+ }
+});
+
router.get('/', list, limits('media.object.list'), async (req, res) => {
router.get('/', list, limits('media.object.list'), async (req, res) => {
try {
try {
const q = req.query;
const q = req.query;
@@ -690,6 +764,7 @@ router.get('/:id/download', read, limits('media.object.download'), async (req, r
@@ -690,6 +764,7 @@ router.get('/:id/download', read, limits('media.object.download'), async (req, r
if (!obj) return;
if (!obj) return;
if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Object was deleted');
if (obj.lifecycle_status === 'deleted') return problem(res, 410, 'media.object.deleted', 'Object was deleted');
if (obj.lifecycle_status !== 'ready') return problem(res, 409, 'media.object.not_ready', `Object is ${obj.lifecycle_status}`);
if (obj.lifecycle_status !== 'ready') return problem(res, 409, 'media.object.not_ready', `Object is ${obj.lifecycle_status}`);
+ if (obj.review_status === 'pending' || obj.review_category === 'minor_sexual') return problem(res, 404, 'media.object.not_found', 'No such object');
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (req.query.format === 'mp4') return await downloadMp4(req, res, obj);
if (req.query.format === 'mp4') return await downloadMp4(req, res, obj);
const json = req.query.format === 'json';
const json = req.query.format === 'json';
@@ -700,7 +775,7 @@ router.get('/:id/download', read, limits('media.object.download'), async (req, r
@@ -700,7 +775,7 @@ router.get('/:id/download', read, limits('media.object.download'), async (req, r
// A clip without its own timeline plays its window of the source's (F3.5) and is never cut a CMAF timeline here.
// A clip without its own timeline plays its window of the source's (F3.5) and is never cut a CMAF timeline here.
// The same answer shapes the v1 VOD and clip (objects/hls.js discovery; vodPublic/clipPublic).
// The same answer shapes the v1 VOD and clip (objects/hls.js discovery; vodPublic/clipPublic).
const hlsInfo = json ? await hls.discoveryForObject(obj) : null;
const hlsInfo = json ? await hls.discoveryForObject(obj) : null;
- if (obj.visibility !== 'private' && !await db.isSandboxTenant(obj.app_id)) {
+ if (obj.review_category !== 'sex_acts' && obj.visibility !== 'private' && !await db.isSandboxTenant(obj.app_id)) {
const url = model.legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`;
const url = model.legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`;
return json ? res.json({ url, expires_at: null, public: true, ...(hlsInfo || {}) }) : res.redirect(302, url);
return json ? res.json({ url, expires_at: null, public: true, ...(hlsInfo || {}) }) : res.redirect(302, url);
}
}
@@ -839,7 +914,9 @@ async function hlsObject(req, res, next) {
@@ -839,7 +914,9 @@ async function hlsObject(req, res, next) {
if (!obj) { res.status(404).json({ error: 'Not found' }); return null; }
if (!obj) { res.status(404).json({ error: 'Not found' }); return null; }
// A playlist token (purpose 'hls', from /download's hls_url) or, as before, the object's download signature.
// A playlist token (purpose 'hls', from /download's hls_url) or, as before, the object's download signature.
const signed = !!req.query.sig && (signing.verifyPlaylist(obj.id, req.query.exp, req.query.sig) || signing.verifyDownload(obj.id, req.query.exp, req.query.sig));
const signed = !!req.query.sig && (signing.verifyPlaylist(obj.id, req.query.exp, req.query.sig) || signing.verifyDownload(obj.id, req.query.exp, req.query.sig));
- const closed = obj.visibility === 'private' || await db.isSandboxTenant(obj.app_id);
+ if (review.publicBlocked(obj, signed)) { res.status(404).json({ error: 'Not found' }); return null; }
+ const closed = obj.visibility === 'private' || obj.review_category === 'sex_acts' || obj.review_category === 'minor_sexual'
+ || obj.review_status === 'pending' || await db.isSandboxTenant(obj.app_id);
if (closed && !signed) { res.status(404).json({ error: 'Not found' }); return null; }
if (closed && !signed) { res.status(404).json({ error: 'Not found' }); return null; }
if (obj.lifecycle_status === 'deleted') { res.status(410).json({ error: 'Gone' }); return null; }
if (obj.lifecycle_status === 'deleted') { res.status(410).json({ error: 'Gone' }); return null; }
if (obj.lifecycle_status !== 'ready') { res.status(404).json({ error: 'Not found' }); return null; }
if (obj.lifecycle_status !== 'ready') { res.status(404).json({ error: 'Not found' }); return null; }
@@ -1002,11 +1079,16 @@ publicRouter.get('/:id', async (req, res) => {
@@ -1002,11 +1079,16 @@ publicRouter.get('/:id', async (req, res) => {
const obj = await model.getObject(String(req.params.id || ''));
const obj = await model.getObject(String(req.params.id || ''));
if (!obj) return res.status(404).json({ error: 'Not found' });
if (!obj) return res.status(404).json({ error: 'Not found' });
const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);
const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);
+ const operator = !!req.query.sig && signing.verifyOperator(obj.id, req.query.exp, req.query.sig);
+ // A pending review, a quarantine and a hidden sex-act object are indistinguishable from missing,
+ // exactly like a private object. A signed read opens a sex-act object for its owner; a separate,
+ // 60-second operator signature opens either hidden category for the review page (and is logged).
+ if (review.publicBlocked(obj, signed, operator)) return res.status(404).json({ error: 'Not found' });
// Private objects (and every developer-project sandbox object) are indistinguishable from
// Private objects (and every developer-project sandbox object) are indistinguishable from
// missing ones without a valid signature — checked before the lifecycle answers, or a 410
// missing ones without a valid signature — checked before the lifecycle answers, or a 410
// for a deleted private object would still say it existed.
// for a deleted private object would still say it existed.
const sandbox = await db.isSandboxTenant(obj.app_id);
const sandbox = await db.isSandboxTenant(obj.app_id);
- if ((obj.visibility === 'private' || sandbox) && !signed) return res.status(404).json({ error: 'Not found' });
+ if ((obj.visibility === 'private' || sandbox) && !signed && !operator) return res.status(404).json({ error: 'Not found' });
if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });
if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });
if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });
if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });
@@ -57,6 +57,25 @@ function verifyDownload(objectId, exp, sig) {
@@ -57,6 +57,25 @@ function verifyDownload(objectId, exp, sig) {
return safeEqual(sig, mac('get', objectId, e));
return safeEqual(sig, mac('get', objectId, e));
}
}
+/**
+ * A 60-second operator evidence view of a hidden/quarantined object. Its own purpose ('operator') so a
+ * normal download signature never opens a quarantine and this one expires quickly; the route that mints it
+ * logs who asked. Only the public object route accepts it.
+ */
+function signedOperatorUrl(objectId, ttlS = 60) {
+ const exp = nowS() + Math.min(300, Math.max(30, Number(ttlS) || 60));
+ return {
+ url: `${config.publicUrl}/o/${encodeURIComponent(objectId)}?exp=${exp}&sig=${mac('operator', objectId, exp)}`,
+ expires_at: new Date(exp * 1000).toISOString(),
+ };
+}
+
+function verifyOperator(objectId, exp, sig) {
+ const e = Number(exp);
+ if (!Number.isInteger(e) || e < nowS() || e > nowS() + 300) return false;
+ return safeEqual(sig, mac('operator', objectId, e));
+}
+
/**
/**
* Signed HLS master playlist of an object (/o/:id/master.m3u8), carried onto every playlist and segment URI under it.
* Signed HLS master playlist of an object (/o/:id/master.m3u8), carried onto every playlist and segment URI under it.
* Its own purpose ('hls'): a viewer plays a long VOD on one page load, so it lives longer than a download URL
* Its own purpose ('hls'): a viewer plays a long VOD on one page load, so it lives longer than a download URL
@@ -180,6 +199,6 @@ function verifyMultipartToken(token, { tenant, objectId, uploadId, totalSize })
@@ -180,6 +199,6 @@ function verifyMultipartToken(token, { tenant, objectId, uploadId, totalSize })
}
}
module.exports = {
module.exports = {
- signedDownloadUrl, verifyDownload, signedPlaylistUrl, verifyPlaylist, signedFileUrl, verifyFile, signedMediaUrl, verifyMedia,
+ signedDownloadUrl, verifyDownload, signedOperatorUrl, verifyOperator, signedPlaylistUrl, verifyPlaylist, signedFileUrl, verifyFile, signedMediaUrl, verifyMedia,
uploadToken, uploadTtl, checkUploadToken, verifyUploadToken, multipartToken, verifyMultipartToken,
uploadToken, uploadTtl, checkUploadToken, verifyUploadToken, multipartToken, verifyMultipartToken,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,224 @@
@@ -0,0 +1,224 @@
+'use strict';
+// VIS-6 in Media: an unsafe-image review's three categories are enforcement, not advice.
+// fine the object serves as before
+// sex_acts hidden from every public surface; a signed read still keeps it for its owner
+// minor_sexual quarantined even from a signed read, with the operator's report id on the row
+// A visible upload is pending until Vision answers, so an outage cannot publish an unreviewed image.
+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 { ids, serviceAuth } = require('openvibe-contracts');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-review-'));
+ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+ process.env.VOD_PATH = dir('vods');
+ process.env.FILES_PATH = dir('files');
+ process.env.OBJECTS_PATH = dir('objects');
+ process.env.THUMBNAILS_PATH = dir('thumbnails');
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+ process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
+ process.env.MEDIA_OBJECT_MAX_MB = '2';
+ process.env.MEDIA_PUBLIC_OBJECT_MAX_MB = '10';
+ process.env.OV_NETWORK_INTERNAL_URL = 'http://127.0.0.1:1'; // replaced below with the mock's address
+ process.env.OV_OAUTH_CLIENT_ID = 'media';
+ process.env.OV_OAUTH_CLIENT_SECRET = 'media-secret';
+
+ // ── Vision, as Media sees it: a token, then one review per image ──
+ const state = { category: 'fine', fail: false, requests: [] };
+ const vision = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const body = Buffer.concat(chunks);
+ const send = (status, obj) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return send(200, { access_token: 'svc-vision-review', token_type: 'Bearer', expires_in: 300 });
+ if (req.url === '/api/v1/review') {
+ state.requests.push(JSON.parse(body.toString('utf8')));
+ if (state.fail) return send(503, { code: 'vision.review_unavailable', detail: 'the reviewer is down' });
+ const out = { category: state.category, action: state.category === 'fine' ? 'allow' : state.category === 'sex_acts' ? 'hide' : 'quarantine_and_report', public: state.category === 'fine', report: state.category === 'minor_sexual' ? { id: 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ', status: 'open' } : null };
+ return send(201, out);
+ }
+ send(404, {});
+ });
+ });
+ await new Promise((r) => vision.listen(0, '127.0.0.1', r));
+ const visionBase = `http://127.0.0.1:${vision.address().port}`;
+ process.env.VISION_URL = visionBase;
+ process.env.OV_NETWORK_INTERNAL_URL = visionBase;
+
+ const db = require('../server/db/database');
+ const auth = require('../server/auth');
+ const objectRoutes = require('../server/objects/routes');
+ const express = require('express');
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ auth._setNetworkPublicKeyForTests(keys.publicKey);
+ const nowS = Math.floor(Date.now() / 1000);
+ const svc = (cap) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:vision', actor_type: 'service', aud: ['openvibe.media'], cap, ns: ['live'], iat: nowS, exp: nowS + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
+
+ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+ const app = express();
+ app.put('/api/v2/:app/objects/:id/content', ...objectRoutes.contentHandlers);
+ app.use(express.json());
+ app.use('/api/v2/:app/objects', objectRoutes);
+ app.use('/o', objectRoutes.publicRouter);
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const local = (u) => base + String(u).replace('https://media.test', '');
+ const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
+ const png = await require('sharp')({ create: { width: 80, height: 60, channels: 3, background: '#3355aa' } }).png().toBuffer();
+ const O = '/api/v2/live/objects';
+ const call = async (method, p, { body, raw, headers = {}, bearer = 'live-key' } = {}) => {
+ const h = { authorization: `Bearer ${bearer}`, ...headers };
+ let payload;
+ if (raw !== undefined) payload = raw;
+ else if (body !== undefined) { h['content-type'] = 'application/json'; payload = JSON.stringify(body); }
+ const res = await fetch(p.startsWith('http') ? p : base + p, { method, headers: h, body: payload, redirect: 'manual' });
+ const text = await res.text();
+ let json = null; try { json = JSON.parse(text); } catch { /* bytes */ }
+ return { status: res.status, body: json, text, headers: res.headers };
+ };
+ async function upload({ kind = 'screenshot', visibility = 'public', mime = 'image/png', bytes = png } = {}) {
+ let r = await call('POST', O, { body: { kind, visibility, mime_type: mime, size_bytes: bytes.length, content_hash: sha(bytes) } });
+ assert.strictEqual(r.status, 201, r.text);
+ const id = r.body.id;
+ r = await call('PUT', `${O}/${id}/content`, { raw: bytes, headers: { 'content-type': mime } });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await call('POST', `${O}/${id}/complete`);
+ assert.strictEqual(r.status, 200, r.text);
+ return { id, complete: r.body };
+ }
+
+ let passed = 0;
+ const check = async (name, fn) => { await fn(); passed++; console.log(` ✓ ${name}`); };
+
+ await check('fine: the image serves publicly, and the row says clear', async () => {
+ state.category = 'fine';
+ const { id, complete } = await upload();
+ globalThis.__fineId = id;
+ assert.strictEqual(complete.review.category, 'fine');
+ assert.strictEqual(complete.review.status, 'clear');
+ assert.strictEqual(complete.public_url, `https://media.test/o/${id}`);
+ const got = await fetch(`${base}/o/${id}`);
+ assert.strictEqual(got.status, 200);
+ assert.strictEqual((await call('GET', `${O}/${id}`)).body.visibility, 'public');
+ });
+
+ await check('sex_acts: hidden from /o, private in the row, and a signed read still reaches it for its owner', async () => {
+ state.category = 'sex_acts';
+ const { id, complete } = await upload();
+ assert.deepStrictEqual([complete.review.category, complete.review.status, complete.visibility, complete.public_url], ['sex_acts', 'hidden', 'private', null]);
+ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 404);
+ const signed = await call('GET', `${O}/${id}/download`);
+ assert.strictEqual(signed.status, 200, JSON.stringify(signed.body));
+ assert.strictEqual(signed.body.public, false);
+ const bytes = await fetch(local(signed.body.url));
+ assert.strictEqual(bytes.status, 200, 'the owner keeps the bytes through a signed link');
+ assert.strictEqual(Buffer.from(await bytes.arrayBuffer()).length, png.length);
+ });
+
+ await check('minor_sexual: quarantined even with a signature, report id kept on the row', async () => {
+ state.category = 'minor_sexual';
+ const { id, complete } = await upload();
+ assert.deepStrictEqual([complete.review.category, complete.review.status, complete.visibility, complete.public_url], ['minor_sexual', 'quarantined', 'private', null]);
+ assert.match(complete.review.report_id, /^vor_/);
+ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 404);
+ assert.strictEqual((await call('GET', `${O}/${id}/download`)).status, 404, 'a signed download is closed too');
+ const row = await db.get('SELECT review_category, review_status, review_report_id FROM media_objects WHERE id = ?', [id]);
+ assert.deepStrictEqual([row.review_category, row.review_status], ['minor_sexual', 'quarantined']);
+ assert.match(row.review_report_id, /^vor_/);
+ });
+
+ await check('a Vision outage leaves the object pending and unserved; a later re-review clears it', async () => {
+ state.category = 'fine';
+ state.fail = true;
+ const { id, complete } = await upload();
+ assert.deepStrictEqual([complete.review.status, complete.public_url], ['pending', null]);
+ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 404, 'pending is not public');
+ state.fail = false;
+ const again = await call('POST', `${O}/${id}/review`);
+ assert.strictEqual(again.status, 200, again.text);
+ assert.deepStrictEqual([again.body.review.category, again.body.review.status], ['fine', 'clear']);
+ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 200);
+ });
+
+ await check('a non-image is never sent to Vision', async () => {
+ const before = state.requests.length;
+ const text = Buffer.from('hello world');
+ const { complete } = await upload({ kind: 'file', mime: 'text/plain', bytes: text });
+ assert.strictEqual(state.requests.length, before, 'text files are not reviewed');
+ assert.deepStrictEqual([complete.review.status, complete.public_url !== null], ['clear', true]);
+ });
+
+ await check('an operator decision needs the service capability; the same enforcement follows', async () => {
+ state.category = 'fine';
+ const { id } = await upload();
+ const no = await call('POST', `${O}/${id}/review-decision`, { body: { category: 'sex_acts' } });
+ assert.deepStrictEqual([no.status, no.body.code], [403, 'capability.denied']);
+ const r = await call('POST', `${O}/${id}/review-decision`, { body: { category: 'sex_acts' }, bearer: svc(['media.object.review.decide']) });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.body.review.category, r.body.review.status, r.body.visibility], ['sex_acts', 'hidden', 'private']);
+ assert.strictEqual((await fetch(`${base}/o/${id}`)).status, 404);
+ const bad = await call('POST', `${O}/${id}/review-decision`, { body: { category: 'nudity' }, bearer: svc(['media.object.review.decide']) });
+ assert.deepStrictEqual([bad.status, bad.body.code], [400, 'media.object.invalid']);
+ });
+
+ await check('minor_sexual places an evidence hold; the owner delete is the usual neutral 409', async () => {
+ state.category = 'minor_sexual';
+ const { id } = await upload();
+ const hold = await db.get("SELECT id, kind, released_at, released_by FROM media_holds WHERE object_id = ? AND kind = 'evidence' AND released_at IS NULL", [id]);
+ assert.ok(hold && hold.id, 'an open evidence hold exists');
+ const del = await call('DELETE', `${O}/${id}`);
+ assert.deepStrictEqual([del.status, del.body.code], [409, 'media.object.held']);
+ // A second category-3 decision does not duplicate the hold.
+ await call('POST', `${O}/${id}/review-decision`, { body: { category: 'minor_sexual', report_id: 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, bearer: svc(['media.object.review.decide']) });
+ const count = await db.get("SELECT COUNT(*)::int AS n FROM media_holds WHERE object_id = ? AND kind = 'evidence'", [id]);
+ assert.strictEqual(Number(count.n), 1);
+ });
+
+ await check('operator view: only the review.view capability, only hidden/quarantined, and it opens a 60-second URL', async () => {
+ state.category = 'minor_sexual';
+ const { id } = await upload();
+ const no = await call('POST', `${O}/${id}/operator-view`, { body: {} });
+ assert.deepStrictEqual([no.status, no.body.code], [403, 'capability.denied'], 'an app key is not an operator');
+ const wrong = await call('POST', `${O}/${id}/operator-view`, { body: {}, bearer: svc(['media.object.review.decide']) });
+ assert.strictEqual(wrong.status, 403, 'another capability is not view');
+ const r = await call('POST', `${O}/${id}/operator-view`, { body: {}, bearer: svc(['media.object.review.view']) });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.match(r.body.url, /\?exp=\d+&sig=/);
+ const ttl = Math.round((Date.parse(r.body.expires_at) - Date.now()) / 1000);
+ assert.ok(ttl >= 30 && ttl <= 60, `a 60-second view, got ${ttl}s`);
+ const bytes = await fetch(local(r.body.url));
+ assert.strictEqual(bytes.status, 200, 'the operator URL opens a quarantined object');
+ const fine = await call('POST', `${O}/${globalThis.__fineId}/operator-view`, { body: {}, bearer: svc(['media.object.review.view']) });
+ assert.deepStrictEqual([fine.status, fine.body.code], [409, 'media.object.not_reviewed']);
+ // A past operator signature does not open it.
+ const past = nowS - 10;
+ const sig = crypto.createHmac('sha256', 'test-signing-secret').update(`operator\n${id}\n${past}`).digest('base64url');
+ assert.strictEqual((await fetch(`${base}/o/${id}?exp=${past}&sig=${sig}`)).status, 404);
+ const viewed = await db.get('SELECT viewer_sub, expires_at FROM media_review_views WHERE object_id = ? ORDER BY id DESC LIMIT 1', [id]);
+ assert.strictEqual(viewed.viewer_sub, 'svc:vision');
+ });
+
+ await check('an operator overruling to fine releases the evidence hold with who and when', async () => {
+ state.category = 'minor_sexual';
+ const { id } = await upload();
+ const open = await db.get("SELECT id FROM media_holds WHERE object_id = ? AND kind = 'evidence' AND released_at IS NULL", [id]);
+ assert.ok(open);
+ const r = await call('POST', `${O}/${id}/review-decision`, { body: { category: 'fine', report_id: 'vor_01JAB2C3D4E5F6G7H8J9K0MNPQ', decided_by: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, bearer: svc(['media.object.review.decide']) });
+ assert.strictEqual(r.status, 200, r.text);
+ const released = await db.get('SELECT released_at, released_by FROM media_holds WHERE id = ?', [open.id]);
+ assert.ok(released.released_at);
+ assert.strictEqual(released.released_by, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ');
+ const del = await call('DELETE', `${O}/${id}`);
+ assert.strictEqual(del.status, 200, del.text);
+ });
+
+ console.log(`object review: ${passed} checks passed`);
+ await new Promise((r) => server.close(r));
+ await new Promise((r) => vision.close(r));
+})().catch((e) => { console.error(e); process.exit(1); });