openvibers/openvibe.media Commits

Media enforces the unsafe-image review: pending, hidden and quarantined (#321)

OP OpenVibers committed 7 days ago · 43eeb8d8d6 · 12 files, +603 −19

* Media enforces the unsafe-image review: pending until reviewed, hidden sex acts, quarantined minor content

* Quarantined reviews place an evidence hold and Vision gets a logged 60-second operator view

* Docs: name openvibe-contracts v0.294.0

* Docs: the dependency list names openvibe-contracts v0.294.0

* Merge origin/main (legacy paste retire) and keep the review routes and evidence holds

* Merge origin/main (operator retirement, shared 4.115.1) and keep the review routes

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9a9cf6e..bfab28a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 repeat
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
tenant, `host`, through the Object API v2. The tenant is created at boot like the other first-party ones, and reached only
ddiff --git a/README.md b/README.md
iindex 0802993..9f1a4e2 100644
---- a/README.md
++++ b/README.md
@@@ -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
the 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 tools
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
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).
++## 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
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)**.
(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 not
restart; 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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 58b9e60..c4a7f43 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/migrations/0018_object_reviews.sql b/migrations/0018_object_reviews.sql
nnew file mode 100644
iindex 0000000..9a18ffd
---- /dev/null
++++ b/migrations/0018_object_reviews.sql
@@@ -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;
ddiff --git a/migrations/0019_review_evidence.sql b/migrations/0019_review_evidence.sql
nnew file mode 100644
iindex 0000000..2c69699
---- /dev/null
++++ b/migrations/0019_review_evidence.sql
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex c9cb6c8..42fcb08 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 230a782..6dee4c9 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex 8579119..98edc63 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -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,
ddiff --git a/server/objects/review.js b/server/objects/review.js
nnew file mode 100644
iindex 0000000..9b45bb6
---- /dev/null
++++ b/server/objects/review.js
@@@ -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 };
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 63b159b..28dbf28 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -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.multipar
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) => {
try {
const q = req.query;
@@@ -690,6 +764,7 @@ router.get('/:id/download', read, limits('media.object.download'), async (req, r
if (!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' });
ddiff --git a/server/objects/signing.js b/server/objects/signing.js
iindex 4acaefb..cbc9d35 100644
---- a/server/objects/signing.js
++++ b/server/objects/signing.js
@@@ -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,
};
ddiff --git a/test/object-review.test.js b/test/object-review.test.js
nnew file mode 100644
iindex 0000000..54d7192
---- /dev/null
++++ b/test/object-review.test.js
@@@ -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); });